Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

match 제어 흐름 구문

러스트에는 match 라는 매우 강력한 제어 흐름 구문이 있습니다. 이 구문은 어떤 값을 일련의 패턴과 비교한 뒤, 어떤 패턴이 맞는지에 따라 코드를 실행하게 해 줍니다. 패턴은 리터럴 값, 변수 이름, 와일드카드 등 다양한 것으로 이루어질 수 있으며, 19장에서 패턴 종류와 역할을 자세히 다룹니다. match 의 강력함은 패턴이 매우 표현력이 높다는 점과, 컴파일러가 가능한 모든 경우가 처리되었는지 확인해 준다는 점에서 나옵니다.

match 식을 동전 분류 기계라고 생각해도 좋습니다. 동전이 여러 크기의 구멍이 뚫린 레일을 따라 내려오다가, 들어갈 수 있는 첫 번째 구멍을 만나면 그곳으로 떨어집니다. 마찬가지로 값도 match 의 각 패턴을 차례로 통과하고, 값이 “맞는” 첫 번째 패턴을 만나면 그와 연결된 코드 블록으로 떨어져 실행됩니다.

마침 동전 이야기가 나왔으니, match 예제로 동전을 사용해 봅시다! 정체를 모르는 미국 동전을 받아서, 분류 기계처럼 그것이 어떤 동전인지 판별하고 몇 센트인지 반환하는 함수를 목록 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: enum의 variant를 패턴으로 가지는 enum과 match

value_in_cents 함수 안의 match 를 하나씩 뜯어 봅시다. 먼저 match 키워드를 쓰고, 그 뒤에 식을 놓습니다. 여기서는 값 coin 입니다. 겉보기에는 if 와 함께 사용하는 조건식과 비슷해 보이지만, 큰 차이가 있습니다. if 는 조건이 반드시 불리언으로 평가되어야 하지만, 여기서는 어떤 타입이든 올 수 있습니다. 이 예제에서 coin 의 타입은 첫 줄에서 정의한 Coin enum 입니다.

그다음은 match arm 들입니다. 각 arm은 패턴과 코드, 두 부분으로 이루어집니다. 첫 번째 arm에는 Coin::Penny 라는 패턴이 있고, 그 뒤에 패턴과 실행할 코드를 구분하는 => 연산자가 있습니다. 여기서 코드는 단순히 값 1 입니다. 각 arm은 쉼표로 구분됩니다.

match 식이 실행되면, 주어진 값은 각 arm의 패턴과 순서대로 비교됩니다. 어떤 패턴이 값과 맞으면 그 패턴에 연결된 코드가 실행됩니다. 맞지 않으면 다음 arm으로 넘어갑니다. 동전 분류 기계와 같은 방식입니다. 필요한 만큼 arm을 둘 수 있으며, 목록 6-3에는 네 개의 arm이 있습니다.

각 arm에 연결된 코드는 하나의 식이며, 매칭된 arm 식의 결과값이 전체 match 식의 결과가 됩니다.

보통 목록 6-3처럼 각 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의 또 다른 유용한 기능은, 패턴이 매칭된 값 안의 일부를 변수에 바인딩할 수 있다는 점입니다. 이 방식으로 enum variant 안의 값을 꺼낼 수 있습니다.

예를 들어, enum variant 하나가 내부에 데이터를 갖도록 바꿔 봅시다. 1999년부터 2008년까지 미국은 50개 주 각각에 대해 다른 디자인을 한쪽 면에 새긴 quarter를 발행했습니다. 다른 동전에는 주 디자인이 없으므로, quarter만 이 추가 정보를 갖게 하면 됩니다. 목록 6-4처럼 Quarter variant 안에 UsState 값을 저장하도록 enum 을 변경할 수 있습니다.

#[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 variant 안에 UsState 값을 저장하는 Coin enum

친구가 50개 주 quarter를 전부 모으고 있다고 상상해 봅시다. 우리는 동전 종류별로 잔돈을 분류하면서, quarter가 나올 때마다 어떤 주의 것인지 이름도 함께 말해 줄 수 있습니다. 그러면 친구가 아직 없는 quarter라면 수집품에 추가할 수 있겠지요.

이 코드의 match 식에서는 Coin::Quarter variant에 매칭되는 패턴에 state 라는 변수를 추가합니다. 어떤 Coin::Quarter 가 매칭되면, state 변수는 그 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)) 를 호출했다면, coinCoin::Quarter(UsState::Alaska) 가 됩니다. 이 값을 각 match arm과 비교해 보면, Coin::Quarter(state) 에 도달하기 전까지는 어느 것도 맞지 않습니다. 그 지점에서 stateUsState::Alaska 값에 바인딩됩니다. 그 다음 println! 식 안에서 이 바인딩을 사용해 Quarter 안의 실제 주 값만 꺼내 쓸 수 있습니다.

