여러 언어에서 음수를 판별하는 간단한 함수를 예로 들어 람다 문법을 비교한다. 완전 익명 함수, 플레이스홀더 표현식, 부분 함수 적용이라는 세 가지 스타일을 정리하고, 특히 C++ 람다가 왜 길어질 수밖에 없는지와 관련 제안(P0573, P2036 등), 그리고 플레이스홀더 기반 문법의 가능성을 논의한다.
Conor Hoekstra의 영상을 보는 걸 좋아한다. 그는 발표도 흥미롭게 잘 하고, 무엇보다 수많은 프로그래밍 언어를 다루기 때문이다. 나는 그렇게 많은 언어를 알지 못하기 때문에, 서로 다른 언어가 같은 문제를 어떻게 푸는지 접해볼 수 있다는 점이 좋다.
그의 최근 영상 하나에서는 꽤 단순한 문제(행렬에서 음수의 개수를 세는 법)를 다루며, 십수 개 언어의 구현을 훑었다. 모든 언어가 해야 할 일은 어떤 수가 음수인지 판별하는 장치를 갖는 것인데, 대부분의 경우 람다(익명 함수라고도 함)를 사용한다.
특히 흥미로웠던 점은 언어마다 람다가 어떻게 보이는지가 제각각이었다는 것이다. 그래서 여기에 집중해 보려 한다. 주어진 언어에서, 어떤 수가 음수인지 확인하는 람다 표현식은 어떻게 쓸까?
(<0) // 4: Haskell
_ < 0 // 5: Scala
_1 < 0 // 6: Boost.Lambda
#(< % 0) // 8: Clojure
&(&1 < 0) // 9: Elixir
|e| e < 0 // 9: Rust
\(e) e < 0 // 10: R 4.1
{ $0 < 0 } // 10: Swift
{ it < 0 } // 10: Kotlin
e -> e < 0 // 10: Java
e => e < 0 // 10: C#, JS, Scala
\e -> e < 0 // 11: Haskell
{ |e| e < 0 } // 13: Ruby
{ e in e < 0 } // 14: Swift
{ e -> e < 0 } // 14: Kotlin
fun e -> e < 0 // 14: F#, OCaml
lambda e: e < 0 // 15: Python
(λ (x) (< x 0)) // 15: Racket
fn x -> x < 0 end // 17: Elixir
(lambda (x) (< x 0)) // 20: Racket/Scheme/LISP
[](auto e) { return e < 0; } // 28: C++
std::bind(std::less{}, _1, 0) // 29: C++
func(e int) bool { return e < 0 } // 33: Go
중괄호가 필요한 언어들의 경우, 중괄호도 길이에 포함했다(공정한지는 별개로). 또한 Clojure에서는 % 대신 %1을 사용할 수도 있다.
그리고 예, 저 목록에 Boost.Lambda와 std::bind도 들어 있다(플레이스홀더를 쓰려면 적절한 using namespace 선언이 범위 안에 있다고 가정하자).
이 표 자체가 흥미롭다고 생각한다. 본질적으로 여기엔 세 가지 부류의 람다가 있음을 보여준다:
$0을 쓰는 점이 특이하다. 내가 아는 다른 언어/라이브러리는 1부터 번호를 매긴다.std::bind 등)여러 언어가 둘 이상의 방식을 제공하기도 한다.
주목할 점은, 여기서 C++의 람다가 거의 최장 길이라는 것이다. 이 글의 초안에서는 C++이 가장 길었는데, Eugene Yakubovich가 Go의 람다를 알려주었다 — 다소 놀라웠다. C++의 람다가 길어지는 건 언어 자체의 복잡성 때문이라고 치더라도, Go는 무슨 변명이 있을까? 어쨌든 좋다. 엄밀히 말해 꼴지는 아니다!
사실 이는 C++에 유리한 비교다. 값 하나를 받고 값 하나를 반환하는 경우만 다루고 있으니 말이다. 참조를 받아야 했다면 auto const&나 auto&&가 되어(각각 7자 또는 2자 더 길어진다), 값을 반환하는 대신 참조를 반환하고 싶다면 -> decltype(auto)를 덧붙여 17자를 더해야 한다. 이 추가 17자는 다른 언어의 람다 전체 길이에 맞먹는다.
C++ 람다는 이 언어 집합 가운데 유일하거나 거의 유일한 요소를 세 가지 갖는다:
지정된 캡처. 예를 들어 Rust는 move 캡처를 허용해서 move |needle| haystack.contains(needle)처럼 쓸 수 있다. reddit의 Nobody_1707가 지적했듯, Swift도 C++와 꽤 비슷한 캡처 문법을 가진다. 하지만 그 외 대부분 언어는 애초에 캡처라는 개념이 있는지조차 확실치 않다. 대개 그냥 [&] 같은 느낌이다. 다만 C++은 가비지 컬렉션 언어가 아니므로, [] 이상의 좋은 기본 캡처 규칙이 있는지도 모르겠다 — 그리고 그 정도면 문자 수를 크게 아끼는 것도 아니다.
의무적인 매개변수 선언. 다른 정적 타입 언어 대부분에서 타입 주석을 제공할 수는 있지만, 선택 사항이다. Rust의 예도 |e: i32| e < 0로 쓸 수 있고, Scala도 (e: Int) => e < 0처럼 쓸 수 있다. 핵심은 매개변수 선언이 선택이라는 점이다. 단순한 경우엔 타입을 생략하고, 복잡한 경우엔 타입을 유지하는 식으로 말이다.
return 키워드. 다른 언어들은 그냥 표현식만 적으면 된다.
내가 시도했던 제안 중 하나인 P0573는 매개변수 선언을 선택적으로 만들고 return 키워드를 생략하는 새로운 형태의 “완전 익명 함수”를 도입하려 했다. 그 문서에서는 다음과 같이 제안했다:
[](e) => e < 0 // P0573R2: 14
[](auto e) { return e < 0; } // C++: 28
길이가 절반이 된다. 여전히 다른 많은 언어보다 길지만 상당히 나아진다. 그러나 이 제안은 몇 가지 이유로 거절되었다. 파싱이 달라지는 문제(이름 없는 매개변수의 경우 사람에게 모호함), 그리고 반환 타입의 의미가 대표적이다. 관련 내용은 이전 글을 참고하라. C++에서 타입 주석을 없애는 게 특히 어려운 이유 중 하나는, C++의 매개변수 선언이 Type name 형태인데 반해 많은 다른 언어는 name: Type을 쓰기 때문이다. 후자는 타입을 생략해도 이름에 초점이 맞춰지고(또한 이름 없는 매개변수를 허용하지 않는데, 이게 C++ 이슈의 핵심이다) 개인적으로도 꼭 필요한 기능이라고 느낀 적이 없다.
따라서 “완전 익명 함수”를 위한 새로운 문법을 도입하는 건 현실적이지 않아 보인다 — 위 두 문제를 어떻게 극복할까? 게다가 세 번째 이슈와 관련해 제시된 P2036은 프라하에서 매우 호의적으로 받아들여졌고 결함 수정으로 채택될 가능성이 높다. C++의 람다와 비슷하게 생긴 문법으로는 해결이 어려워 보인다. 완전 람다에 대해 아예 다른 문법을 도입하는 것도 지금으로선 탐탁지 않다(그리고 여전히 auto vs decltype(auto) 문제는 남는다).
그렇다면 플레이스홀더 표현식은 어떨까. 예전에는 이 스타일이 완전 익명 함수 스타일보다 읽기 어렵다고 다소 폄하했었다. 하지만 단순한 경우라면, 이제는 잘 모르겠다. vector<bool>이 Now I Am Become Perl에서 지적했듯, 초기의 혼란과 영구적인 혼란은 다르다(물론 글은 초기에 혼란을 줄 법한 문법 제안들도 제시한다). 그가 지적한 게 바로 이 플레이스홀더 표현식 람다 아이디어다.
그 문법은 어떤 모양일까? 여전히 캡처 개념을 보존하고 싶다 — C++에서 중요한 개념이고, 어차피 도입자(introducer)가 필요하다. 그 이유는 다음을 생각해 보면 분명해진다: f(_1)은 무엇을 뜻할까?
f([](auto&& x, auto&&...) -> decltype(auto) { return (x); })
혹은
[](auto&& x, auto&&...) -> decltype(auto) { return f(x); }
문맥 의존이라면… 어떻게 정할까? 어렵다. 솔직히 Scala가 이걸 어떻게 처리하는지 확신이 없다. Clojure, Elixir, Swift는 람다가 시작되는 지점을 명확히 표시한다. 그리고 { f(_1) }처럼 중괄호를 여기서 쓸 수 있다고 보기도 어렵다.
그렇다면 도입자 다음에 어떤 구분 기호를 두고, 그 뒤에 표현식을 두면 어떨까?
[] => _1 < 0
[] -> _1 < 0
[]: _1 < 0
확실히 기존과는 다르지만, 본질적으로 Boost.Lambda에서 하던 것과 같다(단지 생성되는 코드는 비교할 수 없을 정도로 좋아질 것이다).
HOPL 논문에 나오는 STL 예시가 있다:
vector<string>::iterator p =
find_if(v.begin(), v.end(), Less_than<string>("falcon"));
여기 함수 객체의 형태를 보라 — 부분 함수 적용이다. 우리가 Haskell에서 쓸 법한 것, 곧 (< "falcon")과 정확히 같다(의미적으로). Björn Fahller에겐 이런 부분 함수 적용을 지원하는 함수 객체를 모아 둔 저장소가 있다. 그의 것은 타입을 생략한다는 점만 다르다: less_than("falcon").
이와 동등한 C++ 람다는 어떻게 될까?
[](std::string const& s) { return s < "f"; } // 44: C++11
[](auto&& s) { return s < "f"; } // 32: C++14
lift::less_than("f") // 20: with lift
바로 이 때문에, 단형으로도 충분한 경우에도 제네릭 람다를 자주 쓰게 된다. 문자열을 줄여 적은 이유는 람다가 내 블로그 폭을 넘어갈 정도로 넓었기 때문이다! 고마워요, Faisal!
이 스타일이 서로 매우 다른 프로그래밍 성향을 가진 두 사람(이름의 철자가 꽤 겹치긴 하지만)에게도 충분히 괜찮다면, 매개변수 이름은 과대평가된 게 아닐까? 영어 문장처럼 읽힌다: find_if(..., less_than(...)) 꽤 좋다. 연산자 자체를 단어 대신 쓴다고 해서 정말로 달라질까?
(<"f") // 6: Haskell
[]: _1 < "f" // 12: placeholder?
lift::less_than("f") // 20: with lift
[](auto&& s) { return s < "f"; } // 32: C++14
이 정도면 충분히 익숙해질 수 있을 것 같다. 영구적인 혼란의 근원 같지는 않다.
물론 C++이니만큼 고려할 질문이 더 많다. 전달 최적화는 어떻게 할 것인가(매크로를 쓰자), 가변 인수는 어떻게 할 것인가(모르겠다), 이런 람다의 아리티(인수 개수)는 무엇인가(가장 큰 플레이스홀더 번호에 기반할까? 아니라고 본다), 여기에 P0834가 여전히 필요할까(좋은 질문이다), P0119에서 제안된 연산자 함수의 더 짧은 표기(예를 들어 (>)를 std::greater()의 축약으로 쓰는 것)는 어떨까(그렇다, 있으면 좋겠다) 등등.
하지만 이는 이 글의 요점에서 한참 벗어난다. 요지는 간단하다. C++의 람다는 정말, 정말 길다.