본문 바로가기

Rust의 Generics 맛보기

기술적인 이야기/기타 개발 2020. 11. 1.
반응형

제네릭(Generics)은 많은 언어에서 지원하는 다형 타입 지원 기능입니다. 동일한 기능을 여러 타입으로 사용하려는 경우 각 타입 별로 비슷한 코드를 중복해서 코딩해야 할 수도 있는데, 제네릭은 타입과 코드를 분리시켜서 하나의 코드로 다양한 타입을 사용해서 동작시킬 수 있게 만들어 주기 때문에 매우 효율적인 코딩이 가능합니다.

러스트(Rust)도 이런 제네릭을 지원합니다. 이 글에서는 러스트의 제네릭 기능을 살짝 살펴보며 깊게는 안 들어가고 간단히 맛만 보고자 합니다.

벡터 구경

제네릭은 여러 코드에 구현되어 있는데 그중 가장 가깝게(?) 찾아볼 수 있는 예제는 아마도 벡터(Vector) 일 것 같습니다. 다른 언어에는 리스트나 배열이라고 부를만한 그 자료구조지요.

벡터는 구조체로 구현된 자료구조이지만, 벡터를 간단하게 선언할 때는 아래와 같이 할 수 있습니다.

let values = vec![1, 2, 3, 4, 5];

5개의 정수형 아이템을 담는 벡터를 선언하는 예제입니다. 이렇게만 보기엔 숨겨져 있어서 잘 안 보이는데, 위 vec! 매크로에 의해 생성되는 타입을 좀 더 명시적으로 적으면 아래와 같습니다.

let values: Vec<i32> = vec![1, 2, 3, 4, 5];

Vec<i32>라는 특수한 이름이 바로 제네릭 문법입니다. 이 이름은 i32 타입 데이터를 담는 벡터라는 의미입니다.

따라서 이 타입 파라미터만 바꾸면 원하는 타입의 아이템을 담는 벡터를 생성할 수 있습니다. 물론 러스트는 타입을 추론하는 언어이기 때문에 vec! 매크로를 이용해 생성할 때의 타입을 알아서 추론해서 해당 제네릭 타입을 사용합니다.

여기까지면 정말 그냥 겉만 핥기네요. 좀 더 이어지는 예를 살펴봅시다.

문제의 상황

벡터를 println!()으로 통째로 찍을 수가 없어서 어떤 함수 하나를 만들었습니다.

fn print_list(values: Vec<i32>) {
    for v in values.iter() {
        println!("{}", v);
    }
}

위 함수는 정수형 아이템을 담는 벡터의 각 아이템을 콘솔에 찍는 함수입니다. 이 함수를 이용해 아래와 같은 형식으로 벡터를 출력해 볼 수 있게 되었습니다.

print_list(vec![1, 2, 3, 4, 5]);

그런데 상황에 있어 다른 벡터를 찍으려고 했습니다. 아래와 같은 식이지요.

print_list(vec![1.1, 2.2, 3.3, 4.4, 5.5]);

불행히도 이 코드는 에러가 납니다.

print_list(vec![1.1, 2.2, 3.3, 4.4, 5.5]);
                ^^^ expected `i32`, found floating-point number

초기 버전은 32비트 정수형 타입만을 담는 벡터를 취급하기 때문에 실수를 담는 벡터는 에러가 날 수밖에 없습니다.

그렇다면 이를 해결하기 위해 해당 타입을 지원하는 또 다른 print_list를 만들어야겠네요. 하지만 너무 귀찮은 일일 것입니다. 만약 문자열까지 지원한다고 한다면요? 타입이 늘어날 때마다 비슷한 코드가 또 늘어나야겠지요.

제네릭 구현하기

그래서 print_list 함수를 제네릭으로 구현해보기로 합니다. 과감하게 타입을 분리해 봅시다.

fn print_list<T>(values: Vec<T>) {
    for v in values.iter() {
        println!("{}", v);
    }
}

