Post

C++ 개발자를 위한 Rust 트레이트 완전 정복: 상속이 없어도 괜찮은 이유

C++ 개발자가 Rust를 처음 만났을 때, 가장 먼저 던졌던 질문은 “상속이 없다고? 그럼 코드를 어떻게 재사용하지?”였습니다. 상속에 익숙한 우리에게 트레이트(Trait)는 낯선 개념일 수밖에 없죠.

하지만 Rust의 트레이트는 C++ 상속의 불편함을 해결하는 아주 명쾌한 철학을 담고 있었습니다. Rust 트레이트의 기본부터 그 숨겨진 매력까지, C++ 개발자의 시선으로 함께 파헤쳐 보겠습니다.

1. Rust 트레이트, C++의 추상 클래스/인터페이스와 무엇이 다른가?

Rust의 트레이트는 C++의 순수 가상 함수를 가진 추상 클래스인터페이스와 비슷합니다. 특정 타입이 가져야 할 동작(메서드)을 정의하는 일종의 ‘규약’이라고 할 수 있죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 트레이트 정의: Printable 규약을 만듭니다.
trait Printable {
    fn print(&self);
}

// Book 구조체
struct Book {
    title: String,
    author: String,
}

// Book에 Printable 트레이트 구현
impl Printable for Book {
    fn print(&self) {
        println!("Title: {}, Author: {}", self.title, self.author);
    }
}

C++에서는 보통 class Book : public Printable과 같이 클래스 정의 안에 메서드를 구현하지만, Rust는 impl Printable for Book이라는 별도의 블록을 사용합니다. 이처럼 구현체를 외부에 분리하는 것이 바로 Rust 설계의 핵심입니다.

2. ‘is-a’ 대신 ‘has-a’를 택한 이유: 상속의 함정에서 벗어나기

C++의 상속은 ‘is-a’ 관계를 통해 부모 클래스의 모든 것을 물려받습니다. 이 때문에 때로는 의도하지 않은 데이터나 기능까지 함께 상속받아 복잡성을 키우는 단점이 있습니다.

상속의 함정: ‘정사각형은 직사각형이다’

객체지향 설계에서 흔히 발생하는 문제입니다. Rectangle(직사각형) 클래스에 Square(정사각형) 클래스를 상속하는 경우를 생각해 보죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// C++ 코드 예시
class Rectangle {
public:
    void setWidth(double w) { width = w; }
    void setHeight(double h) { height = h; }
protected:
    double width;
    double height;
};

class Square : public Rectangle {
public:
    void setWidth(double s) { width = height = s; }
    void setHeight(double s) { width = height = s; }
};

논리적으로는 ‘정사각형은 직사각형’이 맞지만, 코드로는 문제가 발생합니다. Rectangle을 인수로 받는 함수에 Square 객체를 전달할 경우, Square의 너비를 변경해도 높이는 그대로인 상황이 생겨 객체 불변성이 깨집니다.

Rust는 이 문제를 합성(Composition)을 기본으로 하는 ‘has-a’ 관계를 통해 해결합니다. 이는 ‘리스코프 치환 원칙(Liskov Substitution Principle)’을 위반하는 전형적인 사례입니다.

러스트에서는 이 문제를 어떻게 해결할까요? 정사각형은 직사각형이다(is-a)가 아니라, 정사각형은 “직사각형의 특성을 가진다(Has-a)”로 이해해 보면 상속의 함정을 벗어날 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 직사각형 구조체
struct Rectangle {
    width: f64,
    height: f64,
}

impl Rectangle {
    fn new(width: f64, height: f64) -> Self {
        Rectangle { width, height }
    }

    fn area(&self) -> f64 {
        self.width * self.height
    }
}

// 정사각형은 "직사각형의 특성을 갖는다"
struct Square {
    rect: Rectangle,
}

impl Square {
    fn new(size: f64) -> Self {
        Square {
            rect: Rectangle::new(size, size),
        }
    }

    fn area(&self) -> f64 {
        self.rect.area()
    }
}

// 공통 동작을 정의하고 싶으면 Trait을 사용
trait Shape {
    fn area(&self) -> f64;
}

impl Shape for Rectangle {
    fn area(&self) -> f64 {
        self.area()
    }
}

impl Shape for Square {
    fn area(&self) -> f64 {
        self.area()
    }
}

3. 코드 재사용의 비밀: #[derive] vs. default 메서드

C++의 상속처럼 “기본 구현체를 물려받는” 기능을 Rust에서도 구현할 수 있습니다. 바로 #[derive] 매크로와 default 메서드라는 두 가지 방법으로요. 이 둘은 비슷해 보이지만 그 역할은 근본적으로 다릅니다.

특징traitdefault 메서드#[derive] 매크로
개념‘기본 구현체를 제공’하는 기능‘구현체를 자동으로 생성’하는 도구
작동 방식impl 블록에서 별도로 구현하지 않으면 트레이트에 정의된 기본 코드를 사용.컴파일러가 struct의 내용을 분석해 트레이트 구현 코드를 통째로 생성.
비유‘기본 제공되는 공통 기능’이 있는 추상 클래스‘똑똑한 코드 자동 생성기’

undefined #[derive]는 코드를 물려받는 것이 아니라, 새로 만드는 역할을 합니다. 예를 들어, #[derive(Debug)]는 컴파일러에게 ‘Book 구조체의 필드를 확인하고, Debug 트레이트 규약에 맞는 디버깅 코드를 자동으로 생성해줘!’라고 요청하는 것과 같습니다. 이는 C++의 상속처럼 기존 코드를 공유하며 발생하는 문제 없이, 해당 타입에 완벽하게 맞춤화된 코드를 생성하여 안전하고 유연한 코드 재사용을 가능하게 합니다.

4. 트레이트 간 상속을 통한 동작 강제하기

C++ 상속이 부모의 property까지 물려받는 반면, Rust 트레이트는 데이터(state)를 가질 수 없습니다. 오직 동작(behavior)만 정의하죠.

그렇다면 특정 property가 있어야 한다고 어떻게 강제할까요? 바로 트레이트 상속을 활용합니다.

1
2
3
4
5
6
7
8
9
10
11
// 1. 이름이 있어야 한다고 규약을 정합니다.
trait HasName {
    fn name(&self) -> &str;
}

// 2. 이름을 출력하는 트레이트를 정의하고, HasName 규약을 요구합니다.
trait PrintableName: HasName {
    fn print_name(&self) {
        println!("이름: {}", self.name());
    }
}

PrintableName: HasName 문법은 PrintableName을 구현하는 모든 타입은 반드시 HasName 트레이트도 구현해야 한다고 **강제**합니다. 이렇게 하면 트레이트에 property를 두지 않으면서도, 특정 메서드(이름을 반환하는 name()`)가 있어야 함을 보장할 수 있습니다.

5. 실제 트레이트 활용 예시: Display 트레이트

C++ operator<< 오버로딩과 가장 유사한 Display 트레이트를 직접 구현해 보면 다음과 같이 작성할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::fmt;

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

// C++ operator<< 오버로딩과 유사한 기능
impl fmt::Display for Book {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "제목: {}, 저자: {}", self.title, self.author)
    }
}

fn main() {
    let book = Book {
        title: String::from("Rust 프로그래밍"),
        author: String::from("Rust 개발자"),
    };

    println!("{}", book);
}

이처럼 Rust의 트레이트는 C++ 상속이 가진 복잡성을 해결하고, 명시적인 동작의 규약과 안전한 코드 재사용을 통해 더 견고하고 예측 가능한 코드를 작성할 수 있게 해줍니다.

This post is licensed under CC BY 4.0 by the author.