Enum 정의하기

Beginner

This tutorial is from open-source community. Access the source code

소개

Enum 정의하기에 오신 것을 환영합니다. 이 랩은 Rust Book의 일부입니다. LabEx 에서 Rust 기술을 연습할 수 있습니다.

이 랩에서는 버전 4(V4) 및 버전 6(V6) 을 포함하여 가능한 IP 주소 종류를 나타내기 위해 IpAddrKind라는 enum 을 정의합니다.

Enum 정의하기

구조체 (struct) 가 widthheight를 가진 Rectangle과 같이 관련 필드와 데이터를 함께 그룹화하는 방법을 제공하는 반면, enum 은 값이 가능한 값 집합 중 하나임을 나타내는 방법을 제공합니다. 예를 들어, RectangleCircleTriangle도 포함하는 가능한 도형 집합 중 하나라고 말하고 싶을 수 있습니다. 이를 위해 Rust 는 이러한 가능성을 enum 으로 인코딩할 수 있도록 합니다.

코드에서 표현하고 싶은 상황을 살펴보고, 이 경우 enum 이 왜 유용하고 구조체보다 더 적합한지 알아보겠습니다. IP 주소로 작업해야 한다고 가정해 봅시다. 현재 IP 주소에 사용되는 두 가지 주요 표준은 버전 4 와 버전 6 입니다. 이것이 우리 프로그램이 접하게 될 IP 주소에 대한 유일한 가능성이기 때문에, 가능한 모든 변형을 *열거 (enumerate)*할 수 있으며, 여기서 열거 (enumeration) 라는 이름이 유래되었습니다.

모든 IP 주소는 버전 4 또는 버전 6 주소일 수 있지만 동시에 둘 다일 수는 없습니다. IP 주소의 이러한 속성은 enum 데이터 구조가 적합하게 만듭니다. enum 값은 해당 변형 중 하나만 가질 수 있기 때문입니다. 버전 4 및 버전 6 주소는 여전히 근본적으로 IP 주소이므로, 코드에서 모든 종류의 IP 주소에 적용되는 상황을 처리할 때는 동일한 유형으로 처리해야 합니다.

IpAddrKind 열거형을 정의하고 IP 주소가 가질 수 있는 가능한 종류인 V4V6를 나열하여 이 개념을 코드에서 표현할 수 있습니다. 이것이 enum 의 변형입니다.

enum IpAddrKind {
    V4,
    V6,
}

이제 IpAddrKind는 코드의 다른 곳에서 사용할 수 있는 사용자 정의 데이터 유형입니다.

Enum 값

IpAddrKind의 두 가지 변형 각각의 인스턴스를 다음과 같이 생성할 수 있습니다.

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

enum 의 변형은 해당 식별자 아래에 네임스페이스화되며, 두 값을 구분하기 위해 이중 콜론을 사용합니다. 이는 IpAddrKind::V4IpAddrKind::V6의 두 값 모두 동일한 유형인 IpAddrKind이기 때문에 유용합니다. 예를 들어, 모든 IpAddrKind를 받는 함수를 정의할 수 있습니다.

fn route(ip_kind: IpAddrKind) {}

그리고 이 함수를 두 변형 중 하나로 호출할 수 있습니다.

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

enum 을 사용하면 더 많은 이점이 있습니다. IP 주소 유형에 대해 더 생각해보면, 현재 실제 IP 주소 데이터를 저장할 방법이 없습니다. 우리는 단지 그것이 어떤 종류인지 알고 있을 뿐입니다. 5 장에서 구조체에 대해 배웠다는 점을 감안할 때, Listing 6-1 에 표시된 것처럼 구조체로 이 문제를 해결하고 싶을 수 있습니다.

1 enum IpAddrKind {
    V4,
    V6,
}

2 struct IpAddr {
  3 kind: IpAddrKind,
  4 address: String,
}

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

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

Listing 6-1: struct를 사용하여 IP 주소의 데이터와 IpAddrKind 변형을 저장

