Devin.KR

값과 변수 - 불변이 기본인 언어

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서는 cargo 로 프로젝트를 만들고 첫 프로그램을 빌드해 실행했다. 이번 장에서는 그 프로그램 안에서 값을 이름에 묶어 두고 다루는 방법을 배운다. 가계부 도구는 잔액, 지출 금액, 분류 기호, 하루 기록 같은 값을 계속 들고 있어야 한다. Rust 는 이 값들을 기본적으로 바꿀 수 없게 만들고, 바꾸려면 그 의도를 코드에 적도록 요구한다. 이 규칙이 처음에는 번거롭게 느껴지지만, 뒤에서 배울 소유권과 빌림을 이해하는 바탕이 된다.

  • let 과 let mut 의 차이를 설명하고, 불변 변수에 재대입할 때 나오는 컴파일 오류를 읽을 수 있다.
  • 섀도잉(shadowing)이 무엇이며 mut 재대입과 어떻게 다른지 구분한다.
  • 정수·부동소수점·bool·char 타입을 고르고, 정수 오버플로가 debug 와 release 빌드에서 어떻게 다르게 동작하는지 설명한다.
  • 튜플과 배열로 여러 값을 묶고, 인덱스와 구조 분해로 꺼낸다.

문제 상황

가계부 도구를 만든다고 하자. 월초 잔액이 있고, 수입과 지출이 들어올 때마다 잔액이 달라진다. 한편 "이번 달이 몇 월인가" 같은 값은 실행 중에 바뀌면 곤란하다. 다른 언어에서는 두 경우 모두 변수를 선언해 쓰고, 실수로 값을 덮어써도 언어가 알려 주지 않는다.

또 하나의 문제는 숫자 크기다. 하루 지출을 0 에서 255 사이의 작은 정수 타입에 담았는데 합계가 260 이 되면 어떻게 되는가. 어떤 언어는 조용히 4 로 돌려놓고, 어떤 언어는 아주 큰 수로 자동 확장한다. Rust 는 빌드 방식에 따라 동작이 달라지므로, 이 차이를 모르고 있으면 개발 중에는 멀쩡하던 프로그램이 배포판에서 잘못된 금액을 낼 수 있다.

이 장에서는 위 두 문제를 작은 실험으로 확인하면서 Rust 의 기본 값 타입과 변수 규칙을 익힌다.

변수: 불변이 기본이다

let 과 let mut

let 으로 만든 변수는 기본적으로 값을 바꿀 수 없다. 값을 바꾸고 싶다면 선언할 때 mut(mutable, 가변)을 붙인다. 코드를 읽는 사람은 mut 가 없는 변수를 보면 "이 값은 끝까지 그대로"라고 믿고 읽어도 된다. 반대로 mut 가 보이면 그 아래 어딘가에서 값이 바뀐다는 신호다.

다음 코드는 오류를 낸다.

fn main() {
    let balance = 100;
    balance = 200;
    println!("{balance}");
}

컴파일러는 error[E0384]: cannot assign twice to immutable variable `balance` 라고 알려 준다. 요지는 "불변 변수에 두 번 대입할 수 없다"이다. 컴파일러는 mut balance 로 선언하라는 도움말도 함께 보여 준다.

타입은 대부분 적지 않아도 된다. let balance = 100; 에서 컴파일러가 정수 리터럴(코드에 직접 적은 값)을 보고 타입을 정하는데, 이를 타입 추론(type inference)이라 한다. 다른 근거가 없으면 정수는 i32, 소수는 f64 로 정해진다. 타입을 직접 밝히려면 let count: u8 = 250; 처럼 이름 뒤에 콜론과 타입을 쓴다.

상수와의 차이

이름에 const 를 쓰면 컴파일 시점에 값이 정해지는 상수가 된다. 상수는 타입을 반드시 적고 이름은 관례상 대문자와 밑줄로 쓴다. 이 장의 예제는 let 만 사용하고, 상수는 필요한 장에서 다시 만난다.

섀도잉: 같은 이름으로 새 변수를 만든다