여기서 T가 바로 제네릭 타입 파라미터입니다. 어떤 타입이 실제로 올지 모르기 때문에 이 타입 이름을 대체하기 위한 특수한 별명을 사용하는 것이지요. 물론 T는 자주 사용되는 이름이며 꼭 이 이름을 써야 하는 것은 아닙니다.

원칙적으로 제네릭은 위 코드와 같은 방식으로 정의해서 사용할 수 있습니다.

하지만 위 코드는 원하는대로 동작이 되지 않습니다.

fn print_list<T>(values: Vec<T>) {
    for v in values.iter() {
        println!("{}", v);
                       ^ `T` cannot be formatted with the default formatter
    }
}

엄밀히 말해서 println!으로 콘솔에 표시하려면 std::fmt::Display라는 트레잇(trait)을 해당 타입이 구현하고 있어야 합니다. 그런데 여기서 명시된 T라는 타입은 이 트레잇을 구현하고 있는지 알 수가 없기 때문에 에러가 발생합니다.

이 문제를 해결하기 위해 해당 트레잇을 만족하는 타입만으로 제한하는 방법을 사용할 수 있습니다.

fn print_list<T: std::fmt::Display>(values: Vec<T>) {
    for v in values.iter() {
        println!("{}", v);
    }
}

이렇게 하면 이 함수의 제네릭 타입 파라미터는 std::fmt::Display 트레잇을 만족하는 타입만 사용될 수 있습니다. 러스트의 기본 타입들은 모두 이를 만족하므로 커스텀 타입이 아닌 한 타입에 무관하게 사용할 수 있겠지요.

print_list(vec![1, 2, 3, 4, 5]);
print_list(vec![1.1, 2.2, 3.3, 4.4, 5.5]);
print_list(vec!["a", "b", "c", "d", "e"]);

이제 이 세 코드 모두 동작합니다. 원하는 바를 이루었습니다. 제네릭은 이렇게 기능과 타입을 분리시킬 수 있는 좋은 기능이지요.

좋은 예인지는 잘 모르겠지만 어쨌든 이것도 제네릭이니깐요. 제네릭의 기본적인 정의 및 사용법은 정리되었다고 생각되네요.

물론 함수에서만 쓰는 것은 아닙니다

제네릭은 함수나 메서드뿐만 아니라 구조체나 열거형 등 타입을 정의할 수 있는 상황에서는 대부분 사용할 수 있습니다. 예를 들어 구조체의 경우 아래와 같은 식입니다.

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

제네릭 타입 파라미터는 꼭 하나만 써야 하는 것은 아닙니다. 아래와 같이 두 가지 이상의 타입을 지정할 수도 있습니다.

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

열거형도 동일하게 제네릭을 사용할 수 있습니다. 대표적인 예로 옵션 타입의 경우 아래와 같이 정의되어 있습니다.

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

일단은 이 정도면 메모 용도로는 충분할 것 같네요.

728x90

마무리

사실 예제는 잘 만드는 것이 이런 기술 관련 글을 쓸 때 가장 중요한 것 같습니다. 제 예제는 그냥 즉흥적으로 만들어지기 때문에 급이 많이 낮지요. 그래서 과연 많은 분들을 이해시킬 수 있을까 걱정이 되기도 합니다. 심지어 하단의 예제들은 모두 rust-lang.org의 온라인 서적에서 언급하고 있는 예제이기도 하지요.

하지만 이런 글을 쓰는 것은 제가 공부하기 위함 혹은 저에게 도움이 되는 메모 용도도 있기 때문에 뭐... 쪽팔림을 감수하고 글을 써서 올려봅니다.

다음 글의 주제는 아직 정하진 않았지만 아마도 개인적으로 궁금했던 포인터에 관한 내용이 되지 않을까 생각합니다. 그럼 이만 여기서 줄입니다.

728x90
반응형

댓글