Rust 트레이트 설명: 작동 방법 및 필요성
Ethan Miller
Product Engineer · Leapcell

Rust의 설계 목표에서, 제로 코스트 추상화는 가장 중요한 원칙 중 하나입니다. 이는 Rust가 성능 저하 없이 고급 언어의 표현력을 가질 수 있도록 합니다. 이러한 제로 코스트 추상화의 기초는 제네릭과 트레이트에 있으며, 이는 고급 구문을 컴파일 시간에 효율적인 저수준 코드로 컴파일하여 런타임 효율성을 달성할 수 있도록 합니다. 이 글에서는 트레이트의 사용법을 소개하고 세 가지 일반적인 문제를 분석하며, 이러한 문제 탐구를 통해 기본 메커니즘을 설명합니다.
사용법
기본 사용법
트레이트의 주요 목적은 다른 프로그래밍 언어의 "인터페이스"와 유사하게 동작을 추상화하는 것입니다. 다음은 트레이트의 기본 사용법을 설명하는 예제입니다.
trait Greeting { fn greeting(&self) -> &str; } struct Cat; impl Greeting for Cat { fn greeting(&self) -> &str { "Meow!" } } struct Dog; impl Greeting for Dog { fn greeting(&self) -> &str { "Woof!" } }
위 코드에서 Greeting
트레이트가 정의되었고 두 개의 구조체에 의해 구현되었습니다. 함수 호출 방식에 따라 주로 두 가지 방법으로 사용할 수 있습니다.
- 제네릭 기반의 정적 디스패치
- 트레이트 객체 기반의 동적 디스패치
제네릭 개념은 더 일반적으로 알려져 있으므로 여기서는 트레이트 객체에 집중하겠습니다.
트레이트 객체는 트레이트 집합을 구현하는 다른 유형의 불투명한 값입니다. 트레이트 집합은 객체 안전 기본 트레이트와 임의의 자동 트레이트로 구성됩니다.
중요한 세부 사항은 트레이트 객체가 동적으로 크기가 결정되는 유형(DST)에 속한다는 것입니다. 즉, 크기를 컴파일 시간에 결정할 수 없습니다. 포인터를 통해 간접적으로 액세스해야 합니다. 일반적인 형식으로는 Box<dyn Trait>
, &dyn Trait
등이 있습니다.
fn print_greeting_static<G: Greeting>(g: G) { println!("{}", g.greeting()); } fn print_greeting_dynamic(g: Box<dyn Greeting>) { println!("{}", g.greeting()); } print_greeting_static(Cat); print_greeting_static(Dog); print_greeting_dynamic(Box::new(Cat)); print_greeting_dynamic(Box::new(Dog));
정적 디스패치
Rust에서 제네릭은 단형화를 사용하여 구현되며, 이는 컴파일 시 서로 다른 유형에 대해 함수의 다른 버전을 생성합니다. 따라서 제네릭은 유형 매개변수라고도 합니다. 장점은 가상 함수 호출로 인한 오버헤드가 없다는 것이지만, 단점은 바이너리 크기가 증가한다는 것입니다. 위의 예에서 print_greeting_static
은 두 버전으로 컴파일됩니다.
print_greeting_static_cat(Cat); print_greeting_static_dog(Dog);
동적 디스패치
모든 함수 호출이 컴파일 시간에 호출자 유형을 결정할 수 있는 것은 아닙니다. 일반적인 시나리오는 GUI 프로그래밍의 이벤트에 대한 콜백입니다. 일반적으로 하나의 이벤트는 여러 콜백 함수에 해당할 수 있으며, 이러한 함수는 컴파일 시간에 알려지지 않습니다. 따라서 제네릭은 이러한 경우에 적합하지 않으며 동적 디스패치가 필요합니다.
trait ClickCallback { fn on_click(&self, x: i64, y: i64); } struct Button { listeners: Vec<Box<dyn ClickCallback>>, }
impl Trait
Rust 1.26에서는 트레이트를 사용하는 새로운 방법인 impl Trait
가 도입되었습니다. 이는 함수 매개변수와 반환 값의 두 위치에서 사용할 수 있습니다. 이는 주로 복잡한 트레이트의 사용을 단순화하기 위한 것이며 제네릭의 특수한 경우로 간주할 수 있습니다. impl Trait
를 사용하는 경우 여전히 정적 디스패치입니다. 그러나 반환 유형으로 사용될 때 데이터 유형은 모든 반환 경로에서 동일해야 합니다. 이는 중요한 점입니다!
fn print_greeting_impl(g: impl Greeting) { println!("{}", g.greeting()); } print_greeting_impl(Cat); print_greeting_impl(Dog); // 다음 코드는 컴파일 오류를 발생시킵니다. fn return_greeting_impl(i: i32) -> impl Greeting { if i > 10 { return Cat; } Dog } // | fn return_greeting_impl(i: i32) -> impl Greeting { // | ------------- expected because this return type... // | if i > 10 { // | return Cat; // | --- ...is found to be `Cat` here // | } // | Dog // | ^^^ expected struct `Cat`, found struct `Dog`
고급 사용법
연관된 유형
위의 기본 사용법 섹션에서는 트레이트 메서드의 매개변수 또는 반환 유형이 고정되어 있습니다. Rust는 유형의 지연 바인딩이라는 메커니즘, 즉 연관된 유형을 제공합니다. 이를 통해 트레이트를 구현할 때 구체적인 유형을 지정할 수 있습니다. 일반적인 예는 표준 라이브러리의 Iterator
트레이트이며, 여기서 next
의 반환 값은 Self::Item
입니다.
trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; } /// 짝수만 출력하는 샘플 반복기 struct EvenNumbers { count: usize, limit: usize, } impl Iterator for EvenNumbers { type Item = usize; fn next(&mut self) -> Option<Self::Item> { if self.count > self.limit { return None; } let ret = self.count * 2; self.count += 1; Some(ret) } } fn main() { let nums = EvenNumbers { count: 1, limit: 5 }; for n in nums { println!("{}", n); } } // 출력: 2 4 6 8 10
연관된 유형의 사용법은 제네릭과 유사합니다. Iterator
트레이트는 제네릭을 사용하여 정의할 수도 있습니다.
pub trait Iterator<T> { fn next(&mut self) -> Option<T>; }
두 가지 접근 방식의 주요 차이점은 다음과 같습니다.
- 특정 유형(위의
Cat
구조체와 같이)은 제네릭 트레이트를 여러 번 구현할 수 있습니다. 예를 들어From
트레이트를 사용하면impl From<&str> for Cat
와impl From<String> for Cat
를 모두 가질 수 있습니다. - 그러나 연관된 유형이 있는 트레이트는 한 번만 구현할 수 있습니다. 예를 들어
FromStr
을 사용하면impl FromStr for Cat
를 하나만 가질 수 있습니다.Iterator
및Deref
와 같은 트레이트는 이 패턴을 따릅니다.
Derive 매크로
Rust에서 derive
속성은 Debug
또는 Clone
과 같은 몇 가지 일반적인 트레이트를 자동으로 구현하는 데 사용할 수 있습니다. 사용자 정의 트레이트의 경우 절차적 매크로를 구현하여 derive
를 지원할 수도 있습니다. 자세한 내용은 사용자 정의 derive 매크로를 작성하는 방법을 참조하십시오. 여기서는 더 자세히 다루지 않겠습니다.
일반적인 문제
업캐스팅
SubTrait: Base
인 트레이트의 경우 현재 Rust 버전에서는 &dyn SubTrait
를 &dyn Base
로 변환할 수 없습니다. 이 제한 사항은 트레이트 객체의 메모리 레이아웃과 관련이 있습니다.
Rust fat pointers 탐색 기사에서 작성자는 transmute
를 사용하여 트레이트 객체 참조를 두 개의 usize
값으로 변환하고 각각 데이터와 vtable을 가리키는지 확인했습니다.
use std::mem::transmute; use std::fmt::Debug; fn main() { let v = vec![1, 2, 3, 4]; let a: &Vec<u64> = &v; // 트레이트 객체로 변환 let b: &dyn Debug = &v; println!("a: {}", a as *const _ as usize); println!("b: {:?}", unsafe { transmute::<_, (usize, usize)>(b) }); } // a: 140735227204568 // b: (140735227204568, 94484672107880)
이는 Rust가 fat pointers(즉, 두 개의 포인터)를 사용하여 트레이트 객체 참조를 나타냄을 보여줍니다. 하나는 데이터를 가리키고 다른 하나는 vtable을 가리킵니다. 이는 Go에서 인터페이스를 처리하는 방식과 매우 유사합니다.
+--------------------+
| fat object pointer |
+---------+----------+
| data | vtable |
+----|----+----|------+
| | |
v v v
+---------+ +-----------+
| object | | vtable |
+---------+ +-----+-----+
| ... | | S | S |
+---------+ +-----+-----+
pub struct TraitObjectReference { pub data: *mut (), pub vtable: *mut (), } struct Vtable { destructor: fn(*mut ()), size: usize, align: usize, method: fn(*const ()) -> String, }
fat pointers는 포인터 크기를 늘리지만(원자적 연산에 사용할 수 없게 만듦) 이점은 상당합니다.
- 기존 유형에 대해 트레이트를 구현할 수 있습니다(예: 블랭킷 구현).
- vtable에서 메서드를 호출하려면 한 수준의 간접 참조만 필요합니다. 대조적으로 C++에서는 vtable이 객체 내부에 있으므로 각 함수 호출에는 다음과 같은 두 수준의 간접 참조가 포함됩니다.
object pointer --> object contents --> vtable --> DynamicType::method() implementation
트레이트에 상속 관계가 있는 경우 vtable은 여러 트레이트의 메서드를 어떻게 저장합니까? 현재 구현에서는 모든 메서드가 해당 메서드가 속한 트레이트 간의 구분 없이 단일 vtable에 순차적으로 저장됩니다.
Trait Object
+---------------+
| data | <------------ | data |
+---------------+
| vtable | ------------> +---------------------+
+------------------+ | destructor |
| +---------------------+
| size |
+---------------------+
| align |
+---------------------+
| base.fn1 |
+---------------------+
| base.fn2 |
+---------------------+
| subtrait.fn1 |
+---------------------+
| ...... |
+---------------------+
보시다시피 모든 트레이트 메서드는 어떤 메서드가 어떤 트레이트에 속하는지 구분 없이 순서대로 저장됩니다. 이것이 업캐스팅이 불가능한 이유입니다. 이 문제를 추적하는 진행 중인 RFC—RFC 2765—가 있습니다. 여기서 RFC에서 제안한 해결책을 논의하는 대신 AsBase
트레이트를 추가하여 더 일반적인 해결 방법을 소개하겠습니다.
trait Base { fn base(&self) { println!("base..."); } } trait AsBase { fn as_base(&self) -> &dyn Base; } // 블랭킷 구현 impl<T: Base> AsBase for T { fn as_base(&self) -> &dyn Base { self } } trait Foo: AsBase { fn foo(&self) { println!("foo.."); } } #[derive(Debug)] struct MyStruct; impl Foo for MyStruct {} impl Base for MyStruct {} fn main() { let s = MyStruct; let foo: &dyn Foo = &s; foo.foo(); let base: &dyn Base = foo.as_base(); base.base(); }
다운캐스팅
다운캐스팅은 트레이트 객체를 원래의 구체적인 유형으로 다시 변환하는 것을 의미합니다. Rust는 이를 위해 Any
트레이트를 제공합니다.
pub trait Any: 'static { fn type_id(&self) -> TypeId; }
'static
참조가 아닌 참조를 포함하는 유형을 제외하고 대부분의 유형은 Any
를 구현합니다. type_id
를 사용하여 런타임에 유형을 확인할 수 있습니다. 다음은 예입니다.
use std::any::Any; trait Greeting { fn greeting(&self) -> &str; fn as_any(&self) -> &dyn Any; } struct Cat; impl Greeting for Cat { fn greeting(&self) -> &str { "Meow!" } fn as_any(&self) -> &dyn Any { self } } fn main() { let cat = Cat; let g: &dyn Greeting = &cat; println!("greeting {}", g.greeting()); // &Cat로 변환 let downcast_cat = g.as_any().downcast_ref::<Cat>().unwrap(); println!("greeting {}", downcast_cat.greeting()); }
여기서 핵심은 downcast_ref
이며, 구현은 다음과 같습니다.
pub fn downcast_ref<T: Any>(&self) -> Option<&T> { if self.is::<T>() { unsafe { Some(&*(self as *const dyn Any as *const T)) } } else { None } }
보시다시피 유형이 일치하면 트레이트 객체의 데이터 포인터(첫 번째 포인터)가 unsafe
코드를 사용하여 구체적인 유형의 참조로 안전하게 캐스팅됩니다.
객체 안전성
Rust에서는 모든 트레이트를 트레이트 객체로 사용할 수 있는 것은 아닙니다. 사용하려면 트레이트가 특정 조건을 충족해야 합니다. 이를 객체 안전성이라고 합니다. 주요 규칙은 다음과 같습니다.
-
트레이트 메서드는
Self
(즉, 구현 유형)를 반환할 수 없습니다. 객체가 트레이트 객체로 변환되면 원래 유형 정보가 손실되므로Self
가 불확실해지기 때문입니다. -
트레이트 메서드는 제네릭 매개변수를 가질 수 없습니다. 단형화는 많은 수의 함수 구현을 생성하여 트레이트 내부의 메서드를 부풀릴 수 있기 때문입니다. 예를 들면 다음과 같습니다.
trait Trait { fn foo<T>(&self, on: T); // 더 많은 메서드 } // 10개의 구현 fn call_foo(thing: Box<Trait>) { thing.foo(true); // 위의 10개 유형 중 하나일 수 있습니다. thing.foo(1); thing.foo("hello"); } // 10 * 3 = 30개의 서로 다른 구현이 생성됩니다.
- 트레이트 객체로 사용되는 트레이트는
Sized
를 상속(트레이트 바운드)해서는 안 됩니다. Rust는 트레이트 객체가 해당 트레이트를 구현한다고 가정하고 다음과 같은 코드를 생성합니다.
trait Foo { fn method1(&self); fn method2(&mut self, x: i32, y: String) -> usize; } // 자동 생성된 impl impl Foo for TraitObject { fn method1(&self) { // `self`는 `&Foo` 트레이트 객체입니다. // 올바른 함수 포인터를 로드하고 불투명 데이터 포인터로 호출합니다. (self.vtable.method1)(self.data) } fn method2(&mut self, x: i32, y: String) -> usize { // `self`는 `&mut Foo` 트레이트 객체입니다. // 위와 동일하게 다른 인수를 전달합니다. (self.vtable.method2)(self.data, x, y) } }
Foo
가 Sized
를 상속하는 경우 트레이트 객체도 Sized
여야 합니다. 그러나 트레이트 객체는 DST(동적으로 크기가 결정되는 유형)이므로 ?Sized
이며 따라서 제약 조건이 실패합니다.
객체 안전성을 위반하는 안전하지 않은 트레이트의 경우 가장 좋은 방법은 객체 안전 формы으로 리팩터링하는 것입니다. 그것이 불가능하다면, 제네릭을 사용하는 것이 대안적인 해결책입니다.
결론
이 기사의 시작 부분에서 트레이트는 제로 코스트 추상화의 기초라고 소개했습니다. 트레이트를 사용하면 기존 유형에 새 메서드를 추가하고, 표현식 문제를 해결하고, 연산자 오버로딩을 활성화하고, 인터페이스 지향 프로그래밍을 허용할 수 있습니다. 이 기사가 독자들에게 트레이트를 효과적으로 사용하는 방법에 대한 확실한 이해를 제공하고 Rust에서 트레이트를 사용하여 작업할 때 컴파일러 오류를 쉽게 처리할 수 있는 자신감을 주기를 바랍니다.
Rust 프로젝트 호스팅을 위한 최고의 선택, Leapcell입니다.
Leapcell은 웹 호스팅, 비동기 작업 및 Redis를 위한 차세대 서버리스 플랫폼입니다.
다국어 지원
- Node.js, Python, Go 또는 Rust로 개발하세요.
무료로 무제한 프로젝트 배포
- 사용량에 따라서만 비용을 지불하세요. 요청, 요금은 없습니다.
탁월한 비용 효율성
- 사용한 만큼 지불하고 유휴 요금은 없습니다.
- 예: $25는 평균 응답 시간 60ms에서 694만 요청을 지원합니다.
간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI.
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합.
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅.
간편한 확장성 및 고성능
- 고도의 동시성을 쉽게 처리하기 위한 자동 확장.
- 제로 운영 오버헤드 - 구축에만 집중하세요.
설명서에서 자세히 알아보세요!
X에서 저희를 팔로우하세요: @LeapcellHQ