StatCounter - Free Web Tracker and Counter

Rust Chapter11: Trait

반응형

들어가기 전 

이 글은 프로그래밍 러스트 책을 공부하고 정리한 글입니다. 


요약

  • Trait은 인터페이스 의미를 가짐. Type은 Struct, i32 같은 것들을 의미함.
  • 디스패치 방법
    • 정적 디스패치 : impl Trait, 제네릭은 정적 디스패치임. Call Site를 확인하고 필요한 타입에 대한 함수 코드를 모두 생성한다.
    • 동적 디스패치 : Trait Object를 사용하는 경우. V Table은 컴파일 시점에 한번만 생성되고, Trait Object는 런타임 시점에 타입에 맞는 V Table의 주소와 함께 Fat Pointer로 생성된다. 그리고 VTable을 참조해서 메서드를 호출한다.
  • dyn 키워드는 dynamic의 줄임말임.
    • dyn Type은 허용되지 않음. dyn은 동적 타입이 오는데, 구현체는 실제로 여러 필드를 가지고 있을 수 있어서 컴파일 타임에 크기가 결정되지 않는다. 따라서 컴파일 에러가 발생한다.
    • &dyn Type만 허용된다. 참조자는 항상 크기가 정해지기 때문이다. 
  • 함수에서 Trait Object 타입을 원할 때 참조자를 넘기면, 참조자는 자동으로 Trait Object로 변환된다. 
  • 라이프타임도 제네릭이다. 그러나 컴파일 시점에 생성되는 정적 디스패치용 코드 생성에는 영향을 미치지 않는다. 오로지 타입들만 컴파일 시점에 생성되는 함수만(Monomorphization) 영향을 미친다.
  • 제네릭의 이점 (Trait Object 대비)
    • 정적 디스패치를 하기 때문에 메서드 실행 속도가 빠름.
    • 모든 Trait이 Trait Object를 지원하지는 않음. (예를 들어 연관 함수는 제네릭에서만 사용할 수 있음)
    • 제네릭은 타입 매개변수에 여러 Trait을 Bound해서 타입을 좁힐 수 있음. (Trait Object는 지원하지 않음)
  • Trait Object
    • 다양한 타입의 집합으로 구성된 값을 만들 때 유용함. 
    • 런타임에는 구체 타입에 대한 어떠한 정보도 들어가있지 않다. Trait Object는 기존 구체 데이터를 담은 Data Pointer, 그리고 VTable을 가리기는 VPtr만 가진다.
    • VTable은 컴파일 타임에 생성되고, 각 Trait Object가 사용될 Vtable은 컴파일 타임에 구체 타입을 보고 이미 결정되어있다.
  • Trait도 Default Method를 지원함. 
  • Trait 구현 시, Impl<W: Write + ...> MyTrait for W 같은 형식으로 범용적으로 구현할 수도 있음. 
  • 내가 만들지 않은 타입 + 내가 정의하지 않은 Trait인 경우, 그 타입을 위한 Trait은 내가 정의할 수 없다.
  • trait Child: Parent에서 Child는 Parent의 Sub Trait이다.
    • 이것은 trait Child where Self: Parent와 동일한 의미를 가진다. 즉, Self(Child)는 Parent Trait에 Bound 된 것을 의미함.

 


Trait 기본

  • Trait은 자바의 Interface와 유사 의미
  • Struct는 자바의 Class와 유사 의미
trait Write {
    fn write(&mut self, but: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;
}
  • Write Trait을 구현하는 Struct는 write(), flush() 메서드를 구현해야함.
// 제네릭 T는 Ord Trait에 Bound됨. 
fn min<T: Ord>(value1: T, value2: T) -> T {
    if value1 <= value2 {
        value1
    } else {
        value2
    }
}
  • Generic T는 Ord Trait에 Bound 됨. 이것은 T는 Ord Trait을 구현한 타입을 의미함. 자바 Generic에서는 T extends Ord 같은 느낌

 

 


트레이드 객체 (Trait Object)

