Programming/Rust

쪼잔한 Rust 6. Enum과 패턴 맞추기

동건 2018. 9. 3. 23:27

Chapter 6. Enum과 패턴 맞추기

이번 챕터에서는 enumeration, 또는 enum이라고 부르는 것에 대해 알아보고자 한다. Enum은 가능한 경우의 값들을 헤아려서(enumerate) 그 종류를 정의할 수 있게 해준다. 우선, enum을 정의하고 사용해 봄으로써 enum이 어떻게 데이터와 연계되는 의미들을 코드에 새기는지 볼 것이다. 그 다음에는, 그 중에서도 특별히 유용한 enum인 Option을 알아볼 것인데, Option은 그 값이 무언가 있거나 없거나 할 수 있는 표현을 가능하게 한다. 그러고 나서는 match expression을 통한 pattern matching이 enum의 여러 값들을 다루기 위한 다른 종류의 코드를 얼마나 쉽게 다루는지 보게 될 것이다. 마지막으로, if let이 enum을 다루기에 어떻게 편리하고 간결한 표현이 되는지 보고 마칠 것이다.

Enum은 여러 언어에서 제공하는 기능인데, 각 언어마다 그 기능이 조금씩 다르다. Rust의 enum은 F#이나 OCaml, Haskell과 같은 함수형 언어에서의 algebraic 데이터 타입에 가장 비슷하다고 이야기할 수 있다.




Enum 정의하기

우선 enum이 struct보다 더 유용하고 적절할 상황을 생각해보려고 한다. IP 주소를 사용하는 경우를 보자. 현재 IP 주소는 두 가지 주요 버전 (버전 4와 버전 6)가 있다. 이 두 가지 경우가 프로그램이 다루게 될 모든 경우이다. 우리는 이렇게 모든 가능한 값들을 헤아리고(enumerate) 싶을 때, enum은 그 값들의 이름을 인식하게 된다.

IP 주소는 버전 4이든지 버전 6이든지 둘 중 하나의 버전일 것이 분명하며, 두 버전이 동시에 해당될 수는 없다. Enum은 그 값을 오직 하나만 택할 수 있기 때문에, 이러한 특성은 enum 데이터 구조를 쓰기에 적절한 상황이라고 볼 수 있다. 버전 4와 버전 6의 IP 주소 모두 (당연하게도) 기본적으로 IP 주소이므로, 이 둘은 IP 주소를 다루는 코드에서 같은 타입으로 취급해야 한다.

이러한 IP 주소의 컨셉을 IpAddrKind enum을 정의하고 그 가능한 종류들을 나열함으로써 표현할 수 있다. 그 종류 V4와 V6는 enum이 가질 수 있는 값(variants)으로 알고 있어야 하는 것이다.

enum IpAddrKind {
    V4,
    V6,
}

이제 IpAddrKind는 우리가 다른 코드에서 사용할 수 있는 데이터 타입이 되었다.


Enum의 값

IpAddrKind가 가질 수 있는 두 종류의 값을 아래와 같이 만들 수 있다.

let four = IpAddrKind::V4;
let six = IpAddrKind::V6;

Enum의 값들을 그 상위 식별자의 이름공간(namespace)에 속해 있어서, 더블 콜론을 사용해서 그 이름공간을 분리해줘야 함을 알아두자. 이러한 표현은 두 값 V4V6가 모두 IpAddrKind라는 같은 타입임을 알 수 있다는 점에서 유용하다. 따라서 IpAddrKind 타입을 받는 함수를 정의하면,

fn route(ip_type: IpAddrKind) { }

이 enum의 모든 종류의 값을 받을 수 있는 함수로 쓸 수 있는 것이다.

route(IpAddrKind::V4);
route(IpAddrKind::V6);

Enum은 이것 말고도 다른 장점이 있다. 우리가 방금 만든 IP 주소 타입에 더 생각해보면, 우리는 실제 IP 주소의 데이터를 저장하지는 않았고, 단지 그 종류가 무엇인지를 나타낼 뿐이었다. 이전 챕터 5에서 배운 struct를 활용해보면, 이 문제를 아래 예제 6-1처럼 해결할 수 있다.

