Rust 기초: 트레이트(Trait) 사용법
구조체와 열거형으로 데이터의 형태를 정의하는 방법을 배웠는데요. 그런데 서로 다른 타입이 같은 동작을 공유해야 하는 상황이 자주 생깁니다. 원과 직사각형은 완전히 다른 구조의 데이터이지만 둘 다 “넓이를 구한다”는 동작은 가지고 있잖아요.
Rust에서는 이런 공통 동작을 트레이트(Trait)로 정의합니다. 이 글에서는 트레이트가 무엇이고 어떻게 정의하고 구현하는지 예제와 함께 살펴보겠습니다.
트레이트란?
트레이트(Trait)는 여러 타입이 공통으로 가져야 하는 동작(behavior)을 정의하는 방법입니다. Java나 TypeScript의 인터페이스(interface)와 비슷한 개념인데요. “이 타입은 이런 메서드를 반드시 가져야 한다”는 약속을 코드로 표현한 것이죠.
trait 키워드로 트레이트의 이름을 붙이고 중괄호 안에 메서드 시그니처를 나열하면 됩니다.
trait Area {
fn area(&self) -> f64;
}
이렇게 정의하면 Area 트레이트를 구현하는 모든 타입은 area() 메서드를 반드시 제공해야 합니다.
메서드 본문 없이 시그니처만 적어두면 구현하는 쪽에서 각자의 방식으로 메서드를 완성하게 됩니다.
트레이트 구현
트레이트를 구현할 때는 impl 트레이트명 for 타입명 구문을 사용합니다.
앞에서 정의한 Area 트레이트를 원과 직사각형 구조체에 각각 구현해보겠습니다.
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Area for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
impl Area for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
같은 Area 트레이트를 구현하지만 원은 π × r²으로, 직사각형은 가로 × 세로로 넓이를 계산하고 있죠.
이제 두 구조체 모두 area() 메서드를 호출할 수 있습니다.
fn main() {
let circle = Circle { radius: 5.0 };
let rect = Rectangle { width: 4.0, height: 6.0 };
println!("원의 넓이: {:.2}", circle.area());
println!("직사각형의 넓이: {:.2}", rect.area());
}
원의 넓이: 78.54
직사각형의 넓이: 24.00
구조체에 메서드를 추가하는 것과 모양이 비슷하죠?
차이점은 impl 뒤에 트레이트 이름이 오고 for 뒤에 타입 이름이 온다는 것입니다.
일반 메서드는 impl 타입명이고, 트레이트 구현은 impl 트레이트명 for 타입명 형식이에요.
기본 구현
트레이트를 정의할 때 메서드 시그니처만 적는 게 아니라 본문까지 작성해두면 기본 구현(default implementation)이 됩니다. 기본 구현이 있는 메서드는 트레이트를 구현하는 쪽에서 굳이 작성하지 않아도 동작합니다.
Area 트레이트에 기본 구현이 있는 is_large() 메서드를 추가해보겠습니다.
trait Area {
fn area(&self) -> f64;
fn is_large(&self) -> bool {
self.area() > 50.0
}
}
area()는 시그니처만 있으니 구현할 때 반드시 작성해야 합니다.
반면 is_large()는 기본 구현이 있으니 생략해도 괜찮고 마음에 들지 않으면 재정의(override)할 수도 있죠.
impl Area for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
// is_large()는 기본 구현 사용
}
impl Area for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
fn is_large(&self) -> bool {
self.area() > 30.0 // 직사각형만 기준을 다르게
}
}
fn main() {
let circle = Circle { radius: 5.0 };
let rect = Rectangle { width: 4.0, height: 6.0 };
println!("원이 크다? {}", circle.is_large());
println!("직사각형이 크다? {}", rect.is_large());
}
원이 크다? true
직사각형이 크다? false
Circle은 기본 구현(넓이 > 50.0)을 그대로 쓰기 때문에 넓이 78.54로 true를 반환합니다.
Rectangle은 재정의된 기준(넓이 > 30.0)을 적용하기 때문에 넓이 24.00으로 false를 반환하고요.
이처럼 필요한 부분만 바꾸면 되니 코드 중복을 줄일 수 있습니다.
트레이트 매개변수
트레이트의 진짜 힘은 함수의 매개변수로 사용할 때 드러납니다. 특정 트레이트를 구현한 타입이면 무엇이든 받을 수 있는 함수를 만들 수 있거든요.
fn print_area(shape: &impl Area) {
println!("넓이: {:.2}", shape.area());
}
fn main() {
let circle = Circle { radius: 5.0 };
let rect = Rectangle { width: 4.0, height: 6.0 };
print_area(&circle);
print_area(&rect);
}
넓이: 78.54
넓이: 24.00
&impl Area는 “Area 트레이트를 구현한 어떤 타입의 참조든 받겠다”는 뜻입니다.
Circle이든 Rectangle이든 Area를 구현하기만 하면 이 함수에 넘길 수 있죠.
이 문법은 사실 트레이트 바운드(trait bound)의 축약형입니다. 제네릭을 사용하는 아래 코드와 동일하죠.
fn print_area<T: Area>(shape: &T) {
println!("넓이: {:.2}", shape.area());
}
+ 기호를 사용하면 여러 트레이트를 동시에 요구할 수도 있습니다.
예를 들어 Area와 Display 트레이트를 모두 구현한 타입만 받으려면 이렇게 작성합니다.
use std::fmt;
impl fmt::Display for Circle {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "반지름 {}인 원", self.radius)
}
}
fn describe(shape: &(impl Area + fmt::Display)) {
println!("{}: 넓이 {:.2}", shape, shape.area());
}
fn main() {
let circle = Circle { radius: 5.0 };
describe(&circle);
}
반지름 5인 원: 넓이 78.54
트레이트 바운드가 길어지면 where 절을 사용하면 가독성이 좋아집니다.
fn describe<T>(shape: &T)
where
T: Area + fmt::Display,
{
println!("{}: 넓이 {:.2}", shape, shape.area());
}
derive로 자동 구현
Rust에서 자주 사용하는 트레이트 중에는 #[derive] 매크로로 자동 구현할 수 있는 것들이 있습니다.
구조체 위에 #[derive(...)]를 붙이면 컴파일러가 표준적인 구현을 알아서 만들어주죠.
#[derive(Debug, Clone, PartialEq)]
struct Point {
x: f64,
y: f64,
}
fn main() {
let p1 = Point { x: 1.0, y: 2.0 };
let p2 = p1.clone();
println!("{:?}", p1); // Debug 트레이트
println!("{}", p1 == p2); // PartialEq 트레이트
}
Point { x: 1.0, y: 2.0 }
true
자주 사용되는 derive 가능한 트레이트를 정리하면 다음과 같습니다.
Debug—{:?}포맷으로 디버그 출력Clone—clone()메서드로 값 복제Copy— 할당 시 자동 복사 (Clone필수)PartialEq—==와!=로 비교Eq— 완전한 동등 비교 (PartialEq필수)PartialOrd—<,>,<=,>=로 비교Ord— 완전한 순서 비교 (PartialOrd+Eq필수)Hash—HashMap의 키로 사용Default—Default::default()로 기본값 생성
Copy와 Clone에 대해서는 Copy와 Clone 트레이트에서 더 자세히 다루고 있으니 참고해보세요.
트레이트 객체
지금까지 살펴본 impl Trait이나 제네릭은 컴파일 시점에 구체적인 타입이 결정됩니다.
이를 정적 디스패치(static dispatch)라고 하는데요.
서로 다른 타입의 값을 하나의 컬렉션에 담으려면 트레이트 객체(trait object)가 필요합니다.
트레이트 객체는 dyn 키워드로 만듭니다.
fn main() {
let circle = Circle { radius: 5.0 };
let rect = Rectangle { width: 4.0, height: 6.0 };
let shapes: Vec<&dyn Area> = vec![&circle, &rect];
for shape in shapes {
println!("넓이: {:.2}", shape.area());
}
}
넓이: 78.54
넓이: 24.00
Vec<&dyn Area>는 Area 트레이트를 구현한 어떤 타입의 참조든 담을 수 있는 벡터입니다.
Circle과 Rectangle은 서로 다른 타입이지만 둘 다 Area를 구현하고 있으니 같은 벡터에 넣을 수 있죠.
Box를 사용하면 참조 대신 소유권을 가진 트레이트 객체도 만들 수 있습니다.
fn main() {
let shapes: Vec<Box<dyn Area>> = vec![
Box::new(Circle { radius: 3.0 }),
Box::new(Rectangle { width: 2.0, height: 5.0 }),
];
for shape in &shapes {
println!("넓이: {:.2}", shape.area());
}
}
넓이: 28.27
넓이: 10.00
트레이트 객체는 런타임에 실제 타입을 판단하는 동적 디스패치(dynamic dispatch)를 사용합니다.
제네릭의 정적 디스패치보다 약간의 런타임 비용이 있지만, 서로 다른 타입을 유연하게 다룰 수 있다는 장점이 있죠.
dyn 키워드와 동적 디스패치의 동작 원리가 궁금하다면 dyn 키워드와 동적 디스패치를 참고해보세요.
전체 코드
본 포스팅에서 작성한 실습 코드는 Rust Playground에서 확인하시고 직접 실행해보실 수 있습니다.
마치며
트레이트는 Rust 타입 시스템의 핵심입니다.
공통 동작을 정의하고 서로 다른 타입이 같은 인터페이스를 공유하도록 만들어주죠.
기본 구현으로 코드 중복을 줄이고 트레이트 바운드로 제네릭 함수의 타입을 제한할 수도 있습니다.
dyn을 쓰면 런타임 다형성까지 가능하죠.
Rust 표준 라이브러리 자체가 트레이트 위에 설계되어 있기도 합니다. 데이터 변환을 위한 From과 Into, 값 복사를 위한 Copy와 Clone, 스마트 포인터의 기반이 되는 Deref 트레이트까지 모두 이 글에서 배운 개념 위에 세워져 있으니 함께 살펴보시면 좋겠습니다.
더 자세한 내용은 trait keyword와 dyn keyword를 참고하세요.
This work is licensed under
CC BY 4.0