같은 이름으로 let 을 다시 쓰면 새 변수가 만들어지고, 이후에는 이전 변수 대신 새 변수가 그 이름으로 불린다. 이를 섀도잉(shadowing, 가려짐)이라 한다. 이전 값이 바뀌는 것이 아니라 새 값이 하나 더 생기는 것이다. 그래서 mut 없이도 값을 가공해 나갈 수 있고, 타입도 바꿀 수 있다.

let memo = "coffee";
let memo = memo.len();

첫 줄의 memo 는 글자 조각이고, 둘째 줄의 memo 는 그 길이인 정수다. 둘째 줄의 오른쪽 memo 는 아직 첫 번째 변수를 가리킨다는 점에 주의한다. 반면 mut 변수에 다른 타입 값을 대입하려 하면 컴파일 오류가 난다.

let mut memo = "coffee";
memo = memo.len();

이 코드는 error[E0308]: mismatched types 를 낸다. 요지는 &str 자리에 usize 가 왔다는 것이다. &str 은 문자열 리터럴의 타입인데, 문자열은 뒤에서 자세히 다룬다. 지금은 "글자 조각의 타입"으로만 읽어 두자.

mut 재대입은 같은 칸의 값을 바꾸고, 섀도잉은 새 칸을 만들어 그 이름을 넘겨받는다.

섀도잉은 블록(중괄호로 묶은 영역) 안에서도 쓸 수 있다. 블록 안에서 가린 이름은 블록이 끝나면 원래 변수로 돌아온다. 완성 코드의 rate 예제가 이를 보여 준다.

기본 값 타입

정수

정수 타입은 부호 유무와 비트 수로 이름이 정해진다. i 는 음수를 표현할 수 있는 부호 있는 정수, u 는 0 이상만 표현하는 부호 없는 정수이고, 뒤의 숫자가 비트 수다. isize 와 usize 는 실행하는 컴퓨터의 포인터 크기를 따르며, 배열 길이와 인덱스에 쓰인다.

자주 쓰는 정수 타입의 범위와 용도
타입범위크기가계부에서의 용도
u80 ~ 2551바이트월, 일 같은 작은 값
i32-2147483648 ~ 21474836474바이트정수 리터럴의 기본 타입
i64약 ±9.22×10^188바이트원 단위 금액 합계
usize플랫폼에 따라 다름포인터 크기길이, 인덱스

큰 수는 1_200_000 처럼 밑줄로 자릿수를 나눠 쓸 수 있다. 밑줄은 값에 영향을 주지 않는다. 각 타입의 경계 값은 u8::MAX, i32::MIN 처럼 읽는다.

정수끼리 나누면 소수점 아래가 버려진다. 7 / 2 는 3 이고 7 % 2(나머지)는 1 이다. 소수까지 필요하면 as f64 로 부동소수점으로 바꾼 뒤 나눈다. as 는 값을 다른 숫자 타입으로 바꾸는 연산자다.

서로 다른 정수 타입은 자동으로 섞이지 않는다. i32 값과 i64 값을 더하면 error[E0308]: mismatched types 와 cannot add `i64` to `i32` 가 나온다. 이 오류는 뒤의 "자주 틀리는 것"에서 다시 다룬다.

부동소수점, bool, char

소수는 f32 와 f64 두 가지이고 기본은 f64 다. 부동소수점은 이진수로 소수를 근사하기 때문에 0.1 + 0.2 == 0.3 이 거짓이 된다. 그래서 금액은 소수 대신 원 단위 정수로 저장하는 편이 안전하다.

bool 은 true 와 false 두 값만 가지며 1바이트를 차지한다. Rust 는 숫자 0 이나 빈 문자열을 참·거짓으로 자동 해석하지 않는다.

char 는 글자 한 개를 나타내며 작은따옴표로 쓴다. 크기는 4바이트이고 유니코드 글자 하나를 담을 수 있어 '가' 나 '₩' 도 char 다. 큰따옴표로 쓴 "가" 는 문자열이라 타입이 다르다. 글자를 UTF-8 로 저장할 때 필요한 바이트 수는 글자마다 다르며, '가' 는 3바이트다.

