쪼잔한 Rust 3. 일반적인 프로그래밍 개념들
Rust는 그 사용법을 익히는 것만으로도 주니어 레벨에서 안전한 프로그래밍에 대한 철학을 배울 수 있겠다고 생각하게 될 정도로 철저하게 설계된 언어라고 생각한다. 공식 문서인 "The Rust Programming Language" 1독을 했지만, 역시나 일로써 계속 접하지 않으면 그 지식이 조금씩 날라가버리는 것이 아까웠다. 그래서 내 스스로도 다시 정리를 하고, 요점만 정리해서 공유하고자 "쪼잔한 Rust" 시리즈를 시작한다. 이 이름은 clickbait 용도로 사용했을 뿐, 공식 문서를 번역하는 것이 주된 내용이 될 것이다. 본문 번역에 치중하되, 그 양이 많기 때문에 핵심적인 내용만 고르려고 노력했다.
한 챕터씩 차근 차근 이어나가겠다. Chapter 2는 생략하고 (그래도 직접 읽어보시길 추천한다) Chapter 3 부터 시작!
Chapter 3. 일반적인 프로그래밍 개념들
변수의 가변성
Rust에서 선언되는 변수는 기본적으로 불가변적(immutable)이다. 이는 Rust가 안전하고 동시성(concurrency)있는 코드를 쉽게 쓸 수 있게 하는 많은 참견 중 하나이다. 물론 가변적인 변수를 선언할 수도 있다. 왜 Rust는 불가변적인 변수를 선호하고, 또 어떨 때는 가변적인 변수를 써야 하는지에 대해 알아보겠다.
// src/main.rs
fn main() {
let x = 5;
println!("The value of x is: {}", x);
x = 6;
println!("The value of x is: {}", x);
}
위 코드를 cargo run
으로 실행시켜 보려고 하면, 아래와 같은 컴파일 에러를 맞닥뜨리게 될 것이다.
error[E0384]: cannot assign twice to immutable variable `x`
--> src/main.rs:4:5
|
2 | let x = 5;
| - first assignment to `x`
3 | println!("The value of x is: {}", x);
4 | x = 6;
| ^^^^^ cannot assign twice to immutable variable
코드에서 x
에 값을 두 번 할당하려고 했기 때문에, 에러 메세지는 불가변적 변수인 x
의 값을 두 번 할당할 수 없다고 말해 준다.
불가변적인 변수의 값을 변경하려고 하는 것은 버그를 유발할 수 있기 때문에, 이럴 때 컴파일 시간에 에러를 볼 수 있다는 것은 매우 중요하다. 만약 코드의 어느 부분은 그 값이 절대 변하지 않을 것이라는 전제 하에 작동하는 반면에 다른 부분에서는 그 값을 바꾸는 코드가 있다면, 첫 부분의 코드가 믿고 있는 전제 자체가 깨졌으므로 우리가 원했던 대로 작동하지 않을 수도 있는 것이다. 이런 유형의 버그가 일어났을 경우 그 원인을 찾기가 어렵다. 특히나 두 번째 코드가 어쩔 때만 그 값을 바꾼다면 더더욱 어렵게 된다.
당신이 어떤 값이 변하지 않을 것이라고 정한다면, Rust의 컴파일러는 그 값이 변하도록 절대 놔두지 않을 것이다. 그 덕분에 코드를 작성하거나 읽을 때, 당신은 어디서 어떻게 값들이 바뀌는 지에 대해 계속 추적하고 있을 필요가 없게 된다. 전체적으로 이해하기 쉬운 코드가 되는 것이다.
가변적인 변수를 선언하고 싶다면 mut
를 붙여서 선언하면 된다.
// src/main.rs
fn main() {
let mut x = 5;
println!("The value of x is: {}", x);
x = 6;
println!("The value of x is: {}", x);
}
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables)
Finished dev [unoptimized + debuginfo] target(s) in 0.30 secs
Running `target/debug/variables`
The value of x is: 5
The value of x is: 6
위에서 설명했던 버그를 예방하는 것은 별개로, 어떨 때는 가변적인 변수를 쓰는 게 좋을 수도 있고, 나쁠 때도 있을 수 있는 상황들이 있다. 예를 들어 거대한 데이터 구조를 사용하는 상황이라면, 그 instance를 변경시키는 것이, 새로운 instance로 그 내용을 통째로 복사해서 할당하는 것보다 빠를 것이다. 반대로 조그만 데이터 구조라면, 새롭게 instance를 생성하고 좀 더 함수형 프로그래밍 스타일로 코드를 작성하는 것이 코드를 논리적으로 이해하기에 쉬울 수 있다. 약간의 성능을 감수하고 코드의 명료함을 취하는 것이 더 이득일 상황도 있는 것이다.
Statement와 Expression
함수의 body는 statement들의 나열로 구성되고, 선택적으로 마지막에 expression으로 끝낼 수 있다. Expression은 statement의 일부로 쓰이기도 한다. Rust는 expression 기반의 언어이기 때문에, statement와 expression의 차이를 이해하는 것은 매우 중요하다.
- Statement는 어떤 동작을 취하고 어떠한 값도 내보내지 않는 구문이다.
- Expression은 결과값을 계산한다 (evaluate to a resulting value).
더 좋은 이해를 위해 몇 가지 예를 들어보겠다.
// src/main.rs
fn main() {
let y = 6;
}
let y = 6;
처럼let
을 사용해서 변수를 만들고 값을 할당하는 구문은 statement이다.- 함수 정의 역시 statement이다. 따라서 위의 구문 전체가 하나의 statement가 된다.
다시 한 번 말하지만, statement는 어떤 값을 내보내지 않는다. 따라서 다른 변수에 let
statement를 다시 할당할 수는 없다. 아래 코드는 에러가 나오게 될 코드이다.
// src/main.rs
fn main() {
let x = (let y = 6);
}
위 코드를 실행시킨다면 다음과 같은 컴파일 에러를 보게 될 것이다.
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
error: expected expression, found statement (`let`)
--> src/main.rs:2:14
|
2 | let x = (let y = 6);
| ^^^
|
= note: variable declaration using `let` is a statement
let y = 6
이라는 statement는 아무런 값을 주지 않으므로, x
에 연결될 것이 아무 것도 없는 것이다. 이는 C나 Ruby 같은 다른 언어와 다른 특징이다. C나 Ruby 등에서는 x = y = 6
을 통해서 x
와 y
에 모두 6
이라는 값을 할당할 수 있을 텐데, Rust에서는 가능한 얘기가 아니다.
무엇인가를 계산(evaluate)하는 expression은 당신이 쓰게 될 Rust 코드의 대부분을 구성한다. 5 + 6
이라는 단순한 수학 작업도 11
이라는 값을 계산해내는 expression인 것이다. 위 코드의 let y = 6;
에서의 한 글자 6
도 6
이라는 값을 계산하는 expression이다. 또한 함수 및 매크로를 호출하는 것 역시 expression이다. 새로운 scope를 생성하는 블럭인 {}
또한 expression이다. 아래 예를 보자.
// src/main.rs
fn main() {
let x = 5;
let y = {
let x = 3;
x + 1
};
println!("The value of y is: {}", y);
}
여기서 블럭
{
let x = 3;
x + 1
}
은 값 4
를 계산하는 expression이다. 이 값 4는 let
statement에서 선언하는 변수 y
에 묶이게 된다.
그리고 이 블럭의 마지막 코드인 x + 1
은 마지막에 semicolon이 없는데, 지금까지 본 코드와 무척 다른 것이다. Expression은 마지막 semicolon을 포함하지 않는다. 만약 expression의 끝에 semicolon이 붙여진다면, 그것은 statement가 되어 어떠한 값도 내보내지 않을 것이다.
if
Expressions
if
expression의 조건문은 반드시 bool
타입의 값이어야 한다. 그렇지 않으면 컴파일 에러를 보게 된다. 아래 예제를 보자.
// src/main.rs
fn main() {
let number = 3;
if number {
println!("number was three");
}
}
위 조건문 number
의 값이 3
이 되고, Rust는 에러를 말한다.
error[E0308]: mismatched types
--> src/main.rs:4:8
|
4 | if number {
| ^^^^^^ expected bool, found integral variable
|
= note: expected type `bool`
found type `{integer}`
위 에러는 Rust가 bool
값을 원했지만 정수 값이 나타나서 문제가 있음을 말해준다. Ruby나 JavaScript 등의 언어와 다르게, Rust는 Boolean 타입이 아닌 값들을 Boolean 타입으로 자동적으로 변환하려고 하지 않는다. 따라서 당신은 항상 if
의 조건문을 명확하게 Boolean 값으로 제공해야 한다.
if
또한 expression이기 때문에, let
statement의 오른쪽 값으로 사용할 수 있다. 아래처럼 말이다.
// src/main.rs
fn main() {
let condition = true;
let number = if condition {
5
} else {
6
};
println!("The value of number is: {}", number);
}
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
Finished dev [unoptimized + debuginfo] target(s) in 0.30 secs
Running `target/debug/branches`
The value of number is: 5
이 경우 number
의 값은 if
expression의 어느 블럭이 실행되느냐에 따라 달라지게 된다. 이는 각 블럭이 계산하는 값이 같은 타입이어야 함을 의미한다. 위 경우 두 블럭 모두 정수형 값을 내보냈기 때문에 문제가 없었다. 만약 이 타입이 다를 경우, 컴파일러는 에러를 띄운다.
// src/main.rs
fn main() {
let condition = true;
let number = if condition {
5
} else {
"six"
};
println!("The value of number is: {}", number);
}
이 경우 컴파일러는 어디서 타입이 일치하지 않는지 정확히 집어준다.
error[E0308]: if and else have incompatible types
--> src/main.rs:4:18
|
4 | let number = if condition {
| __________________^
5 | | 5
6 | | } else {
7 | | "six"
8 | | };
| |_____^ expected integral variable, found &str
|
= note: expected type `{integer}`
found type `&str`
if
블럭은 정수형 값을 주는 반면, else
블럭은 문자열(string)을 내보낸다. 변수는 한 가지 타입만을 가져야 하므로 이는 정상적인 행동이 아니다. Rust는 컴파일하는 시점에서 number
변수가 어떤 타입인지 알아서, number
를 사용하는 모든 곳에서 타입에 맞게 쓰였는지를 검사한다. 만약 number
변수의 타입이 런타임에서 결정된다면 Rust는 이를 확인할 수가 없다. 따라서 컴파일러는 어느 변수든지 가질 수 있는 여러 타입을 쫓아다니기 위해 더욱 복잡해져야 되고, 코드의 정상적인 작동에 대해 보장하는 힘이 줄어들게 된다.