</>PatchNote
목록으로

2024-07-14

HSPACE Rust 특강 #5 — 표현식

RustHSPACE 2024ExpressionsControl Flow

이번 회차는 문법 회차다. 그런데 "문법 훑기"가 아니라 표현식 언어라는 성질이 코드 모양을 어떻게 바꾸는가가 핵심이었다. 회차 끝에는 첫 과제도 나왔다.

표현식 언어

C 계열 언어는 문장(statement)표현식(expression) 을 나눈다. if 는 문장이라 값이 없고, 그래서 값이 필요하면 삼항 연산자(? :)라는 별도 문법이 있다.

Rust에는 삼항 연산자가 없다. if 자체가 값이기 때문에 필요가 없다.

// C
// int status = disaster ? 1 : 0;

// Rust
let status = if disaster { 1 } else { 0 };

match 도 마찬가지다.

let msg = match code {
    200 => "OK",
    404 => "Not Found",
    _   => "Unknown",
};

과제 풀이에서도 이 성질이 자연스럽게 나온다.

pub fn collatz_length(mut n: i32) -> u32 {
    let mut ret = 1;

    while n > 1 {
        n = if n % 2 == 0 { n / 2 } else { 3 * n + 1 };
        ret += 1;
    }

    ret
}

n = if ... { } else { }; 한 줄로 끝난다. C였다면 if/else 안에서 각각 대입하거나 삼항 연산자를 썼을 자리다. "이 변수는 이 값들 중 하나가 된다" 는 의도가 대입 한 번에 담긴다.

블록과 세미콜론

여기가 처음 헷갈리는 부분이었다. 블록도 표현식이고, 마지막 표현식의 값이 블록의 값이 된다.

let x = {
    let a = 3;
    let b = 4;
    a * a + b * b     // 세미콜론 없음 → 이게 블록의 값
};                    // x == 25

세미콜론을 붙이면 값이 버려지고 블록은 ()(유닛 타입)가 된다.

let y = {
    let a = 3;
    a * a;            // 세미콜론 → 값 버림
};                    // y == ()

함수 반환도 같은 규칙이다.

fn square(n: i32) -> i32 {
    n * n       // 세미콜론 없음 → 반환값
}

fn square_broken(n: i32) -> i32 {
    n * n;      // 에러: 세미콜론 때문에 ()를 반환하게 됨
}

초반에 제일 많이 낸 컴파일 에러가 이거였다. expected i32, found () 가 뜨면 99%는 마지막 줄 세미콜론이다. 익숙해지고 나니 오히려 return 을 거의 안 쓰게 됐다.

선언

let 은 타입을 생략할 수 있고, 초기화를 나중에 할 수도 있다.

let x;                  // 타입도 값도 아직 없음

if condition {
    x = compute();      // 여기서 초기화
} else {
    x = fallback();
}

println!("{x}");        // 모든 경로에서 초기화되었으므로 OK

컴파일러가 모든 경로에서 정확히 한 번 초기화되었는지 검사한다. 한 갈래에서 빠뜨리면 used binding is possibly-uninitialized 에러가 난다. C에서 초기화 안 된 변수를 읽던 실수가 구조적으로 막힌다.

let 은 재선언(shadowing)도 된다.

let spaces = "   ";
let spaces = spaces.len();   // 이제 usize

같은 이름을 다른 타입으로 다시 묶는 것이라, spaces_str / spaces_len 처럼 어색한 이름을 짓지 않아도 된다.

if와 match

match 는 단순한 switch 가 아니다.

모든 경우를 다뤘는지 검사한다 (exhaustiveness)

match code {
    200 => "OK",
    404 => "Not Found",
    // 에러: 나머지 i32 값들이 처리되지 않음
}

_ 를 넣거나 전부 나열해야 컴파일된다. enum 에 변형을 하나 추가하면 그걸 처리하지 않은 모든 match 가 컴파일 에러로 튀어나온다. 리팩터링이 두렵지 않은 이유가 대부분 여기에 있다.

