match 제어 흐름 구조

Rust는 match라는 매우 강력한 제어 흐름 구조를 제공한다. match는 값을 일련의 패턴과 비교한 후, 어떤 패턴이 일치하는지에 따라 코드를 실행한다. 패턴은 리터럴 값, 변수명, 와일드카드 등 다양한 요소로 구성될 수 있다. 19장에서는 모든 종류의 패턴과 그 기능에 대해 다룬다. match의 강점은 패턴의 표현력과 컴파일러가 모든 가능한 경우를 처리하도록 보장한다는 점이다.

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의 조건은 불리언 값으로 평가되어야 하지만, match는 어떤 타입이든 가능하다. 이 예제에서 coin의 타입은 첫 줄에서 정의한 Coin 열거형이다.

다음은 match의 각 가지(arm)이다. 각 가지는 패턴과 코드 두 부분으로 구성된다. 첫 번째 가지는 Coin::Penny 값을 패턴으로 사용하며, => 연산자를 통해 패턴과 실행할 코드를 구분한다. 여기서 코드는 단순히 1 값이다. 각 가지는 쉼표로 구분된다.

match 표현식이 실행되면, 결과 값을 각 가지의 패턴과 순서대로 비교한다. 패턴이 값과 일치하면 해당 패턴과 연결된 코드가 실행된다. 패턴이 값과 일치하지 않으면 다음 가지로 실행이 계속된다. 동전 분류기와 마찬가지로 필요한 만큼 가지를 추가할 수 있다. Listing 6-3에서는 네 개의 가지가 있다.

각 가지의 코드는 표현식이며, 일치하는 가지의 표현식 결과 값이 전체 match 표현식의 반환 값이 된다.

일반적으로 짧은 코드의 경우 중괄호를 사용하지 않는다. Listing 6-3에서 각 가지는 단순히 값을 반환한다. 만약 한 가지에서 여러 줄의 코드를 실행하고 싶다면 중괄호를 사용해야 하며, 이 경우 가지 뒤의 쉼표는 선택 사항이다. 예를 들어, 다음 코드는 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() {}

값에 바인딩하는 패턴

매치 갈래의 또 다른 유용한 기능은 패턴과 일치하는 값의 일부에 바인딩할 수 있다는 점이다. 이를 통해 열거형(enum) 변형에서 값을 추출할 수 있다.

예를 들어, 열거형의 변형 중 하나를 내부에 데이터를 포함하도록 변경해보자. 1999년부터 2008년까지 미국에서는 각 주마다 다른 디자인을 가진 쿼터 동전을 발행했다. 다른 동전에는 주 디자인이 없기 때문에, 쿼터만이 이 추가 값을 가진다. Quarter 변형을 UsState 값을 포함하도록 변경하면 이 정보를 열거형에 추가할 수 있다. 이를 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개 주의 쿼터를 모두 수집하려 한다고 상상해보자. 동전 종류별로 잔돈을 정리하면서, 각 쿼터와 관련된 주의 이름도 함께 알려주면 친구가 아직 가지고 있지 않은 쿼터를 수집할 수 있다.

이 코드의 매치 표현식에서, Coin::Quarter 변형과 일치하는 패턴에 state라는 변수를 추가한다. Coin::Quarter가 매치되면, state 변수는 해당 쿼터의 주 값에 바인딩된다. 그런 다음 이 갈래의 코드에서 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))를 호출하면, coinCoin::Quarter(UsState::Alaska)가 된다. 이 값을 각 매치 갈래와 비교할 때, Coin::Quarter(state)에 도달할 때까지 아무것도 일치하지 않는다. 그 시점에서 state의 바인딩은 UsState::Alaska 값이 된다. 이 바인딩을 println! 표현식에서 사용하여, Quarter 열거형 변형에서 내부의 주 값을 추출할 수 있다.

Option<T>와 매칭하기

이전 섹션에서 Option<T>를 사용할 때 Some 케이스 안에 있는 T 값을 꺼내고 싶었다. Coin 열거형에서 했던 것처럼 match를 사용해 Option<T>를 처리할 수도 있다. 동전을 비교하는 대신 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 함수 본문의 변수 xSome(5) 값을 갖게 된다. 그런 다음 이 값을 각 match 갈래와 비교한다:

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 패턴과 일치하지 않으므로 다음 갈래로 넘어간다:

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)와 일치하는가? 일치한다! 동일한 변형이다. iSome 안에 있는 값에 바인딩되므로, i는 값 5를 갖는다. 그런 다음 match 갈래의 코드가 실행되어, i 값에 1을 더하고 결과값 6을 포함한 새로운 Some 값을 생성한다.

