타입 서명만으로 구현을 결정하게 해주는 매개변수성과, Zig의 comptime이 그 보장을 어떻게 포기하는지 살펴본다.
여기 퍼즐이 하나 있다. 본문을 보지 않고, 이 Rust 함수는 무엇을 할까?
fn mystery<T>(a: T) -> T
타입 이론을 조금이라도 알고 있다면, 아마 이미 보일 것이다. 이 함수는 반드시 a를 반환해야 한다. 관례 때문도, 스타일 가이드 때문도 아니다. 타입 시스템이 다른 어떤 구현도 불가능하게 만든다. 알 수 없는 타입의 값으로 할 수 있는 일은 그것을 그대로 돌려주는 것뿐이다.
이 성질—타입 서명이 구현을 결정할 수 있다는 성질—을 매개변수성(parametricity) 이라고 하며, 프로그래밍 언어 설계에서 가장 과소평가된 아이디어 중 하나다. 그리고 이것이 바로 Zig의 comptime이 포기하는 것이다.
최근 글은 comptime이 “미칠 만큼 좋다("bonkers good")”는 주장을 펼쳤고, 나는 그에 반박하고 싶지는 않다. comptime은 진짜로 강력하다. 하지만 힘에는 형태가 있고, 매개변수성이 무엇인지—그리고 그것이 없을 때 무엇을 잃는지—를 이해하면 Zig가 무엇을 하고 있는지, 그리고 왜 그것이 흥미로운 설계 선택인지에 대한 훨씬 풍부한 그림을 얻을 수 있다.
위의 Rust 함수 서명에 해당하는 Zig 버전을 생각해보자. comptime 키워드는 T를 컴파일 타임 매개변수로 표시한다. 즉, 함수 본문이 그것을 관찰하고, 그것에 따라 분기하고, 어떤 타입을 받는지에 따라 완전히 다르게 동작할 수 있다는 뜻이다.
다음 Zig 함수 서명을 보자:
fn mystery(comptime T: type, a: T) T
서명만 보고 이것이 무엇을 하는지 알 수 있을까?
내가 작성한 구현의 출력은 다음과 같다:
mystery(f64, 1.0) is 1
mystery(i32, 1) is 43
mystery(bool, true) is false
결과가 놀라웠다면 comptime에 온 것을 환영한다. 이 함수는 부동소수점에서는 값을 그대로 반환하고, 정수에서는 42를 더하며, 불리언은 부정한다. 서명에는 이런 내용이 전혀 드러나지 않으며—드러날 수도 없다. comptime은 본문이 무엇을 하든 허용되는 것에 어떤 제약도 두지 않기 때문이다.
그럼 매개변수성이 정확히 무엇일까? 이는 제네릭 타입, 즉 타입 매개변수를 가진 함수의 성질이다. 함수 본문 안에서는, 함수 인자로 전달되는 것 이상으로 타입 매개변수에 대해 어떤 것도 알 수 없다는 뜻이다.
Rust 함수
fn mystery<T>(a: T) -> T
에서 우리는 T의 크기를 알 수 없고, 어떤 메서드도 호출할 수 없고, 무엇과도 비교할 수 없다. 할 수 있는 일은 반환뿐이다. 그래서 항등 함수만이 가능한 구현이 된다.
함수가 제네릭 값으로 무언가를 해야 한다면, 그 “무언가”는 그 자체로 인자로 전달되어야 한다. 예를 들어 다음을 보자:
fn mystery2<A, B>(a: A, f: fn(A) -> B) -> B
우리는 타입 A의 값을 갖고 있고, 타입 B의 값을 만들어야 한다. 사용 가능한 B 값의 유일한 공급원은 f다. 따라서 여기에서도 구현은 정확히 하나뿐이다:
fn mystery2<A, B>(a: A, f: fn(A) -> B) -> B {
f(a)
}
매개변수성은 모듈성의 한 형태다. 모듈성은 보통 컴포넌트 간의 성질로 설명된다. 한 모듈이 다른 모듈로부터 구현을 숨기고, 인터페이스만 노출하는 것이다. 매개변수성도 똑같이 작동하지만, 단일 함수 내부에서 일어난다. 정의 지점에서 함수 본문은 구체적인 타입 T가 무엇일지에 대한 어떤 지식으로부터도 격리된다. 본문은 자신에게 제시된 인터페이스, 즉 함수의 매개변수들만 가지고 일할 수 있다. 호출 지점에서 호출자는 자신이 어떤 타입을 넘기는지 정확히 알고 있지만, 함수 서명을 넘어 구현을 들여다볼 수는 없다.
매개변수성은 추상의 쌍대(dual)다. 추상은 호출자에게 불필요한 세부사항을 숨긴다. 우리는 함수가 어떻게 동작하는지 몰라도 사용할 수 있다. 매개변수성은 구현자에게 불필요한 세부사항을 숨긴다. 우리는 함수가 어떤 타입으로 호출될지 알 수 없게 만든 채로 함수를 작성한다. 둘 다 같은 아이디어다. 어떤 지식을 어디에서 이용 가능하게 할지 관리함으로써, 추론을 다룰 만하게(tracable) 유지한다.
그 결과 중 하나는, 매개변수 함수가 균일한 동작을 갖는다는 점이다. 본문이 타입에 따라 분기할 수 없다면, 타입마다 다르게 동작할 수 없다. 우리는 매개변수 함수를 한 번 배우면, 어디에서 사용하든 그 지식을 신뢰할 수 있다.
연구들(예를들어)은 개발자들이 자신의 시간 중 대략 절반을 단지 코드를 읽고 이해하는 데 쓴다는 것을 꾸준히 보여준다. 코드를 작성하는 것도 아니고 디버깅하는 것도 아닌, 기존 코드가 무엇을 하는지 파악하는 데만 그렇다는 뜻이다. 이는 인상적인 수치이며, 이해 비용을 줄이는 어떤 것이든 생산성에 과도하게 큰 영향을 미친다.
매개변수성은 이 비용을 정면으로 겨냥한다. 함수가 매개변수적이면, 타입 서명은 구현에 대한 힌트가 아니라 “그 함수가 도대체 무엇을 할 수 있는지”에 대한 언어 차원의 강제 제약이 된다. 우리는 함수의 성질을 이해하기 위해 본문을 읽거나, 테스트를 확인하거나, 이름이 정확하다고 믿을 필요가 없다. 타입은 증명이며, 공짜로 얻는 정리들(theorems for free)1을 준다.
이 효과는 코드베이스 전체로 누적된다. map이 무엇을 하는지—컬렉션의 모든 원소에 함수를 적용한다—를 한 번 이해하면, Iterator에서도, Option에서도, Result에서도, 어떤 타입에서든 이해한 것이다. 동작이 균일하기 때문에 지식이 전이된다. 우리는 한 번만 배우면 된다.
이것이 깨질 때의 실패 양상은 유익하다. JavaScript의 Array.toSorted()는 비교 함수를 주지 않으면, 정렬하기 전에 모든 것을 문자열로 변환한다. 일관적이긴 하지만 균일하진 않다. 결과를 예측하려면 특수 케이스 지식이 필요하다.
["Zachery", 1, {name: "Ziggy"}, "~Tilde~", "$bill"].toSorted()
// Array(5) [ "$bill", 1, "Zachery", {…}, "~Tilde~" ]
정수 1이 "$bill"과 "Zachery" 사이에 놓이는 이유는, "1"이 사전식(lexicographically)으로 그 위치에 정렬되기 때문이다. 함수 서명 어디에도 이런 동작은 암시되어 있지 않다. 이것이 매개변수성이 제거해주는 이해세(comprehension tax)다. 첫 번째로 읽을 때뿐 아니라, 우리가 전에 본 적 없는 코드를 마주칠 때마다 반복해서 발생하는 비용이기도 하다. 같은 논리는 문맥 제약 하에서 읽는 어떤 독자에게도 적용된다. 타입 서명만으로 더 많이 추론할 수 있을수록, 펼쳐서 읽어야 하는 양이 줄어든다. 매개변수 타입은 동작을 압축적이고 검증 가능한 형태로 표현하며, 이는 코드 리뷰를 하는 사람이든 코드베이스의 제한된 창만 가진 도구든 모두에게 유용하다.
여기서 질문이 생길 수 있다. “정말로 타입마다 다른 동작이 필요하면 어떡하지?” 현대의 매개변수적 언어들2은 이에 대한 해법을 갖고 있고, 이제 그쪽으로 가보자.
집합(set) 자료구조를 생각해보자. 부호 없는 정수의 집합은 비트셋(bitset)으로 아주 압축적으로 표현할 수 있지만, 다른 타입들은 다른 표현이 필요하다. 예를 들어 해시 테이블이나 균형 트리일 수 있다. 매개변수 함수는 이런 선택을 하기 위한 정보를 갖고 있지 않지만, comptime은 모듈성 장벽을 뚫어버려 타입에 대한 컴파일 타임 디스패치를 허용한다.
매개변수적 언어들은 컴파일 타임에 알려진 정보를 추가로 제공하는 매개변수를 함수가 받을 수 있게 함으로써 이를 해결한다. 이것이 바로 Haskell의 타입 클래스(type classes), Rust의 트레이트(traits), Scala의 암시적 값(implicits)의 핵심이다. 이런 언어에서는 타입과 연관된 서로 다른 표현을 정의할 수 있다. 부호 없는 정수에는 비트셋 표현을, 다른 타입에는 다른 표현을 두는 식이다. 이는 타입마다 다른 동작—소위 임시 다형성(ad hoc polymorphism)—을 허용하면서도 매개변수성을 유지하는 문제를 해결한다. 이 추가 정보는 여전히 개념적으로 함수 매개변수이며, 함수 서명에 나타나기 때문이다.
Rust에서 우리의 집합 예제는 다음과 같은 서명을 가질 수 있다.
fn empty_set<T: SetRepresentation>() -> Set<T>
여기서 SetRepresentation 트레이트는 자료구조를 구성하는 데 필요한 정보를 담고 있다. 트레이트가 서명에 나타나므로 매개변수성이 보존된다. 호출자는 타입만으로도 여전히 추론할 수 있다.
comptime은 진짜 힘을 준다. 컴파일 타임에 타입에 따라 동작을 특수화할 수 있는 능력은 유용하며, Zig의 스테이징 이야기—임의의 코드를 컴파일 타임에 실행하는 것—는 대부분의 언어가 형편없이 다루는, 과소평가된 아이디어다. 나는 이를 폄하하고 싶지 않다.
하지만 제네릭 프로그래밍이라는 구체적인 문제에 대해서는, 그 트레이드오프가 성립하지 않는다. 대안—Haskell의 타입 클래스나 Rust의 트레이트—는 매개변수성을 보존하면서도 임시 다형성(타입에 따라 다르게 동작하는 함수)을 제공한다. 우리는 요청한 곳에서는 특수화를 얻고, 그 외 모든 곳에서는 추론 보장을 얻는다. 또한 확장 가능하다. 누구나 기존 타입 클래스에 새 타입을 추가할 수 있다. Zig의 comptime 디스패치는 그렇지 않다.
더 깊은 문제는 comptime이 두 가지를 혼동한다는 점이다. 스테이징(컴파일 타임에 코드를 실행하는 것)과 제네릭 프로그래밍(여러 타입에 대해 동작하는 코드를 작성하는 것)이다. 이것들은 서로 다른 문제이며 최선의 해법도 다르다. 스테이징은 실제로 comptime 스타일의 힘으로부터 이득을 본다. 제네릭 프로그래밍은 실제로 매개변수성으로부터 이득을 본다. 하나의 메커니즘을 둘 다에 사용한다는 것은, 둘 중 하나에 대해 더 나쁜 답을 받아들이는 것을 의미한다.
그러니 그렇다—comptime은 미쳤다. 하지만 전적으로 좋은 의미에서만은 아니다.
2
Java 같은 일부 오래된 언어들은 이 문제에 대한 해법이 없지만, Java조차도 추가할 계획을 갖고 있다.