Option<T>match 패턴

앞 절에서는 Option<T> 를 사용할 때 Some 안쪽의 T 값을 꺼내고 싶다고 이야기했습니다. 이것 역시 Coin enum을 다룰 때처럼 match 로 처리할 수 있습니다! 동전을 비교하는 대신 Option<T> 의 variant를 비교하는 것이고, match 식의 동작 방식은 그대로 같습니다.

예를 들어 Option<i32> 를 받아서 값이 있으면 그 값에 1을 더하고, 값이 없으면 None 을 그대로 반환하는 함수를 작성하고 싶다고 합시다.

이 함수는 match 덕분에 아주 쉽게 쓸 수 있으며, 목록 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 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) 와 맞을까요? 맞습니다! 같은 variant이기 때문입니다. iSome 안의 값에 바인딩되고, 따라서 i 는 값 5 를 갖습니다. 그러면 이 arm의 코드가 실행되어 i 에 1을 더하고, 합계 6 을 담은 새 Some 값이 만들어집니다.

이제 목록 6-5에서 xNone 인 두 번째 호출을 생각해 봅시다. 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 와 enum을 조합하는 방식은 여러 상황에서 유용합니다. 러스트 코드에서는 이 패턴을 아주 자주 보게 될 것입니다. enum에 대해 match 를 하고, 안의 데이터를 변수에 바인딩한 뒤, 그것을 바탕으로 코드를 실행하는 방식입니다. 처음에는 약간 헷갈릴 수 있지만, 익숙해지고 나면 모든 언어에 이런 기능이 있기를 바라게 될지도 모릅니다. 실제로 많은 사용자가 아주 좋아하는 기능입니다.

match 는 모든 경우를 다루어야 한다

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 경우를 처리하지 않았기 때문에 이 코드는 버그를 만듭니다. 다행히 이것은 러스트가 아주 잘 잡아내는 종류의 버그입니다. 이 코드를 컴파일하려 하면 다음 오류가 나옵니다.

$ 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

러스트는 우리가 가능한 모든 경우를 다루지 않았다는 사실을 알고 있고, 심지어 어떤 패턴을 빠뜨렸는지도 알고 있습니다! 러스트의 match완전해야(exhaustive) 합니다. 코드가 유효하려면 가능한 경우를 하나도 빠짐없이 다루어야 합니다. 특히 Option<T> 의 경우, 러스트는 None 경우를 명시적으로 처리하는 것을 잊지 못하게 함으로써, 값이 없을지도 모르는데 있다고 가정하는 실수를 막아 줍니다. 앞에서 이야기한 “10억 달러짜리 실수”가 여기서는 일어날 수 없게 되는 것입니다.

catch-all 패턴과 _ 플레이스홀더

enum을 사용할 때, 몇몇 특정 값에 대해서만 특별한 동작을 하고 나머지 모든 값에 대해서는 기본 동작을 하고 싶을 수도 있습니다. 예를 들어 어떤 게임을 구현하는데, 주사위를 굴려 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은 절대로 실행되지 않을 것이므로, 러스트는 catch-all 뒤에 arm을 추가하면 경고합니다.

러스트에는 catch-all을 원하되, 그 값 자체를 사용하고 싶지 않을 때 쓰는 특별한 패턴도 있습니다. _ 는 어떤 값과도 매칭되지만, 그 값에 이름을 바인딩하지 않습니다. 이것은 “우리는 이 값을 사용하지 않을 것이다”라고 러스트에게 알려 주는 것이며, 따라서 러스트는 사용하지 않는 변수에 대한 경고도 하지 않습니다.

게임 규칙을 조금 바꿔 봅시다. 이제 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과 함께 유닛 값(3장의 “튜플 타입” 절에서 언급한 빈 튜플 타입)을 코드로 쓰면 표현할 수 있습니다.

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의 패턴과 맞지 않는 다른 모든 값은 사용하지 않겠고, 그 경우에는 아무 코드도 실행하고 싶지 않다고 러스트에게 명시적으로 말하고 있는 것입니다.

패턴과 매칭에 대해서는 19장에서 더 많은 내용을 다룹니다. 지금은 조금 장황하게 느껴질 수 있는 상황에서 유용한 if let 문법으로 이제 넘어가 보겠습니다.