정수 오버플로

정수 타입이 표현할 수 있는 범위를 넘는 계산을 오버플로(overflow)라 한다. Rust 에서 결과는 빌드 방식에 따라 갈린다.

fn add_days(days: u8, extra: u8) -> u8 {
    days + extra
}

fn main() {
    println!("{}", add_days(250, 10));
}

cargo run 은 기본으로 debug 빌드를 만든다. debug 빌드는 오버플로를 검사하고, 넘치면 프로그램이 즉시 중단(panic)된다. 메시지에는 attempt to add with overflow 가 나온다. 반면 cargo run --release 로 만든 release 빌드는 기본적으로 이 검사를 끄고, 결과를 범위 안으로 돌려 감는다. 260 은 256 을 뺀 4 가 된다. 두 결과가 다르므로 debug 에서만 시험하고 배포하면 위험하다.

덧붙이면 250 + 10 처럼 컴파일러가 값을 미리 알 수 있는 계산이 넘치면 빌드 단계에서 this arithmetic operation will overflow 오류가 난다. 위 예제가 함수 인자를 쓰는 이유다.

빌드 방식에 기대지 말고 원하는 동작을 직접 고르는 방법이 있다. 정수 타입의 메서드가 그 수단이다.

u8 값 250 에 10 을 더할 때 메서드별 결과
메서드넘칠 때 동작결과쓰는 때
wrapping_add범위 안으로 돌려 감음4돌아가는 값이 의도일 때
saturating_add경계 값에서 멈춤255상한이 있는 값
checked_add넘쳤음을 값으로 알림None넘침을 직접 처리할 때
overflowing_add돌린 값과 넘침 여부 반환(4, true)둘 다 필요할 때
u8 범위를 넘는 계산은 debug 에서 panic, release 에서 4 로 감기며, 메서드로 동작을 직접 고를 수 있다.

checked_add 는 Some(255) 나 None 같은 값을 돌려준다. 이것은 "값이 있거나 없음"을 나타내는 타입이며, 자세한 내용은 Option 을 다루는 장에서 배운다. 이 장에서는 출력에 {:?} 를 써서 그대로 확인만 한다.

튜플과 배열

튜플

튜플(tuple)은 서로 다른 타입의 값을 하나로 묶는다. 길이는 정해져 있고 바뀌지 않는다. 지출 한 건을 (항목, 부호, 금액)으로 묶으면 ("점심", '-', 8_500) 이다. 요소는 entry.0, entry.2 처럼 점과 번호로 꺼내거나, let (label, kind, amount) = entry; 처럼 한 번에 풀어 낼 수 있다. 후자를 구조 분해(destructuring)라 한다.

배열

배열(array)은 같은 타입의 값을 고정된 개수만큼 담는다. 타입은 [i32; 7] 처럼 요소 타입과 길이를 함께 나타낸다. 요소는 0 부터 시작하는 인덱스로 꺼내고, 길이는 len() 으로 얻는다. [0; 3] 은 0 을 세 개 채운 배열을 만든다. 길이가 늘거나 줄어드는 목록은 컬렉션을 다루는 장에서 배울 Vec 이 맡는다.

범위를 벗어난 인덱스는 실행 중 panic 을 일으키고, 메시지는 index out of bounds: the len is 7 but the index is 9 형태다. 이 검사는 debug 와 release 모두에서 동작한다. 정수 오버플로와 달리 빌드 방식에 따라 사라지지 않는다.

완성 코드

지금까지의 내용을 하나의 src/main.rs 에 모았다. 앞 장에서 만든 프로젝트의 파일을 이 내용으로 바꾸고 실행한다.