여기서, 두 개의 필드를 가진 구조체 IpAddr [2]를 정의했습니다. IpAddrKind 유형 (이전에 정의한 enum [1]) 의 kind 필드 [3]와 String 유형의 address 필드 [4]입니다. 이 구조체의 두 인스턴스가 있습니다. 첫 번째는 home [5]이며, 127.0.0.1의 관련 주소 데이터와 함께 IpAddrKind::V4 값을 kind로 갖습니다. 두 번째 인스턴스는 loopback [6]입니다. kind 값으로 IpAddrKind의 다른 변형인 V6을 가지며, ::1 주소가 연결되어 있습니다. kindaddress 값을 함께 묶기 위해 구조체를 사용했으므로 이제 변형이 값과 연결됩니다.

그러나 enum 만 사용하여 동일한 개념을 표현하는 것이 더 간결합니다. 구조체 내부의 enum 대신, 각 enum 변형에 직접 데이터를 넣을 수 있습니다. IpAddr enum 의 이 새로운 정의는 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 의 각 변형에 데이터를 직접 연결하므로 추가 구조체가 필요하지 않습니다. 여기서 enum 이 작동하는 방식의 또 다른 세부 사항을 쉽게 볼 수 있습니다. 우리가 정의하는 각 enum 변형의 이름은 enum 의 인스턴스를 생성하는 함수가 됩니다. 즉, IpAddr::V4()String 인수를 받아 IpAddr 유형의 인스턴스를 반환하는 함수 호출입니다. enum 을 정의한 결과로 이 생성자 함수를 자동으로 얻습니다.

구조체 대신 enum 을 사용하는 또 다른 장점이 있습니다. 각 변형은 서로 다른 유형과 양의 관련 데이터를 가질 수 있습니다. 버전 4 IP 주소는 항상 0 에서 255 사이의 값을 갖는 4 개의 숫자 구성 요소를 갖습니다. V4 주소를 4 개의 u8 값으로 저장하고 V6 주소를 하나의 String 값으로 표현하고 싶다면, 구조체로는 할 수 없습니다. enum 은 이 경우를 쉽게 처리합니다.

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 과 변형을 가지고 있지만, 각 변형에 대해 다르게 정의된 두 개의 다른 구조체 형태로 변형 내부에 주소 데이터를 포함합니다.

struct Ipv4Addr {
    --snip--
}

struct Ipv6Addr {
    --snip--
}

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

이 코드는 문자열, 숫자 유형 또는 구조체와 같이 enum 변형 내부에 모든 종류의 데이터를 넣을 수 있음을 보여줍니다. 다른 enum 을 포함할 수도 있습니다! 또한 표준 라이브러리 유형은 여러분이 생각해낼 수 있는 것보다 훨씬 더 복잡하지 않은 경우가 많습니다.

표준 라이브러리에 IpAddr에 대한 정의가 포함되어 있더라도, 표준 라이브러리의 정의를 범위로 가져오지 않았으므로 충돌 없이 자체 정의를 만들고 사용할 수 있습니다. 7 장에서 유형을 범위로 가져오는 것에 대해 더 자세히 이야기하겠습니다.

Listing 6-2 에서 enum 의 또 다른 예를 살펴보겠습니다. 이 예는 변형에 다양한 유형이 포함되어 있습니다.

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

Listing 6-2: 각 변형이 서로 다른 양과 유형의 값을 저장하는 Message enum

이 enum 에는 서로 다른 유형의 네 가지 변형이 있습니다.

  • Quit에는 관련 데이터가 전혀 없습니다.
  • Move는 구조체와 같이 명명된 필드를 갖습니다.
  • Write는 단일 String을 포함합니다.
  • ChangeColor는 세 개의 i32 값을 포함합니다.

Listing 6-2 와 같은 변형으로 enum 을 정의하는 것은 서로 다른 종류의 구조체 정의를 정의하는 것과 유사하지만, enum 은 struct 키워드를 사용하지 않고 모든 변형이 Message 유형 아래에 함께 그룹화됩니다. 다음 구조체는 앞의 enum 변형이 보유하는 것과 동일한 데이터를 보유할 수 있습니다.

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

그러나 서로 다른 구조체를 사용하면 각 구조체는 자체 유형을 가지므로, 단일 유형인 Listing 6-2 에 정의된 Message enum 을 사용했을 때만큼 쉽게 이러한 종류의 메시지를 모두 받을 함수를 정의할 수 없습니다.

