Introduction

정수 슬라이스에서 최댓값을 찾는 함수를 작성했다고 가정해 봅시다. 이제 부동소수점 슬라이스에서도 같은 일을 해야 합니다. 로직은 동일한데 타입만 다릅니다. 함수를 복사해서 타입만 바꿀 건가요? 러스트의 제네릭Generic은 이 질문에 대한 답입니다. 이번 글에서는 타입을 추상화하여 코드 중복을 제거하는 제네릭의 문법과 원리를 살펴봅니다.

제네릭이 필요한 이유

아래 두 함수는 슬라이스에서 최댓값을 찾는 동일한 로직이다. 타입만 다르다.

fn largest_i32(list: &[i32]) -> &i32 {
    let mut largest = &list[0];
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn largest_char(list: &[char]) -> &char {
    let mut largest = &list[0];
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

두 함수의 본문은 글자 하나 다르지 않다. 이런 중복은 유지보수 부담을 키우고 버그 수정 시 동기화 누락의 원인이 된다. 제네릭은 타입을 매개변수로 추상화하여 이 문제를 해결한다.

fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

<T: std::cmp::PartialOrd>는 "타입 T는 비교 연산이 가능해야 한다"는 트레이트 바운드다. 이 하나의 함수로 i32, char, f64PartialOrd를 구현한 모든 타입의 슬라이스를 처리할 수 있다.

제네릭 함수

제네릭 함수는 함수 이름 뒤에 꺾쇠괄호(<>)로 타입 매개변수를 선언한다.

fn first<T>(list: &[T]) -> &T {
    &list[0]
}

타입 매개변수 T는 관례적으로 대문자 한 글자를 사용하며, T(Type), E(Error), K(Key), V(Value) 등이 흔하다. 여러 타입 매개변수가 필요하면 쉼표로 구분한다.

fn pick<A, B>(flag: bool, a: A, b: B) -> (A, B) {
    if flag { (a, b) } else { (a, b) }
}

타입 추론과 터보피시

러스트 컴파일러는 대부분의 경우 제네릭 함수의 타입 매개변수를 인자로부터 추론한다.

let numbers = vec![34, 50, 25, 100, 65];
let result = largest(&numbers); // T는 i32로 추론됨

그러나 추론이 불가능한 경우가 있다. 예를 들어 collect처럼 반환 타입에만 제네릭이 사용되는 경우, 컴파일러는 어떤 컬렉션 타입으로 모을지 알 수 없다. 이때 터보피시(turbofish) 문법 ::<>1으로 타입을 명시한다.

let numbers: Vec<i32> = (0..10).collect();       // 타입 어노테이션으로 지정
let numbers = (0..10).collect::<Vec<i32>>();      // 터보피시로 지정

터보피시 문법은 아래와 같이 메서드를 연이어 적용하는 상황에서 중간에 타입을 지정해야 할 때 특히 유용하다.

let parsed = "42".parse::<i32>().unwrap();

제네릭 구조체와 열거형

제네릭 구조체

구조체에 타입 매개변수를 도입하면 다양한 타입의 데이터를 담을 수 있다.

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer_point = Point { x: 5, y: 10 };
    let float_point = Point { x: 1.0, y: 4.0 };
}

위 정의에서 xy는 같은 타입 T다. xi32, yf64를 넣으려면 타입 매개변수를 두 개 사용해야 한다.

struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let mixed = Point { x: 5, y: 4.0 }; // Point<i32, f64>
}

타입 매개변수가 너무 많아지면 코드의 가독성이 떨어진다. 제네릭 매개변수가 여러 개 필요하다면, 설계를 재검토해 볼 시점일 수 있다.

제네릭 열거형

러스트 표준 라이브러리의 핵심 열거형Option<T>Result<T, E>가 대표적인 제네릭 열거형이다.

enum Option<T> {
    Some(T),
    None,
}

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Option<T>는 값이 있거나(Some(T)) 없는(None) 상태를 타입 수준에서 표현한다. Result<T, E>는 성공 값(Ok(T))과 오류 값(Err(E))을 각각 다른 타입으로 표현한다. 이 두 열거형이 제네릭이 아니었다면, 모든 타입 조합에 대해 별도의 열거형을 정의해야 했을 것이다.

제네릭 메서드