패턴이 값을 분해한다

과제 중 RPG 문제의 풀이가 좋은 예다.

pub fn cast_spell(&mut self, mana_cost: u32) -> u32 {
    match self.mana {
        Some(ref mut mana) => {
            if *mana < mana_cost {
                0
            } else {
                *mana -= mana_cost;
                2 * mana_cost
            }
        }
        None => {
            if self.health <= mana_cost {
                self.health = 0;
            } else {
                self.health -= mana_cost;
            }
            0
        }
    }
}

Some(ref mut mana) 는 "Option 안의 값을 가변으로 빌려서 mana 라는 이름을 붙여라"는 뜻이다. 마나가 있으면 소모하고, 없으면 체력을 대신 깎는 두 갈래가 match 로 깔끔하게 나뉜다. if self.mana != null 같은 검사 후 역참조가 아니라, 값의 존재 여부와 값 꺼내기가 한 번에 이루어진다.

if let 은 한 갈래만 필요할 때 쓰는 축약형이다.

if let Some(v) = maybe_value {
    println!("{v}");
}

루프

Rust의 반복문은 세 가지다.

while condition { }         // 조건이 참인 동안
loop { }                    // 무한 반복
for x in collection { }     // 이터레이터 순회

C의 for (i = 0; i < n; i++) 형태는 없다. 범위로 쓴다.

