Devin.KR

구조체·열거형·match - 데이터 모양 정하기

개발자KR 조회 0

이 장에서 배우는 것

앞 장까지는 숫자와 문자열 같은 기본 값을 변수에 담고, 그 값을 빌려 주고 돌려받는 규칙을 익혔다. 실제 프로그램의 데이터는 낱개 값보다 "날짜, 금액, 분류가 한 묶음"처럼 모양이 있는 경우가 많다. 이 장에서는 그런 모양을 타입으로 직접 정의하는 방법을 다룬다. 묶음을 만드는 구조체(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를 쓸 수 있다. 이 장의 코드는 읽기 쉽도록 타입 이름을 그대로 적는다.

struct 는 모든 필드가 함께 들어 있고 enum 은 변형 중 하나의 자료만 들어 있다.

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 를 컴파일러가 전부 찾아 준다. 문자열로 분류를 다뤘다면 사람이 직접 검색해야 했을 일이다.

match 는 모든 변형에 팔이 있어야 컴파일되고 빠진 변형이 있으면 오류가 난다.

패턴으로 자료 꺼내기

패턴은 변형을 가려낼 뿐 아니라 그 안의 자료를 변수에 묶어 준다. Kind::Income { source }는 수입일 때만 맞고, 맞으면 source라는 이름으로 출처를 쓸 수 있게 한다. 쓰지 않을 필드는 ..로 생략한다.

자주 쓰는 패턴 모양
패턴맞는 값비고
Category::Food그 변형자료가 없는 변형
Command::Record(entry)Record 이고, 안의 값을 entry 에 묶음튜플 형태
Kind::Income { .. }Income 이면 필드 값과 무관하게 맞음필드를 쓰지 않을 때
0..=9_9990 이상 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 }여러 모양 중 하나변형마다 자료가 다를 수 있음
matchmatch v { P => 식 }변형별 처리빠진 경우는 컴파일 오류
if letif let P = v { }한 변형만 관심 있을 때완전성 검사를 받지 않음
match 대상이 값일 때와 참조일 때
대상패턴 변수의 타입원본예
값자료를 소유일부 또는 전부 이동match cmd
참조자료를 가리키는 참조그대로 유지match &self.kind

연습 문제

  1. Category에 Leisure 변형을 추가하고 나머지 코드는 그대로 두면 어떤 오류가 나는가. 어디를 어떻게 고쳐야 하는지 적어라.
  2. Entry에 지출이면 true, 수입이면 false를 돌려주는 메서드 is_expense를 match 로 작성하라.
  3. Ledger에 분류가 Food인 지출 기록의 개수를 세는 메서드 count_food를 if let 으로 작성하라.
  4. 금액을 받아 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 같은 범위 팔을 앞에 추가하면 된다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.