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

열거형 정의하기

구조체가 widthheight 를 가진 Rectangle 처럼 관련된 필드와 데이터를 함께 묶는 방법을 제공한다면, enum은 어떤 값이 “가능한 값들의 집합 중 하나”라고 말할 수 있는 방법을 제공합니다. 예를 들어 RectangleCircle, Triangle 도 포함하는 여러 가능한 도형 중 하나라고 표현하고 싶을 수 있습니다. 이를 위해 러스트는 이런 가능성들을 enum으로 인코딩하게 해 줍니다.

코드에서 표현하고 싶은 한 가지 상황을 살펴보면서, 왜 이 경우 enum이 유용하고 구조체보다 더 적절한지 알아봅시다. IP 주소를 다뤄야 한다고 해 보겠습니다. 현재 IP 주소에는 두 가지 주요 표준이 있습니다. 버전 4와 버전 6입니다. 프로그램이 만날 수 있는 IP 주소의 가능성은 이 둘뿐이므로, 우리는 가능한 모든 variant를 열거(enumerate) 할 수 있습니다. 열거형(enumeration)이라는 이름도 여기서 나옵니다.

어떤 IP 주소든 버전 4이거나 버전 6일 수는 있지만, 동시에 둘 다일 수는 없습니다. 이 특성 때문에 enum 데이터 구조가 적합합니다. enum 값은 그 variant 중 하나만 될 수 있기 때문입니다. 버전 4 주소와 버전 6 주소는 둘 다 본질적으로 IP 주소이므로, 코드가 “모든 종류의 IP 주소에 공통으로 적용되는 상황”을 다룰 때는 같은 타입으로 취급되어야 합니다.

이 개념은 IpAddrKind 라는 enum을 정의하고, IP 주소가 될 수 있는 종류인 V4, V6 를 나열함으로써 코드로 표현할 수 있습니다. 이것들이 enum의 variant입니다.

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

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

fn route(ip_kind: IpAddrKind) {}

이제 IpAddrKind 는 코드의 다른 곳에서도 사용할 수 있는 사용자 정의 데이터 타입이 되었습니다.

enum 값

IpAddrKind 의 두 variant 각각의 인스턴스는 다음처럼 만들 수 있습니다.

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

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

fn route(ip_kind: IpAddrKind) {}

enum의 variant는 그 enum 식별자 아래에 네임스페이스가 나뉘어 있으며, 둘을 구분할 때 이중 콜론을 사용한다는 점에 주목하세요. 이는 유용합니다. 왜냐하면 IpAddrKind::V4IpAddrKind::V6 가 둘 다 같은 타입, 즉 IpAddrKind 이기 때문입니다. 따라서 예를 들어 어떤 IpAddrKind 든 받을 수 있는 함수를 정의할 수 있습니다.

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

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

fn route(ip_kind: IpAddrKind) {}

그리고 이 함수는 어느 variant로도 호출할 수 있습니다.

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

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

fn route(ip_kind: IpAddrKind) {}

enum을 사용하는 데는 더 많은 장점이 있습니다. IP 주소 타입을 더 생각해 보면, 지금은 실제 IP 주소 데이터 를 저장할 방법이 없습니다. 지금은 단지 그것이 어떤 종류 인지만 알고 있을 뿐입니다. 여러분은 5장에서 구조체를 배웠으니, 이 문제를 목록 6-1처럼 구조체로 해결하고 싶어질 수도 있습니다.

fn main() {
    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"),
    };
}
Listing 6-1: IP 주소의 데이터와 IpAddrKind variant를 struct 로 저장하기

여기서는 IpAddr 라는 구조체를 정의했고, 두 개의 필드를 가집니다. 하나는 앞에서 정의한 enum 타입 IpAddrKind 를 가지는 kind 필드이고, 다른 하나는 String 타입의 address 필드입니다. 그리고 이 구조체의 인스턴스를 두 개 만들었습니다. 첫 번째 인스턴스 homekind 값으로 IpAddrKind::V4 를 가지고, 그에 대응하는 주소 데이터는 127.0.0.1 입니다. 두 번째 인스턴스 loopbackkind 값으로 다른 variant인 V6 를 가지며, 주소 ::1 을 함께 가집니다. 즉, 구조체를 사용해 kindaddress 값을 하나로 묶어 둔 것입니다.

하지만 같은 개념은 enum만으로도 더 간결하게 표현할 수 있습니다. 구조체 안에 enum을 넣는 대신, enum variant 각각에 직접 데이터를 담을 수 있습니다. 다음 IpAddr enum 정의는 V4V6 variant가 모두 String 값을 함께 가진다고 말합니다.

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

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

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