fn main() {
    // 1. 불변과 가변
    let month = 9;
    let mut balance = 1_200_000;
    println!("{month}월 시작 잔액: {balance}원");
    balance += 350_000;
    balance -= 48_500;
    println!("{month}월 현재 잔액: {balance}원");

    // 2. 섀도잉
    let memo = "coffee";
    let memo = memo.len();
    println!("메모 길이: {memo}");

    let fee = 1_250;
    let fee = fee * 3;
    println!("수수료 합계: {fee}원");

    let rate = 3;
    {
        let rate = rate * 10;
        println!("블록 안 rate: {rate}");
    }
    println!("블록 밖 rate: {rate}");

    // 3. 정수와 오버플로
    let count: u8 = 250;
    println!("wrapping_add: {}", count.wrapping_add(10));
    println!("saturating_add: {}", count.saturating_add(10));
    println!("checked_add(10): {:?}", count.checked_add(10));
    println!("checked_add(5): {:?}", count.checked_add(5));
    let (value, overflowed) = count.overflowing_add(10);
    println!("overflowing_add: {value} {overflowed}");
    println!("u8 범위: {} ~ {}", u8::MIN, u8::MAX);
    println!("i32 범위: {} ~ {}", i32::MIN, i32::MAX);

    let total = 7;
    let people = 2;
    println!("몫 {}, 나머지 {}", total / people, total % people);
    println!("실수 나눗셈: {}", total as f64 / people as f64);

    // 4. 부동소수점, bool, char
    let price: f64 = 12_000.0;
    let tax_rate = 0.1;
    let with_tax = price * (1.0 + tax_rate);
    println!("세금 포함: {with_tax:.2}");
    println!("0.1 + 0.2 == 0.3 ? {}", 0.1 + 0.2 == 0.3);

    let is_income = false;
    let sign = '-';
    let icon = '₩';
    println!("수입 여부: {is_income}, 부호: {sign}, 기호: {icon}");
    println!(
        "char 크기: {} 바이트, bool 크기: {} 바이트",
        size_of::<char>(),
        size_of::<bool>()
    );
    println!("'가' 는 UTF-8 로 {} 바이트", '가'.len_utf8());

    // 5. 튜플
    let entry: (&str, char, i64) = ("점심", '-', 8_500);
    let (label, kind, amount) = entry;
    println!("{label} {kind}{amount}");
    println!("금액만: {}", entry.2);

    // 6. 배열
    let weekly = [12_000, 8_500, 0, 23_000, 5_500, 41_000, 9_000];
    let weekend = weekly[5] + weekly[6];
    println!("이번 주 {}일 기록, 주말 지출 {weekend}원", weekly.len());
    println!("첫날 {}원, 마지막 날 {}원", weekly[0], weekly[weekly.len() - 1]);
    let zeros = [0; 3];
    println!("{zeros:?}");

    // 7. as 변환으로 값이 잘리는 경우
    let wide: i32 = 300;
    let narrow = wide as u8;
    println!("300 as u8 = {narrow}");
}

줄별 해설

1. 불변과 가변. month 는 mut 가 없어 끝까지 9 다. balance 는 mut 로 선언했으므로 += 와 -= 로 값을 바꾼다. 출력 문자열의 {balance} 는 같은 이름의 변수 값을 그 자리에 넣는다.

2. 섀도잉. 첫 memo 는 글자 조각이고, 둘째 let memo = memo.len(); 는 그 길이(6)를 새 memo 에 담는다. fee 는 같은 타입으로 섀도잉해 값만 가공한다. rate 는 블록 안에서만 30 으로 가려지고, 블록이 끝나면 3 이 다시 보인다.

3. 정수와 오버플로. count 는 u8 로 명시했다. 네 메서드는 같은 250 + 10 을 서로 다르게 처리한다. {:?} 는 개발자용 출력 형식으로 None 이나 Some(255) 를 그대로 보여 준다. overflowing_add 가 돌려주는 두 값은 튜플이며, let (value, overflowed) 로 바로 풀어 받았다. u8::MIN 과 i32::MAX 는 각 타입이 가진 경계 값이다. 이어지는 total / people 은 정수 나눗셈이고, as f64 를 붙인 쪽은 실수 나눗셈이다.

