구조체·열거형·match - 데이터 모양 정하기
이 장에서 배우는 것
앞 장까지는 숫자와 문자열 같은 기본 값을 변수에 담고, 그 값을 빌려 주고 돌려받는 규칙을 익혔다. 실제 프로그램의 데이터는 낱개 값보다 "날짜, 금액, 분류가 한 묶음"처럼 모양이 있는 경우가 많다. 이 장에서는 그런 모양을 타입으로 직접 정의하는 방법을 다룬다. 묶음을 만드는 구조체(struct), 여러 가능성 중 하나를 나타내는 열거형(enum), 그 값을 종류별로 나눠 처리하는 match가 중심이다. 가계부의 수입·지출 기록을 이 도구들로 다시 설계한다.
- struct 로 관련 필드를 하나의 타입으로 묶고 impl 블록에 메서드를 붙인다.
- self 없이 호출하는 연관 함수로 값을 만드는 생성 함수를 작성한다.
- 데이터를 품는 enum 변형(variant)을 정의하고 소유권이 어떻게 움직이는지 설명한다.
- match 의 완전성 검사가 무엇을 막아 주는지 컴파일 오류로 확인한다.
- 변형 하나만 관심 있을 때 if let 으로 간결하게 쓴다.
문제 상황
가계부 도구의 첫 버전을 만든다고 하자. 기록 한 건을 (u32, i64, String) 같은 튜플이나 따로 떨어진 변수 세 개로 다루기 시작하면 곧 문제가 생긴다. 튜플의 두 번째 칸이 금액이라는 사실은 코드 어디에도 적혀 있지 않다. 지출 분류를 문자열 "식비"로 받으면 "식빙"이라고 잘못 쓴 기록도 컴파일이 통과하고, 집계할 때 별개 분류로 조용히 갈라진다. 수입에는 "출처"가 필요하고 지출에는 "분류"가 필요한데, 이를 한 타입에 억지로 넣으면 수입 기록의 분류 칸은 빈 문자열로 채우게 된다.
원하는 것은 세 가지다. 첫째, 필드에 이름이 붙은 하나의 기록 타입. 둘째, 수입과 지출이 서로 다른 자료를 갖되 하나의 타입으로 다뤄지는 것. 셋째, 나중에 지출 분류가 하나 늘어나면 고쳐야 할 곳을 컴파일러가 알려 주는 것. 구조체, 열거형, match 가 각각 이 세 가지에 대응한다.
struct 와 impl: 이름 붙은 묶음
구조체 정의와 생성
구조체는 여러 값을 필드 이름과 함께 하나로 묶는 타입이다. 필드마다 타입을 적는다.
struct Entry {
day: u32,
amount: i64,
kind: Kind,
}
값을 만들 때는 모든 필드를 빠짐없이 적는다. 변수 이름이 필드 이름과 같으면 Entry { day, amount, kind }처럼 day: day를 줄여 쓸 수 있다. 이를 필드 초기화 축약이라 부른다. 필드에 접근할 때는 entry.amount처럼 점을 쓴다. 구조체 값 전체가 변수 하나의 소유이므로, 필드를 바꾸려면 변수를 mut로 선언해야 한다. 특정 필드만 골라 불변으로 만드는 문법은 없다.
필드에 이름을 붙이지 않고 struct Won(i64);처럼 쓰는 튜플 구조체도 있다. 값 하나에 새 타입 이름을 주어 다른 정수와 섞이지 않게 할 때 쓴다. 이 장의 예제는 이름 있는 필드 방식만 쓴다.
impl 블록과 메서드
타입에 붙는 함수는 impl 블록에 모은다. 첫 매개변수가 self 계열이면 값.메서드() 형태로 부르는 메서드가 된다. 앞 장에서 배운 빌림 규칙이 여기서 그대로 이어진다.
| 선언 | 의미 | 호출 후 원래 값 | 쓰는 때 |
|---|---|---|---|
&self | 불변으로 빌림 | 그대로 사용 가능 | 읽기만 할 때 |
&mut self | 가변으로 빌림 | 호출이 끝나면 사용 가능 | 내용을 바꿀 때 |
self | 소유권을 넘겨받음 | 이동되어 사용 불가 | 값을 소비하고 다른 값으로 바꿀 때 |
대부분의 메서드는 &self로 충분하다. self로 받는 메서드는 호출한 쪽의 변수를 이동시키므로 정말 값을 소비해야 할 때만 쓴다.
연관 함수
첫 매개변수가 self 계열이 아닌 함수는 연관 함수(associated function)라 부른다. 값이 아직 없으니 Entry::income(...)처럼 타입 이름과 ::로 호출한다. 다른 언어의 생성자 역할을 하는 함수를 이렇게 만든다. Rust 에는 생성자 문법이 따로 없고 new도 관례상 붙이는 이름일 뿐이다. impl 블록 안에서는 타입 이름 대신 Self를 쓸 수 있다. 이 장의 코드는 읽기 쉽도록 타입 이름을 그대로 적는다.
enum: 여러 모양 중 하나
데이터를 담는 변형
열거형은 "이 값은 다음 중 하나다"를 나타낸다. 분류처럼 자료가 없는 변형만 나열할 수도 있다.
enum Category {
Food,
Transport,
Housing,
}
Rust 의 enum 은 변형마다 자료를 담을 수 있다는 점이 특징이다. 변형이 자료를 담는 방식은 세 가지다.
- 자료 없음:
Summary - 튜플 형태:
Record(Entry), 위치로 구분한다. - 구조체 형태:
Income { source: String }, 이름으로 구분한다.
가계부의 수입과 지출은 다음처럼 표현한다.
enum Kind {
Income { source: String },
Expense { category: Category },
}
이제 수입 기록에는 분류 칸이 존재하지 않고, 지출 기록에는 출처 칸이 존재하지 않는다. 빈 문자열로 채워 넣던 칸이 타입에서 사라진 것이다. 분류 오타도 Category::Foood가 컴파일 오류가 되므로 실행 전에 잡힌다. enum 값 하나가 차지하는 메모리는 가장 큰 변형의 자료에 "지금 어느 변형인지"를 표시하는 작은 꼬리표를 더한 크기다. 어느 변형인지는 값 자체가 알고 있으므로 따로 저장할 필요가 없다.
변형은 Category::Food처럼 enum 이름을 앞에 붙여 쓴다. enum 도 impl 블록에 메서드를 붙일 수 있다.
match 와 if let: 변형별로 나누기
match 는 모든 경우를 요구한다
enum 값을 꺼내 쓰는 기본 도구가 match다. 값을 위에서부터 패턴과 차례로 비교해 처음 맞는 팔(arm)의 식을 실행하고, match 전체가 그 결과 값이 된다. 제어 흐름에서 본 if 와 마찬가지로 match 도 식이다.
fn label(self) -> &'static str {
match self {
Category::Food => "식비",
Category::Transport => "교통",
Category::Housing => "주거",
}
}
핵심은 완전성 검사(exhaustiveness check)다. 가능한 값이 하나라도 빠지면 컴파일되지 않는다. 위 코드에서 Transport 줄을 지우면 다음 오류가 난다.
error[E0004]: non-exhaustive patterns: `Category::Transport` not covered
오류는 "Transport 를 처리하는 팔이 없다"는 뜻이다. 분류를 새로 추가했을 때도 같은 방식으로, 고쳐야 할 match 를 컴파일러가 전부 찾아 준다. 문자열로 분류를 다뤘다면 사람이 직접 검색해야 했을 일이다.
패턴으로 자료 꺼내기
패턴은 변형을 가려낼 뿐 아니라 그 안의 자료를 변수에 묶어 준다. Kind::Income { source }는 수입일 때만 맞고, 맞으면 source라는 이름으로 출처를 쓸 수 있게 한다. 쓰지 않을 필드는 ..로 생략한다.
| 패턴 | 맞는 값 | 비고 |
|---|---|---|
Category::Food | 그 변형 | 자료가 없는 변형 |
Command::Record(entry) | Record 이고, 안의 값을 entry 에 묶음 | 튜플 형태 |
Kind::Income { .. } | Income 이면 필드 값과 무관하게 맞음 | 필드를 쓰지 않을 때 |
0..=9_999 | 0 이상 9999 이하인 정수 | 양 끝을 포함하는 범위 |
_ | 무엇이든 맞음 | 남은 경우를 한꺼번에 처리 |
정수도 match 할 수 있다. 정수는 값의 종류가 사실상 무한하므로 마지막에 _가 있어야 완전해진다.
fn size_label(amount: i64) -> &'static str {
match amount {
0..=9_999 => "소액",
10_000..=99_999 => "보통",
_ => "고액",
}
}
소유권과 match
match 대상이 참조인지 값인지에 따라 소유권 동작이 달라진다. match &self.kind처럼 참조를 대상으로 하면 패턴이 묶는 변수는 안쪽 자료를 가리키는 참조가 되어 원본은 그대로 남는다. 반대로 Command::Record(entry)를 값으로 match 하면 entry가 자료의 소유권을 가져간다. 앞 장의 빌림 규칙과 같은 원리를 패턴이 따르는 것이다. 뒤의 "자주 틀리는 것"에서 이 차이가 만드는 오류를 본다.
if let: 한 변형만 볼 때
지출 집계는 Expense만 필요하고 나머지는 무시한다. 이럴 때 match 에 _ => {}를 붙이는 대신 if let을 쓴다.
if let Kind::Expense { category } = &e.kind {
// 지출일 때만 여기로 온다
}
if let 패턴 = 값은 패턴이 맞을 때만 블록을 실행한다. else를 붙여 맞지 않는 경우를 처리할 수도 있다. 간결한 대신 완전성 검사를 받지 않으므로, 모든 변형을 구분해야 하는 곳에서는 match 를 쓴다. 완성 코드에 나오는 Vec의 pop은 "값이 있을 수도, 없을 수도 있다"를 나타내는 Some(값) 또는 None을 돌려준다. 이 타입은 다음 장에서 자세히 다루고, 이 장에서는 if let 이 Some(last) 패턴에도 똑같이 적용된다는 점만 확인한다. Vec과 HashMap도 이후 장의 주제이므로 여기서는 "값을 여러 개 담는 상자"와 "키로 찾는 표" 정도로만 이해하고 쓴다.
패턴 문법의 전체 목록은 Rust 레퍼런스의 패턴 항목에서 확인할 수 있다.
완성 코드
수입·지출 기록 타입, 명령 enum, 집계 보고서를 한 파일에 모았다. src/main.rs에 그대로 붙여 넣는다.
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Category {
Food,
Transport,
Housing,
}
impl Category {
fn label(self) -> &'static str {
match self {
Category::Food => "식비",
Category::Transport => "교통",
Category::Housing => "주거",
}
}
}
enum Kind {
Income { source: String },
Expense { category: Category },
}
struct Entry {
day: u32,
amount: i64,
kind: Kind,
}
impl Entry {
fn income(day: u32, amount: i64, source: &str) -> Entry {
Entry {
day,
amount,
kind: Kind::Income {
source: source.to_string(),
},
}
}
fn expense(day: u32, amount: i64, category: Category) -> Entry {
Entry {
day,
amount,
kind: Kind::Expense { category },
}
}
fn signed_amount(&self) -> i64 {
match &self.kind {
Kind::Income { .. } => self.amount,
Kind::Expense { .. } => -self.amount,
}
}
fn describe(&self) -> String {
match &self.kind {
Kind::Income { source } => {
format!("{}일 수입 {}원 ({})", self.day, self.amount, source)
}
Kind::Expense { category } => {
format!(
"{}일 지출 {}원 [{}]",
self.day,
self.amount,
category.label()
)
}
}
}
}
struct Ledger {
entries: Vec<Entry>,
}
impl Ledger {
fn new() -> Ledger {
Ledger {
entries: Vec::new(),
}
}
fn add(&mut self, entry: Entry) {
self.entries.push(entry);
}
fn balance(&self) -> i64 {
self.entries.iter().map(|e| e.signed_amount()).sum()
}
fn expense_by_category(&self) -> HashMap<Category, i64> {
let mut totals = HashMap::new();
for e in &self.entries {
if let Kind::Expense { category } = &e.kind {
*totals.entry(*category).or_insert(0) += e.amount;
}
}
totals
}
fn report(&self) {
println!("--- 보고서 ---");
println!("잔액: {}원", self.balance());
let mut rows: Vec<(Category, i64)> = self.expense_by_category().into_iter().collect();
rows.sort_by(|a, b| b.1.cmp(&a.1));
for (category, total) in rows {
println!("{}: {}원", category.label(), total);
}
}
}
enum Command {
Record(Entry),
Undo,
Report,
}
fn run(ledger: &mut Ledger, cmd: Command) {
match cmd {
Command::Record(entry) => {
println!("기록: {}", entry.describe());
ledger.add(entry);
}
Command::Undo => {
if let Some(last) = ledger.entries.pop() {
println!("취소: {}", last.describe());
} else {
println!("취소할 내역이 없다");
}
}
Command::Report => ledger.report(),
}
}
fn main() {
let mut ledger = Ledger::new();
let commands = vec![
Command::Record(Entry::income(1, 2_500_000, "월급")),
Command::Record(Entry::expense(2, 550_000, Category::Housing)),
Command::Record(Entry::expense(3, 12_000, Category::Food)),
Command::Record(Entry::expense(4, 1_450, Category::Transport)),
Command::Record(Entry::expense(5, 38_000, Category::Food)),
Command::Undo,
Command::Record(Entry::expense(5, 8_000, Category::Food)),
Command::Report,
];
for cmd in commands {
run(&mut ledger, cmd);
}
}
줄별 해설
Category 와 Kind
#[derive(...)]는 컴파일러가 트레이트 구현을 자동으로 만들게 하는 속성이다. 트레이트는 뒤에서 자세히 다룬다. 여기서는 Copy와 Clone 덕에 Category가 정수처럼 복사되고, Eq와 Hash 덕에 HashMap의 키로 쓸 수 있다는 것만 알면 된다. label(self)는 값을 받지만 Category가 Copy이므로 호출한 뒤에도 원래 값을 계속 쓸 수 있다. 변형 세 개를 모두 나열했기 때문에 _가 없다. Kind는 변형마다 필드가 다르다.
Entry 와 연관 함수
Entry::income과 Entry::expense는 self가 없는 연관 함수다. 출처를 &str로 빌려 받아 to_string()으로 소유하는 String을 만들어 저장한다. 그래서 Entry는 빌려 온 자료에 의존하지 않고 스스로 자료를 소유한다. 필드 초기화 축약 덕에 day, amount,는 이름을 한 번만 쓴다. signed_amount는 지출을 음수로 바꿔 합계 계산을 단순하게 한다. 두 팔 모두 필드를 쓰지 않으므로 { .. }로 충분하다. describe는 &self.kind를 match 하므로 source와 category는 안쪽 자료를 가리키는 참조이고 Entry는 그대로 남는다.
Ledger
add는 &mut self로 내용을 바꾸고 entry의 소유권을 Vec에 넘긴다. balance는 각 기록의 부호 붙은 금액을 더한다. 반환 타입이 i64이므로 sum()의 결과 타입이 추론된다. expense_by_category는 if let 으로 지출만 걸러 낸다. category는 참조이므로 *category로 Copy 값을 꺼내 키로 쓴다. entry(키).or_insert(0)은 키가 없으면 0 으로 만들고 그 자리의 가변 참조를 돌려주므로 +=로 누적할 수 있다. report는 HashMap의 순회 순서가 일정하지 않으므로 Vec에 옮겨 금액이 큰 순으로 정렬한 뒤 출력한다. 정렬하지 않으면 실행마다 출력 순서가 달라질 수 있다.
Command 와 run
Command는 세 가지 명령을 하나의 타입으로 묶는다. Record(Entry)만 자료를 품는다. run의 match 는 cmd를 값으로 받으므로 Record(entry) 팔에서 entry가 소유권을 얻고, 출력한 뒤 ledger.add(entry)로 이동시킨다. Undo 팔에서는 pop이 돌려준 Some(last)를 if let 으로 열어 마지막 기록을 취소한다. 기록이 없어서 None이 돌아오면 else 로 간다. 마지막 팔은 식 하나이므로 중괄호가 없다.
main
명령 여덟 개를 vec!에 담고 for cmd in commands로 하나씩 꺼내 run에 넘긴다. 반복하면서 각 명령의 소유권이 cmd로 옮겨 가고, run이 끝나면 정리된다. 5일의 38,000원 지출은 Undo로 취소되어 이후 집계에 들어가지 않는다.
실행 결과
$ cargo run
기록: 1일 수입 2500000원 (월급)
기록: 2일 지출 550000원 [주거]
기록: 3일 지출 12000원 [식비]
기록: 4일 지출 1450원 [교통]
기록: 5일 지출 38000원 [식비]
취소: 5일 지출 38000원 [식비]
기록: 5일 지출 8000원 [식비]
--- 보고서 ---
잔액: 1928550원
주거: 550000원
식비: 20000원
교통: 1450원
잔액은 수입 2,500,000원에서 지출 550,000 + 12,000 + 1,450 + 8,000 = 571,450원을 뺀 값이다. 식비는 취소되지 않은 12,000원과 8,000원의 합인 20,000원이다.
실무에서 자주 틀리는 것
참조 뒤의 enum 에서 자료를 꺼내 이동시키려 한다
&self로 빌린 상태에서 match self.kind처럼 값을 대상으로 하면, 패턴이 String의 소유권을 가져가려 한다.
fn source_text(&self) -> String {
match self.kind {
Kind::Income { source } => source,
Kind::Expense { .. } => String::new(),
}
}
error[E0507]: cannot move out of `self.kind` as enum variant `Income` which is behind a shared reference
빌린 값에서 소유권을 가져갈 수는 없다. 대상을 참조로 바꾸고, 필요하면 복제해서 돌려준다.
fn source_text(&self) -> String {
match &self.kind {
Kind::Income { source } => source.clone(),
Kind::Expense { .. } => String::new(),
}
}
_ 로 남은 변형을 뭉뚱그린다
_는 편하지만 완전성 검사를 스스로 꺼 버린다.
fn label(self) -> &'static str {
match self {
Category::Food => "식비",
_ => "기타",
}
}
이 코드는 나중에 Category::Leisure를 추가해도 오류 없이 컴파일되고, 새 분류가 조용히 "기타"로 표시된다. 변형이 늘 수 있는 enum 이라면 각 변형을 적는다.
fn label(self) -> &'static str {
match self {
Category::Food => "식비",
Category::Transport => "교통",
Category::Housing => "주거",
}
}
_는 정수 범위처럼 값이 사실상 무한한 경우나, "나머지는 정말 모두 같은 처리"가 요구사항일 때만 쓴다.
mut 없이 필드를 바꾼다
let e = Entry::income(1, 2_000, "용돈");
e.amount = 3_000;
error[E0594]: cannot assign to `e.amount`, as `e` is not declared as mutable
구조체 필드의 가변성은 변수 선언을 따른다. 바꿀 값이라면 변수에 mut를 붙인다.
let mut e = Entry::income(1, 2_000, "용돈");
e.amount = 3_000;
self 로 받는 메서드를 부른 뒤 원래 값을 쓴다
impl Entry {
fn into_text(self) -> String {
self.describe()
}
}
let e = Entry::income(1, 2_000, "용돈");
let text = e.into_text();
println!("{}", e.describe());
error[E0382]: borrow of moved value: `e`
into_text(self)가 e의 소유권을 가져갔기 때문이다. 값을 소비할 이유가 없다면 &self로 받는다.
impl Entry {
fn to_text(&self) -> String {
self.describe()
}
}
한눈에 보기
| 개념 | 문법 | 언제 쓰나 | 주의점 |
|---|---|---|---|
| 구조체 | struct Entry { day: u32 } | 필드가 모두 함께 있는 묶음 | 바꾸려면 변수에 mut |
| 메서드 | fn f(&self) | 값에 붙는 동작 | self 는 소유권을 소비 |
| 연관 함수 | Entry::income(..) | 값을 만드는 생성 함수 | self 가 없어 :: 로 호출 |
| 열거형 | enum Kind { A { x: i64 }, B } | 여러 모양 중 하나 | 변형마다 자료가 다를 수 있음 |
| match | match v { P => 식 } | 변형별 처리 | 빠진 경우는 컴파일 오류 |
| if let | if let P = v { } | 한 변형만 관심 있을 때 | 완전성 검사를 받지 않음 |
| 대상 | 패턴 변수의 타입 | 원본 | 예 |
|---|---|---|---|
| 값 | 자료를 소유 | 일부 또는 전부 이동 | match cmd |
| 참조 | 자료를 가리키는 참조 | 그대로 유지 | match &self.kind |
연습 문제
Category에Leisure변형을 추가하고 나머지 코드는 그대로 두면 어떤 오류가 나는가. 어디를 어떻게 고쳐야 하는지 적어라.Entry에 지출이면true, 수입이면false를 돌려주는 메서드is_expense를 match 로 작성하라.Ledger에 분류가Food인 지출 기록의 개수를 세는 메서드count_food를 if let 으로 작성하라.- 금액을 받아 1만 원 미만은 "소액", 1만 원 이상 10만 원 미만은 "보통", 그 이상은 "고액"을 돌려주는 함수를 작성하라.
정답과 해설
1번. Category::label의 match 가 새 변형을 처리하지 않으므로 error[E0004]: non-exhaustive patterns: `Category::Leisure` not covered가 난다. match 에 Category::Leisure => "여가", 팔을 추가하면 된다. 이 파일에서는 Leisure를 만드는 코드가 없으므로 그대로 두면 사용되지 않는 변형에 대한 경고가 날 수 있다. 실제로 쓰는 기록을 하나 추가해 함께 확인하면 좋다. 핵심은 고칠 위치를 컴파일러가 알려 준다는 점이다.
2번.
fn is_expense(&self) -> bool {
match &self.kind {
Kind::Income { .. } => false,
Kind::Expense { .. } => true,
}
}
자료를 쓰지 않으므로 ..로 필드를 생략했다. 참조로 match 했으므로 Entry는 이동하지 않는다.
3번.
fn count_food(&self) -> usize {
let mut count = 0;
for e in &self.entries {
if let Kind::Expense {
category: Category::Food,
} = &e.kind
{
count += 1;
}
}
count
}
패턴 안에 category: Category::Food처럼 변형을 직접 적으면 분류까지 한 번에 검사한다. 변수를 묶지 않으므로 category를 따로 꺼낼 필요가 없다. 개수를 세는 변수 count는 값이 바뀌므로 mut다.
4번.
fn size_label(amount: i64) -> &'static str {
match amount {
0..=9_999 => "소액",
10_000..=99_999 => "보통",
_ => "고액",
}
}
범위 패턴은 양 끝을 포함한다. 정수 전체를 범위로 다 덮을 수는 없으므로 _가 필요하다. 이 함수에서 _는 10만 원 이상뿐 아니라 음수도 "고액"으로 분류한다. 음수를 다르게 취급하려면 i64::MIN..=-1 같은 범위 팔을 앞에 추가하면 된다.