이제 각 enum variant에 데이터를 직접 붙였습니다. 그래서 별도의 구조체가 더 이상 필요하지 않습니다. 여기서는 enum이 어떻게 동작하는지에 대한 또 다른 중요한 세부도 더 쉽게 보입니다. 우리가 정의한 각 enum variant 이름은 그 enum 인스턴스를 만드는 함수 역할도 한다는 점입니다. 즉 IpAddr::V4()String 인수를 받아 IpAddr 타입 인스턴스를 반환하는 함수 호출입니다. enum을 정의하는 것만으로 이런 생성자 함수를 자동으로 얻게 됩니다.

구조체 대신 enum을 사용하는 또 다른 장점은, 각 variant가 서로 다른 타입과 개수의 데이터를 가질 수 있다는 점입니다. 버전 4 IP 주소는 항상 0에서 255 사이의 값을 가진 숫자 네 개로 이루어집니다. 만약 V4 주소는 네 개의 u8 값으로 저장하되, V6 주소는 하나의 String 값으로 표현하고 싶다면 구조체만으로는 어렵습니다. 하지만 enum은 이를 쉽게 처리할 수 있습니다.

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

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

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

지금까지 버전 4와 버전 6 IP 주소를 저장하기 위한 데이터 구조를 여러 방식으로 정의해 보았습니다. 그런데 알고 보면, IP 주소 자체를 저장하고 동시에 그것이 어떤 종류인지도 표현하고 싶어 하는 일은 너무 흔해서 표준 라이브러리에 이미 사용할 수 있는 정의가 있습니다. 표준 라이브러리가 IpAddr 를 어떻게 정의하는지 봅시다. 방금 우리가 직접 정의해 사용한 것과 정확히 같은 enum과 variant를 갖고 있지만, 주소 데이터는 각 variant마다 서로 다르게 정의된 두 구조체 형태로 안에 넣고 있습니다.