4. 부동소수점, bool, char. {with_tax:.2} 는 소수점 아래 두 자리까지 출력하라는 뜻이다. 12000 에 1.1 을 곱한 값은 이진 근사 때문에 13200 에서 아주 조금 벗어날 수 있으므로 자릿수를 정해 출력했다. size_of::<char>() 는 타입의 크기를 바이트로 알려 준다. len_utf8() 는 글자 하나가 UTF-8 로 몇 바이트인지 돌려준다.

5. 튜플. 타입 표기 (&str, char, i64) 는 세 요소의 타입을 순서대로 적은 것이다. 구조 분해로 세 값을 각각의 변수에 받고, 원본 entry 는 이후에도 쓸 수 있다. 요소가 모두 복사가 가능한 단순한 값이기 때문이다. 이 조건은 소유권 장에서 정확히 다룬다.

6. 배열. 리터럴이 모두 정수이므로 weekly 의 타입은 [i32; 7] 이다. weekly.len() - 1 은 마지막 인덱스이며, len() 이 usize 를 돌려주므로 인덱스로 바로 쓸 수 있다. {zeros:?} 는 배열 전체를 Debug 형식으로 출력한다.

7. as 변환. 300 은 u8 의 범위 밖이다. as 는 오류를 내지 않고 상위 비트를 잘라내며, 300 을 256 으로 나눈 나머지 44 가 남는다.

실행 결과

$ cargo run --quiet
9월 시작 잔액: 1200000원
9월 현재 잔액: 1501500원
메모 길이: 6
수수료 합계: 3750원
블록 안 rate: 30
블록 밖 rate: 3
wrapping_add: 4
saturating_add: 255
checked_add(10): None
checked_add(5): Some(255)
overflowing_add: 4 true
u8 범위: 0 ~ 255
i32 범위: -2147483648 ~ 2147483647
몫 3, 나머지 1
실수 나눗셈: 3.5
세금 포함: 13200.00
0.1 + 0.2 == 0.3 ? false
수입 여부: false, 부호: -, 기호: ₩
char 크기: 4 바이트, bool 크기: 1 바이트
'가' 는 UTF-8 로 3 바이트
점심 -8500
금액만: 8500
이번 주 7일 기록, 주말 지출 50000원
첫날 12000원, 마지막 날 9000원
[0, 0, 0]
300 as u8 = 44

--quiet 는 컴파일 진행 메시지를 감추고 프로그램 출력만 보여 준다. 이 코드는 오버플로 계산을 메서드로 명시했으므로 cargo run --release --quiet 로 실행해도 출력이 같다.

실무에서 자주 틀리는 것

1. 바뀌는 값을 mut 없이 선언한다

누적 합계처럼 값이 바뀌는 변수를 let 만으로 선언하면 재대입 오류가 난다.

// 틀린 코드
let total = 0;
total += 8_500;
// error[E0384]: cannot assign twice to immutable variable `total`
// 고친 코드
let mut total = 0;
total += 8_500;

바뀌지 않는 변수까지 mut 로 선언하면 읽는 사람이 값이 바뀐다고 오해한다. 컴파일러가 요구할 때만 붙이는 습관이 좋다.

2. mut 변수의 타입을 바꾸려 한다

mut 는 값을 바꿀 뿐 타입은 바꾸지 못한다.

// 틀린 코드
let mut amount = "8500";
amount = amount.len();
// error[E0308]: mismatched types (expected `&str`, found `usize`)
// 고친 코드
let amount = "8500";
let amount = amount.len();

타입이 달라지는 가공은 섀도잉으로 쓴다. 이때 이름은 그대로 두어도 되고, 뜻이 달라진다면 amount_len 처럼 새 이름을 붙이는 편이 더 읽기 좋다.

3. 다른 정수 타입을 섞고, as 로 잘라 버린다

// 틀린 코드
let a: i32 = 5;
let b: i64 = 6;
let sum = a + b;
// error[E0308]: mismatched types (expected `i32`, found `i64`)
// error[E0277]: cannot add `i64` to `i32`
// 고친 코드: 넓은 타입으로 맞춘다
let a: i32 = 5;
let b: i64 = 6;
let sum = i64::from(a) + b;

