제네릭 데이터 타입
함수·구조체·열거형·메서드의 중복 타입을 제네릭 매개변수로 추상화하고 단형화가 런타임 성능을 유지하는 원리를 이해합니다.
모든 프로그래밍 언어에는 개념의 중복을 효과적으로 다루는 도구가 있으며, 러스트에서는 제네릭(generic)이 그 도구입니다. 제네릭은 구체적인 타입이나 다른 성질을 추상적으로 대신하는 것으로, 코드를 컴파일하고 실행할 때 그 자리에 무엇이 들어갈지 몰라도 제네릭의 동작이나 다른 제네릭과의 관계를 나타낼 수 있게 해 줍니다. 함수가 어떤 값일지 모르는 매개변수를 받아 같은 코드를 여러 값에 실행하듯이, 함수는 i32나 String 같은 구체적인 타입 대신 제네릭 타입의 매개변수를 받을 수 있습니다.
제네릭은 이미 써 보았습니다. 5장의 Option<T>, 7장의 Vec<T>와 HashMap<K, V>, 8장의 Result<T, E>가 모두 제네릭입니다. 9장에서는 제네릭으로 직접 타입과 함수, 메서드를 정의하는 방법을 봅니다. 이 글에서는 먼저 함수를 뽑아내 코드의 중복을 줄이는 방법을 다시 보고, 같은 방법으로 타입만 다른 두 함수를 제네릭 함수 하나로 합친 뒤, 구조체와 열거형, 메서드에 제네릭을 쓰는 방법과 그 성능을 살펴봅니다. 다음 글에서는 트레이트(trait)로 동작을 제네릭하게 정의해 아무 타입이 아니라 특정한 동작을 하는 타입만 받는 방법을, 그다음 글에서는 참조 사이의 관계를 컴파일러에 알려 주는 제네릭의 한 종류인 라이프타임(lifetime)을 다룹니다.
함수를 추출하여 중복 없애기
제네릭은 여러 타입을 대신하는 자리 표시로 특정한 타입을 바꿔 넣어 코드의 중복을 없앱니다. 제네릭 문법을 보기 전에, 먼저 제네릭 없이 여러 값을 대신하는 자리 표시로 특정한 값을 바꿔 넣는 함수를 뽑아내 중복을 없애 봅니다. 함수로 뽑아낼 수 있는 중복 코드를 알아보는 방법을 익히면, 제네릭으로 줄일 수 있는 중복 코드도 알아볼 수 있습니다. 목록에서 가장 큰 수를 찾는 짧은 프로그램부터 시작합니다.
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}");
}The largest number is 100number_list에 정수 목록을 담고, largest에 목록의 첫 수를 가리키는 참조를 넣습니다. 그다음 목록의 모든 수를 반복하며, 지금 수가 largest가 가리키는 수보다 크면 largest가 지금 수를 가리키게 바꿉니다. 지금 수가 그보다 작으면 그대로 둡니다. 목록을 다 돌면 largest는 가장 큰 수를 가리키므로, 34, 50, 50, 100, 100의 순서로 바뀌어 100이 됩니다.
이번에는 두 목록에서 각각 가장 큰 수를 찾아야 한다고 합시다. 앞의 코드를 복사해 같은 로직을 두 번 쓸 수도 있습니다.
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}");
}이 코드는 동작하지만 코드를 복사하는 것은 지루하고 실수하기 쉬우며, 로직을 바꾸고 싶을 때 여러 곳을 고쳐야 한다는 것도 기억해야 합니다. 이 중복을 없애기 위해 정수 목록을 받아 동작하는 함수를 정의해 추상화합니다. 그러면 코드가 더 분명해지고, 목록에서 가장 큰 수를 찾는다는 개념을 추상적으로 나타낼 수 있습니다.
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}");
}The largest number is 100
The largest number is 6000largest 함수의 매개변수 list는 함수에 넘길 수 있는 i32 값의 비어 있지 않은 슬라이스를 나타내며, 함수를 부르면 넘겨받은 구체적인 값으로 코드가 실행됩니다. 비어 있는 슬라이스를 넘기면 &list[0]에서 패닉이 납니다. 여기까지 거친 과정을 정리하면, 중복된 코드를 찾고, 그 코드를 함수의 본문으로 옮겨 시그니처에 입력과 반환 값을 적고, 중복되던 두 곳을 함수 호출로 바꾼 것입니다.
이제 같은 과정을 제네릭으로 해 봅니다. 함수의 본문이 특정한 값 대신 추상적인 list로 동작하듯이, 제네릭을 쓰는 코드는 추상적인 타입으로 동작합니다. 예를 들어 i32 슬라이스에서 가장 큰 값을 찾는 함수와 char 슬라이스에서 가장 큰 값을 찾는 함수가 따로 있다면, 그 중복은 어떻게 없앨까요?
제네릭 함수 정의
제네릭으로 함수를 정의할 때는 시그니처에서 매개변수와 반환 값의 데이터 타입이 놓이는 자리에 제네릭을 씁니다. 그러면 코드가 더 유연해지고, 부르는 쪽에 더 많은 기능을 주면서 중복도 막을 수 있습니다. 다음은 슬라이스에서 가장 큰 값을 찾는 두 함수입니다.
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}");
}The largest number is 100
The largest char is ylargest_i32는 앞에서 본 가장 큰 i32를 찾는 함수이고, largest_char는 가장 큰 char를 찾는 함수입니다. 두 함수의 본문이 똑같으므로, 함수 하나에 제네릭 타입 매개변수를 두어 중복을 없앱니다. 새 함수의 시그니처에서 타입을 매개변수로 만들려면 타입 매개변수에 이름을 붙여야 합니다. 어떤 이름이든 쓸 수 있지만, 여기서는 T를 씁니다. 러스트에서 타입 매개변수의 이름은 짧게, 흔히 한 글자로 짓고, 타입 이름의 관례대로 대문자로 시작하는 UpperCamelCase로 짓기 때문에, 타입(type)의 머리글자인 T가 대부분의 러스트 프로그래머가 고르는 기본 이름입니다.
함수의 본문에서 매개변수를 쓰려면 시그니처에 그 이름을 선언해 컴파일러가 그 이름의 뜻을 알게 해야 하듯이, 타입 매개변수도 시그니처에서 쓰기 전에 선언해야 합니다. 제네릭 largest 함수를 정의하려면 함수 이름과 매개변수 목록 사이의 꺾쇠괄호 <> 안에 타입 이름을 선언합니다.
fn largest<T>(list: &[T]) -> &T {이 정의는 함수 largest가 어떤 타입 T에 대해 제네릭하다고 읽습니다. 이 함수는 T 타입 값의 슬라이스인 매개변수 list를 받고, 같은 T 타입 값의 참조를 돌려줍니다. 다음 코드는 이 제네릭 largest를 정의해 i32 슬라이스와 char 슬라이스 모두로 부르지만, 아직 컴파일되지 않습니다.
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}");
}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 {
| ++++++++++++++++++++++도움말에 다음 글에서 다룰 std::cmp::PartialOrd라는 트레이트(trait)가 나옵니다. 이 오류는 largest의 본문이 T가 될 수 있는 모든 타입에서 동작하지는 않는다는 뜻입니다. 본문에서 T 타입의 값을 >로 비교하므로, 값에 순서를 매길 수 있는 타입만 쓸 수 있습니다. 표준 라이브러리는 비교할 수 있게 하려는 타입에 구현하는 std::cmp::PartialOrd 트레이트를 제공하므로, 도움말처럼 T를 PartialOrd를 구현한 타입으로 제한하면 이 코드가 컴파일됩니다. i32와 char는 모두 PartialOrd를 구현하기 때문입니다. 이 제한은 트레이트와 공통 동작에서 다룹니다.
제네릭 구조체 정의
<> 문법으로 구조체의 필드에도 제네릭 타입 매개변수를 쓸 수 있습니다. 다음 Point<T>는 어떤 타입이든 x와 y 좌표 값을 담습니다.
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가 모두 같은 타입이라는 뜻입니다. 그래서 다음처럼 서로 다른 타입의 값으로 Point<T>를 만들면 컴파일되지 않습니다.
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}error[E0308]: mismatched types
--> src/main.rs:7:38
|
7 | let wont_work = Point { x: 5, y: 4.0 };
| ^^^ expected integer, found floating-point numberx에 정수 5를 넣으면 컴파일러는 이 Point<T>의 제네릭 타입 T가 정수라고 정합니다. 그런데 x와 같은 타입이어야 하는 y에 4.0을 넣었으므로 타입이 맞지 않는다는 오류가 납니다. x와 y가 둘 다 제네릭이면서 서로 다른 타입일 수 있게 하려면 제네릭 타입 매개변수를 여러 개 씁니다.
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는 T와 U에 대해 제네릭하며 x는 T, y는 U 타입이므로, 위의 Point 인스턴스가 모두 허용됩니다. 제네릭 타입 매개변수는 원하는 만큼 둘 수 있지만, 너무 많으면 코드를 읽기 어려워집니다. 코드에 제네릭 타입이 많이 필요하다면 코드를 더 작은 조각으로 나눠야 한다는 신호일 수 있습니다.
제네릭 열거형 정의
구조체처럼 열거형도 배리언트에 제네릭 데이터 타입을 담을 수 있습니다. 표준 라이브러리의 Option<T>를 다시 봅니다.
enum Option<T> {
Some(T),
None,
}이제 이 정의를 더 잘 이해할 수 있습니다. Option<T>는 타입 T에 대해 제네릭한 열거형으로, T 타입의 값 하나를 담는 Some과 아무 값도 담지 않는 None 두 배리언트를 가집니다. Option<T>를 쓰면 선택적인 값이라는 추상적인 개념을 나타낼 수 있고, 제네릭이므로 그 값의 타입이 무엇이든 이 추상화를 쓸 수 있습니다.
열거형도 제네릭 타입을 여러 개 쓸 수 있습니다. 8장에서 쓴 Result 열거형의 정의가 그 예입니다.
enum Result<T, E> {
Ok(T),
Err(E),
}Result는 T와 E 두 타입에 대해 제네릭하며, T 타입의 값을 담는 Ok와 E 타입의 값을 담는 Err 두 배리언트를 가집니다. 이 정의 덕분에 성공해서 T 타입의 값을 돌려주거나 실패해서 E 타입의 오류를 돌려줄 수 있는 연산이라면 어디에든 Result를 쓸 수 있습니다. 실제로 8장에서 파일을 열 때, 성공하면 T가 std::fs::File이고 실패하면 E가 std::io::Error였습니다. 코드에서 담는 값의 타입만 다른 구조체나 열거형 정의가 여럿 보인다면, 제네릭 타입을 써서 중복을 없앨 수 있습니다.
제네릭 메서드 정의
4장과 5장에서 했듯이 구조체와 열거형에 메서드를 구현할 때도 정의에 제네릭 타입을 쓸 수 있습니다. 다음 코드는 Point<T>에 x라는 메서드를 구현합니다.
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());
}p.x = 5Point<T>에 필드 x의 데이터를 가리키는 참조를 돌려주는 메서드 x를 정의했습니다. impl 바로 뒤에 T를 선언해야 Point<T> 타입에 메서드를 구현한다는 것을 나타낼 때 T를 쓸 수 있다는 점에 주의합니다. impl 뒤에 T를 제네릭으로 선언하면, 러스트는 Point의 꺾쇠괄호 안의 타입이 구체적인 타입이 아니라 제네릭 타입이라는 것을 압니다. 구조체 정의에서 선언한 제네릭 매개변수와 다른 이름을 붙일 수도 있지만, 같은 이름을 쓰는 것이 관례입니다.
제네릭 타입을 선언한 impl 안에 정의한 메서드는, 그 제네릭 타입에 어떤 구체적인 타입이 들어가든 그 타입의 모든 인스턴스에 정의됩니다. 반대로 특정한 구체 타입에만 메서드를 정의할 수도 있습니다. 다음 코드는 모든 Point<T>가 아니라 Point<f32>에만 메서드를 구현하며, 그래서 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)에서 얼마나 떨어져 있는지 계산하며, 부동 소수점 타입에만 있는 수학 연산을 씁니다.
구조체 정의의 제네릭 매개변수와 그 구조체의 메서드 시그니처에서 쓰는 제네릭 매개변수가 언제나 같을 필요는 없습니다. 다음 코드는 예를 분명히 하려고 Point 구조체에는 제네릭 타입 X1과 Y1을, mixup 메서드에는 X2와 Y2를 씁니다.
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);
}p3.x = 5, p3.y = cmixup은 self의 Point에서 x 값을, 인수로 받은 Point에서 y 값을 가져와 새 Point 인스턴스를 만듭니다. p1은 x가 i32인 5, y가 f64인 10.4이고, p2는 x가 문자열 슬라이스 "Hello", y가 char인 'c'입니다. 그래서 p3는 p1에서 온 i32 타입의 x와 p2에서 온 char 타입의 y를 가집니다. 아래 그림이 이 호출을 따라갑니다.
Rust 제네릭의 선언 범위와 타입 관계
p1(5,10.4)과 p2(Hello,c)를 mixup이 값으로 소비해 p3(5,c)를 만드는 한 호출의 전후 상태다. 두 출처 화살표 사이 순서는 없으므로 호출 전·소비/필드 선택·반환으로 구성해 p1.y/p2.x 제외와 p1/p2 사용 불가를 표시한다.
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는 빈 입력에서 list[0]이 패닉합니다. PartialOrd의 비교 불가능한 값(예: NaN) 처리도 별도 정책이며, 전체 순서를 보장하지 않습니다.
largest의 입력 원소와 반환 참조는 같은 T입니다. mixup의 결과는 self에서 온 X1과 other에서 온 Y2를 선택합니다.
| 선언 위치 | 적용 범위 | 같거나 다른 이름의 의미 | 필요한 제약 |
|---|---|---|---|
| function | 함수 선언부터 매개변수, 반환 타입, 본문까지입니다. | &[T] -> &T는 입력 원소와 반환 대상이 같은 구체 타입임을 고정합니다. | 본문의 비교 때문에 PartialOrd bound가 필요합니다. |
| struct | 구조체의 필드에 적용합니다. 별도 impl에서는 타입 매개변수를 다시 선언합니다. | 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>
같은 이름은 같은 필드 타입, 다른 이름은 독립된 필드 타입을 표현합니다. 별도 impl의 타입 매개변수는 다시 선언합니다.
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처럼 별도 문법으로 표현합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | mixup은 self와 other Point 값을 소비한다. self.x의 5와 other.y의 c가 결과 p3에 들어간다. 두 화살표는 실행 순서가 아니라 필드의 출처와 이동을 나타내며 p1.y와 p2.x는 결과에 포함되지 않는다. |
| 관계·조건 설명 | mixup은 self와 other Point 값을 소비한다. self.x의 5와 other.y의 c가 결과 p3에 들어간다. 두 화살표는 실행 순서가 아니라 필드의 출처와 이동을 나타내며 p1.y와 p2.x는 결과에 포함되지 않는다. 모바일에서 self.x는 왼쪽 외부 레일로 p2를 거치지 않고 결과의 왼쪽 포트로 이동하고 other.y는 결과의 오른쪽 포트로 이동한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 소유 입력 p1과 p2의 타입·필드를 확인한다. |
|
|
| 소비·필드 출처 self와 other를 소비해 결과 필드를 고른다. |
|
|
| 새 결과 p3을 반환하고 원래 바인딩의 사용 가능성을 구분한다. |
|
|
Rust 제네릭의 선언 범위와 타입 관계
p1(5,10.4)과 p2(Hello,c)를 mixup이 값으로 소비해 p3(5,c)를 만드는 한 호출의 전후 상태다. 두 출처 화살표 사이 순서는 없으므로 호출 전·소비/필드 선택·반환으로 구성해 p1.y/p2.x 제외와 p1/p2 사용 불가를 표시한다.
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는 빈 입력에서 list[0]이 패닉합니다. PartialOrd의 비교 불가능한 값(예: NaN) 처리도 별도 정책이며, 전체 순서를 보장하지 않습니다.
largest의 입력 원소와 반환 참조는 같은 T입니다. mixup의 결과는 self에서 온 X1과 other에서 온 Y2를 선택합니다.
| 선언 위치 | 적용 범위 | 같거나 다른 이름의 의미 | 필요한 제약 |
|---|---|---|---|
| function | 함수 선언부터 매개변수, 반환 타입, 본문까지입니다. | &[T] -> &T는 입력 원소와 반환 대상이 같은 구체 타입임을 고정합니다. | 본문의 비교 때문에 PartialOrd bound가 필요합니다. |
| struct | 구조체의 필드에 적용합니다. 별도 impl에서는 타입 매개변수를 다시 선언합니다. | 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>
같은 이름은 같은 필드 타입, 다른 이름은 독립된 필드 타입을 표현합니다. 별도 impl의 타입 매개변수는 다시 선언합니다.
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처럼 별도 문법으로 표현합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | mixup은 self와 other Point 값을 소비한다. self.x의 5와 other.y의 c가 결과 p3에 들어간다. 두 화살표는 실행 순서가 아니라 필드의 출처와 이동을 나타내며 p1.y와 p2.x는 결과에 포함되지 않는다. |
| 관계·조건 설명 | mixup은 self와 other Point 값을 소비한다. self.x의 5와 other.y의 c가 결과 p3에 들어간다. 두 화살표는 실행 순서가 아니라 필드의 출처와 이동을 나타내며 p1.y와 p2.x는 결과에 포함되지 않는다. 모바일에서 self.x는 왼쪽 외부 레일로 p2를 거치지 않고 결과의 왼쪽 포트로 이동하고 other.y는 결과의 오른쪽 포트로 이동한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 소유 입력 p1과 p2의 타입·필드를 확인한다. |
|
|
| 소비·필드 출처 self와 other를 소비해 결과 필드를 고른다. |
|
|
| 새 결과 p3을 반환하고 원래 바인딩의 사용 가능성을 구분한다. |
|
|
이 예는 제네릭 매개변수 가운데 일부는 impl에, 일부는 메서드 정의에 선언하는 경우를 보여 줍니다. X1과 Y1은 구조체 정의와 함께 쓰이므로 impl 뒤에 선언했고, X2와 Y2는 이 메서드에만 쓰이므로 fn mixup 뒤에 선언했습니다. 또 mixup의 self와 other는 참조가 아니라 Point 값 자체이므로, 이 Point에는 Copy가 없어서 호출한 뒤에는 p1과 p2를 쓸 수 없고 결과 p3만 씁니다.
제네릭 코드의 성능
제네릭 타입 매개변수를 쓰면 실행할 때 비용이 드는지 궁금할 수 있습니다. 제네릭 타입을 써도 구체적인 타입을 쓸 때보다 프로그램이 느려지지 않습니다. 러스트는 컴파일할 때 제네릭을 쓰는 코드를 단형화(monomorphization)하기 때문입니다. 단형화는 컴파일할 때 쓰인 구체적인 타입을 채워 넣어 제네릭 코드를 특정한 코드로 바꾸는 과정입니다. 아래 그림이 그 과정과 비용을 정리합니다.
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 같은 바운드에 근거해 제네릭 본문을 검사하고, 호출 지점에서는 구체 타입이 그 바운드를 만족하는지 확인합니다. | 요구 동작을 구현하지 않은 타입은 실행 전에 컴파일 오류가 됩니다. |
| 컴파일 시간 | 사용된 타입 조합마다 검사와 코드 생성 작업이 추가될 수 있습니다. | 제네릭 사용 지점과 최적화 수준이 많아질수록 빌드 비용이 커질 수 있습니다. |
| 바이너리 크기 | 서로 다른 구체화가 별도 기계어를 필요로 하면 코드 크기가 늘 수 있습니다. | 컴파일러와 링커가 동일 코드를 합치거나 사용되지 않은 코드를 제거할 수 있어 증가량이 고정되지는 않습니다. |
| dyn Trait | trait object는 런타임에 구체 구현을 선택하는 동적 디스패치를 사용할 수 있습니다. | 이 출력의 “타입별 정적 코드” 설명을 trait object 호출까지 일반화하지 않습니다. |
호출 타입은 컴파일 시 확정
정적 제네릭은 호출 때마다 타입을 판별하지 않고 구체 코드의 최적화 기회를 얻습니다.
bound는 컴파일 시 확인
필요한 동작을 구현하지 않은 구체 타입은 실행 전에 거절됩니다.
타입 조합마다 빌드 작업 증가 가능
검사와 코드 생성이 늘어날 수 있으므로 실행 비용이 완전히 사라지는 것이 아니라 일부 비용 위치가 이동합니다.
구체 코드 수만큼 커질 수 있음
최적화와 링크 단계가 합치거나 제거할 수 있어 증가량은 사용 패턴에 따라 달라집니다.
동적 디스패치는 별도 경계
trait object 호출은 여기서 설명한 정적 단형화의 실행 모델과 구분합니다.
런타임 타입 분기를 추상화의 기본 비용으로 남기지 않는다
정적으로 선택된 구현은 직접 구체 타입을 작성한 코드와 같은 방식으로 최적화될 수 있습니다. 성능 결과는 함수 본문과 최적화, 데이터 배치에도 의존합니다.
사용 조합 수를 빌드 지표와 함께 본다
컴파일 시간이 길거나 바이너리 크기가 문제가 될 때 실제 프로파일과 크기 분석으로 확인합니다. 모든 제네릭 사용을 미리 수동 중복 코드로 바꾸지 않습니다.
단형화는 소스 코드를 이름만 다른 열거형이나 함수로 문자 그대로 복사하는 규칙이 아니라, 구체 타입별 레이아웃과 연산을 정해 코드 생성과 최적화를 수행하는 컴파일 전략입니다.
이 과정에서 컴파일러는 제네릭 함수를 만들 때 우리가 거친 과정을 거꾸로 합니다. 제네릭 코드가 불리는 곳을 모두 찾아, 그곳에서 쓰인 구체적인 타입으로 코드를 만듭니다. 표준 라이브러리의 제네릭 Option<T>로 예를 들어 봅니다.
let integer = Some(5);
let float = Some(5.0);러스트가 이 코드를 컴파일할 때 단형화를 합니다. 컴파일러는 Option<T> 인스턴스에 쓰인 값을 읽어 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>가 컴파일러가 만든 구체적인 정의로 바뀌었습니다. 러스트가 제네릭 코드를 인스턴스마다 타입이 정해진 코드로 컴파일하므로, 제네릭을 써도 실행할 때 비용이 들지 않습니다. 코드가 실행될 때는 정의를 하나하나 직접 손으로 적었을 때와 똑같이 동작하며, 이 단형화 덕분에 러스트의 제네릭은 실행할 때 아주 효율적입니다. 그 대신 쓰이는 타입의 종류가 많아지면 컴파일 시간과 실행 파일의 크기가 늘어날 수 있습니다. 실행 중에 메서드를 고르는 트레이트 객체의 동적 디스패치는 트레이트 객체와 다형성에서 다룹니다.
정리
- 제네릭은 구체적인 타입을 대신하는 매개변수로, 타입만 다른 코드의 중복을 없앱니다.
- 제네릭 함수는
fn largest<T>(list: &[T]) -> &T처럼 이름 뒤의<>에 타입 매개변수를 선언하고, 본문이 그 타입에 하는 일은 트레이트로 제한해야 합니다. - 구조체와 열거형도
struct Point<T>,enum Option<T>처럼 제네릭 타입 매개변수를 가질 수 있으며, 서로 다른 타입이 필요하면 매개변수를 여러 개 둡니다. - 제네릭 타입의 메서드는
impl<T> Point<T>처럼impl뒤에 타입 매개변수를 선언하며,impl Point<f32>처럼 특정 타입에만 메서드를 구현할 수도 있습니다. - 러스트는 컴파일할 때 쓰인 구체적인 타입마다 코드를 만드는 단형화를 하므로, 제네릭은 실행 중 비용이 없지만 컴파일 시간과 실행 파일의 크기가 늘 수 있습니다.
연습 문제
1. 가장 큰 값의 변화
largest에 [3, 9, 9, 2, 10]을 넘기면, 반복하는 동안 largest가 가리키는 값은 어떻게 바뀌나요? 빈 슬라이스를 넘기면 어떻게 되나요?
해설 보기
처음에 3을 가리키고, 9를 만나 9로 바뀌며, 다음 9는 9 > 9가 거짓이라 그대로이고, 2는 작아서 그대로이며, 10을 만나 10으로 바뀝니다. 그래서 3, 9, 9, 9, 10을 거쳐 10을 돌려줍니다. 빈 슬라이스를 넘기면 첫 줄의 &list[0]이 범위를 벗어나 패닉이 나므로, 빈 목록을 받을 수 있다면 Option<&T>를 돌려주도록 바꿉니다.
2. 섞은 결과의 타입
p1이 Point { x: String::from("left"), y: 10u8 }, p2가 Point { x: true, y: String::from("right") }일 때, p1.mixup(p2)의 결과 타입과 필드 값은 무엇인가요? 호출한 뒤 p1과 p2를 쓸 수 있나요?
해설 보기
결과는 x를 p1에서, y를 p2에서 가져오므로 Point<String, String>이고, x는 "left", y는 "right"입니다. 두 String 필드를 결과로 옮기므로 복사할 필요는 없습니다. mixup이 self와 other를 값으로 받아 소비하므로, 호출한 뒤에는 p1과 p2를 쓸 수 없습니다.
3. 어떤 타입에 메서드가 있을까
impl Point<f32>에 정의한 distance_from_origin은 Point { x: 3.0_f32, y: 4.0_f32 }, Point { x: 3.0, y: 4.0 }, Point { x: 3, y: 4 } 가운데 어디서 부를 수 있나요?
해설 보기
첫째에서만 부를 수 있습니다. 둘째는 소수 리터럴의 기본 타입이 f64이므로 Point<f64>이고, 셋째는 Point<i32>이므로 두 타입에는 이 메서드가 없습니다. 둘째에서 부르려면 3.0_f32처럼 타입을 정해 주거나, 변수에 Point<f32>라고 타입을 적습니다. 첫째에서 부르면 3과 4의 제곱의 합 25의 제곱근인 5.0을 돌려줍니다.
다음 글 트레이트와 공통 동작에서는 여러 타입이 함께 갖는 동작을 트레이트로 정의하고, 제네릭 타입이 그 동작을 갖도록 제한하는 방법을 살펴봅니다.