6 분 소요

이전 글에서 ownership, borrowing, lifetime을 정리했다면, 이제는 실제 데이터를 어떻게 모델링하고 공통 동작을 어떻게 표현할지 배울 차례다. Rust에서는 여러 필드를 하나로 묶을 때 struct를 사용하고, 여러 경우 중 하나를 표현할 때 enum을 사용한다. 그리고 matchif let은 이런 값을 안전하게 분기 처리하는 핵심 문법이며, trait는 여러 타입이 같은 동작을 공유하도록 만드는 도구다.

이번 글에서는 struct, enum, pattern matching, trait를 한 흐름으로 정리하면서, Rust에서 데이터와 동작을 어떻게 설계하는지 기초를 잡아 본다.

실습 프로젝트 만들기

아래처럼 새 Cargo 프로젝트를 만든 뒤 src/main.rs에서 예제를 하나씩 실행해 보면 된다.

cargo new rust-structs-enums-traits
cd rust-structs-enums-traits
code .

예제를 붙여 넣은 뒤에는 아래 명령으로 실행하면 된다.

cargo run

Struct: 관련 데이터를 하나로 묶기

struct는 서로 관련된 여러 필드를 하나의 이름 아래 묶고 싶을 때 사용한다. 예를 들어 사용자 정보를 각각 따로 변수로 두는 대신, 하나의 사용자 타입으로 표현할 수 있다.

struct User {
    username: String,
    active: bool,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        username: String::from("k4nul"),
        active: true,
        sign_in_count: 1,
    };

    println!("username = {}", user1.username);
    println!("active = {}", user1.active);
    println!("sign_in_count = {}", user1.sign_in_count);
}

실행 결과는 아래와 같다.

struct 예제 결과 1

User처럼 이름 있는 필드를 가지는 형태를 named struct라고 보면 된다. 각 값이 어떤 의미인지 이름으로 바로 드러나기 때문에 읽기가 쉽다.

새 값을 만들 때는 아래처럼 field init shorthand도 자주 사용한다.

struct User {
    username: String,
    active: bool,
    sign_in_count: u64,
}

fn build_user(username: String) -> User {
    User {
        username,
        active: true,
        sign_in_count: 1,
    }
}

fn main() {
    let user = build_user(String::from("rustacean"));
    println!("{}", user.username);
}

실행 결과는 아래와 같다.

struct 예제 결과 2

여기서는 함수 인자 이름 username과 struct 필드 이름 username이 같기 때문에 username: username을 줄여서 쓸 수 있다.

impl로 Struct에 메서드 붙이기

Rust에서 struct에 동작을 붙일 때는 impl 블록을 사용한다.

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 20,
    };

    let rect2 = Rectangle {
        width: 10,
        height: 15,
    };

    println!("area = {}", rect1.area());
    println!("can_hold = {}", rect1.can_hold(&rect2));
}

실행 결과는 아래와 같다.

impl로 struct에 메서드 붙이기 예제 결과

여기서 &self는 현재 인스턴스를 참조로 받는다는 뜻이다. 즉, rect1.area()는 내부적으로 Rectangle::area(&rect1)처럼 호출된다고 생각하면 이해하기 쉽다.

함수를 그냥 분리해서 만들 수도 있지만, 특정 타입과 밀접한 동작은 impl 안에 두는 편이 훨씬 읽기 좋다.

Enum: 여러 경우 중 하나를 표현하기

struct가 여러 필드를 동시에 가지는 타입이라면, enum은 여러 variant 중 정확히 하나를 가지는 타입이다.

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

fn main() {
    let message1 = Message::Quit;
    let message2 = Message::Move { x: 10, y: 20 };
    let message3 = Message::Write(String::from("hello"));

    match message1 {
        Message::Quit => println!("quit"),
        Message::Move { x, y } => println!("move to ({}, {})", x, y),
        Message::Write(text) => println!("text = {}", text),
        Message::ChangeColor(r, g, b) => println!("rgb({}, {}, {})", r, g, b),
    }

    match message2 {
        Message::Quit => println!("quit"),
        Message::Move { x, y } => println!("move to ({}, {})", x, y),
        Message::Write(text) => println!("text = {}", text),
        Message::ChangeColor(r, g, b) => println!("rgb({}, {}, {})", r, g, b),
    }

    match message3 {
        Message::Quit => println!("quit"),
        Message::Move { x, y } => println!("move to ({}, {})", x, y),
        Message::Write(text) => println!("text = {}", text),
        Message::ChangeColor(r, g, b) => println!("rgb({}, {}, {})", r, g, b),
    }
}

실행 결과는 아래와 같다.

enum 예제 결과 1

중요한 점은 각 variant가 서로 다른 형태의 데이터를 가질 수 있다는 것이다. Quit는 데이터가 없고, Move는 named field를 가지며, WriteString 하나를 담고, ChangeColor는 튜플처럼 값을 담는다.

Rust 표준 라이브러리에서 가장 자주 보는 enum 중 하나는 Option<T>다.

fn main() {
    let some_number = Some(10);
    let no_number: Option<i32> = None;

    println!("some_number = {:?}", some_number);
    println!("no_number = {:?}", no_number);
}

실행 결과는 아래와 같다.

enum 예제 결과 2

