트레이트 객체 - 동적 디스패치와 제네릭 사이에서 고르기
이 장에서 배우는 것
기본서에서 트레이트 바운드(trait bound)를 단 제네릭 함수를 썼다. 그 방식에서는 컴파일 시점에 타입이 하나로 정해진다. 이 장은 반대쪽을 다룬다. 실행 중에야 어떤 타입인지 알 수 있는 값을 같은 칸에 담고, 같은 방식으로 호출하는 방법이다. 이 도구가 트레이트 객체(trait object)다. 창고 프로그램에 주문 검증 규칙을 붙이면서 두 방식의 차이와 비용, 고르는 기준을 정리한다.
dyn Trait와Box<dyn Trait>로 서로 다른 타입을 한 컬렉션에 담는다.- 정적 디스패치와 동적 디스패치가 기계어 수준에서 어떻게 다른지 설명한다.
- 객체 안전성(dyn 호환성) 규칙을 알고, 규칙에 걸리는 트레이트를 고친다.
- 제네릭과 트레이트 객체 중 무엇을 쓸지 기준을 세워 판단한다.
문제 상황
창고 프로그램에 주문이 들어온다. 주문을 받기 전에 검사할 규칙이 여러 개다. 한 번에 팔 수 있는 최대 수량이 있고, 재고가 모자라면 안 되고, 판매를 막은 품목도 걸러야 한다. 운영 모드에 따라 규칙 개수도 달라진다. 엄격 모드에서는 금지 품목 검사가 추가된다.
처음에는 규칙마다 구조체를 만들고 제네릭 함수로 검사하면 된다고 생각하기 쉽다. 그런데 Vec 의 원소 타입은 하나여야 한다. MaxQuantity, InStock, Blocked 는 서로 다른 타입이므로 한 Vec 에 넣을 수 없다. 규칙 목록이 실행 중에 정해지는 것도 문제다. 컴파일 시점에 (A, B, C) 같은 튜플로 고정해 둘 수 없다. 해결책이 트레이트 객체다.
트레이트 객체 기본
dyn Trait 와 팻 포인터
dyn OrderRule 은 "OrderRule 을 구현한 어떤 타입"을 뜻하는 타입이다. 크기가 컴파일 시점에 정해지지 않으므로(unsized) 값으로 직접 가질 수 없고, &dyn OrderRule 이나 Box<dyn OrderRule> 처럼 포인터 뒤에 둔다. 이 포인터는 일반 참조와 달리 주소를 두 개 담는다. 하나는 실제 값을 가리키고, 다른 하나는 그 타입의 메서드 표인 vtable 을 가리킨다. 이런 포인터를 팻 포인터(fat pointer)라고 부른다.
vtable 에는 소멸 함수, 값의 크기와 정렬, 트레이트 메서드들의 주소가 들어 있다. 타입마다 하나씩 컴파일러가 만들고, 같은 타입의 모든 트레이트 객체가 공유한다.
이질적 컬렉션
Vec<Box<dyn OrderRule>> 에는 OrderRule 을 구현한 타입이면 무엇이든 넣을 수 있다. Box::new(InStock) 은 Box<InStock> 이지만, 기대 타입이 Box<dyn OrderRule> 로 주어져 있으면 컴파일러가 자동으로 변환한다(unsized coercion). 이 변환 때문에 변수에 타입을 적어 주는 일이 중요하다. 뒤의 "자주 틀리는 것"에서 다시 본다.
정적 디스패치와 동적 디스패치
fn passes<R: OrderRule>(rule: &R, ..) 처럼 제네릭으로 쓰면 컴파일러는 호출에 쓰인 타입마다 함수 사본을 만든다(단형화, monomorphization). 호출할 check 의 주소가 컴파일 시점에 정해지므로 인라인 최적화도 가능하다. 반대로 &dyn OrderRule 로 받으면 함수는 하나뿐이다. 호출 때마다 vtable 에서 check 주소를 읽어 간접 호출한다.
| 항목 | 제네릭 (정적) | dyn Trait (동적) | 영향 |
|---|---|---|---|
| 호출 대상 결정 | 컴파일 시점 | 실행 시점 | 동적은 간접 호출 한 번이 든다 |
| 인라인 | 가능 | 대개 불가능 | 작은 메서드를 뜨겁게 부르면 차이가 난다 |
| 코드 크기 | 타입 수만큼 증가 | 함수 하나 | 정적은 바이너리가 커질 수 있다 |
| 한 컬렉션에 섞기 | 불가능 | 가능 | 이질적 컬렉션은 dyn 의 몫이다 |
간접 호출 비용은 보통 작다. 규칙 검사처럼 메서드 안에서 해시 조회나 문자열 처리를 하는 일이면 호출 방식의 차이는 묻힌다. 수백만 번 도는 안쪽 루프에서 한두 줄짜리 메서드를 부르는 경우라면 측정해 볼 만하다. 측정은 이 책의 성능을 다루는 장에서 다시 한다. 지금은 비용이 있다는 것, 그러나 습관적으로 피할 만큼 크지는 않다는 것만 기억하면 된다.
객체 안전성
모든 트레이트를 dyn 으로 쓸 수는 없다. vtable 은 크기가 고정된 함수 주소 목록이므로, 그 안에 넣을 수 없는 메서드를 가진 트레이트는 트레이트 객체가 될 수 없다. 이 성질을 객체 안전성(object safety)이라 부르고, 최근 컴파일러는 "dyn 호환(dyn compatible)"이라고 표현한다. 주요 규칙은 다음과 같다.
| 깨는 요소 | 이유 | 해결 |
|---|---|---|
| 타입 매개변수가 있는 메서드 | 타입마다 다른 함수가 필요해 표에 못 담는다 | where Self: Sized 를 붙여 dyn 에서 제외 |
Self 를 반환하거나 인자로 받음 | 실제 타입을 가려서 크기를 모른다 | Box<dyn Trait> 를 반환하는 메서드로 바꾼다 |
Clone 같은 Sized 상위 트레이트 | dyn 자체가 unsized 라 조건이 어긋난다 | clone_box 메서드를 직접 둔다 |
self 인자가 없는 연관 함수 | 값 없이 호출할 방법이 없다 | 메서드로 바꾸거나 where Self: Sized |
완성 코드의 label 이 첫째 규칙의 사례다. 제네릭 메서드지만 where Self: Sized 덕분에 OrderRule 은 여전히 dyn 호환이다. 대신 label 은 구체 타입에서만 부를 수 있다.
제네릭과 트레이트 객체 고르기
다음 순서로 판단하면 대부분 결정된다.
- 한 컬렉션이나 필드에 서로 다른 구현체를 섞어야 하는가. 그렇다면
dyn이다. - 구현체 목록이 설정이나 입력으로 실행 중에 정해지는가. 그렇다면
dyn이다. - 분기에 따라 함수가 서로 다른 타입을 반환해야 하는가.
impl Trait는 반환 타입이 하나여야 하므로Box<dyn Trait>를 쓴다. - 위에 해당하지 않고 호출이 성능에 민감한가. 제네릭을 쓴다.
- 둘 다 괜찮다면 코드가 단순한 쪽을 고르고, 문제가 되면 측정한 뒤 바꾼다.
두 방식은 한 프로그램에 함께 쓰는 것이 자연스럽다. 아래 완성 코드가 그 예로, 규칙 목록은 dyn 으로 보관하고 개별 규칙 하나를 확인하는 함수는 제네릭으로도 만들어 둔다.
완성 코드
use std::collections::HashMap;
use std::fmt::Display;
struct Order {
sku: String,
qty: u32,
}
struct Inventory {
items: HashMap<String, u32>,
}
impl Inventory {
fn new() -> Self {
Inventory { items: HashMap::new() }
}
fn add(&mut self, sku: &str, qty: u32) {
*self.items.entry(sku.to_string()).or_insert(0) += qty;
}
fn available(&self, sku: &str) -> u32 {
self.items.get(sku).copied().unwrap_or(0)
}
}
trait OrderRule {
fn name(&self) -> &str;
fn check(&self, order: &Order, inv: &Inventory) -> Result<(), String>;
fn label<T: Display>(&self, extra: T) -> String
where
Self: Sized,
{
format!("{}({})", self.name(), extra)
}
}
struct MaxQuantity {
limit: u32,
}
struct InStock;
struct Blocked {
skus: Vec<String>,
}
impl OrderRule for MaxQuantity {
fn name(&self) -> &str {
"최대 수량"
}
fn check(&self, order: &Order, _inv: &Inventory) -> Result<(), String> {
if order.qty > self.limit {
Err(format!("한도 {}, 요청 {}", self.limit, order.qty))
} else {
Ok(())
}
}
}
impl OrderRule for InStock {
fn name(&self) -> &str {
"재고 확인"
}
fn check(&self, order: &Order, inv: &Inventory) -> Result<(), String> {
let have = inv.available(&order.sku);
if have < order.qty {
Err(format!("재고 {}, 요청 {}", have, order.qty))
} else {
Ok(())
}
}
}
impl OrderRule for Blocked {
fn name(&self) -> &str {
"금지 품목"
}
fn check(&self, order: &Order, _inv: &Inventory) -> Result<(), String> {
if self.skus.iter().any(|s| *s == order.sku) {
Err(order.sku.clone())
} else {
Ok(())
}
}
}
fn build_rules(strict: bool) -> Vec<Box<dyn OrderRule>> {
let mut rules: Vec<Box<dyn OrderRule>> = vec![
Box::new(MaxQuantity { limit: 25 }),
Box::new(InStock),
];
if strict {
rules.push(Box::new(Blocked {
skus: vec!["plum".to_string()],
}));
}
rules
}
fn run_all(rules: &[Box<dyn OrderRule>], order: &Order, inv: &Inventory) -> Vec<String> {
let mut failures = Vec::new();
for rule in rules {
if let Err(msg) = rule.check(order, inv) {
failures.push(format!("{}: {}", rule.name(), msg));
}
}
failures
}
fn passes<R: OrderRule>(rule: &R, order: &Order, inv: &Inventory) -> bool {
rule.check(order, inv).is_ok()
}
fn order(sku: &str, qty: u32) -> Order {
Order { sku: sku.to_string(), qty }
}
fn main() {
let mut inv = Inventory::new();
inv.add("apple", 10);
inv.add("pear", 3);
inv.add("fig", 50);
let rules = build_rules(true);
println!(
"엄격 규칙 {}개, 느슨한 규칙 {}개",
rules.len(),
build_rules(false).len()
);
let orders = vec![
order("apple", 4),
order("pear", 5),
order("fig", 30),
order("plum", 1),
];
for o in &orders {
let failures = run_all(&rules, o, &inv);
if failures.is_empty() {
println!("{} x{}: 통과", o.sku, o.qty);
} else {
println!("{} x{}: 거부 [{}]", o.sku, o.qty, failures.join(" / "));
}
}
let max = MaxQuantity { limit: 25 };
println!("정적 호출: apple x4 -> {}", passes(&max, &orders[0], &inv));
println!("label: {}", max.label(25));
println!(
"포인터 크기: dyn 참조는 usize 두 배 = {}, 일반 참조는 하나 = {}",
size_of::<&dyn OrderRule>() == 2 * size_of::<usize>(),
size_of::<&MaxQuantity>() == size_of::<usize>()
);
}
줄별 해설
trait OrderRule: 규칙의 공통 약속이다.name과check는&self만 받고 제네릭이 없어 vtable 에 들어간다.label: 타입 매개변수T가 있어 원래는 dyn 호환을 깬다.where Self: Sized로 트레이트 객체에서 빼 두었다.MaxQuantity,InStock,Blocked: 필드 구성이 모두 다른 별개의 타입이다.InStock은 필드가 없는 단위 구조체다.build_rules:let mut rules: Vec<Box<dyn OrderRule>>의 타입 표기가Box::new(..)결과를Box<dyn OrderRule>로 변환하게 한다.strict값에 따라 목록 길이가 달라지는데, 이는 실행 중에만 알 수 있다.run_all:&[Box<dyn OrderRule>]슬라이스를 받아 모든 규칙을 돌고 실패 메시지를 모은다.rule.check(..)에서Box는 자동 역참조되어 vtable 을 통해 호출된다. 첫 실패에서 멈추지 않고 모두 모으는 것은 사용자에게 이유를 한꺼번에 보여 주기 위해서다.passes: 같은 트레이트를 제네릭으로 받는 함수다.passes(&max, ..)한 번의 호출로passes::<MaxQuantity>사본이 만들어진다.main의 마지막println!: 팻 포인터가usize두 개 크기라는 사실을size_of로 비교한다. 플랫폼에 따라 절대 크기가 달라도 비교 결과는true로 같으므로 출력이 결정적이다.max.label(25): 구체 타입MaxQuantity에서 부르므로Sized조건이 충족된다.rules[0].label(25)는 컴파일되지 않는다.
실행 결과
$ cargo run
엄격 규칙 3개, 느슨한 규칙 2개
apple x4: 통과
pear x5: 거부 [재고 확인: 재고 3, 요청 5]
fig x30: 거부 [최대 수량: 한도 25, 요청 30]
plum x1: 거부 [재고 확인: 재고 0, 요청 1 / 금지 품목: plum]
정적 호출: apple x4 -> true
label: 최대 수량(25)
포인터 크기: dyn 참조는 usize 두 배 = true, 일반 참조는 하나 = true
실무에서 자주 틀리는 것
타입 표기 없이 서로 다른 Box 를 vec! 에 넣는다
let rules = vec![Box::new(InStock), Box::new(MaxQuantity { limit: 25 })];
오류 요지: mismatched types. 첫 원소로 Vec<Box<InStock>> 가 추론되어, 두 번째 원소의 Box<MaxQuantity> 와 맞지 않는다. 기대 타입을 적어 주면 변환이 일어난다.
let rules: Vec<Box<dyn OrderRule>> = vec![Box::new(InStock), Box::new(MaxQuantity { limit: 25 })];
제네릭 메서드 때문에 트레이트 객체를 못 만든다
trait Report {
fn line<T: std::fmt::Display>(&self, extra: T) -> String;
}
fn all(items: Vec<Box<dyn Report>>) {}
오류 요지: the trait Report is not dyn compatible(E0038), 원인은 제네릭 메서드 line 이다. 그 메서드가 dyn 에서 꼭 필요하지 않다면 where Self: Sized 를 붙인다. 필요하다면 extra: &dyn Display 처럼 인자 자체를 트레이트 객체로 바꾼다.
trait Report {
fn line(&self, extra: &dyn std::fmt::Display) -> String;
}
분기마다 다른 타입을 impl Trait 로 반환한다
fn pick(strict: bool) -> impl OrderRule {
if strict { Box::new(InStock) } else { Box::new(MaxQuantity { limit: 25 }) }
}
오류 요지: if 와 else 의 타입이 호환되지 않는다. impl Trait 는 호출 쪽에 보이지 않을 뿐 구체 타입이 하나로 정해진 것이다. 분기별로 타입이 다르면 Box<dyn OrderRule> 를 반환해야 한다.
fn pick(strict: bool) -> Box<dyn OrderRule> {
if strict { Box::new(InStock) } else { Box::new(MaxQuantity { limit: 25 }) }
}
Clone 을 상위 트레이트로 달아 객체를 복제하려 한다
trait Rule: Clone { fn name(&self) -> &str; }
fn all(v: Vec<Box<dyn Rule>>) {}
오류 요지: Clone 이 Sized 를 요구하고 Self 를 반환하므로 dyn 호환이 아니다. 복제가 필요하면 fn clone_box(&self) -> Box<dyn Rule> 를 트레이트에 두고 각 구현에서 Box::new(self.clone()) 으로 만든다. 연습 문제에서 직접 해 본다.
한눈에 보기
| 개념 | 형태 | 핵심 | 쓰는 때 |
|---|---|---|---|
| 트레이트 객체 | &dyn T, Box<dyn T> | 값 주소와 vtable 주소를 가진 팻 포인터 | 구현체가 실행 중에 정해질 때 |
| 정적 디스패치 | fn f<R: T>(r: &R) | 타입마다 사본, 인라인 가능 | 타입이 하나로 고정될 때 |
| 이질적 컬렉션 | Vec<Box<dyn T>> | 타입 표기가 변환을 일으킨다 | 서로 다른 구조체를 한 목록에 |
| dyn 호환 | where Self: Sized | 제네릭·Self 메서드를 표에서 제외 | 트레이트에 제네릭 메서드가 필요할 때 |
| 분기 반환 | Box<dyn T> | impl T 는 타입이 하나여야 함 | 조건별로 다른 구현을 돌려줄 때 |
연습 문제
MinQuantity { limit: u32 }규칙을 추가한다. 주문 수량이limit미만이면 거부하고 이름은 "최소 수량"으로 한다. 이를build_rules에 어떻게 넣어야 하는지 쓴다.- 완성 코드의
OrderRule에clone_box를 더해Vec<Box<dyn OrderRule>>를 복제할 수 있게 한다.MaxQuantity에 대한 구현과Box<dyn OrderRule>에 대한Clone구현을 쓴다. - 다음 상황에서 제네릭과
dyn중 무엇이 적절한지 이유와 함께 답한다. (가) 설정 파일의 줄 순서대로 규칙을 쌓는 검증기. (나) 한 종류의 로그 출력기만 쓰고 안쪽 루프에서 1억 번 호출하는 함수. rules[0].label(25)를main에 넣으면 왜 컴파일되지 않는지 설명하고, 컴파일되게 하는 방법 두 가지를 말한다.
정답과 해설
- 다음과 같이 구현한 뒤
rules벡터에Box::new(MinQuantity { limit: 1 })을 넣는다.struct MinQuantity { limit: u32, } impl OrderRule for MinQuantity { fn name(&self) -> &str { "최소 수량" } fn check(&self, order: &Order, _inv: &Inventory) -> Result<(), String> { if order.qty < self.limit { Err(format!("최소 {}, 요청 {}", self.limit, order.qty)) } else { Ok(()) } } }build_rules의 반환 타입은 그대로이고, 기존 코드는 수정하지 않는다. 새 타입이 트레이트만 구현하면 되는 점이 동적 디스패치의 장점이다. - 트레이트에
fn clone_box(&self) -> Box<dyn OrderRule>;를 추가한다. 각 규칙은#[derive(Clone)]를 달고 구현한다.
이렇게 하면impl OrderRule for MaxQuantity { // name, check 는 그대로 fn clone_box(&self) -> Box<dyn OrderRule> { Box::new(self.clone()) } } impl Clone for Box<dyn OrderRule> { fn clone(&self) -> Self { self.clone_box() } }rules.clone()이 가능하다. 상위 트레이트에Clone을 두지 않고Box쪽에 구현했기 때문에 dyn 호환이 유지된다. 이 코드는MaxQuantity에#[derive(Clone)]이 있어야 하고, 나머지 규칙에도 같은 구현이 필요하다. - (가)
dyn이다. 줄 수와 종류가 실행 중에 정해지므로Vec<Box<dyn Rule>>로 보관해야 한다. (나) 제네릭이다. 타입이 하나이고 호출이 아주 뜨거우므로 인라인이 가능한 정적 디스패치가 유리하다. 다만 실제 차이는 측정해서 확인하는 것이 바람직하다. rules[0]의 타입은Box<dyn OrderRule>이고dyn OrderRule은Sized가 아니므로,where Self: Sized가 붙은label을 부를 수 없다. 방법은 둘이다. 구체 타입 변수(max)에서 부르거나,label의 제네릭을 없애fn label(&self, extra: &dyn Display) -> String으로 바꾸고where절을 제거한다. 후자는 vtable 에 들어가므로 dyn 에서도 호출된다.
다음 장에서는 이 규칙 목록처럼 값의 연속을 다루는 반복자를 어댑터 조합과 직접 구현으로 깊이 살펴본다.