Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

제네릭(Generics)과 연관 타입(Associated Types)

지금까지 살펴본 두 트레이트, FromDeref의 정의를 다시 한번 비교해 봅시다.

pub trait From<T> {
    fn from(value: T) -> Self;
}

pub trait Deref {
    type Target;
    
    fn deref(&self) -> &Self::Target;
}

두 트레이트 모두 타입을 매개변수화하여 사용하고 있습니다. From의 경우 제네릭 매개변수인 T를 사용하고, Deref는 연관 타입인 Target을 사용합니다.

이 둘의 차이점은 무엇일까요? 그리고 왜 상황에 따라 다른 방식을 선택하는 걸까요?

오직 하나의 구현만 허용할 때

역참조 강제 변환(Deref coercion)이 작동하는 방식 때문에, 특정 타입에 대한 “대상(target)” 타입은 오직 하나만 존재해야 합니다. 예를 들어, String은 오직 str로만 역참조될 수 있습니다. 이는 모호함을 방지하기 위해서입니다. 만약 하나의 타입에 대해 Deref를 여러 번 구현할 수 있다면, 컴파일러는 &self 메서드를 호출할 때 어떤 Target 타입을 선택해야 할지 알 수 없게 됩니다.

이것이 바로 Deref가 연관 타입인 Target을 사용하는 이유입니다. 연관 타입은 트레이트 구현 시점에 고유하게 결정됩니다. 하나의 타입에 대해 트레이트를 두 번 이상 구현할 수 없으므로(연관 타입만 다르게 해서 구현하는 것은 불가능합니다), 주어진 타입에 대해 Target은 단 하나만 지정될 수 있고 모호함이 사라집니다.

제네릭 트레이트: 여러 구현을 허용할 때

반면, 입력 타입 T가 서로 다르다면 하나의 타입에 대해 From을 여러 번 구현할 수 있습니다. 예를 들어 WrappingU32라는 타입에 대해 u32u16을 각각 입력으로 받는 From을 모두 구현할 수 있습니다.

impl From<u32> for WrappingU32 {
    fn from(value: u32) -> Self {
        WrappingU32 { inner: value }
    }
}

impl From<u16> for WrappingU32 {
    fn from(value: u16) -> Self {
        WrappingU32 { inner: value.into() }
    }
}

이것이 가능한 이유는 From<u16>From<u32>가 서로 다른 트레이트로 간주되기 때문입니다. 컴파일러는 변환하려는 값의 타입이 무엇인지 보고 어떤 구현체를 사용할지 명확히 판단할 수 있으므로 모호함이 없습니다.

사례 연구: Add 트레이트

마지막으로 표준 라이브러리의 Add 트레이트를 살펴봅시다.

pub trait Add<RHS = Self> {
    type Output;
    
    fn add(self, rhs: RHS) -> Self::Output;
}

Add 트레이트는 두 가지 메커니즘을 모두 활용합니다.

  • 제네릭 매개변수 RHS: 더할 오른쪽 피연산자의 타입을 나타내며, 기본값은 Self입니다.
  • 연관 타입 Output: 덧셈 결과의 타입을 나타냅니다.

RHS는 제네릭일까요?

RHS가 제네릭인 덕분에 서로 다른 타입끼리 더하는 것이 가능해집니다. 예를 들어 u32 타입에 대해서 다음과 같은 두 가지 구현이 있을 수 있습니다.

impl Add<u32> for u32 {
    type Output = u32;
    
    fn add(self, rhs: u32) -> u32 {
      //                      ^^^
      // 이 부분은 `Self::Output`으로 써도 무방합니다.
      // 컴파일러는 위에서 정의한 `Output` 타입과 실제 반환 타입이
      // 일치하는지만 확인합니다.
    }
}

impl Add<&u32> for u32 {
    type Output = u32;
    
    fn add(self, rhs: &u32) -> u32 {
        // [...]
    }
}

덕분에 u32Add<u32>Add<&u32>를 모두 구현하고 있으므로 다음과 같은 코드가 가능해집니다.

let x = 5u32 + &5u32 + 6u32;

Output은 연관 타입일까요?

Output은 덧셈 연산의 결과 타입을 정의합니다.

그냥 항상 Self를 반환하면 안 될까요? 그럴 수도 있겠지만, 그러면 트레이트의 유연성이 크게 떨어집니다. 예를 들어 다음과 같은 경우를 보시죠.

impl Add<&u32> for &u32 {
    type Output = u32;

    fn add(self, rhs: &u32) -> u32 {
        // [...]
    }
}

여기서 트레이트를 구현하는 타입(Self)은 &u32이지만, 덧셈의 결과는 u32여야 합니다. 만약 add 메서드가 반드시 Self 타입(여기서는 &u32)을 반환해야 했다면, 이런 구현은 불가능했을 것입니다1. Output을 연관 타입으로 분리함으로써 결과 타입을 자유롭게 지정할 수 있게 된 것이죠.

하지만 Output을 제네릭 매개변수로 만들 수는 없습니다. 피연산자들의 타입이 결정되었다면, 그 연산의 결과 타입은 반드시 하나로 고정되어야 하기 때문입니다. 이것이 바로 Output이 연관 타입인 이유입니다.

요약

  • 특정 트레이트 구현에 대해 결과 타입이 고유하게 결정되어야 한다면 연관 타입을 사용하세요.
  • 하나의 타입에 대해 여러 가지 서로 다른 입력 타입을 허용하고 싶다면 제네릭 매개변수를 사용하세요.

Exercise

The exercise for this section is located in 04_traits/10_assoc_vs_generic


  1. 유연함에는 대가가 따릅니다. Output을 사용하면 트레이트 정의가 복잡해지고 구현 시 고려할 사항도 늘어납니다. 이 유연함이 정말로 필요한 경우에만 이런 구조를 설계하는 것이 좋습니다. 여러분만의 트레이트를 만들 때 이 점을 꼭 기억하세요.