제네릭 타입

제네릭 타입

함수나 구조체 같은 정의를 만들 때, 그 안의 타입을 나중에 여러 구체 타입으로 채워 쓸 수 있다면 얼마나 좋을까요? 그게 바로 제네릭(generics)이에요. 함수 시그니처나 구조체처럼 항목을 정의할 때 제네릭을 쓰면, 그 정의를 다양한 구체 타입에 대해 재사용할 수 있어요. 같은 논리를 타입만 다르게 해서 반복하고 있던 코드를 하나로 합칠 수 있죠. 먼저 함수·구조체·열거형·메서드에서 제네릭을 어떻게 정의하는지 보고, 그다음 제네릭이 코드 성능에 어떤 영향을 주는지 이야기할게요.

제네릭의 핵심은 "타입을 매개변수로 만든다"는 거예요. 값 매개변수에 이름을 붙이듯 타입 매개변수에도 이름을 붙이고, 그 이름을 시그니처 안에서 쓰면 되죠. Rust에서 타입 매개변수 이름은 관례상 짧게, 보통 한 글자로 써요. T가 대부분의 Rust 프로그래머가 고르는 기본값이에요.

출처: The Rust Programming Language — Generic Data Types

함수 정의에서

슬라이스에서 가장 큰 값을 찾는 함수를 생각해 보죠. i32 슬라이스용 largest_i32char 슬라이스용 largest_char 두 개가 있었다면, 몸통은 같은데 타입만 달라서 중복이 생겨요. 이 중복을 없애기 위해 하나의 함수에서 제네릭 타입 매개변수를 도입해요. 타입 매개변수 이름은 함수 이름과 매개변수 목록 사이의 꺾쇠 <> 안에 선언해요.

