제네릭 데이터 타입
함수·구조체·열거형·메서드의 중복 타입을 제네릭 매개변수로 추상화하고 단형화가 런타임 성능을 유지하는 원리를 이해합니다.
모든 프로그래밍 언어는 중복되는 개념을 효율적으로 처리하기 위한 도구를 가지고 있습니다.
러스트에서는 제네릭(generic) 이 그 역할을 맡습니다.
제네릭은 구체(concrete) 타입 혹은 기타 속성에 대한 추상화된 대역입니다.
컴파일과 실행 시점에 제네릭들이 실제로 무슨 타입으로 채워지는지 알 필요 없이 제네릭의 동작이나 다른 제네릭과의 관계를 표현할 수 있습니다.
함수가 어떤 값이 들어있을지 모르는 매개변수를 전달받아서 동일한 코드를 다양한 구체적 값으로 실행하는 것처럼, 함수는 i32, String 같은 구체 타입 대신 제네릭 타입의 매개변수를 전달받을 수 있습니다.
사실은 이미 여러 제네릭을 사용해 봤었습니다.
6장에서는 Option<T>, 8장에서는 Vec<T>와 HashMap<K, V>, 9장에서는 Result<T, E> 제네릭을 사용했죠.
이번 장에서는 제네릭을 사용해 자체 타입, 함수, 메서드를 정의하는 방법을 살펴보겠습니다.
우선, 함수를 추출하여 중복되는 코드를 제거하는 방법을 살펴보겠습니다.
그다음 매개변수의 타입만 다른 두 함수가 생기면 제네릭 함수를 사용해 코드 중복을 한 번 더 줄여보겠습니다.
또한, 제네릭 타입을 구조체 및 열거형 정의에 사용하는 방법도 살펴보겠습니다.
다음으로는 트레이트(trait) 를 이용해 동작을 제네릭한 방식으로 정의하는 법을 배워보겠습니다.
트레이트를 제네릭 타입과 함께 사용하면, 아무 타입이나 허용하는 것이 아니라 특정 동작을 하는 타입만 허용할 수 있습니다.
마지막으로는 라이프타임(lifetime) 을 살펴보겠습니다.
라이프타임은 제네릭의 일종이며, 컴파일러에게 참조자들이 서로 어떤 관계에 있는지를 알려주는 데에 사용합니다.
라이프타임은 빌린 값들에 대한 정보를 컴파일러에게 충분히 제공하여 작성자의 추가적인 도움 없이도 참조자의 여러 가지 상황에 대한 유효성 검증을 할 수 있게 해 줍니다.
함수를 추출하여 중복 없애기
제네릭은 여러 가지 타입을 나타내는 자리표시자의 위치에 특정 타입을 집어넣는 것으로 코드 중복을 제거할 수 있게 해 줍니다.
제네릭 문법을 배우기 전에, 먼저 제네릭 타입을 이용하지 않고 여러 가지 값을 나타내는 자리표시자로 특정 값을 대체하는 함수를 추출하는 방식으로 중복되는 코드를 없애는 요령을 알아보겠습니다.
그다음 동일한 기법을 이용하여 제네릭 함수를 추출해보겠습니다!
함수로 추출할 수 있는 중복되는 코드를 알아내는 방법을 보는 것으로 제네릭을 사용할 수 있는 중복되는 코드들이 인식되기 시작할 것입니다.
예제 9-1과 같이 리스트에서 가장 큰 숫자를 찾아내는 간단한 프로그램부터 시작하겠습니다.
예제 9-1: 숫자 리스트에서 가장 큰 수 찾기fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let mut largest = &number_list[0];
for number in &number_list {
if number > largest {
largest = number;
}
}
println!("The largest number is {}", largest);
}
number_list 변수에는 정수 리스트를 저장하고, largest 변수에 리스트의 첫 번째 숫자에 대한 참조자를 집어넣습니다.
그리고 리스트 내 모든 숫자를 순회하는데, 만약 현재 값이 largest에 저장된 값보다 크다면 largest의 값을 현재 값으로 변경합니다.
현재 값이 여태까지 본 가장 큰 값보다 작다면 largest의 값은 바뀌지 않습니다.
리스트 내 모든 숫자를 돌아보고 나면 largest는 가장 큰 값을 갖게 되며, 위의 경우에는 100이 됩니다.
이번에는 두 개의 다른 숫자 리스트에서 가장 큰 숫자를 찾으라는 일감을 받았습니다.
그렇게 하려면 예제 9-2처럼 예제 9-1의 코드를 프로그램 내 다른 곳에 복사하여 동일한 로직을 이용할 수도 있습니다.
예제 9-2: 두 개의 숫자 리스트에서 가장 큰 숫자를 찾는 코드fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let mut largest = &number_list[0];
for number in &number_list {
if number > largest {
largest = number;
}
}
println!("The largest number is {}", largest);
let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];
let mut largest = &number_list[0];
for number in &number_list {
if number > largest {
largest = number;
}
}
println!("The largest number is {}", largest);
}
이 코드는 잘 동작하지만, 중복된 코드를 생성하는 일은 지루하고 에러가 발생할 가능성도 커집니다.
또한, 로직을 바꾸고 싶을 때 수정해야 할 부분이 여러 군데임을 기억해야 한다는 의미이기도 합니다.
이러한 중복을 제거하기 위해서, 정수 리스트를 매개변수로 전달받아 동작하는 함수를 정의하여 추상화할 것입니다.
이렇게 하면 코드가 더 명확해지고 목록에서 가장 큰 숫자를 찾는다는 개념을 추상적으로 표현할 수 있습니다.
예제 9-3에서는 가장 큰 수를 찾는 코드를 largest라는 이름의 함수로 추출합니다.
그다음 예제 9-2에 있는 두 리스트에서 가장 큰 수를 찾기 위해 이 함수를 호출합니다.
나중에 있을지 모를 다른 어떤 i32 값의 리스트에 대해서라도 이 함수를 사용할 수 있습니다.
fn largest(list: &[i32]) -> &i32 {
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 number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];
let result = largest(&number_list);
println!("The largest number is {}", result);
}
largest 함수는 list 매개변수를 갖는데, 이는 함수로 전달될 임의의 i32 값 슬라이스를 나타냅니다.
실제로 largest 함수가 호출될 때는 전달받은 구체적인 값으로 실행됩니다.
예제 9-2에서부터 예제 9-3까지 거친 과정을 요약하면 다음과 같습니다.
- 중복된 코드를 식별합니다.
- 중복된 코드를 함수의 본문으로 분리하고, 함수의 시그니처 내에 해당 코드의 입력값 및 반환 값을 명시합니다.
- 중복됐었던 두 지점의 코드를 함수 호출로 변경합니다.
다음에는 제네릭으로 이 과정을 그대로 진행하여 중복된 코드를 제거해보겠습니다.
함수 본문이 특정한 값 대신 추상화된 list로 동작하는 것처럼, 제네릭을 이용한 코드는 추상화된 타입으로 동작합니다.
만약 i32 슬라이스에서 최댓값을 찾는 함수와 char 슬라이스에서 최댓값을 찾는 함수를 따로 가지고 있다면 어떨까요?
이런 중복은 어떻게 제거해야 할지 한번 알아봅시다!
이제 제네릭, 트레이트, 라이프타임의 연결 구조를 살펴보겠습니다.
제네릭을 사용하면 함수 시그니처나 구조체의 아이템에 다양한 구체적 데이터 타입을 사용할 수 있도록 정의할 수 있습니다.
함수, 구조체, 열거형, 메서드를 제네릭으로 정의하는 방법을 알아보고, 제네릭이 코드 성능에 미치는 영향을 알아보겠습니다.
제네릭 함수 정의
제네릭 함수를 정의할 때는, 함수 시그니처 내 매개변수와 반환 값의 데이터 타입 위치에 제네릭을 사용합니다.
이렇게 작성된 코드는 더 유연해지고, 이 함수를 호출하는 쪽에서 더 많은 기능을 사용할 수 있도록 하며 코드 중복 또한 방지합니다.
largest 함수를 이용해 계속해보겠습니다.
예제 9-4는 슬라이스에서 가장 큰 값을 찾는 두 함수를 보여줍니다.
제네릭 사용하여 이 함수들을 하나의 함수로 묶어보겠습니다.
예제 9-4: 이름과 타입 시그니처만 다른 두 함수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);
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest_char(&char_list);
println!("The largest char is {}", result);
}
함수 largest_i32는 예제 9-3에서 봤던 슬라이스에서 가장 큰 i32를 찾는 함수이고, largest_char 함수는 슬라이스에서 가장 큰 char를 찾는 함수입니다.
이 두 함수의 본문은 완벽히 동일하니, 제네릭을 이용해 이 두 함수를 하나로 만들어서 코드 중복을 제거해보겠습니다.
새 단일 함수의 시그니처 내 타입을 매개변수화하려면 타입 매개변수의 이름을 지어줄 필요가 있습니다.
방법은 함수 매개변수와 비슷합니다.
타입 매개변수의 이름에는 아무 식별자나 사용할 수 있지만, 여기서는 T를 사용하겠습니다.
러스트에서는 타입 이름을 지어줄 때는 대문자로 시작하는 낙타 표기법(UpperCamelCase)을 따르고, 타입 매개변수의 이름은 짧게(한 글자로만 된 경우도 종종 있습니다) 짓는 것이 관례이기 때문에, 대부분의 러스트 프로그래머는 'type'을 줄인 T를 사용합니다.
함수 본문에서 매개변수를 사용하려면 함수 시그니처에 매개변수의 이름을 선언하여 컴파일러에게 해당 이름이 무엇을 의미하는지 알려주어야 해야 하는 것처럼, 타입 매개변수를 사용하기 전에도 타입 매개변수의 이름을 선언해야 합니다.
예를 들어, 제네릭 largest 함수를 정의하려면 아래와 같이 함수명과 매개변수 목록 사이의 꺾쇠괄호(<>)에 타입 매개변수 이름을 선언해야 합니다.
fn largest<T>(list: &[T]) -> &T {이 정의는 ‘largest 함수는 어떤 타입 T에 대한 제네릭 함수’라고 읽힙니다.
이 함수는 T 타입 값의 슬라이스인 list 매개변수를 가지고 있고, 동일한 T 타입의 값에 대한 참조자를 반환합니다.
예제 9-5는 제네릭 데이터 타입을 사용해 하나로 통합한 largest 함수 정의를 나타냅니다.
코드에서 볼 수 있듯, 이 함수를 i32 값들의 슬라이스로 호출할 수도 있고 char 값들의 슬라이스로도 호출할 수 있습니다.
이 코드는 아직 컴파일되지 않음을 주의해 주시고, 나중에 고치도록 하겠습니다.
예제 9-5: 제네릭 타입 매개변수를 이용한largest 함수;
아직 컴파일되지는 않습니다
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`
|
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` due to previous error
도움말에서 트레이트(trait) std::cmp::PartialOrd가 언급되는데, 트레이트는 다음 절에서 살펴볼 것입니다.
지금은 이 에러가 ‘largest의 본문이 T가 될 수 있는 모든 타입에 대해 동작할 수 없음’을 뜻한다는 정도만 알아둡시다.
함수 본문에서 T 타입 값들에 대한 비교가 필요하므로, 여기에는 값을 정렬할 수 있는 타입에 대해서만 동작할 수 있습니다.
비교가 가능하도록 하기 위해, 표준 라이브러리는 임의의 타입에 대해 구현 가능한 std::cmp::PartialOrd 트레이트를 제공합니다.
(이 트레이트에 대한 더 자세한 사항은 부록 C를 보세요.)
도움말의 제안을 따라서 T가 PartialOrd를 구현한 것일 때만 유효하도록 제한을 두면 이 예제는 컴파일되는데, 이는 표준 라이브러리가 i32와 char 둘 모두에 대한 PartialOrd를 구현하고 있기 때문입니다.
제네릭 구조체 정의
<> 문법으로 구조체 필드에서 제네릭 타입 매개변수를 사용하도록 구조체를 정의할 수도 있습니다.
예제 9-6은 임의의 타입으로 된 x, y를 갖는 Point<T> 구조체를 정의합니다.
T 타입의 값 x, y를 갖는
Point<T> 구조체
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 필드는 실제 타입이 무엇이건 간에 둘 다 동일한 타입이라는 것을 의미합니다.
만약 예제 9-7처럼 서로 다른 타입의 값을 갖는 Point<T> 인스턴스를 생성하려고 할 경우, 코드는 컴파일되지 않습니다.
x와 y 필드는 둘 다 동일한
제네릭 데이터 타입 T이므로 같은 타입이어야 합니다
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
컴파일러는 x에 정숫값 5를 대입할 때 Point<T> 인스턴스의 제네릭 타입 T를 정수 타입으로 인지합니다.
그다음에는 y에 4.0을 지정했는데, y는 x와 동일한 타입을 갖도록 정의되었으므로 컴파일러는 타입 불일치 에러를 발생시킵니다.
$ 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` due to previous error
제네릭 Point 구조체의 x, y가 서로 다른 타입일 수 있도록 정의하고 싶다면 여러 개의 제네릭 타입 매개변수를 사용해야 합니다.
예제 9-8에서는 x는 T 타입으로, y는 U 타입으로 정의한 제네릭 Point 정의를 나타냅니다.
x와 y가 서로 다른 타입의 값이 될 수 있는 Point<T, U>
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 인스턴스를 생성할 수 있습니다!
제네릭 타입 매개변수는 원하는 만큼 여러 개를 정의할 수 있지만, 많으면 많아질수록 코드 가독성은 떨어집니다.
만약 코드에서 많은 수의 제네릭 타입이 필요함을 알게 되었다면, 코드를 리팩터링해서 작은 부분들로 나누는 것을 고려해야 할 수도 있습니다.
제네릭 열거형 정의
구조체처럼, 열거형도 배리언트에 제네릭 데이터 타입을 갖도록 정의할 수 있습니다.
6장에서 사용했었던 표준 라이브러리의 Option<T> 열거형을 다시 살펴봅시다.
enum Option<T> {
Some(T),
None,
}이제는 이 코드를 이해할 수 있습니다.
보시다시피 Option<T> 열거형은 T 타입에 대한 제네릭이며, T 타입을 들고 있는 Some 배리언트와 아무런 값도 들고 있지 않은 None 배리언트를 갖습니다.
Option<T> 열거형을 사용함으로써 옵션 값에 대한 추상화된 개념을 표현할 수 있고, Option<T> 열거형이 제네릭으로 되어있는 덕분에 옵션 값이 어떤 타입이건 상관없이 추상화하여 사용할 수 있죠.
열거형에서도 여러 개의 제네릭 타입을 이용할 수 있습니다.
9장에서 사용했던 Result 열거형의 정의가 대표적인 예시입니다.
enum Result<T, E> {
Ok(T),
Err(E),
}Result 열거형은 T, E 두 타입을 이용한 제네릭이며, T 타입 값을 갖는 Ok와 E 타입 값을 갖는 Err 배리언트를 갖습니다.
제네릭으로 정의되어 있는 덕분에, 연산이 성공할지(따라서 T 타입 값을 반환할지) 실패할지(E 타입 값을 반환할지) 알 수 없는 어느 곳이든 Result 열거형을 편리하게 사용할 수 있습니다.
예제 9-3에서 파일을 열 때도 사용했었죠.
이때는 파일을 여는 데 성공하면 T는 std::fs::File 타입이 되고, 파일을 열다가 문제가 생기면 E는 std::io::Error 타입이 됐었습니다.
작성한 코드에서 보유하는 값의 타입만 다른 구조체나 열거형이 여러 개 있음을 발견했을 때는 제네릭 타입을 사용해 코드 중복을 제거할 수 있습니다.
제네릭 메서드 정의
5장에서 했던 것처럼 구조체나 열거형에 메서드를 구현할 때도 제네릭 타입을 이용해 정의할 수 있습니다.
예제 9-9는 예제 9-6에서 정의했던 Point<T> 구조체에 x 메서드를 구현한 모습입니다.
T 타입의 x 필드에 대한
참조자를 반환하는 x 메서드를 Point<T>에
정의
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());
}
x 필드 데이터의 참조자를 반환하는 x 메서드를 Point<T>에
정의해 보았습니다.
impl 바로 뒤에 T를 선언하여 Point<T> 타입에 메서드를 구현한다고 명시했음을 주의하세요.
이렇게 하면 러스트는 Point의 꺾쇠괄호 내 타입이 구체적인 타입이 아닌 제네릭 타입임을 인지합니다.
구조체 정의에 선언된 제네릭 매개변수와는 다른 제네릭 매개변수를 선택할 수도 있었겠지만, 같은 이름을 사용하는 것이 관례입니다.
제네릭 타입이 선언된 impl 안에 작성된 메서드는 이 제네릭 타입에 어떤 구체 타입을 집어넣을지와는 상관없이 어떠한 타입의 인스턴스에라도 정의될 것입니다.
이 타입의 메서드를 정의할 때 제네릭 타입에 대한 제약을 지정할 수도 있습니다.
예를 들면, 임의의 제네릭 타입 Point<T> 인스턴스가 아닌 Point<f32> 인스턴스에 대한 메서드만을 정의할 수 있습니다.
예제 9-10에서는 구체적 타입 f32을 사용하였는데, impl 뒤에는 어떤 타입도 선언하지 않았습니다.
T가
특정 구체적 타입인 경우에만 적용되는 impl 블록
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
이 코드에서 Point<f32> 타입 인스턴스는 distance_from_origin 메서드를 갖게 될 것입니다; T가 f32 타입이 아닌 Point<T> 인스턴스는 이 메서드가 정의되지 않습니다.
이 메서드는 생성된 점이 원점(0.0, 0.0)으로부터 떨어진 거리를 측정하며 부동 소수점 타입에서만 사용 가능한 수학적 연산을 이용합니다.
구조체 정의에서 사용한 제네릭 타입 매개변수와, 구조체의 메서드 시그니처 내에서 사용하는 제네릭 타입 매개변수가 항상 같은 것은 아닙니다.
예제 9-11을 보면 예제를 명료하게 만들기 위해 Point 구조체에 대해서는 X1와 Y1이라는 제네릭 타입을, 그리고 mixup 메서드에 대해서는 X2와 Y2라는 제네릭 타입을 사용했습니다.
이 메서드는 self Point의 (X1 타입인) x값과 매개변수로 넘겨받은 Point의 (Y2 타입인) y값으로 새로운 Point 인스턴스를 생성합니다.
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에서는 i32 타입 x(5)와 f64 타입 y(10.4)를 갖는 Point를 정의했습니다.
p2는 문자열 슬라이스 타입 x("Hello")와 char 타입 y(c)를 갖는 Point입니다.
p3는 p1에서 mixup 메서드를 p2를 인수로 호출하여 반환된 값입니다.
p3의 x는 p1에서 온 i32 타입이며, y는 p2에서 온 char 타입입니다.
println! 매크로는 p3.x = 5, p3.y = c를 출력합니다.
이 예제는 제네릭 매개변수 중 일부가 impl 에 선언되고 일부는 메서드 정의에 선언되는 경우를 보여주기 위한 예제입니다.
여기서 제네릭 매개변수 X1, Y1는 구조체 정의와 한 묶음이니 impl 뒤에 선언했지만, 제네릭 매개변수 X2, Y2는 mixup 메서드에만 연관되어 있으므로 fn mixup 뒤에 선언합니다.
제네릭 문법은 위치마다 의미가 조금씩 다르지만, 결국 타입 사이의 관계를 이름으로 붙잡는 일입니다.
이어지는 다이어그램에서는 함수, 구조체, 열거형, 메서드에서 제네릭을 읽을 때 확인할 순서를 정리합니다.
PARAMETERIZE · SCOPE · TYPE RELATION · BOUND
제네릭 이름은 타입의 빈칸이 아니라 관계의 표식이다
타입만 다르고 구조가 같은 코드에서 달라지는 타입을 매개변수로 올리면 한 정의를 여러 구체 타입에 재사용할 수 있습니다. 같은 제네릭 이름은 호출 시 같은 구체 타입으로 묶이고, 선언 위치는 그 이름을 사용할 수 있는 범위를 정합니다.
-
타입만 다른 중복을 찾는다
largest_i32와largest_char처럼 알고리즘과 데이터 흐름이 같고 원소 타입만 다른 구현을 비교합니다. -
달라지는 타입을 올바른 범위에 선언한다
함수 전체의 타입은
fn name<T>, 타입 정의의 필드는struct Name<T>, 특정 메서드 호출에서만 필요한 타입은 그 메서드의<U>에 선언합니다. -
본문이 요구하는 동작만 bound로 제한한다
최댓값 비교에는
T: PartialOrd가 필요합니다. 제네릭은 아무 타입이나 무조건 허용하는 문법이 아니라, 구현이 실제로 쓰는 동작을 타입 계약에 드러내는 도구입니다.
선언과 반복 위치를 함께 읽는다
fn largest<T: PartialOrd>(
list: &[T],
) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
struct Point<X, Y> {
x: X,
y: Y,
}
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 }
}
}
largest의 입력 원소와 반환 참조는 같은 T입니다. mixup의 결과는 self에서 온 X1과 other에서 온 Y2를 선택합니다.
| 선언 위치 | 적용 범위 | 같거나 다른 이름의 의미 | 필요한 제약 |
|---|---|---|---|
| function | 함수 선언부터 매개변수, 반환 타입, 본문까지입니다. | &[T] -> &T는 입력 원소와 반환 대상이 같은 구체 타입임을 고정합니다. | 본문의 비교 때문에 PartialOrd bound가 필요합니다. |
| struct | 구조체의 모든 필드와 그 타입을 사용하는 구현입니다. | x: T, y: T는 두 필드 타입을 묶고, 서로 다르면 Point<T, U>로 이름을 나눕니다. | 값을 저장하는 정의 자체에는 동작 bound가 필요하지 않습니다. |
| enum | 열거형의 배리언트가 보유하는 값입니다. | Ok(T)와 Err(E)는 성공 값과 오류 값을 독립된 타입 관계로 표현합니다. | 배리언트에 담는 것만으로는 추가 bound가 필요하지 않습니다. |
| impl | 모든 구체 Point<T>에 제공되는 메서드입니다. | impl Point<f32>처럼 구체 타입을 쓰면 그 인스턴스에만 메서드를 제공할 수 있습니다. | 필요한 연산에 따라 impl이나 메서드에 bound를 둡니다. |
| method | 메서드 호출마다 새로 정하는 매개변수 타입입니다. | impl의 X1, Y1과 독립이며 결과는 X1과 Y2의 출처를 명시합니다. | 필드만 이동하므로 추가 동작 bound가 필요 없습니다. |
fn largest<T: PartialOrd>
입력과 반환의 같은 T가 타입 관계를 만들고, 비교 동작이 bound를 요구합니다.
Point<T>와 Point<T, U>
같은 이름은 같은 필드 타입, 다른 이름은 독립된 필드 타입을 표현합니다.
Result<T, E>
성공 값과 오류 값의 모양을 서로 다른 두 타입 매개변수로 분리합니다.
모든 T 또는 특정 f32
impl<T>는 모든 구체 타입에, impl Point<f32>는 한 구체 타입에 메서드를 제공합니다.
mixup<X2, Y2>
메서드 호출에서만 정해지는 타입을 선언하고 결과가 X1과 Y2를 선택합니다.
Point<i32, f64>
값 5와 10.4에서 X1·Y1을 추론합니다.
Point<&str, char>
x = "Hello"가 X2, y = 'c'가 Y2를 정합니다.
Point<i32, char>
self.x의 X1과 other.y의 Y2가 결과 타입을 만듭니다.
X1, Y1, X2, Y2는 선언 범위를 가진 타입 매개변수이며 객체와 수명이 같다는 뜻이 아닙니다. 참조 유효 기간을 나타내는 라이프타임 매개변수는 'a처럼 별도 문법으로 표현합니다.
제네릭 코드의 성능
USE SITE · MONOMORPHIZATION · STATIC DISPATCH · CODE SIZE
정적 제네릭은 타입별 코드로 확정된다
컴파일러는 제네릭 정의를 런타임의 느슨한 타입으로 남겨 두지 않고, 실제 사용된 구체 타입을 바탕으로 타입 검사와 코드 생성을 수행합니다. 그래서 정적 제네릭 호출에는 매번 타입을 판별하는 분기가 필요하지 않지만, 비용이 컴파일 시간과 코드 크기로 이동할 수 있습니다.
-
한 제네릭 정의를 작성한다
fn largest<T: PartialOrd>(...),Option<T>처럼 타입 관계와 필요한 동작을 매개변수로 표현합니다. -
사용 지점의 구체 타입을 확인한다
largest가i32와char슬라이스에 호출되고Option<i32>,Option<f64>가 사용된다는 사실을 수집합니다. -
각 타입 조합을 검사하고 구체화한다
구체 타입이 trait bound를 만족하는지 확인하고, 필요한 레이아웃과 연산을 타입별로 결정합니다. 만족하지 않으면 컴파일 단계에서 거절합니다.
-
구체 코드로 최적화한다
정적 호출, 인라이닝, 불필요한 분기 제거 같은 일반 최적화를 적용할 수 있습니다. 실제 생성물은 최적화에 따라 합쳐지거나 제거될 수 있습니다.
같은 정의를 여러 구체 타입으로 사용한다
let integer: Option<i32> = Some(5);
let float: Option<f64> = Some(5.0);
let numbers = [34, 50, 25, 100];
let letters = ['y', 'm', 'a', 'q'];
let number = largest(&numbers);
let letter = largest(&letters);
이는 컴파일러가 실제 소스에 Option_i32나 largest_char라는 이름의 항목을 만든다는 뜻이 아닙니다. 서로 다른 구체 인스턴스가 필요하다는 개념을 보여 주는 모델입니다.
| 관점 | 정적 제네릭에서 일어나는 일 | 해석할 때의 경계 |
|---|---|---|
| 실행 시간 | 구체 타입이 컴파일 시 확정되므로 호출 때마다 타입 태그를 검사하는 분기가 필요하지 않습니다. | “제네릭이라서 자동으로 더 빠르다”가 아니라 구체 타입 코드와 같은 최적화 기회를 얻는다는 뜻입니다. |
| 타입 안전성 | PartialOrd 같은 bound와 함수 본문을 각 구체 타입에 대해 컴파일 시 검사합니다. | 요구 동작을 구현하지 않은 타입은 실행 전에 컴파일 오류가 됩니다. |
| 컴파일 시간 | 사용된 타입 조합마다 검사와 코드 생성 작업이 추가될 수 있습니다. | 제네릭 사용 지점과 최적화 수준이 많아질수록 빌드 비용이 커질 수 있습니다. |
| 바이너리 크기 | 서로 다른 구체화가 별도 기계어를 필요로 하면 코드 크기가 늘 수 있습니다. | 컴파일러와 링커가 동일 코드를 합치거나 사용되지 않은 코드를 제거할 수 있어 증가량이 고정되지는 않습니다. |
| dyn Trait | trait object는 런타임에 구체 구현을 선택하는 동적 디스패치를 사용할 수 있습니다. | 이 출력의 “타입별 정적 코드” 설명을 trait object 호출까지 일반화하지 않습니다. |
호출 타입은 컴파일 전에 확정
정적 제네릭은 호출 때마다 타입을 판별하지 않고 구체 코드의 최적화 기회를 얻습니다.
bound는 컴파일 시 확인
필요한 동작을 구현하지 않은 구체 타입은 실행 전에 거절됩니다.
타입 조합마다 빌드 작업 증가 가능
검사와 코드 생성이 늘어날 수 있으므로 실행 비용이 완전히 사라지는 것이 아니라 일부 비용 위치가 이동합니다.
구체 코드 수만큼 커질 수 있음
최적화와 링크 단계가 합치거나 제거할 수 있어 증가량은 사용 패턴에 따라 달라집니다.
동적 디스패치는 별도 경계
trait object 호출은 여기서 설명한 정적 단형화의 실행 모델과 구분합니다.
런타임 타입 분기를 추상화의 기본 비용으로 남기지 않는다
정적으로 선택된 구현은 직접 구체 타입을 작성한 코드와 같은 방식으로 최적화될 수 있습니다. 성능 결과는 함수 본문과 최적화, 데이터 배치에도 의존합니다.
사용 조합 수를 빌드 지표와 함께 본다
컴파일 시간이 길거나 바이너리 크기가 문제가 될 때 실제 프로파일과 크기 분석으로 확인합니다. 모든 제네릭 사용을 미리 수동 중복 코드로 바꾸지 않습니다.
단형화는 소스 코드를 이름만 다른 열거형이나 함수로 문자 그대로 복사하는 규칙이 아니라, 구체 타입별 레이아웃과 연산을 정해 코드 생성과 최적화를 수행하는 컴파일 전략입니다.
성능 절에서는 제네릭 문법을 읽는 것만큼 컴파일러가 어떤 구체 타입으로 펼치는지 추적하는 습관이 중요합니다.
제네릭 타입 매개변수를 사용하면 런타임 비용이 발생하는지 궁금해 할지도 모르겠습니다.
정적으로 디스패치되는 제네릭은 호출할 때마다 타입을 검사하는 방식이 아니라, 컴파일러가 구체 타입별 코드를 만들고 최적화하므로 보통 직접 구체 타입을 작성한 코드와 같은 실행 모델을 가집니다.
대신 사용하는 타입 조합이 많아지면 컴파일 시간과 바이너리 크기가 늘 수 있으며, 트레이트 객체를 통한 동적 디스패치는 여기서 설명하는 단형화와 다른 실행 방식입니다.
러스트는 컴파일 타임에 제네릭을 사용하는 코드를 단형성화(monomorphization) 합니다.
단형성화란 제네릭 코드를 실제 구체 타입으로 채워진 특정한 코드로 바꾸는 과정을 말합니다.
이 과정에서, 컴파일러는 예제 9-5에서 제네릭 함수를 만들 때 거친 과정을 정반대로 수행합니다.
즉 컴파일러는 제네릭 코드가 호출된 곳을 전부 찾고, 제네릭 코드가 호출될 때 사용된 구체 타입으로 코드를 생성합니다.
표준 라이브러리의 Option 열거형을 사용하는
예제를 통해 알아봅시다.
let integer = Some(5);
let float = Some(5.0);러스트는 이 코드를 컴파일할 때 단형성화를 수행합니다.
이 과정 중 컴파일러는 Option<T> 인스턴스에 사용된 값을 읽고, i32, f64 두 종류의 Option<T>가 있다는 것을 인지합니다.
그리고 제네릭 정의를 i32와 f64에 대해 특성화시킨 정의로 확장함으로써, 제네릭 정의를 이 구체적인 것들로 대체합니다.
이 과정을 이해하기 위한 개념 모델은 다음과 비슷합니다. 아래 이름은 설명용일 뿐이며, 컴파일러가 실제 소스에 Option_i32, Option_f64라는 열거형을 만들거나 그대로 출력한다는 뜻은 아닙니다.
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>를 사용한 두 지점이 서로 다른 구체 타입의 인스턴스라는 관계를 개념적으로 펼쳐 보인 것입니다. 실제 코드 생성기는 필요한 레이아웃과 연산을 만들고 최적화 과정에서 일부 코드를 합치거나 제거할 수도 있습니다.
러스트 컴파일러가 제네릭 코드를 각 인스턴스의 명시적인 타입으로 변경해 주는 덕분에, 굳이 런타임 비용을 줄이기 위해 수동으로 직접 각 타입마다 중복된 코드를 작성할 필요가 없습니다.
따라서 정적 제네릭은 런타임 타입 분기 없이 구체 타입에 맞춘 코드를 실행할 수 있지만, 그 대가가 컴파일 작업과 코드 크기로 이동할 수 있습니다.