변수와 가변성
기본 불변 변수와 mut 가변 변수, 상수, 섀도잉의 차이를 코드로 확인하고 값 변경에 맞는 선언을 선택합니다.
2장에서는 거의 모든 프로그래밍 언어에 있는 개념인 변수, 기본 타입, 함수, 주석, 제어 흐름이 러스트에서 어떻게 동작하는지 살펴봅니다. 러스트에만 있는 개념은 아니지만, 러스트가 이 개념들에 정해 둔 규칙은 모든 러스트 프로그램에 쓰이므로 일찍 익혀 두면 뒤의 코드를 읽기가 쉬워집니다.
러스트에도 다른 언어처럼 언어가 쓰려고 예약해 둔 키워드(keyword)가 있으며, 키워드는 변수나 함수의 이름으로 쓸 수 없습니다. 대부분의 키워드는 특별한 뜻이 있어 앞으로 하나씩 쓰게 되고, 몇몇은 아직 기능이 없지만 나중에 쓰려고 예약되어 있습니다. 키워드 목록은 부록 A 키워드에 있습니다.
이 글에서는 변수가 기본으로 불변인 까닭과 mut으로 가변 변수를 만드는 방법, 상수, 그리고 같은 이름으로 새 변수를 만드는 섀도잉을 비교합니다.
불변 변수와 가변 변수
추리 게임에서 보았듯이 러스트의 변수는 기본으로 불변(immutable)입니다. 이름에 한 번 값을 묶으면 그 값을 바꿀 수 없다는 뜻입니다. cargo new variables로 새 프로젝트를 만들고 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으로 실행하면 컴파일러가 다음 오류를 냅니다. 이 오류는 실행 중이 아니라 컴파일 단계에서 나므로, 실행 파일이 만들어지지 않고 첫 println!도 실행되지 않습니다.
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;
| +++E0384는 불변 변수에 값을 두 번 대입할 수 없다는 오류입니다. 메시지는 처음 값을 넣은 2번째 줄과, 다시 대입하려 한 4번째 줄을 함께 짚고, 2번째 줄에 mut을 붙이면 된다는 도움말까지 보여 줍니다. 오류 메시지의 세부 모양은 컴파일러 버전에 따라 조금씩 다를 수 있습니다.
컴파일러가 이 상황을 오류로 막는 데는 까닭이 있습니다. 코드의 한 부분은 어떤 값이 바뀌지 않는다고 믿고 동작하는데 다른 부분이 그 값을 바꾼다면, 앞부분은 의도대로 동작하지 않을 수 있습니다. 이런 오류는 특히 값을 가끔씩만 바꿀 때 원인을 찾기 어렵습니다. 러스트에서는 값이 바뀌지 않는다고 적으면 컴파일러가 정말로 바뀌지 않게 지켜 주므로, 코드를 읽을 때 그 값이 어디서 바뀌는지 따로 추적할 필요가 없습니다.
그래도 값을 바꿀 수 있으면 편할 때가 많습니다. 변수 이름 앞에 mut을 붙이면 가변(mutable) 변수가 되며, mut은 코드를 읽는 사람에게도 이 변수의 값이 뒤에서 바뀐다는 뜻을 알려 줍니다.
fn main() {
let mut x = 5;
println!("The value of x is: {x}");
x = 6;
println!("The value of x is: {x}");
}The value of x is: 5
The value of x is: 6mut 덕분에 x의 값을 5에서 6으로 바꿀 수 있었습니다. 다만 mut은 같은 변수에 다시 대입할 수 있게 할 뿐, 변수의 타입을 바꾸게 해 주지는 않습니다. x는 처음 값 5로 정수 타입이 정해졌으므로, 다시 대입하는 값도 같은 타입이어야 합니다. 불변과 가변 가운데 무엇을 쓸지는 상황에 따라 어느 쪽이 더 분명한지로 정하며, 바꿀 필요가 없는 값은 불변으로 두는 것이 러스트의 기본입니다.
상수
상수(constant)도 불변 변수처럼 이름에 묶인 값을 바꿀 수 없지만, 몇 가지가 다릅니다. 상수에는 mut을 붙일 수 없고 언제나 불변이며, let 대신 const 키워드로 선언하고 값의 타입을 반드시 적어야 합니다. 타입과 타입 표기는 다음 글 데이터 타입에서 다루므로, 지금은 상수에는 항상 타입을 적는다는 것만 기억하면 됩니다.
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;상수의 이름은 THREE_HOURS_IN_SECONDS이고, 값은 1분의 초 60과 1시간의 분 60, 그리고 시간 수 3을 곱한 10800입니다. 러스트에서는 상수 이름을 모두 대문자로 쓰고 단어 사이를 밑줄로 잇는 것이 관례입니다. 상수의 값을 구하는 식은 컴파일할 때 계산되므로, 런타임에야 알 수 있는 값은 쓸 수 없습니다. 그 대신 10800이라고 바로 적지 않고 60 * 60 * 3처럼 값의 뜻이 드러나는 식으로 쓸 수 있으며, 상수에 쓸 수 있는 연산은 Reference의 상수 평가에 정리되어 있습니다.
상수는 main 같은 함수 밖의 전역 범위를 포함해 어떤 범위에든 선언할 수 있고, 선언한 범위 안에서 프로그램이 실행되는 동안 언제나 쓸 수 있습니다. 그래서 게임에서 플레이어가 얻을 수 있는 최대 점수나 빛의 속도처럼 프로그램의 여러 곳이 함께 쓰는 값에 알맞습니다. 코드에 직접 적힌 값에 이름을 붙이면 그 값의 뜻이 드러나고, 나중에 값을 바꿀 때도 한 곳만 고치면 됩니다.
섀도잉
추리 게임에서 보았듯이, 이전 변수와 같은 이름으로 새 변수를 선언할 수 있습니다. 이때 첫 변수가 둘째 변수에 가려졌다(shadowed)고 하며, 그 이름을 쓰면 컴파일러는 둘째 변수를 봅니다. 둘째 변수는 다시 가려지거나 범위가 끝날 때까지 그 이름을 차지합니다.
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 =를 다시 써서 원래 값에 1을 더한 6을 담은 새 변수 x를 만듭니다. 중괄호로 만든 안쪽 범위에서는 세 번째 let이 또 새 x를 만들어 6에 2를 곱한 12를 담습니다. 이 범위가 끝나면 안쪽의 x도 사라지므로, 그 뒤의 x는 다시 6입니다.
The value of x in the inner scope is: 12
The value of x is: 6섀도잉은 mut과 다릅니다. let 없이 다시 대입하면 불변 변수이므로 컴파일 오류가 나지만, let으로 새 변수를 만들면 값을 바꾸는 계산을 몇 번 한 뒤에도 결과는 불변으로 남습니다. 또 섀도잉은 새 변수를 만드는 것이므로 같은 이름에 다른 타입의 값을 담을 수 있습니다. 예를 들어 사용자가 입력한 공백 문자열을 받아 그 개수를 숫자로 저장하고 싶다면 다음과 같이 씁니다.
let spaces = " ";
let spaces = spaces.len();첫 spaces는 문자열 타입이고 둘째 spaces는 숫자 타입이지만, 섀도잉 덕분에 spaces_str과 spaces_num처럼 이름을 둘 만들지 않고 spaces 하나로 쓸 수 있습니다. 그런데 같은 일을 mut으로 하려 하면 컴파일되지 않습니다.
fn main() {
let mut spaces = " ";
spaces = spaces.len();
}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`spaces는 처음 값 " "으로 문자열 슬라이스 타입 &str이 되었는데, spaces.len()은 길이를 나타내는 숫자 타입 usize를 돌려주므로 타입이 맞지 않는다는 E0308 오류가 납니다. E0308은 mut에만 해당하는 오류가 아니라, 어떤 자리에 컴파일러가 기대한 타입과 실제 식의 타입이 다를 때마다 나는 오류입니다. mut은 같은 변수의 값을 바꿀 뿐 타입은 바꾸지 못하기 때문에 이 경우에 나타났습니다.
더 깊이: 가려진 변수는 언제 사라질까
섀도잉은 이름을 새 변수에 넘길 뿐, 앞 변수의 값을 그 자리에서 버리지 않습니다. Reference의 소멸자 규칙에 따르면 let으로 선언한 지역 변수는 그 let을 담은 블록의 범위에 속하고, 실행이 그 범위를 벗어날 때 그 범위의 변수들이 선언의 반대 순서로 버려집니다. 가려진 변수도 같은 블록에서 선언된 변수이므로, 이름으로 더는 가리킬 수 없을 뿐 블록이 끝날 때까지 살아 있다가 자신을 가린 새 변수보다 나중에 버려집니다.
그래서 섀도잉으로 큰 값을 일찍 해제할 수는 없습니다. 큰 String을 담은 변수를 같은 이름의 정수로 가려도 그 String의 메모리는 블록이 끝날 때까지 남으며, 일찍 돌려주려면 안쪽 블록을 만들거나 Drop과 자원 정리에서 볼 drop 함수를 부릅니다. 반대로 가려진 값이 살아 있기 때문에, 섀도잉하기 전에 만든 참조는 섀도잉한 뒤에도 쓸 수 있습니다. let r = &s; 다음에 let s = 5;로 s를 가려도, r은 처음의 s를 계속 가리킵니다.
선언 방식 고르기
아래 표는 기본 불변인 let, 같은 변수의 값을 바꾸는 let mut, 같은 이름으로 새 변수를 만드는 섀도잉, 상수인 const를 비교합니다.
RUST · DECLARATION CONTRACTS
선언은 변경할 대상과 타입 계약을 정한다
같은 바인딩을 바꾸는 mut, 새 바인딩을 만드는 섀도잉, 상수 아이템 const는 서로 다릅니다. 변경·타입·평가 범위를 함께 비교해 의도에 맞는 선언을 고릅니다.
| 선언 · 대상 | 변경 · 타입 계약 | 평가 · 스코프 | 선택 기준 |
|---|---|---|---|
let x = expr;기본이 불변인 로컬 바인딩 |
같은 바인딩에는 재대입할 수 없다. 새 let은 새 바인딩이므로 같은 이름을 가리고 타입도 바꿀 수 있다. |
초기화식은 런타임 식일 수 있고, 이름은 바인딩의 스코프에서 사용한다. | 같은 바인딩을 바꿀 필요가 없을 때 기본으로 선택한다. 변환 단계라면 섀도잉을 검토한다. |
let mut x = expr;값을 바꿀 수 있는 하나의 로컬 바인딩 |
같은 바인딩에 기존 타입으로 대입 가능한 값을 재대입할 수 있다. 필요한 경우 허용된 coercion이 먼저 적용되며, 소유권과 빌림 규칙은 그대로 적용된다. | 초기화식은 런타임 식일 수 있고, mut는 이후 변경 의도를 코드에 드러낸다. |
같은 의미와 타입을 가진 상태가 시간에 따라 바뀌어야 할 때 선택한다. |
const NAME: T = expr;이름 붙은 상수 아이템. 트레이트나 |
mut나 재대입의 대상이 아니며 타입 표기가 필수다. 정의의 초기화식은 상수 표현식이어야 한다. |
자유 상수는 모듈·블록에 둘 수 있고 연관 상수는 타입 경로로 사용한다. 특정 저장 위치나 동일 주소를 보장하지 않는다. | 상수 컨텍스트에서 계산할 수 있고 여러 곳에서 의미를 공유할 도메인 값에 사용한다. |
let x = expr;- 의미 기본이 불변인 로컬 바인딩입니다.
- 계약 같은 바인딩에는 재대입할 수 없습니다. 새
let은 새 바인딩이므로 같은 이름을 가리고 타입도 바꿀 수 있습니다. - 평가·스코프 초기화식은 런타임 식일 수 있고 이름은 바인딩의 스코프에서 사용합니다.
- 선택 같은 바인딩의 변경이 필요 없을 때 기본으로 사용하고, 변환 단계라면 섀도잉을 검토합니다.
let mut x = expr;- 의미 값을 바꿀 수 있는 하나의 로컬 바인딩입니다.
- 계약 기존 타입으로 대입 가능한 값을 재대입할 수 있습니다. 필요한 경우 허용된 coercion이 먼저 적용되며, 소유권과 빌림 규칙은 그대로 적용됩니다.
- 평가·스코프 초기화식은 런타임 식일 수 있고
mut는 이후 변경 의도를 드러냅니다. - 선택 같은 의미와 타입의 상태가 시간에 따라 바뀌어야 할 때 사용합니다.
const NAME: T = expr;- 의미 이름 붙은 상수 아이템이며, 트레이트나
impl에서는 타입에 연결된 연관 상수입니다. - 계약
mut나 재대입의 대상이 아니며 타입 표기가 필수이고, 정의의 초기화식은 상수 표현식이어야 합니다. 특정 저장 위치나 동일 주소는 보장하지 않습니다. - 평가·스코프 자유 상수는 모듈·블록에 둘 수 있고 연관 상수는 타입 경로로 사용합니다.
- 선택 상수 컨텍스트에서 계산할 수 있고 여러 곳에서 의미를 공유할 도메인 값에 사용합니다.
let mut, 변환 단계나 새 타입이면 새 let으로 섀도잉하고, 둘 다 아니면 불변 let을 유지합니다. const는 “절대 안 바뀌는 변수”가 아니라 상수 표현식으로 정의한 아이템입니다.값을 바꿀 필요가 없으면 let을 기본으로 쓰고, 같은 값을 계속 고쳐 나가야 하면 let mut을 씁니다. 값을 다른 형태나 타입으로 바꾸는 단계를 거치며 이전 값은 더 쓰지 않는다면 섀도잉이 알맞고, 컴파일할 때 정해지고 여러 곳이 함께 쓰는 값이라면 const로 이름을 붙입니다.
E0384나 E0308을 만났을 때도 이 기준으로 고칠 방법을 고릅니다. 아래 그림처럼 먼저 오류가 가리키는 변수 선언과 대입하는 식, 기대한 타입을 소스에서 찾습니다.
E0384와 E0308 진단 처리 흐름
E0384/E0308에서 해당 바인딩·오른쪽 식·기대 타입을 찾고 변경 의도에 맞게 mut·새 let·타입 수정을 선택한 뒤 check/test로 재검사하는 실제 작업 절차다. 오류 코드만으로 자동 수정하는 이야기를 피한다.
RUST · DIAGNOSTIC PROCESS
진단 범주를 읽고 변경 의도에 맞게 수정한다
E0384는 불변 바인딩의 재할당, E0308은 기대 타입과 실제 타입의 불일치입니다. 같은 바인딩을 바꿀지 새 바인딩이 필요한지 소스에서 확인한 뒤 다시 검사합니다.
오류 코드만으로 수정하지 않음현재 소스와 의도를 함께 확인
- 진단 범주 확인
E0384는 불변 바인딩 재할당,E0308은 기대 타입과 실제 식의 타입 불일치를 뜻합니다. - 해당 소스 찾기
let또는let mut선언, 재대입문의 오른쪽 식, 기대 타입이 정해진 위치를 함께 확인합니다. - 의도에 맞는 수정 선택
기존 바인딩 타입에 대입 가능한 상태 변경이면
mut, 변환 단계나 새 타입이면 새let섀도잉, 타입 의도가 잘못되었다면 선언·식·타입 표기를 수정합니다. - 다시 검사
cargo check로 남은 진단을 읽고, 동작 계약이 있는 코드는 관련 테스트까지 다시 실행합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 4노드와 3간선이다. E0384는 불변 바인딩 재할당, E0308은 일반 타입 불일치다. 진단→해당 소스→의도 선택→재검사를 유지한다. mut는 기존 타입과 빌림 계약을 우회하지 않고 모든 E0308의 해결책도 아니다. 테스트 명령은 실제로 검사할 동작 계약이 있는 경우에 사용한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 진단과 위치 E0384/E0308의 바인딩·식·기대 타입을 읽는다. |
|
|
| 의도에 맞는 수정 같은 상태 변경·새 값과 타입·계약 수정을 구분한다. |
|
|
| 같은 검사 check 뒤 동작 계약이 있으면 test까지 재검사한다. |
|
|
E0384와 E0308 진단 처리 흐름
E0384/E0308에서 해당 바인딩·오른쪽 식·기대 타입을 찾고 변경 의도에 맞게 mut·새 let·타입 수정을 선택한 뒤 check/test로 재검사하는 실제 작업 절차다. 오류 코드만으로 자동 수정하는 이야기를 피한다.
RUST · DIAGNOSTIC PROCESS
진단 범주를 읽고 변경 의도에 맞게 수정한다
E0384는 불변 바인딩의 재할당, E0308은 기대 타입과 실제 타입의 불일치입니다. 같은 바인딩을 바꿀지 새 바인딩이 필요한지 소스에서 확인한 뒤 다시 검사합니다.
오류 코드만으로 수정하지 않음현재 소스와 의도를 함께 확인
- 진단 범주 확인
E0384는 불변 바인딩 재할당,E0308은 기대 타입과 실제 식의 타입 불일치를 뜻합니다. - 해당 소스 찾기
let또는let mut선언, 재대입문의 오른쪽 식, 기대 타입이 정해진 위치를 함께 확인합니다. - 의도에 맞는 수정 선택
기존 바인딩 타입에 대입 가능한 상태 변경이면
mut, 변환 단계나 새 타입이면 새let섀도잉, 타입 의도가 잘못되었다면 선언·식·타입 표기를 수정합니다. - 다시 검사
cargo check로 남은 진단을 읽고, 동작 계약이 있는 코드는 관련 테스트까지 다시 실행합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 4노드와 3간선이다. E0384는 불변 바인딩 재할당, E0308은 일반 타입 불일치다. 진단→해당 소스→의도 선택→재검사를 유지한다. mut는 기존 타입과 빌림 계약을 우회하지 않고 모든 E0308의 해결책도 아니다. 테스트 명령은 실제로 검사할 동작 계약이 있는 경우에 사용한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 진단과 위치 E0384/E0308의 바인딩·식·기대 타입을 읽는다. |
|
|
| 의도에 맞는 수정 같은 상태 변경·새 값과 타입·계약 수정을 구분한다. |
|
|
| 같은 검사 check 뒤 동작 계약이 있으면 test까지 재검사한다. |
|
|
그다음 같은 변수의 값을 바꾸려던 것인지, 다른 값이나 타입의 새 변수가 필요했던 것인지, 아니면 타입을 잘못 적은 것인지를 가립니다. 첫째라면 mut을, 둘째라면 섀도잉을, 셋째라면 타입이나 식을 고치며, mut이 모든 E0308을 해결해 주지는 않는다는 점에 주의합니다. 고친 뒤에는 cargo check로 다시 확인하고, 그 코드의 동작을 검사하는 테스트가 있다면 cargo test까지 실행합니다.
정리
- 러스트의 변수는 기본으로 불변이며, 불변 변수에 다시 대입하면 E0384 오류가 납니다.
mut을 붙이면 같은 변수에 다시 대입할 수 있지만, 변수의 타입은 바뀌지 않습니다.- 상수는
const로 선언하고 타입을 반드시 적으며, 값은 컴파일할 때 계산되는 식이어야 합니다. - 상수 이름은 대문자와 밑줄로 쓰고, 여러 곳이 함께 쓰는 값에 이름을 붙이는 데 씁니다.
- 섀도잉은 같은 이름으로 새 변수를 만들며, 새 변수는 다른 타입일 수 있고 범위가 끝나면 이전 변수가 다시 보입니다.
- E0308은 기대한 타입과 실제 타입이 다를 때 나는 오류이며,
mut으로는 타입을 바꿀 수 없습니다.
연습 문제
1. 안쪽과 바깥쪽의 값
섀도잉 예제에서 처음 값을 10으로, 바깥의 두 번째 let을 let x = x + 2;로, 안쪽의 let을 let x = x * 3;으로 바꾸면 두 줄은 각각 무엇을 출력하나요? 안쪽 줄에서 let만 지우면 어떻게 되나요?
해설 보기
바깥의 두 번째 let은 10을 읽어 12를 담은 새 x를 만들고, 안쪽의 let은 그 12를 읽어 36을 담은 또 다른 x를 만듭니다. 그래서 안쪽 줄은 The value of x in the inner scope is: 36, 블록 뒤의 줄은 The value of x is: 12를 출력합니다. 안쪽의 계산은 새 변수를 만들었을 뿐 바깥의 x를 바꾸지 않았습니다. 안쪽 줄에서 let을 지우면 불변인 바깥 x에 다시 대입하는 것이 되므로 E0384 오류로 컴파일되지 않습니다.
2. 고칠 방법 고르기
다음 두 코드는 각각 어떤 오류가 나고, 어떻게 고치는 것이 알맞나요? (가) 반복할 때마다 합계에 값을 더하려고 let total = 0;을 선언한 뒤 total = total + 5;라고 썼다. (나) 문자열로 읽은 입력 let count = "3";을 숫자로 쓰려고 let mut count = "3";으로 바꾼 뒤 count = count.len();이라고 썼다.
해설 보기
(가)는 불변 변수에 다시 대입하므로 E0384 오류가 납니다. 같은 합계 값을 계속 고쳐 나가려는 것이므로 let mut total = 0;으로 바꿉니다. (나)는 &str인 변수에 usize를 대입하므로 E0308 오류가 납니다. 문자열을 다른 타입의 값으로 바꾸는 단계이므로 mut을 빼고 let count = count.len();처럼 섀도잉으로 새 변수를 만듭니다. 다만 len()은 글자 수가 아니라 바이트 수이므로, 문자열 "3"을 숫자 3으로 바꾸려는 것이었다면 let count: u32 = count.parse().expect("not a number");처럼 parse를 써야 합니다.
3. 상수가 될 수 있을까
다음 가운데 const로 선언할 수 있는 것은 무엇인가요? (가) const MAX_POINTS: u32 = 100_000; (나) const LIMIT = 50; (다) 실행할 때 사용자에게 입력받은 값을 담는 상수.
해설 보기
(가)만 선언할 수 있습니다. (나)는 상수의 타입을 적지 않았으므로 컴파일되지 않으며 const LIMIT: u32 = 50;처럼 타입을 적어야 합니다. (다)는 상수의 값이 컴파일할 때 계산되어야 하는데 사용자의 입력은 실행해야 알 수 있으므로 상수가 될 수 없고, let으로 만든 변수에 담습니다.
다음 글 데이터 타입에서는 정수와 부동 소수점 수, 불리언, 문자 같은 스칼라 타입과, 여러 값을 묶는 튜플과 배열을 살펴봅니다.