Option<T>는 값이 있을 수도 없을 수도 있다는 가능성을 타입 수준에서 드러낸다. 다른 언어의 null처럼 애매하게 두지 않고, 그 가능성을 처리하지 않은 채 일반 값처럼 사용하지 못하게 막는 것이 핵심이다. 실제로는 match, if let, 여러 메서드, ? 등을 통해 안전하게 다룬다.

Pattern Matching: match와 if let

enum을 꺼내 쓸 때 가장 자주 쓰는 문법은 match다. match는 모든 경우를 빠짐없이 처리하도록 강제하기 때문에 안전하다.

enum Ticket {
    Normal,
    Vip(u32),
    Staff(String),
}

fn describe(ticket: Ticket) {
    match ticket {
        Ticket::Normal => println!("normal ticket"),
        Ticket::Vip(level) => println!("vip level = {}", level),
        Ticket::Staff(name) => println!("staff = {}", name),
    }
}

fn main() {
    describe(Ticket::Normal);
    describe(Ticket::Vip(3));
    describe(Ticket::Staff(String::from("admin")));
}

실행 결과는 아래와 같다.

pattern matching 예제 결과 1

match 안에서는 variant에 들어 있던 값을 바로 꺼내서 사용할 수 있다. Ticket::Vip(level)에서 level, Ticket::Staff(name)에서 name이 바로 바인딩되는 부분이 핵심이다.

단, 모든 경우를 다 쓸 필요는 없고 특정 패턴 하나만 간단히 확인하고 싶을 때는 if let이 편하다.

fn main() {
    let config_max = Some(5u8);

    if let Some(max) = config_max {
        println!("max = {}", max);
    } else {
        println!("no max value");
    }
}

실행 결과는 아래와 같다.

pattern matching 예제 결과 2

if letmatch의 축약형처럼 생각하면 된다. 경우가 많을 때는 match, 특정 패턴 하나만 빠르게 다룰 때는 if let이 잘 어울린다.

Trait: 여러 타입의 공통 동작 정의하기

trait는 여러 타입이 공유해야 하는 동작의 약속이라고 보면 된다. Java의 interface와 비슷하게 느껴질 수 있지만 완전히 같지는 않다. Rust의 trait는 기본 메서드 구현을 둘 수도 있고, 같은 이름의 메서드가 있다고 해서 자동으로 trait를 만족하는 것도 아니다.

trait Summary {
    fn summarize(&self) -> String;
}

struct BlogPost {
    title: String,
    author: String,
}

struct NewsArticle {
    headline: String,
    reporter: String,
}

impl Summary for BlogPost {
    fn summarize(&self) -> String {
        format!("{} - {}", self.title, self.author)
    }
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{} ({})", self.headline, self.reporter)
    }
}

fn notify(item: &impl Summary) {
    println!("summary = {}", item.summarize());
}

fn main() {
    let post = BlogPost {
        title: String::from("Rust Traits"),
        author: String::from("K4NUL"),
    };

    let article = NewsArticle {
        headline: String::from("Rust 1.xx Released"),
        reporter: String::from("Dev Reporter"),
    };

    notify(&post);
    notify(&article);
}

실행 결과는 아래와 같다.

trait 예제 결과

핵심은 BlogPostNewsArticle의 구조는 다르지만, 둘 다 Summary라는 같은 동작을 제공할 수 있다는 점이다. 그래서 notify 함수는 구체 타입을 몰라도 Summary를 구현했다는 사실만 알면 호출할 수 있다.

한 번에 보는 종합 예제

지금까지 본 내용을 한 파일에 모으면 아래처럼 정리할 수 있다.

trait Summary {
    fn summarize(&self) -> String;
}

#[derive(Clone, Copy)]
enum PostState {
    Draft,
    Published,
    Archived,
}

struct Article {
    title: String,
    state: PostState,
}

impl Article {
    fn new(title: &str, state: PostState) -> Self {
        Self {
            title: String::from(title),
            state,
        }
    }

    fn status_label(&self) -> &'static str {
        match self.state {
            PostState::Draft => "draft",
            PostState::Published => "published",
            PostState::Archived => "archived",
        }
    }
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{} [{}]", self.title, self.status_label())
    }
}

fn notify(item: &impl Summary) {
    println!("summary = {}", item.summarize());
}

fn main() {
    let post = Article::new("Rust Structs and Traits", PostState::Published);

    notify(&post);

    if let PostState::Published = post.state {
        println!("This post can be shown to readers.");
    }
}

이 예제에는 struct, enum, match, if let, trait가 모두 들어 있다. 각각을 따로 실행해 본 뒤 마지막에 종합 예제를 보면, Rust가 데이터와 동작을 어떻게 묶는지 감이 훨씬 잘 잡힌다.

정리

이번 글에서는 Rust에서 데이터를 다루는 핵심 도구인 struct, enum, pattern matching, trait를 한 번에 정리했다. struct는 관련 필드를 묶는 데 적합하고, enum은 여러 경우 중 하나를 안전하게 표현하는 데 강력하다. 여기에 matchif let을 이용하면 각 경우를 명확하게 꺼내 처리할 수 있고, trait를 사용하면 서로 다른 타입에 공통 동작을 부여할 수 있다.

다음 단계에서는 Vec, HashMap, iterator, error handling 같은 주제로 넘어가면서, 지금 배운 structenum, trait가 실제 애플리케이션 코드에서 어떻게 연결되는지 이어서 보면 좋다.

댓글남기기