fn largest<T>(list: &[T]) -> &T {

이 정의는 "함수 largest는 어떤 타입 T에 대해 제네릭이다"라고 읽어요. 매개변수 list는 타입 T의 값들로 이뤄진 슬라이스이고, largest는 같은 타입 T의 값에 대한 참조를 반환해요.

그런데 이대로 쓰면 아직 컴파일되지 않아요. 오류 메시지가 std::cmp::PartialOrd라는 트레이트를 언급해요. 트레이트는 다음 절에서 다루는데, 요점은 largest의 몸통이 비교 연산을 하므로 T가 될 수 있는 모든 타입에 대해 동작하지는 않는다는 거예요. T 타입의 값을 비교하려면 그 값들의 순서를 정할 수 있는 타입이어야 하죠. 표준 라이브러리의 std::cmp::PartialOrd 트레이트가 바로 비교를 가능하게 해줘요. 이 트레이트를 구현한 타입으로 T를 제한하면 컴파일되는데, 표준 라이브러리는 i32char 모두에 PartialOrd를 구현해 두었어요.

구조체 정의에서

구조체도 <> 문법으로 하나 이상의 필드에 제네릭 타입 매개변수를 쓸 수 있어요. Point<T> 구조체는 타입 Tx, y 좌표 값을 담아요.

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

구조체에서 제네릭을 쓰는 문법은 함수 정의와 비슷해요. 구조체 이름 바로 뒤 꺾쇠 안에 타입 매개변수 이름을 선언하고, 구체 타입을 쓸 자리에 그 제네릭 타입을 쓰면 되요. 주의할 점이 하나 있어요. Point<T>를 정의할 때 타입을 하나만 썼으므로, 이 정의는 xy 필드가 '둘 다' 같은 타입이어야 한다는 뜻이에요. x에 정수 5를 넣어 컴파일러가 그 인스턴스의 T를 정수로 알아챈 뒤, yx와 다른 타입인 4.0을 지정하면 타입 불일치 오류가 나요.

xy가 서로 다른 타입일 수 있게 하려면 타입 매개변수를 여러 개 쓰면 돼요. x가 타입 T, y가 타입 UPoint<T, U>로 정의하면 두 필드가 다른 타입의 값을 가질 수 있어요.

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

정의에 타입 매개변수는 원하는 만큼 쓸 수 있지만, 몇 개가 넘어가면 코드가 읽기 어려워져요. 타입이 너무 많이 필요하다면 코드를 더 작은 조각으로 재구성해야 한다는 신호일 수 있어요.

열거형 정의에서

구조체와 마찬가지로 열거형도 변형(variant)에 제네릭 데이터 타입을 담을 수 있어요. 표준 라이브러리의 Option<T> 열거형을 다시 보죠.

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

Option<T>는 타입 T에 대해 제네릭이고, 두 변형을 가져요. 타입 T의 값을 하나 담는 Some과 아무 값도 담지 않는 None이죠. Option<T>를 쓰면 "값이 있을 수도 없을 수도 있다"는 추상 개념을 표현할 수 있고, 제네릭이라 그 선택적 값의 타입이 무엇이든 상관없이 쓸 수 있어요.

열거형도 여러 제네릭 타입을 쓸 수 있어요. Chapter 9에서 쓴 Result 열거형이 그 예시예요.

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

ResultTE 두 타입에 대해 제네릭이고, 성공 시 타입 T의 값을 담는 Ok와 실패 시 타입 E의 오류를 담는 Err을 가져요. 성공하거나(타입 T의 값) 실패하거나(타입 E의 오류) 하는 연산 어디서든 편리하게 쓸 수 있어요. 파일을 열 때 T에는 성공 시 std::fs::File이, E에는 문제가 있을 때 std::io::Error가 채워지는 것처럼요.

코드에서 값의 타입만 다르고 나머지는 같은 구조체·열거형 정의가 여러 번 반복되는 게 보인다면, 제네릭 타입을 써서 중복을 피할 수 있어요.

메서드 정의에서

구조체와 열거형에 메서드를 구현할 때도 정의 안에서 제네릭 타입을 쓸 수 있어요. Point<T>x 필드의 참조를 반환하는 메서드 x를 구현해 볼게요.

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

impl 바로 뒤에서 T를 선언해야 그 TPoint<T> 타입에 메서드를 구현한다는 걸 지정할 수 있어요. 구조체 정의에서 선언한 제네릭 매개변수와 이름을 다르게 골라도 되지만, 같은 이름을 쓰는 게 관례예요. impl에서 제네릭 타입을 선언한 메서드는, 어떤 구체 타입이 그 제네릭 타입을 대신하든 그 타입의 모든 인스턴스에 정의돼요.

한편 타입에 제약을 걸어 특정 타입의 인스턴스에만 메서드를 정의할 수도 있어요. 예를 들어 Point<f32> 인스턴스에만 distance_from_origin 메서드를 구현하고 싶다면, impl 뒤에 타입을 선언하지 않고 구체 타입 f32를 그대로 쓰면 돼요. 그러면 원점 (0.0, 0.0)에서 거리를 재는 메서드가 Point<f32>에만 생기고, f32가 아닌 타입의 Point<T>에는 정의되지 않아요. 부동소수점 타입에서만 쓸 수 있는 수학 연산이 필요하기 때문이죠.

제네릭을 쓴 코드의 성능

제네릭 타입 매개변수를 쓰면 런타임 비용이 생기지 않을까 궁금할 수 있어요. 좋은 소식은, 제네릭 타입을 써도 프로그램이 구체 타입을 쓸 때보다 느려지지 않는다는 거예요.

Rust는 컴파일 타임에 제네릭을 쓴 코드를 '단형성화(monomorphization)'해서 이걸 이뤄요. 단형성화란 제네릭 코드를, 컴파일할 때 실제로 쓰이는 구체 타입으로 채워 넣어 특정 코드로 바꾸는 과정이에요. 컴파일러는 제네릭 코드가 호출되는 모든 곳을 살펴보고, 그 제네릭 코드가 호출되는 구체 타입에 대한 코드를 생성해요.

표준 라이브러리의 제네릭 Option<T>를 예로 들면, 아래 코드를 컴파일할 때 컴파일러가 단형성화를 수행해요. Option<T> 인스턴스에 사용된 값을 읽고 두 종류, 즉 i32f64를 찾아내서, 제네릭 정의를 이 두 타입에 특화된 정의 두 개로 확장해요.

let integer = Some(5);
let float = Some(5.0);

단형성화된 버전은 대략 이런 모습이에요(컴파일러가 쓰는 이름은 다르지만, 개념은 같아요).

enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

제네릭 Option<T>는 컴파일러가 만든 특정 정의들로 대체돼요. Rust는 제네릭 코드를 각 인스턴스의 타입을 지정한 코드로 컴파일하므로, 제네릭을 쓴다고 런타임 비용을 내지 않아요. 실행 시에는 각 정의를 손으로 중복해 쓴 것과 동일하게 동작해요. 이 단형성화 과정 덕분에 Rust의 제네릭은 런타임에 매우 효율적이에요.

더 알아보기 (Learn more)

  • 제네릭 타입을 제한하는 트레이트 바운드의 문법은 다음 페이지 트레이트: 공통 동작 정의하기에서 다뤄요.
  • Option<T>를 만나면 None까지 처리하는 matchif let 문법은 Chapter 6에서 살펴볼 수 있어요.
  • Result<T, E>로 복구 가능한 오류를 다루는 방법은 Chapter 9에서 확인하세요.