  • 트레이트 객체는 런타임에 호출될 실제 메서드를 V Table을 통해서 결정함. 컴파일 되는 코드의 양은 줄어드는데, 런타임에서 그만큼 성능 열화가 있음.
pub fn hello() {
    let mut buf: Vec<u8> = vec![];

    // 아래 코드에서 컴파일 에러 발생.
    // dyn Write는 컴파일 시점에 크기를 알 수 없다.
    // 구현체는 여러 필드를 가지고 있어서, 스택 프레임에 배정된 크기가 다를 수 있기 때문이다.
    // let writer: dyn Write = buf;

    // Reference는 고정된 크기를 가지므로 문제가 없다.
    let writer: &mut dyn Write = &mut buf;
}
  • dyn 키워드는 Dynamic의 줄임말로 런타임에 뭔가 결정된다고 봐도 될 것 같음.
  • dyn Write는 ' 런타임에 결정되는 Write Trait을 구현한 Struct인데'로 이해할 수 있음. 그러나 Struct는 내부 필드를 여러 개 가질 수 있고, 따라서 Struct 구현체마다 필요한 스택 프레임의 크기가 바뀔 수 있음. 이 때문에 dyn Write는 컴파일 타임에 메모리를 결정할 수 없기 때문에 컴파일 에러가 발생함. 
  • &dyn Write는 '런타임에 결정되는 Write Trait을 구현한 Struct의 참조자'다. 
    • 참조자의 크기는 일정하기 때문에 컴파일 타임에도 스택 프레임의 크기를 정할 수 있음. (따라서 문제 없음) 
    • V Table은 컴파일 타임에 타입별로 하나씩 생성되고, Trait Object가 생성될 때 참조자와 V Table 주소를 넘겨서 메서드 호출 시 동적 Dispatch가 일어나도록 한다.

  • 위 이미지는 &mut dyn Write라는 Trait Object의 메모리 구조다.
  • 스택 프레임에는 데이터 + vptr이 있음. 데이터는 힙에 저장된 Buf를 가리키고, Vptr은 동적 디스패치에 사용될 메서드를 모아둔 VTable을 가리키는 형태임. 
// 함수가 Trait Object를 원할 때, 참조자를 넘기면 암묵적으로 Trait Object로 변환됨.
fn need_trait_object(writer: &mut dyn Write) { }
fn other_function() {
    let mut buf: Vec<u8> = vec![];
    need_trait_object(&mut buf);
}

함수가 Trait Object 타입을 원할 때, 참조자를 넘기면 참조자는 암묵적으로 Trait Object로 변환된다.

 


Trait Object에서 VTable이 생성되는 시점, 결정되는 시점

  • 필요한 VTable은 컴파일 타임에 생성된다.
  • Trait Object 캐스팅할 때 사용할 VTable은 컴파일 시점에 이미 결정된다. (아래 코드에서 자세히 확인)
  • 컴파일 타임
    • 컴파일러는 Trait Object로 변환되는 객체의 구체 타입을 알고 있기 때문에 Trait Object 전환 시, 어떤 VTable을 만들어야 하고 쓰여야 할지 알고 있다.
    • 런타임에는 어떠한 타입 정보도 없다. (기본적으로 컴파일 타임에 모든 타입 관련이 정의됨)
  • 그러나 Trait Object를 사용하는 함수 내부에서는 컴파일 타임에 구체적인 타입을 알 수 없다. 따라서 동적 디스패치가 일어난다.
  • 대부분의 경우는 제네릭을 이용한 정적 디스패치로 해결할 수 있다. 그런데 하나의 Collection에서 이종 타입을 사용할 경우에 Trait Object를 사용하는데, 이런 Collection을 처리하는 함수는 결국 Trait Object를 인자로 받을 수 밖에 없다.
struct T1{}

trait Hello { 
    fn hello(&self) -> String; }

impl Hello for T1 { 
    fn hello(&self) -> String { todo!() } }

fn main() {
    let k = T1 {};
    // 컴파일 타임 -> 여기서 &k가 &dyn Hello로 Trait Object 변환되는 것을 확인함.
    // 컴파일러는 이 때, VTable for (Hello, T1)를 생성한다.
    // 컴파일 타임에 이미 &k가 &dyn Hello로 전환되는 시점에 어떤 VTable이 포함되어야 할지 안다.
    // 컴파일 시점에 필요한 VTable이 생성되고, 어떤 VTable이 사용될지 결정된다.
    // 런타임에는 Trait Object 변환 시, 단순히 VTable의 주소만 붙인 Fat Pointer가 생성되는 정도다.
    do_it(&k)
}

fn do_it(trait_object: &dyn Hello) { 
    // 그러나 trait_object 관점에서는 어떤 타입이 오는지 알 수 없다. 
    // 따라서 이때 동적 디스패치가 일어난다. 
	trait_object.hello(); 
}

 


제네릭 함수와 타입 매개변수

