Rust에서 const를 auto 트레이트처럼 바라보는 관점과, const 문맥에서 트레이트 메서드를 호출하는 문제를 async/Send와의 유비를 통해 최소한의 문법 변화로 해결하는 방안을 모색한다.
이 연재의 직전 두 글에서는 더 높은 수준의 언어 개념을 렌즈로 삼아 Rust의 설계를 논하려고 했다:
이번 글은 그런 거창한 개념을 도입하지 않는다. 완전히 세부적인 내용에 파고든다. 부분적으로는, 지난 글이 더 높은 논점을 다루도록 하기 위해 삭제했던 내용을 바탕으로 하고 있기 때문이다.
이미 썼듯이, 제어 흐름 효과는 Rust 고유의 명령형 표현 조합 방식을 활용해 자연스럽게 합성될 수 있도록 제너레이터와 try 블록을 도입하는 것이 Rust에서 가장 잘 관리하는 방법이라고 믿는다. 하지만 const는 그 패턴에 잘 들어맞지 않았기 때문에 제어 흐름 효과와 분리했다. 썼듯이, const fn은 제어 흐름 효과처럼 함수 본문을 변형하는 문법 설탕이 아니고, 그 스코프 안에서 추가 연산자를 가능하게 만들지도 않으며, 코드 블록이 “어떻게” 실행되는지를 정하는 것이 아니라 그 블록이 “언제” 실행될 수 있는지를 제한한다.
간단히 주장했듯이, 이런 점에서 const는 auto 트레이트와 더 비슷하다: Send가 어떤 타입이 스레드 간에 전송될 수 있는지를 제한하듯이, const는 어떤 함수가 컴파일 타임에 호출될 수 있는지를 제한한다. 이 연결을 좀 더 깊이 탐구하고, 특히 Niko Matsakis가 설명한 “async 메서드의 Send” 문제와도 연결지어 보고자 한다.
const는 아직 auto 트레이트가 아니다(아직?)const를 auto 트레이트와 같은 종류의 검사로 상정하는 것은 생산적이지만, 구현 면에서는 분명한 차이가 있다. 첫째로 말할 수 있는 것은, 무엇이든 구현할 수도, 못할 수도 있는 “ConstFn 트레이트” 같은 것은 존재하지 않는다는 점이다. 대신 함수에 적용할 수 있는 마커가 있을 뿐이다.
이들 auto 트레이트와 const의 기능은 흥미롭게도 재귀적이라는 점에서 비슷하다: 어떤 타입이 그 필드 모두가 Send라면 그 타입도 Send이고, const로 표시된 함수는 그 내부의 모든 표현식이 const라면 컴파일된다. 하지만 애너테이션 부담은 반대로 배분된다: 타입은 모든 필드가 Send이면 자동으로 Send가 되지만, const 표현식만을 담은 함수라도 명시적으로 const로 주석(애너테이션)하지 않으면 const가 아니다. 또한 const가 표시하는 대상은 Send가 표시하는 대상과 꽤 다르다. Send의 경우에는 (예: Rc 대 Arc) 스레드 간 전송의 안전성이 타입에 의해 갈리지만, const는 함수 안의 타입이 아니라 표현식들에 의해 결정된다.
이 비교는 async fn과 제너레이터 같은 코루틴을 고려할 때 더 흥미로워진다. 이들의 반환(사실상) 익명 상태 머신 타입(Future나 Iterator)은 Send일 수도, 아닐 수도 있다. 이는 const fn과 더 유사한데, 함수의 “본문”(이 경우 상태 머신으로 구체화된 것)이 auto 트레이트를 구현하느냐 마느냐가 갈리기 때문이다. 다만 const와 달리, “함수” 그 자체(코루틴의 경우 상태 머신의 순수 생성자)는 항상 Send를 구현한다.
이 비교를 더 밀어붙이면, “컴파일 타임에 실행될 수 있다”는 뜻의 일종의 auto 트레이트를 상상해 볼 수 있다. 함수가 아닌 타입에 대해서는 이 트레이트의 구현이 무의미하고 관련도 없겠지만, 함수 “인” 타입들에는 의미가 있다. 여기서 사용자들이 흔히 간과하는 Rust의 한 가지 세부 사항을 지적하는 것이 중요하다: Rust에서 모든 함수는 표면 언어로는 쓸 수 없지만 타입 시스템 안에는 존재하는 고유한 익명 타입을 가진다.
그런 ConstFn auto 트레이트가 있다면, 어떤 함수 타입이 이를 구현하는지 판별하는 것은 비교적 간단할 것이다:
const fn 함수의 익명 함수 타입은 ConstFn을 구현하고, 일반 fn은 구현하지 않는다.ConstFn을 구현할 수 있으므로, 바운드는 F: Fn() + ConstFn처럼 쓸 수 있다.unsafe fn 변형이 있는 것처럼 const fn 변형이 필요하고, 그 변형은 ConstFn을 구현하지만 다른 것들은 구현하지 않는다.이런 auto 트레이트를 언어에 추가해 직접 노출해야 한다고 꼭 생각하는 것은 아니지만, 이 글에서는 개념적으로 더 살펴보고 싶다. 예를 들어, 실제 auto 트레이트 대신에 const 클로저를 F: const Fn() 같은 특수 문법으로 써야 할지도 모른다. 요지는 이것이 함수 타입에 추가적인 바운드처럼 동작하고, 컴파일러는 그 정의를 검사해 조건을 만족하는지 여부를 판정할 수 있어야 한다는 것이다.
const와 트레이트 메서드아직 키워드 제네릭스 그룹이 해결하려 했던 진짜 문제, 즉 const 문맥에서 const인 트레이트 메서드를 호출하는 문제(예를 들면 const 문맥에서 for 루프를 쓰게 하는 것. 이는 Iterator::next 호출로 디슈거링된다)를 해결하진 못했다.
여기서 Send인 async 메서드와의 비교가 특히 또렷해진다. 높은 수준에서 보면 이 또한 같은 문제다. async 메서드를 호출해 태스크를 스폰하되, 태스크가 Send를 구현해야만(그래야 스레드 간에 작업을 스케줄할 수 있다) 하는 실행기 위에서 돌리고 싶다. 추상적으로는, 이는 const 문맥에서 const 메서드를 호출하고 싶은 문제와 같다. 이 유비는 예전 “trait transformers” 작업에서도 제기되었지만, 그 문법을 끌어들이지 않고도 성립한다.
다만 Send인 async 메서드와 const 메서드의 정의 방식에는 중요한 차이가 있다. 앞서 힌트했듯이, async 메서드는 추가 애너테이션 없이도 await 지점에 걸쳐 !Send 타입을 보유하지 않으면 Send가 된다. 반면 함수는 명시적으로 const로 표시될 때만 const가 된다. 두 접근법에는 장단이 있지만, Rust는 이전부터 auto 트레이트는 묵시적으로 구현되는 반면 const는 명시적 애너테이션이 있어야만 적용된다는 선택을 해 왔다.
그 차이를 받아들인다면, 메서드를 const로 만드는 유일한 방법은 그것을 const로 표시하는 것이다. 여기서 const는 다른 애너테이션과 다르게, 트레이트 정의 전체가 const로 표시되지 않았더라도 개별 메서드를 const로 표시하는 것이 허용된다는 점에서 다르게 동작한다. 내 관점에서는, 이는 const가 다른 함수 한정자들과 동작이 다른 데서 비롯된 불규칙성으로서 받아들일 만하다.
그렇다면 논의를 위해, 이제 트레이트 메서드를 const로 만드는 방법이 있고 그건 해당 트레이트 메서드에 const를 추가하는 것만큼 간단하다고 하자. 그렇다면 제네릭 문맥에서 “next 메서드가 const인 이터레이터만 원한다”고 바운드로 어떻게 제한할 수 있을까?
여기서 문제가 Send async 메서드 문제와 합쳐진다: async 메서드가 Send임을 말할 방법이 필요하듯이, 어떤 메서드(임의의 메서드)든 ConstFn임을 말할 방법이 필요하다. 따라서 둘은 같은 문법으로 풀 수 있다. 이를 위해 나는 소위 “반환 타입 표기” 문법에 가장 기대를 걸고 있는데, 한 가지 비틀기를 제안한다: 이를 반환 타입을 위한 문법으로 보기보다, “그 함수”에 바운드를 적용하는 것으로 보자는 것이다. 코루틴의 경우 이 “함수”에는 그 함수에 연관된 상태 머신이 포함된다.
이는 다소 색다르게 보일 수 있지만, 실제로는 잘 작동해야 한다. 코루틴의 상태 머신이 구현하는 어떤 바운드든 그것의 순수 생성자도 구현하기 때문이다. 이 생성자들은 모두 순수한 생성자 함수이므로 Send, ConstFn, 그 밖에 당신이 신경 쓰는 어떤 것이라도 구현한다. 따라서 상태 머신을 “반환 타입”으로 말하기보다는, 그것을 (상태 머신으로 구체화된) 함수라고 개념화하고, 상태 머신으로 구체화되지 않은 함수까지 포함해 임의의 함수가 const임을 요구하는 데에 상응하는 문법을 사용할 수 있다.
이 정식화에서, 이터레이터 문제의 해법은 T: Iterator, T::next(): ConstFn처럼 쓰는 것만큼 간단하다. 또는 T::next(): const 같은 특수 문법일 수도 있다. 마찬가지로 async 메서드나 제너레이터 메서드도 const로 표시할 수 있고 동일한 문법을 적용할 수 있으며, 이는 그 상태 머신이 컴파일 타임에 끝까지 처리될 수 있음을 함의한다(이는 async보다는 제너레이터에 더 관련이 크다고 본다).
이 제안의 목표는 const 문맥에서 트레이트 메서드를 지원하는 데 필요한 가장 최소하고 덜 침습적인 언어 변경을 찾는 것이다. 이미 async 메서드의 Send 문제를 해결하기 위해 “반환 타입 표기” 같은 것을 도입한다고 가정하면, const 문맥에서 트레이트 메서드를 호출하는 문제는 다음으로 해결될 수 있다고 본다:
const로 표시할 수 있게 한다.Send임을 표현하는 것과 같은 문법으로, 트레이트의 어떤 메서드가 const임을 바운드에 표현할 수 있게 한다.내게는 이것이 실제로 필요한 전부처럼 보인다. 더 넓고 복잡한 종류의 추상화에는 장점이 있을 수 있지만, 그 장점은 Rust에 또 하나의 휩쓰는 언어 구성 요소를 추가함으로써 생기는 인지적 부담과 저울질돼야 한다. 보다 최소한의 대안이 존재할 때, 그것을 넘어서는 것은 꽤 높은 문턱이라고 느낀다.
다음 글에서는 async Rust와 코루틴으로 돌아가, 저수준 레지스터의 문제와 그것이 왜 중요한지를 더 철저히 탐구할 생각이다.