예제 6-1: struct를 이용해서 IP 주소의 종류 IpAddrKind와 그 데이터 저장하기

enum IpAddrKind {
    V4,
    V6,
}

struct IpAddr {
    kind: IpAddrKind,
    address: String,
}

let home = IpAddr {
    kind: IpAddrKind::V4,
    address: String::from("127.0.0.1"),
};

let loopback = IpAddr {
    kind: IpAddrKind::V6,
    address: String::from("::1"),
};

여기서 우리는 IpAddr이라는 struct와 그 필드 두 개를 정의했다: IP 주소의 종류와 그 값. 이렇게 enum의 종류에 따르는 값을 연관시킬 수 있음을 보았다.

위와 같은 개념을 enum만 사용하는 훨씬 간결한 방법으로 표현할 수 있다. Enum이 가지는 종류에 직접 데이터를 넣을 수 있는 것이다. 아래 새로운 enum IpAddr은 그 종류 V4V6가 모두 String 값과 연계되어 있을 것이라 말해준다.

enum IpAddr {
    V4(String),
    V6(String),
}

let home = IpAddr::V4(String::from("127.0.0.1"));

let loopback = IpAddr::V6(String::from("::1"));

Enum의 종류들에 직접 데이터를 붙이므로, 추가적인 struct가 필요 없어졌다.

Enum이 struct보다 좋은 점이 하나 더 있다: Enum의 각 종류들에 연결되는 데이터의 타입은 각각 다를 수 있다는 점이다. struct는 아래처럼 사용할 수가 없다.

enum IpAddr {
    V4(u8, u8, u8, u8),
    V6(String),
}

let home = IpAddr::V4(127, 0, 0, 1);

let loopback = IpAddr::V6(String::from("::1"));

그동안 두 종류의 IP 주소를 저장하는 데이터 구조를 정의하는 여러 방법을 봐왔다. 그런데 사실, IP 주소에 대한 정의는 기본 라이브러리에서 제공하고 있다! 기본 라이브러리에서도 위와 똑같이 enum에 데이터를 연결하는 방식을 사용하는데, 그 데이터 타입을 두 가지의 struct으로 사용하고 있다.

struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}

위 코드는 enum 종류에 연결되는 데이터가 어떤 종류이든 (string이든, 숫자 타입, struct든지) 상관없다는 것을 보여준다. 또 다른 enum을 붙일 수도 있다! 여담이지만, 기본 라이브러리가 제공하는 타입들은 당신이 생각하는 것만큼 그렇게 복잡하지 않을 경우가 종종 있을 것이다.

이제 예제 6-2에서 enum의 새로운 예제를 볼 것이다. 이 enum은 그 종류가 굉장히 다양하다.

예제 6-2: 다양한 종류의 값을 가지는 Message enum

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

이 enum의 네 가지 종류들은 각기 다른 타입의 데이터와 연결된다:

  • Quit은 연결될 데이터가 전혀 없다.
  • Move는 익명의 struct를 갖는다.
  • Write는 하나의 String을 갖는다.
  • ChangeColor는 세 개의 i32 값을 갖는다.

예제 6-2의 enum 정의는 기본 라이브러리에서의 IP 주소처럼 각기 종류에 따른 struct를 정의해서 사용하는 것과 같지만, struct 키워드를 사용하지 않고 Message 타입에 묶일 수 있다. 아래는 똑같은 enum 종류를 struct를 사용해서 정의한 것이다.

struct QuitMessage; // unit struct
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct

하지만 이렇게 여러 struct를 사용한다면, 우리는 이 message 종류를 받는 함수를 예제 6-2에서 단 하나의 타입인 Message enum을 사용하는 것처럼 쉽게 정의할 수 없을 것이다.

Enum과 struct 사이에 또 하나의 비슷한 점이 있다. impl 키워드를 사용해서 method를 정의할 수 있다는 것이다.