enum 과 구조체 사이에는 한 가지 더 유사점이 있습니다. impl을 사용하여 구조체에 메서드를 정의할 수 있는 것처럼, enum 에도 메서드를 정의할 수 있습니다. 다음은 Message enum 에 정의할 수 있는 call이라는 메서드입니다.

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

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

메서드의 본문은 self를 사용하여 메서드를 호출한 값을 가져옵니다. 이 예에서는 Message::Write(String::from("hello")) 값을 갖는 변수 m [2]을 만들었으며, m.call()이 실행될 때 call 메서드 [1]의 본문에서 self가 됩니다.

표준 라이브러리에서 매우 일반적이고 유용한 또 다른 enum 인 Option을 살펴보겠습니다.

Option Enum 과 Null 값에 대한 장점

이 섹션에서는 표준 라이브러리에 정의된 또 다른 enum 인 Option의 사례 연구를 살펴봅니다. Option 유형은 값이 있을 수도 있고 없을 수도 있는 매우 일반적인 시나리오를 인코딩합니다.

예를 들어, 여러 항목이 포함된 목록에서 첫 번째 항목을 요청하면 값을 얻게 됩니다. 빈 목록에서 첫 번째 항목을 요청하면 아무것도 얻지 못합니다. 이 개념을 유형 시스템 측면에서 표현한다는 것은 컴파일러가 처리해야 하는 모든 경우를 처리했는지 확인할 수 있음을 의미합니다. 이 기능은 다른 프로그래밍 언어에서 매우 흔한 버그를 방지할 수 있습니다.

프로그래밍 언어 설계는 포함하는 기능 측면에서 종종 생각되지만, 제외하는 기능도 중요합니다. Rust 에는 다른 많은 언어에 있는 null 기능이 없습니다. Null은 값이 없음을 의미하는 값입니다. null 이 있는 언어에서 변수는 항상 null 또는 not-null 의 두 가지 상태 중 하나일 수 있습니다.

2009 년 프레젠테이션 "Null References: The Billion Dollar Mistake"에서 null 의 발명가인 Tony Hoare 는 다음과 같이 말했습니다.

저는 이것을 제 10 억 달러짜리 실수라고 부릅니다. 당시 저는 객체 지향 언어에서 참조에 대한 최초의 포괄적인 유형 시스템을 설계하고 있었습니다. 제 목표는 컴파일러가 자동으로 수행하는 검사를 통해 모든 참조 사용이 절대적으로 안전하도록 보장하는 것이었습니다. 그러나 구현하기가 너무 쉬워서 null 참조를 넣고 싶은 유혹을 참을 수 없었습니다. 이것은 지난 40 년 동안 수많은 오류, 취약성 및 시스템 충돌로 이어졌으며, 아마도 10 억 달러의 고통과 피해를 초래했을 것입니다. null 값의 문제는 null 값을 not-null 값으로 사용하려고 하면 어떤 종류의 오류가 발생한다는 것입니다. 이 null 또는 not-null 속성은 널리 퍼져 있기 때문에 이러한 종류의 오류를 쉽게 만들 수 있습니다.

그러나 null 이 표현하려는 개념은 여전히 유용합니다. null 은 어떤 이유로 현재 유효하지 않거나 부재하는 값입니다.

문제는 실제로 개념이 아니라 특정 구현에 있습니다. 따라서 Rust 에는 null 이 없지만, 값이 존재하거나 부재하는 개념을 인코딩할 수 있는 enum 이 있습니다. 이 enum 은 Option<T>이며, 표준 라이브러리에 다음과 같이 정의되어 있습니다.

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

Option<T> enum 은 매우 유용하여 prelude 에도 포함되어 있습니다. 명시적으로 범위를 가져올 필요가 없습니다. 해당 변형도 prelude 에 포함되어 있습니다. Option:: 접두사 없이 SomeNone을 직접 사용할 수 있습니다. Option<T> enum 은 여전히 일반적인 enum 이며, Some(T)None은 여전히 Option<T> 유형의 변형입니다.

