왜 하나 이상의 backend인가, 그리고 그것들을 동일하게 유지한다는 것
Mere는 네 가지로 돈다: 트리 워킹 인터프리터, 그리고 C·LLVM IR·WebAssembly로의 코드 생성기. 요점은 도달 범위만이 아니다 —— 넷 모두가, 같은 프로그램에서 바이트 단위로 동일한 출력을 내야 한다. 그 일치가, backend를 서로의 테스트 오라클로 바꾼다. 그리고 그것은, 언어의 제1원칙을 구현으로 가져온 것이다: 어느 backend를 골랐는가가, 프로그램의 동작을 바꾸는 숨은 변수여선 안 된다.
두 Part가 Mere가 무엇인가를 그렸다 —— 메모리 모델과 이펙트 시스템, 어느 쪽도 명시적이고, 어느 쪽도 검사 가능하다. 하지만 언어는, 무언가가 그것을 실행하기까지는 약속에 불과하다. Part IV는 실행의 이야기이고, 그것은, 무엇을 사는지를 보기 전까지는 과잉 설계로 보이는 선택에서 열린다: Mere는 한 가지 방식으로 돌지 않고, 네 가지로 돌며, 그 넷을 엄밀한 일치로 유지한다.
같은 언어를 돌리는 네 갈래 길
네 타깃이 존재하는 것은, “이 프로그램을 돌린다”가 장소에 따라 다름을 뜻하기 때문이다:
- 트리 워킹 인터프리터는 구문 트리를 걸으며 직접 평가한다. 그것은 빠른 안쪽 루프 —— REPL, 재빠른 테스트, 코드를 쓰면서 손을 뻗는 것 —— 이고, 동시에 기준을 겸한다: 프로그램이 무엇을 뜻하는가의, 소박하고 명백히 옳은 읽기다.
- C backend는, 임의의 C 컴파일러로 컴파일하는 C 소스를 뱉는다. 즉 어디서나 돈다 —— 모든 플랫폼에 C 툴체인이 있다.
- LLVM IR backend는 LLVM의 중간 표현을 뱉고, 그 생태계의 최적화기와 네이티브 코드 생성에 프로그램을 맡긴다.
- WebAssembly backend는 Wasm을 뱉는다. 그래서 같은 프로그램이 브라우저, 혹은 임의의 샌드박스화된 Wasm 호스트에서 돈다.
하나의 언어, 네 실행 환경, “이 줄을 지금 당장 평가한다”에서 “이것을 브라우저에 배송한다” 까지의 폭을 덮기 위해 골라졌다. 그것만으로도 상응하는 공학이다. 하지만 네 타깃으로 컴파일하는 것은, 쉬운 쪽의 주장이다. 어려운 것은 다음 한 문장이다.
어려운 부분: 그것들은 엄밀히 일치해야 한다
프로그램이 네 backend 모두에서 컴파일된다는 것만으로는 부족하다. Mere가 스스로에게 부과하는 요건은, 넷 모두가 같은 프로그램에서 바이트 단위로 동일한 출력을 내는 것이다 —— “동등”도, “정형을 빼면 충분히 가깝다”도 아니라, 같은 바이트다. 열을 넘는 현실적인 프로그램 —— JSON 파서, 작은 계산기, S식 리더, 그리고 천 줄을 넘는 SQL 풍의 엔진에 이르기까지 —— 이, 인터프리터와 세 코드 생성기 모두에서 돌고, 무엇 하나 다르지 않은 출력을 낸다. 전면에서 diff가 제로다.
그것은 까다로운 기준이고, 그것을 채우는 것이 Part IV 일의 대부분이다. 하지만, 왜 기준이 거기에, 더 관대한 어딘가가 아니라 놓이는가를 분명히 할 가치가 있다. “네 backend에서 바이트 단위 동일”은 완벽주의로 들리지만, 실은 언어의 제1원칙을 한 단 아래에 적용한 것이기 때문이다.
parity는 제1원칙을 codegen에 적용한 것
Mere는, 동작을 프로그램에서 읽어내어 확신할 수 있는 것으로 만들기 위해 존재한다. 그렇다면, 언어가 네 가지로 돌면서, 그것들이 일치함을 주장하지 않는다면 무엇을 뜻하는지 생각해 보자. 그것은, backend가 프로그램의 동작으로의 숨은 입력이 됨을 뜻한다: 같은 소스가, 브라우저용으로 컴파일되면, 인터프리터 아래와는 미묘하게 다른 무언가를 표시할지도 모른다 —— 반올림, 순서, 엣지 케이스. 어쩌다 어느 backend를 썼는가가, 코드가 하는 일을 잠자코 바꾸는 보이지 않는 변수가 된다. 그것이야말로, 언어 전체가 폐하기 위해 세워진, 암묵적이고 주변적인 영향이다. 메모리와 이펙트를 명시로 만들려 싸운 언어가, 마지막 한 걸음에서 “어느 컴파일러인가”에 결과를 조용히 바꾸게 한다면, 그것은 자신과 모순된다.
그래서 바이트 단위의 parity는, 마지막에 붙이는 품질 보증의 사치가 아니다. 명시적 타입·명시적 이펙트와 같은 약속을, 구현으로 가져온 것이다: backend의 선택은 의미론적 변수여선 안 된다. 인터프리터가 프로그램의 의미를 정하고, 그리고 각 코드 생성기는, 그 의미를 바이트까지 재현할 의무를 진다. 어떤 괴리도, 허용된 방언이 아니라, backend의 버그다.
예상 밖의 배당: backend가 서로를 테스트한다
네 backend를 동일한 출력으로 유지하는 것에는, 비용을 충분히 웃도는 보답이 있다. 프로그램을 네 가지로 돌리고, 출력을 비교한다: 어느 둘인가가 어긋나면, 적어도 하나의 backend가 틀렸고, 정답이 무엇이었는지를 써 내리지 않고 버그를 찾은 것이 된다. 중복이 그 자신의 테스트 오라클이다.
이것은 차분 테스트이고, 여기서는 backend가 동일할 터이기에 유별나게 값싸다. 보통, 컴파일러를 테스트한다는 것은 기대 출력을 손으로 짓는 것을 뜻한다 —— 지루하고, 엣지 케이스로의 상상력만큼 밖에 좋아지지 않는다. 일치해야 할 네 구현이 있으면, 모든 프로그램이, 넷을 자동으로 서로 비추는 테스트가 된다. 문자열 비교의, variant 태그의 레이아웃의, 평가 순서의 미묘한 버그 —— 눈으로 찾기가 고행인 부류 —— 가, 같은 입력에 대해 두 backend가 다른 바이트를 표시한 순간에 나타난다. 인터프리터는, 가장 단순하기에, 대개 “옳은 쪽”의 역을 연기하고, 코드 생성기는 그것에 맞춰진다.
장부는 정직한 채로
네 방향 parity는 큰 표면이고, 프로젝트는 그것을 커버리지 행렬로 좇는다: 모든 언어 feature, 모든 builtin에 대해, 네 backend 저마다에 표시를 —— 완료, 부분적, 아직. 그 장부의 정직함이 규율의 일부다. C backend 아래에서만 도는 feature는 완료로 표시되지 않는다; “C만”으로 표시되어, 눈에 띄게 미완이고, LLVM과 Wasm이 따라잡을 때 치를 부채다. 한 줌의 것 —— 부동소수점, 더 별난 표준 라이브러리 함수 몇몇 —— 은 인터프리터만인 채이고, 행렬은 “어딘가에서 돈다”를 “돈다”로 둔갑 시키는 대신, 그렇다고 태연히 말한다. 프로젝트가 스스로에게 부과하는 기준은, feature가 완성되는 것은 네 backend 모두가 그것에 일치했을 때이지, 하나가 그것을 돌렸을 때가 아니라는 것이다.
그 원칙은 언뜻보다 더 스케일한다 —— 뒤의 Part가, 셀프호스트된 컴파일러의 출력이 backend 간에 같은 바이트 단위 일치를 유지하는 것을 보인다 —— 하지만 형태는 이미 여기에 보인다: 것을 한 번 더 만들고, 사본이 일치한다고 주장하고, 그 일치에 검증을 시킨다.
왜가 자리 잡았으니, Part IV의 나머지는 그 방법이다. 다음 회: 인터프리터와 C backend —— 기준으로서의 트리 워킹 평가와, 컴파일된 코드로의 첫 번역. 거기서의 흥미로운 문제는, 클로저를 갖지 않는 언어에서 클로저를 어떻게 표현하는가다.