impl Message {
    fn call(&self) {
        // method body would be defined here
    }
}

let m = Message::Write(String::from("hello"));
m.call();

이 method는 self를 통해서 우리가 method를 호출하고자 하는 enum의 값을 가져올 수 있다. 위 예제에서 Message::Write(String::from("hello"))라는 값을 가지는 변수 m을 만들었는데, m.call()을 호출할 때의 함수 body에서 self는 그 값 m이 되는 것이다.

이제 기본 라이브러리에서 제공하는 매우 흔하게 그리고 유용하게 쓰이는 enum인 Option에 대해 알아보자.


Option Enum이 Null 값에 비해 가지는 이점

바로 전에 우리는 IpAddr enum이 어떻게 Rust의 타입 시스템을 통해 단지 데이터 뿐 아닌 그 이상의 정보를 프로그램에 새기는지 보았다. 이번에 볼 Option enum은 그 종류가 무언가 있거나(something) 없거나(or nothing) 할 수 있는 굉장히 흔한 시나리오를 새길 수 있기 때문에 많은 곳에서 쓰이게 된다. 타입 시스템 안에서 이러한 개념을 표현한다는 것은 컴파일러가 당신이 가능한 모든 경우를 잘 처리했는지 검사한다는 것을 의미한다. 이런 기능을 통해 Rust는 다른 언어에서 굉장히 흔하게 발생할 수 있는 버그를 예방해준다.

프로그래밍 언어를 설계한다는 것은 종종 "어떤 기능을 넣을 것이냐"의 의미로 생각되기도 하는데, "어떤 기능을 제외할 것이냐" 역시 중요하게 생각해야 한다. Rust는 여타 언어가 지원하는 null의 개념을 제공하지 않는다. Null은 그 값이 없다는 의미의 값이다. Null을 지원하는 언어에서, 변수는 항상 두 가지 중 하나의 상태이다: null이거나 null이 아니거나.

Null 개념의 창시자인 Tony Hoare는 2009년 그의 발표 "Null References: The Billion Dollar Mistake"에서 이렇게 이야기했다.

나는 이것을 내가 초래한 10억 불짜리 실수라고 이야기한다. 그 당시에 나는 object-oriented 언어에서의 reference를 위한 첫 번째 포괄적인 타입 시스템을 설계하고 있었다. 나의 목표는 모든 reference가 절대적으로 안전한 것을 보장하는 것이었는데, 그 검사는 컴파일러가 자동적으로 수행함으로써 이루어지도록 생각했었다. 하지만 나는 null reference를 만드는 유혹을 이겨내지 못했는데, 그 이유는 단순하게도 그게 만들기 쉬웠기 때문이다. 그리고 이 null reference는 셀 수 없이 많은 에러, 취약점을 유발했고 시스템을 쉽게 깨지게 했다. 아마도 지난 40여년 동안 그 피해를 액수로 따지면 10억 불 정도가 되지 않을까 싶다.

Null 값을 null이 아닌 값으로 쓰려고 한다면, 에러가 난다는 점이 null 값의 문제이다. Null이거나 null이 아니거나 하는 성질은 만연하게 존재하는 것이기 때문에, 이러한 에러가 나는 것은 매우 쉬운 일이다.

하지만 null이 표현하고자 하는 개념 자체는 그래도 쓸모가 있다. Null은 어떠한 이유로 인해 타당하지 않거나 존재하지 않는 값을 나타내는 개념이다.

그래서 문제는 그 개념 자체가 아니라 특정한 구현에 대한 문제라고 볼 수 있다. Rust는 null이 없지만, null의 개념을 새길 수 있는 enum을 가지고 있다. 그 enum이 기본 라이브러리에서 아래와 같이 정의되어 있는 Option<T>이다.

enum Option<T> {
    Some(T),
    None,
}

Option<T> enum은 너무도 유용하기 때문에 이미 prelude에 포함되어 있다. 즉 사용자가 직접 scope 안으로 그 기능을 가져올 필요가 없이 사용할 수 있다. 이는 Option의 종류들도 마찬가지이어서, SomeNone 역시 Option::을 앞에 붙이지 않고도 바로 사용할 수 있다. 하지만 여전히 Option<T>는 여타 다른 enum과 같은 enum이고, Some(T)None 역시 Option<T> 타입의 두 가지 종류이다.