넓은 타입으로 옮길 때는 값이 보존되는 i64::from 을 쓴다. 반대로 as u8 처럼 좁은 타입으로 바꾸면 완성 코드의 300 → 44 처럼 조용히 값이 잘린다. 좁히는 변환은 값의 범위를 확인한 뒤에만 쓴다.

4. release 에서도 오버플로가 검사된다고 믿는다

// 틀린 코드: debug 에서는 panic, release 에서는 4 로 감김
fn add_days(days: u8, extra: u8) -> u8 {
    days + extra
}
// 고친 코드: 넘침을 값으로 받는다
fn add_days(days: u8, extra: u8) -> Option<u8> {
    days.checked_add(extra)
}

금액과 개수처럼 넘치면 안 되는 값은 checked_add 나 saturating_add 로 의도를 코드에 적는다. 반환 타입의 Option 은 뒤에서 자세히 배운다.

한눈에 보기

이 장에서 다룬 문법과 동작 요약
주제문법 또는 규칙기억할 점확인 방법
불변 변수let x = 1;재대입하면 컴파일 오류E0384
가변 변수let mut x = 1;값만 바뀌고 타입은 그대로x += 1
섀도잉let x = x + 1;새 변수가 이름을 가림, 타입 변경 가능블록이 끝나면 원래 값
정수 오버플로debug / releasepanic / 돌려 감김checked_add 등으로 명시
튜플(항목, 부호, 금액)타입이 달라도 되고 길이 고정t.0, 구조 분해
배열[i32; 7]타입이 같고 길이 고정a[0], a.len()
기본 값 타입 비교
타입리터럴 예크기주의
i32 / i641_200_0004 / 8바이트서로 자동 변환되지 않음
f6412_000.08바이트금액에는 쓰지 않음
booltrue1바이트숫자로 대신할 수 없음
char'₩'4바이트작은따옴표, 글자 한 개

표준 라이브러리에서 정수 타입의 메서드를 확인하려면 u8 의 공식 문서를 참고한다.

연습 문제

  1. 다음 코드는 컴파일되지 않는다. 오류 코드와 이유를 말하고 고쳐라.
    let count = 0;
    count += 1;
    println!("{count}");
  2. 다음 코드의 출력을 예상하라.
    let x = 5;
    let x = x + 1;
    {
        let x = x * 2;
        println!("{x}");
    }
    println!("{x}");
  3. let n: u8 = 200; 에 100 을 더할 때 wrapping_add, saturating_add, checked_add 의 결과를 각각 적어라.
  4. 튜플 ("월세", 500_000) 을 만들어 두 요소를 변수로 풀어 출력하고, 고정비 세 개(500000, 45000, 30000)를 담은 배열의 합계도 출력하는 프로그램을 작성하라.

정답과 해설

1. error[E0384]: cannot assign twice to immutable variable `count` 가 나온다. count 가 불변인데 += 로 값을 바꾸려 했기 때문이다. let mut count = 0; 으로 고친다.

2. 출력은 두 줄이다.

12
6

둘째 줄에서 x 는 6 이 된다. 블록 안에서 x * 2 로 만든 12 는 블록 안에서만 유효하고, 블록 밖에서는 6 인 x 가 다시 보인다.

3. 200 + 100 = 300 이다. wrapping_add 는 300 - 256 = 44, saturating_add 는 255, checked_add 는 None 이다.

4. 예시 답안이다.

fn main() {
    let rent = ("월세", 500_000);
    let (name, cost) = rent;
    let costs = [500_000, 45_000, 30_000];
    println!("{name} {cost}");
    println!("{}", costs[0] + costs[1] + costs[2]);
}

출력은 다음과 같다.

월세 500000
575000

튜플은 let (name, cost) = rent; 로 풀었고, 배열은 인덱스 0, 1, 2 로 꺼내 더했다. 요소 개수가 정해져 있고 인덱스가 범위 안이므로 panic 없이 끝난다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.