구조체나 열거형에 제네릭 메서드를 정의하려면, impl 블록에도 타입 매개변수를 선언해야 한다.

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

impl 뒤의 <T>는 "이 impl 블록이 모든 타입 T에 대한 Point<T>를 대상으로 한다"는 선언이다.

특정 타입에 대한 구현

특정 구체 타입에 대해서만 메서드를 구현할 수도 있다. 이 경우 impl 뒤에 타입 매개변수를 선언하지 않는다.

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

distance_from_originPoint<f32>에만 존재한다. Point<i32>에서 이 메서드를 호출하면 컴파일 오류가 발생한다. 이 패턴은 특정 타입에서만 의미 있는 연산을 정의할 때 유용하다.

메서드 수준의 타입 매개변수

impl 블록의 타입 매개변수와 별개로, 메서드 자체에 추가 타입 매개변수를 도입할 수 있다.

struct Point<T, U> {
    x: T,
    y: U,
}

impl<T, U> Point<T, U> {
    fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };
    let p3 = p1.mixup(p2);
    // p3: Point<i32, char> = Point { x: 5, y: 'c' }
}

여기서 T, Uimpl 블록에서 선언된 매개변수이고, V, Wmixup 메서드에서 새로 선언된 매개변수다. 이 네 가지 타입은 서로 독립적이다.

조건부 메서드 구현

트레이트 바운드impl 블록에 적용하면, 특정 트레이트를 구현한 타입에 대해서만 메서드를 제공할 수 있다.

use std::fmt::Display;

impl<T: Display> Point<T> {
    fn print(&self) {
        println!("({}, {})", self.x, self.y);
    }
}

Point<i32>Display를 구현하므로 print()를 호출할 수 있지만, Display를 구현하지 않는 커스텀 타입의 Point에서는 이 메서드가 존재하지 않는다.

트레이트 바운드와 where

타입 매개변수에 아무 제약이 없으면 그 타입에 대해 할 수 있는 일이 거의 없다. 값을 비교하려면 PartialOrd, 출력하려면 Display, 복제하려면 Clone이 필요하다. 트레이트 바운드는 타입 매개변수가 갖춰야 할 능력을 명시하는 제약 조건이다.

fn notify<T: Display + Clone>(item: &T) {
    println!("속보: {}", item.clone());
}

바운드가 복잡해지면 함수 시그니처가 읽기 어려워진다. where 절은 바운드를 함수 본문 바로 앞으로 분리하여 가독성을 높인다.

fn some_function<T, U>(t: &T, u: &U) -> i32
where
    T: Display + Clone,
    U: Clone + std::fmt::Debug,
{
    // ...
    0
}

두 표기는 의미가 완전히 같다. 트레이트 바운드의 상세한 내용과 impl Trait 문법, + 연산자로 여러 바운드를 결합하는 방법은 트레이트 포스트에서 다루고 있다.

블랭킷 구현

트레이트 바운드를 impl 블록에 적용하면, 특정 트레이트를 구현한 모든 타입에 대해 일괄적으로 다른 트레이트를 구현할 수 있다. 이를 블랭킷 구현(blanket implementation)이라 한다.

impl<T: Display> ToString for T {
    // Display를 구현한 모든 타입에 to_string() 제공
}

표준 라이브러리에서 Display를 구현한 타입이면 자동으로 to_string()을 사용할 수 있는 것이 이 메커니즘 덕분이다.

단형화와 제로 비용 추상화

러스트의 제네릭은 런타임 비용이 없다. 컴파일러는 제네릭 코드를 사용된 구체 타입별로 복제하는 단형화(monomorphization)를 수행한다. 예를 들어 Option<i32>Option<f64>를 사용하면, 컴파일러는 내부적으로 다음과 같은 두 개의 구체 열거형을 생성한다.

// 컴파일러가 생성하는 코드 (개념적)
enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

단형화된 코드는 제네릭이 아닌 코드를 직접 작성한 것과 동일한 성능을 보인다. 함수 호출이 직접 호출로 해석되므로 인라이닝2도 가능하다. 대신, 사용된 타입 조합이 많아지면 바이너리 크기가 증가한다는 트레이드오프가 있다. 이를 단형화 팽창monomorphization bloat이라 부르기도 한다.

단형화는 정적 디스패치static dispatch를 가능하게 한다. 호출할 함수가 컴파일 시점에 이미 결정되므로 간접 호출 오버헤드가 없다. 반면, 런타임에 호출 대상을 결정하는 동적 디스패치dynamic dispatchdyn Trait을 통해 구현되며, vtable3 간접 호출 비용이 발생하는 대신 바이너리 크기를 줄일 수 있다. 두 디스패치 방식의 상세한 비교는 트레이트 포스트에서 확인할 수 있다.

const 제네릭

러스트 1.51부터 도입된 const 제네릭4을 타입 매개변수처럼 사용할 수 있게 해 준다. 가장 대표적인 용례는 배열의 크기를 제네릭으로 다루는 것이다.

const 제네릭 이전

const 제네릭이 없던 시절에는 배열 크기별로 코드를 반복해야 했다. 표준 라이브러리가 한때 배열 트레이트 구현을 크기 0부터 32까지만 제공했던 것이 이 한계의 단적인 예다.

// 크기별로 함수를 복제해야 했다
fn print_array_3(arr: &[i32; 3]) { /* ... */ }
fn print_array_5(arr: &[i32; 5]) { /* ... */ }

const 제네릭 이후

const N: usize처럼 상수 매개변수를 선언하면, 배열 크기를 제네릭으로 추상화할 수 있다.

fn print_array<T: std::fmt::Debug, const N: usize>(arr: &[T; N]) {
    println!("{:?}", arr);
}

fn main() {
    print_array(&[1, 2, 3]);       // N = 3
    print_array(&[1, 2, 3, 4, 5]); // N = 5
}

구조체에도 const 제네릭을 적용할 수 있다.

struct Matrix<T, const ROWS: usize, const COLS: usize> {
    data: [[T; COLS]; ROWS],
}

impl<T: Default + Copy, const ROWS: usize, const COLS: usize> Matrix<T, ROWS, COLS> {
    fn new() -> Self {
        Matrix {
            data: [[T::default(); COLS]; ROWS],
        }
    }
}

fn main() {
    let m: Matrix<f64, 3, 4> = Matrix::new(); // 3×4 행렬
}

const 제네릭의 매개변수로 사용할 수 있는 타입은 현재 정수 타입(usize, i32 등), bool, char로 제한된다.

기본 타입 매개변수

타입 매개변수에 기본값을 지정할 수 있다. 대부분의 경우 특정 타입을 사용하지만, 필요할 때 다른 타입으로 교체할 여지를 남기고 싶을 때 유용하다.

use std::ops::Add;

// Add 트레이트의 정의 (표준 라이브러리)
trait Add<Rhs = Self> {
    type Output;
    fn add(self, rhs: Rhs) -> Self::Output;
}

Rhs = Self는 "타입 매개변수 Rhs를 명시하지 않으면 Self와 같은 타입으로 간주한다"는 의미다. 덕분에 i32 + i32처럼 같은 타입끼리 더하는 가장 흔한 경우에 <Rhs>를 생략할 수 있고, Meters + Millimeters처럼 다른 타입끼리 더하는 경우에만 Rhs를 명시하면 된다.

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Meters) -> Millimeters {
        Millimeters(self.0 + (other.0 * 1000))
    }
}

기본 타입 매개변수는 기존 코드를 깨뜨리지 않으면서 타입에 새로운 가능성을 추가할 때, 또는 대부분의 사용자에게 불필요한 설정을 감출 때 효과적이다.


출처

Footnotes

  1. 터보피시라는 이름은 ::<>의 생김새가 물고기를 닮은 데서 유래했다.

  2. 인라이닝(inlining)이란 함수 호출을 함수 본문의 코드로 대체하는 컴파일러 최적화 기법이다. 호출 오버헤드가 제거되고 이후 최적화(상수 전파, 데드 코드 제거 등)의 기회가 열린다.

  3. vtable(Virtual Method Table)은 가상 메서드의 함수 포인터를 저장하는 테이블이다. Rust에서 dyn Trait은 데이터 포인터와 vtable 포인터로 구성된 팻 포인터(fat pointer)로 표현된다.

  4. const 제네릭은 RFC 2000에서 제안되어 Rust 1.51(2021년 3월)에 최소 기능(MVP)이 안정화되었다. 이후 const 제네릭 표현식({ N + 1 } 등)과 더 넓은 타입 지원이 점진적으로 추가되고 있다.