  • 타입 매개변수는 <T> 같은 것들을 의미한다.
  • 타입 매개변수를 가지는 함수를 제네릭 함수라고 한다.
  • 타입 매개변수에 원하는 Trait을 바운드해서 타입을 제한할 수 있음.
// 상위 10개의 값을 출력하고 싶다.
pub fn top_ten<T: Debug + Clone + Ord>(values: &Vec<T>) {
    let mut k = values.to_vec();
    k.sort();
    println!("{:}", k);
}
  • 상위 10개의 값을 출력하는 제네릭 함수를 정의하면, 다음 타입 바운드가 필요함.
    • Clone : 값을 복사하기 때문.
    • Ord : 정렬하기 때문
    • Debug: 값을 출력하기 때문. 
pub fn top_ten_with_where<T>(values: &Vec<T>)
    where T: Debug + Clone + Ord
{
	...
}
  • 타입 매개변수에 Trait Bound가 많아지면 가독성이 떨어짐. 이를 개선하기 위해 Where 절로 뺄 수 있음.

 


제네릭 함수 / Trait Object 중 어떤 것을 써야할까?

  • 제네릭 함수와 Trait Object는 기본적으로 다음 차이가 있다.
    • 제네릭 함수는 정적 디스패치를 한다. 따라서 성능이 빠르나, 컴파일 타임 + 컴파일 용량이 더 필요할 수 있음. 
    • Trait Object는 동적 디스패치를 한다. 성능은 희생되나 컴파일 타임 + 컴파일 용량이 줄어듦. 
  • Trait Object는 다양한 타입으로 된 값들의 집합체가 필요할 때 사용하는 것이 적합함. 
struct Carrot {}
struct Apple {}

trait Vegetable {}
impl Vegetable for Carrot {}
impl Vegetable for Apple {}

// 이렇게 작성된 경우, Salad의 Veggis는 한 가지 타입만 가능함.
struct Salad<V: Vegetable> {
    veggies: Vec<V>
}

fn only_one_salad() {
    let apples: Vec<Apple> = vec![];
    let salad = Salad { veggies: apples };
}
  • 위는 Trait Object가 필요한 상황임. 
    • 현재 구현에서 각 Vegetable Trait의 구현체는 서로 다른 크기를 가질 수 있음. 따라서 Vec<Vegetable> 내부에는 단 하나의 타입만 들어갈 수 있음. 
    • 하지만 Salad가 여러 Vegetable로 구현되어야 한다는 요구사항이 있다면, 이 때는 Trait Object를 사용할 수 있음.
struct SaladDynamic { veggies: Vec<Box<dyn Vegetable>> }

fn only_multiple_salad() {
    let mut veggies: Vec<Box<dyn Vegetable>> = vec![];
    veggies.push(Box::new(Apple {}));
    veggies.push(Box::new(Carrot {}));

    let salad = SaladDynamic { veggies: veggies };
}
  • Box<dyn Vegetable>은 Vegetable의 Trait Object를 선언하는 문법이다. 따라서 Vec에는 여러 형태의 Vegetable이 한꺼번에 올 수도 있게 된다. 
// 이건 불가능함, Trait Object는 타입 매개변수로 들어갈 수 없음.
struct SaladDynamic<V: &dyn Vegetable> { veggies: Vec<V> }

// 이것도 불가능함. dyn Vegetable은 struct 그 자체를 의미하기 때문에 크기가 다를 수 있음.  
struct SaladDynamic { veggies: Vec<dyn Vegetable> }
  • 참고로 위 타입은 사용할 수 없음.

 


Trait의 정의와 구현

struct Canvas{}

trait Visible {
    fn draw(&self, canavas: &mut Canvas);
}

struct Broom{}

impl Visible for Broom {
    // Impl ... For ... 에서는 Trait 메서드만 구현 가능.
    // 다른 메서드는 다른 Impl Block에서 생성.
    // Impl ... For ... 에서 Impl Block에 있는 메서드 호출 가능함.
    fn draw(&self, canavas: &mut Canvas) { hello() }
}

impl Broom {
    fn hello() { }
}
  • Visible Trait을 구현하기 위해서는 Impl <Trait Name> for <Struct Name>으로 구현할 수 있음. 
    • 이 Impl Block에서는 Visible Trait 관련 메서드만 구현할 수 있음.
    • 다른 메서드를 선언하려며 Impl <Struct Name> 블록에서 구현하면 됨.
    • Impl <Struct Name> 블록에서 구현한 메서드를 Impl <Trait Name> for <Struct Name>에서도 사용할 수 있음.
// Write Trait을 구현하는 타입들을 위해 Visible Trait을 구현할 수도 있음.
impl<W: Write> Visible for W {
    fn draw(&self, canavas: &mut Canvas) {
        todo!()
    }
}

