Rust에서 타입 수준 가변 제네릭의 개념과 도입 동기, 활용 예시, 문법·설계상의 쟁점, 그리고 다른 언어와의 비교를 다룬다.
여기에서 사용하는 문법의 대부분은 가상의(가정된) 것입니다.
가변 제네릭은 가변 함수와 비슷하지만 타입 시스템 수준에서 동작해, Rust의 항목이 임의 개수의 제네릭 타입을 가질 수 있게 합니다.
fn default<..T:Default>() -> (..T) {
for type T in ..T {
(..T::default())
}
}
assert_eq!(default(),());
assert_eq!(default<usize>(),0);
assert_eq!(default<usize>(),(0));
assert_eq!(default<usize,bool>(),(0,false));
왜 굳이 필요할까요? 이 기능 없이도 Rust는 지금까지 잘 굴러왔습니다. 다만 가변 제네릭이 있으면 해결될 수 있는, 아주 비(非)관용적인 구현 사례들이 몇 가지 있습니다.
제가 가장 먼저 떠올린 것은 Bevy의 시스템입니다. Bevy에서는 시스템 파라미터로 변환될 수 있는 매개변수를 가진 어떤 함수든 시스템입니다. 이를 구현하려면 손으로 직접 작성해야 합니다.
trait System<T> {}
impl<F: FnMut()> System<()> for F {}
impl<F: FnMut(T1), T1: 'static> System<(T1,)> for F{}
impl<F: FnMut(T1, T2), T1: 'static, T2: 'static> System<(T1, T2)> for F {}
이게 한 번으로 끝나는 일이라면 큰 수고는 아니겠지만, 이런 항목이 많은 크레이트를 상상해 보세요. 또한 어디까지 지원할지에 따라 한계가 생깁니다. 보통 10개 같은 꽤 큰 수까지 지원하면 대부분의 사용 사례를 덮지만, 가변 제네릭이 있으면 임의 개수의 매개변수를 지원하면서도 훨씬 더 적은 코드를 작성할 수 있습니다.
trait System<T> { }
impl<F:FnMut(T),..T:'static> System<(..T)> for F { }
요컨대, 임의 개수의 필드를 갖는 튜플이나 튜플과 유사한 항목과 관련된 거의 모든 것은 이로부터 이득을 볼 수 있습니다.
사실 Rust에는 튜플과 유사한 문법을 사용하는 것들이 정말 많습니다. 다음은 가변 제네릭으로 확장하거나 “더 낫게” 만들 수 있는 몇 가지 예시입니다:
std::cmp::min (또는 max) 확장// 현재
fn min<T:Ord>(v1:T,v2:T)
assert_eq!(
min(
min(
min(1,2),
3
),
4
),
1
);
// 가변 제네릭 사용
fn min<..T:Ord>(items:..T)
assert_eq!(min(1,2,3,4,5,6),1);
// 현재
pub fn zip<A, B>(
a: A,
b: B,
) -> Zip<<A as IntoIterator>::IntoIter, <B as IntoIterator>::IntoIter>
where
A: IntoIterator,
B: IntoIterator,
// 가변 제네릭 사용
pub fn zip<..I: IntoIterator>(items: ..I) -> Zip<..I>
Fn 트레이트에서 extern 호출 제거// 현재
pub trait FnOnce<Args: Tuple> {
type Output;
extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
}
// 새 제안
pub trait FnOnce<..Args> {
type Output;
fn call_once(self, args: ..Args) -> Self::Output;
}
여러 해에 걸쳐 가변 제네릭에 대한 여러 초안이 제안되었지만, 아직 확정된 것은 많지 않습니다. 정확히 어떤 기능을 도입할지에 관해 답이 나지 않은 질문과 해결되지 않은 논쟁이 정말 많습니다. 우선 사용할 문법에 대한 합의조차 현재는 없습니다. 흔히 제안되는 문법은 ..T, ...T, T.., T... 등이 있습니다.
이 기능이 제대로 작동하려면 어떤 형태로든 가변 튜플이 필요할 가능성이 큽니다. 즉, 항목을 임의 개수만큼 담을 수 있는 튜플입니다. 이는 특히 반환 타입에 유용합니다. 모든 가변 반환 타입은 튜플이어야 하며, 리스트는 항목 타입이 균질(homogeneous)이므로 해당하지 않습니다.
let any: (..i32) = (10,20,50,-255);
여러 타입이 지원된다면 가변 라이프타임도 지원해야 할까요? 이 경우 각 타입은 고유한 라이프타임을 가지게 됩니다. 예를 들어 슬라이스를 순회하는 함수를 생각해 보세요. 가변 제네릭만 있으면 각 슬라이스는 같은 라이프타임을 가져야 하며, 이는 경우에 따라 제약이 될 수 있습니다. 가변 라이프타임이 있으면 각 슬라이스가 서로 다른 라이프타임을 가질 수 있어 호출자에게 더 많은 자유를 줍니다.
// 가변 기능 없음
pub fn zip_slice<'a,'b,A,B>(s1:&'a [A],s2: &'b [B])
-> impl Iterator<Item=(&'a A,&'b B)>;
// 가변 제네릭
pub fn zip_slice<'a,..T>(slices: &'a [..T],)
-> impl Iterator<Item=(&'a ..T)>;
// 가변 제네릭 + 가변 라이프타임
pub fn zip_slice<..'a,..T>(slices: &..'a [..T],)
-> impl Iterator<Item=(&..'a ..T)>;
이 기능이 const 제네릭과 어떻게 상호작용할지, 혹은 상호작용이 있기나 할지조차 아직 명확하지 않습니다.
가변 항목들은 반드시 순회(반복)되어야 합니다. 그렇다면 타입 자체에 대해서도 순회가 가능해야 할까요? 가능하다면 문법은 어떻게 될까요? 이것은 상수 시점의 연산이므로, 이를 나타내기 위해 static 또는 const 선언이 필요할 수도 있습니다.
fn default_all<..Ts:Default>() -> (..T){
for static T in Ts {
T::default();
}
}
대부분, 아니 거의 모든 문제는 매크로로 해결할 수 있습니다. 작성하기 불편하더라도요. 심지어 코드를 그냥 손으로 쓰는 것도 가능합니다. 매크로는 임의 개수의 Rust 토큰을 받기 때문에, 가변 제네릭의 모든 사용 사례를 포괄합니다. 결국 인체공학적 이점이 이 기능을 구현하는 복잡성을 상쇄할 수 있느냐에 달려 있습니다.
macro_rules! impl_system {
($($T:ident),*) => {
impl<F, $($T: 'static),*> System(($($T,)*)) for F
where F: FnMut($($T),*) { }
};
}
impl_system!(T1);
impl_system!(T1, T2);
impl_system!(T1, T2, T3);
가변성은 매우 인기 있는 기능이라 대부분의 언어가 가변 함수를 갖고 있습니다. 그러나 가변 제네릭(또는 동등한 기능)을 가진 언어는 많지 않습니다. 가변 함수를 가진 대부분의 언어에서는 언어가 동적 타입이거나, 가변 인자(varargs)가 모두 같은 타입이어야 합니다. 다음은 가변 제네릭 또는 대략적으로 동등한 기능을 가진 언어들입니다: