`match` 제어 흐름 구문
match 제어 흐름 구문 (The match Control Flow Construct)
Rust에는 match라는 매우 강력한 제어 흐름 구문이 있어요. 값을 일련의 패턴과 비교한 뒤, 어떤 패턴이 일치하는지에 따라 코드를 실행해요. 패턴은 리터럴 값, 변수 이름, 와일드카드, 그리고 그 밖의 여러 것들로 만들 수 있어요. 19장에서 다양한 패턴의 종류와 그 동작을 모두 다룰게요. match의 힘은 패턴의 표현력과, 컴파일러가 가능한 모든 경우가 처리되는지 확인해 준다는 사실에서 나와요.
출처: The Rust Book
match 표현식을 동전 분류기처럼 생각해 보세요. 동전이 크기가 제각각인 구멍이 뚫린 트랙을 따라 미끄러져 내려가는데, 각 동전은 자신이 들어맞는 첫 번째 구멍으로 떨어져요. 같은 방식으로 값은 match의 각 패턴을 지나가고, 값이 "들어맞는" 첫 번째 패턴에서 값은 실행 중 사용될 연관된 코드 블록으로 떨어져요.
동전 얘기가 나왔으니 동전을 match의 예시로 사용해 볼게요! 알 수 없는 미국 동전을 받아, 분류기처럼 그 동전이 무엇인지 판별해 센트 단위의 값을 반환하는 함수를 작성할 수 있어요. Listing 6-3을 보세요.
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
fn main() {}
Listing 6-3: 열거형과, 그 열거형의 변형들을 패턴으로 갖는 match 표현식
value_in_cents 함수 안의 match를 뜯어볼게요. 먼저 match 키워드를 쓰고 그 뒤에 표현식을 나열하는데, 이 경우엔 값 coin이에요. if와 함께 쓰이는 조건식과 아주 비슷해 보이지만, 큰 차이가 하나 있어요. if에서는 조건이 Boolean 값으로 평가되어야 하지만, 여기서는 어떤 타입이든 될 수 있어요. 이 예시에서 coin의 타입은 첫 줄에서 정의한 Coin 열거형이에요.
다음은 match arm들이에요. arm은 두 부분으로 이루어져요. 패턴과 코드죠. 여기서 첫 번째 arm은 값 Coin::Penny인 패턴을 갖고, 그다음에 패턴과 실행할 코드를 구분하는 => 연산자가 와요. 이 경우 코드는 그냥 값 1이에요. 각 arm은 다음 arm과 쉼표로 구분돼요.
match 표현식이 실행될 때 결과 값을 각 arm의 패턴과 순서대로 비교해요. 패턴이 값과 일치하면 그 패턴과 연관된 코드가 실행돼요. 패턴이 값과 일치하지 않으면 동전 분류기에서처럼 실행이 다음 arm으로 계속 진행돼요. 필요한 만큼 많은 arm을 가질 수 있어요. Listing 6-3에서 우리의 match는 arm이 네 개예요.
각 arm과 연관된 코드는 표현식이고, 일치하는 arm에 있는 표현식의 결과 값이 match 표현식 전체에서 반환되는 값이에요.
Listing 6-3처럼 각 arm이 그냥 값을 반환하는 짧은 코드라면 arm에 중괄호를 보통 쓰지 않아요. match arm에서 여러 줄의 코드를 실행하고 싶다면 중괄호를 써야 하고, 그러면 arm 뒤의 쉼표는 선택 사항이 돼요. 예를 들어 다음 코드는 Coin::Penny로 메서드가 호출될 때마다 "Lucky penny!"를 출력하지만, 여전히 블록의 마지막 값인 1을 반환해요.
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => {
println!("Lucky penny!");
1
}
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
fn main() {}
값에 바인딩되는 패턴
match arm의 또 다른 유용한 기능은 패턴과 일치하는 값의 일부에 바인딩될 수 있다는 거예요. 이것이 열거형 변형에서 값을 추출하는 방법이에요.
예시로 열거형 변형 중 하나가 내부에 데이터를 담도록 바꿔 볼게요. 미국은 1999년부터 2008년까지, 50개 주 각각에 대해 한쪽 면에 서로 다른 디자인을 넣은 쿼터를 주조했어요. 다른 동전에는 주 디자인이 없어서, 이 추가 값을 가지는 건 쿼터뿐이에요. Quarter 변형이 내부에 UsState 값을 저장하도록 바꿔서 이 정보를 우리 enum에 추가할 수 있어요. Listing 6-4에서 그렇게 했어요.
#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
Alabama,
Alaska,
// --snip--
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
fn main() {}
Listing 6-4: Quarter 변형이 UsState 값도 담는 Coin 열거형
친구가 50개 주 쿼터를 전부 모으려고 한다고 상상해 볼게요. 우리가 잔돈을 동전 종류별로 분류하는 동안 각 쿼터와 연관된 주의 이름도 불러 주면, 친구가 없는 주가 나오면 컬렉션에 추가할 수 있겠죠.
이 코드의 match 표현식에서 Coin::Quarter 변형의 값과 일치하는 패턴에 state라는 변수를 추가해요. Coin::Quarter가 일치하면 state 변수가 그 쿼터의 주 값에 바인딩돼요. 그러면 그 arm의 코드에서 state를 이렇게 사용할 수 있어요.
#[derive(Debug)]
enum UsState {
Alabama,
Alaska,
// --snip--
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter(state) => {
println!("State quarter from {state:?}!");
25
}
}
}
fn main() {
value_in_cents(Coin::Quarter(UsState::Alaska));
}
만약 value_in_cents(Coin::Quarter(UsState::Alaska))를 호출하면 coin은 Coin::Quarter(UsState::Alaska)가 돼요. 그 값을 각 match arm과 비교할 때, Coin::Quarter(state)에 도달하기 전까지는 어느 arm도 일치하지 않아요. 그 시점에 state의 바인딩은 UsState::Alaska 값이 돼요. 그러면 println! 표현식에서 그 바인딩을 사용할 수 있게 되어, Quarter에 대한 Coin 열거형 변형에서 내부의 주 값을 꺼내 오게 돼요.
Option match 패턴
이전 절에서 Option<T>를 쓸 때 Some 케이스에서 내부의 T 값을 꺼내 오고 싶었어요. Option<T>도 Coin 열거형에서 했던 것처럼 match로 처리할 수 있어요! 동전을 비교하는 대신 Option<T>의 변형들을 비교하지만, match 표현식이 동작하는 방식은 똑같아요.
Option<i32>를 받아서 그 안에 값이 있으면 그 값에 1을 더하는 함수를 작성하고 싶다고 해 볼게요. 안에 값이 없다면 함수는 None 값을 반환하고 어떤 연산도 수행하려고 하지 말아야 해요.
이 함수는 match 덕분에 아주 쉽게 작성할 수 있어요. Listing 6-5처럼 생겼어요.
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
Listing 6-5: Option<i32>에 match 표현식을 사용하는 함수
plus_one의 첫 번째 실행을 더 자세히 살펴볼게요. plus_one(five)를 호출하면 plus_one 본문의 변수 x는 값 Some(5)를 가져요. 그러면 그것을 각 match arm과 비교해요.
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
Some(5) 값은 패턴 None과 일치하지 않으므로 다음 arm으로 계속 진행해요.
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
Some(5)가 Some(i)와 일치할까요? 일치해요! 같은 변형이니까요. i가 Some 안에 담긴 값에 바인딩되므로 i는 값 5를 가져요. 그러면 match arm의 코드가 실행되어 i의 값에 1을 더하고, 총합 6을 안에 담은 새 Some 값을 만들어요.
이제 Listing 6-5의 plus_one 두 번째 호출을 살펴볼게요. 여기서 x는 None이에요. match에 진입해서 첫 번째 arm과 비교해요.
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
일치해요! 더할 값이 없으므로 프로그램은 멈추고 => 오른쪽의 None 값을 반환해요. 첫 번째 arm이 일치했으므로 나머지 arm은 비교되지 않아요.
match와 열거형을 결합하는 것은 여러 상황에서 유용해요. Rust 코드에서 이 패턴을 자주 보게 될 거예요. 열거형에 대해 match를 하고, 내부의 데이터에 변수를 바인딩한 다음, 그것에 기반해 코드를 실행하는 것이죠. 처음에는 조금 까다롭지만, 익숙해지면 모든 언어에 이게 있으면 좋겠다고 생각하게 될 거예요. 사용자들이 꾸준히 가장 좋아하는 기능이에요.
match는 철저하다(Exhaustive)
우리가 논의해야 할 match의 또 다른 측면이 있어요. arm의 패턴이 모든 가능성을 다루어야 한다는 점이에요. 버그가 있어서 컴파일되지 않는 plus_one 함수의 이 버전을 생각해 볼게요.
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
우리는 None 경우를 처리하지 않았으므로, 이 코드는 버그를 일으킬 거예요. 다행히도 Rust가 잡아낼 줄 아는 버그예요. 이 코드를 컴파일하려고 하면 다음과 같은 에러가 나와요.
$ cargo run
Compiling enums v0.1.0 (file:///projects/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
--> src/main.rs:3:15
|
3 | match x {
| ^ pattern `None` not covered
|
note: `Option<i32>` defined here
--> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/core/src/option.rs:593:1
::: /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/core/src/option.rs:597:5
|
= note: not covered
= note: the matched value is of type `Option<i32>`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|
4 ~ Some(i) => Some(i + 1),
5 ~ None => todo!(),
|
For more information about this error, try `rustc --explain E0004`.
error: could not compile `enums` (bin "enums") due to 1 previous error
Rust는 우리가 가능한 모든 경우를 다루지 않았다는 것도 알고, 심지어 어떤 패턴을 잊었는지도 알아요! Rust의 match는 철저합니다(exhaustive). 코드가 유효하려면 마지막 가능성 하나까지 전부 처리해야 해요. 특히 Option<T>의 경우, Rust가 None 경우를 명시적으로 처리하는 걸 잊지 못하게 막아 줄 때, 우리는 값이 없을 수도 있는데 값이 있다고 가정하는 일로부터 보호받게 돼요. 이로써 앞서 논의한 억만 달러짜리 실수(billion-dollar mistake)가 불가능해지죠.
Catch-All 패턴과 _ 자리표시자
열거형을 사용하면 특정한 몇몇 값에 대해서는 특별한 동작을 하고, 다른 모든 값에 대해서는 하나의 기본 동작을 취할 수도 있어요. 주사위를 굴려 3이 나오면 플레이어가 움직이지 않고 대신 멋진 새 모자를 얻는 게임을 구현한다고 상상해 볼게요. 7이 나오면 플레이어는 멋진 모자를 하나 잃어요. 다른 모든 값에서는 플레이어가 게임판에서 그 숫자만큼 이동해요. 이 로직을 구현하는 match를 볼게요. 주사위 굴림 결과는 무작위 값이 아니라 하드코딩되어 있고, 나머지 로직은 이 예시의 범위를 벗어나므로 본문이 없는 함수들로 표현되어 있어요.
fn main() {
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
other => move_player(other),
}
fn add_fancy_hat() {}
fn remove_fancy_hat() {}
fn move_player(num_spaces: u8) {}
}
처음 두 arm에서는 패턴이 리터럴 값 3과 7이에요. 다른 모든 가능한 값을 다루는 마지막 arm에서는 패턴이 우리가 other라고 이름 지은 변수예요. other arm에 실행되는 코드는 move_player 함수에 전달해 그 변수를 사용해요.
u8이 가질 수 있는 모든 가능한 값을 나열하지는 않았지만 이 코드는 컴파일돼요. 마지막 패턴이 명시적으로 나열되지 않은 모든 값을 일치시키기 때문이에요. 이 catch-all 패턴이 match가 철저해야 한다는 요구를 충족시켜 줘요. catch-all arm은 반드시 마지막에 두어야 한다는 점에 유의하세요. 패턴은 순서대로 평가되니까요. catch-all arm을 더 앞에 두면 다른 arm들이 절대 실행되지 않으므로, Rust는 catch-all 뒤에 arm을 추가하면 경고를 해줘요!
Rust에는 catch-all을 원하지만 catch-all 패턴의 값을 사용하고 싶지 않을 때 쓸 수 있는 패턴도 있어요. _는 어떤 값이든 일치하고 그 값에 바인딩하지 않는 특별한 패턴이에요. 이는 Rust에게 값을 사용하지 않을 것임을 알려주므로, Rust는 미사용 변수에 대해 경고하지 않아요.
게임의 규칙을 바꿔 볼게요. 이제 3이나 7이 아닌 값을 굴리면 다시 굴려야 해요. catch-all 값을 더 이상 사용할 필요가 없으므로, other라는 변수 대신 _를 사용하도록 코드를 바꿀 수 있어요.
fn main() {
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
_ => reroll(),
}
fn add_fancy_hat() {}
fn remove_fancy_hat() {}
fn reroll() {}
}
이 예시도 철저성 요구를 충족시켜요. 마지막 arm에서 다른 모든 값을 명시적으로 무시하고 있으니, 아무것도 잊지 않은 거예요.
마지막으로 게임 규칙을 한 번 더 바꿔서, 3이나 7이 아닌 값을 굴리면 차례에 아무 일도 일어나지 않게 할게요. _ arm과 함께 쓰는 코드로 유닛 값(unit value, "튜플 타입" 절에서 언급한 빈 튜플 타입)을 사용해 그걸 표현할 수 있어요.
fn main() {
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
_ => (),
}
fn add_fancy_hat() {}
fn remove_fancy_hat() {}
}
여기서 우리는 Rust에게 앞선 arm의 패턴과 일치하지 않는 다른 어떤 값도 사용하지 않고, 이 경우에 어떤 코드도 실행하고 싶지 않다는 것을 명시적으로 알려줘요.
패턴과 매칭에 대해 다룰 내용이 19장에 더 있어요. 지금은 match 표현식이 조금 장황할 때 유용한 if let 문법으로 넘어갈게요.