Devin.KR

트레이트 객체 - 동적 디스패치와 제네릭 사이에서 고르기

개발자KR 조회 0

이 장에서 배우는 것

기본서에서 트레이트 바운드(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)라고 부른다.

Box<dyn OrderRule> 는 값 주소와 vtable 주소를 함께 가진 포인터다.

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 은 하나의 코드에서 vtable 로 점프한다.
정적 디스패치와 동적 디스패치의 차이
항목제네릭 (정적)dyn Trait (동적)영향
호출 대상 결정컴파일 시점실행 시점동적은 간접 호출 한 번이 든다
인라인가능대개 불가능작은 메서드를 뜨겁게 부르면 차이가 난다
코드 크기타입 수만큼 증가함수 하나정적은 바이너리가 커질 수 있다
한 컬렉션에 섞기불가능가능이질적 컬렉션은 dyn 의 몫이다

간접 호출 비용은 보통 작다. 규칙 검사처럼 메서드 안에서 해시 조회나 문자열 처리를 하는 일이면 호출 방식의 차이는 묻힌다. 수백만 번 도는 안쪽 루프에서 한두 줄짜리 메서드를 부르는 경우라면 측정해 볼 만하다. 측정은 이 책의 성능을 다루는 장에서 다시 한다. 지금은 비용이 있다는 것, 그러나 습관적으로 피할 만큼 크지는 않다는 것만 기억하면 된다.

객체 안전성

모든 트레이트를 dyn 으로 쓸 수는 없다. vtable 은 크기가 고정된 함수 주소 목록이므로, 그 안에 넣을 수 없는 메서드를 가진 트레이트는 트레이트 객체가 될 수 없다. 이 성질을 객체 안전성(object safety)이라 부르고, 최근 컴파일러는 "dyn 호환(dyn compatible)"이라고 표현한다. 주요 규칙은 다음과 같다.

dyn 호환을 깨는 대표 요소와 해결책
깨는 요소이유해결
타입 매개변수가 있는 메서드타입마다 다른 함수가 필요해 표에 못 담는다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 은 구체 타입에서만 부를 수 있다.

제네릭과 트레이트 객체 고르기

다음 순서로 판단하면 대부분 결정된다.

  1. 한 컬렉션이나 필드에 서로 다른 구현체를 섞어야 하는가. 그렇다면 dyn 이다.
  2. 구현체 목록이 설정이나 입력으로 실행 중에 정해지는가. 그렇다면 dyn 이다.
  3. 분기에 따라 함수가 서로 다른 타입을 반환해야 하는가. impl Trait 는 반환 타입이 하나여야 하므로 Box<dyn Trait> 를 쓴다.
  4. 위에 해당하지 않고 호출이 성능에 민감한가. 제네릭을 쓴다.
  5. 둘 다 괜찮다면 코드가 단순한 쪽을 고르고, 문제가 되면 측정한 뒤 바꾼다.

두 방식은 한 프로그램에 함께 쓰는 것이 자연스럽다. 아래 완성 코드가 그 예로, 규칙 목록은 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 는 타입이 하나여야 함조건별로 다른 구현을 돌려줄 때

연습 문제

  1. MinQuantity { limit: u32 } 규칙을 추가한다. 주문 수량이 limit 미만이면 거부하고 이름은 "최소 수량"으로 한다. 이를 build_rules 에 어떻게 넣어야 하는지 쓴다.
  2. 완성 코드의 OrderRule 에 clone_box 를 더해 Vec<Box<dyn OrderRule>> 를 복제할 수 있게 한다. MaxQuantity 에 대한 구현과 Box<dyn OrderRule> 에 대한 Clone 구현을 쓴다.
  3. 다음 상황에서 제네릭과 dyn 중 무엇이 적절한지 이유와 함께 답한다. (가) 설정 파일의 줄 순서대로 규칙을 쌓는 검증기. (나) 한 종류의 로그 출력기만 쓰고 안쪽 루프에서 1억 번 호출하는 함수.
  4. rules[0].label(25) 를 main 에 넣으면 왜 컴파일되지 않는지 설명하고, 컴파일되게 하는 방법 두 가지를 말한다.

정답과 해설

  1. 다음과 같이 구현한 뒤 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 의 반환 타입은 그대로이고, 기존 코드는 수정하지 않는다. 새 타입이 트레이트만 구현하면 되는 점이 동적 디스패치의 장점이다.
  2. 트레이트에 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)] 이 있어야 하고, 나머지 규칙에도 같은 구현이 필요하다.
  3. (가) dyn 이다. 줄 수와 종류가 실행 중에 정해지므로 Vec<Box<dyn Rule>> 로 보관해야 한다. (나) 제네릭이다. 타입이 하나이고 호출이 아주 뜨거우므로 인라인이 가능한 정적 디스패치가 유리하다. 다만 실제 차이는 측정해서 확인하는 것이 바람직하다.
  4. rules[0] 의 타입은 Box<dyn OrderRule> 이고 dyn OrderRule 은 Sized 가 아니므로, where Self: Sized 가 붙은 label 을 부를 수 없다. 방법은 둘이다. 구체 타입 변수(max)에서 부르거나, label 의 제네릭을 없애 fn label(&self, extra: &dyn Display) -> String 으로 바꾸고 where 절을 제거한다. 후자는 vtable 에 들어가므로 dyn 에서도 호출된다.

다음 장에서는 이 규칙 목록처럼 값의 연속을 다루는 반복자를 어댑터 조합과 직접 구현으로 깊이 살펴본다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.