Enum 정의하기
Enum 정의하기
struct가 서로 연관된 필드와 데이터를 묶어 주는 방법이라면, enum은 값이 가능한 값들의 집합 중 하나라는 것을 표현하는 방법이에요. 예를 들어 Rectangle이 Circle과 Triangle을 포함한 여러 도형 중 하나라고 표현하고 싶을 수 있는데요, Rust에서는 이런 가능성들을 enum으로 부호화할 수 있어요.
코드로 표현하고 싶은 상황 하나를 보면서, 이 경우에 왜 struct보다 enum이 더 적절한지 살펴볼게요. IP 주소를 다뤄야 한다고 해 봐요. 현재 IP 주소에는 크게 두 가지 표준이 쓰여요. 버전 4와 버전 6이죠. 프로그램이 만나는 IP 주소는 결국 이 두 가지뿐이기 때문에, 가능한 모든 variant를 나열할 수 있어요. 바로 이 '나열(enumeration)'에서 enum이라는 이름이 나온 거예요.
어떤 IP 주소든 버전 4이거나 버전 6이지, 동시에 둘 다일 수는 없어요. IP 주소의 이런 성질 덕분에 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는 식별자 아래에 이름공간(namespace)으로 묶이며, 둘을 구분할 때 쌍점 두 개(::)를 써요. 이게 유용한 이유는 IpAddrKind::V4와 IpAddrKind::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장에서 막 struct를 배웠기 때문에, 이 문제를 Listing 6-1처럼 struct로 풀고 싶어질 거예요.
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: struct를 사용해 IP 주소의 데이터와 IpAddrKind variant를 저장하기
여기서 두 필드를 가진 IpAddr라는 struct를 정의했어요. kind 필드는 타입이 IpAddrKind(아까 정의한 enum)이고, address 필드는 타입이 String이에요. 이 struct의 인스턴스는 두 개예요. 첫 번째는 home인데, kind 값으로 IpAddrKind::V4를, 연관 데이터로 127.0.0.1을 가져요. 두 번째 인스턴스는 loopback인데, kind 값으로 다른 variant인 IpAddrKind::V6를, 연관된 ::1 주소를 가져요. kind와 address 값을 struct로 묶어서 variant가 그 값과 연관되게 만들었어요.
하지만 같은 개념을 enum만으로도 더 간결하게 표현할 수 있어요. struct 안에 enum을 넣는 대신, 각 enum variant에 직접 데이터를 넣는 거예요. 이 새 IpAddr enum 정의는 V4와 V6 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"));
}
각 variant에 데이터를 직접 붙이니 별도의 struct가 필요 없어요. 여기서 enum이 어떻게 동작하는지에 대한 또 다른 세부 내용도 더 쉽게 눈에 들어와요. 우리가 정의하는 각 enum variant의 이름은 enum의 인스턴스를 생성하는 함수가 되기도 해요. 즉 IpAddr::V4()는 String 인자를 받아 IpAddr 타입의 인스턴스를 반환하는 함수 호출이라는 거죠. enum을 정의하면 이 생성자 함수가 자동으로 정의돼요.
struct 대신 enum을 쓰는 또 다른 장점이 있어요. variant마다 연관 데이터의 타입과 양이 달라도 된다는 거예요. 버전 4 주소는 항상 0과 255 사이의 값을 가진 숫자 요소 네 개를 가지죠. V4 주소는 u8 값 네 개로 저장하고 싶은데 V6 주소는 String 값 하나로 표현하고 싶다면 struct로는 불가능해요. 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마다 다르게 정의된 두 개의 서로 다른 struct 형태로 주소 데이터를 variant 안에 내장하고 있어요.
let ip = std::net::IpAddr::from([127, 0, 0, 1]);
#![allow(unused)]
fn main() {
struct Ipv4Addr {
// --snip--
}
struct Ipv6Addr {
// --snip--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
}
이 코드는 enum variant 안에는 어떤 종류의 데이터든 넣을 수 있다는 걸 보여줘요. String 같은 문자열, 숫자 타입, struct처럼요. 심지어 다른 enum도 포함할 수 있어요! 또 표준 라이브러리 타입도 우리가 직접 만든 것보다 그리 복잡하지 않은 경우가 많아요.
표준 라이브러리에 IpAddr 정의가 있다고 해도, 우리가 그 정의를 스코프 안으로 가져오지 않았기 때문에 우리 자신의 정의를 충돌 없이 만들고 쓸 수 있다는 점에 주목하세요. 타입을 스코프 안으로 가져오는 방법은 7장에서 더 자세히 다룰게요.
Listing 6-2의 enum 예시를 하나 더 볼게요. variant에 다양한 타입이 내장돼 있어요.
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:struct처럼 이름 붙은 필드를 가져요.Write:String하나를 포함해요.ChangeColor:i32값 세 개를 포함해요.
Listing 6-2의 variant들처럼 어느 variant든 가진 enum을 정의하는 것은 서로 다른 종류의 struct 정의와 비슷해요. 다만 enum은 struct 키워드를 쓰지 않을 뿐이고, 모든 variant가 Message 타입 아래에 함께 묶여요. 앞선 enum variant들이 담는 것과 같은 데이터를 담는 struct는 다음과 같아요.
struct QuitMessage; // unit struct
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct
fn main() {}
하지만 서로 다른 struct들을 쓰면 각각이 자기만의 타입을 가지기 때문에, Listing 6-2의 Message enum(단일 타입)을 쓸 때처럼 어떤 종류의 메시지든 쉽게 받는 함수를 정의하기가 어려워져요.
enum과 struct 사이의 비슷한 점이 하나 더 있어요. impl로 struct에 메서드를 정의할 수 있는 것처럼 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 타입은 값일 수도 있고 아무것도 아닐 수도 있는 아주 흔한 시나리오를 부호화해요.
예를 들어 비어 있지 않은 리스트에서 첫 번째 항목을 요청하면 값을 얻지요. 빈 리스트에서 첫 번째 항목을 요청하면 아무것도 얻지 못해요. 이 개념을 타입 시스템으로 표현하면 컴파일러가 처리해야 할 모든 경우를 다 처리했는지 확인할 수 있어요. 이 기능은 다른 프로그래밍 언어에서 극도로 흔한 버그를 막아 줄 수 있어요.
프로그래밍 언어 설계는 어떤 기능을 포함하느냐로 생각되곤 하지만, 어떤 기능을 뺐느냐도 중요해요. Rust에는 다른 많은 언어가 가진 null 기능이 없어요. null은 '값이 없다'는 뜻의 값이에요. null이 있는 언어에서는 변수가 항상 상태 두 가지 중 하나일 수 있어요. null이거나 null이 아니거나요.
null의 창시자인 Tony Hoare는 2009년 발표 "Nullable References: The Billion Dollar Mistake"에서 이렇게 말했어요.
저는 이것을 십억 달러짜리 실수라고 부릅니다. 그 당시 저는 객체 지향 언어에서 참조를 위한 최초의 포괄적인 타입 시스템을 설계하고 있었죠. 제 목표는 컴파일러가 자동으로 수행하는 검사로 모든 참조 사용이 절대적으로 안전하도록 하는 것이었어요. 그런데 구현하기가 너무 쉬운 나머지 null 참조를 넣지 않을 수 없었죠. 그 결과 지난 40년 동안 셀 수 없이 많은 오류와 취약점, 시스템 크래시가 발생했고, 아마 십억 달러 상당의 고통과 손해를 입혔을 겁니다.
null 값의 문제는 null이 아닌 값으로 null 값을 사용하려 하면 어떤 종류의 오류가 발생한다는 거예요. null이거나 null이 아닌 성질이 어디에나 퍼져 있기 때문에 이런 오류를 만들기가 극도로 쉬워요. 그런데 null이 표현하려는 개념 자체는 여전히 유용해요. null은 어떤 이유로 현재 유효하지 않거나 부재한 값이라는 뜻이니까요.
문제는 개념 자체가 아니라 그 특정 구현에 있어요. 그래서 Rust에는 null이 없지만, 값이 존재하는지 부재하는지를 부호화하는 enum은 있어요. 이 enum이 Option<T>이고, 표준 라이브러리에 이렇게 정의되어 있어요.
#![allow(unused)]
fn main() {
enum Option<T> {
None,
Some(T),
}
}
Option<T> enum은 너무 유용해서 prelude에도 포함돼 있어요. 굳이 스코프로 가져올 필요가 없죠. 그 variant들도 prelude에 포함되어 있어서 Some과 None을 Option:: 접두사 없이 바로 쓸 수 있어요. Option<T> enum은 여전히 평범한 enum이고, Some(T)와 None은 여전히 Option<T> 타입의 variant예요.
<T> 문법은 우리가 아직 다루지 않은 Rust의 기능이에요. 제네릭 타입 파라미터로, 자세한 내용은 10장에서 다룰 거예요. 지금으로서는 <T>가 Option enum의 Some variant가 어떤 타입의 데이터든 하나 담을 수 있다는 뜻이고, T 자리에 들어가는 각 구체적인 타입이 전체 Option<T> 타입을 서로 다른 타입으로 만든다는 것만 알면 돼요. Option 값을 사용해 숫자 타입과 char 타입을 담는 예시를 몇 개 볼게요.
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 안에 값을 지정했기 때문에 Rust가 이 타입들을 추론할 수 있어요. absent_number의 경우에는 Rust에게 전체 Option 타입을 명시하라고 요구해요. 컴파일러는 None 값만 보고 대응하는 Some variant가 담을 타입을 추론할 수 없거든요. 여기서는 absent_number를 Option<i32> 타입으로 의도했다고 Rust에게 말해 주는 거예요.
Some 값을 가지면 값이 존재한다는 걸 알고, 그 값은 Some 안에 담겨 있어요. None 값을 가지면 어떤 의미에서는 null과 같은 뜻이에요. 유효한 값이 없다는 뜻이죠. 그럼 Option<T>가 null보다 나은 점은 뭘까요?
간단히 말하면 Option<T>와 T(여기서 T는 어떤 타입이든 될 수 있어요)는 서로 다른 타입이기 때문에, 컴파일러가 Option<T> 값을 '확실히 유효한 값'처럼 쓰지 못하게 해요. 예를 들어 이 코드는 Option<i8>에 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
꽤 강렬하죠? 이 오류 메시지가 실질적으로 뜻하는 바는, i8과 Option<i8>이 서로 다른 타입이기 때문에 Rust가 어떻게 더해야 할지 모른다는 거예요. Rust에서 i8 같은 타입의 값을 가지면 컴파일러가 항상 유효한 값을 가진다는 것을 보장해 주므로, 그 값을 사용하기 전에 null인지 확인할 필요 없이 안심하고 진행할 수 있어요. Option<i8>(혹은 작업 중인 어떤 타입이든)를 가질 때만 값이 없을지도 모른다는 걱정을 해야 하고, 컴파일러가 그 값을 사용하기 전에 그 경우를 처리하도록 해요.
다시 말해, Option<T>로 T 연산을 수행하려면 먼저 Option<T>를 T로 변환해야 해요. 일반적으로 이는 null의 가장 흔한 문제 중 하나를 잡아 주는데, 바로 실제로는 null인데 null이 아니라고 가정하는 문제예요. null이 아닌 값을 잘못 가정할 위험을 없애면 코드를 더 자신 있게 만들 수 있어요. null일 가능성이 있는 값을 가지려면 그 값의 타입을 Option<T>로 만들어 명시적으로 동의해야 해요. 그리고 그 값을 사용할 때는 값이 null인 경우를 명시적으로 처리해야 해요. 값의 타입이 Option<T>가 아닌 곳에서는 그 값이 null이 아니라고 안전하게 가정할 수 있어요. 이는 Rust의 의도적인 설계 결정으로, null의 보편성을 제한하고 Rust 코드의 안전성을 높이기 위한 거예요.
그렇다면 타입이 Option<T>인 값을 가질 때, 그 값을 쓸 수 있도록 Some variant에서 T 값을 어떻게 꺼낼까요? Option<T> enum에는 다양한 상황에서 유용한 메서드가 아주 많아요. 그 문서에서 확인할 수 있어요. Option<T>의 메서드에 익숙해지는 것은 Rust 여정 내내 아주 유용할 거예요.
일반적으로 Option<T> 값을 사용하려면 각 variant를 처리하는 코드가 있기를 원해요. Some(T) 값을 가질 때만 실행되는 코드가 필요하고, 그 코드는 안의 T를 쓸 수 있어야 해요. 그리고 None 값을 가질 때만 실행되는 다른 코드가 필요하고, 그 코드는 쓸 수 있는 T 값이 없어요. match 표현식은 enum과 함께 쓰면 정확히 이 역할을 하는 제어 흐름 구성이에요. enum의 어떤 variant를 가지느냐에 따라 다른 코드를 실행하고, 그 코드는 일치하는 값 안의 데이터를 사용할 수 있어요.
더 알아보기
- 이번 장에서 다루지 않은
Option<T>의 생성자·메서드 활용을 더 깊게 보고 싶다면 Rust 표준 라이브러리의Option문서를 확인해 보세요. match표현식으로 enum의 variant별 처리를 하는 방법은 6장 2절 "match흐름 제어 연산자"에서 이어서 배울 수 있어요.Some/None에서 값을 꺼내는 또 다른 방법인if let간결 문법은 6장 3절 "if let로의 간결한 흐름 제어"에서 다뤄요.- 타입을 스코프로 가져오는 방법(표준 라이브러리의
IpAddr를 직접 쓰고 싶을 때)은 7장 "패키지, 크레이트, 모듈로 성장하는 프로젝트 관리"에서 다룰게요. <T>제네릭 타입 파라미터의 자세한 내용은 10장에서 배울 수 있어요.