#![allow(unused)]
fn main() {
struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

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

이 코드는 enum variant 안에 문자열, 숫자 타입, 구조체 등 어떤 종류의 데이터든 넣을 수 있음을 보여 줍니다. 심지어 다른 enum을 넣는 것도 가능합니다! 또한 표준 라이브러리의 타입들은 여러분이 직접 생각해 낼 수 있는 것보다 훨씬 더 복잡하지만은 않은 경우가 많습니다.

표준 라이브러리에 IpAddr 정의가 있더라도, 우리는 그 정의를 스코프로 가져오지 않았기 때문에 충돌 없이 우리 자신의 정의를 여전히 만들고 사용할 수 있다는 점에 주목하세요. 타입을 스코프로 가져오는 방법은 7장에서 더 이야기합니다.

이제 표준 라이브러리에서 또 하나 흔하고 유용한 enum을 살펴봅시다. 목록 6-2는 여러 variant 안에 다양한 타입이 들어 있는 enum의 예입니다.

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

fn main() {}
Listing 6-2: 각 variant가 서로 다른 양과 타입의 값을 저장하는 Message enum

이 enum은 서로 다른 타입을 가진 네 개의 variant를 가집니다.

  • Quit: 어떤 데이터도 전혀 가지지 않습니다
  • Move: 구조체처럼 이름 붙은 필드를 가집니다
  • Write: 하나의 String 을 가집니다
  • ChangeColor: 세 개의 i32 값을 가집니다

목록 6-2처럼 variant가 서로 다른 형태를 가지는 enum을 정의하는 것은, 여러 종류의 구조체를 각각 따로 정의하는 것과 비슷합니다. 다만 enum은 struct 키워드를 사용하지 않고, 모든 variant가 하나의 Message 타입 아래에 함께 묶입니다. 다음 구조체들은 앞의 enum variant들이 담고 있던 것과 같은 데이터를 저장할 수 있습니다.

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

fn main() {}

하지만 이렇게 서로 다른 구조체를 각각 사용하면, 각기 다른 타입을 가진 값들을 모두 받는 함수를 정의하기가 쉽지 않습니다. 반면 목록 6-2의 Message enum은 하나의 타입이므로 훨씬 간단합니다.

enum과 구조체 사이에는 또 다른 공통점이 있습니다. 구조체에 대해 impl 을 사용해 메서드를 정의할 수 있듯이, enum에도 메서드를 정의할 수 있습니다. 다음은 Message enum 위에 정의할 수 있는 call 이라는 메서드입니다.

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

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

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

메서드 본문은 self 를 사용해 메서드가 호출된 값을 가져옵니다. 이 예제에서는 Message::Write(String::from("hello")) 값을 가지는 변수 m 을 만들었고, m.call() 이 실행될 때 call 메서드 본문 안에서 self 는 바로 이 값이 됩니다.

이제 표준 라이브러리의 또 다른, 아주 흔하고 유용한 enum인 Option 을 살펴봅시다.

Option enum

이 절에서는 표준 라이브러리가 정의한 또 다른 enum인 Option 을 사례 연구처럼 살펴봅니다. Option 타입은 값이 어떤 것일 수도 있고, 아무 것도 아닐 수도 있는 아주 흔한 상황을 표현합니다.

예를 들어 비어 있지 않은 리스트에서 첫 번째 항목을 요청하면 값을 얻게 됩니다. 하지만 빈 리스트에서 첫 번째 항목을 요청하면 아무 것도 얻지 못합니다. 이런 개념을 타입 시스템으로 표현하면, 컴파일러는 여러분이 처리해야 할 모든 경우를 다루었는지 검사할 수 있습니다. 이런 기능은 다른 프로그래밍 언어에서 매우 흔한 버그를 막는 데 도움이 됩니다.

프로그래밍 언어 설계는 흔히 어떤 기능을 포함하느냐의 관점에서 이야기되지만, 어떤 기능을 제외하느냐 도 똑같이 중요합니다. 러스트에는 많은 다른 언어가 가진 null 기능이 없습니다. Null 은 거기에 값이 없다는 뜻의 값입니다. null이 있는 언어에서는 변수가 늘 두 가지 상태 중 하나가 될 수 있습니다. null 이거나, null 이 아니거나.

2009년 “Null References: The Billion Dollar Mistake” 발표에서, null의 창시자 Tony Hoare는 이렇게 말했습니다.

나는 이것을 내 10억 달러짜리 실수라고 부른다. 그 당시 나는 객체 지향 언어에서 참조를 위한 최초의 포괄적인 타입 시스템을 설계하고 있었다. 내 목표는 참조의 모든 사용이 절대적으로 안전하도록 만들고, 그 검사를 컴파일러가 자동으로 수행하게 하는 것이었다. 그러나 null 참조를 넣고 싶은 유혹을 이기지 못했다. 구현이 너무 쉬웠기 때문이다. 그 결과 수많은 오류와 취약점, 시스템 충돌이 발생했고, 지난 40년 동안 아마 10억 달러에 해당하는 고통과 피해를 만들어 냈을 것이다.

Null 값의 문제는, null 값을 null 이 아닌 값처럼 사용하려 하면 어떤 형태로든 오류가 발생한다는 점입니다. 그리고 “null 이거나 아니거나” 라는 성질은 너무 널리 퍼져 있어, 이런 종류의 실수를 저지르기가 매우 쉽습니다.

하지만 null이 표현하려던 개념 자체는 여전히 유용합니다. 어떤 이유로든 현재 값이 유효하지 않거나 존재하지 않는 상태를 나타내는 것이니까요.

문제는 사실 개념 자체가 아니라 구체적인 구현 방식에 있습니다. 그래서 러스트는 null은 없지만, 값의 존재 여부를 표현할 수 있는 enum을 제공합니다. 이 enum이 바로 Option<T> 이며, 표준 라이브러리에서 다음과 같이 정의되어 있습니다.

#![allow(unused)]
fn main() {
enum Option<T> {
    None,
    Some(T),
}
}

Option<T> enum은 너무 유용해서 prelude에 포함되어 있습니다. 즉 명시적으로 스코프로 가져올 필요가 없습니다. 그 variant 역시 prelude에 포함되어 있으므로 Option:: 접두사 없이 Some, None 을 바로 쓸 수 있습니다. 물론 Option<T> 는 여전히 그저 평범한 enum이고, Some(T)None 역시 Option<T> 타입의 variant입니다.

<T> 문법은 아직 이야기하지 않은 러스트의 기능입니다. 이것은 제네릭 타입 매개변수이며, 10장에서 더 자세히 다룹니다. 지금은 <T>Option enum의 Some variant가 아무 타입의 데이터 하나를 담을 수 있다는 뜻이고, T 자리에 구체적인 타입이 들어올 때마다 전체 Option<T> 타입도 서로 다른 타입이 된다는 점만 알면 충분합니다. 다음은 숫자 타입과 문자 타입을 담는 Option 값을 사용하는 몇 가지 예입니다.

fn main() {
    let some_number = Some(5);
    let some_char = Some('e');

    let absent_number: Option<i32> = None;
}

some_number 의 타입은 Option<i32> 입니다. some_char 의 타입은 Option<char> 이고, 이는 서로 다른 타입입니다. Some variant 안에 값을 넣었기 때문에 러스트는 이 타입들을 추론할 수 있습니다. 반면 absent_number 에 대해서는 전체 Option 타입을 주석으로 달아 주어야 합니다. None 값만 보고는, 여기에 대응하는 Some variant가 어떤 타입을 가질지 컴파일러가 알 수 없기 때문입니다. 그래서 여기서는 absent_numberOption<i32> 타입이라는 사실을 직접 알려 줍니다.

Some 값을 가질 때 우리는 값이 존재한다는 사실을 알 수 있고, 그 값은 Some 안에 들어 있습니다. None 값을 가질 때는 어떤 의미에서는 null 과 같은 뜻입니다. 유효한 값이 없다는 뜻이니까요. 그렇다면 왜 Option<T> 가 null 보다 나을까요?

짧게 말하면, Option<T>T (T 는 어떤 타입이든 될 수 있습니다)는 서로 다른 타입이기 때문입니다. 그래서 컴파일러는 Option<T> 값을 “확실히 유효한 값”처럼 사용하는 것을 허용하지 않습니다. 예를 들어 다음 코드는 i8Option<i8> 를 더하려 하기 때문에 컴파일되지 않습니다.

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

    let sum = x + y;
}