<T> syntax는 아직 우리가 다루지 않은 Rust의 generic type 기능으로, 챕터 10에서 상세하게 다룰 것이다. 지금 시점에서는 <T>가 그 타입이 가질 수 있는 데이터 타입을 의미한다고만 생각하자. 아래는 Option 값이 숫자 타입과 string 타입을 담는 예제이다.

let some_number = Some(5);
let some_string = Some("a string");

let absent_number: Option<i32> = None;

Some이 아닌 None을 사용하는 경우에는, Rust에게 Option<T>에서의 타입 T가 무엇인지 알려주어야 한다. 왜냐하면 컴파일러는 None 값만 보고는 그에 알맞는 Some의 타입이 무엇인지 추리할 수 없기 때문이다.

Some 값을 사용할 경우에는 우리는 실제 값이 존재하고, 그 실제 값이 Some 안에 들어있다는 것을 알고 있다. None을 사용하는 경우 어떤 의미에서든 그 실제 값이 null임을, 즉 타당한 값이 없음을 의미한다고 볼 수 있다. 그래서 Option<T>가 null보다 좋은 점이 무엇일까?

간단히 말해서 Option<T>T (여기서 T는 어떤 타입이라도 상관없다)는 서로 다른 타입이기 때문에, 컴파일러는 Option<T> 값이 무조건 타당한 값인 것처럼 사용하는 것을 거부한다. 예를 들어 아래 코드는 서로 다른 타입인 i8Option<i8>을 더하려고 하기 때문에 컴파일되지 않을 것이다.

let x: i8 = 5;
let y: Option<i8> = Some(5);

let sum = x + y;

아래는 컴파일러의 에러 메세지이다.

