2024-07-14
HSPACE Rust 특강 #5 — 표현식
이번 회차는 문법 회차다. 그런데 "문법 훑기"가 아니라 표현식 언어라는 성질이 코드 모양을 어떻게 바꾸는가가 핵심이었다. 회차 끝에는 첫 과제도 나왔다.
표현식 언어
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
}
루프의 제어 흐름과 라벨
break 와 continue 는 익숙한데, 라벨이 유용했다.
'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 는 조건이 표현식이라, 컴파일러가 "혹시 거짓이 될 수도 있다"고 보고
루프 다음 경로까지 검사한다.
그리고 loop 는 break 로 값을 낼 수 있다. 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 = c 나 while (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 를 통과시키는 형태다.
- Fibonacci — 재귀. "이 함수는 언제 패닉하는가?"라는 질문이 붙어 있는데,
답은
n이 커질 때u32오버플로다. 디버그 빌드에서 패닉이 나므로 오버플로 회차 내용과 이어진다 - Collatz Sequence —
while+if표현식 - Nested Arrays —
[[i32; 3]; 3]의 전치. 중첩 배열의 타입을 정확히 쓰는 연습 - Role Playing Game —
Option필드와match - RPN Calculator — 스택과
Vec - Minesweeper — 2차원 인덱싱과 경계 처리.
usize인덱스라i-1이 언더플로할 수 있다는 게 함정이다 - Stock Simulator (BOJ 31001) — 백준 문제
Minesweeper에서 usize 언더플로에 제대로 걸렸다. 인덱스가 usize 이므로 0 - 1
이 거대한 수가 되는 게 아니라 디버그 빌드에서 패닉한다. i32 로 계산한 뒤
범위를 확인하고 usize 로 변환하거나, checked_sub 를 쓰는 식으로 풀어야 했다.
오버플로 정책 회차에서 배운 게 과제에서 바로 나온 셈이다.
정리
- 거의 모든 것이 표현식이고, 블록의 마지막 표현식이 그 블록의 값이다
- 세미콜론 하나가 "값을 낸다"와 "값을 버린다"를 가른다
match는 모든 경우를 검사하고, 패턴이 값을 분해한다loop는 발산 타입 덕에 흐름 분석에서 특별 대우를 받고,break로 값을 낸다- 대입이 값을 만들지 않으므로
=/==혼동 버그가 없다
다음 편은 오류 처리 — 패닉과 Result, 그리고 ? 연산자.