열다섯 개의 차용, 그리고 아홉 개의 버그 — Gleam 컴파일러를 읽고 Mere에 들인 것
Gleam의 컴파일러를 내 컴파일러 —— backend 다섯 개를 가진 ML 계열 언어 Mere —— 옆에 펴 놓고 다섯 번 읽으며 열다섯 가지를 가져왔다. 그중 열한 개는 내 컴파일러가 이미 계산하고 있던 답을 출구에 연결하는 배선이었고, 언어 기능이라 부를 만한 것은 둘뿐이었다. 돌아온 것은 차용물이 아니다. 작은 기능 하나를 backend 다섯 개 전부에 통과시키게 만들고, 항목마다 게이트를 한 개씩 쓰게 만든 것 —— 거기서 결함 아홉 개가 나왔다. 그중 하나는 이미 출시되어 있었다.
며칠 동안 Gleam의 컴파일러를 내 컴파일러 옆에 펴 놓고 지냈다. Gleam은 BEAM 위에서 도는 정적 타입 언어이고 JavaScript로도 출력한다. 이쪽 —— Mere —— 은 ML 계열이고 backend가 다섯 개이며 GC가 없다. 아래층에서는 거의 아무것도 공유하지 않는다. 그래서 읽을 가치가 있었다: 바닥이 전부 어긋나는 두 컴파일러 사이에서 옮겨 가는 것은 대개 사람이 만지는 쪽의 이야기이기 때문이다.
다섯 번 훑으면서 매번 같은 질문을 던졌다: 무엇을 가져와야 하나.
첫 번째 훑기 전에 규칙을 한 줄 정해 두었다. 정하지 않으면 이런 독서는 희망 목록이 된다.
이미 계산하고 있는 답을, 아직 연결하지 않은 출구에 잇는 것만 가져온다.
결국 열다섯 개를 가져왔다. 열한 개는 배선이었다. 두 개는 작은 새 분석. 언어 기능은 두 개. 다섯 번째 훑기에서는 새 기능이 하나도 나오지 않았다 —— 그 무렵 상류 changelog의 컴파일러 절은 거의 전부 “에러 메시지의 품질”이었고, 그 자체가 하나의 발견이다. 뒤에서 다시 돌아온다.
다만 이 글은 열다섯 개에 대한 글이 아니다. 가져오는 작업이 내 컴파일러의 버그를 아홉 개 찾아냈다는 이야기이고, 그 대부분은 가져온 것 안에 없었다.
규칙이 만들어 낸 것
“배선”은 정의보다 실례가 빠르다. 내 language server는 빠진 match 가지를
이미 계산할 수 있었다 —— 망라성 검사가 에러 메시지 안에서 빠진 생성자를 이름으로
부르고 있다. 그것을 code action으로 내놓은 적은 한 번도 없었다. 아무도 요구하지
않았기 때문이다. Gleam의 server는 code action을 마흔아홉 개 갖고 있다.
이쪽에 세 개를 더하는 데 반나절이 걸렸다. 답은 있었고, 프로토콜의 필드가 없었을 뿐이다.
같은 모양이 반복됐다. hover는 타입은 알았지만 정의 위에 있는 doc 주석은 보지 않았다.
--decls는 선언 목록을 찍었지만 텍스트라서 아무도 읽을 수 없었다.
prelude의 assert는 사용자의 줄을 가리키는 스택을 계산해 놓고
prelude 자신의 줄 위치를 보고했다. 전부 답은 손에 있었고 출구에서 버리고 있었다.
여기까지는 기분 좋은 발견이고, 이것만이면 지루한 글이었다. 불편해지는 것은 이것들을 시스템 전체에 통과시키기 시작하면서부터다.
1. 기능 하나, backend 다섯, 버그 넷
echo x는 값을 쓰여 있는 줄과 함께 stderr로 내보낸다. 디버깅용 소도구이고,
기능으로서는 가장 작은 축이다 —— prelude에 정의 두 개, 범위를 보는 재작성 패스 하나.
그리고 backend 다섯 개 전부에서 돌린 뒤 stderr를 바이트 단위로 비교했다.
WebAssembly 호스트가 print_err를 제공하지 않고 있었다.
방출 쪽은 243개 릴리스 동안 그것을 import하고 있었고, 호출하는 프로그램은
instantiate에 실패했다 —— 답이 틀린 게 아니라 모듈이 올라가지 않는다.
아무도 부르지 않았기 때문이다. echo가 오기 전까지는 부를 이유가 없었다.
RISC-V backend의 print_err는 끝의 줄바꿈을 쓰지 않았다. 나머지 넷은 쓴다.
같은 프로그램, 같은 입력인데 어느 backend로 컴파일했느냐에 따라 stderr가 달랐다.
parity 스위트는 바로 이런 결함을 위해 존재하고, 그리고 stdout을 비교하고 있었다.
다음은 줄 번호. RISC-V 경로에서는 prelude가 사용자 소스 앞에 텍스트로 붙는다.
그래서 프로그램의 두 번째 줄은 lexer에게 1925번째 줄이다.
진단은 출구에서 이미 보정하고 있었다 —— 모든 에러 메시지가 오프셋을 빼고 있었다.
보정되지 않은 것은 프로그램 안으로 들어가는 위치였고,
그게 바로 echo가 하는 일이다(실행 시 찍을 문자열에 “line 1925”를 구워 넣는다).
같은 미보정 경로 때문에, 읽히지 않은 바인딩 경고가 prelude의 내용을
사용자가 쓴 줄인 것처럼 보고했다.
결함 넷, 그중 하나는 243개 릴리스 동안 잠복. 작은 기능 하나를 어디서나 돌릴 만큼 진지하게 다룬 것만으로 나왔다.
2. 포매터가, 방금 더한 설탕을 먹었다
echo는 파싱 중에 내려간다. echo x는 소스 위치를 들고 있는 호출이 된다.
파서 뒤의 모든 단계는 내려간 형태를 본다. 그게 목적이다.
포매터는 파서 뒤의 단계다.
그래서 echo x가 들어 있는 파일에 mere fmt를 걸면 echo_at "line 2" x가 나왔다.
자기가 포맷하는 프로그램을 고쳐 쓰는 포매터를 만들어 버렸고,
설탕을 더한 바로 그날 오후에 그랬다. 설탕을 남기는 파싱 모드가 필요했고,
그 모드를 쓰는 호출자는 정확히 하나다.
이걸 적는 이유는 발견된 방식 때문이다. 임시 파일에 mere fmt를 손으로 쳤다.
게이트는 한 개도 울리지 않았다. 포매터 자신의 게이트는 주석 개수를 비교하고,
왕복 게이트는 echo가 하나도 없는 코퍼스 위에서 돈다 —— 기능은 하루 된 것이었다.
3. 같은 항의가 두 번 그려지고 있었다
타입을 두 번 선언하면 잘 쓰인 메시지가 나오고 위치는 하나도 나오지 않았다. 고치는 데 열 줄이 들었다. 파서의 표는 어느 타입 이름이 어디서 선언됐는지 이미 기록하고 있고, 두 번 선언된 이름은 거기 항목이 두 개 남는다.
고치고, 다시 빌드하고, 커맨드라인 출력은 바뀌지 않았다.
mere check —— 에디터 경로 —— 는 그 항의에 함수 하나를 통해 도달한다.
컴파일러는 예외를 던지고, 그것은 전혀 다른 곳에서 그려진다.
질문 하나에 답이 둘이었고, 나는 아무도 읽지 않는 쪽을 고치고 있었다.
고친 방법은 재미없다. 강제된 습관 쪽이 중요하다:
진단이 고쳐졌다고 말하기 전에, 그 진단에 도달하는 경로를 센다.
이번 주에만 세 번 필요했다.
4. 네 개의 저장소 중 셋이 비어 있었다
마지막 훑기에서 다른 언어에서 온 철자 —— var x = 1;, def f(n):, a and b ——
에 대한 힌트 목록을 더했다. 의미론 쪽에는 help: 줄이 스물여섯 개 있는데
구문 계층에는 0개였기 때문이다. 표는 파싱이 실패한 자리의 token을 열쇠로 찾는다.
그 단어들 중 몇 개는 내 코드에서 진짜 식별자다.
var case val mut은 모두 내 저장소 어딘가에서 묶여 있다.
그래서 표는 답하기 전에 하나를 묻는다: 이 파일이 그 이름을 묶고 있나.
묶고 있으면 그 단어는 식별자이고 표는 입을 다문다.
어느 단어에 가드가 필요한지 정하려고 코퍼스를 쟀다: 저장소 넷, 889개 파일.
그 숫자가 틀렸다. 넷 중 셋의 경로가 존재하지 않는 디렉터리를 가리키고 있어서
각각 0개 파일을 돌려줬고, 합계는 0이 아니었으므로 아무 데도 고장 나 보이지 않았다.
진짜 코퍼스는 989개 파일이고, 다시 재니 of는 424 → 943,
var는 4 → 29가 됐다.
늘어난 몫은 잡음이 아니었다. 전부 튜플 패턴의 바인딩이었다 ——
let (val, j) = scan_octal s (i + 1) 0 0 in,
let (classes, var, prefix, r2) = parse_rescue_head r in ——
그리고 내 가드는 let NAME = 모양만 알고 있었다.
그 실재하는 줄 위에서, 컴파일러는 작성자에게 다른 언어의 예약어 이야기를 하고 있었다.
코퍼스 측정은 닿은 범위만큼의 가치를 가진다. 파일 수를 저장소별로 찍었다면 0이 셋 나란히 있는 게 한눈에 보였을 것이다. 지금은 그렇게 한다.
5. 버린 실험이, 이미 출시된 구멍을 찾아냈다
네 번째 훑기에서 배선보다 큰 것이 나왔다. Gleam에서는 모듈 안 정의의 순서가 문제되지
않는다. 컴파일러가 호출 그래프를 만들어 강결합 성분을 의존 순서로 타입 검사하기 때문이다.
Mere에서는 아래에 정의한 함수를 부를 수 없다. 우회는 전부를 하나의
let rec ... and ...에 넣는 것이고, 내가 만든 Ruby 부분집합의 main 파일에서는
그 그룹이 650개 이어진다.
실험으로 만들었다. 동작했다 —— 뒤쪽 참조가 컴파일되고 돌았다. 그리고 무엇을 샀는지 쟀더니 거의 아무것도 사지 못했다: 선언 2,858개 중 더 일반적이 된 것은 네 개였고, 넷 다 아무도 쓰지 않을 우연한 일반성. 실험은 버리고 이유를 적었다.
나가는 길에 남긴 것이 있었다. 실험의 한 단계가 이웃한 바인딩을 묶고 있었는데, 그 단계가 들어가 있으면 거절해야 할 회귀 테스트가 거절하지 않게 됐다.
실험이 검사를 깨뜨린 게 아니다. 원래 있던 구멍에 평범한 바인딩을 흘려보낸 것이었다.
Mere의 라이브러리 모드는 export된 호출마다 전용 메모리 영역을 주고 반환할 때 해제한다.
그리고 호출 중에 만든 컨테이너를 모듈 상태에 넣는 프로그램을 거절한다 ——
포인터가 영역보다 오래 살기 때문이다. 이 거절은 let 바인딩을 타입 검사하는 자리에
들어 있다. let rec 바인딩은 같은 match의 다른 가지에서 타입 검사되고,
그 가지는 손으로 쓰여 있으며 네 개의 선언 루프에 나뉘어 있다.
넷 다 이 검사를 갖고 있지 않았다.
값 제한도 마찬가지였다. let store = vec_new ();는 의도적으로 단상인데,
let rec store = vec_new ();라고 쓰면 일반화되어
벡터 하나가 int와 str을 동시에 담을 수 있었고, 두 사용처 모두 불평하지 않았다.
이쪽은 출시된 컴파일러에서, 단어 하나 바꾸면 닿았다.
지금은 둘 다 네 루프가 공유하는 하나의 입구를 지난다(let 쪽은 이미 그랬다).
내가 쓴 게이트는 두 규칙 중 어느 쪽에 대한 것도 아니다.
최상위 바인딩의 다섯 가지 철자 전부에서 각 성질을 돌린다 ——
let / let rec / 그룹의 멤버 / 그 둘을 module 안에 넣은 것 ——
철자마다 판정이 어긋나면 떨어진다. 어느 쪽이 옳은지와 무관하게.
이전 빌드에 걸면 정확히 여섯 군데에서 빨개진다.
모양
다른 컴파일러를 읽는 일은 보통 “기능을 찾는 방법”으로 설명된다. 그렇게 되지 않았다. 열다섯 개의 차용 중 기능은 둘이었고 다섯 번째 훑기는 0이었다 —— Gleam 자신의 최근 작업은 압도적으로 에러 메시지의 품질에 대한 것이고, 그것은 “사용자가 있다는 것”이 컴파일러의 우선순위에 무엇을 하는지 알려 준다.
가져오기가 실제로 한 일은 나를 움직이게 한 것이었다. 디버깅 소도구 하나를 backend 다섯에 통과시켰더니 결함이 넷 나왔다. 빌린 항목마다 게이트를 하나씩 썼더니 —— 열한 개, 모두 “빨개질 수 있음”을 증명하는 독 검사가 붙어 있다 —— 나머지가 나왔다. 전에 가드레일은 프로그램 하나를 지키고 게이트는 질문을 지킨다 고 썼다. 지킬 가치가 있는 질문을 열한 개 늘리는 방법으로 이것이 가장 쌌다. 포매터 버그도, 두 경로 버그도, 코퍼스 버그도, 출시된 건전성 구멍도, 차용물 자체가 아니라 차용물 주변의 작업이 찾았다.
이 글에는 열다섯 개의 기능과 깔끔한 표로 끝나는 판본도 있을 수 있다. 정직한 끝맺음은 더 작고, 그래서 더 낫다: 기능은 대개 이미 거기 있었고, 배선을 기다리고 있었다. 모방은 지름길이 아니었다. 계측기였고, 재고 있던 것은 내 컴파일러였다.
기록을 위한 숫자: 테스트 2,767 → 2,817. 게이트 열한 개 추가(전부 독 검사 포함). CI 잡 열한 개 추가. 구문 계층은 설명 0줄에서 열여덟 줄짜리 목록으로 —— 파싱 에러를 던지는 149군데는 하나도 건드리지 않고.