error[E0277]: the trait bound `i8: std::ops::Add<std::option::Option<i8>>` is
not satisfied
 -->
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + std::option::Option<i8>`
  |

쪼잔하다! 이 에러 메세지는 실제로 Rust가 i8Option<i8>을 더하는 방법을 모른다고 말하고 있는데, 이는 두 개가 서로 다른 타입이기 때문이다. Rust에서 예를 들어 i8과 같은 타입의 값을 가지려면, 컴파일러는 항상 그 가지려고 하는 값이 정당한 지 보장할 것이다. 그래서 우리는 그 값이 null인지 아닌지 점검할 필요 없이 확신을 가지고 사용할 수 있는 것이다. 단지 Option<i8>을 가지고 있을 경우에는 그 값이 i8 값을 가지고 있지 않을 수 있음을 염두에 둬야하고, 컴파일로는 그 값을 사용하기 전에 가능한 경우를 모두 처리하는 지를 확실하게 점검할 것이다.

즉, 당신은 T에 관한 작업을 하기 전에 Option<T>T로 바꿔줘야 한다는 말이다. 일반적으로 이러한 작업은 null로 인해 만연하게 발생하는 문제를 잡아낸다: 실제로 null인 값을 null이 아닐 거라고 생각해버리는 상황이 그것이다.

Null이 아닌 값일 것이라 잘못 판단할 경우를 걱정하지 않음으로써 당신은 코딩함에 있어서 더욱 확신을 가지고 임할 수 있다. Null일 수 있는 값을 쓰기 위해서는, 반드시 Option<T> 타입을 명시적으로 선택해야만 한다. 그러고나서 그 Option 값을 사용할 때, 그 값이 null일 경우를 또한 명시적으로 처리해야만 한다. Option<T>가 아닌 타입을 사용하는 곳이라면, 당신은 그 값이 null이 아님을 안전하게 판단할 수 있다. 이것은 Rust가 null의 만연함을 제한시키고 Rust 코드를 더욱 안전하게 하기 위해 고민한 결과인 설계 결정이다.

그래서 Option<T>을 사용할 때 Some에 실제로 담겨있는 T 값을 사용하기 위해서 어떻게 해야하는가? Option<T> enum은 여러 상황에서 유용하게 쓰일 수 있는 많은 method를 가지고 있다. 공식 문서에서 그 사항들을 볼 수 있을 것이다. Option<T>의 method에 친숙해지면 Rust를 탐험하는데 있어서 굉장히 도움이 될 것이다.

일반적으로, Option<T>를 사용하기 위해서는 그 가능한 결과 종류인 SomeNone일 경우를 모두 처리해야 한다. 그리고 Some(T) 값을 처리하는 경우에서만 그 T 값을 사용하도록 허가될 것이다. None 값을 처리하는 경우에는 접근할 수 있는 T 값이 없어야 한다. match expression은 이렇게 enum의 가능한 결과에 따라 흐름을 제어하게 해주는 도구이다.



흐름 제어 연산자 match

Rust가 가지고 있는 매우 강력한 흐름 제어 연산자(control flow operator)인 match는 어떤 값을 일련의 패턴과 비교해서 일치하는 패턴에 따라 코드를 실행하도록 해준다. 패턴은 literal 값이나 변수명, wildcard, 그 외 여러 다른 것들로 이루어질 수 있는데, 이에 대한 자세한 설명은 챕터 18에서 이어질 것이다. match가 강력한 이유는 그 패턴을 매우 잘 표현해주고(expressiveness), 컴파일러가 모든 가능한 경우를 처리했는지 확인한다는 점에 있다.

match 표현을 일종의 동전 분류 기계로 생각할 수 있다. 기계에 넣은 동전이 그 사이즈에 맞는 구멍에 다다를 때까지 쭉 내려가서 사이즈가 맞는 구멍을 처음으로 만났을 때, 그 구멍으로 들어가서 분류되는 것이다. 이와 마찬가지로, 어떤 값이 match 안의 각 패턴을 거치면서 첫 번째로 일치하는 패턴을 만나게 되면, 그 패턴에 해당하는 코드 블럭으로 그 값이 떨어진다.

예로 들었던 동전을 지금 match 예제로 사용해보자! 우리는 미지의 미국 동전을 받아서 그 동전이 얼마짜리 동전인지 알아내는 함수를 예제 6-3에서 작성해보겠다. 마치 동전 분류 기계처럼 말이다.

예제 6-3: Enum의 종류들이 패턴으로 들어가 있는 enummatch 표현

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u32 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

value_in_cents 함수의 match를 분석해보자. 우선 match 키워드 다음에 expression이 오게 되는데, 여기서는 coin 값이 왔다. 이는 if 다음에 사용하는 expression과 매우 비슷한 것 같으나, 굉장히 큰 차이점이 있다. if에서는 expression이 Boolean 값이 되어야 했으나, 여기서는 어떤 타입이어도 상관이 없다. 위 예제에서의 coin 타입은 함수 위에 정의된 Coin enum이다.

그 다음에 match arm (arm을 어떤 말로 옮겨야 할 지 모르겠습니다) 들이 이어진다. Arm은 두 가지 부분으로 이루어져 있는데, 패턴과 코드이다. 예제에서 첫 번째 arm은 Coin::Penny라는 패턴을 가지고 있고, 그 다음에 이어지는 => 연산자가 패턴과 그 패턴일 경우에 실행할 코드를 구분지어 준다. 지금의 경우 그저 1 값이 그 코드의 전부이다. 각 arm들은 쉼표로 서로 구분된다.

match 표현이 실행될 때, 결과값과 패턴의 각 arm들을 순서대로 돌면서 비교한다. 그 값에 패턴이 일치할 경우, 그 패턴에 맞는 코드를 실행시킨다. 패턴이 일치하지 않는다면 그 다음 arm으로 계속 넘어간다. 동전 분류 기계처럼 말이다. 우리가 원하는 만큼 arm을 가질 수 있고, 위 예제에서 arm의 개수는 4개였다.

Arm에 연결된 코드 또한 expression이다. 그리고 그 expression의 결과값은 match expression 전체의 결과값이 된다.

예제 6-3에서 각 arm마다 단순한 값만을 반활하는 경우에서 볼 수 있듯이 match arm의 코드가 짧을 때는 보통 괄호 {}를 사용하지 않는다. 하지만 match arm의 코드가 여러 줄이 필요할 경우에는 괄호 {}를 사용할 수 있다. 예를 들어, 아래 코드는 "Lucky penny!"라는 문구를 Coin::Penny를 받을 때마다 출력하게 하면서도 여전히 값 1을 반환한다.

fn value_in_cents(coin: Coin) -> u32 {
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        },
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

패턴이 가지고 있는 값을 사용하기

match arm의 유용한 또 다른 기능이 있는데, 일치하는 패턴에 어떤 값들을 같이 엮을 수 있다는 점이다. 이것이 enum 종류들이 가지는 값들을 추출하는 방법이 된다.

예를 들어 우리의 enum 종류 중 하나가 데이터를 갖고 있도록 이야기를 바꿔보겠다. 1999년부터 2008년까지 미국은 쿼터 동전을 각 50개 주마다 각기 다른 디자인으로 발행했다. 그 외 다른 동전들은 주마다의 디자인을 가지고 있지 않고, 쿼터 동전만이 이 추가적인 값을 가지고 있다. 우리는 이 정보를 예제 6-4에 반영시켜보겠다.

예제 6-4: Quarter 종류가 UsState 값을 가지고 있는 Coin enum

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --중략--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

이 enum을 위한 match expression에서는 Coin::Quarter 종류에 연결된 변수 state를 추가할 것이다. Coin::Quarter 패턴에 맞는 경우, 이 패턴에 엮여있는 state 변수가 해당 쿼터 동전의 주 정보를 나타내는 값을 가지고 있으므로 해당 arm에서 state를 코드에서 사용할 수 있다.

fn value_in_cents(coin: Coin) -> u32 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("State quarter from {:?}!", state);
            25
        },
    }
}

value_in_cents(Coin::Quarter(UsState::Alaska))를 호출했다면, coinCoin::Quarter(UsState::Alaska)가 될 것이다. 그 값을 각 match arm과 비교해가다가 일치하는 패턴인 Coin::Quarter(state)에 다다를 것이다. 이 때 state 변수에 UsState::Alaska 값이 묶이게 된다. 그리곤 println!을 통해 그 묶인 값을 확인해 볼 수 있는 것이다.


Matching with Option<T>

이전에, Option<T>를 설명하면서 Some 안에 들어있는 T 값을 꺼내서 쓰고 싶어했었다. 그런데 Coin enum에서 match를 썼듯이 Option<T>를 다룰 수도 있다!

이제 Option<i32>를 받아서 그 안에 제대로 된 값이 있다면 1을 더하고, 없다면 None 값을 반환하고 아무 작업을 하려고 하지 않는 함수를 써보려고 한다.

이 함수는 match 덕분에 매우 쉽게 작성할 수 있다. 아래 예제 6-5를 보자.

예제 6-5: Option<i32>를 다루는 match exression

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);

plus_one의 첫 실행을 좀 더 자세하게 살펴보자. plus_one(five)를 호출할 때, 함수 body에서의 변수 xSome(5)가 될 것이다. 그 다음에는 각 match arm에 대해 비교한다.

None => None,

Some(5) 값은 None 패턴과 맞지 않으므로, 그 다음 arm을 확인하러 간다.

Some(i) => Some(i + 1),

Some(5)가 패턴 Some(i)에 일치할까? 물론이다! 이 둘은 같은 타입을 가지고 있다. iSome 안에 담겨진 값에 묶이므로, 5 값을 갖게 된다. 이 match arm에 연결된 코드가 실행되므로, 그 코드는 i의 값에 1을 더해서 새로운 Some 값으로 6를 넣어서 만들 것이다.

이제 다시 plus_one의 두 번째 호출을 생각해보자. 여기서 xNone이다. 이제 match 안으로 들어가서 첫 번째 arm의 패턴과 비교한다.

None => None,

패턴이 맞는다! 여기서는 뭔가 더할 값이 있는 것이 아니므로, 프로그램은 여기서 멈추고 => 오른 편에 있는 None 값을 반환하면 된다. 첫 번째 arm에서 패턴이 일치했으므로 그 이후의 arm을 비교하러 가지 않는다.

match와 enum을 엮어서 사용하는 것은 여러 상황에서 유용하다. enum에 대응해서 match하고, 그 패턴에 연계되는 데이터를 변수에 묶고, 그 데이터를 바탕으로 코드를 실행시킨다, 이러한 패턴은 Rust 코드에서 매우 자주 보게 될 것이다. 처음엔 조금 까다로울 수도 있겠지만, 한 번 익숙해지면, 아마도 당신은 다른 언어에서도 이렇게 하고 싶어할 것이다. 이는 변치않는 Rust 사용자들의 선호하는 점이다.


Match는 굉장히 철저하다

match에 대해 한 가지 더 이야기할 것이 있다. 아래 컴파일되지 않을 plus_one 함수를 보자.

fn plus_one(x: Option<i32>) -> Option<i32> {
    match x {
        Some(i) => Some(i + 1),
    }
}

우리는 여기서 None일 패턴의 경우를 다루지 않았으므로, 이 코드는 버그를 유발할 수 있다. 다행히도 Rust는 이 버그를 잡아낼 줄을 안다. 위 코드를 컴파일하려고 시도한다면 아래와 같은 에러를 보게 될 것이다.

error[E0004]: non-exhaustive patterns: `None` not covered
 -->
  |
6 |         match x {
  |               ^ pattern `None` not covered

Rust는 우리가 모든 가능한 경우를 다루지 않았음을 알고 있고, 심지어 어떤 패턴이 빠져있는지조차 알고 있다! Rust에서의 match는 굉장히 철저하다(exhaustive). 우리는 올바른 코드를 위해 모든 가능한 경우의 수를 철저하게 처리해야 한다. Option<T>의 경우 특히나 None의 경우를 명시적으로 다루지 않았을 경우를 막아주기 때문에, null일지 아닐지 모르는 값을 가지는 상황을 만들지 않는다. 10억 달러의 실수를 여기서는 만들지 않는 것이다.


Placeholder _

물론 Rust에서도 모든 가능한 경우를 나열하기 싫을 때 사용할 수 있는 패턴이 있다. 예를 들어 u8 타입의 변수는 0부터 255까지를 가능한 값으로 가질 수 있다. 여기서 우리는 1, 3, 5, 7이 값일 경우만 신경쓰고 싶다면, 특별한 패턴 _을 사용해서 그 나머지 모든 경우의 수를 대신해 사용할 수 있다.

let some_u8_value = 0u8;
match some_u8_value {
    1 => println!("one"),
    3 => println!("three"),
    5 => println!("five"),
    7 => println!("seven"),
    _ => (),
}

_ 패턴은 어느 값이든지 일치시킨다. 이 패턴을 가장 마지막 arm에 위치시키면 _는 그 위에 명시한 패턴을 제외한 모든 패턴에 일치하는 코드를 작성할 수 있게 된다. ()는 단지 unit 값으로, 위 코드의 _일 경우에 아무런 일도 일어나지 않는다.

그런데 match expression은 우리가 신경쓰고자 하는 패턴이 오직 하나일 경우, 조금 불필요하게 긴 코드가 될 수도 있다. 이런 상황에서 Rust에서는 if let을 활용할 수 있다.



if let을 통한 간결한 흐름 제어

iflet을 합친 if let syntax는 단 하나의 패턴만을 비교하고 그 나머지 경우는 무시하는 경우에 조금 덜 장황하게 값들을 다루는 방법을 제공한다. 아래 Option<u8>match하는데 패턴에 묶인 값이 오직 3일 경우에만 코드를 실행하고 싶어하는 예제 6-6을 보자.

예제 6-6: Some(3)일 경우에만 코드를 실행하고 싶은 match

let some_u8_value = Some(0u8);
match some_u8_value {
    Some(3) => println!("three"),
    _ => (),
}

Some(3)일 때만 무언가 하고 싶고 그 외의 다른 Some<u8> 값이나 None일 경우에는 아무 것도 하고 싶지 않다. 그런데 match expression을 제대로 쓰려면 우리는 _ => () 을 항상 두 번째 패턴으로 달아주어야 하는데, 이는 꽤나 귀찮은 boilerplate라고 볼 수도 있다.

그 대신 if let을 사용해서 훨씬 짧게 쓸 수 있다. 아래 코드는 예제 6-6에서의 match와 똑같이 작동한다.

if let Some(3) = some_u8_value {
    println!("three");
}

if let syntax에서의 패턴과 expression은 = 기호로 분리된다. 여기서의 expression은 match에 주어지는 것이고, 패턴은 그 첫 번째 arm으로 주어지는 것이다. 그리고 위의 match를 사용하는 것과 똑같이 작동한다.

if let을 사용해서 타이핑을 덜 하고, 덜 들여쓰고, boilerplate 코드를 덜 쓰게 된다. 하지만 그 대신 match가 강제하는 철저함을 잃는다. match를 쓸 것이냐 if let을 쓸 것이냐를 선택하는 것은 당신이 처한 상황에서 원하는 일이 무엇인지에 따라, 그리고 간결함을 얻기 위해서 철저함을 잃어도 되는 것인지를 저울질함에 따라 달라질 것이다.

다시 말하자면, if let은 한 패턴에 일치할 경우에만 코드를 실행하고 그 외의 경우는 모두 무시할 경우의 match를 대신할 수 있는 syntax sugar로 생각할 수 있다는 것이다.

if letelse를 추가할 수 있다. else로 이어지는 코드 블럭은 match expression에서의 _ 패턴에 해당하는 코드와 같다. 예제 6-4에서의 Coin enum은 오직 Quarter 패턴만이 UsState 값을 가지고 있었다. 만약 쿼터가 아닌 동전일 경우 그 수를 세고, 쿼터 동전일 경우에는 그 동전의 주 정보를 알려주고 싶다면 아래처럼 match expression을 쓸 수 있을 것이다.

let mut count = 0;
match coin {
    Coin::Quarter(state) => println!("State quarter from {:?}!", state),
    _ => count += 1,
}

이를 if letelse expression을 사용하면 아래와 같은 코드가 된다.

let mut count = 0;
if let Coin::Quarter(state) = coin {
    println!("State quarter from {:?}!", state);
} else {
    count += 1;
}

match를 사용하는 expression이 당신의 프로그램이 원하는 로직에 비해 너무 장황하다면, Rust 도구 상자에 if let도 있음을 까먹지 말자.



정리하는 말

Enum을 사용해서 열거된 값들의 모임을 나타내는 커스텀 타입을 어떻게 만드는지 살펴보았다.기본 라이브러리의 Option<T> 타입이 타입 시스템을 활용해서 어떻게 에러를 예방하는지 알아보았다. Enum 값 내부에 데이터를 가지고 있을 경우에 matchif let을 사용해서 그 값을 추출하고 사용하는 법을 소개했고, 그 두 가지 방법은 프로그램이 처리해야 할 경우가 얼마나 많은지에 따라 다름을 이야기했다.

당신의 Rust 프로그램은 이제 struct와 enum을 사용해서 당신의 도메인에서 사용하는 개념들을 표현할 수 있을 것이다. 당신의 API에서 사용할 커스텀 타입을 만들어 놓는 것은 타입 안전성을 보장한다. 컴파일러는 당신의 함수가 예상하는 타입의 값만을 받아들이는 지 검사할 것이다.

당신의 프로그램을 쓰게 될 사용자들에게 잘 구성된 API를 만들어서 end-user가 필요한 것만을 정확하게 제공해서 사용하기에 직관적이기 위해서는, Rust의 모듈에 대해 알아보러 가야 한다.

반응형