이제 Listing 6-5에서 plus_one의 두 번째 호출을 고려해보자. 여기서 xNone이다. match에 들어가서 첫 번째 갈래와 비교한다:

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 값을 반환한다. 첫 번째 갈래가 일치했기 때문에 다른 갈래는 비교하지 않는다.

match와 열거형을 결합하는 것은 다양한 상황에서 유용하다. Rust 코드에서 이 패턴을 자주 보게 될 것이다: 열거형에 대해 match를 수행하고, 안에 있는 데이터에 변수를 바인딩한 다음, 이를 기반으로 코드를 실행한다. 처음에는 약간 까다로울 수 있지만, 익숙해지면 모든 언어에서 이 기능을 갖고 싶어질 것이다. 이는 꾸준히 사용자들이 좋아하는 기능 중 하나다.

모든 경우를 다뤄야 하는 match의 특징

match에 대해 다뤄야 할 또 다른 특징이 있다. 바로 모든 가능한 경우를 다뤄야 한다는 점이다. 아래는 버그가 있고 컴파일되지 않는 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 케이스를 처리하지 않았기 때문에 버그를 일으킨다. 다행히도, 러스트는 이런 버그를 잡아낼 수 있다. 이 코드를 컴파일하려고 하면 다음과 같은 에러가 발생한다:

$ 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/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/option.rs:572:1
 ::: /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/option.rs:576: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

러스트는 모든 가능한 케이스를 다루지 않았다는 것을 알고 있으며, 심지어 어떤 패턴을 빠뜨렸는지도 정확히 알고 있다! 러스트의 match모든 경우를 다뤄야 한다(exhaustive). 즉, 코드가 유효하려면 모든 가능성을 다뤄야 한다. 특히 Option<T>의 경우, 러스트는 None 케이스를 명시적으로 처리하지 않으면 이를 막아준다. 이는 우리가 값이 있을 것이라고 가정했을 때 실제로는 null이 있을 수 있는 상황을 방지해주며, 이전에 언급했던 ’10억 달러짜리 실수’를 불가능하게 만든다.

모든 경우를 포괄하는 패턴과 _ 플레이스홀더

열거형을 사용하면 특정 값에 대해 특별한 동작을 정의하고, 나머지 모든 값에 대해 기본 동작을 지정할 수 있다. 예를 들어, 주사위를 굴려 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은 리터럴 값 37에 해당한다. 나머지 모든 가능한 값을 포괄하는 마지막 armother라는 변수로 패턴을 정의한다. other arm에 해당하는 코드는 이 변수를 move_player 함수에 전달하여 사용한다.

이 코드는 u8 타입이 가질 수 있는 모든 값을 나열하지 않았음에도 컴파일된다. 마지막 패턴이 명시적으로 나열되지 않은 모든 값을 매칭하기 때문이다. 이 모든 경우를 포괄하는 패턴은 match가 모든 가능성을 다뤄야 한다는 요구 사항을 충족한다. 모든 경우를 포괄하는 arm은 반드시 마지막에 위치해야 한다는 점에 주의하자. 패턴은 순서대로 평가되기 때문에, 모든 경우를 포괄하는 arm을 앞에 두면 다른 arm은 절대 실행되지 않는다. 따라서 Rust는 모든 경우를 포괄하는 arm 뒤에 다른 arm을 추가하면 경고를 표시한다.

Rust는 모든 경우를 포괄하지만 해당 값을 사용하지 않을 때 사용할 수 있는 특별한 패턴도 제공한다. _는 어떤 값이든 매칭하지만 그 값에 바인딩하지 않는 패턴이다. 이 패턴은 해당 값을 사용하지 않을 것임을 Rust에게 알려주므로, 사용하지 않는 변수에 대한 경고를 피할 수 있다.

이제 게임 규칙을 변경해보자. 이제 3이나 7이 아닌 값이 나오면 다시 주사위를 굴려야 한다. 모든 경우를 포괄하는 값을 사용할 필요가 없으므로, 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에 단위 값(빈 튜플 타입)을 사용할 수 있다:

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() {}
}

여기서는 이전 arm에서 매칭되지 않은 다른 값을 사용하지 않을 것이며, 이 경우 어떤 코드도 실행하지 않을 것임을 Rust에게 명시적으로 알린다.

패턴과 매칭에 대해 더 자세한 내용은 19장에서 다룬다. 지금은 match 표현식이 다소 장황할 때 유용한 if let 문법으로 넘어가보자.