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

제네릭 데이터 타입

제네릭은 함수 시그니처나 구조체 같은 항목 정의를 만들 때 사용하며, 이후 다양한 구체 데이터 타입과 함께 쓸 수 있게 해 줍니다. 먼저 함수, 구조체, enum, 메서드를 제네릭으로 정의하는 방법을 살펴보겠습니다. 그런 다음 제네릭이 코드 성능에 어떤 영향을 미치는지도 이야기하겠습니다.

함수 정의에서의 제네릭

제네릭을 사용하는 함수를 정의할 때는, 보통 매개변수와 반환값의 데이터 타입을 적는 자리인 함수 시그니처에 제네릭을 둡니다. 이렇게 하면 코드는 더 유연해지고, 함수 호출자에게 더 많은 기능을 제공하면서도 코드 중복은 막을 수 있습니다.

앞의 largest 함수 예를 계속 써 봅시다. 목록 10-4는 슬라이스에서 가장 큰 값을 찾는 두 함수가 나와 있는데, 함수 이름과 시그니처 안의 타입만 다르고 나머지는 같습니다. 그다음 이 둘을 제네릭을 사용하는 하나의 함수로 합칠 것입니다.

Filename: src/main.rs
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 main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 100);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&char_list);
    println!("The largest char is {result}");
    assert_eq!(*result, 'y');
}
Listing 10-4: 이름과 시그니처의 타입만 다른 두 함수

largest_i32 함수는 목록 10-3에서 추출했던, 슬라이스 안의 가장 큰 i32 를 찾는 함수입니다. largest_char 함수는 슬라이스 안의 가장 큰 char 를 찾습니다. 함수 본문은 완전히 같으니, 하나의 함수에 제네릭 타입 매개변수를 도입하여 중복을 제거합시다.

하나의 새 함수 안에서 타입들을 매개변수화하려면, 함수의 값 매개변수에 이름을 붙일 때와 마찬가지로 타입 매개변수에도 이름을 붙여야 합니다. 타입 매개변수 이름에는 어떤 식별자를 써도 되지만, 관례적으로 T 를 많이 사용합니다. 러스트에서 타입 매개변수 이름은 대개 짧고, 종종 한 글자이며, 러스트 타입 이름 관례인 UpperCamelCase 를 따릅니다. type 의 첫 글자인 T 는 대부분의 Rust 프로그래머가 기본 선택으로 사용하는 이름입니다.

함수 본문 안에서 어떤 매개변수를 사용하려면, 컴파일러가 그 이름이 무엇을 뜻하는지 알게 하려고 시그니처 안에 선언해 두어야 합니다. 마찬가지로 함수 시그니처 안에서 타입 매개변수 이름을 사용하려면, 먼저 그 이름을 선언해야 합니다. 제네릭 largest 함수를 정의하려면, 함수 이름과 매개변수 목록 사이에 꺾쇠 괄호 <> 안에 타입 이름 선언을 넣습니다. 예를 들면 다음과 같습니다.

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

이 정의는 “함수 largest 는 어떤 타입 T 에 대해 제네릭이다”라고 읽을 수 있습니다. 이 함수는 list 라는 매개변수 하나를 가지며, 이는 T 타입 값을 담은 슬라이스입니다. largest 함수는 같은 T 타입의 값에 대한 참조를 반환합니다.

목록 10-5는 시그니처 안에서 제네릭 데이터 타입을 사용하는 largest 함수 정의를 보여 줍니다. 이 목록은 또한 i32 슬라이스와 char 슬라이스 각각으로 함수를 호출하는 방법도 보여 줍니다. 다만 이 코드는 아직 컴파일되지 않습니다.

Filename: src/main.rs
fn largest<T>(list: &[T]) -> &T {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {result}");

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {result}");
}
Listing 10-5: 제네릭 타입 매개변수를 사용하는 largest 함수. 아직 컴파일되지 않는다

