Associated Type과 Type Safe

개요

Rust의 trait을 쓰다 보면 Iterator의 type Item 같은 문법을 만나게 됩니다. Generic 파라미터처럼 타입을 나중에 정하는 장치인데, 자리를 잡는 방식이 조금 다릅니다. 이것을 Associated Type이라고 부릅니다.

Associated Type은 코드를 짧게 만들어주는 문법 편의처럼 소개될 때가 많습니다. 하지만 실제로 하는 일은 조금 더 근본적입니다. 어떤 코드를 컴파일 타임에 거절할 것인지를 바꿉니다.

이 글에서는 Associated Type이 무엇인지 먼저 보고, 이어서 하나의 trait을 놓고 컴파일러가 통과시키는 코드와 거절하는 코드를 실제 에러 메시지와 함께 대조합니다. 그다음 같은 상황을 Generic 파라미터로 짰을 때 무엇이 달라지는지 비교하고, 마지막으로 "같은 타입에 trait을 여러 번 구현할 수 없다"는 제약을 어떻게 봐야 하는지 정리하겠습니다.

이 글에 실린 코드는 모두 실제로 컴파일해서 결과를 확인한 것입니다(rustc 1.97.1, edition 2021). 에러 메시지도 컴파일러가 출력한 내용 그대로입니다. 다만 읽기 편하도록 파일 경로와 줄 번호를 덜어내고 긴 줄은 접었습니다.


Associated Type — trait 안에 비워둔 타입 자리

The Rust Programming Language는 Associated Type을 이렇게 설명합니다.

"Associated types connect a type placeholder with a trait such that the trait method definitions can use these placeholder types in their signatures."

— Associated Type은 타입 placeholder를 trait과 연결해서, trait의 메서드 정의가 시그니처 안에서 그 placeholder 타입을 쓸 수 있게 한다.

핵심은 trait을 정의하는 시점에는 구체적인 타입을 몰라도 된다는 것입니다. 자리만 비워두고, 무엇을 넣을지는 구현하는 쪽이 정합니다.


Iterator의 type Item

가장 익숙한 예가 표준 라이브러리의 Iterator입니다.

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

type Item; 한 줄이 Associated Type 선언입니다. 이 시점에서 Item이 u32인지 String인지는 정해져 있지 않습니다. next 메서드는 그 미정 타입을 Self::Item이라는 이름으로 참조합니다.

Iterator가 이렇게 정의되어 있기 때문에, 하나의 trait이 정수를 내놓는 Iterator에도 문자열을 내놓는 Iterator에도 쓰일 수 있습니다.


구현하는 쪽이 자리를 채운다

비워둔 자리는 impl 블록에서 채웁니다.

struct Counter {
    count: u32,
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

type Item = u32; 로 Counter의 Item이 확정됩니다. 이 한 줄 덕분에 컴파일러는 Counter를 쓰는 모든 곳에서 next()의 결과가 Option<u32>라는 사실을 알고 있습니다.

let mut counter = Counter { count: 0 };

counter.next();   // Some(1) — Option<u32>

호출하는 쪽에서 "어느 Iterator 구현을 쓸 것인지"를 밝히지 않았다는 점을 눈여겨봐 주세요. Counter에 Iterator 구현은 하나뿐이고 그 하나가 Item을 u32로 확정해 두었기 때문에, 컴파일러가 스스로 결정할 수 있습니다. 뒤에서 이 지점이 Generic 파라미터와 갈리는 핵심이 됩니다.


타입 시스템이 무엇을 거절하는가

이제 본론입니다. Associated Type이 type safety에 기여하는 방식은 의미가 다른 타입을 같은 자리에 놓으려는 코드를 컴파일 타임에 막는 것입니다.

설명을 위해 작은 trait을 하나 만들겠습니다.

trait Container {
    type Item;

    fn get(&self, index: usize) -> Option<&Self::Item>;
    fn len(&self) -> usize;
}

Container는 무언가를 담고 있고 인덱스로 꺼낼 수 있는 타입을 나타냅니다. 무엇을 담는지는 Item이 정합니다.


허용되는 것 — 같은 trait을 다른 Item으로 구현하기

먼저 통과하는 쪽입니다. 서로 다른 타입이 같은 trait을 서로 다른 Item으로 구현하는 것은 아무 문제가 없습니다.

struct Numbers(Vec<i32>);
struct Names(Vec<String>);

impl Container for Numbers {
    type Item = i32;

    fn get(&self, index: usize) -> Option<&i32> { self.0.get(index) }
    fn len(&self) -> usize { self.0.len() }
}

impl Container for Names {
    type Item = String;