<T> 구문은 아직 이야기하지 않은 Rust 의 기능입니다. 이는 제네릭 유형 매개변수이며, 10 장에서 제네릭에 대해 자세히 다룰 것입니다. 지금은 <T>Option enum 의 Some 변형이 모든 유형의 데이터 조각 하나를 보유할 수 있으며, T 대신 사용되는 각 구체적인 유형이 전체 Option<T> 유형을 다른 유형으로 만든다는 것만 알면 됩니다. 다음은 숫자 유형과 문자열 유형을 보유하기 위해 Option 값을 사용하는 몇 가지 예입니다.

let some_number = Some(5);
let some_char = Some('e');

let absent_number: Option<i32> = None;

some_number의 유형은 Option<i32>입니다. some_char의 유형은 Option<char>이며, 이는 다른 유형입니다. Rust 는 Some 변형 내부에 값을 지정했기 때문에 이러한 유형을 추론할 수 있습니다. absent_number의 경우, Rust 는 전체 Option 유형에 주석을 달도록 요구합니다. 컴파일러는 None 값만 보고 해당 Some 변형이 보유할 유형을 추론할 수 없습니다. 여기서 우리는 absent_numberOption<i32> 유형이 되도록 하려고 Rust 에 알립니다.

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]: 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`

강렬하죠! 실제로 이 오류 메시지는 Rust 가 i8Option<i8>을 더하는 방법을 이해하지 못한다는 것을 의미합니다. 왜냐하면 서로 다른 유형이기 때문입니다. Rust 에서 i8과 같은 유형의 값이 있으면 컴파일러는 항상 유효한 값이 있는지 확인합니다. 해당 값을 사용하기 전에 null 을 확인할 필요 없이 자신 있게 진행할 수 있습니다. Option<i8>(또는 우리가 작업하는 값의 유형) 이 있는 경우에만 값이 없을 수 있다는 것을 걱정해야 하며, 컴파일러는 해당 값을 사용하기 전에 해당 경우를 처리하도록 합니다.

다시 말해, Option<T>T로 변환해야만 T 연산을 수행할 수 있습니다. 일반적으로, 이것은 null 과 관련된 가장 일반적인 문제 중 하나인 실제로 null 인데 null 이 아니라고 가정하는 것을 잡는 데 도움이 됩니다.

잘못된 not-null 값을 가정할 위험을 제거하면 코드에 대해 더 자신감을 가질 수 있습니다. null 일 수 있는 값을 가지려면 해당 값의 유형을 Option<T>로 명시적으로 선택해야 합니다. 그런 다음 해당 값을 사용할 때, 값이 null 인 경우를 명시적으로 처리해야 합니다. 값이 Option<T>가 아닌 유형을 갖는 모든 곳에서, 해당 값이 null 이 아니라고 안전하게 가정할 수 있습니다. 이것은 null 의 광범위성을 제한하고 Rust 코드의 안전성을 높이기 위한 Rust 의 의도적인 설계 결정이었습니다.

그렇다면 Option<T> 유형의 값을 가지고 있을 때 Some 변형에서 T 값을 어떻게 얻어 해당 값을 사용할 수 있을까요? Option<T> enum 에는 다양한 상황에서 유용한 많은 메서드가 있습니다. 해당 문서를 확인할 수 있습니다. Option<T>의 메서드에 익숙해지는 것은 Rust 여정에서 매우 유용할 것입니다.

일반적으로, Option<T> 값을 사용하려면 각 변형을 처리하는 코드를 원합니다. Some(T) 값이 있는 경우에만 실행되는 코드를 원하며, 이 코드는 내부 T를 사용할 수 있습니다. None 값이 있는 경우에만 실행되는 다른 코드를 원하며, 해당 코드에는 T 값이 없습니다. match 표현식은 enum 과 함께 사용될 때 바로 이 작업을 수행하는 제어 흐름 구성입니다. enum 의 어떤 변형이 있는지에 따라 다른 코드를 실행하며, 해당 코드는 일치하는 값 내의 데이터를 사용할 수 있습니다.

요약

축하합니다! Enum 정의 랩을 완료했습니다. LabEx 에서 더 많은 랩을 연습하여 실력을 향상시킬 수 있습니다.