러스트 언어 입문 시리즈 - 6편: 트레이트(Trait)와 제네릭(Generic)을 통한 추상화와 다형성
이전 글에서는 구조체, 열거형, 패턴 매칭, 모듈 시스템을 통해 러스트의 타입 정의와 코드 구조화를 살펴보았습니다. 이제는 러스트에서 추상화와 다형성을 어떻게 구현하는지 알아볼 차례입니다. C++ 개발자라면 ‘템플릿(Template)’과 ‘가상 함수(Virtual function)’, ‘인터페이스(Interface)’ 등을 통해 제네릭 프로그래밍, 다형성을 달성하는 것에 익숙할 텐데요. 러스트는 이와 유사하지만 좀 더 정교하고 명확한 개념인 트레이트(Trait)와 제네릭(Generic)을 제공합니다.
트레이트(Trait)란 무엇인가?
러스트에서 트레이트는 특정 타입이 "이런 기능을 갖추고 있다"는 것을 표현하는 인터페이스 역할을 합니다. C++에서 순수 가상 함수만 갖는 클래스(인터페이스)나 개념(Concept)과 유사하다고 볼 수 있습니다. 트레이트는 메서드 시그니처(메서드 이름, 매개변수 타입, 반환 타입)를 정의하고, 그 트레이트를 구현(implement)한 타입은 해당 메서드들을 갖추어야 합니다.
trait Summary {
fn summarize(&self) -> String;
}
struct Article {
title: String,
author: String,
content: String,
}
impl Summary for Article {
fn summarize(&self) -> String {
format!("{} by {}", self.title, self.author)
}
}
fn main() {
let art = Article {
title: String::from("Rust를 배우는 즐거움"),
author: String::from("홍길동"),
content: String::from("..."),
};
println!("요약: {}", art.summarize());
}
trait Summary는 summarize 메서드를 제공해야 한다는 계약을 나타냅니다. impl Summary for Article를 통해 Article 타입이 Summary 트레이트를 만족한다고 선언하고 구현합니다. 이제 Article은 summarize()를 호출할 수 있는 타입이 되었습니다.
C++에서도 가상 함수 기반 클래스나 C++20 Concepts를 사용하면 비슷한 구조를 만들 수 있지만, 러스트 트레이트는 언어 차원에서 이 개념을 매우 깔끔하게 정리하고 강제합니다. 또한, 런타임 다형성뿐 아니라 컴파일 타임 다형성으로 트레이트를 사용할 수 있어, 필요한 경우 오버헤드를 최소화할 수 있습니다.
제네릭(Generic) 타입
C++에서 템플릿(Template)은 제네릭 프로그래밍을 구현하는 핵심 도구입니다. 러스트에서도 T나 U 같은 타입 파라미터를 사용해 제네릭 함수나 제네릭 구조체를 정의할 수 있습니다.
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
let mut max = list[0];
for &item in list.iter() {
if item > max {
max = item;
}
}
max
}
fn main() {
let numbers = vec![10, 20, 5, 30];
let chars = vec!['a', 'z', 'm'];
println!("가장 큰 숫자: {}", largest(&numbers));
println!("가장 큰 문자: {}", largest(&chars));
}
여기서 largest 함수는 타입 T를 제네릭하게 받아들이고, PartialOrd와 Copy 트레이트를 요구하고 있습니다. 이는 **트레이트 바운드(Trait Bound)**를 통해 제네릭 타입이 어떤 성질을 가져야 하는지 명시한 것입니다. C++에서 템플릿 타입 파라미터가 특정 연산자를 지원해야 하는지 명시하기 위해 Concepts를 사용하거나 SFINAE 기법을 사용하는 것과 유사합니다. 하지만 러스트는 이 규칙을 언어 차원에서 강력하게 통합해 컴파일 시간 타입 체크를 더 체계적으로 합니다.
트레이트 바운드(Trait Bound)와 다형성
트레이트 바운드는 제네릭 함수나 구조체가 제약 조건을 갖도록 하여 다형성(polymorphism)을 정교하게 제어합니다.
fn notify(item: &impl Summary) {
println!("새 소식! {}", item.summarize());
}
여기서 &impl Summary는 "Summary 트레이트를 구현하는 어떤 타입의 참조"를 받는다는 의미입니다. 즉, Summary를 구현한 어떤 타입이든 이 함수에 전달할 수 있습니다. C++에서 함수 템플릿 매개변수가 특정 Concepts를 만족하는 타입을 받는 것과 같습니다.
더 정교한 제약을 위해서는 fn notify<T: Summary>(item: &T) 형태로 타입 파라미터를 명시할 수도 있습니다. 이 방식은 여러 트레이트를 + 로 묶어 복합적인 제약을 주는 것도 가능합니다.
fn notify_multi<T: Summary + Display>(item: &T) {
println!("요약: {}, 출력: {}", item.summarize(), item);
}
C++에서 Concepts를 사용해 template <typename T> requires SummaryConcept && DisplayConcept 와 비슷한 식으로 표현할 수 있는 것을 러스트는 T: Summary + Display 형식으로 간결하게 표현합니다.
정적 디스패치와 동적 디스패치
C++에서 템플릿은 인스턴스화 시점에 타입이 결정되는 정적 디스패치(Static Dispatch) 방식이고, 가상 함수를 사용하면 런타임에 함수 포인터 테이블을 참조하는 동적 디스패치(Dynamic Dispatch)가 이루어집니다. 러스트도 비슷한 개념을 가지고 있습니다.
- 정적 디스패치: fn notify<T: Summary>(item: &T) 형태로 작성하면, 컴파일러는 T에 맞춰 함수를 인라인 확장합니다. 따라서 런타임 비용이 없습니다.
- 동적 디스패치: fn notify(item: &dyn Summary) 형태로 dyn 키워드를 사용하면, Summary를 구현한 객체에 대한 trait 객체(Trait Object)를 매개변수로 받습니다. 이는 C++의 가상 함수 테이블과 유사한 메커니즘으로, 런타임에 어떤 타입인지 결정하고 해당 타입의 메서드를 호출합니다. 약간의 런타임 오버헤드가 있지만 더 유연한 폴리모피즘을 제공합니다.
fn main() {
let art = Article {
title: String::from("Rust vs C++"),
author: String::from("이몽룡"),
content: String::from("러스트와 C++를 비교하면..."),
};
notify(&art); // 정적 디스패치
let art_box: &dyn Summary = &art;
notify_dyn(art_box); // 동적 디스패치
}
fn notify<T: Summary>(item: &T) {
println!("정적 디스패치: {}", item.summarize());
}
fn notify_dyn(item: &dyn Summary) {
println!("동적 디스패치: {}", item.summarize());
}
이처럼 러스트는 C++ 템플릿(제네릭)과 가상 함수(트레이트 오브젝트) 기능을 모두 언어 차원에서 안전하게 지원하며, 선택적으로 정적/동적 디스패치를 사용할 수 있습니다.
C++와의 비교 정리
- 트레이트(Trait): C++ 인터페이스나 추상 클래스, Concept와 유사. 메서드 시그니처 정의 후 구현 타입에 impl 통해 구현.
- 제네릭(Generic): C++ 템플릿과 유사하나 트레이트 바운드로 타입 제약 명시, 컴파일 타임 타입 안정성 확보.
- 트레이트 바운드(Trait Bound): C++ Concepts와 유사. 제네릭 타입이 만족해야 할 조건을 명확히 제시.
- 정적 vs 동적 디스패치: C++ 템플릿처럼 정적 디스패치, C++ 가상 함수처럼 동적 디스패치 둘 다 가능. dyn 키워드로 런타임 폴리모피즘 구현.
앞으로의 학습 방향
트레이트와 제네릭은 러스트의 추상화와 다형성을 이해하는 핵심 개념입니다. 이를 잘 활용하면 C++에서 템플릿/가상 함수 사용 시 마주하던 복잡한 에러나 정의되지 않은 동작을 사전에 차단하며 타입 안정성과 성능, 유연성을 모두 얻을 수 있습니다.
다음 글에서는 에러 처리(Error Handling), Result 타입, Panic 처리, 그리고 C++의 예외(Exception) 처리 모델과 비교해보며, 러스트가 에러를 어떻게 안전하게 처리하는지 알아보겠습니다.
유용한 링크와 리소스
- Rust Book: Traits: https://doc.rust-lang.org/book/ch10-02-traits.html
- Rust Book: Generics: https://doc.rust-lang.org/book/ch10-00-generics.html
- Trait Objects: https://doc.rust-lang.org/book/ch17-02-trait-objects.html
- C++ Concepts: https://en.cppreference.com/w/cpp/language/constraints