for i in 0..3 {         // 0, 1, 2
for i in 0..=3 {        // 0, 1, 2, 3
for (i, x) in v.iter().enumerate() {    // 인덱스가 필요하면

과제의 전치 행렬 풀이가 딱 이 형태다.

pub fn transpose(matrix: [[i32; 3]; 3]) -> [[i32; 3]; 3] {
    let mut ret = [[0; 3]; 3];

    for i in 0..3 {
        for j in 0..3 {
            ret[i][j] = matrix[j][i];
        }
    }

    ret
}

루프의 제어 흐름과 라벨

breakcontinue 는 익숙한데, 라벨이 유용했다.

'outer: for i in 0..n {
    for j in 0..m {
        if grid[i][j] == target {
            break 'outer;       // 바깥 루프까지 한 번에 탈출
        }
    }
}

중첩 루프를 빠져나오려고 플래그 변수를 만들던 습관이 사라진다.

Rust에 loop 가 따로 있는 이유

while true 로 충분한데 왜 loop 라는 키워드가 따로 있을까? 이게 이번 회차에서 제일 재미있는 부분이었다. 이유는 컴파일러의 흐름 분석 때문이다.

fn get_value() -> i32 {
    loop {
        if let Some(v) = try_read() {
            break v;        // loop는 break로 값을 낼 수 있다
        }
    }
    // 여기에 도달하지 않는다는 걸 컴파일러가 안다
}

loop 는 "break 없이는 절대 끝나지 않는다"는 것을 타입 시스템 수준에서 안다. 그래서 loop 뒤에 코드가 없어도 함수가 값을 반환한다고 인정해준다. 반면 while true 는 조건이 표현식이라, 컴파일러가 "혹시 거짓이 될 수도 있다"고 보고 루프 다음 경로까지 검사한다.

그리고 loopbreak 로 값을 낼 수 있다. while 은 못 한다.

let result = loop {
    let n = next_attempt();
    if n > 0 {
        break n;    // 이 값이 result가 된다
    }
};

"재시도하다가 성공하면 그 값을 쓴다"는 패턴이 임시 변수 없이 표현된다.

return과 발산 타입

return 은 그 자체로 표현식이고, 타입은 !(never, 발산 타입)이다.

let name = match input {
    Some(s) => s,
    None => return Err(Error::MissingName),   // 이 갈래는 값을 만들지 않는다
};

match 의 두 갈래는 타입이 같아야 하는데, return! 이고 !어떤 타입으로도 강제 변환된다. 그래서 위 코드가 성립한다. panic!(), std::process::exit(), 그리고 loop { } 도 같은 부류다.

이 덕분에 "실패하면 일찍 반환, 성공하면 값을 꺼내 계속"이라는 흐름을 표현식 하나로 쓸 수 있다.

연산자들

산술·비트·비교·논리 연산자는 C와 거의 같지만 차이가 있다.

  • 논리 연산자는 bool 만 받는다. if x 에서 x 가 정수면 에러다. 반드시 if x != 0 이라고 써야 한다. C에서 if (ptr) 같이 쓰던 관례가 없다
  • 단항 부정은 - 뿐이다. 비트 반전은 ~ 가 아니라 ! 이고, bool 에 쓰면 논리 부정, 정수에 쓰면 비트 반전이 된다
  • 오버플로 검사는 앞서 다룬 대로, 디버그 빌드에서 패닉
  • &&|| 는 단락 평가를 한다

대입에는 큰 차이가 하나 있다. Rust에서 대입은 값을 만들지 않는다. a = b = cwhile (n = read()) > 0 같은 관용구가 안 된다. === 를 헷갈려 생기는 고전적 버그가 아예 불가능해진다.

타입 캐스팅as 로만 하고 항상 명시적이다. 앞 회차에서 다뤘다.

레퍼런스 연산자 &, &mut, 역참조 * 도 표현식이다.

필드와 요소, 그리고 클로저

  • 필드 접근 s.field, 튜플 t.0, 인덱스 v[i]
  • 슬라이싱 &v[1..4], &v[..3], &v[2..]
  • 메서드 호출 x.method() — 자동으로 필요한 만큼 역참조/빌림한다

클로저는 마지막에 짧게 소개됐다.

let is_even = |x: i32| x % 2 == 0;
let add = |a, b| a + b;             // 타입 추론

클로저는 별도 회차에서 제대로 다루니, 여기서는 "함수도 값이다" 정도로 넘어갔다.

과제 #1

이 회차 끝에 첫 과제가 나왔다. 전부 cargo test 를 통과시키는 형태다.

  1. Fibonacci — 재귀. "이 함수는 언제 패닉하는가?"라는 질문이 붙어 있는데, 답은 n 이 커질 때 u32 오버플로다. 디버그 빌드에서 패닉이 나므로 오버플로 회차 내용과 이어진다
  2. Collatz Sequencewhile + if 표현식
  3. Nested Arrays[[i32; 3]; 3] 의 전치. 중첩 배열의 타입을 정확히 쓰는 연습
  4. Role Playing GameOption 필드와 match
  5. RPN Calculator — 스택과 Vec
  6. Minesweeper — 2차원 인덱싱과 경계 처리. usize 인덱스라 i-1 이 언더플로할 수 있다는 게 함정이다
  7. Stock Simulator (BOJ 31001) — 백준 문제

Minesweeper에서 usize 언더플로에 제대로 걸렸다. 인덱스가 usize 이므로 0 - 1 이 거대한 수가 되는 게 아니라 디버그 빌드에서 패닉한다. i32 로 계산한 뒤 범위를 확인하고 usize 로 변환하거나, checked_sub 를 쓰는 식으로 풀어야 했다. 오버플로 정책 회차에서 배운 게 과제에서 바로 나온 셈이다.

정리

  • 거의 모든 것이 표현식이고, 블록의 마지막 표현식이 그 블록의 값이다
  • 세미콜론 하나가 "값을 낸다"와 "값을 버린다"를 가른다
  • match 는 모든 경우를 검사하고, 패턴이 값을 분해한다
  • loop 는 발산 타입 덕에 흐름 분석에서 특별 대우를 받고, break 로 값을 낸다
  • 대입이 값을 만들지 않으므로 =/== 혼동 버그가 없다

다음 편은 오류 처리 — 패닉과 Result, 그리고 ? 연산자.