이 코드를 실행하면 다음과 같은 오류 메시지를 받습니다.

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:5:17
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`
  = help: the following other types implement trait `Add<Rhs>`:
            `&i8` implements `Add<i8>`
            `&i8` implements `Add`
            `i8` implements `Add<&i8>`
            `i8` implements `Add`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` (bin "enums") due to 1 previous error

꽤 강렬하지요! 이 오류 메시지가 뜻하는 바는, 러스트가 i8Option<i8> 를 어떻게 더해야 할지 모르겠다는 것입니다. 왜냐하면 둘은 서로 다른 타입이기 때문입니다. 러스트에서 i8 같은 타입의 값을 갖는다는 것은, 컴파일러가 우리가 언제나 유효한 값을 가지고 있음을 보장해 준다는 뜻입니다. 따라서 그 값을 사용하기 전에 null인지 검사할 필요 없이 자신 있게 사용할 수 있습니다. 오직 Option<i8>(혹은 현재 다루는 다른 Option<T> 값)을 가질 때만, 값이 없을 가능성을 걱정해야 합니다. 그리고 컴파일러는 그 값을 사용하기 전에 그 경우를 반드시 처리하도록 강제합니다.

다시 말해, T 에 대한 연산을 수행하기 전에 Option<T>T 로 변환해야 합니다. 이것은 일반적으로 null과 관련된 가장 흔한 문제 중 하나, 즉 실제로는 값이 없는데 값이 있을 것이라고 가정하는 실수를 잡는 데 도움이 됩니다.

값이 null 이 아니라고 잘못 가정할 위험을 없애면 코드에 대해 훨씬 더 확신을 가질 수 있습니다. null 일 수도 있는 값을 가지려면, 그 값의 타입을 Option<T> 로 만들어 명시적으로 선택해야 합니다. 그리고 그 값을 사용할 때는 값이 없을 수도 있는 경우를 명시적으로 처리해야 합니다. 반대로 어떤 값의 타입이 Option<T> 가 아니라면, 그 값이 null이 아니라고 안전하게 가정할 수 있습니다. 이것은 null 이 코드 전체로 퍼지는 것을 제한하고 러스트 코드의 안전성을 높이기 위한, 러스트의 의도적인 설계 선택입니다.

그렇다면 Option<T> 값을 가지고 있을 때, 그 안의 Some variant에서 T 값을 어떻게 꺼내어 사용할까요? Option<T> enum에는 다양한 상황에서 유용한 메서드가 많이 정의되어 있으며, 문서에서 확인할 수 있습니다. 러스트를 배워 나가면서 Option<T> 의 메서드들에 익숙해지는 것은 매우 큰 도움이 됩니다.

일반적으로 Option<T> 값을 사용하려면, 각 variant를 모두 처리하는 코드를 작성해야 합니다. Some(T) 값을 가진 경우에만 실행되는 코드가 필요하며, 그 코드 안에서는 내부의 T 값을 사용할 수 있어야 합니다. 그리고 None 값을 가진 경우에만 실행되는 다른 코드도 필요합니다. 그 경우에는 사용할 T 값이 없습니다. match 식은 enum과 함께 쓸 때 바로 이 역할을 하는 제어 흐름 구문입니다. 어떤 enum variant를 가졌는지에 따라 서로 다른 코드를 실행하며, 그 코드 안에서는 매칭된 값 내부의 데이터를 사용할 수 있습니다.