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
메서드라는 두 가지 방법으로요. 이 둘은 비슷해 보이지만 그 역할은 근본적으로 다릅니다.
특징 | trait 의 default 메서드 | #[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++ 상속이 가진 복잡성을 해결하고, 명시적인 동작의 규약과 안전한 코드 재사용을 통해 더 견고하고 예측 가능한 코드를 작성할 수 있게 해줍니다.