지금 이 코드를 컴파일하면 다음 오류가 납니다.

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T` with trait `PartialOrd`
  |
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
  |             ++++++++++++++++++++++

For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

도움말에는 std::cmp::PartialOrd 가 언급되어 있는데, 이것은 트레이트이고 바로 다음 절에서 자세히 다룰 것입니다. 지금은 이 오류가, largest 본문이 T 가 될 수 있는 모든 타입에 대해 동작하지 않는다고 말하고 있다는 점만 알면 충분합니다. 함수 본문 안에서 T 타입 값들을 비교하려 하기 때문에, 값들을 순서 비교할 수 있는 타입만 쓸 수 있습니다. 이를 가능하게 하기 위해 표준 라이브러리에는 타입이 구현할 수 있는 std::cmp::PartialOrd 트레이트가 있습니다(이 트레이트에 대한 더 자세한 내용은 부록 C를 참고하세요). 목록 10-5를 고치려면 도움말의 제안처럼, T 에 들어올 수 있는 타입을 PartialOrd 를 구현한 타입으로 제한하면 됩니다. 그러면 표준 라이브러리가 i32char 모두에 PartialOrd 를 구현하고 있으므로 이 목록은 컴파일됩니다.

구조체 정의에서의 제네릭

구조체의 하나 이상의 필드에 제네릭 타입 매개변수를 사용하도록 구조체를 정의할 수도 있습니다. 목록 10-6은 x, y 좌표값을 어떤 타입이든 담을 수 있도록 Point<T> 구조체를 정의한 예입니다.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}
Listing 10-6: 타입 Tx, y 값을 담는 Point<T> 구조체

구조체 정의에서 제네릭을 사용하는 문법은 함수 정의에서 사용한 것과 비슷합니다. 먼저 구조체 이름 바로 뒤의 꺾쇠 괄호 안에 타입 매개변수 이름을 선언합니다. 그다음 구조체 정의 안에서 원래 구체 타입을 적을 위치에 제네릭 타입을 사용합니다.

여기서 Point<T> 를 정의할 때 제네릭 타입을 하나만 사용했기 때문에, 이 정의는 Point<T> 구조체가 어떤 타입 T 에 대해 제네릭이며, 필드 xy 둘 다 같은 타입이라는 뜻입니다. 목록 10-7처럼 서로 다른 타입 값을 가진 Point<T> 인스턴스를 만들려고 하면 코드는 컴파일되지 않습니다.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}
Listing 10-7: xy 는 둘 다 같은 제네릭 데이터 타입 T 를 쓰므로 같은 타입이어야 한다

이 예제에서 x 에 정수 값 5 를 대입하는 순간, 컴파일러는 이 Point<T> 인스턴스에 대해 제네릭 타입 T 가 정수 타입임을 알게 됩니다. 그런 뒤 y4.0 을 넣으면, yx 와 같은 타입이어야 하므로 다음과 같은 타입 불일치 오류가 발생합니다.

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
 --> src/main.rs:7:38
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integer, found floating-point number

For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

xy 둘 다 제네릭이지만 서로 다른 타입을 허용하는 Point 구조체를 정의하려면, 여러 개의 제네릭 타입 매개변수를 사용할 수 있습니다. 예를 들어 목록 10-8에서는 Point 정의를 T, U 두 타입에 대해 제네릭하게 바꾸고, xT, yU 타입으로 만듭니다.

Filename: src/main.rs
struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}
Listing 10-8: xy 가 서로 다른 타입 값을 가질 수 있도록 두 타입에 대해 제네릭인 Point<T, U>

이제 목록에 나온 모든 Point 인스턴스가 허용됩니다! 정의 안에는 필요한 만큼 많은 제네릭 타입 매개변수를 둘 수 있습니다. 다만 몇 개 이상으로 많아지면 코드를 읽기 어려워집니다. 코드 안에 제네릭 타입이 너무 많이 필요하다고 느껴진다면, 코드 구조를 더 작은 조각들로 다시 나눠야 한다는 신호일 수도 있습니다.

enum 정의에서의 제네릭

구조체와 마찬가지로, enum도 variant 안에 제네릭 데이터 타입을 담도록 정의할 수 있습니다. 6장에서 사용했던 표준 라이브러리의 Option<T> enum을 다시 봅시다.

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

이제 이 정의가 좀 더 잘 이해될 것입니다. 보시다시피 Option<T> enum은 타입 T 에 대해 제네릭이고, 두 개의 variant를 가집니다. Some 은 타입 T 값 하나를 담고, None 은 아무 값도 담지 않습니다. Option<T> 를 사용하면 “선택적인 값”이라는 추상 개념을 표현할 수 있고, Option<T> 가 제네릭이기 때문에 그 값의 타입이 무엇이든 같은 추상화를 사용할 수 있습니다.

Enum도 여러 개의 제네릭 타입을 사용할 수 있습니다. 9장에서 사용했던 Result enum이 그 예입니다.

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

Result enum은 두 타입 T, E 에 대해 제네릭이며, OkErr 두 variant를 가집니다. OkT 타입 값 하나를 담고, ErrE 타입 값 하나를 담습니다. 이 정의 덕분에, 어떤 연산이 성공할 수도(T 타입 값 반환), 실패할 수도(E 타입의 에러 반환) 있는 상황 어디에서든 Result enum을 편리하게 사용할 수 있습니다. 실제로 이것이 우리가 목록 9-3에서 파일을 열 때 사용했던 방식입니다. 파일이 성공적으로 열리면 Tstd::fs::File 로 채워지고, 열기에 문제가 생기면 Estd::io::Error 로 채워졌습니다.

여러 구조체나 enum 정의가 내부에 담은 값의 타입만 다르고 나머지는 비슷하다면, 제네릭 타입을 사용해 중복을 피할 수 있습니다.

메서드 정의에서의 제네릭

5장에서 했던 것처럼, 구조체와 enum 위에 메서드를 구현하면서 그 정의 안에서 제네릭 타입을 사용할 수도 있습니다. 목록 10-9는 목록 10-6의 Point<T> 구조체에 x 라는 메서드를 구현한 예입니다.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

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

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}
Listing 10-9: x 필드의 T 타입 데이터에 대한 참조를 반환하는 x 메서드를 Point<T> 에 구현하기

여기서는 Point<T> 위에 x 라는 메서드를 정의했고, 이 메서드는 필드 x 안의 데이터에 대한 참조를 반환합니다.

여기서도 Timpl 뒤에 다시 선언해야 한다는 점에 주의하세요. 그래야 Point<T> 타입에 메서드를 구현하고 있다는 사실을 T 를 사용해 표현할 수 있습니다. impl 뒤에 T 를 제네릭 타입으로 선언함으로써, 러스트는 Point 의 꺾쇠 괄호 안에 있는 T 가 구체 타입이 아니라 제네릭 타입임을 알 수 있습니다. 물론 구조체 정의에서 사용한 것과 다른 이름을 써도 되지만, 같은 이름을 쓰는 것이 관례입니다. impl 안에 제네릭 타입을 선언한 메서드는, 그 제네릭 타입 자리에 어떤 구체 타입이 들어오든 그 타입의 모든 인스턴스에 대해 정의됩니다.

타입 위에 메서드를 정의할 때 제네릭 타입에 제한을 둘 수도 있습니다. 예를 들어 Point<T> 의 모든 인스턴스가 아니라, Point<f32> 인스턴스에만 메서드를 구현할 수 있습니다. 목록 10-10에서는 구체 타입 f32 를 사용하므로, impl 뒤에 어떤 타입도 선언하지 않습니다.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

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

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

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}
Listing 10-10: 제네릭 타입 매개변수 T 가 특정 구체 타입일 때만 적용되는 impl 블록

이 코드는 Point<f32> 타입에는 distance_from_origin 메서드가 존재하지만, Tf32 가 아닌 다른 Point<T> 인스턴스에는 이 메서드가 없다는 뜻입니다. 이 메서드는 좌표 (0.0, 0.0) 으로부터 점이 얼마나 떨어져 있는지 측정하며, 부동소수점 타입에서만 가능한 수학 연산을 사용합니다.

구조체 정의에 쓰인 제네릭 타입 매개변수와, 그 구조체 메서드 시그니처에 쓰는 제네릭 매개변수는 항상 같을 필요는 없습니다. 목록 10-11은 예를 더 분명히 하기 위해 구조체 Point 에는 X1, Y1 을, mixup 메서드 시그니처에는 X2, Y2 를 사용합니다. 이 메서드는 selfPoint(타입 X1) 에서 x 값을, 매개변수로 들어온 Point(타입 Y2) 에서 y 값을 가져와 새 Point 인스턴스를 만듭니다.

Filename: src/main.rs
struct Point<X1, Y1> {
    x: X1,
    y: Y1,
}

impl<X1, Y1> Point<X1, Y1> {
    fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
        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);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
Listing 10-11: 구조체 정의에 쓰인 것과 다른 제네릭 타입을 사용하는 메서드

main 안에서는 xi325, yf6410.4 를 가진 Point 하나를 정의했습니다. p2 변수는 x 에 문자열 슬라이스 "Hello", y 에 문자 c 를 가진 Point 구조체입니다. p1 에 대해 p2 를 인수로 mixup 을 호출하면 p3 를 얻는데, xp1 에서 왔기 때문에 i32, yp2 에서 왔기 때문에 char 입니다. 그래서 println!p3.x = 5, p3.y = c 를 출력합니다.

이 예제의 목적은, 어떤 제네릭 매개변수는 impl 뒤에 선언되고 어떤 것은 메서드 정의 뒤에 선언된다는 상황을 보여 주는 데 있습니다. 여기서 X1Y1 은 구조체 정의와 함께 가기 때문에 impl 뒤에 선언되고, X2Y2 는 그 메서드에서만 의미가 있기 때문에 fn mixup 뒤에 선언됩니다.

제네릭을 사용하는 코드의 성능

제네릭 타입 매개변수를 사용하면 런타임 비용이 생기는지 궁금할 수도 있습니다. 좋은 소식은, 제네릭 타입을 사용해도 프로그램은 구체 타입을 직접 썼을 때보다 느려지지 않는다는 점입니다.

러스트는 컴파일 시점에 제네릭 코드를 단형화(monomorphization) 하여 이를 해결합니다. 단형화 는 컴파일 시 사용된 구체 타입을 채워 넣어, 제네릭 코드를 구체 코드로 바꾸는 과정입니다. 이 과정에서 컴파일러는 우리가 목록 10-5의 제네릭 함수를 만들기 위해 수행했던 추상화의 반대 작업을 합니다. 즉, 제네릭 코드가 호출되는 모든 위치를 살펴보고, 그 호출에서 사용된 구체 타입에 대한 코드를 생성합니다.

표준 라이브러리의 제네릭 Option<T> enum을 예로 들어 이 동작을 살펴봅시다.

#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}

러스트가 이 코드를 컴파일할 때 단형화를 수행합니다. 그 과정에서 컴파일러는 Option<T> 인스턴스에서 사용된 값을 읽고, 두 종류의 Option<T> 가 쓰였다는 사실을 알아냅니다. 하나는 i32, 다른 하나는 f64 입니다. 그래서 Option<T> 의 제네릭 정의를 i32 용과 f64 용 두 정의로 확장하여, 제네릭 정의를 구체적인 정의로 대체합니다.

단형화된 버전의 코드는 대략 다음과 비슷한 모습이 됩니다(여기서는 설명을 위해 컴파일러가 실제로 쓰는 이름과는 다른 이름을 사용합니다).

Filename: src/main.rs
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> 는 컴파일러가 만든 구체적인 정의들로 치환됩니다. 러스트는 제네릭 코드를 이렇게 각 인스턴스의 구체 타입을 명시한 코드로 컴파일하므로, 제네릭을 써도 런타임 비용을 치르지 않습니다. 실행 시에는 우리가 각 정의를 손으로 복붙해 쓴 것과 완전히 같은 방식으로 동작합니다. 단형화 과정 덕분에 러스트의 제네릭은 런타임에서 매우 효율적입니다.