Rust에서 Generic 관련 타입 알아가기
Ethan Miller
Product Engineer · Leapcell

일반 관련 타입(GAT)에 대한 약간의 통찰력
이름이 너무 길어요! 이게 대체 뭐죠?
걱정하지 마세요. 처음부터 차근차근 알아봅시다. Rust 구문 구조부터 복습해 보겠습니다. Rust 프로그램을 구성하는 것은 무엇일까요? 답은 _아이템_입니다.
모든 Rust 프로그램은 개별 아이템으로 구성됩니다. 예를 들어 main.rs
에서 구조체를 정의하고, 두 개의 메서드가 있는 impl
블록을 추가하고, 마지막으로 main
함수를 작성하면 이들은 크레이트의 모듈 아이템 내에 있는 세 가지 아이템입니다.
이제 _아이템_에 대해 다루었으니, _관련 아이템_에 대해 이야기해 보겠습니다. 관련 아이템은 독립적인 아이템이 아닙니다! 핵심은 "관련된"이라는 단어에 있습니다. 무엇과 관련되어 있을까요? 이것은 "특정 타입과 관련되어 있다"는 의미이며, 이 관련성을 통해 Self
라는 특별한 키워드를 사용할 수 있습니다. 이 키워드는 관련시키는 타입을 나타냅니다.
관련 아이템은 trait 정의의 중괄호 안이나 implementation 블록 내의 두 곳에서 정의할 수 있습니다.
관련 아이템에는 세 가지 종류가 있습니다: 관련 상수, 관련 함수, 관련 타입(별칭). 이들은 일반적인 Rust의 세 가지 아이템 타입인 상수, 함수, 타입(별칭)에 직접적으로 대응합니다.
예제를 살펴봅시다!
#![feature(generic_associated_types)] #![allow(incomplete_features)] const A: usize = 42; fn b<T>() {} type C<T> = Vec<T>; trait X { const D: usize; fn e<T>(); type F<T>; // ← 여기가 새로운 부분입니다! 이전에는 여기에 <T>를 쓸 수 없었습니다. } struct S; impl X for S { const D: usize = 42; fn e<T>() {} type F<T> = Vec<T>; }
그래서 이것의 용도는 무엇일까요?
매우 유용하지만 특정 상황에서만 그렇습니다. Rust 커뮤니티에는 일반 관련 타입에 대한 두 가지 고전적인 사용 사례가 있습니다. 소개해보도록 하겠습니다.
하지만 들어가기 전에 제네릭을 빠르게 복습해 보겠습니다. "제네릭"이라는 단어는 영어로 "general-purpose(다용도)"를 의미합니다. 그렇다면 제네릭 타입은 무엇일까요? 간단히 말해서 일부 매개변수가 누락된 타입입니다. 사용자가 채워 넣는 매개변수입니다.
참고: 이전 번역가들은 "generic"을 "泛型(일반 타입)"이라고 렌더링하는 것을 선택했는데, 이는 많은 시스템에서 타입에 대해 매개변수화할 수 있기 때문입니다. 하지만 Rust에서는 제네릭이 타입에만 국한되지 않습니다. 실제로 타입, 라이프타임, 상수의 세 가지 종류의 제네릭 매개변수가 있습니다.
자, 여기 제네릭 타입의 구체적인 예가 있습니다: Rc<T>
. 이것은 하나의 매개변수를 가진 제네릭 타입입니다. Rc
자체는 타입이 아니라는 점에 유의하세요. 타입 인수(예: Rc<bool>
의 bool
)를 제공해야만 실제 타입을 얻을 수 있습니다.
이제 데이터 구조를 작성하고 있는데 내부적으로 데이터를 공유해야 하지만 사용자가 Rc
를 사용할지 Arc
를 사용할지 미리 알 수 없다고 상상해 보세요. 어떻게 해야 할까요? 가장 간단한 방법은 코드를 두 번 작성하는 것입니다. 약간 서투르지만 작동합니다. 참고로 크레이트 im
과 im-rc
는 하나는 Arc
를 사용하고 다른 하나는 Rc
를 사용한다는 점을 제외하고는 거의 동일합니다.
사실, GAT는 이 문제를 해결하는 데 완벽합니다. 일반 관련 타입에 대한 첫 번째 고전적인 사용 사례인 타입 패밀리를 살펴봅시다.
작업 #1: GAT를 사용하여 타입 패밀리 지원
자, 컴파일러가 Rc<T>
또는 Arc<T>
를 사용할지 결정할 수 있도록 하는 "선택기"를 구축해 봅시다. 코드는 다음과 같습니다:
trait PointerFamily { type PointerType<T>; } struct RcPointer; impl PointerFamily for RcPointer { type PointerType<T> = Rc<T>; } struct ArcPointer; impl PointerFamily for ArcPointer { type PointerType<T> = Arc<T>; }
꽤 간단하죠? 이를 통해 Rc
또는 Arc
를 사용할지 여부를 나타내는 데 사용할 수 있는 두 개의 "선택기" 타입을 정의했습니다. 실제로 어떻게 작동하는지 살펴봅시다:
struct MyDataStructure<T, PointerSel: PointerFamily> { data: PointerSel::PointerType<T> }
이 설정에서는 제네릭 매개변수가 RcPointer
또는 ArcPointer
가 될 수 있으며, 이는 데이터의 실제 표현을 결정합니다. 덕분에 이전에 언급한 두 개의 크레이트를 하나로 병합할 수 있습니다.
작업 #2: GAT를 사용하여 스트리밍 Iterator 구현
또 다른 문제가 있습니다. 이 문제는 Rust에만 해당하는 문제입니다. 다른 언어에서는 이 문제가 존재하지 않거나 단순히 해결을 포기했습니다(흠).
문제는 다음과 같습니다: API에서 입력 값 사이 또는 입력과 출력 사이의 종속 관계를 나타내고 싶습니다. 이러한 종속성은 항상 표현하기 쉽지 않습니다. Rust의 해결책은 무엇일까요?
그 작은 라이프타임 마커 '_
- 우리 모두가 본 적이 있습니다. API 수준에서 이러한 종속성을 표현하는 데 사용됩니다.
실제로 작동하는 것을 살펴봅시다. 모든 사람이 표준 라이브러리의 Iterator
trait에 익숙할 것입니다. 다음과 같습니다:
pub trait Iterator { type Item; fn next(&'_ mut self) -> Option<Self::Item>; // ... }
훌륭해 보이지만 작은 문제가 있습니다. Item
타입은 Iterator
자체의 타입(Self
)에 전혀 의존하지 않습니다. 왜 그럴까요?
next
를 호출하면 임시 라이프타임 범위('_'
)가 생성되기 때문입니다. 이는 next
함수의 제네릭 매개변수입니다. 한편, Item
은 독립적인 관련 타입입니다. 해당 라이프타임에 연결할 방법이 없습니다.
대부분의 경우 이는 문제가 되지 않습니다. 그러나 일부 라이브러리에서는 이러한 표현력 부족이 실제 제한 사항이 됩니다. 사용자에게 임시 파일을 제공하는 iterator를 상상해 보세요. 언제든지 파일을 닫을 수 있습니다. 이 경우 Iterator
trait는 잘 작동합니다.
하지만 iterator가 임시 파일을 생성하고 일부 데이터를 로드한 다음 사용자가 작업을 완료한 후에 파일을 삭제해야 한다면 어떻게 될까요? 또는 다음 파일을 위해 저장 공간을 재사용하는 것이 더 좋을까요? 이 경우 iterator는 사용자가 해당 아이템 사용을 완료한 시점을 알아야 합니다.
이것이 바로 GAT가 유용한 곳입니다. GAT를 사용하여 다음과 같은 API를 설계할 수 있습니다:
pub trait StreamingIterator { type Item<'a>; fn next(&'_ mut self) -> Option<Self::Item<'_>>; // ... }
이제 구현에서 Item
을 참조와 같은 종속 타입으로 만들 수 있습니다. 타입 system은 next
를 다시 호출하거나 iterator를 삭제하기 전에 Item
값이 더 이상 사용되지 않도록 합니다.
너무 현실적으로만 말씀하셨네요 - 좀 더 추상적으로 말해 주실 수 있나요?
좋아요, 이제부터 인간의 언어를 사용하지 않겠습니다. (농담입니다. 하지만 지금부터는 추상적으로 진행하겠습니다.) 참고: 이 설명은 여전히 단순화되었습니다. 예를 들어 바인더와 술어는 제쳐두겠습니다.
먼저 제네릭 타입 생성자와 구체적인 타입 간의 관계를 설정해 보겠습니다. 기본적으로 이는 매핑입니다.
/// 의사 코드 fn generic_type_mapping(_: GenericTypeCtor, _: Vec<GenericArg>) -> Type;
예를 들어 Vec<bool>
에서 Vec
는 제네릭 타입의 이름이자 생성자입니다. <bool>
은 타입 인수 목록입니다. 이 경우에는 하나뿐입니다. 둘 다 매핑에 넣으면 특정 타입인 Vec<bool>
을 얻을 수 있습니다.
다음: trait. trait는 실제로 무엇일까요? trait는 또한 매핑입니다.
/// 의사 코드 fn trait_mapping(_: Type, _: Trait) -> Option<Vec<AssociateItem>>;
여기서 Trait
는 술어, 즉 타입에 대한 판단을 내리는 것으로 생각할 수 있습니다. 결과는 None
("이 trait를 구현하지 않음"을 의미) 또는 관련 아이템 목록과 함께 Some(items)
("이 타입이 trait를 구현함"을 의미)입니다.
/// 의사 코드 enum AssociateItem { AssociateType(Name, Type), GenericAssociateType(Name, GenericTypeCtor), // ← 여기가 새로운 부분입니다. AssociatedFunction(Name, Func), GenericFunction(Name, GenericFunc), AssociatedConst(Name, Const), }
이 중에서 AssociateItem::GenericAssociateType
는 현재 Rust에서 generic_type_mapping
이 간접적으로 호출되는 유일한 장소입니다.
서로 다른 Type
들을 trait_mapping
에 대한 첫 번째 매개변수로 전달하여 동일한 Trait
에서 다른 GenericTypeCtor
들을 얻을 수 있습니다. 그런 다음 generic_type_mapping
을 적용하고 짠 - 서로 다른 제네릭 타입 생성자를 특정 Vec<GenericArg>
인수와 결합했습니다. 모두 Rust의 구문 프레임워크 내에서요!
잠깐 옆길로 새서: GenericTypeCtor
와 같은 구문은 일부 문서에서 HKT - Higher-Kinded Types라고 부르는 것입니다. 위에 설명된 접근 방식 덕분에 Rust는 이제 처음으로 HKT에 대한 사용자 인터페이스 지원을 제공합니다. 이 형태에서만 나타나지만 다른 사용 패턴은 이를 기반으로 구축할 수 있습니다.
요약하자면: 이상한 새 힘이 잠금 해제되었습니다!
걷는 법 배우기: GAT로 고급 구문 모방하기
자, 마무리하기 위해 GAT를 사용하여 다른 언어의 일부 구문을 모방해 보겠습니다.
#![feature(generic_associated_types)] #![allow(incomplete_features)] trait FunctorFamily { type Type<T>; fn fmap<T, U, F>(value: Self::Type<T>, f: F) -> Self::Type<U> where F: FnMut(T) -> U; } trait ApplicativeFamily: FunctorFamily { fn pure<T>(inner: T) -> Self::Type<T>; fn apply<T, U, F>(value: Self::Type<T>, f: Self::Type<F>) -> Self::Type<U> where F: FnMut(T) -> U; } trait MonadFamily: ApplicativeFamily { fn bind<T, U, F>(value: Self::Type<T>, f: F) -> Self::Type<U> where F: FnMut(T) -> Self::Type<U>; }
이제 특정 "선택기"에 대해 이러한 trait를 구현해 보겠습니다.
struct OptionType; impl FunctorFamily for OptionType { type Type<T> = Option<T>; fn fmap<T, U, F>(value: Self::Type<T>, f: F) -> Self::Type<U> where F: FnMut(T) -> U, { value.map(f) } } impl ApplicativeFamily for OptionType { fn pure<T>(inner: T) -> Self::Type<T> { Some(inner) } fn apply<T, U, F>(value: Self::Type<T>, f: Self::Type<F>) -> Self::Type<U> where F: FnMut(T) -> U, { value.zip(f).map(|(v, mut f)| f(v)) } } impl MonadFamily for OptionType { fn bind<T, U, F>(value: Self::Type<T>, f: F) -> Self::Type<U> where F: FnMut(T) -> Self::Type<U>, { value.and_then(f) } }
자, 이제 OptionType
을 "선택기"로 사용하여 Option
의 동작을 Functor, Applicative, Monad로 표현하고 구현할 수 있습니다.
자 - 기분이 어떠세요? 완전히 새로운 가능성의 세계를 열어본 것 같나요?
Leapcell은 Rust 프로젝트 호스팅을 위한 최고의 선택입니다.
Leapcell은 웹 호스팅, 비동기 작업 및 Redis를 위한 차세대 서버리스 플랫폼입니다.
다국어 지원
- Node.js, Python, Go 또는 Rust로 개발하세요.
무료로 무제한 프로젝트 배포
- 사용량에 대해서만 비용을 지불하세요. 요청도, 요금도 없습니다.
탁월한 비용 효율성
- 유휴 요금 없이 사용량에 따라 지불하세요.
- 예: 25달러로 평균 응답 시간 60ms에서 694만 건의 요청을 지원합니다.
간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI.
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합.
- 실행 가능한 통찰력을 위한 실시간 지표 및 로깅.
손쉬운 확장성 및 고성능
- 고도의 동시성을 쉽게 처리하기 위한 자동 확장.
- 운영 오버헤드가 없으므로 구축에만 집중하세요.
문서에서 자세히 알아보세요!
X에서 팔로우하세요: @LeapcellHQ