  • 제네릭을 이용해 특정 Trait을 구현한 Struct를 위한 Trait을 구현할 수도 있음.
    • 위 코드는 Write Trait을 구현한 타입들을 위해 Visible Trait을 구현하는 것임.

 


Trait Default Method

  • 자바의 인터페이스가 Default Method를 지원하듯이, Rust의 Trait도 Default 메서드를 지원한다.
struct Struct1 {}
struct Struct2 {}
trait MyInterface {
    fn hello() { println!("Parent Hello"); }
}

impl MyInterface for Struct1 {}
impl MyInterface for Struct2 {
    fn hello() {
        println!("It's implemented");
    }
}

pub fn default_call() {
    Struct1::hello();
    Struct2::hello();
}
  • Trait에 메서드에 구현되어있고, Impl에서 그 메서드를 구현하지 않은 경우 Default 메서드가 사용된다.

 


Trait 구현의 법칙

  • 아래 두 가지 조건을 만족하는 경우, 그 타입을 위한 Trait을 구현할 수 없다. 
    1. 내가 만든 타입이 아니다
    2. 내가 만든 Trait이 아니다.
  • 이것은 Orphan Rule(고아 규칙)이라고 하며, 내가 만든 타입이 아니면서 Trait이 아닌 경우에는 이미 다른 라이브러리에서 실제 구현이 존재할 수 있다. 만약 내가 그것을 구현하면, 러스트는 어떤 것을 써야할지 알 수 없기 때문에 이 경우는 사용할 수 없게 차단한다.

 


Trait와 Self

