열거형 정의
서로 다른 형태의 데이터를 하나의 열거형 배리언트로 모델링하고 Option이 null 없이 값의 부재를 표현하는 방식을 이해합니다.
5장의 주제는 열거형(enumeration, 줄여서 enum)입니다. 열거형은 어떤 타입이 가질 수 있는 경우들, 곧 배리언트(variant)를 나열해 그 타입을 정의합니다. 이 글에서는 열거형을 정의하고 써 보며 열거형이 데이터와 함께 뜻을 담는 방법을 보고, 값이 있거나 없을 수 있다는 것을 나타내는 표준 라이브러리의 Option 열거형을 살펴봅니다. 이어지는 글에서는 match 식으로 열거형의 값에 따라 다른 코드를 실행하는 방법과, 열거형을 짧게 다루는 if let을 다룹니다.
열거형 정의하기
구조체가 Rectangle의 width와 height처럼 관련된 필드를 묶는다면, 열거형은 어떤 값이 몇 가지 가능한 경우 가운데 하나라는 것을 나타냅니다. 예를 들어 Rectangle이 Circle, Triangle과 함께 도형의 한 종류라는 것을 나타내고 싶을 때 열거형을 씁니다. IP 주소를 다루는 프로그램으로 열거형이 구조체보다 알맞은 경우를 살펴봅니다.
지금 쓰이는 IP 주소의 표준은 IPv4와 IPv6 두 가지입니다. 프로그램이 다룰 IP 주소는 이 둘뿐이므로 가능한 경우를 모두 나열할 수 있고, 그래서 열거형(enumeration)이라는 이름이 붙었습니다. IP 주소는 IPv4이거나 IPv6이지 둘 다일 수는 없는데, 열거형의 값도 배리언트 가운데 하나일 수밖에 없으므로 잘 맞습니다. 또 IPv4와 IPv6 주소는 결국 모두 IP 주소이므로, 어떤 종류의 IP 주소에도 적용되는 코드에서는 같은 타입으로 다루는 것이 좋습니다.
이 개념은 IpAddrKind 열거형에 가능한 IP 주소의 종류 V4와 V6를 나열해 나타낼 수 있습니다. 이것이 열거형의 배리언트입니다.
enum IpAddrKind {
V4,
V6,
}이제 IpAddrKind는 코드의 다른 곳에서 쓸 수 있는 사용자 정의 타입입니다.
열거형 값
IpAddrKind의 두 배리언트로 각각 인스턴스를 만들 수 있습니다. 두 배리언트에는 담는 데이터가 없으므로, 열거형 이름과 배리언트 이름만 적으면 값이 만들어집니다.
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;배리언트는 열거형의 이름 아래 네임스페이스에 있으므로, 두 콜론 ::으로 둘을 잇습니다. 이렇게 하면 IpAddrKind::V4와 IpAddrKind::V6가 모두 같은 IpAddrKind 타입의 값이 되어, 예를 들어 IpAddrKind를 받는 함수 하나를 두 배리언트 모두로 부를 수 있습니다.
fn route(ip_kind: IpAddrKind) {} route(IpAddrKind::V4);
route(IpAddrKind::V6);그런데 지금은 IP 주소의 종류만 알 수 있을 뿐 실제 주소 데이터를 담을 곳이 없습니다. 구조체를 배웠으니 다음처럼 구조체로 해결하고 싶을 수 있습니다.
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 구조체는 IpAddrKind 타입의 kind 필드와 String 타입의 address 필드를 가집니다. home은 종류가 V4이고 주소가 127.0.0.1이며, loopback은 종류가 V6이고 주소가 ::1입니다. 구조체로 종류와 주소를 함께 묶어 배리언트에 값을 딸리게 했습니다. 하지만 열거형만으로도 같은 개념을 더 짧게 나타낼 수 있습니다. 배리언트마다 데이터를 바로 넣으면 됩니다.
enum IpAddr {
V4(String),
V6(String),
}
let home = IpAddr::V4(String::from("127.0.0.1"));
let loopback = IpAddr::V6(String::from("::1"));배리언트에 데이터를 바로 붙였으므로 구조체가 필요 없습니다. 여기서 열거형의 동작 하나가 드러나는데, 정의한 배리언트의 이름이 그 열거형의 인스턴스를 만드는 함수가 된다는 것입니다. IpAddr::V4()는 String 인수를 받아 IpAddr 타입의 인스턴스를 돌려주는 함수이며, 열거형을 정의하면 이런 생성 함수가 저절로 만들어집니다.
구조체 대신 열거형을 쓰면 좋은 점이 하나 더 있습니다. 배리언트마다 데이터의 타입과 개수가 달라도 됩니다. IPv4 주소는 언제나 0부터 255 사이의 숫자 네 개로 이루어지므로 u8 값 네 개로 담고, IPv6 주소는 String 하나로 담고 싶다면, 구조체로는 할 수 없지만 열거형으로는 쉽게 할 수 있습니다.
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 주소를 담는 일은 흔하므로 표준 라이브러리에 이미 IpAddr가 정의되어 있습니다. 표준 라이브러리의 정의도 같은 두 배리언트를 가지지만, 각 배리언트에 담는 주소 데이터는 따로 정의한 두 구조체입니다.
struct Ipv4Addr {
// --생략--
}
struct Ipv6Addr {
// --생략--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}이처럼 배리언트에는 문자열, 숫자, 구조체는 물론 다른 열거형까지 어떤 데이터든 넣을 수 있습니다. 표준 라이브러리에 IpAddr가 있어도, 그것을 스코프로 가져오지 않았다면 같은 이름의 타입을 직접 만들어 쓸 수 있으며, 타입을 스코프로 가져오는 방법은 6장에서 다룹니다.
다음 열거형은 배리언트마다 다른 종류의 데이터를 담습니다.
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}아래 표처럼 이 열거형의 네 배리언트는 담는 데이터의 모양이 저마다 다르지만, 모두 같은 Message 타입의 값입니다.
ENUM VARIANTS · PAYLOAD SHAPES
하나의 열거형 안에서도 배리언트의 데이터 모양은 다르다
Message 값은 한 번에 하나의 배리언트이지만 모두 같은 타입입니다. 각 배리언트는 필요한 데이터의 유무·필드 이름, 개수, 타입을 독립적으로 정합니다.
| 형태 | 배리언트 정의 | 담는 데이터 |
|---|---|---|
| 유닛형 | Quit |
추가 데이터 없이 배리언트 자체로 한 경우를 나타냅니다. |
| 구조체형 | Move { x: i32, y: i32 } |
이름 있는 필드로 각 값의 역할을 드러냅니다. |
| 튜플형 · 한 필드 | Write(String) |
한 개의 이름 없는 필드에 문자열을 담습니다. |
| 튜플형 · 세 필드 | ChangeColor(i32, i32, i32) |
세 개의 이름 없는 필드에 색상 성분을 순서대로 담습니다. |
- 유닛형 ·
Quit - 추가 데이터 없이 배리언트 자체로 한 경우를 나타냅니다.
- 구조체형 ·
Move { x: i32, y: i32 }처럼 이름 있는 필드로 값의 역할을 드러냅니다.- 튜플형 ·
Write(String) - 한 개의 이름 없는 필드에 문자열을 담습니다.
- 튜플형 ·
ChangeColor (i32, i32, i32)세 필드에 색상 성분을 순서대로 담습니다.
Message 값을 만듭니다. 따라서 하나의 함수 매개변수와 impl Message가 네 형태를 모두 같은 타입으로 다룰 수 있습니다.Quit은 데이터가 없고, Move는 구조체처럼 이름 있는 필드 x와 y를 가지며, Write는 String 하나를, ChangeColor는 i32 세 개를 가집니다. 이렇게 배리언트를 정의하는 것은 서로 다른 구조체를 여러 개 정의하는 것과 비슷하지만, struct 키워드를 쓰지 않고 모든 배리언트가 Message 타입 하나로 묶인다는 점이 다릅니다. 같은 데이터를 다음과 같은 구조체들로도 담을 수 있습니다.
struct QuitMessage; // 유사 유닛 구조체
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // 튜플 구조체
struct ChangeColorMessage(i32, i32, i32); // 튜플 구조체하지만 구조체마다 타입이 다르므로, 이 메시지들 가운데 아무것이나 받는 함수를 정의하기가 어렵습니다. Message 열거형은 하나의 타입이므로 그런 함수를 쉽게 정의할 수 있습니다. 또 구조체처럼 열거형에도 impl로 메서드를 정의할 수 있습니다.
impl Message {
fn call(&self) {
// 메서드 본문
}
}
let m = Message::Write(String::from("hello"));
m.call();메서드 본문은 self로 그 메서드를 부른 열거형 값을 받습니다. 여기서 m의 값은 Message::Write(String::from("hello"))이므로, m.call()을 부르면 call 안의 self가 이 값이 됩니다. 다음 글의 match를 미리 써서, call이 배리언트에 따라 담긴 데이터를 출력하도록 만들면 다음과 같습니다.
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
impl Message {
fn call(&self) {
match self {
Self::Quit => println!("quit"),
Self::Move { x, y } => println!("move: {x}, {y}"),
Self::Write(text) => println!("write: {text}"),
Self::ChangeColor(r, g, b) => println!("color: {r}, {g}, {b}"),
}
}
}
fn main() {
Message::Quit.call();
let movement = Message::Move { x: 2, y: -1 };
movement.call();
let writing = Message::Write(String::from("hello"));
writing.call();
writing.call();
Message::ChangeColor(10, 20, 30).call();
}quit
move: 2, -1
write: hello
write: hello
color: 10, 20, 30call은 &self로 값을 빌려 읽기만 하므로, writing의 String이 이동하지 않아 같은 값으로 두 번 부를 수 있습니다. 이제 표준 라이브러리의 열거형 가운데 아주 유용하고 자주 쓰이는 Option을 봅니다.
Option 열거형이 널 값보다 좋은 점들
Option은 표준 라이브러리에 열거형으로 정의된 타입으로, 값이 있을 수도 있고 없을 수도 있는 아주 흔한 상황을 나타냅니다. 예를 들어 원소가 있는 목록의 첫 원소를 요청하면 값을 얻지만, 빈 목록의 첫 원소를 요청하면 아무것도 얻지 못합니다. 이 개념을 타입으로 나타내면, 처리해야 할 경우를 모두 처리했는지 컴파일러가 확인할 수 있어 다른 언어에서 흔한 오류를 막을 수 있습니다.
언어를 설계할 때는 무엇을 넣을지만큼 무엇을 넣지 않을지도 중요합니다. 러스트에는 다른 많은 언어에 있는 널(null)이 없습니다. 널은 값이 없다는 것을 나타내는 값으로, 널이 있는 언어에서 변수는 언제나 널이거나 널이 아닌 두 상태 가운데 하나입니다. 널을 만든 토니 호어(Tony Hoare)는 2009년 발표에서, 구현하기 쉽다는 이유로 널 참조를 넣은 것이 수많은 오류와 보안 취약점, 시스템 장애를 낳았다며 이를 10억 달러짜리 실수라고 불렀습니다.
널의 문제는 널인 값을 널이 아닌 값처럼 쓰려 할 때 오류가 난다는 것입니다. 널이거나 아닌 성질은 어디에나 있으므로 이런 실수를 하기가 아주 쉽습니다. 하지만 지금은 어떤 까닭으로 값이 없거나 유효하지 않다는, 널이 나타내려는 개념 자체는 여전히 쓸모가 있습니다. 문제는 개념이 아니라 그것을 구현한 방식에 있습니다. 그래서 러스트에는 널은 없지만, 값이 있거나 없다는 것을 나타내는 열거형 Option<T>가 표준 라이브러리에 다음과 같이 정의되어 있습니다.
enum Option<T> {
None,
Some(T),
}Option<T>는 워낙 유용해서 프렐류드에 들어 있으므로 따로 가져올 필요가 없고, 배리언트 Some과 None도 Option:: 없이 바로 쓸 수 있습니다. 그래도 Option<T>는 보통의 열거형이며, Some(T)와 None은 그 배리언트입니다. <T>는 아직 다루지 않은 제네릭 타입 매개변수(generic type parameter)로, 제네릭 데이터 타입에서 자세히 다룹니다. 지금은 <T> 덕분에 Some 배리언트가 어떤 타입의 데이터든 하나 담을 수 있고, T 자리에 구체적인 타입이 들어갈 때마다 Option<T> 전체가 다른 타입이 된다는 것만 알면 됩니다.
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 안에 값을 넣었으므로 러스트가 타입을 추론할 수 있습니다. 반면 absent_number는 None만으로는 Some이 어떤 타입을 담을지 알 수 없으므로 Option<i32>라고 직접 적었습니다. Some 값을 얻으면 값이 있고 그 값이 Some 안에 있다는 것을, None 값을 얻으면 유효한 값이 없다는 것을 알 수 있습니다.
그러면 Option<T>가 왜 널보다 나을까요? Option<T>와 T는 서로 다른 타입이므로, 컴파일러는 Option<T>를 확실히 유효한 값처럼 쓰지 못하게 합니다. 다음 코드는 i8에 Option<i8>을 더하려 하므로 컴파일되지 않습니다.
fn main() {
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`i8과 Option<i8>은 다른 타입이므로 러스트는 둘을 어떻게 더할지 모른다는 오류입니다. 러스트에서 i8 같은 타입의 값이 있으면 컴파일러는 그 값이 언제나 유효하다고 보장하므로, 쓰기 전에 널인지 확인할 필요 없이 안심하고 쓸 수 있습니다. 값이 없을지도 모른다고 걱정해야 하는 것은 Option<i8>을 다룰 때뿐이며, 컴파일러는 그 값을 쓰기 전에 값이 없는 경우를 처리했는지 확인합니다. 다만 이것은 타입이 보장하는 것이고, 나이가 음수가 아니라는 것 같은 프로그램의 규칙은 따로 검사해야 합니다.
다시 말해 T의 연산을 하려면 먼저 Option<T>를 T로 바꿔야 하고, 그때 None이면 어떻게 할지를 정해야 합니다. 이렇게 하면 널에서 가장 흔한 오류인, 실제로는 널인데 널이 아니라고 여기는 실수를 막을 수 있습니다. 값이 없을 수 있는 값은 반드시 타입을 Option<T>로 만들고, 그 값을 쓸 때 값이 없는 경우를 직접 처리해야 합니다. 타입이 Option<T>가 아닌 곳의 값은 언제나 있다고 믿어도 되며, 이것은 널이 퍼지는 것을 막고 러스트 코드를 더 안전하게 하려고 일부러 내린 설계 결정입니다.
그러면 Some 배리언트의 T 값은 어떻게 꺼낼까요? Option<T>에는 상황마다 쓸모 있는 메서드가 많으니 문서에서 필요한 것을 찾아보세요. 이 메서드들을 익혀 두면 러스트를 쓰는 데 큰 도움이 됩니다. 일반적으로는 각 배리언트를 처리하는 코드가 필요합니다. Some(T)일 때만 실행되어 안의 T를 쓰는 코드와, None일 때만 실행되어 T를 쓸 수 없는 코드입니다. 다음 글의 match가 바로 이런 일을 하는 제어 흐름 구조로, 열거형의 배리언트에 따라 다른 코드를 실행하고 그 코드에서 배리언트 안의 데이터를 쓸 수 있게 해 줍니다.
fn main() {
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = match y {
Some(n) => Some(x + n),
None => None,
};
println!("{sum:?}");
}Some(10)아래 그림처럼 y가 Some(5)이면 안의 n = 5를 꺼내 x와 더한 Some(10)을 만들고, y가 None이면 꺼낼 숫자가 없으므로 덧셈을 하지 않고 None을 만듭니다. 두 갈래 모두 같은 Option<i8> 타입의 값을 만들어 sum에 담습니다.
Option은 값의 부재와 처리 방식을 드러낸다
고정 x=5와 y=Some(5)/None에서 한 match 갈래를 골라 Some(10)/None을 표시하는 본문 실행이다. None이면 덧셈하지 않고 Some(0)과 구별하는 상태가 초보자의 값 추적에 유용하다.
OPTION<T> · PRESENCE CONTRACT
Option은 값의 부재를 타입과 처리 정책에 남긴다
T와 Option<T>는 서로 다른 타입입니다. Option<T>를 T처럼 바로 쓸 수 없으며, None일 때의 동작을 선택해야 합니다.
| 구분 | 보장하는 사실 | 사용할 때의 의미 |
|---|---|---|
Some(value) |
T 값이 안에 있습니다. |
패턴이나 메서드로 내부 값을 꺼내 사용합니다. |
None |
T 값이 없습니다. |
기본값, 무시, 조기 반환, 오류 등 부재 정책을 정합니다. |
match |
가능한 모든 경우를 덮어야 합니다. | 컴파일러가 패턴의 완전성을 검사합니다. |
if let Some(value) |
관심 있는 한 패턴을 다룹니다. | else가 없으면 나머지를 무시하며, 완전성 검사는 받지 않습니다. |
Option 메서드 |
메서드마다 None 계약이 다릅니다. |
unwrap_or(default)는 기본값을 돌려주고 expect(message)는 None에서 패닉합니다. |
Some(value)T값이 안에 있으므로 패턴이나 메서드로 꺼내 사용합니다.NoneT값이 없으므로 기본값, 무시, 조기 반환, 오류 같은 정책을 정합니다.match- 가능한 모든 경우를 덮어야 하며, 컴파일러가 완전성을 검사합니다.
if let Some(value)else가 없으면 나머지를 무시하며, 완전성 검사는 받지 않습니다.Option메서드unwrap_or(default)는 기본값을 돌려주고expect(message)는None에서 패닉합니다.
Option, Some, None은 프렐루드에 포함됩니다. 부재 가능성은 타입에 남고, 값을 꺼내는 코드가 처리 정책을 드러냅니다.이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 새 본문의 고정 x 5와 y Some 5/None을 받는 match 프로그램으로 5노드와 5간선이다. 입력→Some 패턴 조건. 예는 n 5를 꺼내 Some 10을 구성하고, 아니요는 숫자를 꺼내지 않고 None을 구성한다. 두 갈래는 같은 표시 노드로 합류하며 Some 결과에서 None 결과를 거쳐 표시하는 간선은 없다. 덧셈은 작은 고정값 조건이고 세로 도표에도 동일한 갈래와 합류를 유지한다. 기존 Option 처리 방식 표는 보조 비교로 그대로 남는다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 입력 x=5와 y=Some(5)를 입력으로 한 호출을 추적한다. |
|
|
| 패턴과 계산 Some(5)에서 n=5를 꺼내 x와 더한다. |
|
|
| 결과 표시 덧셈 결과 Some(10)을 반환하고 표시한다. |
|
|
Option은 값의 부재와 처리 방식을 드러낸다
고정 x=5와 y=Some(5)/None에서 한 match 갈래를 골라 Some(10)/None을 표시하는 본문 실행이다. None이면 덧셈하지 않고 Some(0)과 구별하는 상태가 초보자의 값 추적에 유용하다.
OPTION<T> · PRESENCE CONTRACT
Option은 값의 부재를 타입과 처리 정책에 남긴다
T와 Option<T>는 서로 다른 타입입니다. Option<T>를 T처럼 바로 쓸 수 없으며, None일 때의 동작을 선택해야 합니다.
| 구분 | 보장하는 사실 | 사용할 때의 의미 |
|---|---|---|
Some(value) |
T 값이 안에 있습니다. |
패턴이나 메서드로 내부 값을 꺼내 사용합니다. |
None |
T 값이 없습니다. |
기본값, 무시, 조기 반환, 오류 등 부재 정책을 정합니다. |
match |
가능한 모든 경우를 덮어야 합니다. | 컴파일러가 패턴의 완전성을 검사합니다. |
if let Some(value) |
관심 있는 한 패턴을 다룹니다. | else가 없으면 나머지를 무시하며, 완전성 검사는 받지 않습니다. |
Option 메서드 |
메서드마다 None 계약이 다릅니다. |
unwrap_or(default)는 기본값을 돌려주고 expect(message)는 None에서 패닉합니다. |
Some(value)T값이 안에 있으므로 패턴이나 메서드로 꺼내 사용합니다.NoneT값이 없으므로 기본값, 무시, 조기 반환, 오류 같은 정책을 정합니다.match- 가능한 모든 경우를 덮어야 하며, 컴파일러가 완전성을 검사합니다.
if let Some(value)else가 없으면 나머지를 무시하며, 완전성 검사는 받지 않습니다.Option메서드unwrap_or(default)는 기본값을 돌려주고expect(message)는None에서 패닉합니다.
Option, Some, None은 프렐루드에 포함됩니다. 부재 가능성은 타입에 남고, 값을 꺼내는 코드가 처리 정책을 드러냅니다.이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 새 본문의 고정 x 5와 y Some 5/None을 받는 match 프로그램으로 5노드와 5간선이다. 입력→Some 패턴 조건. 예는 n 5를 꺼내 Some 10을 구성하고, 아니요는 숫자를 꺼내지 않고 None을 구성한다. 두 갈래는 같은 표시 노드로 합류하며 Some 결과에서 None 결과를 거쳐 표시하는 간선은 없다. 덧셈은 작은 고정값 조건이고 세로 도표에도 동일한 갈래와 합류를 유지한다. 기존 Option 처리 방식 표는 보조 비교로 그대로 남는다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 입력 x=5와 y=Some(5)를 입력으로 한 호출을 추적한다. |
|
|
| 패턴과 계산 Some(5)에서 n=5를 꺼내 x와 더한다. |
|
|
| 결과 표시 덧셈 결과 Some(10)을 반환하고 표시한다. |
|
|
더 깊이: Option은 공간을 얼마나 더 차지할까
열거형의 값은 지금 어느 배리언트인지를 나타내는 정보와, 가장 큰 배리언트의 데이터를 담을 공간을 함께 가져야 합니다. i32는 모든 비트 패턴이 올바른 값이므로, 거기에 None이라는 상태 하나를 더한 Option<i32>는 i32보다 클 수밖에 없습니다. 다만 기본 표현의 열거형이 메모리에 어떻게 놓이는지는 Reference가 거의 정해 두지 않았으므로, 구체적인 크기는 std::mem::size_of로 확인할 수 있을 뿐 특정 값을 기대해서는 안 됩니다.
반대로 결코 가질 수 없는 비트 패턴이 있는 타입이라면, 그 패턴을 None으로 쓸 수 있습니다. 표준 라이브러리 문서의 Option 표현 절은 &U, &mut U, Box<U>, 함수 포인터, NonZeroU32 같은 num::NonZero* 타입, ptr::NonNull<U>, 그리고 이들 가운데 하나를 감싼 #[repr(transparent)] 구조체에 대해 Option<T>가 T와 크기, 정렬, 함수 호출 ABI가 같다고 보장합니다. 참조와 Box는 널일 수 없으므로 널 주소를 None에 쓰는 것이며, 이를 널 포인터 최적화(null pointer optimization)라고 부릅니다. 그래서 Option<&T>는 다른 언어의 널이 될 수 있는 포인터와 같은 크기이면서도, 값이 없는 경우를 처리했는지를 컴파일러가 확인해 줍니다.
정리
- 열거형은
enum으로 정의하며, 값이 나열한 배리언트 가운데 하나라는 것을 나타냅니다. - 배리언트는
열거형::배리언트로 쓰고, 배리언트마다 서로 다른 타입과 개수의 데이터를 담을 수 있습니다. - 배리언트의 이름은 그 열거형의 인스턴스를 만드는 함수처럼 쓰입니다.
- 열거형에도
impl로 메서드를 정의할 수 있습니다. - 러스트에는 널이 없고, 값이 있거나 없음은
Some(T)와None을 배리언트로 가진Option<T>로 나타냅니다. Option<T>와T는 다른 타입이므로,T로 쓰려면 먼저None인 경우를 처리해야 합니다.
연습 문제
1. 배리언트의 데이터 바꾸기
Message 예제에서 movement를 Message::Move { x: -3, y: 4 }로, 색상을 Message::ChangeColor(300, -1, 0)으로 바꾸면 그 두 줄은 무엇을 출력하나요? Message::Move { x: 2 }라고 쓰면 어떻게 되나요?
해설 보기
move: -3, 4와 color: 300, -1, 0을 출력합니다. 세 값이 모두 i32의 범위 안에 있으므로 타입은 맞지만, 300이나 -1이 올바른 색상 값인지는 타입이 확인해 주지 않으므로 따로 검사해야 합니다. Message::Move { x: 2 }는 Move 배리언트의 y 필드를 빠뜨렸으므로 컴파일되지 않습니다. 구조체처럼 이름 있는 필드를 가진 배리언트는 필드를 모두 채워야 합니다.
2. 0과 값 없음
match로 더하는 예제에서 y를 Some(0)으로 바꾸면, 그리고 None으로 바꾸면 각각 무엇이 출력되나요? Some(0)을 값이 없는 것으로 보아도 될까요?
해설 보기
Some(0)이면 숫자 0이 있으므로 5 + 0인 Some(5)를, None이면 숫자가 없으므로 None을 출력합니다. Some(0)은 값 0이 있는 상태이고 None은 값이 없는 상태이므로 서로 다릅니다. 어떤 프로그램은 값이 없을 때 0을 쓰기로 정할 수 있지만, 그것은 None일 때의 처리를 정한 것이지 두 상태가 같은 것은 아닙니다.
3. 어떤 타입으로 만들까
(가) 사용자의 중간 이름처럼 있을 수도 없을 수도 있는 값, (나) 신호등의 빨강, 노랑, 초록, (다) 원 또는 반지름과 사각형의 너비와 높이를 담는 도형에는 각각 어떤 타입이 알맞나요?
해설 보기
(가)는 값이 없을 수 있으므로 Option<String>이 알맞습니다. (나)는 정해진 세 경우 가운데 하나이므로 데이터 없는 배리언트 세 개를 가진 열거형 enum Light { Red, Yellow, Green }이 알맞습니다. (다)는 경우마다 담는 데이터가 다르므로 enum Shape { Circle(f64), Rectangle { width: f64, height: f64 } }처럼 배리언트마다 다른 데이터를 담는 열거형이 알맞습니다.
다음 글 match 제어 흐름 구조에서는 열거형의 배리언트마다 다른 코드를 실행하고, 배리언트 안의 값을 꺼내 쓰는 match를 자세히 살펴봅니다.