C++의 제네릭 람다와 Rust의 클로저를 비교하고, Rust에서 SAM(단일 추상 메서드) 트레이트를 통해 제네릭 클로저의 요구를 충족시키는 문법과 설계를 제안하며, 제약과 구현상의 난점, 그리고 향후 확장 가능성을 논의한다.
Rust와 C++는 각각의 “익명 함수” 표현식에 대해 매우 비슷한 동작 의미론을 갖습니다(각각 이를 “클로저”와 “람다”라고 부릅니다; 여기서는 두 용어를 서로 바꿔 써 사용하겠습니다). 다음은 그 표현식의 모습입니다.
auto square = [](int x) { return x * x; }
C++
let square = |x: i32| x * x;
Rust
두 경우 모두에서 square의 타입은 해당 클로저의 캡처를 담는 익명 타입입니다. C++에서는 이 타입이 호출에 사용할 수 있는 operator() 멤버를 제공하고, Rust에서는 캡처에 따라 FnOnce(그리고 필요하다면 FnMut, Fn)를 구현하여 “호출 가능한(callable)” 객체를 표현합니다.
이 글에서는 편의상, “함수 아이템 값(function item value)”을 입력과 출력이 명시된 클로저와 사실상 동일한 것으로 간주하겠습니다. 엄밀히는 정확하지 않습니다. 예컨대
let x = drop;라고 쓰면 결과 객체는 제네릭이지만, 여기서 Rust에서 “클로저”라고 말할 때는 이러한 클로저 유사 타입들도 포함한다고 보겠습니다.
C++ 클로저가 표현할 수 있지만 Rust 클로저가 할 수 없는 것이 하나 있습니다. Rust에서는 “제네릭” 클로저를 만들 수 없습니다. 특히 C++에서는 다음과 같은 코드를 쓸 수 있습니다.
template <typename Fn>
size_t CallMany(Fn fn) {
return fn(std::vector{5}) + fn(std::string("foo"));
}
CallMany([](auto& val) { return val.size(); });
C++
C++의 클로저에서 auto 키워드는 Rust와 다르게 동작합니다. Rust에서 “동등한” 코드로 let x = |val| val.len();을 단독으로 작성하면, 다음과 같은 에러를 보게 됩니다.
error[E0282]: type annotations needed
--> <source>:4:12
|
4 | let x = |val| val.len();
| ^^^ --- type must be known at this point
|
help: consider giving this closure parameter an explicit type
|
4 | let x = |val: /* Type */| val.len();
| ++++++++++++
Rust
이는 Rust에서 타입 표기가 없는 클로저 인자는 “이게 무엇이어야 하는지 추론해 달라”는 뜻이므로, Rust의 타입 추론에 참여하기 때문입니다. 반면 C++에서 클로저의 auto 인자는 “operator()의 템플릿 매개변수로 만들어라”는 뜻입니다.
그렇다면 Rust에서 CallMany를 어떻게 구현할 수 있을까요? 시도해 보면 곧바로 문제에 부딪힙니다.
trait Len {
fn len(&self) -> usize;
}
fn call_many(f: impl Fn(???) -> usize) {
f(vec![5]) + f("foo")
}
Rust
???에는 무엇을 넣어야 할까요? call_many의 타입 매개변수로 둘 수는 없습니다. 함수 본문에서 구체적인 값으로 호출되고 있기 때문이죠. 우리는 Fn이 len을 구현하는 임의의 인자를 받을 수 있기를 원합니다. 이를 표현할 문법조차 없습니다만, 타입에도 적용되는 for<...> 류의 구문이 있다고 상상해 다음처럼 쓸 수 있을 것입니다.
trait Len {
fn len(&self) -> usize;
}
fn call_many(f: impl for<T: Len> Fn(&T) -> usize) -> usize {
f(vec![5]) + f("foo")
}
Rust
가상의 구문 for<T: Len> Fn(&T) -> usize는 “Len을 구현하는 모든 타입 T에 대해 Fn을 구현한다”는 뜻입니다. 이는 러스트 컴파일러가 증명하기에 상당히 강력한 요구입니다. 불가능한 것은 아니지만, 구현은 어렵습니다.
이 글에서는
for<T>를 가능성은 낮더라도 있을 법한 언어 기능으로 간주하겠습니다. 이 기능이 언젠가 반드시 들어갈 것이라 가정하지도, 반대로 영원히 포기해야 한다고 단정하지도 않겠습니다. 이어지는 논의에서 이 기능을 “불가능하게” 만들어 버리지 않도록, 일부러 이 불확실성의 중간 지점을 유지합니다.
대폭 단순화한 Fn 트레이트를 살펴봅시다.
pub trait Fn<Args> {
type Output;
fn call(&self, args: Args) -> Self::Output;
}
Rust
Fn::call은 C++의 operator()에 해당합니다. 우리가 “제네릭 클로저”를 원한다고 말할 때는, 사실 아래와 같은 형태의 트레이트를 원한다는 뜻입니다.
pub trait Fn {
type Output<Args>;
fn call<Args>(&self, args: Args) -> Self::Output<Args>;
}
Rust
여기서는 Args가 트레이트 매개변수에서 함수의 제네릭 매개변수로 옮겨졌고, Output이 이에 의존하게 되었습니다. 이는 앞서 설명한 것과는 약간 다른 정식화입니다. 더 이상 무한히 많은 트레이트 구현을 요구하는 대신, 제네릭 메서드를 가진 하나의 트레이트 구현을 요구하고 있기 때문입니다.
우리의 구체적 예제에서는 다음과 같은 것이 필요합니다.
trait Len {
fn len(&self) -> usize;
}
trait Callback {
fn run(&self, val: impl Len) -> usize;
}
fn call_many(f: impl Callback) -> usize {
f.run(vec![5]) + f.run("foo")
}
Rust
이 코드는 컴파일되며, 우리가 원하는 바를 정확히 표현합니다. 즉 f를 임의의 impl Len 타입에 대해 호출하고 싶다는 것입니다.
하지만 call_many를 어떻게 호출할까요? 여기서부터 모양새가 썩 좋지 않게 됩니다.
struct CbImpl;
impl Callback for CbImpl {
fn run(&self, val: impl Len) -> usize {
val.len()
}
}
call_many(CbImpl);
Rust
이 패턴은 정말, 아주, 못생겨질 소지가 있습니다. 최근에 할당 없는(visitor) 방문자를 작성하면서 이 패턴을 썼는데, 보기 좋지 않았습니다. 보일러플레이트를 줄이기 위해 매크로를 작성해야 했습니다.
macro_rules! resume_by {
($parser:expr, $cb:expr) => {{
struct Cb<'a, 's> {
parser: &'a Parser<'s>,
start: Option<u32>,
}
impl<'s> Resume<'s> for Cb<'_, 's> {
fn resume(
&mut self,
visitor: &mut impl Visitor<'s>,
) -> Result<(), Error> {
self.parser.do_with_rewind(
&mut self.start,
|| ($cb)(self.parser, &mut *visitor),
)
}
}
Cb { parser: $parser, start: None }
}};
}
Rust
예상대로, 이 매크로는 꽤 삐걱거립니다. 또한 실제 코드를 담은 $cb 인자가 중첩된 impl 내부에 파묻혀 있기 때문에, 제대로 된 캡처도 할 수 없습니다.
“음 Miguel, 그럼 $cb를 Cb 구조체로 끌어올리면 되잖아?”라고 생각할 수 있습니다. 하지만 그러면 이제 Resume::resume 본문에서 콜백을 실제로 호출할 수 있도록 impl<'s, F: FnMut(&Parser<'s>, ???)> 같은 것을 써야 하는데, 이건 글 맨 앞에서 본 그 트레이트 경계 문제로 되돌아갑니다!
이 부류의 해결책에는 일반적인 문제가 있습니다. 구현하려는 메서드가 제네릭이라면, 임의의 클로저를 캡처하여 그 클로저를 호출하는 방식으로 트레이트 구현을 만들어 내는 매크로는 존재할 수 없습니다. 만약 가능했다면, 이런 매크로 자체가 필요 없었을 테니까요.
Java는 종종 나쁜 평을 듣지만, 핵심 언어에는 흥미로운 기능들이 있습니다. 그중 하나가 바로 익명 클래스(anonymous class)입니다.
무언가에 콜백을 넘기고 싶다고 합시다. 제가 자라면서 썼던 Java 6에서는 이렇게 했습니다.
public interface Callback {
int run(int arg);
}
public int runMyThing(Callback cb) {
return cb.run(42);
}
runMyThing(new Callback() {
public int run(int arg) { return arg * arg; }
});
Java
new Interface() { ... } 구문은 그 자리에서 Interface를 구현하는 새 클래스를 하나 만들어 냅니다. 타입 이름 뒤의 중괄호 사이에 일반적인 클래스 본문을 제공하면 됩니다. 클래스 타입에도 같은 일을 할 수 있습니다.
다만 이건 조금 번거롭습니다. 메서드가 하나뿐인데도 그 시그니처를 다시 타이핑해야 하니까요. 여러 메서드를 구현해야 한다면 괜찮겠지만, 한 메서드만 구현하는 경우에는 다소 귀찮습니다.
Java 8에서는 람다(문법: x -> expr)가 도입되었습니다. Java는 흥미롭게도, “람다의 타입”이 되는 Function 같은 새 타입을 추가하지 않기로 했습니다. 한동안 저는 이를 어정쩡한 타협이라고 생각했지만, 이제는 언어 설계의 걸작으로 보게 되었습니다.
대신 Java의 람다는 이 익명 클래스 문법 위에 얹힌 일종의 문법 설탕입니다.1 즉, 람다는 반드시 단일 추상 메서드만 가진 인터페이스 타입에 대입되어야 하며, 그 람다의 본문이 그 한 메서드를 구현하는 데 사용됩니다.
람다와 호환되는 인터페이스를 단일 추상 메서드(SAM, Single Abstract Method) 인터페이스라고 부릅니다.
그래서 기존 라이브러리를 건드리지 않고도, new 구문을 이렇게 바꿀 수 있습니다.
runMyThing(x -> x * x);
Java
완벽하다
물론 Java는 java.util.functional 패키지에 “표준 함수 인터페이스”를 한가득 제공하고, 표준 라이브러리의 상당 부분이 이를 사용합니다. 하지만 이들만으로 객체로 캡처하고 싶은 모든 종류의 함수를 전부 표현할 필요는 없습니다.
이러한 “SAM 클로저”는 클로저에 강력한 “인터페이스는 가져오세요(BYO interface)” 특성을 부여합니다. Java의 람다는 “함수 객체”가 아니라, 해당 인터페이스를 구현하는 매우 가벼운 익명 클래스입니다.
이 방식이라면 Rust에서도 제네릭 클로저의 매듭을 단칼에 끊을 수 있다고 생각합니다.
이제부터는, 클로저가 본래 구현하는 트레이트들에 더해, 임의의 SAM 트레이트도 구현하도록 확장하는 방법을 제안하겠습니다.
Rust에서 SAM 트레이트란 무엇일까요? 기본 구현이 없는 메서드가 정확히 “하나”인 임의의 트레이트 T로서, 다음의 제약을 만족해야 합니다.
Self, &Self, &mut Self 중 하나여야 합니다.where 절의 어떤 부분에서도 Self를 언급하지 않아야 합니다.Copy, Send, 또는 Sync여야 합니다.이 제약들은 전체 트레이트를 실제로 구현할 수 있도록 하기 위해 선정했습니다.
Fn트레이트들 외에도, 일반 클로저는 상황에 따라Clone,Copy,Send,Sync를 자동으로 구현합니다.이들 트레이트는 SAM이 아니므로, 동일한 규칙 하에서 SAM 클로저에도 자동으로 파생되도록 허용해도 안전합니다.
SAM 클로저를 요청하기 위한 잠정 문법으로 impl Trait |args| expr를 사용하겠습니다. 이 문법은 경로-내-타입(path-in-type)에는 |가 등장할 수 없고, impl $path 다음에는 반드시 {, for, 또는 where가 와야 하므로, impl 아이템이 아닌 “표현식”임이 명확합니다. 정확한 문법 자체는 중요하지 않습니다.
위의 call_many 예제에 적용하면 이렇게 됩니다.
fn call_many(f: impl Callback) -> usize {
f.run(vec![5]) + f.run("foo")
}
call_many(impl Callback |x| x.len());
Rust
컴파일러는 이를 대략 다음과 같이 다시 써 줍니다.
fn call_many(f: impl Callback) -> usize {
f.run(vec![5]) + f.run("foo")
}
struct CallbackImpl;
impl Callback for CallbackImpl {
fn run(&self, x: impl Len) -> usize {
x.len()
}
}
call_many(CallbackImpl);
Rust
이 재기록은 타입 추론이 x의 타입을 알아내기 전에 비교적 이른 단계에서 수행될 수 있습니다. 또한 이 트레이트의 캡처가 &self 수신자와 호환되는지도 확인해야 합니다. 일반 클로저에서 Fn, FnMut, FnOnce 중 어떤 것을 구현하는지를 결정하는 동일한 규칙이, 세 가지 수신자 타입 중 어떤 것과 호환되는지를 결정합니다.
SAM 클로저는 어떤 Fn 트레이트도 구현하지 않는다는 점에 유의하세요.
원하는 트레이트의 이름은 반드시 명시해야 하지만, 그 타입 매개변수는 비워 둘 수 있습니다. 예를 들어:
pub trait Tr<T> {
type Out;
fn run(x: T, y: impl Display) -> Option<Self::Out>;
}
// 여기서는 `T = i32`, `Tr::Out = String`으로 추론할 수 있습니다.
let tr = impl Tr<_> |x: i32, y| Some(format!("{}, {}"));
Rust
일반적으로, 지정되지 않은 매개변수와 연관 타입은 추론 변수로 남고, 이는 Fn 클로저의 매개변수들이 해소되는 방식과 동일하게 결정됩니다.
사실, SAM 클로저를 사용해 일반 클로저를 흉내 낼 수도 있습니다.
let k = 100;
let x = Some(42);
let y = x.map(impl FnOnce(_) -> _ move |x| x * k);
Rust
Fn과 FnMut에는 사소하지 않은(supertrait가 비자명한) 상위 트레이트가 있으므로, SAM 클로저로 이들을 직접 만들어 낼 수는 없다는 점에 유의하세요.
한 가지 응용으로, std::iter::from_fn을 완전히 대체할 수 있습니다.
fn fibonacci() -> impl Iterator<Item = u64> + Copy {
let state = [1, 1];
impl Iterator move || {
let old = state[0];
state[0] = state[1];
state[1] += ret;
ret
}
}
Rust
또는 Debug의 간이 구현이 필요하다면…
impl fmt::Debug for Thing {
fn fmt(&self, f: fmt::Formatter) -> fmt::Result {
f.debug_list()
.entry(&impl Debug |f| {
write!(f, "something something {}", self.next_thingy())
})
.finish();
}
}
Rust
SAM 트레이트에 추가 제약을 더 두는 편이 좋을지도 모르지만, 그 범위가 어디까지인지 즉시 분명하지는 않습니다. 예컨대, 아마 다음과 같은 것은 지원하려고 해서는 안 될 것입니다.
trait UniversalFactory {
fn make<T>() -> T;
}
let f = impl UniversalFactory || {
// T의 이름을 어떻게 붙여서 size_of에 넘기죠?
};
Rust
이걸 가능하게 만드는 영리한 트릭들이 분명 있겠지만, 실익은 별로 커 보이지 않습니다.
이 개념을 확장하는 길은 두 가지가 있습니다. 하나는 단순하고 바람직하며, 다른 하나는 아마 구현 불가능할 것입니다.
Java의 람다에 해당하는 것보다 한 걸음 물러나 생각하면, 캡처를 만들 수 있는 완전한 형태의 impl의 “표현식 버전”을 갖는 것도 그리 무리한 일로 보이지 않습니다.
문법적으로는 impl Trait for { ... }를 사용하겠습니다. 이는 현재로서는 모호하지 않습니다. 다만 {가 타입의 시작이 될 수 없도록 만드는 것은 아마 받아들여지기 어려울 것입니다.
살짝 복잡한 것을 골라 봅시다… 예를 들어, 메서드를 재정의(overridden)한 Iterator 같은 것 말이죠. 그러면 다음과 같이 쓸 수 있을 겁니다.
let my_list = &foo[...];
let mut my_iterator = impl Iterator for {
type Item = i32;
fn next(&mut self) -> Option<i32> {
let head = *my_list.get(0)?;
my_list = &my_list[1..];
Some(head)
}
fn count(self) -> usize {
my_list.len();
}
};
Rust
for 뒤 중괄호의 내용은 아이템 목록이며, 바깥의 변수들이 캡처의 의미로 사용 가능합니다. 즉, self. 접두사 없이 self에 대한 접근처럼 동작합니다.
본문의 함수들이 갖는 self 타입과 이것이 정확히 어떻게 상호작용할지 다듬는 일은… 복잡해 보입니다. 충분히 가능하되, 성가신 작업일 겁니다. 또한 여기서 Self가 정확히 무엇인지, 그리고 어느 정도까지 상호작용을 허용할 것인지에 대한 난감한 질문들도 있습니다.
가령 우리가 그냥 impl |x| x * x처럼만 쓰고, 컴파일러가 어떤 트레이트를 원한 것인지 알아서 추론해 준다고 해 봅시다(심지어 이를 기본 동작으로 만들어 impl 키워드도 생략한다고 치죠).
이렇게 하면, 단지 다음처럼 쓸 수 있습니다.
fn call_many(f: impl Callback) -> usize {
f.run(vec![5]) + f.run("foo")
}
call_many(impl |x| x.len());
Rust
하지만 금세 곤란해집니다.
trait T1 {
fn foo(&self);
}
trait T2 {
fn foo(&self);
}
impl<T: T1> T2 for T {
fn foo(&self) {
println!("not actually gonna call T1::foo() lmao");
}
}
let x = || println!("hello");
T2::foo(&x); // 무엇을 출력해야 할까요?
Rust
x의 타입이 직접 T2를 구현한다고 결정되면 "hello"를 출력합니다. 하지만 T1을 구현한다고 결정되면, 포괄적(블랭킷) 구현 때문에 T2의 구현이 다른 것이 되어, "hello"가 출력되지 않습니다. 둘 다 구현한다고 하면… 코히어런스(coherence) 위반이 됩니다.
현재 rustc는 “요청 시(on demand)”로 impl을 생성할 필요가 없습니다. 트레이트 솔버는 살펴볼 수 있는 유한한 집합의 impl만 가지고 있습니다. 우리가 트레이트 솔버에 요구하는 것은, 특정 타입들에 대해 사용처에 기반하여 impl을 구체화(reify)하려는 시도입니다. 즉, 불투명한 클로저 타입 T가 있고, 컴파일러가 T: Foo 경계를 증명해야 한다고 판단하면, 이제 해당 impl이 있는지 검증하기 위해 타입 체킹을 수행해야 합니다.
이는 현재 솔버의 작동 방식으로는 구현 불가능해 보입니다. 불가능이라 단정할 수는 없지만, 매우 어려울 것입니다.
완전히 터무니없는 수준이 아닌 완화책이 있을 수는 있습니다. 예를 들어 impl || 표현식이 우연히 제네릭인 함수의 인자를 초기화하는 데 쓰였고, 우리가 그 타입 변수의 경계를 훔쳐와서 그것이 SAM인지 확인할 수 있다든지요. 하지만 현실적으로 이 방향은 얻는 것보다 잃는 것이 더 커 보입니다.
C++의 제네릭 람다는 매우 강력하며, 세련된 API 설계를 가능하게 해 줍니다. 저는 Rust에서 종종 그것이 그립습니다. 비록 넘기 어려운 장애물처럼 느껴지지만, SAM 인터페이스 접근법이 이를 Rust에서 작동하게 만드는 더 단순하고 실용적인 방법을 제공하길 바랍니다.
new T() {}가 완전히 새로운 클래스와 그에 따른 .class 파일을 만들어 내는 반면, Java 람다는 Java 7에서 도입된 복잡한 장치를 사용해, invokedynamic JVM 명령을 통해 런타임에 메서드 핸들을 생성합니다. 그래서(제게 들은 바로는) 최적화가 훨씬 쉬워진다고 합니다.↩