  • 러스트는 Self 키워드를 타입으로 사용할 수 있음. Self 키워드는 자기 자신과 동일한 타입을 의미함. 
  • Self 키워드를 쓴(함수 파라메터 or 반환 타입) Trait은 Trait Object로 사용할 수 없음. 자세한 이유는 아래에 기술
pub trait Concat {
    fn concat(&self, other: &Self) -> &Self;
}

// 컴파일 에러 발생
impl Concat for S1 {
    fn concat(&self, other: &Self) -> &Self { self }
}

// 컴파일 에러 발생
impl Concat for S2 {
    fn concat(&self, other: &Self) -> &Self { self }
}

fn hello() { 
  let mut s1 = S1 {};
  let mut s2 = S2 {};
  concat_do_do(&s1, &s2);
}

fn concat_do_do(s1: &dyn Concat, s2: &dyn Concat) {
    let k = s1.concat(s2);
}
  • Self 키워드가 있는 Trait이 Trait Object로 사용될 수 없는 이유는 위 코드를 보며 설명한다.
    1. 컴파일러는 컴파일 시점에 각 Trait Object에 대한 VTable을 생성한다. 여기서는 VTable Concat for S1, VTable Concat for S2가 생성된다. 
    2. Trait Object에서 사용하는 VTable은 반드시 공통된 함수 Signature로 묶일 수 있어야한다.
    3. 그러나 concat() 함수에 대해 S1은 concat(&self, other: &S1) -> &S1, S2는 concat(&self, other: &S2) -> &S2로 서로 다른 Signature를 가진다. 따라서 VTable을 만들 수 없기 때문에 컴파일 에러가 발생한다. 
  • VTable을 생성할 때는 다음과 같은 형태로 생성한다.
    • Trait의 각 함수 포인터를 Table의 인덱스와 맵핑해서 저장한다. (첫번째 인덱스 -> 첫번째 함수 포인터 이런 방식)
    • 함수 포인터도 엄연히 고유의 타입을 가진다. 예를 들면 아래와 같은 형식이다.
      • fn(&str, usize) -> bool
    • 그런데 Self를 이용하면, 각 인덱스마다 서로 다른 함수 포인터를 저장하려고 하기 때문에 '함수 타입'의 문제가 발생해서 저자할 수 없게 된다.

 


Sub Trait

  • 러스트에서 Sub Trait은 자바에서 부모 인터페이스를 자식 인터페이스에서 상속하는 것과 같다.
  • trait Child: Parent { ... } 같은 형식으로 구현한다. 이 때 Child를 'Sub Trait'이라고 한다.
  • 만약 Child Trait을 구현하려는 구체가 있다면, 반드시 Parent Trait도 함께 구현해야한다. 
    • 마킹용 Trait으로 사용할 수도 있을 것 같음. 
    • 혹은 일반적인 인터페이스의 조합처럼 사용해 볼 수도 있을 듯 함.
trait Visible {
    fn hello(&self);
    fn hello_default() { println!("hello_default"); }

}

trait Creature: Visible {
    fn hello_creature(&self);
}

struct Struct1 {}

impl Creature for Struct1 {
    fn hello_creature(&self) { todo!() }
}

// Creature를 구현하려면 반드시 Visible도 함께 구현해야 컴파일 에러가 발생하지 않음. 
impl Visible for Struct1 {
    fn hello(&self) { todo!() }
}
  • 위 코드에서 Creature는 Visible의 Sub Trait이다.
  • Creature, Visible Trait을 각각 구현해야했다.

타입 연관 함수

  • 러스트의 타입 연관 함수는 자바의 Static 메서드와 동일하다고 보면 된다.
  • 아래에서 &self를 인자로 받지 않는 new() 같은 함수들을 타입 연관 함수라고 하고, 사용 시에는 StringSet::new() 같은 형태로 쓸 수 있음.
trait StringSet {
    fn new() -> Self;
    fn contains(&self, string: &str) -> bool;
}

fn unknown_words<S: StringSet>(document: &[String], wordlist: &S) -> S {
    let mut unknowns = S::new(); // 타입 연관 함수도 이렇게 호출할 수 있음.
    for word in document {
        if !wordlist.contains(word) { // continue }
        }
    };
}
  • StringSet을 Trait Object로 사용하고 싶다면, 현재 구현에서는 new()가 Self를 반환하기 때문에 객체 안전성이 깨져 컴파일 에러가 발생한다.
fn new() -> Self where Self: Sized;
  • 컴파일 에러는 new() 함수의 메서드 시그니처를 위와 같이 변경하면 해결된다.
    1. &dyn MyTrait를 사용하면 컴파일 타임에 객체 안전한 메서드들을 위한 VTable을 생성함.
    2. MyTrait의 new() -> Self where Self: Sized같은 메서드는 기본적으로 객체 안전하지 않음. 또한, Trait Object(&dyn MyTrait) 입장에서는 Sized와 어떠한 관련이 있는지 알 수 없음.  
    3. 따라서 이런 경우에는 new() 메서드 자체는 객체 안전하지 않지만, 그 메서드가 &dyn MyTrait과 관련없다는 것을 where 문법을 이용해 명시해주면서 컴파일 에러를 해결할 수 있음. 즉, MyTrait을 위한 VTable에서 new() 함수가 제외되고, Trait Objectd는 new() 함수를 호출할 수 없지만, 컴파일 에러는 해결되는 형식임.

 


타입 간의 관계를 정의하는 Trait