    fn get(&self, index: usize) -> Option<&String> { self.0.get(index) }
    fn len(&self) -> usize { self.0.len() }
}

Numbers는 i32를, Names는 String을 담습니다. 둘 다 Container지만 각자 독립적으로 동작합니다.

let numbers = Numbers(vec![3, 10]);
let names = Names(vec![String::from("potato"), String::from("gi")]);

numbers.get(0);   // Some(3)        — Option<&i32>
names.get(0);     // Some("potato")  — Option<&String>

같은 get 호출인데 반환 타입이 다릅니다. 컴파일러가 각 타입의 Item이 무엇인지 이미 알고 있기 때문입니다. 여기까지는 Associated Type이 추상화를 넓혀주는 모습입니다.


거절되는 것 — 서로 다른 Item을 섞어 쓰기

이번에는 두 Container의 첫 번째 원소가 같은지 비교하는 함수를 써보겠습니다. 둘 다 Container니까 될 것 같습니다.

fn same_first<A, B>(a: &A, b: &B) -> bool
where
    A: Container,
    B: Container,
    A::Item: PartialEq,
{
    a.get(0) == b.get(0)
}

컴파일하면 거절당합니다.

error[E0308]: mismatched types
   |
   |     a.get(0) == b.get(0)
   |                 ^^^^^^^^ expected `Option<&<A as Container>::Item>`,
   |                             found `Option<&<B as Container>::Item>`
   |
   = note: expected enum `Option<&<A as Container>::Item>`
              found enum `Option<&<B as Container>::Item>`

에러 메시지를 그대로 읽으면 이유가 분명합니다. 컴파일러가 보기에 A::Item과 B::Item은 서로 다른 타입입니다. A: Container와 B: Container라고만 적었으니 A와 B가 같은 것을 담는다는 보장은 어디에도 없습니다.

여기서 주목할 점이 두 가지 있습니다.

첫째, 이 에러는 same_first를 호출하기 전에 납니다. 함수를 정의만 해두고 아무도 부르지 않아도 컴파일러는 거절합니다. 계약이 성립하지 않는다는 사실을 함수 본문만 보고 판단하기 때문입니다.

둘째, 이것은 실수를 잡아준 것입니다. Numbers가 담은 i32와 Names가 담은 String을 ==로 비교하는 코드는 의미가 없습니다. 자리 이름이 똑같이 Item이라고 해서 같은 타입인 것은 아니라는 사실을 타입 시스템이 강제합니다.


Item = 바인딩으로 같은 타입임을 명시하기

그렇다면 "두 Container가 같은 것을 담는다"는 조건은 어떻게 표현할까요. Associated Type은 trait bound 안에서 Item = ... 형태로 값을 지정할 수 있습니다.

fn same_first<A, B>(a: &A, b: &B) -> bool
where
    A: Container,
    B: Container<Item = A::Item>,
    A::Item: PartialEq,
{
    a.get(0) == b.get(0)
}

B: Container<Item = A::Item> 한 줄이 추가됐습니다. "B도 Container인데, 그 Item은 A의 Item과 같은 타입이어야 한다"는 뜻입니다. 이제 컴파일이 통과합니다.

let numbers = Numbers(vec![3, 10]);
let others = Numbers(vec![3, 99]);

same_first(&numbers, &others);   // true

그리고 조건을 어기는 호출은 여전히 막힙니다.

let names = Names(vec![String::from("potato")]);

same_first(&numbers, &names);
error[E0271]: type mismatch resolving `<Names as Container>::Item == i32`
   |
   |     same_first(&numbers, &names);
   |                          ^^^^^^ type mismatch resolving
   |                                 `<Names as Container>::Item == i32`
   |
note: expected this to be `i32`
   |
   |     type Item = String;
   |                 ^^^^^^
note: required by a bound in `same_first`
   |
   |     B: Container<Item = A::Item>,
   |                  ^^^^^^^^^^^^^^ required by this bound in `same_first`

에러가 나는 위치가 앞과 다르다는 점이 중요합니다. 이번에는 함수 정의가 아니라 호출하는 줄에서 거절됩니다. 함수 자체는 올바른 계약을 갖고 있고, 그 계약을 지키지 않은 것은 호출하는 쪽이기 때문입니다.

정리하면 Associated Type은 "같은 타입이어야 한다"는 요구를 시그니처에 적어두게 만듭니다. 적지 않으면 함수를 정의할 수 없고, 적어두면 어기는 호출이 막힙니다. 어느 쪽이든 문제가 런타임까지 흘러가지 않습니다.


같은 상황을 Generic으로 짜면

같은 Container를 Associated Type 대신 Generic 파라미터로 만들면 어떻게 될까요. 문법은 비슷하지만 컴파일러가 통과시키는 지점과 막는 지점이 달라집니다.


Generic은 같은 타입에 여러 번 구현할 수 있다

Generic 파라미터 버전의 Container입니다.

trait Container<T> {
    fn get(&self, index: usize) -> Option<&T>;
}

이름은 같지만 앞 절의 Container와는 다른 trait입니다. Item을 안에 두는 대신 T를 밖에서 받습니다. 겉보기에는 큰 차이가 없어 보이지만 결정적인 차이가 하나 있습니다. Generic 파라미터를 쓰면 하나의 타입에 이 trait을 여러 번 구현할 수 있습니다.

struct Bag {
    ints: Vec<i32>,
    names: Vec<String>,
}

impl Container<i32> for Bag {
    fn get(&self, index: usize) -> Option<&i32> { self.ints.get(index) }
}

impl Container<String> for Bag {
    fn get(&self, index: usize) -> Option<&String> { self.names.get(index) }
}

Bag 하나에 Container<i32>와 Container<String>이 둘 다 붙었습니다. 이 코드는 컴파일됩니다. The Rust Programming Language는 이 차이를 이렇게 설명합니다.

"when a trait has a generic parameter, it can be implemented for a type multiple times, changing the concrete types of the generic type parameters each time. When we use the next method on Counter, we would have to provide type annotations to indicate which implementation of Iterator we want to use."

— trait에 generic 파라미터가 있으면 하나의 타입에 대해 여러 번 구현할 수 있고, 그때마다 generic 타입 파라미터의 구체 타입이 달라진다. Counter에서 next 메서드를 쓸 때는 어떤 Iterator 구현을 쓰려는 것인지 타입 애노테이션으로 알려줘야 한다.

인용문의 마지막 문장이 실제로 어떤 모습인지 확인해 보겠습니다.

let bag = Bag {
    ints: vec![3, 10],
    names: vec![String::from("potato")],
};

bag.get(0);
error[E0283]: type annotations needed
   |
   |     bag.get(0);
   |         ^^^
   |
note: multiple `impl`s satisfying `Bag: Container<_>` found
   |
   | impl Container<i32> for Bag {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   | impl Container<String> for Bag {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: try using a fully qualified path to specify the expected types
   |
   -     bag.get(0);
   +     <Bag as Container<T>>::get(&bag, 0);

bag.get(0)이 무엇을 돌려줘야 할지 컴파일러가 결정할 수 없습니다. 후보가 둘이기 때문입니다. 쓰려면 매번 어느 구현인지 밝혀야 합니다.

Container::<i32>::get(&bag, 0);      // Some(3)
Container::<String>::get(&bag, 0);   // Some("potato")

앞에서 counter.next()를 아무 애노테이션 없이 호출할 수 있었던 것과 대조됩니다. Associated Type 버전에서는 후보가 애초에 하나뿐이라 컴파일러가 혼자 결정할 수 있었습니다.


시그니처가 짧아진다 — Contains 예시

Generic 파라미터의 또 다른 비용은 함수 시그니처에서 드러납니다. Rust by Example의 Contains 예제가 이 차이를 잘 보여줍니다.

먼저 Generic 파라미터 버전입니다.

trait Contains<A, B> {
    fn contains(&self, _: &A, _: &B) -> bool;
    fn first(&self) -> i32;
    fn last(&self) -> i32;
}

fn difference<A, B, C>(container: &C) -> i32
where
    C: Contains<A, B>,
{
    container.last() - container.first()
}

difference는 first()와 last()만 씁니다. A와 B는 함수 본문에 한 번도 등장하지 않는데도 시그니처에 적어야 합니다. C: Contains<A, B>라고 쓰려면 A와 B에 이름이 있어야 하기 때문입니다. Rust by Example은 이 상황을 이렇게 표현합니다.

"Because Contains is generic, we are forced to explicitly state all of the generic types for fn difference(). In practice, we want a way to express that A and B are determined by the input C."

— Contains가 generic이기 때문에 fn difference()에 모든 generic 타입을 명시할 수밖에 없다. 실제로 원하는 것은 A와 B가 입력 C에 의해 결정된다고 표현하는 방법이다.

Associated Type으로 바꾸면 그 표현이 가능해집니다.

trait Contains {
    type A;
    type B;

    fn contains(&self, _: &Self::A, _: &Self::B) -> bool;
    fn first(&self) -> i32;
    fn last(&self) -> i32;
}

fn difference<C: Contains>(container: &C) -> i32 {
    container.last() - container.first()
}

타입 파라미터가 셋에서 하나로 줄었습니다. A와 B는 C가 정해지면 따라서 정해지므로 더 이상 함수 시그니처에 적을 필요가 없습니다.

읽기 좋아진 것은 결과일 뿐, 본질은 앞 절과 같습니다. A와 B가 C에 종속된다는 사실을 타입 시스템이 알고 있느냐의 차이입니다.


구현을 한 번만 허용하는 것은 한계인가

지금까지 본 내용을 뒤집으면 Associated Type에는 분명한 제약이 있습니다. 같은 타입에 같은 trait을 여러 번 구현할 수 없습니다. 앞의 Associated Type 버전 Container와 Numbers로 돌아가 확인해 보겠습니다.

impl Container for Numbers {
    type Item = i32;
    // ...
}

impl Container for Numbers {
    type Item = String;
    // ...
}
error[E0119]: conflicting implementations of trait `Container` for type `Numbers`
   |
   | impl Container for Numbers {
   | -------------------------- first implementation here
   |
   | impl Container for Numbers {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^ conflicting implementation for `Numbers`

The Rust Programming Language도 같은 이야기를 합니다.

"With associated types, we don't need to annotate types, because we can't implement a trait on a type multiple times."

— Associated Type을 쓰면 타입을 애노테이션할 필요가 없다. 하나의 타입에 trait을 여러 번 구현할 수 없기 때문이다.

문장의 구조가 중요합니다. "여러 번 구현할 수 없다"가 "애노테이션할 필요가 없다"의 원인으로 적혀 있습니다. 표현력을 잃은 대가로 모호함이 사라진 것이 아니라, 둘은 같은 사실의 양면입니다.

Numbers가 Container를 한 번만 구현한다면 numbers.get(0)의 답은 언제나 하나입니다. 반대로 여러 번 구현할 수 있다면 numbers.get(0)은 그 자체로 답이 정해지지 않는 식이 되고, 읽는 사람도 컴파일러도 주변 문맥을 뒤져야 합니다.

그래서 이 제약은 한계라기보다 선택의 문제입니다.

  • 하나의 타입이 한 가지 방식으로만 그 trait을 만족하는 것이 자연스럽다면 Associated Type이 맞습니다. Iterator가 대표적입니다. 하나의 Iterator가 두 종류의 원소를 동시에 내놓아야 할 이유는 없습니다.
  • 하나의 타입이 여러 방식으로 만족하는 것이 자연스럽다면 Generic 파라미터가 맞습니다. 표준 라이브러리의 From<T>가 그렇습니다. String은 &str에서도 char에서도 만들어질 수 있어야 하므로 From<&str>과 From<char>이 동시에 존재해야 합니다.

마무리

정리하겠습니다.

  • Associated Type은 trait 안에 비워둔 타입 자리입니다. Iterator의 type Item처럼 trait을 정의할 때는 자리만 잡아두고, 구현하는 쪽이 type Item = u32;로 한 번 채웁니다.
  • 서로 다른 타입이 같은 trait을 다른 Item으로 구현하는 것은 허용됩니다. 각자 독립적으로 동작합니다.
  • 하지만 서로 다른 Item을 한자리에서 섞는 코드는 거절됩니다. 같은 타입임을 요구하려면 B: Container<Item = A::Item>처럼 시그니처에 적어야 하고, 그러면 계약을 어기는 호출이 컴파일 타임에 막힙니다.
  • 같은 상황을 Generic 파라미터로 짜면 하나의 타입에 여러 번 구현할 수 있게 되고, 그 대가로 호출할 때마다 어느 구현인지 밝혀야 합니다. 함수 시그니처에도 본문에서 쓰지 않는 타입 파라미터가 따라붙습니다.
  • "같은 타입에 여러 번 구현할 수 없다"는 제약은 모호함을 없앤 대가입니다. 표현력을 잃은 것이 아니라, 그 타입이 그 trait을 만족하는 방식이 하나라고 선언한 것입니다.

Associated Type을 고를지 Generic 파라미터를 고를지는 결국 한 가지 질문으로 정리됩니다. 이 타입이 이 trait을 만족하는 방식은 하나뿐인가? 하나뿐이라면 Associated Type이 그 사실을 타입 시스템에 알려주고, 컴파일러는 그 사실을 근거로 잘못된 코드를 대신 막아줍니다.


References