트레이트 바운드 (Trait Bounds)
지금까지 우리는 트레이트의 두 가지 쓰임새를 살펴보았습니다:
- 연산자 오버로딩처럼 “내장된” 기능을 활성화하는 용도
- 확장 트레이트처럼 기존 타입에 새로운 기능을 추가하는 용도
세 번째 중요한 쓰임새는 바로 **제네릭 프로그래밍(Generic Programming)**입니다.
문제 상황
지금까지 우리가 작성한 함수나 메서드는 모두 **구체적인 타입(Concrete type)**을 대상으로 했습니다. 구체적인 타입으로 작업하면 이해하기 쉽지만 재사용성은 떨어집니다.
예를 들어, 어떤 숫자가 짝수인지 확인하는 함수를 만든다고 해봅시다. 구체적인 타입만 사용한다면, 지원하고 싶은 모든 정수 타입마다 별도의 함수를 만들어야 합니다:
fn is_even_i32(n: i32) -> bool {
n % 2 == 0
}
fn is_even_i64(n: i64) -> bool {
n % 2 == 0
}
// ... 계속 반복 ...
혹은 확장 트레이트를 만들고 타입마다 각기 다른 구현을 작성할 수도 있겠죠:
trait IsEven {
fn is_even(&self) -> bool;
}
impl IsEven for i32 {
fn is_even(&self) -> bool {
self % 2 == 0
}
}
impl IsEven for i64 {
fn is_even(&self) -> bool {
self % 2 == 0
}
}
하지만 코드가 중복된다는 사실은 변하지 않습니다.
제네릭 프로그래밍 (Generic Programming)
**제네릭(Generics)**을 사용하면 이 문제를 멋지게 해결할 수 있습니다. 구체적인 타입 대신 **타입 매개변수(Type parameters)**를 사용하는 코드를 작성할 수 있거든요:
fn print_if_even<T>(n: T)
where
T: IsEven + Debug
{
if n.is_even() {
println!("{n:?} is even");
}
}
여기서 print_if_even은 제네릭 함수입니다. 특정 입력 타입에 고정되지 않고, 다음 두 조건을 만족하는 모든 타입 T와 함께 사용할 수 있습니다:
IsEven트레이트를 구현함.Debug트레이트를 구현함.
이러한 약속을 **트레이트 바운드(Trait Bounds)**라고 부르며, T: IsEven + Debug와 같이 표현합니다. + 기호는 T가 여러 트레이트를 한꺼번에 구현해야 함을 의미합니다.
트레이트 바운드가 필요한 이유
print_if_even 함수에서 트레이트 바운드를 지우면 어떻게 될까요?
fn print_if_even<T>(n: T) {
if n.is_even() {
println!("{n:?} is even");
}
}
이 코드는 컴파일되지 않습니다. 컴파일러는 타입 매개변수 T가 무엇을 할 수 있는지 알지 못하기 때문입니다. T에 is_even 메서드가 있는지, T를 출력용으로 포맷팅할 수 있는지 알 길이 없죠.
컴파일러 입장에서 아무런 제약이 없는 T는 어떠한 기능도 보장되지 않는 “빈 상자“와 같습니다. 트레이트 바운드는 함수 안에서 필요한 특정 동작들이 실제로 존재함을 보장함으로써, 해당 함수가 받아들일 수 있는 타입의 범위를 안전하게 제한하는 역할을 합니다.
구문: 인라인 트레이트 바운드 (Inline Trait Bounds)
위 예제에서는 **where 절(Clause)**을 사용하여 트레이트 바운드를 명시했습니다.
fn print_if_even<T>(n: T)
where
T: IsEven + Debug
// ^^^^^^^^^^^^^^^^^ 이 부분이 `where` 절입니다.
{
// [...]
}
트레이트 바운드가 간단하다면 타입 매개변수 바로 옆에 **인라인(Inline)**으로 적을 수도 있습니다:
fn print_if_even<T: IsEven + Debug>(n: T) {
// ^^^^^^^^^^^^^^^^^ 인라인 트레이트 바운드
// [...]
}
구문: 의미 있는 이름 사용하기
보통 타입 매개변수 이름으로 T를 많이 쓰지만, 좀 더 의미 있는 이름을 사용해도 좋습니다:
fn print_if_even<Number: IsEven + Debug>(n: Number) {
// [...]
}
타입 매개변수가 여러 개이거나 T라는 이름만으로 역할이 분명하지 않을 때, 가독성을 위해 의미 있는 이름을 사용하는 것이 바람직합니다. 다만 관례에 따라 **파스칼 케이스(PascalCase, 또는 UpperCamelCase)**를 사용해 주세요.
함수 시그니처가 전부입니다 (Signature is King)
컴파일러가 함수 본문을 보고 어떤 트레이트가 필요한지 알아서 추론해 줄 순 없을까요? 기술적으로는 가능할지도 모르지만, Rust는 의도적으로 그렇게 하지 않습니다.
함수 매개변수에 명시적 타입을 적는 것과 마찬가지로, 함수 시그니처는 호출하는 쪽과 함수 간의 명확한 약속입니다. 이 규칙 덕분에 우리는 훨씬 정확한 오류 메시지를 받을 수 있고, 문서를 보기가 쉬워지며, 코드 변경 시 의도치 않은 파급 효과를 줄이고 컴파일 속도를 높일 수 있습니다.
Exercise
The exercise for this section is located in 04_traits/05_trait_bounds