  • Trait은 Struct들이 구현할 메서드들을 정의하는 인터페이스 역할을 했었음.
  • 때로는 Trait은 각 타입들 사이의 관계를 정의하는 역할을 하기도 함.
    • 여기서 이야기하는 타입은 Trait 타입이 아니라, Trait이 어떤 타입을 가지고 있고, 어떻게 연관되어 돌아가는지 정의되는 것을 의미함. 

 

1. 연관 타입(Iterator의 동작 방식)

trait MyIterator {
    // 연관타입 Item
    type Item;

    // 연관타입을 반환.
    // Self::Item은 독립적인 타입이 아니라, 각 Iterator 구현에 종속적인 것을 의미함.
    fn next(&mut self) -> Option<Self::Item>;
}
  • 이터레이터는 내부에 연관타입 Item을 가지고 있음.
  • 이터레이터의 next() 함수는 다음 element를 반환하는데, 이 타입을 Item 타입으로 표현해준다.
  • 각 Iterator마다 독립된 연관 타입을 가지고 있음. 
// 제네릭 코드에서도 연관 타입을 사용할 수도 있음.
// 이 함수는 문맥상 Iterator가 가진 요소들을 반환하는 것이기 때문에 iterator의 연관타입 item을 사용함.
fn collect_into_vector<I: Iterator>(iter: I) -> Vec<I::Item> {
    let mut results = Vec::new();
    for value in iter {
        results.push(value);
    }
    results
}
  • 제네릭 코드에서 연관 타입을 사용할 수 있음
    • 위 코드는 Iterator가 가지는 요소들을 Vector로 반환함. 
    • 이 때 Iterator가 가지는 타입은 Iterator::Item에 정의되므로, 제네릭 코드에서는 이것을 이용해서 반환 타입을 명시할 수 있음. 
fn dump<I>(iter: I)
    where I: Iterator
{
    // 아래 코드는 컴파일 되지 않는다.
    // Iterator가 제공하는 value의 타입이 Debug Trait을 구현하는지 알 수 없음.
    for (index, value) in iter.enumerate() {
        println!("{}, {:?}", index, value);
    }
}

 

 

  • 다음 코드는 컴파일 되지 않는다.
    • Iterator의 Element가 println!(...) 에서 필요한 Debug Trait을 구현하고 있는지 알 수 없기 때문이다.
fn dump1<I>(iter: I)
    where I: Iterator, I::Item: Debug // I가 반환하는 Item 타입이 Display 가능한지를 표현해준다.
//  where I: Iterator<Item=String>  // 타입 바운드의 또 다른 방법
{
    for (index, value) in iter.enumerate() {
        println!("{}, {:?}", index, value);
    }
}
  • 코드를 컴파일 하기 위해서는 Iterator::Item 타입이 Debug Trait을 구현하는지 명시해주면 된다.

 

2. 제네릭 트레이트 (연산자 오버로딩의 동작 방식)

// RHS = Right hand side
pub trait MyMul<RHS=Self> {
    // 연산 이후의 결과 타입.
    type Output;
    // * 연산자를 위한 메서드.
    fn mul(self, rhs: RHS) -> Self::Output;
}
  • 제네릭 트레이트에서도 연관 타입을 활용할 수 있다.
  • Mul은 러스트에서 '*' 연산자를 위해 제공되는 Trait이다.
  • 여기서 RHS는 제네릭을 의미한다.
struct MyComplex {}

// RHS=Self로 구현되어있기 때문에
// impl MyMul for MyComplex는 impl MyMul<Complex> for MyComplex와 동일하다.
// 여기서 RHS=Self는 fn mul()의 rhs의 타입에 사용된다.
impl MyMul for MyComplex {
    type Output = MyComplex;
    fn mul(self, rhs: MyComplex) -> Self::Output {
        todo!()
    }
}
  • 실제 구현은 다음과 같이 해볼 수 있음. 
    • 위에서 RHS=Self라고 구현했다. 따라서 impl MyMul for MyComplex에서 RHS 제네릭은 SELF가 되어서 fn mul(...) 에서 사용되는 인자는 자연스레 MyComplex가 된다.

 

3. impl 트레이트

