Rust의 컴파일 타임 함수 평가(CTFE)를 중심으로, 어떤 연산을 허용할지, 컴파일러의 검증, 승격(promotion)과의 관계, 그리고 우리가 기대할 수 있는 보장들을 타입 시스템 관점에서 정리한다. CTFE 결정성/정확성, const 안전성/사운드니스, const 문맥에서의 unsafe 사용과 승격 정책을 논한다.
한동안(정확히는 1.26 릴리스부터) Rust에는 컴파일 타임 함수 평가(CTFE, compile-time function evaluation)를 위한 매우 강력한 장치가 있었다. 그 이후로 CTFE 동안 어떤 연산을 허용해야 하는지, 컴파일러가 어떤 검사를 해야 하는지, 이것이 승격(promotion)과 어떻게 연관되는지, 그리고 CTFE 주변에서 어떤 종류의 보장을 기대할 수 있는지에 대한 다양한 논의가 있었다. 이 글은 그러한 주제들에 대한 나의 견해이며, 내가 매우 타입 시스템 중심의 관점을 취할 것이라는 점은 놀라울 게 없다. 일종의 구조화된 브레인 덤프를 기대하라. 그래서 글의 끝부분에는 답이 없는 질문들도 좀 있다.
CTFE는 컴파일러가 주로 const x: T = ...; 같은 항목을 평가하는 데 사용하는 메커니즘이다. 여기서 ...는 컴파일 타임에 “실행”되어야 하는 Rust 코드다. 왜냐하면 이 결과는 코드에서 상수로 사용될 수 있기 때문이다. 예를 들어, 배열 길이로 사용할 수 있다.
CTFE는 상수 전파(constant propagation)와는 다르다. 상수 전파는 LLVM 같은 컴파일러가 최적화 과정에서 기회가 되면 3 + 4 같은 코드를 7로 바꿔 런타임 작업을 피하는 것이다. 최적화이므로, 상수 전파는 정의상 프로그램의 동작을 바꾸면 안 되며(성능을 제외하면) 전혀 관찰되어서는 안 된다. 반면 CTFE는, 컴파일러가 다음 처리를 위해 그 결과를 알아야 하므로, 반드시 컴파일 타임에 실행되어야 하는 코드에 관한 것이다. 예를 들어, 메모리에 데이터를 어떻게 배치할지 계산하려면 배열의 크기를 알아야 한다. 어떤 코드 조각에 CTFE가 적용되는지는 구문만 보더라도 정적으로 알 수 있다. CTFE는 const의 값이나 배열 길이 같은 위치에서만 사용된다.
fn demo() {
const X: u32 = 3 + 4; // CTFE
let x: u32 = 4 + 3; // CTFE 아님(하지만 상수 전파는 될 수 있음)
}
위의 3 + 4는 _const 컨텍스트_에 있으므로 CTFE의 대상이지만, 4 + 3은 아니다.
모든 연산이 const 컨텍스트에서 사용될 수 있는 것은 아니다. 예를 들어, “그 디스크에서 파일을 읽어 와서 뭔가를 계산”하여 배열 길이를 구하는 것은 말이 안 된다. 프로그램이 실제로 실행될 때 디스크에 무엇이 있을지 알 수 없기 때문이다. 컴파일을 수행하는 머신의 디스크를 사용할 수도 있겠지만, 그다지 매력적인 생각은 아니다. 프로그램이 네트워크로 정보를 보내는 것을 허용하는 것을 고려하면 상황은 더 안 좋아진다. 분명 우리는 CTFE가 컴파일 바깥에서 실제로 관찰 가능한 부작용을 갖는 것을 원하지 않는다.
사실, 단순히 프로그램이 파일을 읽게 하는 것조차도 매우 위험하다. 배열 길이를 두 번 계산할 때는 같은 결과를 얻는 것이 중요하기 때문이다. 업데이트: @eddyb가 지적했듯이, const 제네릭, 트레이트, 코히런스(coherence)를 고려하면 상황이 더 악화된다. 그 시점에서는 서로 다른 크레이트에서 같은 표현식을 평가했을 때 같은 결과가 나온다는 가정에 의존해야 한다. /업데이트
CTFE는 결정적이어야 한다.
그렇지 않으면, 컴파일러가 두 배열의 길이가 같다고 생각했다가, 나중에 서로 다른 레이아웃을 계산하는 사태가 벌어질 수 있다. 그것은 재앙이다. 따라서 모든 종류의 외부 입력과 모든 종류의 비결정성은 CTFE에서 완전히 금지다. 이것은 I/O에만 관련된 것이 아니라, 심지어 참조를 usize로 변환하는 것조차 결정적이지 않다.
컴파일러는 그런 연산을 실행하려고 시도되면 CTFE 에러를 발생시킨다. const 컨텍스트에서 실행 가능한 프로그램을 우리는 _const 안전_하다고 부른다:
프로그램이 CTFE 중 에러 없이(패닉은 허용) 실행될 수 있다면, 그 프로그램은 const 안전하다.
이는 안전(혹은 const 안전과 구별하기 위해 런타임 안전)한 프로그램이 메모리 오류나 데이터 레이스를 일으키지 않는 프로그램이라는 생각과 매우 유사하다. 사실, CTFE 하에서 “행동이 올바른 프로그램”(const 안전성)과 “UB를 일으키지 않는 프로그램”(런타임 안전성) 사이의 유비는 꽤 멀리까지 우리를 이끌 수 있음을 곧 보게 될 것이다.
이제 매우 흥미로운 질문 하나는 어떤 주어진 함수 foo를 const 컨텍스트에서 호출할 수 있도록 허용해야 하는가이다. 그냥 항상 “예”라고 하고, foo가 수상한 짓을 하면 CTFE가 에러를 던지도록 맡길 수도 있다. 이 접근의 문제는, foo가 라이브러리에 있을 때, 라이브러리를 업데이트하면 foo가 더 이상 const-안전하지 않게 바뀔 수도 있다는 점이다. 다시 말해, 어떤 함수를 const-안전하지 않게 만드는 것은 다운스트림 크레이트를 깨뜨릴 수 있으므로 semver 위반이 된다.
이 문제를 해결하는 전형적인 메커니즘은 함수를 “const 컨텍스트에서 사용 가능”으로 명시적으로 표시하는 애너테이션을 두는 것이다. Rust에서 이를 위한 제안된 메커니즘이 const fn이고, C++에서는 constexpr라고 부른다. 이제 컴파일러는 const 컨텍스트에서 const가 아닌 함수를 호출하는 것을 거부할 수 있으므로, 라이브러리 작성자는 const-안전하지 않은 연산을 추가하더라도 semver를 깨뜨리지 않을 수 있다.
이는 컴파일러가 const 컨텍스트에서는 거부하는 코드를 const 컨텍스트 밖에서는 잘만 받아들이는 흥미로운 상황으로 이어진다. 특히, const fn의 본문(body)도 역시 const 컨텍스트로 간주된다. 그렇지 않다면 임의의 함수를 호출하도록 허용하는 셈이 되고, 같은 문제가 다시 생길 것이다. 이를 생각하는 유용한 방식은, const 컨텍스트에서 코드를 타입 검사하는 데 사용되는 두 번째 “const 타입 시스템”이 있다고 보는 것이다. 이 타입 시스템은 const가 아닌 함수 호출을 허용하지 않는다.
아마도 참조를 정수로 캐스팅하는 것도 허용하지 말아야 할 것이다. 위에서 논의했듯이, 그것은 CTFE 중 수행할 수 없는 비결정적 연산이기 때문이다. 그 밖에 무엇이 있을까?
무작정 임의의 추가 검사를 넣기 전에, 여기서 우리의 목표가 무엇인지 한 발 물러서서 생각해 보자. 보통 타입 시스템의 목적은 잘 형식화된(well-typed) 프로그램에 대해 어떤 종류의 보장을 세우는 것이다. Rust의 “메인”(“런타임”) 타입 시스템의 경우, 그 보장은 “정의되지 않은 동작 없음”이며, 이는 메모리 오류도 데이터 레이스도 없다는 뜻이다. 우리의 새로운 const 타입 시스템의 보장은 무엇인가? 우리는 이미 위에서 이야기했다. 그것은 바로 const 안전성이다! 이는 const 사운드니스에 대한 정의로 이끈다:
우리의 const 타입 시스템이 사운드하다는 것은, 잘 형식화된 프로그램이 const-안전하다는 뜻이다.
다시 한번 말하지만, 이는 런타임 타입 시스템에 대한 올바름 진술과 매우 유사하며, 거기서는 런타임 안전성을 보장한다.
하지만, 여기서는 약간 조심해야 한다. 다음 코드를 보자:
const fn is_eight_mod_256(x: usize) -> bool { x % 256 == 8 }
우리는 이 코드를 당연히 허용하고 싶을 것이다. 왜 ==나 %가 const-안전하지 않겠는가? 음, 이 함수를 다음과 같이 호출할 수도 있다:
is_eight_mod_256(Box::into_raw(Box::new(0)) as usize);
이 문장은 결과가 할당자가 Box를 정확히 어디에 두는지에 달려 있으므로, 확실히 const-안전하지 않다. 하지만 우리는 이 문제를 is_eight_mod_256 탓이 아니라 as usize 탓으로 하고 싶다.
해결책은 const 타입 시스템이 어떤 연산을 허용하는지에 대한 별도의 규칙을 둘 뿐 아니라, 어떤 값이 어떤 타입에 대해 “유효(valid)”한지에 대한 개념도 바꾸는 것이다. 포인터에서 얻은 정수는 런타임에는 usize에 대해 유효하지만, const 모드에서는 유효하지 않다! 결국, 모든 usize가 지원한다고 기대하는 기본 산술 연산들 중 일부를 포인터에 대해서는 CTFE가 지원할 수 없기 때문이다.
함수는, 인수가 const-유효할 때 실행하면 CTFE 에러를 일으키지 않고(반드시 반환한다면) const-유효한 결과를 반환한다면, const-안전하다.
이 정의에 따르면, is_eight_mod_256은 const-안전하다. 왜냐하면 x가 실제 정수일 때는 에러 없이 평가되기 때문이다. 동시에, 참조를 usize로 변환하는 것은 const-안전하지 않다 는 것도 보여준다. 이 연산의 입력은 const-유효하지만 출력은 그렇지 않기 때문이다! 이는 그런 캐스트를 const 컨텍스트에서 거부해야 한다는 견고한 정당화를 제공한다.
Rust에서는 CTFE가 miri라는 MIR 인터프리터에 의해 수행된다. miri는 별도 프로젝트였지만 그 코어 엔진이 rustc에 통합되었다. miri는 const 컨텍스트의 코드를 단계별로 실행하고, 수행할 수 없는 연산이 있으면 불평하며 에러로 실패한다. 이는 비결정성에만 해당되는 것이 아니다. @oli-obk가 우발적으로, RFC를 거쳐야 할 동작을 안정화하지 않도록 매우 신중하기 때문에, miri는 할 수 있는 모든 것을 곧바로 지원하지는 않는다.
사실, 지금 시점에서 miri는 모든 생(raw) 포인터 연산을 거부한다. 그것들은 모두 CTFE 에러를 발생시키므로, const 타입 시스템에서도 모두 거부되어야 한다. 계획은 miri가 더 많은 연산을 지원하도록 바꾸는 것이지만, 그렇게 할 때 조심해야 한다. 이미 말했듯이 miri는 결정적이어야 하지만, 여러분이 훨씬 더 큰 비중으로 다루어질 것이라 예상했을 다른 포인트도 있다. 최소한 CTFE가 성공한다면, CTFE는 런타임 동작과 일치해야 한다!
CTFE가 정확하다는 것은, 무한 루프에 빠지든, 결과와 함께 완료하든, 패닉하든, 그 동작이 동일한 코드를 런타임에 실행했을 때의 동작과 일치한다는 뜻이다.
우리는 코드를 CTFE로 const 컨텍스트에서 실행했을 때와, 기계어로 컴파일되어 “진짜로” 실행되었을 때의 동작이 다르기를 원하지 않는다.
혹은, 정말로 그럴까? 오해하지 말자. 내가 그 속성을 의도적으로 깨뜨리자고 옹호하는 것은 아니다. 하지만 miri가 CTFE-정확하지 않다면 무슨 일이 벌어지는지 고려할 가치는 충분히 있다. 아마도 놀랍게도, 이것은 사운드니스 문제는 아니다! 우리가 사운드니스를 위해 신경 쓰는 것은 오직 CTFE가 결정적이라는 점뿐이다. 이미 논의했듯이 말이다. 우리는 같은 코드를 런타임에 다시 실행하고 여전히 같은 동작을 기대하는 것에 의존하지 않으므로, CTFE 동작이 런타임 동작과 달라져도 실제로 깨지는 것은 없다.
그렇다 하더라도, CTFE-정확하지 않은 것은 분명 매우 놀랍고, 가능한 한 피해야 한다. 하지만 부동소수점 연산의 결과를 결정적으로 예측하는 것은 극도로 어렵고, LLVM이 그다지 도움을 주지도 않는다고 들었다. 따라서 우리는 부동소수점 연산을 const-안전하지 않은 것으로 간주(CTFE 에러 발생)하거나, 부동소수점 연산이 개입된 경우 CTFE 정확성을 포기하는 둘 중 하나를 감수해야 할 가능성이 높다. 나는 그 밖의 모든 연산에 대해서는 CTFE 정확성을 달성하는 것이 가능하다고 생각하며, 그렇게 하기 위해 노력해야 한다고 본다.
계속하기 전에, 위에서 정의한 CTFE 정확성은 CTFE가 에러로 실패하는 경우(예: 미지원 연산 때문에)에 대해서는 아무 말도 하지 않는다는 점을 유의하라. CTFE가 위 의미에서 사소하게 정확하려면, 그냥 항상 즉시 에러를 반환하면 된다. 그러나 const-안전한 프로그램은 CTFE 중 에러가 날 수 없으므로, CTFE 정확성으로부터 그러한 프로그램들은 실제로 컴파일 타임과 런타임에서 정확히 동일하게 동작한다는 것을 알 수 있다.
가령 miri가 생 포인터에 대한 더 많은 연산을 지원하도록 확장하고 싶다고 하자. miri의 결정성을 유지하고, CTFE 정확성을 보장하는 데는 신중해야 한다는 것을 우리는 알고 있다. 어떤 연산들을 지원할 수 있을까?
여기서, const 사운드니스와 관련된 const 안전성은 아직 고려 대상이 아니다. 그 개념들은 더 많은 연산을 허용하도록 const 타입 시스템을 변경할 때 등장한다. 반면 CTFE 결정성과 정확성은 CTFE 엔진(즉 miri) 자체의 속성이다.
다음 예제를 보자:
const fn make_a_bool() -> bool {
let x = Box::new(0);
let x_ptr = &*x as *const i32;
drop(x);
let y = Box::new(0);
let y_ptr = &*y as *const i32;
x_ptr == y_ptr
}
런타임에서는, 이 함수가 true를 반환할지 false를 반환할지는 할당자가 y를 할당할 때 x의 공간을 재사용했는지에 달려 있다. 그러나 CTFE는 결정적이므로, 우리는 컴파일 타임에 하나의 구체적 답을 골라야 하고, 그것이 올바른 답이 아닐 수도 있다. 따라서 CTFE 정확성을 유지하고자 한다면, 이 프로그램이 CTFE 하에서 실행되도록 허용할 수 없다. 메모리 할당을 결정적으로 지원하는 것은 충분히 실현 가능하며(사실 miri는 이미 이를 구현했다), 참조를 생 포인터로 캐스팅하는 것은 타입만 바꾸는 것이다. 여기서 실제로 문제인 유일한 연산은 두 생 포인터를 동등 비교하는 것이다. 포인터 중 하나가 덩글링(dangling)이기 때문에, 이 비교의 결과를 결정적으로 예측할 수 없다!
다시 말해, miri가 포인터를 비교하는 법을 배우게 된다면, 포인터 중 하나가 덩글링(할당되지 않은 메모리를 가리킴)하는 경우에는 CTFE 에러를 발생시키도록 만들어야 한다. 그렇지 않으면 CTFE 정확성을 위반하게 된다.
이제 한 단계 위로 올라가 const 타입 시스템을 보자. 우리는 생 포인터 비교가 CTFE 에러를 일으킬 수 있음을 보았다. 그러므로 이는 사실 const-안전한 연산이 아니다. 포인터를 정수로 캐스팅하는 경우와 마찬가지로, const 타입 시스템은 생 포인터 비교 코드를 거부해야 한다. 그러나, 두 참조 를 생 포인터로 변환한 다음 그 동등성을 비교하는 것조차 허용하지 않는 것은 아깝다! 어쨌든 참조는 결코 덩글링하지 않으므로, 이것은 완전히 const-안전한 연산이다.
다행히도 Rust에는 타입 시스템을 통제된 방식으로 비껴갈 필요에 대한 답이 이미 있다. 바로 unsafe 블록이다. 생 포인터 비교는 const-안전하지 않기 때문에 const 타입 시스템에서는 허용되지 않지만, 런타임-안전하지 않은 연산을 unsafe 블록에서 수행하는 것을 허용하듯, const-안전하지 않은 연산도 허용할 수 있다. 따라서 우리는 다음과 같이 쓸 수 있어야 한다:
const fn ptr_eq<T>(x: &T, y: &T) -> bool {
unsafe { x as *const _ == y as *const _ }
}
언제나 그렇듯 unsafe 코드를 작성할 때는 타입 시스템이 보통 유지하는 안전 보장을 위반하지 않도록 주의해야 한다. 만약 우리의 입력이 const-유효하다면, CTFE 에러를 유발하지 않으면서 const-유효한 결과를 반환하도록 수동으로 보장해야 한다. 이 예제에서 CTFE 에러가 발생하지 않는 이유는 참조가 덩글링할 수 없기 때문이다. 따라서 우리는 잠재적으로 const-안전하지 않은 연산을 내부에 포함하고 있음에도 불구하고, const 컨텍스트에서 완전히 안전하게 사용할 수 있는 추상화 ptr_eq를 제공할 수 있다. 이는, 내부적으로 잠재적으로 unsafe 연산을 많이 사용하지만 Vec 같은 타입을 안전한 Rust에서 완전히 안전하게 사용할 수 있는 것과 완벽히 유사하다.
위에서 내가 어떤 연산은 const 타입 시스템에서 거부되어야 한다고 말할 때, 실제 의미는 그 연산이 const 컨텍스트에서 unsafe여야 한다는 뜻이다. 정렬 제약을 활용해 포인터의 정렬된 부분에 비트를 끼워 넣는 등의 완전히 결정적인 방식으로, 예를 들어 포인터→정수 캐스트조차도 const-안전한 코드 내부에서 사용할 수 있다.
Rust의 CTFE에는 아직 언급하지 않은 또 하나의 측면이 있다. 바로 정적 값의 승격(promotion)이다. 이는 다음 코드를 정타입(well-typed)으로 만들어 주는 메커니즘이다:
fn make_a_3() -> &'static i32 { &3 }
이것은 지역적으로 생성된 값에 대한 참조를 'static 수명으로 반환하고 있으므로 거부되어야 할 것처럼 보일 수 있다. 하지만 대신, 마법(TM)이 일어난다. 컴파일러는 3이 컴파일 타임에 계산되어 정적 메모리(예: static 변수)에 둘 수 있는 정적 값임을 판단하고, 따라서 &3은 수명 'static을 가질 수 있다. 이는 &(3+4) 같은 경우에도 작동한다. static 변수는 const와 마찬가지로 컴파일 타임에 계산되므로, 여기서도 CTFE가 개입한다.
여기서 근본적으로 새로운 점은 사용자가 값을 승격해 달라고 요청하지 않았다는 것이다. 이는 어떤 값을 승격할지 결정할 때 정말 조심해야 한다는 뜻이 된다. miri가 평가할 수 없는 것을 승격하면 CTFE 에러가 발생하고, 아무 이유 없이 컴파일을 깨뜨린 셈이 된다. 우리는 miri가 실제로 계산할 수 있다고 기대할 수 있는 값만 승격해야 한다. 즉, const-안전한 코드의 결과만 승격해야 한다. 이미 눈치챘겠지만, 내가 제안하는 것은 그 목적에 const 타입 시스템을 사용하는 것이다. Const 사운드니스는 이미 그것이 const 안전성을 보장하는 방법임을 말해 준다.
나는 안전하게 const-타입-검사된 값만 승격할 것을 제안한다. (따라서 unsafe 블록 안에 있더라도 const-안전하지 않은 연산이 개입된 값은 승격하지 않는다.) 함수 호출이 있다면, 함수는 안전한 const fn이어야 하고 모든 인수 역시 const-타입-검사되어야 한다. 예를 들어, &is_eight_mod_256(13)은 승격되지만 &is_eight_mod_256(Box::into_raw(Box::new(0)) as usize)은 승격되지 않는다. 타입 시스템에서 보통 그렇듯이, 이는 다른 함수의 본문을 들여다보지 않는 전적으로 로컬한 분석이다. 우리의 const 타입 시스템이 사운드하다고 가정하면, 승격으로 인해 CTFE 에러가 날 수 있는 유일한 경우는 안전한 const fn 안에 사운드하지 않은 unsafe 블록이 있을 때뿐이다.
특히, 우리는 공개 여부와 무관하게, const-유효한 입력에 대해 CTFE 에러를 일으킬 가능성이 조금이라도 있다면 라이브러리 작성자가 반드시 unsafe const fn을 올바르게 표기할 것에 의존하게 된다. 실제로 unsafe인 const fn이 있는데 “그래도 private니까 괜찮다”고 주장하는 것은, 컴파일러가 그 함수의 결과를 승격하기로 결정할 수 있으므로 소용이 없다. 다만, 이는 같은 크레이트 내부의 코드만 깨뜨릴 수 있고 로컬하게 고칠 수 있으므로, 내게는 합리적인 절충안으로 보인다.
고려해 볼 다른 흥미로운 포인트는, 승격을 생각할 때는 아마 CTFE 정확성을 훨씬 더 신경 쓸 것이라는 점이다. 결국 사용자는 런타임 동작을 요청했고, CTFE에서 완전히 다른 동작을 받는다면 문제가 될 것이다. miri가 난해한 부동소수점 이슈를 제외하면 CTFE-정확하다는 뜻은, “오직” 부동소수점 연산의 특정 동작에 의존하는 사람들만 영향을 받을 수 있다는 뜻이며, 대개 LLVM은 이미 그들이 하고 있는 가정들을 위반할 공산이 크다. (miri의 부동소수점 구현은 완전히 온당하며 표준을 준수해야 한다. 불확실성의 근원은 LLVM과 x87 반올림의 특수성이다.) 이것이 승격에 어떤 영향을 미쳐야 할지, 또 실제로 어떤 영향을 미치게 될지는 잘 모르겠다.
이 글에서는 CTFE 결정성과 CTFE 정확성(둘 다 miri 같은 CTFE 엔진의 속성), 그리고 const 안전성(코드 조각의 속성)과 const 사운드니스(타입 시스템의 속성)에 대해 논의했다. 특히, const 컨텍스트에서 안전한 코드를 타입 검사할 때, 그 코드는 const-안전하다고 보장하자, 즉 CTFE 에러를 만나지 않는다고(“런타임” Rust 코드에서와 마찬가지로 패닉은 허용) 제안한다.
아직 열려 있는 질문은 많다. 특히 const fn과 트레이트의 상호작용이 그렇다. 하지만 이런 논의를 할 때 이 용어들이 유용하기를 바란다. 타입 시스템이 우리를 이끌게 하자 :)
이 글의 초안에 피드백을 준 @oli-obk에게, 그리고 나로 하여금 이런 아이디어와 용어를 발전시키게 한 #rust-lang의 흥미로운 논의를 제공한 @centril에게 감사한다. 피드백이나 질문이 있다면, internals 포럼에서 함께 이야기하자!