변수와 가변성
변수와 가변성 (Variables and Mutability)
Rust에서 변수를 처음 다루면서 가장 먼저 맞닥뜨리는 개념이 바로 가변성(mutability) 이에요. Rust는 기본적으로 변수를 불변(immutable) 으로 만들도록 강하게 유도하는데요, 왜 그런 설계를 택했고, 어떤 상황에서 가변으로 바꾸는 게 더 나은지 하나씩 짚어볼게요.
출처: Rust 공식문서
본문
“Storing Values with Variables” 섹션에서도 언급했듯이, Rust의 변수는 기본적으로 불변(immutable) 이에요. 이건 Rust가 안전성(safety)과 쉬운 동시성(concurrency)을 누릴 수 있도록 코드를 작성하라고 주는 많은 넛지(nudge) 중 하나랍니다. 물론 필요할 때 변수를 가변(mutable)으로 바꿀 수도 있어요. Rust가 왜 불변성을 선호하도록 이끄는지, 그리고 언제 그 원칙을 벗어나는 게 나을지 함께 살펴봐요.
변수가 불변이라면, 이름에 값이 한 번 결합(bound)되면 그 값을 바꿀 수 없어요. 이걸 직접 확인해 보기 위해 projects 디렉터리에 cargo new variables로 새 프로젝트를 만들어볼게요. 그리고 새 variables 디렉터리의 src/main.rs를 열어서, 아직은 컴파일되지 않는 다음 코드로 바꿔 보세요.
Filename: 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으로 저장하고 실행하면, 아래 출력처럼 불변성 오류(immutability error) 메시지를 받게 돼요.
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables)
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
|
help: consider making this binding mutable
|
2 | let mut x = 5;
| +++
For more information about this error, try `rustc --explain E0384`.
error: could not compile `variables` (bin "variables") due to 1 previous error
이 예시는 컴파일러가 여러분의 프로그램에서 오류를 찾는 데 얼마나 도움이 되는지 보여줘요. 컴파일러 오류가 답답하게 느껴질 수 있지만, 사실 그건 여러분의 프로그램이 아직 원하는 대로 안전하게 동작하지 않는다는 뜻일 뿐이에요. 여러분이 프로그래머로서 부족하다는 의미가 절대 아니랍니다. 경험 많은 Rust 개발자(Rustacean)들도 여전히 컴파일러 오류를 만나요.
cannot assign twice to immutable variable 'x'라는 오류 메시지를 받은 건, 불변인 x에 두 번째 값을 할당하려고 했기 때문이에요.
불변으로 지정된 값을 바꾸려 할 때 컴파일 타임 오류를 받는 건 매우 중요해요. 정확히 이런 상황이 버그로 이어질 수 있거든요. 코드의 한 부분이 "이 값은 절대 변하지 않을 거야"라는 가정을 하고 있는데, 다른 부분에서 그 값을 바꿔 버리면, 앞의 코드가 설계된 대로 동작하지 않을 수 있어요. 이런 종류의 버그는 특히 두 번째 코드가 값을 가끔씩만 바꿀 때 원인 추적이 매우 어려워져요. Rust 컴파일러는 "이 값은 변하지 않는다"고 선언하면 정말로 변하지 않는다는 것을 보장해 주기 때문에, 여러분이 직접 추적할 필요가 없어요. 그래서 코드를 훨씬 쉽게 추론(reason)할 수 있답니다.
하지만 가변성은 아주 유용할 수 있고, 코드를 더 편하게 작성하게 해 주기도 해요. 변수는 기본적으로 불변이지만, Chapter 2에서 했듯이 변수 이름 앞에 mut을 붙이면 가변으로 만들 수 있어요. mut을 붙이는 건 미래에 코드를 읽을 사람에게 "이 변수의 값은 다른 부분에서 바뀔 거예요"라는 의도를 전달하는 역할도 한답니다.
예를 들어 src/main.rs를 다음처럼 바꿔 볼게요.
Filename: 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` profile [unoptimized + debuginfo] target(s) in 0.30s
Running `target/debug/variables`
The value of x is: 5
The value of x is: 6
mut을 사용하면 x에 결합된 값을 5에서 6으로 바꿀 수 있게 됐죠. 결국 가변성을 쓸지 말지는 여러분의 몫이고, 그 상황에서 무엇이 가장 명확한지에 달려 있어요.
상수 선언 (Declaring Constants)
불변 변수처럼 상수(constant) 도 이름에 결합되고 값을 바꿀 수 없어요. 하지만 상수와 변수 사이에는 몇 가지 차이가 있어요.
첫째, 상수에는 mut을 사용할 수 없어요. 상수는 기본적으로 불변인 게 아니라 항상 불변이거든요. 상수는 let 키워드 대신 const 키워드로 선언하고, 값의 타입을 반드시 명시(annotate) 해야 해요. 타입과 타입 명시의 자세한 내용은 다음 섹션인 “Data Types”에서 다룰 테니 지금은 세부 사항에 신경 쓰지 않아도 돼요. 다만 타입을 항상 명시해야 한다는 점만 기억해 두면 됩니다.
둘째, 상수는 어떤 스코프에서든 선언할 수 있어요. 전역 스코프(global scope)에서도 가능하죠. 그래서 코드의 여러 부분이 알아야 하는 값에 유용해요.
마지막 차이는, 상수에는 상수 표현식(constant expression) 만 넣을 수 있다는 점이에요. 런타임에만 계산될 수 있는 값의 결과는 넣을 수 없답니다.
상수 선언의 예시를 하나 볼게요.
#![allow(unused)]
fn main() {
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;
}
상수의 이름은 THREE_HOURS_IN_SECONDS이고, 값은 60(1분의 초 수) × 60(1시간의 분 수) × 3(이 프로그램에서 세려는 시간 수)을 곱한 결과로 정해져요. Rust의 상수 명명 규칙은 단어 사이에 밑줄(_)을 쓰고 전부 대문자로 쓰는 거예요. 컴파일러는 컴파일 타임에 제한된 연산 집합을 평가할 수 있어서, 이 값을 그냥 10,800으로 적는 대신 이해하고 검증하기 쉬운 형태로 작성할 수 있는 거죠. 상수 선언에 사용할 수 있는 연산이 궁금하다면 Rust Reference의 상수 평가(constant evaluation) 섹션을 참고해 보세요.
상수는 선언된 스코프 안에서 프로그램이 실행되는 동안 내내 유효해요. 이 특성 덕분에 게임에서 한 플레이어가 얻을 수 있는 최대 점수나 빛의 속도처럼, 프로그램의 여러 부분이 알아야 하는 값에 상수가 유용하답니다.
프로그램 곳곳에 하드코딩된 값을 상수로 이름 붙이는 건, 그 값의 의미를 미래에 코드를 유지보수할 사람에게 전달하는 데 유용해요. 또 나중에 하드코딩 값을 수정해야 할 때 코드에서 한 곳만 바꾸면 된다는 장점도 있어요.
섀도잉 (Shadowing)
Chapter 2의 추측 게임 튜토리얼에서 봤듯이, 이전 변수와 같은 이름으로 새 변수를 선언할 수 있어요. Rust 개발자들은 첫 번째 변수가 두 번째 변수에 의해 섀도잉(shadowed) 된다고 말하는데, 이는 변수 이름을 사용할 때 컴파일러가 보게 되는 건 두 번째 변수라는 뜻이에요. 실질적으로 두 번째 변수가 첫 번째 변수를 가려서, 그 변수 이름의 모든 사용이 두 번째 변수 자신이 섀도잉되거나 스코프가 끝날 때까지 자기 자신을 가리키게 돼요. 같은 변수 이름을 쓰고 let 키워드를 반복해서 사용하면 다음과 같이 변수를 섀도잉할 수 있어요.
Filename: src/main.rs
fn main() {
let x = 5;
let x = x + 1;
{
let x = x * 2;
println!("The value of x in the inner scope is: {x}");
}
println!("The value of x is: {x}");
}
이 프로그램은 먼저 x를 값 5에 결합해요. 그다음 let x =를 반복해 새 변수 x를 만들면서 원래 값에 1을 더해 x가 6이 되죠. 그리고 중괄호로 만든 내부 스코프 안에서 세 번째 let 문이 다시 x를 섀도잉하고 새 변수를 만들어, 이전 값에 2를 곱해 x가 12가 돼요. 그 스코프가 끝나면 내부 섀도잉이 끝나고 x는 다시 6이 됩니다. 이 프로그램을 실행하면 다음처럼 출력 돼요.
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
Running `target/debug/variables`
The value of x in the inner scope is: 12
The value of x is: 6
섀도잉은 변수를 mut으로 표시하는 것과 달라요. let 키워드 없이 실수로 이 변수에 재할당하려고 하면 컴파일 타임 오류를 받게 되거든요. let을 사용하면 값에 여러 변환을 적용하고 나서도 그 변수가 변환 완료 후에는 불변으로 유지되게 할 수 있어요.
mut과 섀도잉의 또 다른 차이는, let 키워드를 다시 사용하면 사실상 새로운 변수를 만드는 셈이라 값의 타입을 바꾸면서도 같은 이름을 재사용할 수 있다는 점이에요. 예를 들어 프로그램이 사용자에게 텍스트 사이에 몇 칸의 공백을 원하는지 공백 문자를 입력받게 하고, 그 입력을 숫자로 저장하고 싶다고 해 볼게요.
fn main() {
let spaces = " ";
let spaces = spaces.len();
}
첫 번째 spaces 변수는 문자열 타입이고, 두 번째 spaces 변수는 숫자 타입이에요. 이렇게 섀도잉하면 spaces_str이나 spaces_num처럼 서로 다른 이름을 새로 만들 필요 없이, 더 단순한 spaces라는 이름을 재사용할 수 있어요. 하지만 여기에 mut을 사용하려고 하면, 다음처럼 컴파일 타임 오류를 받게 돼요.
fn main() {
let mut spaces = " ";
spaces = spaces.len();
}
오류 메시지는 변수의 타입을 바꾸는 것이 허용되지 않는다고 말해줘요.
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables)
error[E0308]: mismatched types
--> src/main.rs:3:14
|
2 | let mut spaces = " ";
| ----- expected due to this value
3 | spaces = spaces.len();
| ^^^^^^^^^^^^ expected `&str`, found `usize`
For more information about this error, try `rustc --explain E0308`.
error: could not compile `variables` (bin "variables") due to 1 previous error
이제 변수가 어떻게 동작하는지 살펴봤으니, 변수가 가질 수 있는 데이터 타입들을 더 알아볼게요.