  • 많은 제네릭 타입을 조합하다보면 가독성이 떨어지게 된다. 이를 개선하기 위해 impl Trait을 사용할 수 있음.
  • Impl Trait은 아래 위치에서 사용할 수 있다.
    • 함수의 반환 타입.
    • 제네릭 함수 파라메터 대신에 함수 파라메터를 impl Trait으로 받을 수 있음.
use std::iter;
use std::vec::IntoIter;

// 이 코드는 반환 타입에 대한 가독성이 나쁘다.
fn cyclical_zip(v: Vec<u8>, u: Vec<u8>) ->  iter::Cycle<iter::Chain<IntoIter<u8>, IntoIter<u8>>> {
    v.into_iter().chain(u.into_iter()).cycle()
}

// 가독성을 높이기 위해 Trait Object로 바꿔서 반환할 수 있음.
// 그런데 고작 이거 개선하려고 동적 디스패치로 성능을 낮게 가져가는 것은 말도 안됨.
fn cyclical_zip2(v: Vec<u8>, u: Vec<u8>) ->  Box<dyn Iterator<Item=u8>> {
    Box::new(v.into_iter().chain(u.into_iter()).cycle())
}

// 가독성을 높이기 위해 impl Trait을 사용함. (좀 더 추상화 됨)
// 이 함수를 사용하는 쪽에서는 반환되는 값을 Iterator<u8>을 구현하는 타입이라고 가정하고 사용해도
// 문제없이 컴파일 된다.
fn cyclical_zip3(v: Vec<u8>, u: Vec<u8>) ->  impl Iterator<Item=u8> {
    v.into_iter().chain(u.into_iter()).cycle()
}
  • 첫번째 코드는 반환 타입이 구체적으로 나오는데 복잡해서 읽기 어렵고, 실제 어떤 타입인지 알 수 없음.
  • 두번째 코드는 첫번째 코드를 개선한 버전이지만, Trait Object를 반환하기 때문에 동적 디스패치가 발생해 성능 저하가 우려됨.
  • 세번째 코드는 반환 타입을 impl Trait으로 설정하여 정적 디스패치 형태로 추상화했음.
    • 세번째 코드를 사용하는 쪽에서는 Iterator<u8>을 구현한 값을 가정하고 코드를 작성해도, 컴파일러가 어떠한 불만도 표하지 않는다.
pub fn make_shape(shape: &str) -> impl Shape {
    match shape {
        "circle" => Box::new(Circle::new()),
        "triangle" => Box::new(Triangle::new()),
        _ => Box::new(Rectangular::new()),
    }
}
  • 다만 impl trait은 위 경우에는 사용할 수 없음.
    • 위 코드는 서로 다른 타입을 반환한다. 서로 다른 타입이라는 것은 서로 다른 크기를 가질 수 있기 때문에 컴파일 시점에 메모리 사용을 결정할 수 없어서 컴파일러가 불평한다. 
    • 따라서 위 코드는 impl Shape로 추상화를 한다고 하더라도, 반환 타입이 여러가지이기 때문에 impl Shape로 해결할 수 없다.
    • 엄밀히 말하면 impl Shape가 그 문제를 해결할 수 없다고 보는 것이 맞겠다. 

 

4. 연관 상수

