제네릭 데이터 타입
제네릭 데이터 타입
함수 시그니처나 구조체 같은 항목을 정의할 때, 그 정의를 여러 구체 데이터 타입에 두루 쓸 수 있게 만들어 주는 게 제네릭(Generics)이에요. 여기서는 먼저 함수·구조체·열거형·메서드를 제네릭으로 정의하는 법을 하나씩 살펴보고, 마지막에 제네릭이 코드 성능에 어떤 영향을 주는지도 이야기해 볼게요.
출처: Rust 공식문서
본문
함수 정의에서
제네릭을 쓰는 함수를 정의할 때는, 평소에 파라미터와 반환값의 데이터 타입을 적던 자리에 제네릭을 넣어요. 이러면 우리 코드가 더 유연해지고, 호출하는 쪽에 더 다양한 기능을 제공하면서도 코드 중복은 막을 수 있답니다.
앞에서 만들던 largest 함수를 이어서 볼게요. Listing 10-4에는 슬라이스에서 가장 큰 값을 찾는 함수가 두 개 나와요. 이 둘을 제네릭을 쓰는 함수 하나로 합쳐 볼 거예요.
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');
}
largest_i32는 Listing 10-3에서 추출했던, 슬라이스에서 가장 큰 i32를 찾는 함수예요. largest_char는 슬라이스에서 가장 큰 char를 찾는 함수고요. 두 함수의 본문이 똑같으니, 제네릭 타입 파라미터 하나를 도입해서 중복을 없애 볼게요.
새 함수 하나에 타입을 파라미터로 만들려면, 함수의 값 파라미터에 이름을 붙이듯 타입 파라미터에도 이름을 붙여야 해요. 타입 파라미터 이름은 아무 식별자나 쓸 수 있는데요, Rust에서는 관례상 타입 파라미터 이름을 짧게, 보통 한 글자로 쓰고, 타입 이름 자체는 UpperCamelCase 규칙을 따라요. type의 줄임말인 T가 대부분의 Rust 프로그래머가 기본으로 고르는 이름이죠.
함수 본문에서 파라미터를 쓰려면 시그니처에 그 이름을 선언해서 컴파일러가 뜻을 알게 해야 하듯이, 함수 시그니처에서 타입 파라미터 이름을 쓰려면 그 이름을 먼저 선언해 줘야 해요. 제네릭 largest 함수를 정의하려면 함수 이름과 파라미터 목록 사이의 꺾쇠괄호 <> 안에 타입 이름 선언을 넣으면 돼요. 이렇게요:
fn largest<T>(list: &[T]) -> &T {
이 정의를 읽으면 "함수 largest는 어떤 타입 T에 대해 제네릭이다"라고 이해할 수 있어요. 이 함수는 list라는 파라미터 하나를 받는데, T 타입 값들의 슬라이스예요. largest 함수는 같은 타입 T의 값에 대한 참조를 반환해요.
Listing 10-5는 제네릭 데이터 타입을 시그니처에 쓴, 합쳐진 largest 함수 정의를 보여줘요. 이 리스팅은 두 종류의 슬라이스, 즉 i32 값들의 슬라이스와 char 값들의 슬라이스로 이 함수를 호출하는 법도 보여주고요. 이 코드는 아직 컴파일되지 않는다는 점만 알아 두세요.
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}");
}
지금 바로 이 코드를 컴파일하면 이런 에러를 만나요:
$ 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 트레이트를 제공하고, 우리가 타입에 구현할 수 있답니다(이 트레이트에 대한 더 자세한 내용은 Appendix C를 봐요). Listing 10-5를 고치려면 도움말의 제안을 따라 T에 유효한 타입을 PartialOrd를 구현하는 타입으로만 제한하면 돼요. 표준 라이브러리가 i32와 char 모두에 PartialOrd를 구현해 두었기 때문에, 리스팅은 그다음에 잘 컴파일될 거예요.
구조체 정의에서
구조체에도 <> 문법으로 한 개 이상의 필드에 제네릭 타입 파라미터를 쓸 수 있어요. Listing 10-6은 어떤 타입의 x, y 좌표값이든 담을 수 있는 Point<T> 구조체를 정의해요.
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 };
}
구조체 정의에서 제네릭을 쓰는 문법은 함수 정의와 비슷해요. 먼저 구조체 이름 바로 뒤 꺾쇠괄호 안에 타입 파라미터 이름을 선언하고, 그다음 평소에 구체 데이터 타입을 적을 자리에 제네릭 타입을 쓰면 됩니다.
Point<T>를 제네릭 타입 하나로만 정의했기 때문에, 이 정의는 Point<T> 구조체가 어떤 타입 T에 대해 제네릭이고, 필드 x와 y는 그 타입이 무엇이든 둘 다 같은 타입이라는 뜻이에요. Listing 10-7처럼 서로 다른 타입의 값을 가진 Point<T> 인스턴스를 만들면 코드가 컴파일되지 않아요.
src/main.rs
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
이 예시에서 x에 정수값 5를 넣으면, 이 Point<T> 인스턴스에 대해 제네릭 타입 T가 정수가 될 거라는 걸 컴파일러에 알려 줘요. 그다음 x와 같은 타입으로 정의된 y에 4.0을 지정하면, 이런 타입 불일치 에러를 만나요:
$ 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
x와 y가 둘 다 제네릭이되 서로 다른 타입일 수 있는 Point 구조체를 정의하려면, 제네릭 타입 파라미터를 여러 개 쓰면 돼요. 예를 들어 Listing 10-8에서는 Point를 T와 U 두 타입에 대해 제네릭이도록 바꿔서, x는 T 타입, y는 U 타입이 되게 해요.
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 };
}
이제 보여드린 Point 인스턴스가 전부 허용돼요! 정의 안에 제네릭 타입 파라미터는 원하는 만큼 쓸 수 있지만, 너무 여러 개를 쓰면 코드를 읽기 어려워져요. 코드에 제네릭 타입이 많이 필요하다는 걸 발견하면, 코드를 더 작은 조각으로 재구성해야 한다는 신호일 수 있어요.
열거형 정의에서
구조체에서 했던 것처럼, 열거형도 변형(Variant)에 제네릭 데이터 타입을 담도록 정의할 수 있어요. 6장에서 썼던 표준 라이브러리의 Option<T> 열거형을 다시 살펴볼게요.
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T),
None,
}
}
이 정의는 이제 훨씬 잘 이해가 되죠. 보시다시피 Option<T> 열거형은 타입 T에 대해 제네릭이고, 변형이 두 개 있어요. 값 하나를 담는 Some과 아무 값도 담지 않는 None이요. Option<T>를 쓰면 "선택적인 값"이라는 추상 개념을 표현할 수 있고, Option<T>가 제네릭이기 때문에 선택적인 값의 타입이 무엇이든 이 추상화를 그대로 쓸 수 있어요.
열거형도 제네릭 타입을 여러 개 쓸 수 있어요. 9장에서 썼던 Result 열거형의 정의가 한 예시예요:
#![allow(unused)]
fn main() {
enum Result<T, E> {
Ok(T),
Err(E),
}
}
Result 열거형은 T와 E 두 타입에 대해 제네릭이고, 변형이 두 개예요. T 타입의 값을 담는 Ok와 E 타입의 값을 담는 Err이요. 이 정의 덕분에 성공할 수도(어떤 타입 T의 값을 반환) 실패할 수도(어떤 타입 E의 에러를 반환) 있는 연산이 있는 곳 어디든 Result를 간편하게 쓸 수 있어요. 실제로 9장의 Listing 9-3에서 파일을 열 때 이걸 썼는데, 파일을 여는 데 성공하면 T 자리에 std::fs::File 타입이, 파일을 여는 데 문제가 생기면 E 자리에 std::io::Error 타입이 채워졌어요.
코드에서 값의 타입만 다르고 나머지는 똑같은 구조체나 열거형 정의가 여러 개 보인다면, 그때 제네릭 타입을 써서 중복을 피할 수 있어요.
메서드 정의에서
구조체와 열거형에는 5장에서 했던 것처럼 메서드를 구현하면서 그 정의에 제네릭 타입을 쓸 수도 있어요. Listing 10-9는 Listing 10-6에서 정의한 Point<T> 구조체에 x라는 메서드를 구현한 모습을 보여줘요.
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());
}
여기서는 Point<T>에 x라는 메서드를 정의했는데, x 필드의 데이터에 대한 참조를 반환해요.
impl 바로 뒤에 T를 선언해야 Point<T> 타입에 메서드를 구현한다는 걸 T로 표현할 수 있어요. impl 뒤에 T를 제네릭 타입으로 선언하면, Rust는 Point의 꺾쇠괄호 안 타입이 구체 타입이 아니라 제네릭 타입임을 알아챌 수 있어요. 이 제네릭 파라미터에는 구조체 정의에서 쓴 것과 다른 이름을 지을 수도 있지만, 같은 이름을 쓰는 게 관례예요. 제네릭 타입을 선언하는 impl 안에 메서드를 쓸 때, 그 메서드는 그 제네릭 타입을 대신하는 구체 타입이 무엇이든 그 타입의 모든 인스턴스에 정의돼요.
타입에 메서드를 정의할 때 제네릭 타입에 제약을 지정할 수도 있어요. 예를 들어 Point<T> 인스턴스 전체가 아니라 Point<f32> 인스턴스에만 메서드를 구현할 수 있죠. Listing 10-10에서는 구체 타입 f32를 써서 impl 뒤에 아무 타입도 선언하지 않았어요.
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());
}
이 코드의 뜻은, Point<f32> 타입에는 distance_from_origin 메서드가 있고, T가 f32가 아닌 다른 Point<T> 인스턴스에는 이 메서드가 정의되지 않는다는 거예요. 이 메서드는 우리 점이 좌표 (0.0, 0.0)에서 얼마나 떨어져 있는지를 재는데, 부동소수점 타입에만 있는 수학 연산을 사용해요.
구조체 정의의 제네릭 타입 파라미터가 항상 그 구조체의 메서드 시그니처에서 쓰는 것과 같지는 않아요. Listing 10-11은 예시를 더 명확하게 하려고 Point 구조체에는 제네릭 타입 X1, Y1을, mixup 메서드 시그니처에는 X2, Y2를 써요. 이 메서드는 self Point에서 가져온 x 값(타입 X1)과 인자로 전달된 Point에서 가져온 y 값(타입 Y2)으로 새 Point 인스턴스를 만들어요.
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);
}
main에서 x는 i32(값 5), y는 f64(값 10.4)인 Point를 정의했어요. p2 변수는 x가 문자열 슬라이스(값 "Hello"), y가 char(값 c)인 Point 구조체고요. p1에 인자 p2를 넣어 mixup을 호출하면 p3가 나오는데, p3의 x는 p1에서 왔으니 i32고, p3의 y는 p2에서 왔으니 char예요. println! 매크로 호출은 p3.x = 5, p3.y = c를 출력할 거예요.
이 예시의 목적은 어떤 제네릭 파라미터는 impl에, 어떤 제네릭 파라미터는 메서드 정의에 선언되는 상황을 보여 주는 거예요. 여기서 X1, Y1은 구조체 정의와 함께 가므로 impl 뒤에 선언되고, X2, Y2는 메서드에만 관련되므로 fn mixup 뒤에 선언돼요.
제네릭을 사용한 코드의 성능
제네릭 타입 파라미터를 쓰면 런타임 비용이 생기지 않을까 궁금할 수도 있어요. 좋은 소식은, 제네릭 타입을 써도 프로그램이 구체 타입을 쓸 때보다 느려지지 않는다는 거예요.
Rust는 컴파일 타임에 제네릭을 쓰는 코드의 모노모피제이션(monomorphization)을 수행해서 이걸 이뤄요. 모노모피제이션은 제네릭 코드를, 컴파일할 때 쓰인 구체 타입을 채워 넣어 구체 코드로 바꾸는 과정이에요. 이 과정에서 컴파일러는 우리가 Listing 10-5에서 제네릭 함수를 만들 때 거친 단계의 반대를 수행해요. 즉 컴파일러는 제네릭 코드가 호출되는 모든 곳을 살펴보고, 그 제네릭 코드가 호출될 때 쓰인 구체 타입에 대한 코드를 생성하는 거죠.
표준 라이브러리의 제네릭 Option<T> 열거형으로 이게 어떻게 동작하는지 볼게요.
#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}
Rust가 이 코드를 컴파일하면 모노모피제이션을 수행해요. 그 과정에서 컴파일러는 Option<T> 인스턴스에 쓰인 값들을 읽고, Option<T>의 두 종류, 즉 i32인 것과 f64인 것을 식별해요. 그래서 Option<T>의 제네릭 정의를 i32와 f64에 특화된 두 정의로 확장하면서, 제네릭 정의를 구체 정의들로 바꿔치기해요.
모노모피제이션이 끝난 코드는 대략 이렇게 생겼어요(컴파일러는 설명을 위해 우리가 쓰는 것과 다른 이름을 써요):
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>는 컴파일러가 만든 구체 정의들로 대체돼요. Rust가 제네릭 코드를 각 인스턴스의 타입을 명시하는 코드로 컴파일하므로, 제네릭을 써서 런타임 비용을 지불하지 않아요. 코드가 실행될 때는 각 정의를 손으로 복사해 두었을 때와 똑같이 동작해요. 이 모노모피제이션 과정 덕분에 Rust의 제네릭은 런타임에서 매우 효율적이게 돼요.
더 알아보기
- 10.2 Traits: Defining Shared Behavior — 이 페이지에서 잠깐 언급한
PartialOrd같은 트레이트가 뭔지 자세히 다뤄요. - 10.3 Validating References with Lifetimes — 참조를 다루는 코드에서 또 다른 종류의 제네릭인 라이프타임을 봐요.