  • Trait에서 연관상수를 선언할 수 있음.
  • Trait 구현체에서 연관상수를 오버라이드 할 수도 있음. 
  • 연관 상수를 일반 제네릭 함수에서도 사용할 수 있다. 
  • 연관 상수를 사용하기 위해서는 컴파일 시점에 타입 정보가 필요하다. 그러나 Trait Object는 컴파일 시점에 구체적인 타입을 결정할 수 없기 때문에 어떤 연관 상수를 사용하는지 결정할 수 없다. 따라서 Trait Object에서는 연관상수를 쓸 수 없다. 
use std::ops::Add;

// Trait 같은 인터페이스 내부에 연관 상수를 선언할 수 있음.
trait MyFloat {
    // 연관 상수
    const ZERO: Self;
    const ONE: Self;
    const TWO: &'static i32 = &1;
}

// 연관상수도 상속받아서 오버라이드 할 수 있음.
impl MyFloat for f32 {
    const ZERO: f32 = 0.0;
    const ONE: f32 = 1.0;
}

// 연관 상수를 일반적인 메서드에서 사용할 수도 있음.
// 연관 상수를 사용할 때는 타입 정보가 필요하기 때문에, 컴파일 시점에 타입 정보를 알 수 있어야 한다.
// 따라서 연관 상수는 Trait Object에서는 사용할 수 없다. 
fn fib<T: MyFloat + Add<Output=T>>(n: usize) -> T {
    match n {
        0 => T::ZERO,
        1 => T::ONE,
        n => fib::<T>(n-1) + fib::<T>(n-2)
    }
}

 


바운드 역설계

  • 제네릭 코드를 작성하다보면 모든 요구 사항의 하나의 Trait으로 정의되지 않아서 표현하기 어렵다. 이 때는 바운드 역설계를 해볼 수 있다.
  • 아래처럼 복잡한 바운드가 있다면, 오히려 필요한 값들을 하나씩 추가하면서 바운드 역설계를 해보는 것이 좋은 방법이 된다. 
use std::ops::{Add, Mul};

// 이 함수를 제네릭으로 만들고 싶다.
fn dot1(v1: &[i64], v2: &[i64]) -> i64 {
    let mut total = 0;
    for i in 0 .. v1.len() {
        total = total + v1[i] + v2[i];
    }
    total
}

// 제네릭 N을 넣었지만, 제네릭 N이 Copy 가능한지 알 수 없다.
// 따라서 v1[i]라고 하면 Move를 하려고 해서 컴파일러가 거절한다.
// Copy Trait을 구현하게 되면, Move 대신에 값을 복사해서 전달한다.
fn dot2<N>(v1: &[N], v2: &[N]) -> N
    where N: Copy

{
    let mut total: N = 0;
    for i in 0 .. v1.len() {
        total = total + v1[i] * v2[i];
    }
    total
}


// 제네릭 N이 곱하거나 더할 수 있는 값이라는 것을 알 수 없다.
fn dot3<N>(v1: &[N], v2: &[N]) -> N
    where N: Mul<Output=N> + Add<Output=N> + Copy
{
    let mut total: N = 0;
    for i in 0 .. v1.len() {
        total = total + v1[i] * v2[i];
    }
    total
}


// N 타입의 total이라는 변수가 0을 가질 수 있는지 알 수 없다.
// 따라서 적절한 Default Type을 넣어주는게 좋다.
fn dot4<N>(v1: &[N], v2: &[N]) -> N
    where N: Mul<Output=N> + Add<Output=N> + Copy + Default
{
    let mut total: N = N::default();
    for i in 0 .. v1.len() {
        total = total + v1[i] * v2[i];
    }
    total
}

 

'프로그래밍 언어 > Rust' 카테고리의 다른 글

Rust Chapter9 : Struct  (0) 2025.01.04
Rust : 레퍼런스(Reference)  (1) 2024.12.21
Rust : 소유와 이동  (2) 2024.12.14

댓글

Designed by JB FACTORY