🌐 Read in English

00 — Overview (개관)

한 페이지로 보는 전체 흐름

MicroHs는 2023-05-25에 두 개의 파일로 시작했다: 람다 대수를 컴비네이터로 컴파일하는 하스켈 227줄짜리 Lam.hs와, 그 결과로 나온 컴비네이터 그래프를 평가하는 C 539줄짜리 eval.c다. 첫 커밋 ccb31388("First version," 첫 버전)은 한 자리에서 다 읽을 수 있을 만큼 작다. 이름값 하는 파서도, 오류 처리도, 모듈도 없다 — 그저 람다 표현식을 SKI 스타일 컴비네이터로 컴파일해 실행할 만큼의 장치만 있을 뿐이다. 바로 이 지점에서 시작하는 이유가 여기 있다: 시스템 전체를 머릿속에 담을 수 있다는 것.

그 뒤로 작성자 레나르트 아우구스트손(Lennart Augustsson)은 이 두 파일을 진짜 컴파일러로 키워 나간다. 나흘째, 커밋 a9178695는 노골적으로 "Make a real compiler."(진짜 컴파일러 만들기)라는 제목을 달고 있다 — 장난감 수준의 평가기가 실제 파서, 제대로 된 노드 표현, 최적화기를 갖추게 된다. 여기서부터의 성장은 선형적이지도, 깔끔하지도 않다. 다음 날 커밋 17587b2d는 "Hack around a problem."(문제를 우회하다)이라 불린다 — 동작은 하는 수정이며, 설계 결정으로 포장하지 않고 있는 그대로를 설명한다. 이 솔직함은 역사 전체를 관통한다: 2023-07-03자 6f08f029는 "Running IO still doesn't work. :("(IO 실행은 아직도 안 됨. :()라고 적혀 있으면서도 그대로 커밋된다. 망가진 상태를 커밋하는 것이 바로 이 작업 방식이기 때문이다.

이후 일주일 남짓 동안 언어가 채워진다: let, 중위 연산자, 튜플, 리스트, 문자열·문자 리터럴, do 표기법, 모듈, 프렐루드, 리스트 컴프리헨션 — 표층 문법은 하스켈이라고 알아볼 수 있는 무언가로 수렴해 가지만, 백엔드는 여전히 작은 C 런타임 위의 컴비네이터 그래프 축약이다. 이와 병행해 컴파일러는 바깥 세상과 대화하는 법을 익히고(모나딕 IO, 프로그램 상태를 파일로 직렬화하는 것) 나름의 실험 노트 방식 작업 습관도 갖춰 나간다: 명령줄 플래그, 임시방편 테스트, 첫 README.

이야기의 후반부는 컴파일러가 GHC를 벗어나 자라는 과정이다. 7월부터 컴파일러 자신의 소스를 스스로 컴파일하게 만드는 작업을 둘러싸고 커밋이 쌓인다 — 모듈이 하나씩 이식될 때마다 "Make it compile with uhs"(uhs로 컴파일되게 하기), "Make Desugar compile with uhs"(Desugar가 uhs로 컴파일되게 하기) 같은 커밋 메시지가 반복해서 나타난다. 2023-07-20, 298c9824가 uhs를 mhs로 이름을 바꾸고, 같은 기간 안에 컴파일러가 자기 자신을 성공적으로 컴파일한다. 8월 초까지 이어지는 마지막 구간은 힌들리-밀너 타입 검사기를 추가하며, 셀프 호스팅된 컴파일러가 타입 검사까지 갖춘 채 부트스트랩하게 만든다 — a38c64ce, "Now bootstraps with type checking."(이제 타입 검사까지 갖춰 부트스트랩한다), 2023-08-10에서 이 기록은 멈춘다.

77일 동안 한 명의 작성자가 남긴 232개의 커밋이며, 이 저장소의 git 역사가 1차 출처다 — 여기 담긴 그 무엇도 기억에 의존한 재구성이 아니다. 이 책의 나머지 부분에 대한 독자와의 약속은 이렇다: 1장부터 9장까지 순서대로 읽고(각 장은 바로 앞 장이 남긴 상태를 전제로 한다), "직접 해보기" 블록은 그냥 읽고 넘어가지 말고 실제로 버릴 워크트리에서 실행해 본다. 이 자료의 가치는 무엇이 바뀌었는지의 요약이 아니라, 동작하는 코드가 커밋 단위로 진화해 가는 모습을 지켜보는 데 있다.

타임라인

ChDates (2023)Commit rangeChapterSummary
0105-25ccb31388 + 21168890씨앗씨앗: Lam.hs + eval.c
0205-26 – 05-3021168890..b56ba28b진짜 컴파일러진짜 컴파일러: 파서, 노드 표현, 최적화기
0305-30 – 06-01b56ba28b..95ff6a34언어 키우기언어 키우기: let, 중위 연산자, 튜플, 리스트, 문자열, do
0406-01 – 06-0295ff6a34..8c5fbb1a세상과 대화하기세상과 대화하기: 모나딕 IO, 직렬화
0506-028c5fbb1a..8018eec8실험 노트: 작업 습관실험 노트 방식 작업 흐름: 플래그, 테스트, 첫 README
0606-02 – 06-078018eec8..41d6a7e8하스켈이 되어 가기하스켈이 되어 가기: 모듈, 프렐루드, 컴프리헨션
0707-01 – 07-1841d6a7e8..a824a175셀프 호스팅 총력전셀프 호스팅 총력전: "compile with uhs"
0807-18 – 07-20a824a175..298c9824부트스트랩부트스트랩: 컴파일러가 스스로를 컴파일하다
0907-20 – 08-10298c9824..a38c64ce마침내 타입마침내 타입: HM 검사기, 타입이 붙은 부트스트랩

6장과 7장 사이의 도약에 주목하자: 6장의 마지막 커밋 41d6a7e8는 2023-06-07 날짜이고, 7장의 첫 커밋 44004020은 2023-07-01 날짜다. 이 저장소의 역사 어디에도 24일의 공백을 설명해 주는 내용은 없다 — 로그는 그저 다시 이어질 뿐이다. 이 책에서 장의 경계는 고정된 주기가 아니라 작업의 주제적 흐름을 따른다.

이 브랜치 사용법

각 장은 어느 커밋 범위를 살펴봐야 하는지, 거기서 무엇을 빌드하고 실행해야 하는지 알려준다. 이 책 전체에서 쓰는 관례는, 메인 클론을 건드리는 대신 커밋을 각자의 워크트리로 체크아웃하는 것이다:

git worktree add /tmp/mhs-<shortsha> <shortsha> && cd /tmp/mhs-<shortsha> && nix develop $MICROHS -c make <target>

$MICROHS는 최상위 README.md의 안내대로 한 번 설정해 둔, 여러분의 클론의 절대 경로다. 커밋 작업이 끝나면 해당 워크트리를 제거한다:

git worktree remove --force /tmp/mhs-<shortsha>

이 역사를 누군가의 요약을 믿는 대신 직접 읽는 것이 이 책의 방법론 전부다. 실제 예시로, 첫 커밋을 그대로 보인다:

$ git show --stat ccb31388
 .gitignore |   6 +
 Lam.hs     | 227 ++++++++++++++++++++++++++
 Makefile   |  14 ++
 eval.c     | 539 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 786 insertions(+)

네 개의 파일, 786줄, 커밋 하나. 이것이 1장이 출발하는 시스템의 전부다. 이후 어느 장에서 어떤 커밋이 무엇을 했다고 주장하면, 직접 git show <sha>를 실행해 확인할 수 있다.

이 책이 다루지 않는 것

이 책은 하스켈 튜토리얼이 아니다 — 하스켈을 이미 편하게 읽을 수 있다고 전제한다. 현재의 MicroHs를 위한 레퍼런스도 아니다; 이 프로젝트는 이 기록이 끝난 뒤로도 오래 계속되었고, 그 이후의 역사는 이 책의 범위 밖이다. 이 책은 git 역사의 특정하고 한정된 한 구간 — ccb31388(2023-05-25)부터 a38c64ce("Now bootstraps with type checking.", 2023-08-10)까지 — 을 자세히 읽어 나가는 것이다. 의도적으로 거기서 멈춘다.

🌐 Read in English

01 — The seed (씨앗)

현재 위치

아무것도 없다. 이 커밋은 ccb31388, "First version,"(첫 버전) 2023-05-25 19:33:17 +0200 — 저장소의 첫 커밋이다. 가정할 이전 상태가 없다. 이 장의 모든 내용은 이 커밋 하나에서 그대로 읽어낸 것이다.

네 개의 파일이 함께 도착한다: .gitignore, Lam.hs(하스켈 227줄), Makefile(14줄), eval.c(C 539줄) — 총 786줄(커밋 통계는 00-overview.md 참고). 코드가 정당화하는 만큼만 읽으면, 코드가 보여주는 것은 이렇다: 작성자는 작은 하스켈류 언어에서 컴비네이터로 내려가는 컴파일러와, 한 개의 C 파일에 들어갈 만큼 작은 런타임을 원하며, 이 세션이 끝나기 전에 둘을 함께 동작시키고 싶어한다. 파서도, 타입 검사기도, 모듈 시스템도, 이름값 하는 오류 처리도 없다 — 그저 하드코딩된 람다 항 하나를 컴파일하고 실행할 만큼의 장치만 있을 뿐이다.

무슨 일이 있었나

이 커밋은 두 개의 파일로 이루어진 구조와, 그 사이에 놓인 세 번째 산출물 — 텍스트 파일 하나 — 로 구성된다.

Lam.hs: 파서 없는 컴파일러

Lam.hs는 Exp AST(Var, App, Lam, Int, Comb, Prim — 각 생성자의 역할은 aux/lambda-calculus-and-ski.md 참고)와, Var/Lam을 포함한 Exp 항을 순수 컴비네이터 항으로 바꾸는 브래킷 추상화 컴파일러(유도 과정은 aux/bracket-abstraction.md 참고), 컴비네이터 출력을 줄여주는 키셀료프(Kiselyov) 스타일의 improve 패스, 프리티 프린터, 그리고 — 파일 맨 아래에 — 하드코딩된 프로그램 하나, nfib 35를 계산하는 eNfib를 정의한다. readFile도 없고, Makefile이 넘겨주는 것 이상의 getArgs 파싱도 없으며, 소스 언어를 위한 렉서나 파서도 전혀 없다. 이 커밋의 모든 예제는 Lam.hs 안에서 손으로 직접 Exp 값으로 만들어 넣은 하스켈 소스다.

전체 파이프라인은 Makefile 하나에 다 들어갈 만큼 작다. 전문을 그대로 인용한다(ccb31388:Makefile):

test:	eval nfib.comb
	./eval nfib.comb

eval:	eval.c
	gcc -O3 eval.c -o eval

nfib.comb:	Lam
	./Lam > nfib.comb

Lam:	Lam.hs
	ghc Lam.hs -o Lam

clean:
	rm -f *.hi *.o eval Lam

아래에서 위로 읽으면 이야기의 전부다. ghc가 Lam.hs를 Lam 바이너리로 컴파일하고, Lam을 실행해 표준 출력을 리다이렉트하면 텍스트 파일 nfib.comb가 생기고, gcc가 eval.c를 eval 바이너리로 컴파일하며, eval을 nfib.comb에 대해 실행하는 것이 실제 프로그램이다. 서로 무관한 두 개의 툴체인(GHC와 C 컴파일러)이 텍스트 파일 하나에서 만난다.

컴파일러 본체는 compile이며, 모든 Lam에 대해 abstract로 위임한다. 전문을 그대로 인용한다(ccb31388:Lam.hs, 97–101행 — 그 위의 compile은 생성자당 한 줄씩이며 aux/bracket-abstraction.md 1절에서 다룬다):

abstract :: Ident -> Exp -> Exp
abstract x e@(Var y) | x == y = CI
abstract x (App f a) = App2 CS (abstract x f) (abstract x a)
abstract x (Lam y e) = abstract x $ improveC $ abstract y e
abstract x e = App CK e

네 개의 식뿐, 이름 테이블로의 재귀도 없고 환경도 없다 — 설계상 그렇다. 왜 그런지는 aux 문서가 짚어준다. Lam.hs는 이 커밋에서 227줄을 넘지 않으며, 그 길이의 대부분은 프리티 프린터와 improve/improveC/improveT 재작성 규칙 (aux/bracket-abstraction.md 3절), 참조용 축약기 reduce/redOne, 그리고 eNfib를 읽기 좋은 형태로 작성하기 위한 작은 컴비네이터 조립 DSL(ToLam, $$, comp)이 차지한다.

eval.c: 런타임

eval.c는 독립적인 C 프로그램이다. 고정 크기의 셀 힙, 노드 태그에 대한 스위치문으로 구현된 모든 컴비네이터 축약 규칙, 명시적인 스파인 스택, 그리고 제자리 갱신을 위해 간접 노드를 사용하는 진짜 마크-스캔 가비지 컬렉션으로 이루어져 있다. 여기서는 더 요약하지 않는다 — aux/graph-reduction-and-laziness.md가 셀 표현, 스파인 순회, 공유, GC를 줄 단위 인용과 함께 상세히 다루고, aux/y-combinator-and-recursion.md가 Y가 재귀를 위해 진짜로 순환하는 그래프를 어떻게 만드는지 다룬다. 둘 다 바로 이 커밋을 기준으로 검증했으므로, 여기서 중복할 이유가 없다.

.comb 파일: 읽을 수 있는 텍스트 인터페이스

nfib.comb — Lam이 쓰고 eval이 읽는 파일 — 은 평범한 텍스트로, 한 줄짜리이며 사람이 읽을 수 있는 컴비네이터 구문이다. 이 커밋 시점의 워크트리에서 새로 생성해 보면 이렇게 나온다(파일 전체이며, 정말로 한 줄이다):

((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)

이것은 압축된 바이너리 덩어리가 아니다 — S/K/I/B/C/S'/C' 등이 왼쪽부터 오른쪽으로 괄호로 묶여 있으며, eval.c의 pretty()가 찍어내고 같은 파일의 parse()가 다시 읽어들인다(둘 다 aux 문서에서 다룬다). 서로 무관한 두 프로그램 사이의 교환 형식으로 평문 텍스트를 택한 것 자체가 눈여겨볼 만한 결정이다. 이 덕분에 중간 표현을 cat으로 볼 수 있고, 두 버전을 diff할 수 있고, 두 컴파일러 중 어느 쪽도 없이 테스트 케이스를 손으로 작성할 수 있다.

main 안의 주석 처리된 블록

main은 배포되는 eNfib-출력 한 줄만으로 이루어져 있지 않다. ccb31388:Lam.hs의 8–30행은 실행되는 그 줄 바로 위에 놓인 {- ... -} 주석 블록이다:

main :: IO ()
main = do
{-
  let c1 = compile e1
      c1' = improve c1
      r1' = reduce (App c1 (Var "k"))
      r1'' = reduce (App c1 (Int 5))
  pp e1
  pp c1
  ...
  pp $ improve $ compile $ App (Lam "x" (App2 (Var "x") (Int 10) (Int 2))) (Prim "-")
-}
  putStrLn $ toStringP $ eNfib `App` 35

이 블록은 e1, e2, e3(main 아래 몇 줄에 손으로 작성된 세 개의 작은 테스트 항)를 다루는 21줄짜리 pp/let 호출로, 이들을 컴파일하고, 개선하고, 축약하고, 모든 중간 결과를 출력한다. 이것은 못 쓰게 되어 버려진 코드가 아니다 — main 안에 붙여넣은 뒤 지우지 않고 주석 처리만 한 REPL 세션이며, 그래서 여전히 그 자리에 있고, 여전히 올바르며, 여전히 실행할 수 있다 — "직접 해보기" 절 참고.

커밋 2: "Clean more"(더 정리하기)

21168890은 ccb31388 30초 뒤에 작성(및 커밋)되었다 — 같은 날, 30초 차이의 두 타임스탬프 — 그리고 Makefile의 정확히 한 줄만 바꾼다:

 clean:
-	rm -f *.hi *.o eval Lam
+	rm -f *.hi *.o eval Lam *.comb

이것이 diff의 전부다. make clean이 생성된 nfib.comb를 지우지 않고 있었는데, 첫 버전을 커밋한 지 30초 만에 이를 알아채고 고쳤다. 이 커밋에서 바뀐 것은 이것뿐이다.

B' 공백

이 커밋에서 배포되어 그대로 남는 미해결 사항이 하나 있다. eval.c는 BB 태그(B'로 출력됨)를 선언하고, 파서는 B' 구문을 받아들이며, pretty()도 이를 출력할 줄 안다 — 하지만 eval의 스위치문에는 case BB:가 없어서, B' 노드가 런타임에 도달하면 bad tag 12로 멎는다. 이것이 문제가 되지 않는 이유는, Lam.hs의 redOne이 이 규칙을 올바르게 구현하고 있음에도 improveC가 이 커밋에서는 B'를 전혀 발생시키지 않기 때문이다. aux/lambda-calculus-and-ski.md 5절에서 검증된 충돌을, aux/bracket-abstraction.md 4절에서 compile/improve가 왜 이를 유발하지 않는지를 다룬다.

왜 중요한가

이 책의 나머지 전부는 이 두 파일, 혹은 그 사이의 형식이 자라나는 과정이다. 나흘 뒤 등장하는 파서(2장)는 main 안에 손으로 만든 Exp 값들을 대체하고, 개선된 노드 표현과 최적화기는 eval.c의 태그 스위치와 improve의 재작성 규칙을 확장하며, 이후의 셀프 호스팅 작업은 파이프라인의 모양을 한 번도 바꾸지 않은 채 Lam.hs 자체를 모듈 단위로 대체해 나간다. .comb 파일을 통한 인계 — 텍스트 형식으로 컴파일한 뒤, 별도의 프로그램으로 그 텍스트 형식을 실행하는 것 — 은 두 달 뒤의 부트스트랩이 사용하는 것과 같은 모양이며, 한쪽에 훨씬 큰 컴파일러가 있을 뿐이다. 파서 대신 하드코딩된 프로그램으로 시작한 것이야말로 첫날이 처음부터 끝까지 실제로 동작하는 무언가로 끝나게 만드는 이유다: 그날 아침에는 존재하지 않았던 그래프 축약 기계가 실제로 계산해 낸 nfib 35.

실험 노트 여백

커밋 1과 2에서 이미 드러나는 습관들:

  • 탐색적으로 작성한 코드는 지우지 않고 주석 처리한다. main 안의 e1/e2/e3 스크래치 블록은 파일에 온전히 기능하는 채로 남아 있으며, 다만 기본으로는 실행되지 않을 뿐이다.
  • "Clean more"(더 정리하기)는 "First version"(첫 버전) 30초 뒤에 도착한다 — 나중에 정리를 몰아서 하는 대신, 같은 세션에서 make clean의 누락을 즉시 고친다.
  • 하스켈 쪽과 C 쪽 사이의 인터페이스는 눈으로 읽을 수 있는 텍스트 파일이지, 직렬화된 바이너리 형식이 아니다.
  • 이 커밋은 모든 컴비네이터가 끝까지 연결되기를 기다리지 않고, 알려진 충돌 케이스(B')를 그대로 안은 채 배포된다. 아직 그 경로에 도달할 수 없기 때문에 배포를 막지 않는다.

직접 해보기

이 책 전체에서 쓰는 관례: 커밋을 자신만의 워크트리로 체크아웃한다.

git worktree add /tmp/mhs-ccb31388 ccb31388
cd /tmp/mhs-ccb31388
nix develop $MICROHS -c make test

($MICROHS는 최상위 README.md의 안내대로 한 번 설정해 둔, 여러분의 클론의 절대 경로다.) GHC 컴파일에서 무해한 경고 하나가 뜨고, 이어서 프로그램 출력이 나올 것이다:

Lam.hs:211:13: warning: [GHC-63394] [-Wx-partial]
    In the use of ‘head’
    ...
./eval nfib.comb
((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)
29860703
node size=24, heap size=100000
1045124618 reductions, 1944 GCs

29860703이 nfib 35다. 경고는 Data.List.\\가 부분 함수 head를 211행에서 사용하는 것에 관한 것이다(새 변수 이름을 고르는 데 쓰이는 \\의 한 버전) — 이 커밋의 용례에서는 빼는 대상 리스트가 항상 비어 있지 않으므로 여기서는 무해하지만, 실제로 부분성이 있는 함수이니 GHC가 지적하는 것이 옳다.

연습 문제 1: 주석 처리된 실험 실행해 보기

Lam.hs를 열어 주석 표시 두 개만 지운다: 8행의 {-와 30행의 -}. 그 사이는 그대로 둔다. 그런 다음:

nix develop $MICROHS -c make Lam
nix develop $MICROHS -c ./Lam

이렇게 하면 16줄이 출력된다 — 블록 자체의 pp/putStrLn 호출들에 이어 평소의 nfib 항이 나온다. 첫 세 줄은 e1 = \x. + x x에 compile/improve를 적용한 결과다:

(\x. + x x)
S (S (K +) I) I
S + I

— aux/bracket-abstraction.md 2절이 손으로 유도한 compile e1, 3절의 improve (compile e1)과 정확히 일치하지만, 이번에는 (ghci가 아니라) 독립적인 두 번째 방식으로 재확인된 것이다. 좀 더 아래(실행의 14번째 줄)에서 pp $ improve $ compile e3는 다음을 출력한다:

T

— aux/bracket-abstraction.md 5절이 ghci로 확인하기 전에 손으로 예측해 보라고 한 것과 같은 답이다. 여기서는 같은 계산을 ghci 대신 컴파일된 Lam 바이너리 안에서 인라인으로 수행한 것일 뿐이다. (다음으로 넘어가기 전에 {-/-}를 다시 붙여 놓는다 — 다음 두 연습 문제가 nfib.comb를 다시 생성하는데, 주석을 풀어놓은 채로 두면 이 블록의 추가 출력도 그 파일에 함께 들어가 버린다. nfib.comb는 그저 Lam의 표준 출력을 리다이렉트한 것이기 때문이다.)

연습 문제 2: 더 작은 nfib 실행해 보기

31행을 eNfib \App` 35에서 eNfib `App` 25로 바꾼다. 다시 빌드하기 전에, eNfib자신의 점화식으로부터 답을 예측해 본다 ([aux/y-combinator-and-recursion.md](aux/y-combinator-and-recursion.md) 3절에서 인용):n < 2이면 nfib n = 1, 아니면 nfib(n-1) + nfib(n-2) + 1`. 기저 사례부터 전개하면:

nfib: 1, 1, 3, 5, 9, 15, 25, 41, 67, 109, 177, 287, 465, 753, 1219,
      1973, 3193, 5167, 8361, 13529, 21891, 35421, 57313, 92735,
      150049, 242785
       (n= 0  1  2  3  4   5   6   7   8    9   10   11   12   13   14
            15    16    17    18     19     20     21     22     23
              24      25)

따라서 nfib 25는 242785가 되어야 한다. 다시 빌드해 확인한다:

nix develop $MICROHS -c make test
./eval nfib.comb
((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 25)
242785
node size=24, heap size=100000
8497488 reductions, 15 GCs

예측대로 242785다 — 그리고 지나가는 김에 짚어 두자면, 호출 횟수가 대략 피보나치 성장률을 따르는 함수치고는 nfib 35보다 두 자릿수 넘게 적은 축약 횟수다(1,045,124,618 대비 8,497,488). 다음 연습 문제로 넘어가기 전에 31행을 다시 35로 되돌리고 다시 빌드한다 — Lam.hs만 고쳐서는 디스크에 남아 있는 nfib.comb가 재생성되지 않으며, 그 파일은 여전히 25 버전이다:

nix develop $MICROHS -c make test

이제 원래의 ... 35) 항과 29860703이 다시 출력되어야 한다. nfib.comb는 이제 원래 내용으로 되돌아왔다.

연습 문제 3: improveC를 부수고 항이 커지는 것을 지켜보기

improveC(ccb31388:Lam.hs, 106–117행)에는 catch-all 앞에 특수 처리된 여섯 개의 패턴 중 하나로 다음이 있다:

    (App CS (App CK e1),     e2)        -> improve (App2 CB e1 e2)

이것은 B를 도입하는 규칙이다 — 왜 존재하는지는 aux/bracket-abstraction.md 4절 참고. 먼저, 나중에 비교할 수 있도록 현재(아직 깨지지 않은) nfib.comb를 저장해 둔다:

cp nfib.comb nfib.comb.orig

그런 다음 improveC의 그 한 줄을 지우고 다시 빌드한다:

nix develop $MICROHS -c make test
./eval nfib.comb
((Y ((C ((S (K C)) ((S (K (S (> 2)))) ((C ((S (K C)) ((S (K (S (K +)))) ((S ((S (K S)) ((S (K (S (K +)))) ((C ((S (K S)) K)) (-' 1))))) ((C ((S (K S)) K)) (-' 2)))))) 1)))) 1)) 35)
29860703
node size=24, heap size=100000
1373651156 reductions, 2542 GCs

여전히 29860703이다 — improve 규칙 하나를 제거해도 프로그램이 계산하는 값은 바뀌지 않는다. 컴파일된 항의 크기와 eval이 이를 축약하는 데 드는 작업량만 바뀔 뿐이다. 편집 직전에 저장해 둔 nfib.comb.orig와 비교하면:

$ nix develop $MICROHS -c diff nfib.comb.orig nfib.comb
1c1
< ((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)
---
> ((Y ((C ((S (K C)) ((S (K (S (> 2)))) ((C ((S (K C)) ((S (K (S (K +)))) ((S ((S (K S)) ((S (K (S (K +)))) ((C ((S (K S)) K)) (-' 1))))) ((C ((S (K S)) K)) (-' 2)))))) 1)))) 1)) 35)

(diff는 이 프로젝트 자체의 Makefile에는 없지만, nix develop이 넘겨주는 PATH에는 있다. devShell이 호스트와 완전히 격리되어 빌드되지 않기 때문이다.) 한 줄짜리 항은 102바이트에서 181바이트로 커진다 — 빠진 규칙이 만들어 냈을 B 하나하나가 대신 S (K _) _ 형태로 풀려서 나타난다. eval은 그 대가를 치른다: 축약 횟수는 원래의 1,045,124,618 대비 1,373,651,156(31% 증가), GC 횟수는 1,944 대비 2,542(31% 증가) — 재작성 규칙 하나가 빠졌을 뿐인데, 관찰 가능한 동작은 그대로인 함수 하나를 두고 프로그램 텍스트와 런타임 비용이 나란히 3분의 1가량 늘어난다.

끝나면 정리한다:

git worktree remove --force /tmp/mhs-ccb31388

🌐 Read in English

02 — A real compiler (진짜 컴파일러)

현재 위치

1장은 두 개의 파일과 그 사이에 놓인 텍스트 형식 하나를 남겼다: Lam.hs는 손으로 만든 Exp 값 하나 — nfib 35를 계산하는 eNfib — 를 컴비네이터로 컴파일하고, eval.c는 그 결과를 실행한다. 아직 파서는 없다. 시스템이 실행할 수 있는 모든 예제는 하스켈 소스이지만, 컴파일러가 읽어들이는 텍스트가 아니라 Lam.hs 안에 손으로 작성한 Exp 값 그대로의 하스켈 소스다. 커밋 21168890("Clean more", 더 정리하기)은 이 장의 범위 직전 마지막 커밋이며, Makefile의 한 줄만 바꾼다. 하드코딩된 프로그램이라는 모양은 아직 전혀 움직이지 않았다.

무슨 일이 있었나

준비 운동: 2023-05-26

씨앗 다음 금요일에 세 개의 커밋이 도착하는데, 모두 여전히 옛 두 파일 세계(Lam.hs/eval.c) 안에 있으며, 아직 작성되지 않은 파서를 건드리는 것은 하나도 없다:

54806439("Use file handle for printing.", 출력에 파일 핸들 사용하기)는 eval.c의 pretty/pp 함수를 하드코딩된 printf/putchar 호출에서 명시적인 스트림을 받는 print(FILE *f, ...)/pp(FILE *f, ...)로 바꾼다. main은 여전히 두 번 다 pp(stdout, n)을 호출하므로 프로그램이 눈에 보이는 출력은 아무것도 바뀌지 않는다 — 하지만 이제 그 함수는 아무 파일 핸들이나 가리킬 수 있게 되었고, 이것이 34분 뒤 b0c33a8f가 같은 종류의 핸들에서 읽어 들이는 레이블 파싱 경로를 추가할 수 있게 해 주는 바탕이 된다.

6485a341("Move some improvements earlier.", 개선을 더 일찍 적용하기)는 규칙 집합은 바꾸지 않은 채 Lam.hs의 최적화기 순서만 재배치한다: "교환 연산의 인자를 뒤집는" 재작성((CC, Prim "+") -> Prim "+" 등)이 improveT의 케이스 목록에서 나와 improveC의 케이스 목록으로, 그것도 improveC 자신의 catch-all보다 앞으로 옮겨 간다. 두 패스는 여전히 모두 존재하지만(improve = improveT . improveC), 이제 항은 두 번째 순회를 기다릴 필요 없이 첫 번째 순회에서 뒤집기 정규화를 거친다 — 바로 위에 있는 improveC의 S'/C' 규칙이 이미 뒤집힌 피연산자에 대해 한 번의 패스만으로 발화할 기회가 늘어난다. 커밋 메시지는 의도("더 일찍 ... 옮기기")를 말할 뿐 이 메커니즘은 말하지 않는다 — 이 장이 계속 마주치는 패턴이다 ("실험 노트 여백" 참고).

b0c33a8f("Print and parse sharing/cycles.", 공유/순환 출력 및 파싱)는 인용할 가치가 있는 준비 운동 커밋이다. 이 커밋이 도입하는 형식이 a9178695부터 2장 자체의 컴파일러 출력이 사용하는 형식이기 때문이다: 트리뿐 아니라 DAG나 순환도 평평한 텍스트로 출력하는 방법이다. 파서 쪽 (b0c33a8f:eval.c, 295–306행):

  case '_' :
    l = (int)parse_int(f);  /* The label */
    if (shared[l] == NIL) ERR("shared == NIL");
    return shared[l];
  case ':' :
    l = (int)parse_int(f);  /* The label */
    if (!gobble(f, ' ')) ERR("parse ' '");
    if (shared[l] != NIL) ERR("shared != NIL");
    shared[l] = alloc_node(IND); /* Must have a placeholder for cycles */
    r = parse(f);
    INDIR(shared[l]) = r;
    return r;

:N <expr>은 공유 노드 N을 정의한다(간접 노드 자리표시자가 <expr>이 파싱되기 전에 미리 할당되는데, 이는 <expr> 내부에서 N을 되가리키는 순환 참조가 가리킬 대상을 갖도록 하기 위해서다); _N은 그것을 다시 가리킨다. 출력 쪽은 사전 패스(find_sharing)에서 두 번 방문된 노드를 모두 SHARED로 표시한 다음, 실제 출력 패스에서 SHARED 노드에 처음 도달했을 때만 :N 을 찍고 그 이후로는 매번 _N만 찍는다 (b0c33a8f:eval.c, 327–333행):

  if (MARK(n) == PRINTED) {
    fprintf(f, "_%d", LABEL(n));
    return;
  } else if (MARK(n) == SHARED) {
    fprintf(f, ":%d ", LABEL(n));
    MARK(n) = PRINTED;
  }

이 커밋 자신의 테스트 데이터에서는 아직 실제로 노드를 공유하는 것이 없다 — 기반 다지기일 뿐이다. 나흘 뒤, 17587b2d의 "hack"이 처음으로 이 레이블 형식을 실전에 쓴다.

a9178695: "Make a real compiler."(진짜 컴파일러 만들기) (2023-05-29, 16:14)

전환점이다. 새 파일 여섯 개, 561줄, git show a9178695 --stat:

 Desugar.hs    |  31 +++++++++
 Exp.hs        | 141 ++++++++++++++++++++++++++++++++++++++++
 Lam.hs        |   6 ++
 Main.hs       |  14 ++++
 Parse.hs      | 204 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 ParserComb.hs | 165 +++++++++++++++++++++++++++++++++++++++++++++++

ParserComb.hs와 Parse.hs는 파서 컴비네이터 라이브러리와 그 위에 세운 문법이다 — aux/parser-combinators.md가 둘 다 자세히 다룬다. 여기서 중요한 것은 이들이 무엇을 받아들이고 그 아래 방향으로 무엇이 바뀌는가이다. 이 커밋 시점의 표면 언어는 작다: 모듈 (module Name where와 정의 블록), 함수 정의(f x y = expr), 데이터 선언(data T = C1 t1 t2 | C2), 그리고 변수/생성자 이름, 정수 리터럴, 적용, \x. expr 람다, 생성자 패턴 절을 가진 case expr of, 괄호로 묶은 부분식으로 만들어지는 식들이다 — 대표적인 생성 규칙 하나 (a9178695:Parse.hs, 147–150행):

pDef :: P Def
pDef =
      Data <$> (pKeyword "data" *> pLHS pUIdent <* pSymbol "=") <*> esepBy1 ((,) <$> pUIdent <*> many pAType) (pSymbol "|")
  <|> Fcn <$> (pLHS pLIdent <* pSymbol "=") <*> pExpr

Exp.hs는 Lam.hs 자신의 Exp 타입과 compile/abstract/improve/ reduce 파이프라인(aux/bracket-abstraction.md가 원본을 다룬다)을 거의 그대로 자기 모듈로 복사해 넣은 것이다 — Lam.hs는 자신의 사본을 수정하지 않은 채 그대로 두고 make test의 뒷단으로 계속 남으며, Exp.hs는 새 파서 주도 경로의 뒷단이 된다. 이 커밋부터 둘은 갈라진다. Desugar.hs는 Parse.hs의 Expr/Def/Module AST를 Exp.hs의 Exp로 잇는 다리다: 식에 대해서는 단순한 case를 적용으로 바꾸는 재작성이고(a9178695:Desugar.hs, 25–31행, 전문 인용) —

dsExpr :: Expr -> Exp
dsExpr (EVar i) = Var i
dsExpr (EApp f a) = App (dsExpr f) (dsExpr a)
dsExpr (ELam x e) = Lam x (dsExpr e)
dsExpr (EInt i) = Int i
dsExpr (ECase e as) = apps (dsExpr e) (map dsArm as)
  where dsArm ((_, xs), r) = lams xs $ dsExpr r

— 더 흥미로운 것은 데이터 선언 디슈가링(문법 설탕 풀어내기)인데, 각 생성자를 자신의 필드들 뒤에 생성자마다 하나씩의 연속(continuation) 인자를 받아 그중 자기 것을 고르는 평범한 함수로 바꾼다 (a9178695:Desugar.hs, 16–22행): dsDef (Data _ cs) = zipWith dsConstr [0..] cs는 n개 중 i번째 생성자에 대해 \x1 x2 ... $f0 $f1 ... $f(n-1) -> $fi x1 x2 ... 모양의 함수를 만든다 — 스콧 인코딩이며, 런타임에 별도의 패턴 매칭 장치 없이 케이스 분석을 바로 적용으로 컴파일한다. main은 하드코딩된 항 하나를 출력하던 것에서 다음으로 바뀐다(a9178695:Main.hs, 전문 인용):

import System.Environment(getArgs)
import Parse
import Desugar
import Exp

main :: IO ()
main = do
  [fn] <- getArgs
  mdl <- parseDie pTop fn <$> readFile fn
  let ds = desugar mdl
      ds' = [ (i, improve $ compile e) | (i, e) <- ds ]
  mapM_ (\ (i, e) -> putStrLn $  i ++ " = " ++ toString e) ds
  mapM_ (\ (i, e) -> putStrLn $  i ++ " = " ++ toString e) ds'

이제 명령줄 인자는 파일 이름 하나뿐이다. 프로그램을 읽어들이고 (readFile fn), 파싱하고(parseDie pTop fn), 디슈가한 다음, 최상위 정의 각각을 독립적으로 컴파일하고 개선한다 — 이 버전은 아직 정의들을 하나의 실행 가능한 프로그램으로 연결하려 하지 않는다. 이름별로 나이브한 형태와 최적화된 컴비네이터 형태를 출력할 뿐이다. 아직 writeFile도 없고, 어느 정의가 진입점인지에 대한 개념도 없다. 그 빈틈을 정확히 채우는 것이 다섯 커밋 뒤의 17587b2d다.

648aeef0("Add another node representation.", 노드 표현 하나 추가하기, a9178695 13분 뒤)과 71729105("Add pair and cons.", pair와 cons 추가하기, 그로부터 19초 뒤)는 같은 날 오후에 이어진다. 둘 다 각자 주제상 속하는 커밋들과 함께 아래에서 설명한다.

648aeef0: eval.c의 세 번째 노드 레이아웃

1장의 eval.c는 노드 표현 하나, NODE_NAIVE — 진짜 포인터를 담은 C struct이며 파일 맨 위의 #define NODE_NAIVE로 선택됨 — 만 실행했지만, 파일은 이미 같은 #if/#elif 사슬 안에 컴파일은 되지만 선택되지 않은 두 번째 옵션, NODE_SPLIT을 갖고 있었다(ccb31388:eval.c, 5–8행: 아래 인용된 것과 같은 주석 블록에서 아직 존재하지 않는 NODE_INDEX 줄만 뺀 것). 이 커밋은 세 번째로 살아있는 옵션 NODE_INDEX를 추가하고 활성 #define을 그쪽으로 바꾼다(648aeef0:eval.c, 5–10행):

/* Node representation:
 * NODE_NAIVE   all fields in a struct, regular pointers
 * NODE_INDEX   use 32 bit indices instead of pointers
 * NODE_SPLIT   split flags, funs, and args
 */
#define NODE_INDEX

NODE_INDEX는 각 셀(FUN/ARG) 안의 8바이트 포인터 두 개를 하나의 cells 배열을 가리키는 32비트 배열 인덱스로 바꾼다(648aeef0:eval.c, 51–67행, 유니온 본체는 생략 — NODE_NAIVE와 같은 모양):

#elif defined(NODE_INDEX)
/* Naive node representation with minimal unions */
typedef u_int32_t NODEPTR;
typedef struct node {
  enum node_mark mark;
  enum node_tag tag;
  union {
    ...
  } u;
} node;
#define NIL (-1)
#define HEAPREF(i) (i)
#define MARK(p) cells[p].mark

이것은 가상의 효과가 아니라 실제로 측정된 효과다: 1장의 ccb31388 빌드는 NODE_NAIVE에 대해 node size=24, heap size=100000을 보고하는데, 이 장의 b56ba28b(NODE_INDEX를 계속 활성 상태로 유지)를 빌드해 수정 없는 Lam.hs 파이프라인으로 정확히 같은 nfib 35 프로그램을 실행하면 node size=16을 보고한다 — 3분의 1 더 작으며, 축약 횟수와 GC 횟수는 둘 다 그대로다(1045124618회 축약, 1944회 GC, 둘 다 동일 — 아래 "직접 해보기"의 트랜스크립트 참고). 세 가지 표현 선택지가 이제 같은 #if 사슬 안에 나란히 존재한다. NODE_NAIVE와 NODE_SPLIT 둘 다 여전히 컴파일은 되지만 선택되지 않을 뿐이다 — 표현에 관한 실험은 나중에 합칠 브랜치가 아니라 메인라인 위에서 #define 하나를 편집하는 것으로 일어난다.

9cf1e937 + b4e44ded: "New optimiser."(새 최적화기) (2023-05-30, 12:46–12:47)

다음 세션, 그러니까 다음 날은 같은 커밋 메시지가 40초 간격으로 두 번 등장하며 시작한다. 문자 그대로 읽으면 첫 번째 것은 빌드되지 않는다: 9cf1e937의 전체 diff는 Exp.hs에 추가된 한 줄, toStringP (Lbl i e) = ":" ++ show i ++ " " ++ toStringP e — 이 커밋의 Exp 타입이 아직 선언하지 않은 생성자 Lbl에 대한 프리티 프린터 케이스다(Lbl은 b4e44ded에 가서야 data Exp에 추가된다). Exp.hs를 정확히 9cf1e937에서만, 그리고 이 범위의 다른 어느 지점에서도 아니게 빌드해 보면 이를 확인할 수 있다:

Exp.hs:129:37: error: [GHC-58481] parse error on input ‘=’
    |
129 | redOne (App4 (Comb "O") x1 x2 f1 f2 = Just $ App2 f2 x1 x2

— 빠진 Lbl 생성자 때문에 예상할 법한 오류조차 아니다. 평범한 괄호 짝이 안 맞는 오타이며, 전날 오후 71729105("Add pair and cons.")에서 고쳐지지 않은 채 물려받은 것으로, 그것 하나만으로도 파일 전체가 컴파일되지 못하게 막기에 충분하다. 두 번째 "New optimiser." 커밋인 b4e44ded는 그 구문 오류를 고치는 동시에 진짜 재작성을 수행한다: 옛 2패스 compile(나이브 브래킷 추상화) 다음 improve = improveT . improveC(별도의 정리 패스)가, 항을 만드는 도중에 같은 재작성 규칙을 스마트 생성자 — cS/cC/cB/cS'/cC' — 를 통해 인라인으로 적용하는 한 패스로 바뀐다(b4e44ded:Exp.hs, 73–100행):

abstract :: Ident -> Exp -> Exp
abstract x e@(Var y) | x == y = CI
abstract x (App f a) = cS (abstract x f) (abstract x a)
abstract x (Lam y e) = abstract x $ abstract y e
abstract x e = cK e

cK :: Exp -> Exp
--cK CI = CT
cK e  = App CK e

cS :: Exp -> Exp -> Exp
cS (App CK e1)     (App CK e2) = cK (App e1 e2)    -- S (K e1) (K e2) = K (e1 e2)
cS (App CK e1)     CI          = e1                -- S (K e1) I      = e1
cS (App CK e1)     e2          = cB e1 e2          -- S (K e1) e2     = B e1 e2
cS e1              (App CK e2) = cC e1 e2          -- S e1     (K e2) = C e1 e2
cS (App2 CB e1 e2) e3          = cS' e1 e2 e3      -- S (B e1 e2) e3  = S' e1 e2 e3
cS e1 e2                       = App2 CS e1 e2

cC :: Exp -> Exp -> Exp
cC (App2 CB e1 e2) e3          = cC' e1 e2 e3      -- C (B e1 e2) e3  = C' e1 e2 e3
--cC (Prim op)       e2 | Just op' <- lookup op flipOps = App (Prim op') e2 -- C op e = flip-op e
cC (Var op)        e2 | Just op' <- lookup op flipOps = App (Var op') e2 -- C op e = flip-op e
cC e1              e2          = App2 CC e1 e2

cB :: Exp -> Exp -> Exp
cB CC          (App CC CI) = CP -- Pair
cB (App CB CK) CP          = CO -- Cons
cB e1          e2          = App2 CB e1 e2

옛 improveC/improve 블록은 지워지지 않고, 1장 커밋 1에서 짚었던 "탐색적 코드는 지우지 말고 실행만 멈춰라"는 습관 그대로 파일 안에 주석 처리({- ... -})된 채 남아 있다. 읽어서만이 아니라 기계로도 검증된다: compileOpt = improveT . compile일 때, a9178695에서 \x. \y. \f. f x y(pair 함수)를 컴파일하면 P 컴비네이터로 축약되기 위해 별도의 improve 패스가 필요하다 —

compile epair            = S (K C) (S (S (K C) (K I)) I)
improve (compile epair)  = P

— 반면 b4e44ded에서는 compile 혼자서 이미 P를 만들어 내며, 두 번째 패스가 필요 없다:

compile epair    = P
compileOpt epair = P

같은 입력, 같은 최종 답, 도달하는 데 순회 한 번이 줄었다 — "New optimiser."라는 제목은 의도를 말할 뿐이며, diff를 읽어야만 이것이 새 규칙을 추가하는 게 아니라 정리 패스를 추상화 안으로 합치는 것임을 알 수 있다(규칙 집합 — Pair/Cons 포함 — 은 improveC가 이미 가지고 있던 것을 거의 그대로 옮긴 것에 가깝다. cB의 Pair/Cons 케이스는 전날 71729105가 주석 처리해 둔 바로 그 두 규칙이 이제 다시 살아난 것이다).

17587b2d: "Hack around a problem."(문제를 임시방편으로 피해 가기) (2023-05-30, 12:47:15, 13초 뒤)

a9178695의 main은 최상위 정의 각각을 독립적으로 컴파일해 각각을 따로 출력했다 — 그것들을 하나의 실행 가능한 항으로 합친 적이 없었고, 어느 것이 진입점인지도 알지 못했다. 그것이 문제다. 그 해법은 (17587b2d:Main.hs, 전문 인용):

import Data.Maybe
import System.Environment(getArgs)
import Parse
import Desugar
import Exp

primOps :: [(Ident, Exp)]
primOps =
  [ ("error", Prim "?")
  , ("+",     Prim "+")
  , ("-",     Prim "-")
  , ("-'",    Prim "-'")
  , ("*",     Prim "*")
  , ("quot",  Prim "/")
  , ("rem",   Prim "%")
  , ("<",     Prim "<")
  , ("<=",    Prim "<=")
  , (">",     Prim ">")
  , (">=",    Prim ">=")
  , ("==",    Prim "==")
  , ("/=",    Prim "!=")
  ]

main :: IO ()
main = do
  [fn] <- getArgs
  mdl <- parseDie pTop fn <$> readFile fn
  let ds = desugar mdl
      ds' = [ (n, compileOpt e) | (n, e) <- ds ]
      defs :: [(Ident, Exp)]
      defs = [ (n, ref i) | ((n, _), i) <- zip ds' [0..] ] ++ primOps
      find n = fromMaybe (error $ "undefined: " ++ show n) $ lookup n defs
      emain = find "main"
      res = foldr def emain (zip ds' [0..])
      def :: ((Ident, Exp), Int) -> Exp -> Exp
      def ((n, e), i) r = App2 CT (Lbl i (subst e)) r
      subst (Var n) = find n
      subst (App f a) = App (subst f) (subst a)
      subst e = e
      ref i = Var $ "_" ++ show i
--  mapM_ (\ (i, e) -> putStrLn $  i ++ " = " ++ toString e) ds
  mapM_ (\ (i, e) -> putStrLn $  i ++ " = " ++ toString e) ds'
  putStrLn $ toStringP res
  writeFile "out.comb" $ toStringP res

최상위 정의 n마다 정수 인덱스 i가 붙고(소스 파일 순서, zip ds' [0..]), 다른 정의의 본문 안에서 어떤 최상위 이름을 자유 변수로 참조하든 그것은 (subst에 의해) 텍스트 상으로 Var "_i"로 바뀐다 — b0c33a8f의 레이블 형식으로 들어가는 참조이지, 진짜 속박 변수가 아니다. 프로그램 전체는 중첩된 T (Lbl i body_i) rest 적용이 되고(T x y = y — 여기서 T는 정의의 텍스트를 출력에 이어붙이기 위해 순수하게 순서를 정하는 용도로만 쓰인다. 그 값은 버려지고, 그 레이블만 중요하다), main이 받은 인덱스에 대한 참조로 끝난다. 의존성 분석도, 레이블 형식 자신의 파싱 전 자리표시자 트릭(b0c33a8f)이 공짜로 벌어다 주는 것 이상의 전방 선언 처리도 없으며, 해결되지 않은 이름은 파싱에 실패하는 게 아니라 — find에서, error $ "undefined: " ++ show n이라는 하스켈 예외로 실패한다. 파서가 내는 진단이 아니다. 그것은 커밋 메시지가 말하는 그대로다: 실제 문제(독립적으로 컴파일된 N개의 정의를 하나의 닫힌, 실행 가능한 그래프로 만드는 것)에 적용된 임시방편(hack)이지, 그 문제에 대한 일반적인 해법(링킹, 의존성 순서 매기기, 진짜 모듈 시스템)이 아니다 — 그리고 커밋 로그에 그렇다고 정직하게 표시되어 있을 뿐, 설계 결정으로 포장되지 않는다. 아래 "직접 해보기" 연습 문제 1은 바로 이 임시방편의 한계에 정확히 부딪힌다.

4030f721과 b56ba28b: 파싱과 컴비네이터가 계속 자라다

4030f721("Parse more", 더 파싱하기)는 새 식 형태 하나 — 괄호로 감싼 연산자 (+), 평범한 식별자를 쓸 수 있는 곳이면 어디서든 쓸 수 있음 — 를 열두 줄만 추가해 넣는데, 모두 aux/parser-combinators.md 3절이 이미 다룬 조각들로 만든 평범한 컴비네이터 코드다. 그 문서가 diff 전체를 5절에서 따라간다. b56ba28b("More combinators.", 컴비네이터 추가)는 b4e44ded가 되살린 Pair/Cons 규칙의 eval.c 쪽 대응이다: P/O 컴비네이터가 enum node_tag에 태그를 얻고, 전역 combP/combO 노드, 파서 케이스('P'/'O'), 프린터 케이스, 런타임 축약 규칙을 얻는다 (b56ba28b:eval.c, 599–620행, 전문 인용):

    case P:                     /* P x y f = f x y */
      CHECK(3);
      GCCHECK(1);
      x = ARG(TOP(1));
      y = ARG(TOP(2));
      f = ARG(TOP(3));
      POP(3);
      n = TOP(0);
      FUN(n) = new_ap(f, x);
      ARG(n) = y;
      GOTO ap;
    case O:                     /* P x y g f = f x y */
      CHECK(4);
      GCCHECK(1);
      x = ARG(TOP(1));
      y = ARG(TOP(2));
      f = ARG(TOP(4));
      POP(4);
      n = TOP(0);
      FUN(n) = new_ap(f, x);
      ARG(n) = y;
      GOTO ap;

P는 pair 자신의 컴비네이터로, 세 인자를 받아 세 번째를 처음 둘에 적용한다; O는 cons로, 네 인자를 받아 처음 둘에 네 번째를 적용한다(세 번째 — "nil" 연속체 — 는 건너뛰는데, 이것이 바로 cons-대-nil 케이스 분석에 필요한 것이다). case O: 위의 주석조차 여전히 P x y g f = f x y라고 적혀 있다 — 바로 위 케이스에서 복사-붙여넣기 되었지만 O로 고쳐지지 않은 것이다. 이 커밋으로 범위가 마무리된다: b56ba28b 이후, 소스 파일이 표현할 수 있는 언어는 아무것도 없는 상태에서 모듈, 함수, 데이터 선언, case 식, 정수로 자라났고, 런타임은 하루 전에는 모르던 컴비네이터 둘을 새로 이해하게 되었다.

왜 중요한가

한 개의 손으로 만든 항만 실행할 수 있던 컴파일러가 나흘 만에, a9178695는 그것에 진짜 정문을 달아 준다: 손으로 main 안에 조립한 하스켈 값이 아니라, 진짜 파서를 거친 소스 텍스트가 입력이 된다. 그것이 헤드라인이다. 하지만 이 열한 개 커밋 전체에서 더 오래 지속되는 습관은 헤드라인 커밋 다음에 일어나는 일이다: 노드 표현이 바뀌고(648aeef0), 최적화기가 2패스 설계에서 1패스 설계로 다시 쓰이며 첫 시도에서 컴파일이 깨지고(9cf1e937/b4e44ded, 40초 간격), 빠져 있던 "N개의 정의를 프로그램 하나로 합치기" 조각이 인정된 임시방편으로 덧붙는다 (17587b2d) — 9cf1e937부터 b56ba28b까지 다섯 커밋이 커밋 타임스탬프로 총 98초(12:46:22부터 12:48:00까지)에 걸쳐 있는데, 이는 그 코드를 작성하는 데 걸린 시간이 아니라 이미 끝나 있던 작업을 커밋하는 데 걸린 시간이다. 이틀 된 코드 — 최적화기, 그것도 두 번 — 를 다시 쓰는 일이 여기서는 아무 비용도 들지 않는데, 이 프로젝트 바깥 어디에도 아직 그것에 의존하는 것이 없기 때문이다. 그것은 장난감이 누리는 사치이지 출시된 컴파일러는 누리지 못하는 것이며, 이 범위는 그것을 거리낌 없이 활용한다.

실험 노트 여백

  • 커밋 메시지는 메커니즘이 아니라 의도를 말한다. "Make a real compiler."는 파서 컴비네이터 라이브러리와 디슈가러, 브래킷 추상화 파이프라인의 두 번째 사본을 새 파일 여섯 개에 추가한다고 말하지 않는다; "New optimiser."(40초 간격으로 두 번)는 둘 중 첫 번째가 컴파일되지 않는다는 것도, 진짜 변화가 정리 패스를 추상화 안으로 합치는 것이지 새 규칙을 추가하는 것이 아니라는 것도 말하지 않는다.
  • 표현에 관한 실험은 브랜치가 아니라 메인라인 위 커밋으로 일어난다: 648aeef0은 eval.c의 기존 #if/#elif 사슬에 NODE_INDEX를 세 번째 옵션으로 추가하고 #define 하나를 바꿔 그것을 활성화한다; NODE_NAIVE(1장의 표현)는 파일에 그대로 남아 컴파일은 되지만 선택되지 않을 뿐이다.
  • 적용되고 정직하게 표시된 임시방편: 17587b2d는 diff를 열어 보기도 전에 이미 커밋 메시지가 말해 준다. 그것이 임시방편으로 피해 가는 것 — 독립적으로 컴파일된 최상위 정의들을 진입점을 아는 하나의 실행 가능한 그래프로 만드는 것 — 은 이 범위가 끝내 일반적인 해법을 내놓지 않는 실제 문제다.

직접 해보기

기준점: b56ba28b. 관례(00-overview.md 참고):

git worktree add /tmp/mhs-b56ba28b b56ba28b
cd /tmp/mhs-b56ba28b
nix develop $MICROHS -c make test

이것은 여전히 옛 Lam.hs/nfib.comb 파이프라인을 실행한다 — Makefile의 test 타깃은 이 범위 안에서 새 Main.hs/Parse.hs 경로를 빌드하도록 한 번도 갱신되지 않았다(git show b56ba28b:Makefile은 6485a341의 것과 바이트 단위로 동일하다). 1장과 같은 nfib 35 계산을, 새 NODE_INDEX 표현 위에서 기대할 수 있다:

./eval nfib.comb
((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)
29860703
node size=16, heap size=100000
1045124618 reductions, 1944 GCs

1장에서 본 ccb31388의 node size=24 빌드와 답, 축약 횟수, GC 횟수 모두 같다 — 표현 변경은 계산에는 보이지 않고 노드당 메모리에만 보인다.

빌드 노트: 새 파서 주도 경로(Main.hs)는 이 커밋 시점에 Makefile 타깃이 없으며, 이 프로젝트의 flake 툴체인(GHC 9.8.4, mtl 2.3.1)으로 빌드하려면 한 줄이 필요하다. 쓰인 그대로 빌드하면 실패한다:

nix develop $MICROHS -c ghc Main.hs -o uhs
ParserComb.hs:52:11: error: [GHC-88464]
    Variable not in scope:
      ap :: Prsr s (a -> b) -> Prsr s a -> Prsr s b
    Suggested fix: Perhaps use ‘map’ (imported from Data.List)
   |
52 |   (<*>) = ap
   |           ^^

ap(ParserComb.hs의 Applicative 인스턴스에서 쓰임, aux/parser-combinators.md 2절)은 이 GHC/mtl 조합에서 ParserComb.hs 자신의 임포트 어디로도 스코프에 들어오지 않는다 — Control.Monad를 명시적으로 임포트해야 한다. ParserComb.hs의 기존 import Control.Monad.State.Strict 뒤에 한 줄을 추가한다:

import Control.Monad(ap)

그러면 빌드가 성공한다:

nix develop $MICROHS -c ghc Main.hs -o uhs
[1 of 6] Compiling ParserComb       ( ParserComb.hs, ParserComb.o )
[2 of 6] Compiling Parse            ( Parse.hs, Parse.o )
[3 of 6] Compiling Exp              ( Exp.hs, Exp.o )
[4 of 6] Compiling Desugar          ( Desugar.hs, Desugar.o )
[5 of 6] Compiling Main             ( Main.hs, Main.o )
[6 of 6] Linking uhs

(Parse.hs:203의 부분 함수 tail에 대한 GHC 경고도 나타난다 — 1장이 Lam.hs에서 짚었던 것과 같은 종류의 부분성이다; 여기서는 무해하며 위에서는 생략했다.) 아래 연습 문제들 전에 이 한 줄짜리 패치를 적용해 둔다.

연습 문제 1: 받아들여지는 문법으로 작은 프로그램 만들어 보기

cat > pair.hs <<'EOF'
module Pair where
f x = x
main = f 3
EOF
./uhs pair.hs
f = I
main = f 3
((T :0 I) ((T :1 (_0 3)) _1))

첫 두 줄은 ds'다(각 정의의 컴파일되고 최적화된 형태 — f x = x는 항등 컴비네이터 I로 축약되는데, 정확히 aux/bracket-abstraction.md 1절이 다루는 (\x. x) = I 사례다). 세 번째 줄은 17587b2d가 링크한 프로그램으로, 표준 출력뿐 아니라 out.comb에도 쓰인다. 실행해 보자:

nix develop $MICROHS -c ./eval out.comb
3
node size=16, heap size=100000
14 reductions, 0 GCs

예상대로 3이다. 해 볼 만한 가치가 있고, 의존하기 전에 17587b2d의 임시방편에 대해 알아 둘 가치도 있는 실험: pair.hs에서 선언 순서를 바꿔 — f x = x보다 main = f 3을 먼저 — 다시 빌드해 보자:

cat > fwd.hs <<'EOF'
module Fwd where
main = f 3
f x = x
EOF
./uhs fwd.hs
nix develop $MICROHS -c ./eval out.comb
ERR: shared == NIL

레이블은 소스 순서대로 매겨지므로(zip ds' [0..]), 이 파일에서 첫 번째인 main이 이제 레이블 0이 되어, 레이블 1인 f를 _1로 참조하는데, 출력 텍스트에 :1이 등장하기도 전에 그렇게 된다. b0c33a8f의 파서는 전방 선언 지원이 전혀 없는 단일한 좌에서 우로의 패스다 — 레이블은 텍스트 안에서 필요해지기 전이 아니라 참조되기 전에 정의되어 있어야 한다. 17587b2d의 "임시방편"은 이 제약을 조용히 물려받는다 — C 수준의 ERR 위에서 이를 잡거나 보고하는 것은 아무것도 없다. (계속하기 전에 main 앞에 f를 다시 놓거나, 그냥 다시 pair.hs를 쓴다.)

연습 문제 2: 파스 오류 일으켜 보기

cat > bad.hs <<'EOF'
module Bad where
main = )
EOF
./uhs bad.hs

실제 동작: 잡히지 않은 하스켈 예외가 모두 stderr로, 종료 코드 1 (parseDie, a9178695:Parse.hs 85행: Left err -> error err):

uhs: line 2, col 7:
   found: ")"
   expeced: = white-space
   expeced: lower case
   expeced: upper case
   expeced: int digit
   expeced: '('
   expeced: \ symbol
   expeced: case letter, digit
   expeced: case <empty>

CallStack (from HasCallStack):
  error, called at ./Parse.hs:85:17 in main:Parse

문자 그대로 읽으면: 실제 소스에서 expeced는 철자가 틀려 있다 (formatFailed, a9178695:ParserComb.hs 109행: pr e = " expeced: " ++ e) — 여기서 전사(transcription) 실수가 아니라, 실제로 실행 중인 바이너리가 정말로 그렇게 출력한다. 목록은 가장 멀리 나아간 지점(7열, main = 바로 뒤)에서 시도되어 실패한 모든 대안이며, <?>의 레이블링과 LastFail의 longest를 통해 합쳐진 것이다. "case <empty>"는 구체적으로 확인 가능한 지점으로 거슬러 올라간다: pExpr의 세 번째 대안인 pCase는 pKeyword "case"로 시작한다(a9178695:Parse.hs, 98–102행, 전문 인용):

pKeyword kw = (skipWhite $ do
  s <- pIdentRest
  guard (kw == s)
  pure ()
  ) <?> kw

)에 대해서는 pIdentRest가 문자를 0개 읽고()는 글자도 숫자도 아니므로), 그래서 s = "", guard ("case" == "")는 guard False이며, 이는 Alternative의 empty이고(guard는 ParserComb.hs 자신의 것이 아니라 Control.Monad의 것이다), <?> "case"는 empty 자신의 "<empty>" 메시지 앞에 "case"를 붙인다 — unwords ["case", "<empty>"]다. 의도적으로 설계된 것이 아니라, 이틀 된 오류 보고 패스의 정직한 부산물이다(empty의 문자 그대로의 자리표시자 텍스트가 실제 오류 메시지로 새어 나온 것).

연습 문제 3: 최적화기, 전과 후

9cf1e937~1..9cf1e937(첫 번째 "New optimiser." 커밋의 문자 그대로의 양 끝점)는 빌드되지 않는다 — 위 "무슨 일이 있었나"에서 이미 확인했다: 9cf1e937 혼자서는 71729105의 오타에서 물려받은 파스 오류로 실패하며, 그 자신의 diff 때문에 그렇지 않았다면 드러났을 Lbl이-스코프에-없음 문제가 나타날 기회조차 갖지 못한다. 이 쌍도, 71729105/9cf1e937/ b4e44ded의 어떤 조합도 재작성 양쪽에 빌드 가능한 Main.hs 바이너리 두 개를 내놓지 않는다: b4e44ded 자신의 Exp.hs는 빌드되지만, 그 커밋 시점에 나란히 있는 Main.hs는 여전히 옛날의, 이제는 제거된 improve 함수를 호출해 Variable not in scope: improve로 실패한다.

빌드 노트: 관련 코드(Exp.hs의 compile/improve/compileOpt)가 그 자체로 파싱되고 타입 검사되는 가장 가까운 커밋 쌍 — a9178695(재작성 전; 원래의 2패스 improveC/improveT)와 b4e44ded(재작성 후; 합쳐진 스마트 생성자 버전) — 로 대신하고, ap 패치가 이 범위의 모든 커밋에서 필요하며 실제로 바뀐 부분이 아닌 전체 Main.hs 파이프라인을 거치는 대신 aux/bracket-abstraction.md 2절과 같은 방식으로 ghci/ghc -e로 Exp.hs 위에서 직접 비교한다.

git worktree add /tmp/mhs-a9178695 a9178695
cd /tmp/mhs-a9178695

위와 같은 한 줄짜리 ParserComb.hs 수정(import Control.Monad(ap), 여기서도 필요하다. Exp.hs가 ParserComb를 임포트하는 Parse를 임포트하기 때문이다)을 적용한 다음:

nix develop $MICROHS -c ghc -e 'let epair = Lam "x" (Lam "y" (Lam "f" (App2 (Var "f") (Var "x") (Var "y")))) in putStrLn (toString (compile epair)) >> putStrLn (toString (improve (compile epair)))' Exp.hs
S (K C) (S (S (K C) (K I)) I)
P

epair은 \x. \y. \f. f x y — pair 함수이며, Pair 규칙이 매치하는 정확한 형태다. 나이브한 compile만으로는 이것을 축약하지 못한다; P에 도달하려면 별도의 improve 호출이 필요하다. 두 번째 워크트리:

git worktree add /tmp/mhs-b4e44ded b4e44ded
cd /tmp/mhs-b4e44ded

같은 한 줄짜리 수정을 적용한 다음:

nix develop $MICROHS -c ghc -e 'let epair = Lam "x" (Lam "y" (Lam "f" (App2 (Var "f") (Var "x") (Var "y")))) in putStrLn (toString (compile epair)) >> putStrLn (toString (compileOpt epair))' Exp.hs
P
P

compile만으로 이미 P를 만들어 낸다 — "무슨 일이 있었나"가 diff를 읽고 설명한 그대로, 추상화 단계와 최적화가 이제 두 패스가 아니라 한 패스다; 같은 입력을 재작성 양쪽에서 실행해 보면 diff의 모양이 아니라 그 의미를 확인할 수 있다.

끝나면 워크트리 세 개를 모두 정리한다:

git worktree remove --force /tmp/mhs-b56ba28b
git worktree remove --force /tmp/mhs-a9178695
git worktree remove --force /tmp/mhs-b4e44ded

🌐 Read in English

03 — Growing the language (언어 키우기)

현재 위치

2장은 파일에서 작은 람다 언어 하나를 읽어 들이는 컴파일러를 남긴다: 모듈, 함수 정의, data 선언, case 식, 정수 리터럴, 적용, \x. expr 람다, 이 모두를 ParserComb.hs/Parse.hs 컴비네이터 라이브러리 (aux/parser-combinators.md)가 파싱하고, Desugar.hs가 Exp.hs의 작은 컴비네이터 코어로 디슈가하며, eval.c가 실행한다. 모든 최상위 정의는 17587b2d가 인정한 임시방편으로 프로그램 하나에 링크된다: 소스 순서가 레이블 번호가 되고, 자신의 레이블이 출력되기 전에 쓰인 이름은 C 런타임을 ERR: shared == NIL로 멎게 한다. 이 장의 범위, b56ba28b..95ff6a34는 열두 개의 커밋이다 — 아홉 개는 2023-05-30 한 자리에서 도착하고, 나머지 세 개는 다음 작업 세션인 2023-06-01에 도착한다 — 이 최소한의 언어를 let, 중위 연산자, 튜플, 리스트, 문자, 문자열, 모듈 임포트, do 표기법을 갖춘 언어로 키워 낸다.

무슨 일이 있었나

5ccb1b2a: 주석, let, error

한 커밋 안에 서로 무관한 기능 세 개가 들어 있는데, 각각이 값싸서 "이렇게 값쌀 때는 무관한 것들이 함께 도착한다"는 것 자체가 눈여겨볼 만하다. 주석은 파서가 텍스트를 보기 전에 처리된다, 파서 안이 아니라: parseDie는 이제 소스 문자열 전체에 대해 먼저 removeComments file을 실행하고(5ccb1b2a:Parse.hs 84행), removeComments는 "..." 문자열 리터럴 안쪽은 건드리지 않으면서 --부터 줄 끝까지를 지우는 줄 단위 패스다(5ccb1b2a:Parse.hs, 90–98행, 전문 인용):

removeComments :: String -> String
removeComments = unlines . map remCom . lines
  where remCom ('-':'-':_) = ""
        remCom ('"':cs) = str cs
        remCom (c:cs) = c : remCom cs
        remCom "" = ""
        str ('"':cs) = remCom cs
        str ('\\':_:cs) = str cs
        str (_:cs) = str cs
        str "" = ""

(문자열 리터럴은 아직 파싱 가능한 구문이 아니지만 — 그것은 일곱 커밋 뒤의 ddb1bb29다 — 주석 제거는 이미 인용된 --를 피해 가서 줄 나머지를 먹어치우지 않도록 우회해야 한다.) let은 새 식 형태 ELet [Def] Expr이며, 중첩된, 재귀하지 않는 람다 적용으로 디슈가된다 — 케이스 위의 주석이 말하듯 "그저 순차적인 바인딩"이다 (5ccb1b2a:Desugar.hs 33행, 주석 포함 인용):

-- For now, just sequential bindings.
dsExpr (ELet ds e) =
  let ds' = concatMap dsDef ds
      e' = dsExpr e
      def (i, d) e = App (Lam i e) d
  in  foldr def e' ds'

let x = a; y = b in e는 (\x -> (\y -> e) b) a가 된다 — 각 바인딩은 소스 순서대로 그 안쪽을 감싸는 App (Lam ...) 하나씩으로 컴파일되며, letrec 식의 자기 참조나 상호 재귀 지원은 전혀 없다(나중 장의 문제다). error는 새 구문 형태가 아니다 — +나 <와 같은 방식으로 도달하는 새 eval.c 프리미티브이지만, 자신의 인자를 강제 평가하고 stderr에 출력한 뒤 종료한다는 점이 다르다(5ccb1b2a:eval.c, 661–667행, 전문 인용):

    case ERROR:
      x = ARG(TOP(1));
      x = evali(x);
      fprintf(stderr, "error: ");
      print(stderr, x);
      fprintf(stderr, "\n");
      exit(1);

3aa12deb: 중위 연산자, 그리고 여기서 결합성과 우선순위가 실제로 동작하는 방식

이 커밋 이전에는 pExpr이 pExprApp(순수 적용), pLam, pCase, pLet만 알았다 — +나 ==처럼 이항 연산자를 담은 식은 괄호로 감싼 전위 식별자로 쓰는 것 말고는 표현할 방법이 없었다, (+) x y처럼. 3aa12deb는 새 대안 pExprOp를 추가하는데, 이것이 이 커밋 시점의 결합성/우선순위 메커니즘 전부이며, 전문 인용이다 (3aa12deb:Parse.hs, 203–212행):

pExprOp :: P Expr
pExprOp = pA
  where
    pA = pRightAssoc (pOpers ["$"]) pB
    pB = pRightAssoc (pOpers ["||"]) pC
    pC = pRightAssoc (pOpers ["&&"]) pD
    pD = pNonAssoc (pOpers ["==", "/=", "<", "<=", ">", ">="]) pE
    pE = pLeftAssoc (pOpers ["+", "-"]) pF
    pF = pLeftAssoc (pOpers ["*", "quot", "rem"]) pG
    pG = pExprApp

이 언어 어디에도 결합성/우선순위 선언은 없다. 소스에서 읽어 들이는 표는 없고, 사용자 프로그램이 영향을 줄 수 있는 것도 없다. 우선순위는 문자 그대로 이 고정된 사슬 안에서 파서의 위치다 — pA는 pB보다 느슨하게 묶이고, pB는 pC보다 느슨하고, 이런 식으로 가장 강하게 묶이는 순수 함수 적용인 pG까지 내려간다. 결합성은 어떤 단계를 세 컴비네이터 함수 pRightAssoc, pNonAssoc, pLeftAssoc 중 어느 것이 감싸는지에 달렸다 — 각각 자신의 피연산자 파서를 한 번 파싱한 다음, 자기 자신을 재귀 호출하거나(우결합), 더 이상 이어지지 않고 정확히 피연산자 하나만 더 받거나(비결합 — 이 단계에서는 ==가 연달아 나오면 파스 오류다), (op, operand) 쌍의 평평한 리스트에 대해 왼쪽으로 접거나(좌결합) 한다 — pLeftAssoc, 전문 인용 (3aa12deb:Parse.hs, 235–239행):

pLeftAssoc :: P String -> P Expr -> P Expr
pLeftAssoc pOp p = do
  e1 <- p
  es <- emany ((,) <$> pOp <*> p)
  pure $ foldl (\ x (op, y) -> appOp op x y) e1 es

연산자 토큰 열네 개가 이 여섯 단계 어딘가에 자리를 얻는다($, ||, &&, 비교 연산자 여섯 개, +/-, */quot/rem), 그리고 이 여섯 목록 어디에도 없는 연산자는 아직 유효한 중위 연산자가 아니다 — 이 커밋 시점에 a ++ b를 쓰면 파스 오류이며, ++는 e96d04be가 비교 단계와 +/- 단계 사이에 새 단계를 끼워 넣기 전까지는(:도 함께) 등장하지 않는다. 조악하다 — 우선순위가 파서 자신의 호출 그래프에 그대로 박혀 있어서, 새 우선순위에 연산자를 추가하려면 그 사슬에 상호 재귀하는 pN 함수 하나를 새로 끼워 넣고 다른 것은 아무것도 다시 번호 매기지 않으면 된다 — 정확히 13분 뒤 e96d04be가 하는 일이다(p0부터 p10까지, 여섯 개의 문자 단계 대신 열한 개의 번호 붙은 단계, ->, :, ++, .이 추가된다). 하지만 이것으로 동작하며, 이것이 메커니즘 전부다: 연산자 표도, 사용자가 확장 가능한 결합성도 없이, 그저 손으로 쓴 파서 사다리 하나뿐이다.

e96d04be: 타입 시그니처, 파싱은 되지만 버려진다

연산자 사다리를 넓히는 것과 같은 커밋이 f :: T를 파싱 가능한 구문으로 추가한다 — 그리고 그 외에는 아무것도 하지 않는다. 새 Def 케이스인 Sign Ident Type(e96d04be:Parse.hs 19행)은 pDef의 다른 모든 대안과 같은 방식으로 만들어진다(e96d04be:Parse.hs 178행):

  <|> Sign <$> (pLIdent <* pSymbol "::") <*> pType

pType은 특기할 만하게도 별도의 문법이 전혀 아니다 — 다른 이름을 쓴 pExpr일 뿐이다(e96d04be:Parse.hs, 183–184행, 전문 인용):

pType :: P Type
pType = pExpr

타입 시그니처는 마치 평범한 식인 것처럼 파싱된다 — Type은 Expr의 타입 동의어다 — 아직 재사용할 별도의 타입 구문 문법이 없기 때문이고, e96d04be가 방금 넓힌 중위 사슬이 이미 가장 느슨한 단계(p0)에 ->를 포함하고 있어서, T -> T 시그니처가 파싱되는 데 필요한 것을 마침 갖추고 있다. 그리고 디슈가러는 그것을 한 줄로 그냥 바닥에 버린다, 처리의 전부다(e96d04be:Desugar.hs 24행):

dsDef (Sign _ _) = []

dsDef는 각 Def에 대해 컴파일된 정의들의 리스트를 반환한다; Sign은 그중 0개를 내놓는다. 이것은 "파싱하고 무시하기"다: 타입 시그니처를 쓰는 소스 파일이 파싱에 실패하지 않도록 지금 당장 구문을 받아들이되, 그 의미에는 아직 아무것도 걸지 않는다. 그 의미는 몇 달 뒤에 도착한다 — 9장이 마침내 Sign이 버리지 않고 담아 두는 것을 읽어내는 타입 검사기를 다룬다 — 하지만 e96d04be 이후로는, 그 사이의 모든 커밋이 아무 일도 하지 않는 타입 시그니처를 담은 코드를 자유롭게 쓰고 배포할 수 있다.

어떤 기능 커밋이든 같은 모양: 튜플, 리스트, 문자열

연달아 커밋 세 개가 같은 레시피를 보여 준다 — 새 Expr 생성자 하나, 그것을 만드는 pAExpr 대안 하나, 그리고 그것을 기존 컴비네이터 코어가 이미 이해하는 무언가로 재작성하는 dsExpr 케이스 하나.

튜플(8939d55b)은 괄호 안에 콤마로 구분된 목록으로 파싱되고, 스콧 인코딩된 연속체 전달 함수로 디슈가된다 — 2장의 a9178695:Desugar.hs가 data 생성자에 썼던 것과 같은 트릭을, 이번에는 내장 튜플 형태를 위해 손으로 만든 것이다(8939d55b:Parse.hs, 197–200행, 전문 인용):

eTuple :: [Expr] -> Expr
eTuple [] = ELam "_x" (EVar "_x")    -- encoding of ()
eTuple [e] = e
eTuple es = ELam "_f" $ foldl EApp (EVar "_f") es

쌍 (a, b)는 \f -> f a b가 된다 — 자기 자신의 소거자(eliminator)이며 연속체를 기다리는 값이다. 이 모양이 흔해서, 같은 커밋이 브래킷 추상화 스마트 생성자에게 이를 알아보고 호출 지점에서 S/K/C로 만드는 대신 바로 2장의 b56ba28b가 eval.c에 추가한 P(pair) 컴비네이터로 축약하도록 가르친다(8939d55b:Exp.hs 95행, cC에 추가된 한 줄):

cC (App2 CC CI e1) e2          = App2 CP e1 e2

리스트 구문(d91b9812)은 더 작다 — [e1, e2, ...]에 대한 파서 대안 하나, 그리고 b56ba28b의 O 컴비네이터와 기존 K를 써서 중첩된 cons/nil로 바로 이어지는 두 줄짜리 디슈가링뿐이다 (d91b9812:Desugar.hs, 39–40행, 전문 인용):

dsExpr (EList es) =
  foldr (App2 CO) CK $ map dsExpr es

[1,2,3]은 O 1 (O 2 (O 3 K))가 된다. 새 런타임 프리미티브는 필요 없다: O 자신의 축약 규칙(redOne (App4 (Comb "O") x1 x2 f1 f2) = Just $ App2 f2 x1 x2, 2장 b56ba28b:Exp.hs 184행)은 두 개의 연속체 — nil 갈래와 cons 갈래 — 를 그 순서대로 받는데, K a b = a는 정확히 그 두 연속체가 주어졌을 때 언제나 첫 번째(nil 갈래)를 고르는 함수이며, 이것이 바로 CK 혼자서도 유효한 빈 리스트가 되는 이유다: 스콧 인코딩된 []는 자신의 cons 연속체를 무시하고 nil 연속체를 반환하는 값이고, K는 부분 적용된 상태로 정확히 그 일을 한다.

문자열 리터럴(ddb1bb29)은 리스트 단계에서 자기만의 디슈가링 규칙조차 필요 없다 — 문자열은 문자 리스트로 정의되어, 이미 존재하는 리스트 디슈가링 케이스에 곧바로 넘겨진다(ddb1bb29:Desugar.hs 47행, 전문 인용):

dsExpr (EStr cs) = dsExpr $ EList $ map EChar cs

두 커밋 앞선 60cd285c가 이미 이 삼총사 중 유일하게 진짜 작업이 필요했던 부분을 해 두었다: 문자 리터럴에는 실제로 새 런타임 프리미티브가 필요한데, Char가 eval.c 안에 이미 있는 것으로는 표현할 수 없기 때문이다 — 새 CHAR 노드 태그가 INT 바로 뒤에 enum node_tag에 합류하고(60cd285c:eval.c 18행), 파서는 스트림에서 바이트 하나를 그대로 읽어 CHAR 노드로 만드는 case '\''를 얻으며(60cd285c:eval.c 321–325행), eval의 축약 루프는 CHAR를 이미 INT를 다루던 것과 똑같이 비활성 값으로 다룬다 — case CHAR: 절 전체가 그냥 RET;이고, 바로 위 INT의 절과 똑같다, 축약 규칙 없이, 그저 "이것은 이미 값이다"일 뿐이다(60cd285c:eval.c, 508–511행). 이 바탕이 마련되고 나면 EStr은 공짜다: 문자열은 Chr의 리스트이고, EList의 디슈가링과 O/K의 런타임 동작 둘 다 이미 그것들을 실어 나를 준비가 되어 있다.

4e4c5252("Refactor.", 리팩터링, 리스트 구문과 문자 리터럴 커밋 사이)는 기능이 아니다: 튜플과 리스트가 추가되면서 하나씩 덧붙여져 pLHS pUIdent/튜플/[]/:-중위 분기로 임기응변으로 자라난 case-패턴 파싱이, 하나의 Pat 타입(PConstr Ident [Ident] | PTuple [Ident])으로 뽑혀 들어가고 dsExpr의 ECase 절이 이를 매치하도록 다시 쓰이는 순간이다. 파싱되는 것은 아무것도 바뀌지 않는다; 바뀌는 것은 네 개의 임기응변 패턴 모양이 이제 네 개의 따로 꿰인 튜플 대신 하나의 표현을 공유한다는 것이다. 이것이 이 장 나머지가 계속 되풀이하는 리듬의 첫 사례다: 기능, 기능, 리팩터링 — 쌓인 지저분함을 미리가 아니라 정리할 만큼 쌓인 뒤에야 청소한다.

b25d9fc8: 모듈 임포트하기

이 범위에서 가장 큰 커밋이다 — 새 파일 Compile.hs, 91줄 — 여기서 "모듈"이 뜻하는 바는 "파싱하고 무시하기"보다 훨씬 더 실질적이다: 모듈은 파일 <Name>.uhs이며, 그 자신이 선언한 모듈 이름은 그것이 로드된 파일 이름과 일치해야 한다(b25d9fc8:Compile.hs, 40–45행, 전문 인용):

compileModule :: IdentModule -> StateT Cache IO CModule
compileModule nm = do
  let fn = nm ++ ".uhs"
  mdl@(Module nm' defs) <- parseDie pTop fn <$> liftIO (readFile fn)
  when (nm /= nm') $
    error $ "module name does not agree with file name: " ++ show nm

import M(새 Def 케이스, pDef에서 data/import의 이웃과 한 키워드 차이)은 M에 대해 compileModuleCached를 촉발하는데, 이는 어떤 작업이든 시작하기 전에 이미 컴파일된 모듈들의 Map을 확인하고, 현재 컴파일 중인 모듈들의 working 목록을 추적해서 (전이적으로) 자기 자신을 임포트하는 모듈은 무한 루프가 아니라 강한 오류가 되도록 한다 (b25d9fc8:Compile.hs, 24–38행) — 조악한 임시방편이 아니라 진짜 순환 탐지다. 모듈이 정의하는 최상위 이름 하나하나는 그 모듈 자신의 심볼 테이블 안에서 두 가지 표기로 보인다: 비한정(i)과 점 붙은 (mn ++ "." ++ i) — 둘 다 같은 한정된 Var를 가리킨다 (b25d9fc8:Compile.hs 53행, 전문 인용):

    syms (mn, ds) = [ (s, [Var qi]) | (i, _) <- ds, let qi = mn ++ "." ++ i, s <- [i, qi] ]

이것이 바로 임포트된 두 모듈이 같은 비한정 이름을 정의하는 것이 임포트 시점이 아니라 참조 시점에 잡히는 모호성 오류인 이유다 (checkExpr의 Just qis -> error $ "ambiguous: " ++ ..., 17587b2d에서 바뀌지 않음). 2장의 "직접 해보기"가 정면으로 부딪혔던 빈틈 하나를 조용히 메우는, 같은 커밋에 함께 들어간 더 작은 수정도 있다: 17587b2d의 레이블 형식은 정의의 :N이 그것에 대한 어떤 _N 참조보다도 출력 텍스트에 먼저 나타나야 했으므로, 호출하는 함수보다 텍스트상 먼저 나오는 main은 C 런타임을 ERR: shared == NIL로 멎게 했다. b25d9fc8:eval.c는 어느 쪽이 먼저 보이든 _ 케이스가 자리표시자가 이미 존재하기를 요구하는 대신, _와 : 케이스 둘 다 조건 없이 간접 자리표시자를 할당하도록 만든다(b25d9fc8:eval.c, 336–343행, 전문 인용):

  case '_' :
    l = (int)parse_int(f);  /* The label */
    if (shared[l] == NIL) {
      /* Not yet defined, so make it an indirection */
      shared[l] = alloc_node(IND);
      INDIR(shared[l]) = NIL;
    }
    return shared[l];

이제 링크된 프로그램 하나 안에서 전방 참조가 그냥 동작한다 — 17587b2d의 링킹 임시방편이 다시 설계되어서가 아니라, 그 밑에 있는 레이블 형식이 빠져 있던 순서 독립성 한 조각을 갖추게 되었기 때문이다.

ff4622e8와 693dc6ce: 또 한 번의 리팩터링, 그다음 do 표기법

ff4622e8("Refactor ident parsing.", 식별자 파싱 리팩터링)는 기능-기능- 리팩터링 리듬의 두 번째 사례다: 따로 정의되어 있던 식별자 파서 세 개 (소문자, 대문자, "식별자의 나머지"를 위한 임기응변 문자 집합)가 하나의 pWord/pIdent 쌍과, .로 구분된 조각들을 이어 붙이는 새 pQIdent로 통합된다 — M.name, 즉 한정 참조가 이제 표현 가능해지고, 693dc6ce가 22초 뒤 곧바로 이를 필요로 한다. do 표기법은 Monad 타입 클래스로 디스패치하지 않는다 — 아직 그런 것은 없다 — 대신 호출 지점에 명시적으로 적힌 한정자로 디스패치한다: pDo는 <Uident>.do { ... }를 파싱하고, 그 식별자는 디슈가된 모든 바인드의 연산자 이름 접두어로 실어 날라진다(ff4622e8:Parse.hs, 359–372행, 전문 인용):

pDo :: P Expr
pDo = EDo <$> pQualDo <*> pBlock pStmt

pStmt :: P Stmt
pStmt =
      (Bind <$> (pLIdent <* pSymbol "<-") <*> pExpr )
  <|> (Then <$> pExpr)

pQualDo :: P String
pQualDo = do
  s <- pUIdent
  _ <- char '.'
  pKeyword' "do"
  pure s

디슈가링, 693dc6ce:Desugar.hs의 새 줄 전부(49–53행, 전문 인용):

dsExpr (EDo _ []) = error "empty do"
dsExpr (EDo _ [Bind _ _]) = error "do without final expression"
dsExpr (EDo _ [Then e]) = dsExpr e
dsExpr (EDo n (Bind i e : ss)) = App2 (Var (n ++ ".>>=")) (dsExpr e) (Lam i $ dsExpr (EDo n ss))
dsExpr (EDo n (Then   e : ss)) = App2 (Var (n ++ ".>>"))  (dsExpr e)         (dsExpr (EDo n ss))

M.do { x <- e; rest }는 (M.>>=) e (\x -> rest')가 되고, 여기서 rest'는 rest를 같은 방식으로 재귀적으로 디슈가한 것이다(문장이 하나 넘게 남아 있으면 여전히 M.do 블록으로서); M.do { e; rest }는 같은 방식으로 (M.>>) e rest'가 된다 — 교과서적인 do 디슈가링이지만, M.>>=와 M.>>는 타입 클래스 장치를 전혀 거치지 않고 다른 한정된 이름과 마찬가지로 심볼 테이블에서 그냥 찾아질 뿐이다. (>>=)와 (>>)를 정의하는 모듈이 무엇이든, 그 모듈 자신의 이름이 do 표기법을 여는 한정자다 — 이 커밋 시점에는 IO가 그것이며, Compile.hs의 여전히 하드코딩된 primOps 목록에 두 항목으로 배선되어 있지만 (693dc6ce:Compile.hs 78–79행: ("IO.>>=", Prim "IO.>>="), ("IO.>>", Prim "IO.>>")), pQualDo나 dsExpr의 EDo 케이스 어디에도 IO에 특화된 것은 없다 — 자신만의 (>>=)/(>>)를 정의하는 어떤 모듈이든 IO 모나드든 아니든 상관없이 자기만의 쓸 수 있는 do 블록을 공짜로 얻는다("직접 해보기" 연습 문제 1은 정확히 이것을 이용해 IO를 전혀 건드리지 않고 do 표기법을 실행한다).

95ff6a34: primitive, 그리고 컴파일러를 런타임에서 떼어내기

이 커밋 이전의 모든 런타임 프리미티브는 Compile.hs의 primOps 목록에 하드코딩된 항목이다 — 손으로 관리되는, 프리미티브 하나당 하스켈 소스 한 줄이며, 새 연산자가 런타임 대응물을 필요로 할 때마다 늘어난다. 95ff6a34는 그 목록을 통째로 지우고, 아무 모듈이나 자신만의 프리미티브 바인딩을 스스로 적어 넣을 수 있게 해 주는 새 식 형태로 대체한다: primitive "tag"가 새 파싱 가능한 구문이다 (95ff6a34:Parse.hs 291행, pAExpr에 추가된 대안 하나):

  <|> (EPrim <$> (pKeyword "primitive" *> pString))

그리고 매핑이 얼마나 기계적인지 그대로 보여 주듯, 옛 표가 손으로 만들어 주던 것과 정확히 같은 Exp.hs 생성자로 디슈가된다 (95ff6a34:Desugar.hs 54행, 전문 인용):

dsExpr (EPrim s) = Prim s

새 소스 파일 세 개가 같은 커밋에 함께 도착한다 — Prelude.uhs, IO.uhs, List.uhs(List 데이터 선언과 map, primitive와는 무관하다). Prelude.uhs와 IO.uhs는 처음부터 끝까지 우변이 primitive "..."인 최상위 바인딩뿐이다 — (+) = primitive "+", error = primitive "?", (>>=) = primitive "IO.>>=", readFile = primitive "IO.readFile" 등등. 예전에는 런타임 프리미티브 하나를 추가하려면 Compile.hs(컴파일러 내부의 하스켈 소스 파일)를 편집해야 했지만, 이제는 .uhs 모듈(평범한 프로그램 소스 파일)에 한 줄만 쓰면 된다 — primitive "..." 안의 문자열만이 eval.c가 실제로 이해하는 것과 일치해야 할 뿐이고, 컴파일러 자신은 이제 런타임의 컴비네이터 어휘를 전혀 몰라도 된다. Exp.hs의 flipOps 표(브래킷 추상화 스마트 생성자 cC가 "이 비교를 C 컴비네이터로 감싸는 대신 뒤집어라"를 알아보는 데 쓰임)도 같은 커밋에서 프리미티브 이름이 하드코딩된 순수 문자열 목록 대신 진짜 모듈 안에 살게 된 것의 작고 정직한 부작용으로 "Prelude.+", "Prelude.<" 등으로 다시 한정된다(95ff6a34:Exp.hs, 115–124행).

왜 중요한가

이 커밋들 각각이 실제로 치르는 비용을 보자. dsDef (Sign _ _) = []는 한 줄이다. dsExpr (EStr cs) = dsExpr $ EList $ map EChar cs는 한 줄이고 런타임에 새 케이스를 도입하지도 않는다 — 그저 이미 존재하는 디슈가링을 거칠 뿐이다. dsExpr (EList es) = foldr (App2 CO) CK $ map dsExpr es도 새 컴비네이터가 필요 없는데, O와 K가 이미 2장부터 eval.c에 앉아 있었기 때문이다. 그것이 런타임에 닿기 전에 모든 것을 한 줌의 SKI류 컴비네이터로 컴파일해 두는 것의 대가다: 대부분의 새 표면 구문은 백엔드에서 공짜다 — 그저 컴비네이터 코어가 이미 실행할 줄 아는 항을 만드는 새로운 방법일 뿐이다. 예외들이 오히려 시사하는 바가 크다: 문자 리터럴은 진짜 새 런타임 프리미티브가 필요했고 (CHAR 노드 태그, 파서 케이스, eval.c 안에서 GC가 볼 수 있는 표현), 그것이 이 장에서 두 하스켈 파일 몇 줄을 넘어서는 유일한 기능이다. 그 외 나머지 — let, 튜플, 리스트, 문자열, 타입 시그니처, do 표기법 — 는 모두 파서 몇 줄과 디슈가러 몇 줄뿐, 런타임 변화는 전혀 없다.

"파싱하고 무시하기"는 같은 절약을 코드가 아니라 시간에 적용한 것이다. e96d04be는 프로그래머가 f의 정의 위에 f :: Int -> Int를 쓸 수 있게 해 주기 위해 타입 시그니처가 무엇을 뜻하는지 결정할 필요가 없다 — 구문을 받아들이고 버리는 데는 한 줄이 들지만 몇 달을 벌어 준다: 여기서부터 9장까지의 모든 커밋은 타입 검사기가 그것을 강제하거나 심지어 읽어내기 위해 존재하기도 전에, 시그니처를 문서로 쓰든 아예 쓰지 않든 자유롭다. 이 단계의 프로젝트에서는 완결성보다 추진력이 중요하며, 두 습관 — 작은 코어로 디슈가하기, 의미를 정하기 전에 구문부터 받아들이기 — 모두 언어가 모든 커밋을 작은 연구 프로젝트로 만들지 않고도 빠르게 자라나는 방법이다.

실험 노트 여백

  • 커밋별 기능, 시간순: let+주석+error, 중위 연산자, 타입 시그니처 (무시됨), 튜플, 리스트 구문, 문자 리터럴, 문자열 리터럴, 모듈 임포트, do 표기법, primitive. 이 범위의 커밋 두 개는 아예 기능이 아니다 — 4e4c5252와 ff4622e8는 리팩터링이며, 둘 다 임기응변 특수 처리가 통일할 가치가 있을 만큼 쌓인 뒤에야 도착한다 (튜플과 리스트 둘 다 자기 케이스를 덧붙인 뒤의 패턴 파싱; 한정된 이름이 do에 꼭 필요해지기 직전의 식별자 파싱). 리팩터링은 기능 작업과 뒤섞여 있지, 자기만의 커밋이나 자기만의 날로 몰아서 처리되지 않는다.
  • 그날의 개수: 이 장의 열두 개 커밋 중 아홉 개 — 5ccb1b2a부터 b25d9fc8까지 — 는 2장이 b56ba28b로 마무리되는 것과 같은 날인 2023-05-30에 도착한다. 나머지 세 개 — ff4622e8, 693dc6ce, 95ff6a34 — 는 다음 작업 세션인 2023-06-01에 도착한다.
  • 하드코딩된 결합성/우선순위 사다리도 엄연한 메커니즘이다: 3aa12deb의 pA/pB/.../pG 사슬에는 연산자 표도 사용자에게 보이는 결합성/우선순위 선언도 없지만, *가 사슬 안에서 +보다 한 단계 더 강하게 묶이기 때문에 1 + 2 * 3을 1 + (2 * 3)으로 올바르게 파싱한다 — 우선순위 표 전체가 어느 함수가 어느 함수를 호출하는지일 뿐, 그 이상 추상적인 것은 없다.

직접 해보기

기준점: 693dc6ce("Add do desugaring.", do 디슈가링 추가). 관례 (00-overview.md 참고):

git worktree add /tmp/mhs-693dc6ce 693dc6ce
cd /tmp/mhs-693dc6ce

2장의 b56ba28b와 마찬가지로 이 커밋의 Makefile에는 파서 주도 컴파일러(Main.hs)를 위한 타깃이 없다 — 옛 Lam.hs/eval.c의 test 타깃만 존재한다. Main.hs를 손으로 빌드하면 2장의 빌드 노트가 다루는 것과 정확히 같은 GHC-9.8/mtl 문제에 부딪히고, 거기에 더해 이 장 범위에서 처음 등장하는 문제 하나가 더 있다. 노트가 다루는 새 파일 Compile.hs가 처음으로 존재하는 커밋이기 때문이다.

빌드 노트: 쓰인 그대로 빌드하면 —

nix develop $MICROHS -c ghc Main.hs -o uhs

먼저 2장의 ap-스코프에-없음 오류가 ParserComb.hs에서 난다 (Variable not in scope: ap :: Prsr s (a -> b) -> Prsr s a -> Prsr s b, ParserComb.hs:52:11) — 2장이 쓰는 것과 같은 한 줄짜리 수정을 ParserComb.hs의 기존 import Control.Monad.State.Strict 뒤에 적용한다:

import Control.Monad(ap)

그 수정을 적용하면 빌드는 더 나아가지만, b25d9fc8가 추가한 모듈 Compile.hs에서 같은 모양의 두 번째 문제에 부딪힌다:

Compile.hs:30:7: error: [GHC-88464]
    Variable not in scope: when :: Bool -> a2 -> StateT Cache IO a3
    Suggested fix:
      Perhaps use data constructor ‘Then’ (imported from Parse)
   |
30 |       when (nm `elem` ws) $
   |       ^^^^

(같은 모양의 두 번째 오류가 Compile.hs:44:3, 같은 함수의 다른 when 호출에서 뒤따른다 — 위에서는 생략했다.) when(Compile.hs 안에서 두 번 쓰이며, 둘 다 잘못된 조건에서 error를 일으키는 데 쓰임)도 이 GHC/mtl 조합에서 Compile.hs 자신의 import Control.Monad.State.Strict로는 스코프에 들어오지 않는다 — ap 문제와 근본 원인은 같고, 다른 심볼, 다른 파일이다. Compile.hs의 기존 import Control.Monad.State.Strict 뒤에 한 줄을 추가한다:

import Control.Monad(when)

그러면 빌드가 성공한다(2장이 짚었던 것과 같은 종류의 무해한 -Wx-partial 경고, head/tail에 관한 것은 아래에서 생략했다):

nix develop $MICROHS -c ghc Main.hs -o uhs
[1 of 7] Compiling ParserComb       ( ParserComb.hs, ParserComb.o )
[2 of 7] Compiling Parse            ( Parse.hs, Parse.o )
[3 of 7] Compiling Exp              ( Exp.hs, Exp.o )
[4 of 7] Compiling Desugar          ( Desugar.hs, Desugar.o )
[5 of 7] Compiling Compile          ( Compile.hs, Compile.o )
[6 of 7] Compiling Main             ( Main.hs, Main.o )
[7 of 7] Linking uhs

새 컴파일러가 만들어 내는 것을 런타임이 실행하기 전에 한 가지가 더 필요하다: eval.c 자신이며, Makefile의 eval 타깃이 이미 빌드할 줄 안다 —

nix develop $MICROHS -c gcc -Wall -O3 eval.c -o eval

아래 연습 문제들 전에 두 한 줄짜리 패치를 모두 적용하고 eval을 빌드해 둔다.

또한, Compile.hs가 b25d9fc8에서 들어온 이후로: 소스 파일은 파일 인자가 아니라 모듈 이름으로 로드된다 — Compile.hs의 compileModule은 명령줄에 넘긴 경로가 아니라 <name>.uhs를 읽으며, ./uhs Foo(./uhs Foo.uhs가 아니라)가 새 Main.hs가 그것을 호출하는 방식이다([mn] <- getArgs; ds <- compile mn, 693dc6ce:Main.hs).

연습 문제 1: let, 리스트, do 표기법, IO는 건드리지 않고

이 커밋 시점의 do 표기법은 IO 모듈도 어떤 내장 모나드도 필요로 하지 않는다 — "무슨 일이 있었나"에서 봤듯 M.do는 그저 자신만의 (>>=)와 (>>)가 스코프에 있는 모듈 M만 있으면 된다. 사소한 (>>=)/(>>) 쌍을 제공하고 그것을 do를 통해 쓰는, 작고 자족적인 모듈 하나를 써 보자:

cat > Demo.uhs <<'EOF'
module Demo where

(>>=) x f = f x
(>>) x y = y

main = Demo.do
  y <- 10
  let
    xs = [1, 2, 3]
   in case xs of
    [] -> 0
    (:) a as -> a + y
EOF
nix develop $MICROHS -c ./uhs Demo
importing "Demo"
importing done "Demo"
Demo.>>= = C I
Demo.>> = T
Demo.main = Demo.>>= 10 (C' (P 0) (B (B K) (C +)) (O 1 (O 2 (O 3 K))))
((T :0 (C I)) ((T :1 T) ((T :2 ((_0 10) (((C' (P 0)) ((B (B K)) (C +))) ((O 1) ((O 2) ((O 3) K)))))) _2)))

링크된 프로그램을 실행해 보자:

nix develop $MICROHS -c ./eval out.comb
((T :99996 (C I)) ((T T) ((T :99989 ((_99996 10) (((C' (P 0)) ((B (B K)) (C +))) ((O 1) ((O 2) ((O 3) K)))))) _99989)))
11
node size=16, heap size=100000
56 reductions, 0 GCs

eval.c의 main은 pp(stdout, n)을 두 번 호출한다, 파싱 직후 한 번 그리고 축약 이후 한 번(693dc6ce:eval.c 704행과 706행 — 2장의 54806439 이후로 바뀌지 않았다) — 위의 첫 줄은 pp 자신의 독립적인 공유-탐지 패스를 거쳐 출력된, 파싱은 됐지만 아직 축약되지 않은 항 (그래서 파일 자신의 :0/:1/:2와는 맞지 않는 :99996 같은 레이블 번호가 나온다)이고, 두 번째 줄은 evali가 실행된 뒤의 답이다: 11. y는 do 블록의 Bind에 의해 10으로 묶이고, xs는 리스트 [1,2,3]이며, xs에 대한 case는 (:) a as 절에 a = 1로 매치되고, a + y = 11이다. 여기까지 오는 길에 알아 둘 만한 막다른 길 두 곳이 있는데, 둘 다 위 예제의 오타가 아니라 이 커밋 파서의 진짜 한계다:

  • let x = 5 in x를 한 줄로 쓰면 파싱되지 않는다:

    cat > T1.uhs <<'EOF'
    module T1 where
    
    main = let x = 5 in x
    EOF
    nix develop $MICROHS -c ./uhs T1
    
    importing "T1"
    uhs: line 3, col 11:
       found: "x"
       expeced: let ' '
       ...
    

    let이 (pBlock의 softLC를 통해) 여는 블록은 언제나 다음 물리적 줄에서 들여쓰기를 가져오지, 현재 줄의 나머지에서는 가져오지 않으므로, 바인딩되는 변수들은 새로, 들여쓰인 줄에서 시작해야 한다.

  • in은 감싸는 do 블록의 열과 let 블록 자신의 열 사이 어딘가에 엄격하게 들여써야 한다(여기서는 do가 문장을 2열에서 시작하고, let 자신의 블록은 4열이므로 in은 3열에 와야 한다). in을 2열 이하로 들여쓰면 레이아웃 알고리즘의 공백-기반 내어쓰기 규칙 (pWhite, Parse.hs)이 let 블록을 올바르게 닫기는 하지만, 같은 내어쓰기를 새 do 문장의 시작으로도 동시에 읽어서 in 앞에 ;를 끼워 넣어 파싱을 깨뜨린다. (:) 절만 있고 [] 절이 없는 case도 조용히 축약에 실패한다 — 2장의 O 컴비네이터는 그 순서로 두 연속체(nil 갈래, 그다음 cons 갈래)를 받는데, 한 절짜리 case는 이를 덜 적용해서 결과가 숫자가 아니라 부분 적용인 채로 멈춘다 (한 절짜리 버전을 eval로 직접 실행해 축약되지 않은 항이 그대로 나오는 것을 확인했다, 오류가 아니라).

연습 문제 2: out.comb에서 디슈가된 do 체인 읽어 보기

연습 문제 1의 out.comb는 106바이트로, 통째로 읽을 만큼 작다:

((T :0 (C I)) ((T :1 T) ((T :2 ((_0 10) (((C' (P 0)) ((B (B K)) (C +))) ((O 1) ((O 2) ((O 3) K)))))) _2)))

:0, :1, :2는 소스 순서대로의 최상위 정의 세 개 — Demo.>>=, Demo.>>, Demo.main — 이며, 각각 컴비네이터 항으로 컴파일되어 2장의 17587b2d의 레이블 방식으로 링크되어 있다. :0의 항은 C I다 — C f x y = f y x이므로 (>>=) x f = f x가 브래킷 추상화로 컴파일되면 C I x y = I y x = y x = f x가 되어, f와 x의 이름만 맞춰지면 된다. Demo.main의 항인 :2는 693dc6ce:Desugar.hs의 do 디슈가링이 그대로 눈에 보이는 곳이다: (_0 10) (...)은 _0 (Demo.>>=, 즉 바인드 연산자)에 10(디슈가된 Bind "y" (EInt 10))을 적용한 뒤 람다에서 컴비네이터로 바뀐 연속체를 적용한 것이다 — 정확히 dsExpr (EDo n (Bind i e : ss)) = App2 (Var (n ++ ".>>=")) (dsExpr e) (Lam i $ dsExpr (EDo n ss))가 만들어 내는 모양이며, n = "Demo", i = "y", e = EInt 10, ss는 연속체의 본문이 되는 case 식이다. 연속체 자신, (C' (P 0) (B (B K) (C +))) (O 1 (O 2 (O 3 K)))는 연습 문제 1의 막다른 길 노트에서 나온 리스트-위-case가 컴파일된 것이다: O 1 (O 2 (O 3 K))는 [1,2,3]이고(d91b9812:Desugar.hs의 EList 디슈가링 — "무슨 일이 있었나" 참고), (P 0)/(B (B K) (C +))는 그 순서대로 컴파일된 [] -> 0과 (:) a as -> a + y 절이며, O의 축약 규칙이 기대하는 두 연속체다.

연습 문제 3: 파서 확장하기 — 정수 리터럴 안의 밑줄

이 커밋 시점의 pInt는 숫자 하나 이상만 받아들이고 그 외에는 아무것도 받아들이지 않는다(693dc6ce:Parse.hs 204행, 전문 인용):

pInt :: P Integer
pInt = (read <$> (skipWhite $ esome $ satisfy "digit" isDigit)) <?> "int"

하스켈 스타일의 자릿수 구분자가 오늘은 파싱되지 않는다는 것을 확인해 보자:

cat > Under.uhs <<'EOF'
module Under where

main = 1_000
EOF
nix develop $MICROHS -c ./uhs Under
importing "Under"
uhs: line 3, col 8:
   found: "_000"
   expeced: int digit
   ...

(pInt는 1을 읽고 숫자가 아닌 첫 문자에서 멈춘다; pExprApp은 그런 다음 1을 적용할 또 다른 원자 식을 찾는데 _000은 그런 것이 아니다 — 글자로 시작하지 않으니 식별자도 아니다.) pInt (Parse.hs, 위에서 인용한 그 한 줄)를 고쳐 _를 자릿수 구분자로 받아들이게 하되, 앞에 숫자 하나가 오도록 요구하고 read하기 전에 밑줄을 걸러낸다:

수정 전(Parse.hs 204행):

pInt = (read <$> (skipWhite $ esome $ satisfy "digit" isDigit)) <?> "int"

수정 후:

pInt = (read . filter (/= '_') <$> (skipWhite $ (:) <$> satisfy "digit" isDigit <*> emany (satisfy "digit" isDigit <|> char '_'))) <?> "int"

다시 빌드하고 실행해 보자:

nix develop $MICROHS -c ghc Main.hs -o uhs
nix develop $MICROHS -c ./uhs Under
[2 of 7] Compiling Parse            ( Parse.hs, Parse.o ) [Source file changed]
[7 of 7] Linking uhs [Objects changed]
importing "Under"
importing done "Under"
Under.main = 1000
((T :0 1000) _0)
nix develop $MICROHS -c ./eval out.comb
((T 1000) 1000)
1000
node size=16, heap size=100000
6 reductions, 0 GCs

(첫 줄은 연습 문제 1의 트랜스크립트와 같은 pp의 축약 전 에코이고, 두 번째, 1000은 축약된 답이다.) 1_000은 이제 정수 1000으로 파싱되어 1000으로 실행된다; 밑줄 없는 평범한 main = 42도 여전히 컴파일되어 바뀌지 않고 42로 실행된다 — 수정된 uhs로 같은 방식으로 다시 실행해 확인한다:

cat > Reg.uhs <<'EOF'
module Reg where

main = 42
EOF
nix develop $MICROHS -c ./uhs Reg
nix develop $MICROHS -c ./eval out.comb
((T 42) 42)
42
node size=16, heap size=100000
6 reductions, 0 GCs

정리한다:

git worktree remove --force /tmp/mhs-693dc6ce

🌐 Read in English

04 — Talking to the world (세상과 대화하기)

현재 위치

3장은 let, 중위 연산자, 튜플, 리스트, 문자열, 모듈, do-표기법을 갖춘 언어를 남긴다 — 여기에는 Prelude.uhs, List.uhs와 함께 95ff6a34가 만든 IO 모듈, IO.uhs도 포함된다. 그 커밋 시점의 IO.uhs는 프리미티브 선언 목록에 지나지 않는다(95ff6a34:IO.uhs, 전문 인용):

module IO where

(>>=)     = primitive "IO.>>="
(>>)      = primitive "IO.>>"
return    = primitive "IO.return"
readFile  = primitive "IO.readFile"
writeFile = primitive "IO.writeFile"
putChar   = primitive "IO.putChar"
getChar   = primitive "IO.getChar"

그리고 3장의 693dc6ce가 do-표기법을 "IO 모나드"라는 내장 개념이 아니라 (>>=)/(>>)를 제공하는 모듈이 무엇이든 그것에 따라 디스패치하게 만들었기 때문에, IO.do { putChar 'h'; putChar 'i' }는 지금까지 존재하는 모든 단계를 거쳐 깔끔하게 파싱되고, 디슈가되고, 컴파일된다 — 아직 타입 검사기가 없으므로(3장의 e96d04be는 :: 시그니처가 파싱된 뒤 버려진다는 사실을 확립한다) IO가 특별하다는 것을 알아채거나 신경 쓸 대상이 없다. 하지만 컴파일된다고 실행되는 것은 아니다. 2장 이후 한 번도 바뀌지 않은 eval.c는 여전히 예전의 문자 디스패치 파서이자 축약기다: 그 프리미티브 테이블은 단일 문자('+', '-', '*', '/', '%', '=', '!', '<', '>', '?')에 대한 하드코딩된 switch이며, IO.>>=처럼 여러 문자로 된 이름을 인식할 수 있는 항목도, 그런 항목을 위한 메커니즘도 없다. 직접 검증하면: 이 범위의 첫 커밋인 834fd9ba (아직 모나딕 IO가 들어오기 전이다, 아래 참고)를 3장의 빌드 노트가 쓰는 것과 같은 한 줄짜리 ap/when 패치로 빌드하고, 다음을 실행하면

module TestIO where
import Prelude
import IO

main = IO.do
  putChar 'h'
  putChar 'i'

IO.>>=, IO.putChar 등을 접두사 없는 순수 식별자 텍스트로 그대로 담고 있는 .comb 파일로 컴파일되고(Prim은 이 시점에서 자신의 원시 문자열 그대로 출력된다 — 런타임이 프리미티브 참조임을 인식하게 해 주는 $-접두사 관례는 아직 존재하지 않는다), 그 파일을 실행하면 즉시 죽는다:

$ nix develop $MICROHS -c ./eval out.comb
ERR: parse ')'

그러므로 "현재 위치"는 정확히 이렇다: IO 구문, IO 모듈, 그리고 그것과 우연히 잘 맞아떨어지는 범용 do-표기법은 모두 이미 갖춰져 있다. 그 아래의 런타임은 허구다 — 모든 IO 프리미티브는 컴파일러 아래에 아무 의미도 없는 이름일 뿐이다. 이 장의 첫 커밋이 바로 그것에 의미를 부여하는 커밋이다.

무슨 일이 있었나

3246e2a6: 모나딕 IO, 그리고 IO 값이 실제로 무엇인가

"Implement monadic IO."(모나딕 IO 구현)는 2023-06-01 저녁, 3장을 마감한 95ff6a34 이후 약 네 시간 사십 분 뒤에 올라간다 — 빈 공백이 아니다: 같은 날 더 일찍(12:09, 12:32) 834fd9ba "Small improvements"(작은 개선들)와 e2f3cf57 "More stuff"(이것저것 더)가 올라가는데, 둘 다 설명 없는 준비 작업으로 IO.uhs를 확장한다 — 프리미티브 선언 하나(print) 추가, 그리고 기존 프리미티브 위에 지어진 파생 헬퍼(mapM, mapM_, putStr, putStrLn) 첫 묶음 — 여전히 어떤 IO.* 이름 뒤에도 런타임은 없는 채로. 같은 커밋에서 두 가지 일이 벌어지며, 이 둘을 구분해 두는 것이 좋다: eval.c는 이름 있는 프리미티브를 위한 범용 메커니즘(IO 전용이 아닌)을 얻고, 그 메커니즘 위에서 IO가 첫 실제 구현을 얻는다.

범용 메커니즘 쪽: 개별적으로 이름 붙은 전역 변수들의 목록이던 NODEPTR combS, combK, ...가 {name, tag, node} 삼중항의 배열 하나로 대체된다. 일부를 생략하며 인용한다(3246e2a6:eval.c, 168–210행):

struct {
  char *name;
  enum node_tag tag;
  NODEPTR node;
} primops[] = {
  /* combinators */
  { "S", S },
  { "K", K },
  ...
  { "O", O },
  /* primops */
  { "+", ADD },
  ...
  { "ord", ORD },
  { "IO.>>=", IO_BIND },
  { "IO.>>", IO_THEN },
  { "IO.return", IO_RETURN },
  { "IO.readFile", IO_READFILE },
  { "IO.writeFile", IO_WRITEFILE },
  { "IO.getChar", IO_GETCHAR },
  { "IO.putChar", IO_PUTCHAR },
  { "IO.print", IO_PRINT },
};

그리고 .comb 파일 파서의 '+'/'-'/'?' 등 단일 문자 case들은 임의의 이름을 읽어 들여 찾아보는 '$' case 하나로 대체된다 (3246e2a6:eval.c, 365–381행, 전문 인용):

  case '$':
    /* A primitive, keep getting char's until end */
    for (int j = 0;;) {
      c = getc(f);
      if (c == ' ' || c == ')') {
        ungetc(c, f);
        buf[j] = 0;
        break;
      }
      buf[j++] = c;
    }
    for (int j = 0; j < sizeof primops / sizeof primops[0]; j++) {
      if (strcmp(primops[j].name, buf) == 0)
        return primops[j].node;
    }
    fprintf(stderr, "bad primop %s\n", buf);
    ERR("no primop");

이제 모든 컴비네이터와 프리미티브는, IO든 아니든, 이 경로 하나를 지난다 — 이 장의 트랜스크립트 어디에서나 보이는 $-접두사 관례 ($K, $IO.>>=, $chr, ...)는 바로 이 커밋의 작품이며, IO.>>=를 애초에 파싱 가능하게 만드는 인프라 조각이다. IO.>>=가 그다음에 무엇을 하는지와는 별개로 그렇다.

그것이 하는 일은 새로운 enum node_tag 값 여덟 개다(3246e2a6:eval.c, 18–21행, 전문 인용):

enum node_tag { FREE, IND, AP, INT, CHAR, S, K, I, B, C, T, Y, SS, BB, CC, P, O,
                ADD, SUB, MUL, QUOT, REM, SUBR, EQ, NE, LT, LE, GT, GE, ERROR, CHR, ORD,
                IO_BIND, IO_THEN, IO_RETURN, IO_READFILE, IO_WRITEFILE, IO_GETCHAR, IO_PUTCHAR, IO_PRINT
};

IO a 값은, 이 커밋에서도, 이 장의 범위 안 그 어떤 이후 커밋에서도, 별도의 런타임 타입이 아니다 — 이 여덟 개의 새 태그 중 하나를 가진 평범한 그래프 노드일 뿐이다. 보통의 축약 루프인 eval()에게 이 태그들은 불투명하며, INT나 CHAR와 똑같이 이미-값인 태그다 — 전체 처리는 축약 규칙 없는 RET 하나뿐이다(3246e2a6:eval.c, 769–777행, 전문 인용):

    case IO_BIND:
    case IO_THEN:
    case IO_RETURN:
    case IO_READFILE:
    case IO_WRITEFILE:
    case IO_GETCHAR:
    case IO_PUTCHAR:
    case IO_PRINT:
      RET;

의미는 전적으로 두 번째 인터프리터인 evalio에 담기며, main은 예전의 evali+출력 패턴 대신 프로그램 전체에 대해 이를 한 번 호출한다. evalio는 eval과 같은 종류의 그래프를 순회하지만, 그 switch는 이 여덟 개 태그에 실제 동작을 부여한다 — 순차 실행이 C 제어 흐름으로 직접 풀려 쓰인다(3246e2a6:eval.c, 817–830행):

    case IO_BIND:
      CHECKIO(2);
      GCCHECK(1);
      x = evalio(ARG(TOP(1)));  /* first argument, unwrapped */
      f = ARG(TOP(2));          /* second argument, the continuation */
      n = new_ap(f, x);
      POP(3);
      goto top;
    case IO_THEN:
      CHECKIO(2);
      (void)evalio(ARG(TOP(1))); /* first argument, unwrapped, ignored */
      n = ARG(TOP(2));          /* second argument, the continuation */
      POP(3);
      goto top;

IO_BIND(>>=)는 왼쪽 계산을 재귀적으로 실행해 포장 풀린 결과를 얻고, 그 결과에 연속을 적용한 뒤, 그 결과가 무엇이든 그 위에서 다시 루프를 돈다(goto top) — 재귀적 하강이 아니라 진짜 트램폴린이므로, 긴 do-체인이 C 스택을 키우지 않는다. IO_THEN(>>)은 왼쪽 결과를 버린다는 점만 다를 뿐 같은 모양이다. 하스켈/컴파일러 쪽에도 "IO 타입"에 해당하는 것은 없다: EDo의 디슈가링(3장의 693dc6ce:Desugar.hs)은 어떤 M에 대해서도 M.do { x <- e; rest }를 App2 (Var "M.>>=") e (Lam x rest)로 바꾸며, 그것이 Exp.hs에 도달할 즈음에는 +나 error와 항 수준에서 구별되지 않는 평범한 Prim/Var 노드일 뿐이다. "이것은 IO다"라는 사실은 오직 evalio의 switch만 알고 있으며, 시스템 그 어디에도 없다 — nfib를 이미 돌렸던 것과 같은 아키텍처다.

같은 커밋이 한 가지를 더 추가한다: do-블록 안의 Let 문 (M.do { let x = e; rest })이다. 이는 3장의 식-수준 let이 그 시점까지 그랬던 것과 다를 바 없이, 평범하고 재귀적이지 않은 애플리케이션으로 디슈가된다(3246e2a6:Desugar.hs, 54행, 문맥을 위해 앞뒤 두 줄과 함께 인용):

dsExpr (EDo n (Bind i e : ss)) = App2 (Var (n ++ ".>>=")) (dsExpr e) (Lam i $ dsExpr (EDo n ss))
dsExpr (EDo n (Then   e : ss)) = App2 (Var (n ++ ".>>"))  (dsExpr e)         (dsExpr (EDo n ss))
dsExpr (EDo n (Let  i e : ss)) = App  (Lam i $ dsExpr (EDo n ss)) (dsExpr e)

이 줄은 이 장의 범위가 끝날 때까지 한 번도 바뀌지 않는다 — 식-수준 let을 무조건 재귀적으로 만드는 fe22efbd(아래)가 온 뒤에도. do-블록 안에 쓰인 let은 8c5fbb1a에 이르기까지 원래의 단순한, 재귀적이지 않은 람다 애플리케이션인 채로 남는다. 똑같이 쓰인 두 let이, 어느 문법적 위치에 있느냐에 따라 서로 다른 디슈가링 규칙을 갖는 것이다; 이 범위의 어떤 것도 둘을 통일하지 않는다.

그리고 정직하게 배포된 하나의 빈틈: IO_READFILE과 IO_WRITEFILE은 프리미티브 테이블에 선언되어 있고, 파싱되고, 출력도 되지만 — 그 evalio 본체는 스텁이다(3246e2a6:eval.c, 836–841행, 전문 인용):

    case IO_READFILE:
      CHECKIO(1);
      ERR("IO_READFILE");
    case IO_WRITEFILE:
      CHECKIO(2);
      ERR("IO_WRITEFILE");

콘솔 IO — getChar/putChar, stdin/stdout에 하드코딩됨 — 는 이 커밋에서 곧바로 동작한다. 파일 IO는 선언만 되어 있고, 처음 쓰이는 순간 죽도록 방치된다. 4c4229ec("Fix bugs and improve.", 버그 수정과 개선)와 7bb30b64("Minor improvements.", 소소한 개선)가 같은 날 저녁 뒤이어 올라간다 — 4c4229ec는 ELam을 다중 인자 람다로 일반화하고, 이름 붙은 Prelude 함수로 fix = primitive "Y"를 추가한다(let 자체가 하는 무언가와는 별개로, Y 태그로 가는 두 번째, 명시적인 경로다 — List.uhs의 새 map/filter/foldr가 곧바로 이를 사용하는데, 각각 fix (\ rec arg -> ...) 형태로 쓰였다. 평범한 재귀 let이 아직 언어 기능으로 존재하지 않기 때문이다) — 하지만 두 커밋 다 IO_READFILE/IO_WRITEFILE은 건드리지 않는다. 그것들은 다음 날 아침까지 고장 난 채로 남는다.

c5564e64: 파일을 지원하도록 IO를 다시 만들다

여기서는 타임스탬프가 중요하다: 3246e2a6은 2023-06-01 16:41에 커밋되고, c5564e64는 그다음 날 2023-06-02 09:38에 커밋된다 — "이틀"이 아니라 약 열일곱 시간이며, 오래된 일로 돌아온 것이 아니라 바로 다음 작업 세션이다. "Redo IO to allow files"(파일을 지원하도록 IO를 다시 만들다)는 예전엔 동작하던 무언가를 고치는 버그 수정이 아니다 — ERR() 스텁으로 배포됐던 두 프리미티브를 실제로 구현하려는 첫 시도다.

평면적 프리미티브 설계가 부딪히는 문제는 이렇다: 선언된 그대로의 IO.readFile/IO.writeFile에는 열린 파일을 담아 둘 자리가 없다 — "아직 다 읽거나 닫히지 않은, 열려 있는 파일"을 위한 런타임 표현이 없는 것이다. c5564e64는 그런 표현 하나를 추가한다: 새 노드 태그 HDL이며, 그 페이로드는 원시 C FILE *다(c5564e64:eval.c, 유니온이 HANDLE(p)로 접근되는 FILE *file 필드를 얻는다). INT/CHAR와 나란히 또 하나의 이미-값인 태그로 합류하는 것이다. stdin/stdout을 가정하던 모든 IO 프리미티브는 이제 명시적인 핸들 인자를 받는다 — IO.getChar/IO.putChar/IO.print는 hGetChar/hPutChar/hPrint가 되고, 파일을 여는 동작은 이런 핸들 노드 중 하나를 만들며, putChar/getChar/print는 내장된 stdin/stdout 노드에 h-접두사 프리미티브를 적용하는 얇은 래퍼로 살아남는다(c5564e64:IO.uhs, 인용):

openFile :: FilePath -> IOMode -> IO Handle
openFile p m =
  let
    n = case m of
          ReadMode -> 0
          WriteMode -> 1
          AppendMode -> 2
          ReadWriteMode -> 3
  in  (primitive "IO.open") p n

putChar :: Char -> IO ()
putChar = hPutChar stdout

writeFile :: FilePath -> String -> IO ()
writeFile p s = IO.do
  h <- openFile p WriteMode
  hPutStr h s
  hClose h

writeFile은 더 이상 프리미티브가 전혀 아니다 — 모든 것을 다 하는 프리미티브 하나 대신, openFile + hPutStr(그 자체는 mapM_ (hPutChar h)) + hClose, 세 프리미티브가 Prelude에서 합성된 것이다. IO_OPEN의 C 쪽은 fopen에 넘길 C 문자열이 필요한데, 런타임에는 문자열 타입이 없다 — String은 3장의 d91b9812/ddb1bb29 메커니즘이 만든, 관례로만 의미를 갖는 Char 노드의 스콧 인코딩 콘스-리스트다. c5564e64는 그 관례를 손으로 직접 순회하는 새 헬퍼 evalstring을 추가하는데, 각 콘스 셀을 강제 평가해 그 문자를 고정 크기 10000바이트 C 버퍼에 복사한다. 타협임을 스스로 인정하는 주석과 함께 (c5564e64:eval.c, 전문 인용):

/* XXX this is cheating, should use continuations */
char *
evalstring(NODEPTR n)
{
  size_t sz = 10000;
  char *p, *name = malloc(sz);

  if (!name)
    ERR("evalstring malloc");
  for (p = name;;) {
    if (p >= name + sz)
      ERR("evalstring too long");
    n = evali(n);
    if (TAG(n) == K)
      break;
    else if (TAG(n) == AP && TAG(FUN(n)) == AP && TAG(FUN(FUN(n))) == O) {
      *p++ = evalchar(ARG(FUN(n)));
      n = ARG(n);
    } else {
      ERR("evalstring not Nil/Cons");
    }
  }
  *p = 0;
  return name;
}

10000자를 넘는 파일 경로는 우아하게 처리되지 않고 그냥 ERR이다 — 실재하는, 그대로 남겨진 한계다. 이것은 축소판 재작성-기반 설계의 한 예다: 첫 시도는 그 아래에 표현 하나 없이 파일 IO의 모양 (readFile, writeFile, 프리미티브 둘)만 선언했고, 다음 날 아침 첫 실사용에서 그 사실을 발견했으며, 낡은 모양을 억지로 고쳐 동작시키는 대신 모양 자체를 교체했다 — 일급 값이 된 핸들, 콘솔/파일 연산 하나하나가 그 위에 매개변수화된 것으로.(readFile 자체는 같은 날 나중에 나오는 a3355632("More functions.", 함수 추가)에서야 다시 등장한다. openFile + 새로운 hGetContents로 만들어지며, 이는 IO.getChar가 CHAR 노드에 담긴 -1을 반환할 때까지 읽는다 — getc의 EOF 신호값 그 자체이며, 아무런 특별한 처리도 없이 ord c == negate 1이라는 정수 동등성 비교로 저장되고 비교될 뿐이다. 이 시스템 어디에도 파일 끝을 나타내는 별도의 표현은 없다.)

fe22efbd: 재귀 let, 그리고 "재귀였는가"라는 질문이 실제로 답을 얻는 곳

3장의 5ccb1b2a는 let을 엄격하게 순차적이고 재귀적이지 않은 람다 애플리케이션으로 디슈가하면서 "For now, just sequential bindings."라는 주석을 남긴다. fe22efbd("Make let bindings recursive, and optimise the non-recursive case", let 바인딩을 재귀적으로 만들고 비재귀 경우는 최적화)는 그 "일단은"이라는 말을 지킨다 — let으로 바인딩된 모든 이름은, 예외 없이, 이제 고정점 컴비네이터를 통해 자기 자신의 우변에서 보인다 (aux/y-combinator-and-recursion.md). 디슈가링은 바인딩마다 Y 하나를 끼워 넣는 식으로 바뀐다 (fe22efbd:Desugar.hs, 41행):

      def (i, d) a = App (Lam i a) (App (Comb "Y") (Lam i d))

let i = d in a는 (\i -> a) (Y (\i -> d))가 된다 — d가 실제로 i를 언급하든 안 하든 모든 바인딩이 이렇게 된다. 이는 aux 문서의 메커니즘(Y f = f (Y f), 스스로에게 고리를 건 그래프 셀 하나, eval.c의 case Y)을 일률적으로 적용한 것이며, 그 결과 압도적으로 흔한 경우인 비재귀적 let은, 무언가가 알아채지 않는 한, 필요 없는 Y 셀 값을 치르게 된다. 같은 커밋이 컴파일 후 정리 패스인 Exp.hs의 improveT에 규칙 딱 하나를 더 추가한다(fe22efbd:Exp.hs, 159–165행, 전문 인용):

improveT :: Exp -> Exp
improveT (App f a) =
  case (improveT f, improveT a) of
    (CK,                     CI) -> CT
    (CY,               App CK e) -> e
    (e1,                     e2) -> App e1 e2
improveT e = e

흥미로운 부분은 "d가 재귀적이었는가"가 여기서 어떻게 답을 얻는가다 — 디슈가링 전 표층 구문에 대한 자유 변수 스캔이 아니라, 브래킷 추상화 자신의 스마트 생성자들로부터 사후에, 공짜로 얻어진다. d가 i를 언급하지 않을 때 \i -> d를 브래킷 추상화하면 결국 abstract의 캐치올 케이스인 App CK d로 귀결된다 — 그리고 2장부터 있던 cS의 기존 규칙(cS (App CK e1) (App CK e2) = cK (App e1 e2))에 따르면 닫힌 부분항은 내부 모양이 무엇이든 단 하나의 최상위 K 래퍼로 무너진다. 그래서 비재귀 바인딩의 컴파일된 형태는 항상, 기계적으로, 어떤 d'에 대해 Y (K d')이 된다 — 그리고 Y (K e) = K e (Y (K e)) = e는 Y 자신의 정의식만으로, 순환 없이, 한 단계 만에 성립한다. improveT의 새 줄은 정확히 그 모양을 알아보고 곧바로 e로 바꾼다. 그래서 필요 없던 바인딩에 대해서는 런타임이 스스로에게 고리를 건 셀을 결코 만들지 않게 된다 — 재귀/비재귀의 구분이 선언되는 것이 아니라 발견되는 것이다.

967b92c0: 데이터 타입은 늘어도 컴비네이터는 그대로

967b92c0("More data types.", 데이터 타입 추가)는 Prelude.uhs에 Maybe와 Either를, String.uhs에 showMaybe/showEither/showBool을 추가한다 — 그 외에는 아무것도 건드리지 않는다(git show 967b92c0 --stat이 이를 확인해 준다: .uhs 파일 두 개뿐이고, eval.c, Desugar.hs, Exp.hs는 한 줄도 바뀌지 않았다). 그것이 가능한 이유는 data 선언이 컴파일되는 메커니즘이 2장 이후 바뀌지 않았고, 이 커밋이 그 메커니즘의 깔끔한 한 사례이기 때문이다. 이 커밋 시점에도 바뀌지 않은 Desugar.hs의 처리(967b92c0:Desugar.hs, 17–22행, 전문 인용):

dsDef (Data _ cs) = zipWith dsConstr [0..] cs
  where
    fs = [f i | (i, _) <- zip [0..] cs]
    dsConstr i (c, ts) = (c, lams xs $ lams fs $ apps (Var (f i)) (map Var xs))
      where xs = ["$x" ++ show (j::Int) | (j, _) <- zip [0..] ts]
    f i = "$f" ++ show (i::Int)

선언 순서상 인덱스 i, 필드 ts를 가진 각 생성자는, 자기 자신의 필드 값(xs)들 뒤에 그 타입의 생성자마다 하나씩인 연속(fs, cs의 각 항목에 대해 $f0, $f1, ... 하나씩) 인자를 받는 함수로 컴파일된다 — 이 모든 것이 채워지면 자기 자신의 연속인 f i를 자기 자신의 필드에 적용하고 다른 모든 연속은 그냥 무시한다. data Maybe a = Nothing | Just a는 Nothing을 \$f0 $f1 -> $f0로, Just를 \x $f0 $f1 -> $f1 x로 컴파일한다 — 그 타입의 값은 검사할 태그를 지니고 있는 것이 아니라, 그 자체가 디스패처다. 그 타입이 가진 생성자 개수만큼의 연속을 건네받기를 기다리다가 정확히 하나를 호출한다. 이것은 2장이 손으로 만든 튜플/리스트 생성자에 썼고 3장의 8939d55b/d91b9812가 튜플과 리스트 구문을 위해 재사용한 것과 같은 스콧 인코딩 트릭이다 — 967b92c0은 진짜로 새로운, 사용자가 직접 정의한 합 타입에 이를 적용하는 이 장 범위의 첫 커밋이며, Desugar.hs 아래로는 아무 비용도 들지 않는다. 그 메커니즘이 이미 완전히 범용이기 때문이다. ECase의 디슈가링은 그 쌍대다: 스크루티니를 케이스 팔의 본문들에, 연속으로서, 본문이 텍스트상 쓰인 순서대로 적용한다 — dsDef의 생성자-인덱스 번호 매김이 조용히 그 순서와 맞물릴 것을 전제하는 사실이다(맞물리지 않으면 어떻게 되는지는 "직접 해보기"의 연습 문제 1 참고).

d2956ee8: Y를 더 잘 최적화하기, 그리고 aux 문서가 남긴 약속 매듭짓기

fe22efbd의 improveT 규칙은 Y (K e)를 잡아내지만, 그 정확한 모양이 완전히 브래킷 추상화된 항의 최상위까지 구조적으로 온전히 살아남을 때에만 그렇다. improveT가 compile이 끝난 뒤 완성된 항 위에서 한 번만 실행되는 별도의 패스이기 때문이다(compileOpt = improveT . compile). 감싸는 람다 안에 놓인 let은 — 실제로는 파라미터 하나만 있는 최상위 함수도 이미 람다 하나이므로 사실상 거의 모든 let이 그렇다 — 그 모양으로 improveT에 도달하지 않는다. 바깥 람다 자신의 브래킷 추상화가 안쪽의 Y (K e)를 흡수해 cS/cB를 거쳐 바깥 함수를 만드는 과정에서 재구조화해 버리고, 그 과정이 끝날 즈음에는 그 항은 App CY (App CK e)가 아니라 B Y (B K e)가 되어 있다 — improveT의 유일한 규칙이 결코 매칭하지 못하는 모양이며, 그래서 Y 셀은 최적화되지 않은 채 런타임으로 넘어간다.

d2956ee8("Optimize Y better.", Y를 더 잘 최적화)는 이 인식을 브래킷 추상화 자신의 메커니즘 안으로 옮겨서 이 문제를 고친다. 재구조화가 일어나기 전, 컴파일 도중 아래에서 위로 발동하도록 만드는 것이다 — B 컴비네이터의 스마트 생성자인 cB에 새 줄 하나(d2956ee8:Exp.hs, 99–104행, 전문 인용):

cB :: Exp -> Exp -> Exp
cB CC          (App CC CI)    = CP -- Pair
cB (App CB CK) CP             = CO -- Cons
cB CY          (App2 CB CK e) = e  -- B Y (B K e) = e
cB CI          e              = e  -- B I e = e
cB e1          e2             = App2 CB e1 e2

직접 검증하면: 다음의 동일한 프로그램

module YOpt where
import Prelude
import IO

f :: Int -> Int
f x =
  let
    y = x + 1
   in y + y

main = print (f 10)

을 967b92c0(이 최적화 이전)과 d2956ee8(이후) 시점에서 각각 컴파일하면, f에 대해 서로 다른 두 컴파일 결과가 나온다:

967b92c0:  YOpt.f = B (S Prelude.+ I) (B Y (B K (Prelude.+ 1)))
d2956ee8:  YOpt.f = B (S Prelude.+ I) (Prelude.+ 1)

두 번째 것에서는 Y가 완전히 사라졌다 — 줄어든 게 아니라 제거된 것이다 — 그리고 두 버전 다 여전히 같은 답인 22로 실행되지만, d2956ee8 빌드는 더 적은 축약 횟수로 그렇게 한다(같은 계산에 대해 340 대 351, 둘 다 ./eval의 축약 카운터로 측정). f의 본문 안에 있는 비재귀 let은 다른 람다 아래에 아무리 깊이 중첩되어 있어도 더 이상 런타임에 스스로에게 고리를 건 그래프 셀을 요구하지 않는다 — 이는 aux 문서가 "커밋 d2956ee8 ... 이 구현을 다시 손본다. 무엇이 바뀌는지는 여기서 다룰 범위 밖이다; 4장이 다룬다."로 마무리하며 남긴 약속 그대로다. 같은 커밋은 또한 List.uhs의 map/filter/foldr와 IO.uhs의 mapM_를 4c4229ec/c5564e64가 써 오던 명시적인 fix (\ rec arg -> ...) 관용구에서 평범한 let rec = ... in rec로 옮긴다 — 라이브러리 코드는 이 최적화가 방금 공짜로 만든 언어 기능을, 그것을 공짜로 만든 바로 그 커밋에서 곧바로 쓰기 시작한다.

b1e84cf7과 8c5fbb1a: 직렬화와 역직렬화

Main.hs의 writeFile "out.comb" $ toStringP res(2장/3장의 메커니즘, 그대로)와 eval.c의 print/pp(축약 전후로 프로그램을 그대로 찍어내는 데 쓰임) 둘 다 이미 존재한다. IO.print(나중에 IO.serialize로 이름이 바뀐다)는 바로 그 print 함수를 런타임 IO 프리미티브로 배선한 것에 지나지 않는다 — 시작 시점에 컴파일된 프로그램 전체뿐 아니라, 어떤 살아 있는 그래프 값이든 동일한 텍스트 형식($name 프리미티브 참조, :N/_N 공유 라벨)으로 파일 핸들에 덤프한다. 그 배선 자체는 이 장의 범위 이전에 이미 존재했다 (IO.print/hPrint, c5564e64); 새로운 것은 핸들을 담은 값들과 접촉해도 살아남게 만드는 것, 그리고 역방향을 추가하는 것이다.

b1e84cf7("Handle serialization of standard handles.", 표준 핸들의 직렬화 처리)는 좁은 틈 하나를 고친다: 이 이전에는 어떤 HDL 노드든 print의 switch에 다다르면 그대로 오류였다(1a2f7b7a:eval.c, 468행: case HDL: ERR("Cannot serialize handles");). b1e84cf7은 잘 알려진 세 핸들만 C 포인터 동일성으로 정확히 알아보고, 대신 이름 붙은 프리미티브로 출력한다 (b1e84cf7:eval.c, 483–492행, 전문 인용):

  case HDL:
    if (HANDLE(n) == stdin)
      fprintf(f, "$IO.stdin");
    else if (HANDLE(n) == stdout)
      fprintf(f, "$IO.stdout");
    else if (HANDLE(n) == stderr)
      fprintf(f, "$IO.stderr");
    else
      ERR("Cannot serialize handles");
    break;

새로 openFile로 연 핸들은 여전히 직렬화할 수 없다 — 커밋 제목은 "handles"가 아니라 "standard handles"라고 말하고, 코드는 정확히 그와 일치한다.

8c5fbb1a("Add deserialization.", 역직렬화 추가)는 역방향 프리미티브인 IO.deserialize를 추가하는데, 시작 시점에 컴파일된 .comb 프로그램을 읽는 바로 그 함수인 parse()를, 실행 도중 살아 있는 파일 핸들 위에서 직접 호출함으로써 그렇게 한다(8c5fbb1a:eval.c, 965–970행, 전문 인용):

    case IO_DESERIALIZE:
      CHECKIO(1);
      hdl = evalhandle(ARG(TOP(1)));
      gc();                     /* parser runs without GC */
      n = parse(hdl);
      RETIO(n);

"실행 중인 프로그램의 그래프를 직렬화한다"는 것이 구체적으로 의미하는 바는 이렇다: 프로그램을 .comb 파일로부터 부트스트랩하는 바로 그 리더가, 시작 파일이 아닌 임의의 핸들을 가리킨 채, 실행 중인 프로그램 내부에서 프리미티브로서 도달 가능하다는 것이다. IO.serialize로 쓰이고 IO.deserialize로 다시 읽힌 값은, 2장이 프로그램 전체 링킹을 위해 확립한 것과 같은 :N/_N 라벨 구문을 통해 왕복한다 — 그리고 아래 "직접 해보기"가 그대로 보여주듯, 이것이야말로 진짜로 순환하는 값이 그 여정에서 살아남는 방법이기도 하다: :N/_N은 반복되는 부분항을 위한 최적화에 그치지 않고, 이 텍스트 형식이 스스로에게 고리를 건 그래프를 표현할 수 있는 유일한 방법이다.

하지만 parse()의 :/_ case들은 모두 전역 변수 NODEPTR *shared를 통해 읽고 쓰며, 그 전역 변수는 컴파일된 프로그램을 읽는 함수에 의해 시작 시점에 딱 한 번 할당되고, 0으로 채워지고, 해제된다 (8c5fbb1a:eval.c, 447–456행, 전문 인용):

NODEPTR
parse_top(FILE *f)
{
  shared = malloc(heap_size * sizeof(NODEPTR));
  for(int i = 0; i < heap_size; i++)
    shared[i] = NIL;
  NODEPTR n = parse(f);
  free(shared);
  return n;
}

IO_DESERIALIZE는 parse_top()이 아니라 parse()를 직접 호출한다 — 그래서 shared를 결코 재할당하지 않으며, main이 evalio를 실행하기 시작하기도 전에 이미 해제된 메모리를 역참조하는 것이다. :/_ 라벨이 전혀 없는 값(공유되는 것도, 순환하는 것도 없는)을 역직렬화하는 것은 shared[]를 전혀 건드리지 않으므로 잘 동작한다; 라벨을 하나라도 실제로 쓰는 것(순환하는 값은 예외 없이 모두 포함)을 역직렬화하는 것은 해제 후 사용이다. 직접 검증하면(직접 해보기, 연습 문제 3): 매번, 일관되게, 세그폴트가 난다.

같은 날 세 커밋이 더 올라가지만 이 장이 아니라 5장의 이야기다: ef082bbe/8c66d4dd/c0fb9a91는 uhs(컴파일러)와 eval(런타임) 양쪽에 독립적인 -v 상세 출력 플래그를 추가하고, b0915dcf는 라이브러리 .uhs 모듈들을 lib/ 하위 디렉터리로 옮기고 -i<dir> 모듈 검색 경로를 추가한다 — 둘 다 아래 "직접 해보기"에서 기계적으로 드러난다(eval은 이제 축약-횟수 트레일러를 찍으려면 -v가 필요하다; uhs는 Prelude/List/IO/String을 찾으려면 -ilib가 필요하다), 다만 이 장의 주제는 아니다.

왜 중요한가

이 장의 모든 확장은 1–3장이 이미 지어 놓은 아키텍처 위에, 그 모양을 바꾸지 않은 채 내려앉는다. IO a는 새로운 종류의 런타임 값이 아니다 — 새로운 태그 여덟 개 중 하나를 가진 그래프 노드일 뿐이고, 보통의 축약기에게는 불투명하며, 같은 그래프 위에 얹힌 두 번째 작은 인터프리터(evalio)에게만 의미가 있다. Maybe/Either 값 역시 새로운 종류의 런타임 값이 아니다 — 2장이 이미 튜플과 리스트를 위해 일반화해 둔 같은 스콧 인코딩 트릭을, 합 타입 두 개 더에, eval.c는 한 줄도 바꾸지 않고 적용한 것이다(967b92c0의 diff는 Prelude.uhs와 String.uhs뿐, 그 외엔 없다). "컴비네이터를 좀 더, 그리고(IO에 한해서만) 그 컴비네이터 일부에 의미를 부여하는 작은 인터프리터 하나 더" — 이 두 문장이 1장의 ccb31388 이후 계속 돌아가고 있는 핵심 루프 eval()을 건드리지 않고도 두 기능 모두를 흡수한다.

IO를 배포한 지 열일곱 시간 만에 다시 만든 것은, 재작성-기반 설계가 의도대로 작동할 때 어떤 모습인지 보여준다: 첫 시도는 아직 실제 표현으로 뒷받침할 방법을 알아내지 못한 파일 IO를 프리미티브 두 개 (readFile, writeFile)로 선언했고, 동작하는 척하지 않고 ERR() 스텁 그대로 배포했으며, 바로 다음 세션 — 파일이 실제로 필요해지자마자 — 낡은 모양(암묵적인 stdin/stdout을 가진 평면적 프리미티브)을 억지로 동작시키는 대신, 열린 파일을 담을 자리가 있는 새 모양(HDL 노드, 모든 프리미티브가 명시적 핸들을 받음)으로 설계 자체를 교체했다. 그리고 d2956ee8의 최적화는 더 작은 규모에서 같은 교훈을 준다: fe22efbd는 좁은 범위의 정리 하나를 이미 갖춘 채로 정확하지만 순진한 재귀 let(모든 바인딩에 예외 없이 Y)을 배포했고, d2956ee8는 그 정리를 일반화한 바로 그 자리에서, 곧바로 List.uhs/IO.uhs의 fix (\ rec -> ...) 관용구를 평범한 재귀 let으로 다시 썼다 — 새로 값싸진 기능이, 그것을 값싸게 만든 바로 그 커밋에서, 수작업 대체물을 대신할 만큼 신뢰받았다는 증거다.

실험 노트 여백

  • 4c4229ec("Fix bugs and improve.")와 7bb30b64("Minor improvements.")는 3246e2a6과 같은 날 저녁, 각각 약 44분과 약 한 시간 뒤에 올라간다 — ELam이 다중 인자 람다로 일반화되고, 이름 붙은 함수로 fix = primitive "Y"가 추가되고, print/pp가 printrec 헬퍼를 공유하도록 리팩터링된다. 둘 다 IO_READFILE/IO_WRITEFILE 스텁은 건드리지 않는다; 그것들은 다음 날 아침의 c5564e64까지 기다린다.
  • 8b39ba55("Remove junk.", 잡동사니 제거)는 Lam.hs를 통째로 지운다 — 233줄 전부, ccb31388부터 이어져 온(그 사이 6485a341과 a9178695 두 커밋에서만 가볍게 손댄) 손으로 내장한 하스켈 프로토타입 전체다. aux/y-combinator-and-recursion.md와 1–2장이 훑었던 그 코드는, 3장의 95ff6a34/b25d9fc8가 파서로 구동되는 Main.hs에 진짜 Prelude를 붙여 대체품을 마련한 이후로는 이미 대체된 것이었다. 이것은 어떤 기능과도 묶이지 않은 독립된 커밋이다 — 정리는, 그럴 만큼 쌓인 뒤에는, 그 자체로 일급 작업이 된다.
  • do-블록 let(3246e2a6)과 식 let(5ccb1b2a, fe22efbd)은 똑같이 쓰이지만 이 장의 나머지 범위 내내 다르게 디슈가된다: fe22efbd의 무조건적인 Y-감싸기를 거치는 것은 식 형태뿐이다. M.do { ... } 안에 쓰인 let은 8c5fbb1a에 이르기까지 원래의 단순한, 재귀적이지 않은 람다 애플리케이션 그대로다. 이 범위의 어떤 것도 둘을 통일하지 않는다.
  • a3355632의 readFile에는 파일 끝을 나타내는 별도의 값이 없다 — hGetChar는 원시 getc()가 반환한 값을 그대로, EOF일 때의 -1도 포함해서, 평범한 CHAR 노드에 담아 돌려주고, hGetContents는 ord c == negate 1일 때 멈춘다. C의 관례가 그대로 새어 나온다.

직접 해보기

기준 커밋: 8c5fbb1a("Add deserialization."). 관례(참고: 00-overview.md):

git worktree add /tmp/mhs-8c5fbb1a 8c5fbb1a
cd /tmp/mhs-8c5fbb1a

빌드 노트: 이 커밋의 Makefile에는 마침내 Main: 타깃이 있지만 (ghc Main.hs -o Main, 3246e2a6가 추가), 여전히 맨몸의 ghc 호출이라 2장과 3장의 빌드 노트가 다룬 것과 같은 GHC-9.8/mtl 문제를 겪는다. 이전 장들과 바이너리 이름을 일관되게 유지하기 위해 -o uhs로 손수 빌드한다:

nix develop $MICROHS -c ghc Main.hs -o uhs

처음에는 ParserComb.hs의 ap-not-in-scope 오류로 실패한다(2/3장과 동일); ParserComb.hs의 기존 import Control.Monad.State.Strict 뒤에 다음을 추가한다:

import Control.Monad(ap)

그다음 Compile.hs의 when-not-in-scope 오류가 난다(3장과 동일); Compile.hs의 기존 import Control.Monad.State.Strict 뒤에 다음을 추가한다:

import Control.Monad(when)

두 한 줄짜리 패치를 모두 적용하면 빌드가 성공한다(head/tail에 대한 무해한 -Wx-partial 경고는 생략):

nix develop $MICROHS -c ghc Main.hs -o uhs
[1 of 7] Compiling ParserComb       ( ParserComb.hs, ParserComb.o )
[2 of 7] Compiling Parse            ( Parse.hs, Parse.o )
[3 of 7] Compiling Exp              ( Exp.hs, Exp.o )
[4 of 7] Compiling Desugar          ( Desugar.hs, Desugar.o )
[5 of 7] Compiling Compile          ( Compile.hs, Compile.o )
[6 of 7] Compiling Main             ( Main.hs, Main.o )
[7 of 7] Linking uhs

런타임은 2/3장과 같은 방식으로 빌드한다:

nix develop $MICROHS -c gcc -Wall -O3 eval.c -o eval

이 커밋 범위에서 새로 생겨나 아래의 모든 명령에 영향을 주는 것이 두 가지 더 있다. b0915dcf는 Prelude.uhs/List.uhs/IO.uhs/ String.uhs를 lib/ 하위 디렉터리로 옮기고 -i<dir> 모듈 검색 경로(Compile.hs의 path 필드)를 추가했다 — 아래의 모든 ./uhs 호출은 import Prelude/import IO 등이 해석되도록 -ilib를 넘긴다. 그리고 ef082bbe/8c66d4dd/c0fb9a91은 eval 자신의 진단 트레일러(node size=..., N reductions, M GCs)를 2/3장에는 없던 -v 플래그 뒤에 가두었다 — 기본값으로는 이제 ./eval이 프로그램 자신의 출력만 찍고 그 외엔 아무것도 찍지 않는다; 축약 횟수를 보려면 -v를 넘긴다(./eval -v out.comb처럼).

연습 문제 1: 파일을 읽어 변형하고 결과를 출력하기

printf 'hello world\nfrom a file\n' > greeting.txt
cat > Shout.uhs <<'EOF'
module Shout where
import Prelude
import List
import IO

upc :: Char -> Char
upc c =
  case (ord c >= ord 'a') && (ord c <= ord 'z') of
    True  -> chr (ord c - 32)
    False -> c

main = IO.do
  s <- readFile "greeting.txt"
  putStr (map upc s)
EOF
nix develop $MICROHS -c ./uhs -ilib Shout
nix develop $MICROHS -c ./eval out.comb
hello world�from a file�

원하던 결과가 아니며, 위는 오타가 아니라 진짜 막다른 골목이다. upc의 case는 팔을 True 먼저, False 나중 순서로 쓰지만, Prelude.uhs는 data Bool = False | True를 선언하므로 False가 인덱스 0인 생성자, True가 인덱스 1인 생성자가 된다. 위 "무슨 일이 있었나"(967b92c0)에 따르면 dsExpr (ECase e as) = apps (dsExpr e) (map dsArm as)는 스크루티니를 팔들의 컴파일된 본문에 쓰인 순서 그대로, 위치에 따라 적용한다 — 쓰인 순서를 선언 순서와 맞춰 보는 검사는 어디에도 없다. True를 먼저 쓴다는 것은 처음 건네지는 연속이 True를 위한 것이라는 뜻이지만, Bool 값은 선언-인덱스 0의 연속 (False의 것)을 먼저 돌려받는다 — 그래서 upc의 조건이 실제로 무엇으로 평가되든, 모든 문자가 먼저 쓰인 팔을 실행하게 된다. 모든 공백(ord ' ' = 32)은 chr (32 - 32) = NUL 바이트를 얻고, 모든 줄바꿈(ord '\n' = 10)은 chr (10 - 32) = 바이트 0xEA를 얻는다 — 출력해 보면 눈에 띄게 잘못됐고, od로 확인된다:

$ nix develop $MICROHS -c ./eval out.comb | od -An -tx1 | head -1
 68 65 6c 6c 6f 00 77 6f 72 6c 64 ea 66 72 6f 6d

Bool의 실제 선언 순서 — False 먼저, True 나중 — 에 맞게 팔을 바꾸고 다시 빌드한다:

cat > Shout.uhs <<'EOF'
module Shout where
import Prelude
import List
import IO

upc :: Char -> Char
upc c =
  case (ord c >= ord 'a') && (ord c <= ord 'z') of
    False -> c
    True  -> chr (ord c - 32)

main = IO.do
  s <- readFile "greeting.txt"
  putStr (map upc s)
EOF
nix develop $MICROHS -c ./uhs -ilib Shout
nix develop $MICROHS -c ./eval out.comb
HELLO WORLD
FROM A FILE

연습 문제 2: out.comb에서 공유 라벨을 읽어 내기

연습 문제 1의 out.comb(고쳐진 버전)는 2792바이트, 한 줄, 81개의 최상위 정의(Prelude, List, IO, Shout가 함께 링크됨)로 되어 있다. 그 첫머리(바이트 그대로, ...는 네 번째 정의 이후 전부를 생략):

(($T :0 (($B $K) (($B $K) $K))) (($T :1 ($K (($B $K) $K))) (($T :2 ($K ($K $K))) (($T :3 ($K ($K $T))) (($T :4 $IO.>>=) ...

:0부터 :3, 그리고 :4($IO.>>=)는 소스-모듈 링킹 순서로 매겨진 최상위 정의들이다 — 정확히 2장의 17587b2d 라벨 체계 그대로다. 같은 파일 더 뒤쪽에서 가져온 구체적인 역참조 하나:

... (($T :16 (_7 _13)) (($T :17 (_8 _12)) (($T :18 (_9 _13)) ...

:16(알고 보면 IO.putChar의 stdout에 묶인 래퍼, 즉 putChar)은 _7 _13으로 정의된다 — 정의 7(IO.putChar)에 대한 참조를 정의 13(IO.stdout)에 대한 참조에 적용한 것이며, 본문을 반복하는 대신 그 둘의 라벨을 재사용한다.

이것이 어떤 종류의 "공유"인지 정확히 짚어 둘 필요가 있다. 이 장은 또 다른 종류의 공유도 만들어 내기 때문이다. 이 :N 라벨들은 하나하나 Main.hs 자신이(def ((_, e), i) r = App2 CT (Lbl i (subst e)) r, 다른 어딘가에서 실제로 참조되든 안 되든 모든 정의에 대해 zip ds' [0..]를 순회하며) 최상위 정의마다 하나씩, 무조건적으로 붙인다 — 컴파일 시점에 소스 순서로부터 결정되는 정적이고 위치적인 체계다. 이를 런타임 자신의 IO.serialize가 만들어 내는 것과 대조해 보자. IO.serialize는 find_sharing의 이중 순회 그래프 walk(처음 방문 시 표시, 두 번째 방문 시 공유로 승격)를 써서, 값을 출력하는 동안 실제로 두 번 이상 방문된 노드에만 라벨을 붙인다 — 소스 순서와는 무관하게, 동적으로, 그래프의 실제 모양으로부터 결정된다. 반복되는 부분 구조가 없는 평범한 리스트는 라벨을 전혀 얻지 않는다:

cat > PlainSer.uhs <<'EOF'
module PlainSer where
import Prelude
import List
import IO

main = IO.do
  writeSerialized "plain.ser" [1, 2, 3]
  v <- readSerialized "plain.ser"
  print v
EOF
nix develop $MICROHS -c ./uhs -ilib PlainSer
nix develop $MICROHS -c ./eval out.comb
echo "exit: $?"
cat plain.ser
(($O 1) (($O 2) (($O 3) $K)))
exit: 0
(($O 1) (($O 2) (($O 3) $K)))

(실행은 깨끗하게 종료되고, 런타임이 출력한 결과와 파일 내용 둘 다 같은 텍스트다. $O/$K뿐, :/_는 어디에도 없다.) 같은 let-바인딩된 리스트를 두 번 재사용해서, 그것을 쓰는 순회가 실제로 한 노드를 두 번 이상 방문하게 만들면, 라벨이 생긴다:

cat > ShareDemo.uhs <<'EOF'
module ShareDemo where
import Prelude
import List
import IO

main = IO.do
  let
    xs = [1, 2, 3]
   in writeSerialized "share.ser" (xs, xs)
EOF
nix develop $MICROHS -c ./uhs -ilib ShareDemo
nix develop $MICROHS -c ./eval out.comb
cat share.ser
(($P :99474 (($O 1) (($O 2) (($O 3) $K)))) ($I _99474))

라벨 하나, :99474(힙-셀 인덱스라서 이렇게 크고 임의적인 숫자다 — Main.hs가 매기는 작고 순차적인 :0/:1/...와는 전혀 다르다)가, 쌍의 순회가 처음 도달했을 때 리스트 xs를 표시한다; _99474는 두 번째로 도달했을 때인데, (xs, xs)의 두 반쪽 모두 같은 힙 셀이기 때문이다(하나의 let-바인딩된 썽크가 두 번 쓰인 것이지, 비슷하게 생긴 복사본 두 개가 아니다).

연습 문제 3: 매듭 묶어 보기

take는 72d4d093(List.uhs)부터 존재하므로, 순환 리스트를 유한한 앞부분을 통해 관찰할 수 있다:

cat > Knot.uhs <<'EOF'
module Knot where
import Prelude
import List
import String
import IO

main = IO.do
  let
    xs = 1 : xs
   in putStrLn (showList showInt (take 5 xs))
EOF
nix develop $MICROHS -c ./uhs -ilib Knot
nix develop $MICROHS -c ./eval out.comb
[1,1,1,1,1]

fe22efbd의 무조건적인 Y-감싸기는 함수 정의뿐 아니라 어떤 let에도 적용된다 — xs = 1 : xs는 aux/y-combinator-and-recursion.md의 nfib가 Y로 하는 것과 같은 방식으로 진짜로 스스로에게 고리를 건 그래프 셀을 만들고, take 5는 그 안으로 다섯 걸음 걸어 들어간 뒤 멈추며, 그 리스트에 끝이 없다는 사실을 결코 알아채지 못한다. xs를(take를 거치지 않고, 무한 구조 전체를) 직접 직렬화하면 순환이 라벨 구문 자체에 드러난다:

cat > KnotSer.uhs <<'EOF'
module KnotSer where
import Prelude
import List
import IO

main = IO.do
  let
    xs = 1 : xs
   in writeSerialized "knot.ser" xs
EOF
nix develop $MICROHS -c ./uhs -ilib KnotSer
nix develop $MICROHS -c ./eval out.comb
cat knot.ser
:99731 (($O 1) _99731)

이것은 종료된다 — find_sharing의 표시-기반 순회는 순환 그래프를 eval.c의 가비지 컬렉터가 항상 해 온 것과 같은 방식으로 다룬다. AP 포인터를 영원히 따라가는 대신 이미 방문한 노드에서 멈춘다 — 그리고 결과는 그 그래프가 정확히 무엇인지 말해 준다: 정의 :99731은 콘스 셀 (O 1 _99731) 그 자체이고, 그 안의 _99731은 지금 정의되고 있는 바로 그 노드를 도로 가리킨다. 똑같이 생긴 콘스 셀 둘이 아니라, 자기 자신을 가리키는 셀 하나다.

다시 읽어 들이는 순간, 이 커밋 범위는 활주로가 바닥난다. 바로 이 파일을 역직렬화하면 세그폴트가 난다:

cat > KnotRead.uhs <<'EOF'
module KnotRead where
import Prelude
import List
import String
import IO

main = IO.do
  ys <- readSerialized "knot.ser"
  putStrLn (showList showInt (take 5 ys))
EOF
nix develop $MICROHS -c ./uhs -ilib KnotRead
nix develop $MICROHS -c ./eval out.comb
echo "exit: $?"
exit: 139

(139 = 128 + SIGSEGV.) 위 "무슨 일이 있었나"에 따르면 이는 순환에 국한된 문제가 아니다 — :/_ 라벨 구문을 조금이라도 쓰면 마찬가지다. IO_DESERIALIZE는 parse()를 직접 호출하고, parse()의 :/_ case들은 parse_top()이 시작 시점에 딱 한 번 할당했다가 evalio가 실행되기도 전에 이미 free()한 전역 포인터 shared를 읽고 쓴다. 연습 문제 2의 순환 없는 share.ser를 같은 방식으로 시도해 봐도 확인된다 — 공유된 노드 하나, 순환 없음, 그런데도 같은 크래시:

cat > ShareRead.uhs <<'EOF'
module ShareRead where
import Prelude
import List
import IO

main = IO.do
  v <- readSerialized "share.ser"
  print v
EOF
nix develop $MICROHS -c ./uhs -ilib ShareRead
nix develop $MICROHS -c ./eval out.comb
echo "exit: $?"
exit: 139

이는 writeSerialized가 만든 앞서의, 공유되지 않은 [1,2,3] 값(그 파일에는 :/_가 전혀 없다 — 연습 문제 2 참고)이 아무 탈 없이 역직렬화되는 것과 대비된다. 8c5fbb1a 시점에서 IO.serialize는 진짜 순환을 파일에 쓸 수 있고 IO.deserialize는 프리미티브로서 존재하지만 — 라벨 메커니즘을 실제로 필요로 하는 것을 읽어 들이면 매번, 재현 가능하게, 런타임이 죽는다. 이것이 이 커밋 시점에서 이 기능의 실제 현재 상태이지, 가상의 이야기가 아니다.

정리한다:

git worktree remove --force /tmp/mhs-8c5fbb1a

🌐 Read in English

05 — Lab notebook: the working habits (실험 노트: 작업 습관)

현재 위치

4장은 모나딕 IO, 동작하는 IO.serialize/IO.deserialize 쌍, 그리고 역직렬화가 공유되거나 순환하는 라벨에 닿는 순간 재현되는 문서화된 크래시를 남긴 채 8c5fbb1a("Add deserialization.", 역직렬화 추가)에서 끝난다. 하지만 8c5fbb1a는 이 세션의 첫 커밋이 아니다 — 그것이 올라오기까지 (2023-06-02 16:17:09) 같은 날 오후에 이미 다른 커밋 네 개가 먼저 들어가 있었다: ef082bbe(14:23:17), 8c66d4dd(14:28:31), c0fb9a91(14:30:13), 그리고 b0915dcf(16:16:53, 8c5fbb1a 자신보다 16초 앞선다). 4장은 이 넷을 모두 언급하며 명시적으로 미뤄 두었다: "같은 날 세 커밋이 더 올라가지만 이 장이 아니라 5장의 이야기다: ef082bbe/8c66d4dd/c0fb9a91는 uhs(컴파일러)와 eval(런타임) 양쪽에 독립적인 -v 상세 출력 플래그를 추가하고, b0915dcf는 라이브러리 .uhs 모듈들을 lib/ 하위 디렉터리로 옮기고 -i<dir> 모듈 검색 경로를 추가한다 ..." 넷 중 어느 것도 IO나 직렬화, 4장이 추적하던 다른 어떤 기능도 건드리지 않는다 — 이들은 도구화이며, 이 장은 도구화가 제 몫을 받는 곳이다.

그래서 이 장의 소재는 두 가지에 걸쳐 있다: 커밋 그래프상 8c5fbb1a 보다 앞서는 그 네 커밋(git log --oneline --reverse ccb31388..8018eec8에서 40–43번째 위치이고, 8c5fbb1a가 44번째다), 그리고 00-overview.md의 타임라인 표가 실제로 이 장에 배정한 열다섯 커밋의 범위인 8c5fbb1a..8018eec8(6c59dd36부터 8018eec8까지, 모두 같은 날 저녁 16:23에서 19:10 사이에 올라간다 — 세 시간이 채 안 된다). 그날 저녁이 끝날 즈음, 프로젝트는 두 바이너리의 실제 이름으로 bin/uhs와 bin/eval을 가지게 되고(더 이상 Main이 아니다), -i 검색 경로로 찾는 모듈 네 개를 담은 lib/ 디렉터리가 생기고, (.uhs에서 이름이 바뀐) .hs 확장자가 전체에 쓰이며, 세 모듈짜리 자동화된 테스트 하네스가 make test에 연결되고, 첫 README.md가 생기며, 컴파일 시점이 아니라 실행 시점에 힙 크기를 정하는 -H<N> 플래그가 생긴다. 이 중 어느 것도 새 언어 기능이 아니다. 이 모두는 앞선 네 장의 기능들을 저자 본인이 아닌 사람도 쓸 수 있고, 검증할 수 있고, 설명할 수 있게 만드는 것들이다 — 그리고 "동작하게 만들기"에서 "프로젝트로 만들기"로의 이 전환이 바로 이 장이 추적하는 것이다.

무슨 일이 있었나

습관 1: 관찰 가능성 먼저 — ef082bbe, 8c66d4dd, c0fb9a91

2023-06-02 오후에, 7분 간격으로 올라간 세 커밋이 각각 "지금 무슨 일이 일어나고 있는지 보여 달라"는 층을 하나씩 더한다:

ef082bbe("Use a flag to be verbose.", 상세 출력에 플래그 사용)는 eval의 기존 진단 트레일러 — main returns ... 덤프, node size=... 줄, 축약/GC/셀 카운터 — 를 기본값 off인 새 -v 플래그 뒤에 가둔다. 이 커밋 이전에는 그 트레일러가 모든 실행마다 무조건 찍혔다 — 프로그램 자신의 출력 (실제 putChar/print 효과)에는 매번, 저자 본인이 디버깅 중일 때가 아니면 아무도 보고 싶어 하지 않을 내부 계측이 늘 뒤따랐다. 전체 변경은 main 하나이며, 전문 인용이다(ef082bbe:eval.c, 989–1018행):

int
main(int argc, char **argv)
{
  int verbose = 0;
  if (argc > 1 && strcmp(argv[1], "-v") == 0) {
    argc--;
    argv++;
    verbose = 1;
  }
  if (argc != 2)
    ERR("no file");
  init_nodes();
  FILE *f = fopen(argv[1], "r");
  if (!f)
    ERR("file not found");
  NODEPTR n = parse_top(f);
  fclose(f);
  PUSH(n); gc(); n = TOP(0); POP(1);
  int start_size = num_marked;
  //pp(stdout, n);
  n = evalio(n);
  if (verbose) {
    printf("\nmain returns ");
    pp(stdout, n);
    printf("node size=%ld, heap size=%ld\n", NODE_SIZE, heap_size);
    printf("%d reductions, %d GCs, max cells used %d\n", num_reductions, num_gc, max_num_marked);
    printf("%d cells at start\n", start_size);
  }
  exit(0);
}

5분 뒤, 8c66d4dd("Add a -v flag.", -v 플래그 추가)는 컴파일러 쪽에서 같은 일을 하는데, 더 큰 변경이다. compile에는 걸어 둘 상세-출력 파라미터가 애초에 없어서 늘 출력해 왔기 때문이다. Compile.hs는 세 함수를 관통하는 Bool 인자를 얻는다(8c66d4dd:Compile.hs, 18–19행과 24행, 인용):

compile :: Bool -> IdentModule -> IO [LDef]
compile verbose nm = do
compileModuleCached :: Bool -> IdentModule -> StateT Cache IO CModule

그리고 세 개의 putStrLn "importing ..." 트레이스 줄 하나하나가 when verbose $로 감싸진다. Main.hs는 명령줄에서 이 플래그를 집어서 그대로 전달한다(8c66d4dd:Main.hs, 8–15행, 전문 인용):

main :: IO ()
main = do
  args <- getArgs
  let verbose = "-v" `elem` args
      mn = case filter ((/= "-") . take 1) args of
             [s] -> s
             _ -> error "Usage: compile [-v] ModuleName"
  ds <- compile verbose mn

그리고 같은 플래그가 이제, 8c66d4dd 이전에는 무조건적이던 동작 — out.comb를 쓰기 전에 컴파일된 모든 정의의 컴비네이터 형태와 링크된 전체 프로그램을 표준 출력에 덤프하는 것 — 도 함께 가둔다(이제 when verbose $ do ...). 그로부터 2분 뒤, c0fb9a91("More verbosity.", 더 많은 상세 출력)는 eval의 일회성 -v를 카운터로 바꾸고 레벨을 하나 더한다: if (argc > 1 && ...)가 while 루프가 되어 -v -v가 유효해지고 verbose를 두 번 증가시키며, verbose > 1은 어떤 축약도 일어나기 전에 로드된 프로그램 그래프 전체를 찍는 pp(stdout, n) 호출을 건다 — 이전 커밋이 주석 처리된 //pp(stdout, n);으로 한 줄 위에 남겨 두었던 것이 이제 주석이 풀린 채 실행된다(c0fb9a91:eval.c, 989–1008행, 인용; 마지막 두 줄의 if (verbose > 1) pp(stdout, n);이 verbose를 int로 넓힌 것 외에 추가된 전부다):

int
main(int argc, char **argv)
{
  int verbose = 0;
  while (argc > 1 && strcmp(argv[1], "-v") == 0) {
    argc--;
    argv++;
    verbose++;
  }
  if (argc != 2)
    ERR("no file");
  init_nodes();
  FILE *f = fopen(argv[1], "r");
  if (!f)
    ERR("file not found");
  NODEPTR n = parse_top(f);
  fclose(f);
  PUSH(n); gc(); n = TOP(0); POP(1);
  int start_size = num_marked;
  if (verbose > 1)

세 커밋, 세 도구(eval 두 번, uhs 한 번), 매번 같은 패턴: 예전에 항상 찍거나 전혀 찍지 않던 것이, 이제는 호출자가 정하는 수준에서 요청 시에만 찍힌다. "직접 해보기" 연습 문제 1이 같은 프로그램에서 세 수준을 모두 실행해 각각이 정확히 무엇을 더하는지 보여 준다.

습관 2: 진행하면서 테스트하기 — 6ac9c791, 뿌리는 두 커밋 전

6ac9c791("Add some testing.", 테스트 몇 가지 추가)은 이 프로젝트에서 자동화된 테스트가 무에서 시작하는 지점이 아니다 — 같은 날 저녁 두 커밋 전인 9b6e23c8가 이미 tests/Fac.uhs(곧 73a427d7에 의해 tests/Fac.hs로 이름이 바뀐다)를 추가했다. fac 6과 fst res + snd res(여기서 res = (fac 10, nfib 30))를 계산하는 작은 모듈이다. 6ac9c791이 실제로 하는 일은: 각 테스트 모듈을 컴파일하고, 실행하고, 그 출력을 체크인된 골든 파일과 diff하는 Makefile 루프이며, 그 이상 정교한 것은 없다. 이 커밋 시점의 하네스 전체, tests/Makefile을 전문 인용한다:

UHS=../bin/uhs -i../lib
EVAL=../bin/eval

tests:
	$(UHS) IOTest && (echo q | $(EVAL) out.comb > IOTest.out) && diff IOTest.ref IOTest.out
	$(UHS) StringTest && $(EVAL) out.comb > StringTest.out && diff StringTest.ref StringTest.out
	$(UHS) Fac && $(EVAL) out.comb > Fac.out && diff Fac.ref Fac.out

세 줄, 세 모듈, 세 개의 참조 파일 — IOTest.ref, StringTest.ref, Fac.ref 모두 이 커밋에서 새로 생겼고, 이들을 실행하는 Makefile 줄과 함께 체크인되었다. 각 줄은 모듈 하나를 out.comb로 컴파일하고, 실행하고, 표준 출력을 .out 파일로 리다이렉트한 뒤, git에 체크인된 .ref 파일과 그것을 diff한다; make 자신의 && 체이닝은 컴파일이나 diff 중 어느 하나라도 실패하는 순간 그 레시피 줄 — 그리고 make test 전체 — 를 큰 소리로 실패시킨다. 테스트 프레임워크는 없다: 어서션 라이브러리도 없고, make 자체를 넘어서는 테스트 러너도 없고, diff의 종료 코드와 그것이 찍는 것 이상의 구조화된 성공/실패 리포트도 없다. IOTest 줄에는 눈여겨볼 조각이 하나 더 있다, (echo q | $(EVAL) out.comb > IOTest.out) — IOTest.hs는 getChar를 한 번 호출하므로, 비대화형으로 실행하려면 표준 입력에 무언가가 필요하고, echo q가 그것을 공급한다(6ac9c791은 또한 IOTest.hs의 스크래치 파일 test.txt/test2.txt를 test.tmp/test2.tmp로 이름 바꾸는데, 이는 다음 커밋 110bcc94가 추가하는 빌드 산출물용 .gitignore 패턴과 맞물린다). 여기 .ref/.out/diff 모양 — 골든 출력, 바이트 비교, 어긋남에 대한 관용 없음 — 이 정확히 "직접 해보기" 연습 문제 2가 같은 스타일로 테스트 하나를 더 확장하는 그 하네스다.

습관 3: 프로젝트가 실체를 갖췄을 때 README를 쓰기 — 37e44acd와 같은 날의 "More text"

37e44acd("Add README", README 추가)는 이 프로젝트가 가진 첫 README.md다 — ccb31388 이후 쉰 개의 커밋, 그리고 여드레가 채 안 되어. 세 줄이며, 전문 인용한다(37e44acd:README.md):

# Micro Haskell
This directory contains an implementation of a very small subset of Haskell.
It uses combinators for the runtime execution.

그것이 그 커밋의 전부다 — 새 파일 하나, 세 줄 추가, 그 외엔 아무것도 건드리지 않았다. 눈여겨볼 것은 내용보다는 타이밍이다: 이는 16:44:57에, 테스트 하네스(6ac9c791, 110bcc94)가 자리를 잡고 통과한 직후, 그리고 언어와 컴파일러와 런타임이 이미 실질적인 일을 하고 나서 한참 뒤에 올라간다. README는 프로젝트를 소개하는 게 아니라, 이 시점까지 이미 문서화를 받을 자격을 얻은 프로젝트를 문서화한다.

그런 다음 같은 날 저녁 네 커밋에 걸쳐 자라나는데, 각각 "More text" 또는 그와 가까운 변형으로 이름 붙었고, 저자가 그것을 채워 넣는 순서 자체가 시사하는 바가 있다:

  1. aae0eee2(17:01:29, "More text.")는 ## Language(언어가 가진 것을 나열한 불릿 목록: 변수, 애플리케이션, 패턴 매칭 없는 람다, 각자의 제약이 나열된 case/let, 튜플, 리스트 구문, 한정된 do, data 선언, "무시되는 타입 시그니처")와, 여섯 컴파일러 모듈 각각을 한 줄로 설명하는 ## Compiler를 추가한다. 세 줄짜리 서론 다음에 오는 첫 실질적인 내용은 언어가 아직 할 수 없는 것의 목록이며, 할 수 있는 것만큼이나 있는 그대로 진술된다.
  2. 04f0840b(17:09:37, "More text.")는 새 파일 Example.hs (fac 함수와 네 개의 팩토리얼을 찍는 main)를 추가하고, README에 ## Example 절을 더해 그 파일의 소스를 펜스드 코드 블록 안에 그대로 재현한 뒤 빌드 안내를 덧붙인다("바이너리가 빌드되어 있는지 확인하고... bin/uhs Example로 파일을 컴파일하고... bin/eval out.comb로 컴비네이터 파일을 실행한다").
  3. 91a8ff34(17:11:02, "More text")는 그 산문을 다듬고("The compile" → "Then compile", "Run" → "Finally, run") 앞 커밋이 빠뜨린 것 하나를 추가한다: 실제 예상 출력인 Some factorials / [1,2,6,3628800].
  4. 0bfdcd18(17:14:21, "Fix some mistakes.")는 빌드 명령 자체를 고친다 — bin/uhs Example이 bin/uhs -ilib Example이 되는데, -ilib 없이는 컴파일러가 새로 옮겨 간 lib/ 안의 Prelude/List/IO/String을 찾지 못하기 때문이다(b0915dcf의 경로 플래그, 이 장의 첫 습관). README 자신의 실습 예제는 처음 쓰인 그대로는 실제로 동작하지 않았다 — 그 파일을 건드리는 다음 커밋에서, 5분도 안 되어 고쳐진다.

다른 기능 작업(아래의 힙 크기 플래그)이 끝난 뒤 그날 저녁 늦게 "More text" 커밋이 두 개 더 올라간다: 60343934(18:35:10)는 직렬화/역직렬화가 공유와 순환을 보존한다는 ### Features 메모를 추가하며, 하나뿐인 예외로 마무리한다("The only exception to this is file handles, which cannot be serialized"); 3cdbb820(18:51:39)는 ## Libraries 문단을 추가한다. 그러니 저자가 이 절들을 쓴 순서 — 완성된 README에서 그것들이 놓인 순서가 아니라 커밋 순서 — 는 이렇다: 언어가 무엇인가 → 컴파일러가 어떻게 조직되어 있는가 → 실행 가능한 실습 예제(실제로 동작하지 않는다는 게 드러난 뒤 한 번 고쳐짐) → 런타임이 무엇을 직렬화할 수 있는가(단서 포함) → 라이브러리가 무엇을 다루는가. 언어와 작업 흐름이 먼저이고, 가장 최근의, 가장 다루기 까다로운 기능(순환 직렬화)이 마지막이며, 과장 없이 정직하게 단서가 달려 있다.

습관 4: 이름은 늦게 바꾸기 — 73a427d7

73a427d7("Rename files to .hs", 파일 이름을 .hs로 변경) 이전에는 이 프로젝트의 모든 소스 파일 — Prelude, List, IO, String, 테스트 모듈들 — 이 .hs가 아니라 .uhs 확장자를 갖는다. 73a427d7의 diffstat이 이야기의 전부다(73a427d7 --stat, 인용):

 Compile.hs                              | 2 +-
 Makefile                                | 5 ++++-
 lib/{IO.uhs => IO.hs}                   | 0
 lib/{List.uhs => List.hs}               | 0
 lib/{Prelude.uhs => Prelude.hs}         | 0
 lib/{String.uhs => String.hs}           | 0
 tests/{Fac.uhs => Fac.hs}               | 0
 tests/{IOTest.uhs => IOTest.hs}         | 0
 tests/{ListTest.uhs => ListTest.hs}     | 0
 tests/Makefile                          | 5 +++++
 tests/{StringTest.uhs => StringTest.hs} | 0
 11 files changed, 10 insertions(+), 2 deletions(-)

파일 여덟 개가 0줄 변경으로 이름 바뀐다 — 순수한 git mv이며, 내용은 건드리지 않는다. diffstat의 나머지 세 줄, Compile.hs, Makefile, tests/Makefile이 실제 코드를 담고 있다: Compile.hs 안에서, 모듈 로더가 모듈 이름에 붙이는 하드코딩된 확장자가 .uhs에서 .hs로 바뀌는 한 줄(compileModule의 let fn = nm ++ ".uhs"가 let fn = nm ++ ".hs"가 된다), 그리고 최상위 Makefile에 추가된 test: 타깃; tests/Makefile 자체도 여기서 처음 생긴다 — 규칙 하나짜리 다섯 줄 파일이며, $(UHS) IOTest.hs && $(EVAL) out.comb > IOTest.out && diff IOTest.ref IOTest.out — 테스트 하네스의 바로 그 첫 형태이며, 바로 다음 커밋인 6ac9c791이 이를 세 모듈짜리 루프로 다듬는다.

uhs — "micro Haskell"에 가까운 이름이며, 이 장의 나머지 범위 내내 컴파일러 바이너리 자신이 유지하는 이름이다(bin/uhs, 한 커밋 전 9b6e23c8가 정함) — 는, 이 커밋 이전에는, 모든 소스 파일이 지니는 확장자이기도 했다. 자체 개발 방언의 표식이었던 셈이다. 이 이름 바꾸기는 do-표기법, IO, 직렬화, Maybe/Either가 모두 자리를 잡은 뒤에 일어난다 — "이것이 하스켈에 얼마나 가까운가"라는 네 장 동안의 질문에 대체로 "가깝다"는 답이 이미 나온 뒤에 — 그리고, 이 장 자신의 세션 안에서는, 그 결과를 설명하게 될 README(37e44acd, 11분 뒤)보다 앞서서다. 파일 확장자는 언어 자체에 관해 바뀌는 마지막 것이지, 첫 번째가 아니다: 미리 이름을 주장하는 대신, 언어 자신이 그 이름에 대해 갖게 된 자격을 뒤따르는 것이다.

습관 5: 실제 제약을 위한 손잡이 — 0b97ab3e

0b97ab3e("Add a flag to set the heap size.", 힙 크기 설정 플래그 추가)는 eval의 힙 용량을 컴파일 시점에 고정된 값 — #define HEAP_CELLS 100000, 이 커밋이 바꾸지 않으며 여전히 기본값이다 — 에서, 새 -H<N> 플래그로 실행마다 재정의할 수 있는 것으로 바꾼다(0b97ab3e:eval.c, 1005–1021행, 인용):

int
main(int argc, char **argv)
{
  int verbose = 0;
  char *fn = 0;
  
  argc--, argv++;
  while (argc > 0 && argv[0][0] == '-') {
    if (strcmp(argv[0], "-v") == 0)
      verbose++;
    else if (strncmp(argv[0], "-H", 2) == 0)
      heap_size = atoi(&argv[0][2]);
    else
      ERR("Bad flag");
    argc--;
    argv++;
  }

같은 커밋은 또한 인자 파싱을 "정확히 하나의 위치 인자, 파일 이름"에서 "0개 또는 1개, 기본값은 out.comb"로 일반화한다 — 이 플래그에 편승한 두 번째의, 더 작은 편의 변경이다.

실제로 이를 촉발한 것이 무엇인지는 메커니즘만큼 확실하지 않고, 깔끔한 이야기를 지어내기보다 그 점을 솔직히 밝힐 필요가 있다: 커밋 메시지는 별 내용이 없고, diff나 주변 커밋 어디에도 이것이 고치는 구체적인 실패가 적혀 있지 않다. 직접 확인해 보면: 이 장의 "직접 해보기"와 기존 테스트 스위트의 모든 실행은 100000-셀 기본값을 여유 있게 밑돈다 — 아래 "직접 해보기"의 프로그램(연습 문제 1)은 140셀에서 정점을 찍고; 하네스 자체의 Fac 테스트는 이 커밋 시점에 -v로 따로 실행해 보면 시작 시 164셀, fac/nfib 35 전체 계산 도중 정점 202셀을 보고한다 — 그러니 체크인된 테스트 스위트에서 눈에 띄게 한계에 부딪히는 것은 없었다. 이 커밋 시점의 임시 빌드에서 직접 더 밀어붙여 보면 진짜 한계가 드러나긴 하지만, 이것과는 다른 한계다: 평범한 자기 재귀(n : upto (n - 1))로 리스트를 즉시 구축해 합산하는 작은 하스켈 쪽 프로그램은 원소 1,000개에서는 잘 동작하고 (500500, 정답), 5,000개에서는 재현 가능하게 세그폴트가 난다(두 경계값의 정확한 지점은 더 좁혀 보지 않았다) — 힙-셀 부족이 아니라 eval 자신의 그래프 walk 안의 비-꼬리 재귀로 인한 C 호출-스택 오버플로이며, -H는 이것에 아무 도움도 되지 않는다. 타임라인이 뒷받침하는 유일한 연결 고리는 이렇다: -H가 18:34:52에 올라가고, 17분 뒤 3cdbb820이 지금까지 실행된 그 무엇보다 무거운 벤치마크인 tests/Nfib.hs를 추가한다("Typical nfib/s is 10M", main = print (nfib 38), 그리고 이를 위해 만들어진 새 make time 타깃과 함께). -H가 그 더 무거운 워크로드 때문에 쓰였는지, 아니면 그것을 계획하기 직전에 그냥 쓰인 것인지는 커밋들 자체가 결론지어 주지 않는다 — 하지만 컴파일 시점에만 고정된 힙 상수는, 실행되는 프로그램들이 실제로 커지기 시작하는 순간 편리함을 잃기 시작하는 바로 그런 종류의 것이며, 이 세션은 눈에 보이게 그 프로그램들을 키우는 중이다.

습관 6: 정리를 일급 작업으로 — 6c59dd36, 9b6e23c8, 28d53dab

이날 저녁 세 개의 별개 커밋이 각자, 어떤 기능과도 묶이지 않은 채, 오직 정돈만 한다:

6c59dd36("Update", 갱신)는 이 장의 git 범위에서 실제로 첫 커밋이며, 최상위 Makefile을 처음부터 끝까지 다시 쓴다. 그것이 대체하는 Makefile은 여전히 이렇게 되어 있다(6c59dd36^:Makefile, 전문 인용):

test:	eval nfib.comb
	./eval nfib.comb

eval:	eval.c
	gcc -Wall -O3 eval.c -o eval

nfib.comb:	Lam
	./Lam > nfib.comb

Main:	*.hs
	ghc Main.hs -o Main

clean:
	rm -f *.hi *.o eval Lam *.comb

nfib.comb: Lam은 ./Lam을 실행해 nfib.comb를 빌드하는데, Lam은 손으로 내장한 하스켈 프로토타입이다. 4장의 실험 노트 여백은 이것이 8b39ba55("Remove junk.", 잡동사니 제거)로 완전히 삭제되었다고 기록한다 — 하지만 그 규칙은 그 삭제보다 더 전에, 이미 죽어 있었다: 4장을 여는 커밋인 3246e2a6("Implement monadic IO.", 모나딕 IO 구현)가 Makefile의 Lam: Lam.hs / ghc Lam.hs -o Lam 규칙을 (위에 보이는, 아직도 그대로인) Main: *.hs / ghc Main.hs -o Main으로 이미 대체했으면서도, 그것이 방금 대체한 규칙에 의존하던 nfib.comb: Lam 줄은 건드리지도 제거하지도 않았다. 그 순간부터 — 8b39ba55가 Lam.hs 자체를 지우기 약 21시간 전, 6c59dd36 바로 24시간이 채 안 되기 전 — Lam 바이너리를 만들 수 있는 레시피는 Makefile에 이미 하나도 남아 있지 않았다; 8b39ba55는 그저 (이미 도달할 수 없던) 그 규칙이 컴파일했을 소스를 제거했을 뿐이다. 6c59dd36은 죽은 nfib.comb/Lam 줄을 알아채고 제거하는 첫 커밋이며, 그 과정에서 bin/ 출력 디렉터리를 도입하고 Main/eval 타깃을 $(BIN)/Main/$(BIN)/eval로 그 아래에 옮긴다 — Main→uhs 이름 바꾸기 자체는 다음 커밋의 몫이다.

9b6e23c8("Better names.", 더 나은 이름들)는 2분 뒤 뒤따르며, 실제로 그 이름을 고르는 커밋이다: Makefile의 $(BIN)/Main 타깃이 $(BIN)/uhs가 되고; Main.hs의 사용법 오류 문자열은, 일반적이고 낡은 ("Usage: compile [-v] ModuleName" — 이 프로젝트의 바이너리가 실제로 갖는 어떤 이름도 아닌 "compile") 것에서, "Usage: uhs [-v] [-iPATH] ModuleName"이 된다 — 방금 정해진 바이너리 이름을 붙이고, b0915dcf가 이미 추가했지만 이 메시지를 갱신하지 않았던 -iPATH 플래그도 반영한다 — 그리고 .gitignore는 이제 낡은 Lam/eval/Main 항목을 bin/*로 대신한다.

28d53dab("Clean up", 정리)는 그날 저녁의 끝에서 두 번째 커밋으로, 19:10:00에, ParserComb.hs에서 함수 하나 restOfInput을 제거한다 — 그 익스포트를 포함해 네 줄이며, 이 세션의 diff 어디에도 그것이 호출된 적이 있다는 증거는 없다. 자신만의 커밋, 로그의 자신만의 줄로, 마지막 기능 커밋(3cdbb820) 약 18분 뒤이자 마지막 커밋 (8018eec8, "Add null") 7초 전이다 — 죽은 코드는, 그것을 제거할 여유가 생기는 순간 제거되지, 마침 근처에 있던 아무 기능 커밋에 조용히 접혀 들어가지 않는다.

왜 중요한가

위의 여섯 습관 중 어느 것도 Micro Haskell에만, 혹은 컴파일러 일반에만, 심지어 이 특정 언어 선택에만 국한되지 않는다. 기본값이 off이고 반복하면 쌓이는(-v, -v -v) 상세-출력 플래그는, 상세히 알릴 것이 아직 아무것도 없는 첫날에도, 공짜로, CLI 도구를 만드는 누구나 내릴 수 있는 결정이다. "컴파일하고, 실행하고, 체크인된 파일과 diff하고, make 자신의 종료-코드 체이닝이 성공/실패를 판정하게 둔다"는 것 이상의 무엇도 아닌 테스트 하네스는 프레임워크도, 의존성도, 6ac9c791의 세 Makefile 줄보다 많은 코드도 필요로 하지 않는다 — "자동화된 테스트가 있다"는 기준선은 대부분의 프로젝트가 그것을 통과하기 전에 스스로 세우는 기준선보다 훨씬 낮다. 그것이 설명하는 것이 이미 동작한 뒤에 쓰인 README는, 미리 세운 야심 찬 계획으로서가 아니라, 문서가 정직한 상태를 유지하는 데 있어 작지만 큰 효과를 내는 규율이다 — 그리고 이 세션은 그렇게 하고도 자동으로 정직해지지는 않는다는 것을 보여 준다: 04f0840b의 실습 예제는 0bfdcd18이 5분도 안 되어 고치기 전까지는 실제로 동작하지 않았고, (짐작건대) 쓰인 그대로 명령을 실제로 시도해 본 누군가에 의해 잡혔다. 그 아래 코드가 확장자가 주장하는 것으로 자라난 뒤에야 바뀌는 파일 확장자는, 대부분의 프로젝트가 처음부터 야심 차게 이름을 붙이는 대신 건너뛰는 작은 지적 정직함의 행위다. 예전엔 값을 바꾸려면 재빌드가 필요했던 상수를 위한 손잡이는 몇 줄의 비용이 들 뿐이지만, 컴파일러 없이 더 큰 숫자가 필요해지는 그 첫 순간에 값어치를 한다. 그리고 정리 — 낡은 Makefile 규칙, 남은 .gitignore 항목, 쓰이지 않는 함수 하나 — 를 편할 때 아무데나 끼워 넣는 게 아니라 그 자체의 커밋으로 다루는 것은, 빠르게 움직이는 프로젝트의 자잘한 부채가 조용히 불어나지 않게 지켜 주는 것이다. 이 중 어느 것도, 특정 컴파일러가 어떻게 만들어졌는가를 다루는 이 책에, 바로 이 장이 존재하는 이유이면서 동시에 어떤 프로젝트에서든, 어떤 언어에서든 첫날부터 그대로 받아들일 수 있는 것들이다.

실험 노트 여백

이 장의 여백은 어느 한 커밋에 대한 메모가 아니라 프로젝트 전체 역사에 관한 사실이다: 어떤 커밋 메시지가 반복되는가, 그리고 어떻게. 정확한 명령과 그 실제 출력:

$ git log --format=%s ccb31388..8018eec8 | sort | uniq -c | sort -rn | head
      3 More text.
      2 New optimiser.
      1 Various small improvements.
      1 Various little fixes,
      1 Use 'primitive'
      1 Use file handle for printing.
      1 Use a flag to be verbose.
      1 Update
      1 Small improvements
      1 Rename files to .hs

"More text."의 세 번의 등장 모두 이 장의 README 연속물이다 (aae0eee2, 04f0840b, 60343934 — 네 번째 README 커밋인 91a8ff34는 마침표 없이 쓰여서 이 정확 문자열 카운트로는 같은 칸에 묶이지 않는다. sort | uniq -c가 의도가 아니라 문자열을 센다는 것을 일깨워 준다). "New optimiser."(b4e44ded, 9cf1e937)는 이 장의 범위 밖에 있는 더 이른 작업으로, 이 명령이 지금까지의 책 전체 (ccb31388..8018eec8, 1–5장)에 걸쳐 실행되기 때문에 드러났을 뿐이다. "Use 'primitive'"는 3장의 마무리인 95ff6a34다; "Various little fixes,"는 — 끝의 쉼표까지 남긴 채로 — 4장의 1a2f7b7a다. 이 목록 어디에도, 그리고 이 표가 추린 대상인 로그 전체 어디에도, feat:/fix:/chore: 접두사는 없다: 여기 있는 모든 메시지는 평범한, 현재 시제 혹은 과거 시제의 영어 문장이며, 마침표가 없을 때도 있고, (위에서 본 것처럼) 거의 똑같은 메시지들 사이에서도 저자 스스로 일관되지 않게 구두점을 찍기도 한다. 이 장이 문서화하는 습관들은 로그에 분류 체계로 나타나지 않는다 — 커밋이 실제로 무엇을 건드리는지에 나타난다.

직접 해보기

기준 커밋: 37e44acd("Add README"), 이 세션에 첫 README를 준 커밋이며, 이 시점에는 이미 .hs 이름 바꾸기(73a427d7), 테스트 하네스의 Fac/IOTest/StringTest 삼종 세트(6ac9c791), lib/와 -i 모듈 경로(b0915dcf)가 모두 자리 잡은 뒤다. 관례(참고: 00-overview.md):

git worktree add /tmp/mhs-37e44acd 37e44acd
cd /tmp/mhs-37e44acd

빌드 노트: 2–4장이 겪은 것과 같은 GHC-9.8/mtl 문제다. 이 커밋 자신의 Makefile 배치에 맞춰, bin/에 손수 빌드한다:

mkdir -p bin
nix develop $MICROHS -c ghc Main.hs -o bin/uhs

처음에는 ParserComb.hs의 ap-not-in-scope 오류로 실패한다; ParserComb.hs의 기존 import Control.Monad.State.Strict 뒤에 다음을 추가한다:

import Control.Monad(ap)

그다음 Compile.hs의 when-not-in-scope 오류가 난다; Compile.hs의 기존 import Control.Monad.State.Strict 뒤에 다음을 추가한다:

import Control.Monad(when)

두 한 줄짜리 패치를 모두 적용하면 빌드가 성공한다(head/tail에 대한 무해한 -Wx-partial 경고는 생략):

[1 of 7] Compiling ParserComb       ( ParserComb.hs, ParserComb.o )
[2 of 7] Compiling Parse            ( Parse.hs, Parse.o )
[3 of 7] Compiling Exp              ( Exp.hs, Exp.o )
[4 of 7] Compiling Desugar          ( Desugar.hs, Desugar.o )
[5 of 7] Compiling Compile          ( Compile.hs, Compile.o )
[6 of 7] Compiling Main             ( Main.hs, Main.o )
[7 of 7] Linking bin/uhs

런타임은 같은 방식으로 빌드한다:

nix develop $MICROHS -c gcc -Wall -O3 eval.c -o bin/eval

아래의 모든 bin/uhs 호출은 lib/(b0915dcf의 경로 플래그, 이 커밋 시점에 이미 자리 잡았고 위 "현재 위치"에서 다룬다) 아래의 Prelude/List/IO/String을 찾으려면 -ilib가 필요하다.

연습 문제 1: 컴파일러와 런타임을 여러 상세-출력 수준으로 실행해 보기

문자열 효과와 산술 효과를 모두 발휘하는 작은 프로그램:

cat > Greet.hs <<'EOF'
module Greet where
import Prelude
import IO

main = IO.do
  putStrLn "hello"
  print (6 * 7)
EOF
nix develop $MICROHS -c ./bin/uhs -ilib Greet

컴파일러 쪽 상세 출력은 플래그 하나(켜짐 또는 꺼짐)다. -v 없이는 bin/uhs가 아무것도 찍지 않는다 — 그냥 out.comb를 쓸 뿐이다. -v와 함께면 모든 모듈 임포트(캐시 적중 포함)를 추적하고, 파일을 쓰기 전에 컴파일된 모든 정의의 컴비네이터 형태를 덤프한다:

nix develop $MICROHS -c ./bin/uhs -v -ilib Greet
importing "Greet"
importing "Prelude"
importing done "Prelude"
importing "IO"
importing cached "Prelude"
importing "List"
importing cached "Prelude"
importing done "List"
importing done "IO"
importing done "Greet"
Greet.main = IO.>> (IO.putStrLn (O 'h' (O 'e' (O 'l' (O 'l' (O 'o' K)))))) (IO.print (Prelude.* 6 7))
IO.ReadMode = B K (B K K)
...

(...는 나머지 모든 Prelude/List/IO 정의의 컴비네이터 형태와, 마지막 한 줄로 이어지는 링크된 전체 프로그램을 생략한다. importing cached "Prelude"가 두 번 나온다: IO와 List 둘 다 Prelude를 임포트하는데, 두 번째 임포트는 다시 파싱되는 대신 Compile.hs의 모듈 캐시에서 제공된다 — -v가 그것을 추적할 때만 보인다.)

런타임 상세 출력은 단계별이다. 레벨 0은 프로그램 자신의 출력뿐이다:

nix develop $MICROHS -c ./bin/eval out.comb > /tmp/greet-lvl0.txt
nix develop $MICROHS -c ./bin/eval -v out.comb > /tmp/greet-lvl1.txt
nix develop $MICROHS -c ./bin/eval -v -v out.comb > /tmp/greet-lvl2.txt
nix develop $MICROHS -c diff /tmp/greet-lvl0.txt /tmp/greet-lvl1.txt
2a3,7
> 
> main returns $I
> node size=16, heap size=100000
> 260 reductions, 1 GCs, max cells used 140
> 140 cells at start

레벨 1(-v)은 ef082bbe가 가두었던 실행 후 진단 트레일러 정확히 그것을 더한다: main이 무엇을 반환했는지($I, 항등 컴비네이터 — IO.>>의 최종 결과이며 버려진다), 노드/힙 크기, 축약/GC/셀 카운트. 레벨 2(-v -v)는 하나를 더 더하는데, 프로그램 자신의 출력 뒤가 아니라 앞에 온다:

nix develop $MICROHS -c diff /tmp/greet-lvl1.txt /tmp/greet-lvl2.txt
0a1
> (($IO.>> ((((($S' ($C' $IO.>>)) (($B (($B $Y) (($B ($B ($P ($IO.return $I)))) (($B ($C' $B)) ($B $IO.>>))))) $IO.putChar)) (($C $IO.putChar) '
)) $IO.stdout) (($O 'h) (($O 'e) (($O 'l) (($O 'l) (($O 'o) $K))))))) (($IO.serialize $IO.stdout) (($* 6) 7)))

그것이 c0fb9a91의 pp(stdout, n) 호출이다: 로드된 프로그램 그래프 전체를, evalio가 실행되기 전에, 한 번 찍는다 — 앞선 장들의 "직접 해보기"가 out.comb 파일에서 읽어 낸 것과 같은 $T/$K/$O 텍스트 형태이지만, 여기서는 디스크의 파일이 아니라 로딩과 가비지 컬렉션 직후 메모리 안의 그래프다(IO.serialize와 pp가 같은 프린터를 공유하기 때문에, 4장의 "무슨 일이 있었나"에 따르면, 둘이 우연히 비슷하게 생겼을 뿐이다). 세 수준, 세 가지 서로 다른 질문에 답한다: 레벨 0은 "프로그램이 무엇을 하는가"; 레벨 1은 "그것이 얼마나 많은 작업을 요구했는가"; 레벨 2는 "실행되기 전 컴파일된 프로그램이 실제로 어떻게 생겼는가."

연습 문제 2: 하네스에 같은 스타일로 테스트 하나 더 추가하기

9b6e23c8+6ac9c791의 패턴을 그대로 따른다: 모듈 하나, 그 실제 출력을 담은 .ref 파일 하나, tests/Makefile에 한 줄 더.

cat > tests/ArithTest.hs <<'EOF'
-- Arithmetic test
module ArithTest where
import Prelude
import IO

main = IO.do
  print (2 + 3)
  print (10 - 4)
  print (6 * 7)
EOF

한 번 손으로 실행해 실제로 무엇을 찍는지 확인한다 — 하네스는 예상 출력이 아니라 실제 출력을 기록한다:

cd tests
nix develop $MICROHS -c ../bin/uhs -i../lib ArithTest
nix develop $MICROHS -c ../bin/eval out.comb
5
6
42

이를 참조 파일로 저장한다:

nix develop $MICROHS -c ../bin/eval out.comb > ArithTest.ref
cat ArithTest.ref
5
6
42

Fac와 같은 스타일로 tests/Makefile에 한 줄을 추가한다:

	$(UHS) ArithTest && $(EVAL) out.comb > ArithTest.out && diff ArithTest.ref ArithTest.out

이 위까지 아무것도 건드리지 않은 독자를 위해, make test가 실제로 실행하는 그대로, 최상위에서 하네스 전체를 실행한다:

cd ..
nix develop $MICROHS -c make test
ghc Main.hs -o bin/uhs
cd tests; make
../bin/uhs -i../lib IOTest && (echo q | ../bin/eval out.comb > IOTest.out) && diff IOTest.ref IOTest.out
../bin/uhs -i../lib StringTest && ../bin/eval out.comb > StringTest.out && diff StringTest.ref StringTest.out
../bin/uhs -i../lib Fac && ../bin/eval out.comb > Fac.out && diff Fac.ref Fac.out
../bin/uhs -i../lib ArithTest && ../bin/eval out.comb > ArithTest.out && diff ArithTest.ref ArithTest.out

맨 앞의 ghc Main.hs -o bin/uhs는 실제이며, 연습 문제 2가 아니라 연습 문제 1의 소행이다: 최상위 Makefile의 $(BIN)/uhs: *.hs 규칙은 최상위 디렉터리의 모든 .hs 파일에 매칭되는데, 연습 문제 1이 남겨 둔 Greet.hs도 포함된다. Greet.hs가 bin/uhs보다 새로우므로, make는 링크 단계를 다시 실행한다 — 무해하게(Main.hs나 그 의존성에 관해 실제로 바뀐 것은 없으므로 바이너리 자체는 똑같이 나온다) 그러나 눈에 띄게, 이후의 모든 make test에서도 마찬가지다. 아무것도 바꾸지 않는 재링크는 bin/uhs 자신의 타임스탬프를 갱신하지 않기 때문이다. 테스트 네 개, 어느 것에서도 diff 출력이 없다 — 침묵은 그 하네스가 ArithTest를 포함해 넷 모두가 각자의 골든 파일과 일치했다고 말하는 것이다.

연습 문제 3: 이 장 범위의 모든 커밋을 분류하기

git log --stat 8c5fbb1a..8018eec8을, 커밋 자체의 제목이 아니라 diff가 실제로 담고 있는 내용으로 커밋 하나하나 분류한다 — 6c59dd36부터 8018eec8까지 열다섯 커밋. 기능/수정/정리/문서는 합리적인 네 갈래 분류법 중 하나다; 다섯 번째 칸인 "혼합"은 아래에서 한 번 쓰이는데, 다른 네 칸 중 진짜로 여러 개를 건드리는 커밋을 억지로 하나에 밀어 넣는 대신이다. 이견은 환영한다 — 이 중 여럿은 어느 쪽으로도 주장할 수 있다.

커밋제목건드린 파일분류
6c59dd36UpdateMakefile정리
9b6e23c8Better names..gitignore, Main.hs, Makefile, tests/Fac.uhs정리
73a427d7Rename files to .hs이름 바꾸기 8건, Compile.hs, Makefile, tests/Makefile정리
6ac9c791Add some testing.tests/*.ref, tests/IOTest.hs, tests/Makefile기능
110bcc94Fix minor issues..gitignore, Makefile, tests/Makefile수정
37e44acdAdd READMEREADME.md문서
aae0eee2More text.README.md문서
04f0840bMore text.Example.hs, README.md문서
91a8ff34More textREADME.md문서
0bfdcd18Fix some mistakes.README.md수정
0b97ab3eAdd a flag to set the heap size.eval.c기능
60343934More text.README.md문서
3cdbb820Various small improvements.Compile.hs, Makefile, README.md, eval.c, lib/IO.hs, tests/Fac.hs, tests/Fac.ref, tests/Makefile, tests/Nfib.hs혼합
28d53dabClean upParserComb.hs정리
8018eec8Add nulllib/List.hs기능

9b6e23c8는 그 자체로도 혼합이라고 주장할 수 있다 — 그 diff의 대부분은 이름 바꾸기와 gitignore 정리이지만, 습관 2의 테스트 하네스가 나중에 의존하는 진짜 새 내용인 tests/Fac.uhs도 도입한다; 여기서는 그것이 그 커밋 자신이 밝힌 의도("Better names.")이자 그 diff에서 더 큰 몫을 차지한다는 이유로 정리로 표에 올렸다. 3cdbb820은 단일 라벨을 끝내 거부하는 유일한 항목이다: 진짜 버그 수정(Compile.hs의 모듈-경로 조회, 점을 슬래시로 바꾸는 것 — 아직 실제로 만들어 내지 못하는 이름들을 위해), 기능(tests/Nfib.hs, 더 무거운 새 벤치마크, 그리고 make time 타깃), 정리(lib/IO.hs의 mapM이 fix (\ rec -> ...) 관용구에서 평범한 재귀 let으로 옮겨 가고, tests/Fac.hs는 이제 Nfib.hs가 대체하는 nfib 35 줄을 뺀다), 그리고 문서 추가(README의 ## Libraries 문단) — 다른 네 범주 전부가, 커밋 하나에(3cdbb820 --stat 자신의 총합: "9 files changed, 45 insertions(+), 18 deletions(-)").

정리한다:

cd tests && rm -f ArithTest.hs ArithTest.ref *.out *.tmp && cd ..
git worktree remove --force /tmp/mhs-37e44acd

🌐 Read in English

06 — Becoming Haskell (하스켈이 되어 가기)

현재 위치

5장은 8018eec8("Add null", null 추가) — 2023-06-02 19:10:07 — 에서 끝난다. 모나딕 IO가 동작하고, IO.serialize/IO.deserialize 쌍이 동작하며, make test에 연결된 세 모듈짜리 골든 파일 테스트 하네스와 첫 README.md를 갖춘, 작지만 동작하는 언어다. 그 시점에 존재하는 모듈 시스템은 최소한이다: lib/Prelude.hs, lib/IO.hs, lib/List.hs, lib/String.hs가 네 개의 평평한 파일로 놓여 있으며, 어느 것도 익스포트 목록을 갖고 있지 않고, 어느 것도 한정자나 as-별칭으로 임포트할 수 없으며, 모든 모듈의 모든 최상위 이름이 하나의 공유된, 한정되지 않은 심볼 테이블로 흘러 들어간다 — 같은 이름을 정의하는 두 임포트는 프로그래머가 의도적으로 겨냥하는 것이 아니라 런타임의 "ambiguous"(모호함) 오류로 충돌한다. do-표기법은 여전히 모든 호출부에서 명시적 한정자를 요구한다(do가 아니라 IO.do). if도, 리스트 컴프리헨션도, 연산자 섹션도 없다. 이 장의 첫 커밋 5f08e33b는 8018eec8 7초 뒤에 도착한다 — 새 세션이 아니라 같은 세션의 연속이다.

무슨 일이 있었나

8018eec8..41d6a7e8 범위는 30개의 커밋이며, 이 범위가 걸쳐 있는 엿새 중 나흘에 걸쳐 도착한다: 2023-06-02 늦은 시각에 한 커밋(5장 자체 저녁 작업의 연속), 2023-06-03 하루 만에 22개의 커밋(11:33부터 15:02까지, 대략 세 시간 반 — 06-04에는 아무것도 없음), 2023-06-05에 4개의 커밋 (11:17부터 15:17까지, 그날 안에 세 시간 반의 공백을 두고 — 06-06에는 아무것도 없음), 그리고 2023-06-07에 3개의 커밋(10:51부터 14:13까지). 그런 다음 로그는 조용해진다: 41d6a7e8(2023-06-07 14:13:56) 이후 날짜순으로 다음 커밋은 44004020("Small fixes.", 사소한 수정) — 2023-07-01 15:41:40 — 24일 뒤이며, 이 책 자체의 개관이 말하듯 "[이] 저장소의 역사에는 24일의 공백을 설명하는 것이 아무것도 없다 — 로그는 그저 다시 이어질 뿐이다." 하나 더, 이는 개관의 표가 읽는 평평한 git log가 아니라 커밋 그래프에서만 보이는 주름이다: 44004020은 실제로는 41d6a7e8의 자식이 아니다. 그 자신의 부모는 53d4bfdc(06-03 15:02:16, 이 장의 마지막에서 두 번째 06-03 커밋)이며 — d5bac600 이후의 모든 커밋보다 앞선 체크아웃으로, if도, 리스트 컴프리헨션도, 두 차례의 파일 재구성도, CHAR 제거도 전혀 담고 있지 않다. 44004020으로부터 약 6분 뒤, 병합 커밋 d128fb6f("Merge branch 'master' of github.com:augustss/MicroHs", master 브랜치 병합, 이 역시 2023-07-01)가 그 오래된 브랜치를 41d6a7e8와 다시 합친다. 날짜순으로 정렬된 목록이 아니라 그래프로 읽으면, 정직한 그림은 06-03에 어긋난 두 개의 체크아웃이 07-01에 다시 이어붙여졌다는 것이지, 24일 동안 그저 멈춰 있다가 재개된 하나의 작업 줄기가 아니다. 그 공백, 그리고 그 건너편에서 재개되는 것은 이 장이 아니라 다음 장의 이야기다.

커밋이 있는 여섯 작업일 안에서는 세 가지 일이 동시에 벌어진다: 모듈 시스템이 한정, 별칭, 필수 익스포트 목록을 갖추게 되고; 파일 배치가 하스켈 자신의 디렉터리 관례를 본떠 두 차례 재구성되며; 표층 문법이 if, 연산자 섹션, 리스트 컴프리헨션을 얻는다. 좀 더 작은 네 번째 줄기 — 스코프 검사가 디슈가러 안으로 옮겨 가는 것, 그리고 Char가 자신만의 런타임 표현을 잃는 것 — 는 나머지 세 줄기가 요구한 모든 것을 컴파일러 내부가 따라잡는 지점이다.

모듈 시스템이 하스켈의 모양을 갖추다

06-03은 모듈 시스템 본연의 작업이 시작되기 전, 두 개의 구조적 준비 커밋으로 열린다. 35ef759d("Refactor module desugaring.", 모듈 디슈가링 리팩터)는 3장의 b25d9fc8가 컴파일된 모듈에 사용하던 맨몸의 (IdentModule, [LDef]) 튜플을, 자체 익스포트 목록과 생성자-애리티 표를 갖춘 진짜 Module 타입으로 대체한다(35ef759d:Desugar.hs 11행, 전문 인용):

data Module = Module IdentModule [Export] [TypeDef] [LDef]

15분 뒤 69437754("Use a map for final lookup", 최종 조회에 맵 사용)는 Main.hs의 최종 링크 조회를, 리스트 위의 선형 lookup(참조 하나당 그때까지의 모든 정의를 훑는 방식)에서 Data.Map으로 바꾼다 (69437754:Main.hs 21–23행, 전문 인용):

      defs :: M.Map Ident Exp
      defs = M.fromList [ (n, ref i) | ((n, _), i) <- zip ds' [0..] ]
      findIdent n = fromMaybe (error $ "undefined: " ++ show n) $ M.lookup n defs

둘 다 사용자 눈에 보이지 않는 변화다. 둘 다 3장이 이미 하나의 리듬으로 이름 붙인 "더 짓기 전에 힘을 받치는 배관을 먼저 정리한다"는 종류의 움직임이다.

그런 다음 빠르게 이어진다: 155e9f5a("Add 'qualified' to imports.", 임포트에 'qualified' 추가)는 qualified 키워드를 추가하며, Bool 하나를 ImportSpec에 실어 날라, 한정 임포트의 이름들이 오직 점으로 연결된 형태로만 심볼 테이블에 들어가고 맨 이름으로는 결코 들어가지 않게 한다(155e9f5a:Parse.hs 279–280행, 전문 인용):

  <|>            (pKeyword "import" *> (Import <$> pQua <*> pUIdent))
  where pQua = (True <$ pKeyword "qualified") <|< pure False

28분 뒤 cfbf3c7d("Implement 'as' in 'import' statement", 'import' 문에 'as' 구현)는 별칭 자체를 추가한다 — ImportSpec이 세 번째 필드, Maybe Ident를 얻고, pImportSpec이 pDef 안에 그대로 풀어써 있던 방식 대신 자기 완결적인 하나의 파서가 된다(cfbf3c7d:Parse.hs 280–282행, 전문 인용):

pImportSpec :: P ImportSpec
pImportSpec = ImportSpec <$> pQua <*> pUIdent <*> optional (pKeyword "as" *> pUIdent)
  where pQua = (True <$ pKeyword "qualified") <|< pure False

그로부터 7분 뒤 bd28b65c("Avoid errors on multiple imports of the same module.", 같은 모듈의 다중 임포트 시 오류 방지)는 앞선 두 커밋 자신의 심볼 테이블 배관이 드러낸 버그를 고친다: 같은 모듈이 두 임포트 경로로 (직접, 그리고 다른 임포트를 거쳐 간접적으로) 도달 가능할 때, 그 모듈의 한정된 이름들이 (++)를 통해 두 번 삽입되고 있었고, 그래서 3장이 만든 "ambiguous" 검사(checkExpr의 Just qis -> error "ambiguous: ")가 실제로는 모호하지 않은 이름 — 그저 자신의 심볼 테이블 버킷에 두 번 나타났을 뿐인 이름 — 에 대해 발동하고 있었다. 고치는 방법은 맵 병합 함수를 리스트 연결에서, 이미 중복을 제거해 주는 Data.List.union으로 바꾸는 것이다(bd28b65c:Desugar.hs 39–44행, 전문 인용):

      allSyms :: SymTable
      allSyms = M.fromListWith union $ concatMap syms (imdl : mdls)
        where syms (is, Module mn qis _ _) = [ (v, [Var qi]) | (i, qi) <- qis, v <- qns is mn i ]
      allTypes :: TypeTable
      allTypes = M.fromListWith union $ concatMap types (imdl : mdls)
        where types (is, Module mn _ tds _) = [ (v, [td]) | td@(TypeDef _ cs) <- tds, (c, _) <- cs, v <- qns is mn c ]

cfbf3c7d와 bd28b65c 사이에는 파일 재구성 커밋 두 개(16c75970, 4f3b8f8d — 아래 "파일이 재구성되다, 그것만의 커밋으로"에서 다룬다)가 끼어 있고, 그다음 7c23e672("Make 'do' qualifier optional.", 'do' 한정자를 선택적으로 만들기)가 do를 홀로 서게 한다: EDo의 한정자가 필수였던 Ident에서 Maybe Ident로 바뀌고, 한정되지 않은 do는 매 호출부에서 모듈 접두어를 요구하는 대신 한정되지 않은 채로 (>>=)/(>>)를 찾는다 — 3장의 "직접 해보기" 연습 문제가 손으로 우회해야 했던 그 변화가 이제 내장되었고, 같은 커밋에서 Example.hs 자신의 main도 IO.do를 그냥 do로 낮춘다.

모듈 시스템 줄기의 마지막 조각은 1136f265("Require an export list.", 익스포트 목록 요구), 06-03 14:10:46이다. 이 커밋 이전에는 module M where가 파싱된다. 이후에는 pModule이 모듈 이름과 where 사이에 괄호로 묶인 익스포트 목록을 요구하며, 구현된 익스포트 항목의 종류는 단 하나뿐이다 — module M, 선택 없는 모듈 전체 재익스포트 (1136f265:Parse.hs 155–161행, 전문 인용):

pModule :: P EModule
pModule = EModule <$> (pKeyword "module" *> pUIdent) <*>
                      (pSym '(' *> many pExportSpec <* pSym ')') <*>
                      (pKeyword' "where" *> pBlock pDef)

pExportSpec :: P ExportSpec
pExportSpec = ExpModule <$> (pKeyword "module" *> pUIdent)

익스포트 목록이 전혀 없는 모듈을 컴파일러에 돌려 보면(이 장 뒤쪽의 빌드가 정확한 트랜스크립트를 재현한다 — "직접 해보기" 연습 문제 3 참고) 이제 모듈 이름 바로 뒤, where에 이르기도 전에 파싱에 실패한다 — module NoExport where는 expeced: '('(원문 그대로의 철자를 그대로 살렸다)로 거부된다. 같은 커밋이 기존의 모든 라이브러리와 테스트 모듈을, 새 문법이 받아들이는 유일한 형태인 module M(module M) where — 선택 익스포트 목록이 생기기 전까지 "전부 익스포트"를 쓰는 유일한 방법인 모듈 전체 자기-재익스포트 — 로 고친다. 이 기계적인 수정 중 하나는 진짜 오타 하나를 함께 배포한다: tests/ListTest.hs가 module ListTest(Module ListTest) where를 얻는데 — pExportSpec이 찾는 module 키워드가 아니라 대문자로 시작하는 Module이라 파싱되지 않았을 것이다. 바로 다음 커밋 dba211e1("More tests", 테스트 추가), 3분 뒤가 관련 없는 내용 변경과 함께 이를 소문자로 고친다 — 30개 커밋, 엿새짜리 스프린트도 여전히 이따금 버그를 내놓고, 거의 즉시 잡아낸다는 작지만 정직한 일깨움이다.

파일이 재구성되다, 그것만의 커밋으로

이 범위 안에서 두 차례의 별개의 재구성이 일어나며, 각각은 동작을 바꾸는 어떤 커밋으로부터도 말끔히 분리되어 있다.

첫 번째: 16c75970("Reorganize modules to be more like Haskell.", 모듈을 하스켈에 더 가깝게 재구성)는 평평한 lib/IO.hs와 lib/List.hs의 본문을 지우고(191줄 삭제, 9개 파일 손댐, 13줄 추가 — 대부분 Desugar.hs와 테스트 모듈들의 임포트 줄 갱신), 47초 뒤 4f3b8f8d("Moved files.", 파일을 옮김)가 같은 내용을 lib/Data/List.hs와 lib/System/IO.hs로, 이제 하스켈 모양의 경로 아래에 되살려 넣는다 (178줄, 순전한 추가, 두 파일). 이 커밋 쌍은 한 커밋이 둘 다 한꺼번에 하는 대신 — 한 커밋에서 내용을 지우고 바로 다음 커밋에서 (모듈 이름을 포함해) 그대로 되살리는 — 손으로 한 git mv다.

두 번째 재구성은 더 크고, 각 커밋 시점의 트리를 문자 그대로 읽으면 한동안 프로젝트를 빌드 불가능한 상태로 남긴다. 34c8965e("Rearrange files.", 파일 재배열), 06-05 15:17:24는 Compile.hs, Desugar.hs, Exp.hs, Main.hs, Parse.hs, ParserComb.hs를 통째로 지운다 — 하스켈 소스 975줄이 사라지고, 같은 커밋 어디에도 그 어느 것에 대한 대체물이 없다(커밋의 --stat 합계는 8개 파일에 걸쳐 980줄 삭제다. 나머지 5개는 아래에 다시 쓰인 Makefile 줄이다) — 동시에 eval.c를 src/runtime/eval.c로 이름을 바꾸고, Makefile이 바로 그 커밋 시점에는 존재하지 않는 경로를 가리키게 한다(34c8965e:Makefile, 전문 인용):

BIN=bin
.PHONY: all test time example

all:	$(BIN)/eval $(BIN)/uhs

$(BIN)/eval:	src/runtime/eval.c
	@mkdir -p bin
	gcc -Wall -O3 src/runtime/eval.c -o $(BIN)/eval

$(BIN)/uhs:	src/*/*.hs
	@mkdir -p bin
	ghc -Wall -O -isrc src/MicroHs/Main.hs -o $(BIN)/uhs

test:	$(BIN)/eval $(BIN)/uhs tests/*.hs
	cd tests; make test

time:	$(BIN)/eval $(BIN)/uhs tests/*.hs
	cd tests; make time

example:	$(BIN)/eval $(BIN)/uhs Example.hs
	$(BIN)/uhs -ilib Example && $(BIN)/eval

clean:
	rm -f src/*/*.hi src/*/*.o eval Main *.comb *.tmp *~ $(BIN)/*
	cd tests; make clean

git ls-tree -r 34c8965e --name-only가 이를 직접 확인해 준다: 이 커밋 시점에는 src/MicroHs/ 아래 어떤 파일도 존재하지 않는다 — Makefile 자체의 $(BIN)/uhs: src/*/*.hs 규칙에는 맞물릴 것이 없다. ac6f377c("Make files a bit more like Haskell.", 파일을 하스켈에 좀 더 가깝게), 트리를 다음으로 손대는 커밋은 거의 이틀 뒤(2023-06-07 10:51:20)에 도착하며, lib/System/IO.hs와 src/runtime/eval.c만 고친다 — 컴파일러 소스는 여전히 빠져 있다. 2f89971e("Move files.", 파일 옮기기), 06-07 14:13:33만이 이들을 src/MicroHs/*.hs와 src/Text/ParserComb.hs 아래에 되살리며, 각 파일 자신의 module 줄을 새 경로에 맞게 다시 한정한다(module Compile은 module MicroHs.Compile이 되는 식으로). 하지만 "Move files."(파일 옮기기)는 다음 문단의 ac6f377c와 같은 방식으로, 두 가지 별개의 측면에서 자신의 diff를 과소 서술한다. 첫째, 되살아난 파일들을 34c8965e가 지운 내용과 diff해 보면 여섯 개 중 넷은 정말로 순수한 이동(모듈/임포트 줄만 변경)이지만 — Desugar.hs와 Exp.hs는 변경되어 돌아온다: Exp의 Chr Char 생성자가 사라지고, Int Integer가 Int Int로 좁아지며, EChar c는 이제 Int (fromEnum c)로 디슈가된다 — 아래의 41d6a7e8가 런타임 쪽 절반을 지우기 23초 전에, "이동" 커밋 안에 컴파일러 쪽 절반의 CHAR 제거가 들어와 있다. 둘째, 이 커밋은 일곱 개의 파일을 추가하는데, 일곱 번째인 src/MicroHs/Translate.hs (78줄)는 아예 옮겨진 파일이 아니다 — 프로젝트 역사 어디에도 이전 대응물이 없다. 이것은 새로운 무언가의 첫 초안이다: 컴파일러 자신의 Exp 컴비네이터 항을 곧바로 네이티브 GHC 값으로 바꾸는 번역기 (trans :: Exp -> Any, unsafeCoerce 위에 지어졌고, 각 컴비네이터 이름을 실제 하스켈 함수와 짝짓는 primOps 표 — comb "S" (\ f g x -> f x (g x)), comb "Y" (let fix f = f (fix f) in fix) 등등을 갖춘) — 즉, 컴파일된 프로그램을 eval.c로 보내는 대신 GHC 자신의 런타임 안에서 실행하는 방법이다(그리고 이것이 Exp의 정수가 방금 머신-폭의 Int로 바뀐 이유이기도 하다: Translate.hs의 산술 표는 Int -> Int -> Int로 타입 지정되어 있다). 이것은 또한 눈에 띄게 미완성이다: 커밋된 파일의 52행은 , comb "IO.h로 끝난다 — 끝나지 않은 문자열 리터럴로, 렉싱조차 되지 않는 파일이 타이핑 도중에 그대로 얼어붙은 것이다. 아직 아무도 이를 임포트하지 않기 때문에 빌드를 깨지 않을 뿐이다(2f89971e:src/MicroHs/Main.hs는 Parse/Exp/Compile을 임포트할 뿐 Translate는 임포트하지 않고, ghc -isrc는 Main이 닿는 것만 컴파일한다). 이 줄기가 어디로 가는지는 다음 장의 소재다. 여기서는 순전한 정리 작업처럼 이름 붙은 커밋 속의 밀항자일 뿐이다. 한편 이 커밋 쌍에 관한 표제 격 사실은 그대로 서 있다: 이 장의 06-05와 06-07 세션 사이의 경계에 걸친 대략 47시간 동안, 이 저장소 자신의 역사에는 uhs를 아예 빌드할 수 있는 커밋이 하나도 없다 — 이 책의 워크트리 관례가 만든 인공물이 아니라 진짜 빌드 가능성의 공백이며, 이것이 이 장 자체의 "직접 해보기"가 그 이전 지점에 닻을 내리는 이유다(아래 참고).

ac6f377c 자신의 diff는, 그 공백 안에 자리하며, 곁가지로 언급할 가치가 있다: 제목은 순전한 미용 목적의 이름 바꾸기 작업처럼 읽히지만, lib/System/IO.hs에 대한 diff는 진짜 오류 처리를 추가한다 — hGetChar와 openFile이 이제 파일의 끝이나 열기 실패 시 조용히 보초값(sentinel)을 돌려주는 대신 제대로 된 error를 낸다 — 그리고 src/runtime/eval.c에 대한 diff는 진짜 가비지 컬렉터 버그를 고친다: scan의 스윕이 프로그램 그래프 안에서 아무것도 그것들을 가리키지 않을 때마다 stdin/stdout/stderr를 도달 불가능한 파일 핸들로 취급해 닫아 버리고 있었는데, 이 커밋은 이 세 핸들을 수거-시-닫기(close-on-collect) 검사에서 제외해 이를 바로잡는다. 정리를 위한 이름이 붙은 커밋이 진짜 버그 수정과 진짜 동작 변경을 함께 실어 나른다 — 5장이 3cdbb820을 두고 지적한 "커밋 제목이 자신의 diff를 온전히 서술하지 않는다"는 바로 그 패턴이다.

스코프 검사가 디슈가링 안으로 옮겨 가다

0cc677d5("Change desugaring to do scope checks. Add case error checks.", 디슈가링에서 스코프 검사를 하도록 변경. case 오류 검사 추가), 06-03 12:30:50는 이 줄기에서 가장 큰 단일 커밋 변경이다. 이전 에는 Compile.hs가 먼저 모듈을 디슈가하고, 그런 다음 이미 디슈가된 Exp 트리를 별도의 패스인 checkExpr(3장의 b25d9fc8)로 훑으며, 맨몸의 각 Var를 임포트된 모든 모듈의 익스포트에서 만들어진 심볼 테이블에 대조해 해소한다. 0cc677d5는 그 별도의 패스를 지우고 그 역할을 dsExpr에 곧바로 접어 넣는다. 이제 dsExpr은 심볼 테이블(과 짝을 이루는 생성자-애리티 표)을 인자로 받아, 나중이 아니라 각 EVar가 디슈가되는 바로 그 순간에 해소한다(0cc677d5:Desugar.hs 53–57행, 전문 인용):

dsExpr syms _ (EVar i) =
  case M.lookup i syms of
    Nothing -> error $ "undefined: " ++ show i
    Just [qi] -> qi
    Just qis -> error $ "ambiguous: " ++ show i ++ ", " ++ show qis

같은 커밋이 메시지가 약속한 "case 오류 검사"를 추가한다: 새로운 reorderArms 패스는, case의 팔들과 생성자 표가 주어지면, 생성자 case를 그것이 속한 실제 데이터 선언에 대조해 검사하고 — 그저 파싱하는 것이 아니라 검증하며 — dsExpr의 스콧 인코딩 case 컴파일러(2장의 O/P-컴비네이터 트릭)가 항상 팔들을 런타임이 기대하는 선언 순서로 보도록 재배열한다(0cc677d5:Desugar.hs 92–108행, 전문 인용):

reorderArms :: TypeTable -> [(EPat, Expr)] -> [(EPat, Expr)]
reorderArms _ [] = error "case has no arms"
reorderArms _ as@[(PTuple _, _)] = as
reorderArms tys as@((PConstr con _, _) : _) =
  let arms = [(c, a) | a@(PConstr c _, _) <- as] in
  if length arms /= length as then error "bad tuple pattern" else
  case M.lookup con tys of
    Nothing -> error $ "undefined constructor: " ++ show con
    Just [TypeDef _ cs] -> map arm cs
      where arm (c, k) =
              case lookup c arms of
                Nothing -> error $ "constructor missing: " ++ show (c, con)
                Just a@(PConstr _ vs, _) ->
                  if length vs == k then a else error $ "bad contructor arity: " ++ show a
                _ -> undefined
    Just _tds -> error $ "ambiguous constructor: " ++ show con
reorderArms _ _ = undefined

16줄짜리 함수 하나에 여섯 가지 서로 다른 실패 양상이 담겨 있다: 빈 case, 튜플/생성자 패턴이 뒤섞인 집합, 알 수 없는 생성자, 데이터 선언에는 있지만 case에는 빠진 생성자, 그 생성자의 선언된 애리티에 맞지 않는 수의 필드를 묶는 팔("contructor"라는 오타가 소스에 그대로 있으며, 위 인용에서도 그대로 살렸다), 그리고 임포트된 두 타입에 걸쳐 모호한 생성자 이름. 이 커밋 이전에는 이 중 어느 것도 검사되지 않았다 — 오타가 난 생성자 이름이나 잘못된 수의 속박 변수를 가진 case는 도움이 안 되는 메시지와 함께 C 런타임을 크래시시키거나 조용히 정의되지 않은 동작으로 빠졌다. 0cc677d5 이후에는 여섯 가지 전부가 디슈가러 안에서, 컴파일 시점에, 하스켈로, 무엇이 잘못됐는지 이름을 붙여 잡아낸다.

Prelude가 Data.* 스타일 모듈들로 갈라지다

3a9c0edb("Split up Prelude", Prelude 분리)와 92960261("More stuff in Prelude", Prelude에 내용 추가)는 1136f265 17분과 26분 뒤, 단일 lib/Prelude.hs를 관심사별로 파일 하나씩으로 쪼개고 이들 전부를 Prelude를 통해 다시 익스포트한다. 92960261이 끝날 무렵 모듈 지도는 다음과 같다(92960261:lib/Prelude.hs, 전문 인용):

module Prelude(
  module Control.Error,
  module Data.Bool,
  module Data.Either,
  module Data.Function,
  module Data.Int,
  module Data.List,
  module Data.Maybe,
  module Data.Tuple,
  module System.IO,
  module Text.String
  ) where
import Control.Error
import Data.Bool
import Data.Either
import Data.Function
import Data.Int
import Data.List
import Data.Maybe
import Data.Tuple
import System.IO
import Text.String

열 개의 모듈, 전부 계층적인 Data.*/Control.*/System.*/Text.* 접두어 아래에 있으며, 대부분은 실제 하스켈 base 패키지에서 같은 이름이 사는 것과 정확히 같은 경로에 있다 — 작은 ADT들과 그 함수들을 위한 Data.Bool, Data.Either, Data.Maybe, Data.Tuple; id/./flip을 위한 Data.Function; 산술과 (곧 항등 함수가 될) chr/ord 쌍을 위한 Data.Int; 리스트 타입 자체와 그 함수들을 위한 Data.List; 핸들과 IO 프리미티브를 위한 System.IO; 그리고 실제 base 모듈은 아니지만 하스켈다운 이름 두 개, 문자열 서식을 위한 Text.String과 error/undefined를 위한 Control.Error. 이 중 어느 것도 새로운 기능은 아니다 — 옮겨 간 이름은 모두 평평한 파일에서 하던 것과 정확히 같게 계속 동작한다 — 하지만 이것은 모듈 시스템이 마침내 지어진 규모대로 단련되는 순간이다: 실제 파일들로 나뉘어, 실제 임포트로 다시 이어붙인 실제 코드.

표층 문법이 따라잡다

178f43d0("Better 'let' in 'do'", 'do' 안의 'let' 개선)는 do-블록의 let이 한 번에 하나보다 많은 이름을 묶을 수 있게 한다 — Let Ident Expr가 Let [EDef]가 되고, pStmt의 let은 name = expr 한 쌍 대신 pBlock pDef 전체를 파싱한다 — 바로 앞 커밋 자신의 주석이 짚어 두었던("could be better", 더 나을 수 있다) 공백을 메운다.

9분 뒤 1a0ac4c6("Add operator sections.", 연산자 섹션 추가)는 (e op)와 (op e)를 표현식으로 추가한다 — 왼쪽 섹션과 오른쪽 섹션이다. 왼쪽 섹션 (e op)(예: (5 <))는 e를 연산자의 첫 번째 인자로 고정하므로, 그저 다음에 오는 것에 적용되는 op e — 평범한 App일 뿐이다. 오른쪽 섹션 (op e)(예: (< 5))는 다음 인자가 연산자의 첫 번째 자리에 들어가야 하고 e는 두 번째 자리에 고정되어야 한다 — 정확히 C(3장의 인자를 뒤바꾸는 컴비네이터, C f x y = f y x)가 하는 일이다(1a0ac4c6:Desugar.hs 107–110행, 전문 인용):

dsExpr syms tys (ESectL e op) =
  App (dsExpr syms tys (EVar op)) (dsExpr syms tys e)
dsExpr syms tys (ESectR op e) =
  App2 CC (dsExpr syms tys (EVar op)) (dsExpr syms tys e)

파서 쪽은 pAExpr에 대안 하나씩이다(1a0ac4c6:Parse.hs 311–312행, 전문 인용):

  <|> (ESectL <$> (pSym '(' *> pExprArg) <*> (pOper <* pSym ')'))
  <|> (ESectR <$> (pSym '(' *> pOper) <*> (pExprArg <* pSym ')'))

그런 다음 이틀의 휴식(06-03 저녁부터 06-05 아침까지). 세션은 d5bac600("Some compatibility fixes.", 호환성 수정 몇 가지)로 재개된다 — 작고 수수한 커밋이다: Flags의 path 필드가 Compile.hs/Main.hs 전체에서 paths로 이름이 바뀌고, eval.c의 printrec은 INT 서식 문자열을 %ld에서 %lld로 바꾸며, 두 Makefile 빌드 규칙 모두 @mkdir -p bin을 얻어 막 체크아웃한 저장소에 없는 bin/ 디렉터리 때문에 빌드가 실패하지 않게 한다 — 두 번째 세션이 곧바로 드러내는 종류의 작은 마찰이다. 11분 뒤 if가 온다 — 정말로 이 범위 이전에는 if가 전혀 없었을까? if가 실제로 동작하려면 필요한 생성자인 EIf를 프로젝트 전체 역사에서 검색해 보면 1baaf7b6 이전에는 아무것도 나오지 않는다 — 이 커밋은 정말로 if/then/else가 이 프로젝트 git 역사 어디에서든 파싱 가능한 문법이 되는 첫 순간이지, 그저 이 장 범위 안에서만 그런 것이 아니다. 그 이전에는 조건식이 오직 case cond of { True -> a; False -> b } 형태로만 쓸 수 있었고, Example.hs 자신의 fac가 이 커밋이 이를 if n <= 0 then 1 else n * fac(n-1)로 다시 쓰기 바로 직전까지 정확히 그 형태를 쓴다. 1baaf7b6("Add 'if'", 'if' 추가), 06-05 11:28:53는 기존 목록에 세 개의 새 키워드(if/then/else)를 추가하고 (1baaf7b6:Parse.hs 201–203행, 전문 인용):

keywords :: [String]
keywords = ["case", "data", "do", "else", "if", "import",
  "in", "let", "module", "of", "primitive", "then", "where"]

다시 쓰인 Example.hs가 손으로 풀어 쓰고 있던 것과 같은 Bool-위의-case 트릭을 재사용하는 디슈가링을 추가한다 — if는 (스콧 인코딩된) 조건을 else-가지와 then-가지에, 그 순서로 적용하는 것으로 컴파일된다(1baaf7b6:Desugar.hs 111–112행, 전문 인용):

dsExpr syms tys (EIf e1 e2 e3) =
  App2 (dsExpr syms tys e1) (dsExpr syms tys e3) (dsExpr syms tys e2)

19분 뒤 0eb369f9("Add list comprehensions.", 리스트 컴프리헨션 추가)는 [e | q1, q2, ...]를 새로운 pAExpr 문법으로 추가하고, 그 한정자들에 do-표기법이 이미 파싱하는 것과 똑같은 EStmt 타입 (Bind, Then, Let)을 재사용하며, 세 가지 한정자 종류 각각을 컴프리헨션의 나머지 부분에 대한 재귀로 디슈가한다 (0eb369f9:Desugar.hs 113–119행, 전문 인용):

dsExpr syms tys (ECompr e []) = dsExpr syms tys (EList [e])
dsExpr syms tys (ECompr e (Bind i b : ss)) =
  App2 (Var "Data.List.concatMap") (dsExpr syms tys (ELam [i] (ECompr e ss))) (dsExpr syms tys b)
dsExpr syms tys (ECompr e (Then c : ss)) =
  dsExpr syms tys (EIf c (ECompr e ss) (EList []))
dsExpr syms tys (ECompr e (Let d : ss)) =
  dsExpr syms tys (ELet d (ECompr e ss))

네 규칙, 각 한 줄씩이다: 한정자가 남지 않으면([]) 원소 하나짜리 리스트다. 생성자(Bind i b)는 concatMap (\i -> rest) b가 된다 — 다시 디슈가된 EVar 조회가 아니라 하드코딩된 이름 "Data.List.concatMap"이므로, 이 규칙은 컴프리헨션이 쓰이는 곳 어디에나 Data.List(또는 그 정확한 한정 이름으로 concatMap을 재익스포트하는 무언가)가 스코프에 있어야 한다는 데 의존한다. 가드 (Then c)는 if c then rest else []가 되어, 1baaf7b6가 막 추가한 if를 재사용한다. 그리고 let 한정자는 나머지를 감싸는 평범한 let이 된다. 이 조리법대로라면 [x*x | x <- xs, x > 2]는 concatMap (\x -> if x > 2 then [x*x] else []) xs다 — 이 커밋이 필요로 했을 때 이미 전부 존재하던 조각들(concatMap, if, let)로 지어 올린, 교과서적인 컴프리헨션 디슈가링이다.

41d6a7e8: CHAR 없애기

이 장의 마지막 커밋, 06-07 14:13:56은 런타임의 전용 문자 표현을 통째로 지운다 — 23초 앞서 2f89971e가 Exp의 Chr 생성자를 지우며 컴파일러 쪽에서 이미 시작해 둔 것을 완성하는 것이다("파일이 재구성되다" 참고). 그 이전에는, 34c8965e의 이동 이후 src/runtime/ eval.c에 살고 있는 eval.c가 INT와 구분되는 CHAR 노드 태그와, 런타임 자신의 컴비네이터-파일 파서 안의 'c 리터럴 문법, 그리고 두 태그 사이를 변환하는 두 프리미티브 ORD/CHR을 갖고 있다. 41d6a7e8는 이 전부를 지운다 — CHAR가 태그 열거형에서 사라지고 (두 상태를 직접 비교하면; 41d6a7e8~1:src/runtime/eval.c 18행에는 아직 있고, 41d6a7e8:src/runtime/eval.c 18행에는 없다):

enum node_tag { FREE, IND, AP, INT, HDL, S, K, I, B, C, T, Y, SS, BB, CC, P, O,

— '\'' 리터럴 파서 케이스, evalchar 함수, CHAR/ORD/CHR에 대한 printrec/eval/evalio 케이스, 그리고 evalchar를 거쳐 호출하던 evalstring(평범한 evalint와 0 <= c <= 127 범위 검사로 대체됨)과 함께. 하스켈 쪽에서는 chr/ord가 — 이전에는 이제는 지워진 런타임 프리미티브를 부르는 primitive "chr"/primitive "ord"였던 것이 — 평범한 항등 함수가 된다. Char와 그 Int 서수가 이제 런타임 수준에서는 문자 그대로 같은 표현이기 때문이다 (41d6a7e8:lib/Data/Int.hs 27–28행, 전문 인용):

chr x = x
ord x = x

죽는 것은 노드 태그 하나 전체와 거기에 딸린 프리미티브들이다 — 이름 바꾸기가 아니라 진짜 단순화다 — 그리고 이를 안전하게 지울 수 있게 해 주는 것은, 런타임 위 어디에도 Char가 자신만의 태그여야 할 필요가 실제로는 없었다는 사실이다: 문자열은 이미 문자로 출력되는 무언가의 리스트였고(3장의 EStr-에서-EList로의 디슈가링), 런타임이 그 "무언가"를 처음부터 끝까지 평범한 정수로 다루는 순간, chr/ord에게는 더 이상 할 일이 남지 않는다.

왜 중요한가

이 장의 개별 조각 중 큰 것은 없다. 한정 임포트는 Bool 필드 하나다. as는 Maybe Ident다. 필수 익스포트 목록은 pModule의 새 생성물 하나이며 익스포트-항목 형태는 정확히 하나만 구현되어 있다. 연산자 섹션은 각각 두 줄짜리 디슈가링이다. 리스트 컴프리헨션은 concatMap과 if에 기대는, 새 장치를 발명하지 않는 네 개의 한 줄짜리 디슈가링 규칙이다. 눈여겨볼 것은 어느 한 커밋이라기보다, 한데 모아 놓았을 때 이 집합이 겨냥하는 것이다: 모듈 시스템과 Prelude의 모양과 표층 문법 전부가 같은 엿새 동안 진짜 하스켈처럼 보이는 쪽으로 움직인다 — 완결성이 목표여서가 아니라, 몇 주 뒤에 구체적인 표적 하나가 놓여 있기 때문이다: 이 컴파일러가 언젠가 실제 하스켈 프로그램을 컴파일하게 만드는 것, 그리고 언젠가 이 컴파일러 자신이, 런타임이 말하는 자그마한 방언이 아니라 하스켈로 쓰인 채로. (셀프 호스팅 총력전은 이 책의 다음 장 이야기다.) 이 장이 더하는 기능은 하나같이, 보통 방식으로 쓰인 실제 하스켈 코드라면 두 번 생각할 것도 없이 쓸 만한 것들이다 — if, 리스트 컴프리헨션, Prelude 충돌을 피하기 위한 한정 임포트, 모듈이 무엇을 드러내는지 통제하는 익스포트 목록 — 그리고 여기에 사변적인 것은 하나도 없다: 결합성 선언 장치도, 사용자 정의 연산자도, 타입 클래스도, 선택 익스포트(모듈 전체 재익스포트뿐)도 여전히 없다. 이 스프린트에서 YAGNI는 양쪽으로 작동한다: 실제 코드가 곧 필요로 할 것을 짓고, 아직 그 이상은 짓지 않는다.

실험 노트 여백

  • "Update docs."(문서 갱신) 커밋들은 세션 끝에 몰아서가 아니라, 자신이 기록하는 사용자 눈에 보이는 변경 바로 뒤에 도착한다: 51a56220 ("Update docs.")는 92960261(Prelude 분리 마무리) 5분 뒤에, 263d2f87("Update docs")는 1a0ac4c6(연산자 섹션) 3분 뒤에 온다.
  • 파일 이동은 그것만의 커밋을 얻으며, 대체로 동작을 바꾸는 커밋과 분리되어 있다 — 이 범위 안에서 두 번, 서로 다른 두 규모로. 16c75970/ 4f3b8f8d는 lib/IO.hs/lib/List.hs를 lib/System/IO.hs/ lib/Data/List.hs로, 47초 간격의 말끔한 삭제-후-재생성 쌍으로 가른다. 34c8965e/2f89971e는 컴파일러 자신의 최상위 .hs 파일 여섯 개와 ParserComb.hs에 대해 같은 일을 하며, 이들을 src/MicroHs//src/Text/ 아래로 옮긴다 — 다만 이번에는 "파일 옮기기" 커밋이 바로 다음 커밋이 아니고(이틀과 관련 없는 커밋 ac6f377c 하나만큼 뒤이며, 저장소에는 그 사이 진짜로 컴파일러 소스 트리가 존재하지 않는다), 순수한 이동도 아니다: 2f89971e는 되살린 여섯 개 파일 옆에 새롭고 절반쯤 끝낸 Translate.hs를 몰래 끼워 넣는다("파일이 재구성되다" 참고). 이 규율은 진짜이지만 절대적이지는 않다 — 어떤 커밋 제목이든 자신의 diff보다 먼저 신뢰하기 전에 알아 둘 만하다.
  • 이 범위 전체의 리듬은 두 겹으로 중첩된 규모에서 스프린트-후-공백 이다: 여섯 작업일 안에서는 커밋이 빽빽이 뭉치고(06-03 하루 오후에 22개) 그런 다음 하루씩 건너뛴다(06-04와 06-06 모두 비어 있다). 책 전체에 걸쳐서는, 이 장의 마무리 뒤로 7장이 07-01에 재개되기 전, 개관 자체의 타임라인 표가 짚어 둔 24일의 침묵이 따라온다.

직접 해보기

닻: 0eb369f9("Add list comprehensions.", 리스트 컴프리헨션 추가), 2023-06-05 11:48:09 — 일부러 34c8965e의 파일 이동 이전에서 골랐다. 그래야 이 장의 빌드 노트가 손대는 컴파일러 소스들이 아직 최상위에 있어, 2–5장이 이미 빌드하던 것과 같은 배치이기 때문이다 (34c8965e와 2f89971e 사이의 아무 지점을 대신 체크아웃하면 Compile.hs/Desugar.hs/Exp.hs/Main.hs/Parse.hs/ ParserComb.hs가 아예 없어 빌드할 것이 없다 — 위 "파일이 재구성되다" 참고). 관례(00-overview.md 참고):

git worktree add /tmp/mhs-0eb369f9 0eb369f9
cd /tmp/mhs-0eb369f9

빌드 노트: 이 커밋의 Makefile은 여전히 *.hs로부터 최상위에서 bin/uhs를 빌드하고 eval.c로부터 bin/eval을 빌드한다 — 5장의 닻과 같은 모양이다. 쓰인 그대로 빌드하면 2–5장이 부딪힌 것과 같은, 이 범위에서도 바뀌지 않은 GHC-9.8/mtl 공백 두 개에 부딪힌다:

mkdir -p bin
nix develop $MICROHS -c ghc Main.hs -o bin/uhs

먼저 ParserComb.hs의 ap가 스코프에 없다는 오류로 실패한다. ParserComb.hs의 기존 import Control.Monad.State.Strict 뒤에 추가한다:

import Control.Monad(ap)

그런 다음 Compile.hs의 when이 스코프에 없다는 오류다(이번에는 다섯 곳의 호출부다. Compile.hs가 5장의 닻 이후로 자랐다 — 다섯 모두 같은 오류이며, 첫 번째만 보인다):

Compile.hs:33:7: error: [GHC-88464]
    Variable not in scope: when :: Bool -> a5 -> StateT Cache IO a6
    Suggested fix:
      Perhaps use data constructor 'Then' (imported from Parse)
   |
33 |       when (nm `elem` ws) $
   |       ^^^^

Compile.hs의 기존 import Control.Monad.State.Strict 뒤에 추가한다:

import Control.Monad(when)

두 개의 한 줄짜리 패치로 빌드가 성공한다(head/tail에 대한 무해한 -Wx-partial 경고는 생략):

[1 of 7] Compiling ParserComb       ( ParserComb.hs, ParserComb.o )
[2 of 7] Compiling Parse            ( Parse.hs, Parse.o )
[3 of 7] Compiling Exp              ( Exp.hs, Exp.o )
[4 of 7] Compiling Desugar          ( Desugar.hs, Desugar.o )
[5 of 7] Compiling Compile          ( Compile.hs, Compile.o )
[6 of 7] Compiling Main             ( Main.hs, Main.o )
[7 of 7] Linking bin/uhs

런타임도 같은 방식으로 빌드한다(%lld 대 value_t에 관한 무해한 -Wformat 경고는 생략):

nix develop $MICROHS -c gcc -Wall -O3 eval.c -o bin/eval

아래의 모든 bin/uhs 호출은 Prelude와 lib/ 아래의 다른 라이브러리 모듈들을 찾기 위해 -ilib가 필요하다.

연습 문제 1: 익스포트 목록, 한정 임포트, 그리고 눈에 보이는 이름 가리기

M1은 이 커밋의 파서가 받아들이는 유일한 익스포트 목록 형태 (module M(module M) where — "무슨 일이 있었나"의 1136f265 참고)로 모든 것을 익스포트하고, double을 정의한다:

cat > M1.hs <<'EOF'
module M1(module M1) where
import Prelude

double :: Int -> Int
double x = x + x
EOF

M2는 M1을 한정해서 임포트하고, 그러지 않았다면 충돌했을 이름인 자신만의 double을 정의한다:

cat > M2.hs <<'EOF'
module M2(module M2) where
import Prelude
import qualified M1

double :: Int -> Int
double x = x * 3

main = do
  print (double 5)
  print (M1.double 5)
EOF
nix develop $MICROHS -c ./bin/uhs -ilib M2
nix develop $MICROHS -c ./bin/eval
15
10

double 5는 M2 자신의 정의로 해소된다(5 * 3 = 15). M1.double 5는 한정된 형태로만 존재하는 임포트에 명시적으로 도달한다(5 + 5 = 10). 이 가리기를 안전하게 만드는 것은 qualified 키워드다: M1의 double은 한정되지 않은 심볼 테이블에는 애초에 전혀 들어가지 않으므로(155e9f5a의 qns 규칙 — 한정 임포트는 점으로 연결된 이름만 기여한다), M2 자신의 double이 충돌할 대상이 없다. qualified를 빼고 같은 프로그램을 새 이름으로 다시 빌드해 충돌이 진짜임을 확인해 본다:

cat > M2plain.hs <<'EOF'
module M2plain(module M2plain) where
import Prelude
import M1

double :: Int -> Int
double x = x * 3

main = do
  print (double 5)
  print (M1.double 5)
EOF
nix develop $MICROHS -c ./bin/uhs -ilib M2plain
uhs: ambiguous: "double", [Var "M1.double",Var "M2plain.double"]
CallStack (from HasCallStack):
  error, called at ./Desugar.hs:73:17 in main:Desugar

같은 두 모듈, 같은 두 이름, 바뀐 것은 오직 키워드 하나뿐이다 — 0cc677d5의 스코프-검사 EVar 케이스("무슨 일이 있었나" 참고)가, 한정되지 않은 double이 M2plain.main에서 조회되어 후보를 하나가 아니라 둘 찾는 바로 그 순간 이를 일으킨다.

연습 문제 2: 가드가 있는 리스트 컴프리헨션, 디슈가된 모습

cat > Compr.hs <<'EOF'
module Compr(module Compr) where
import Prelude

main = do
  putStrLn $ showList showInt [ x*x | x <- [1,2,3,4,5], x > 2 ]
EOF
nix develop $MICROHS -c ./bin/uhs -v -ilib Compr

-v 덤프(5장의 상세 출력 플래그)는 컴파일된 모든 정의를 찍는다. Compr.main을 grep하면 이 연습 문제가 신경 쓰는 한 줄이 나온다:

Compr.main = ((Data.Function.$ System.IO.putStrLn) ((Text.String.showList Text.String.showInt) ((Data.List.concatMap (($S (($C (($C Data.Int.>) 2)) $K)) ((($C' $O) (($S Data.Int.*) $I)) $K))) (($O 1) (($O 2) (($O 3) (($O 4) (($O 5) $K))))))))

Data.List.concatMap이 Var 참조로 바로 저 자리에 앉아 있는 것 — 인라인된 람다가 아니라 라이브러리 함수 호출이기 때문에 브래킷 추상화로 없어지지 않은 것 — 이 눈에 보이게 된 0eb369f9의 Bind 규칙이다: [x*x | x <- [1..5], x > 2]는 정확히 diff의 두 번째 규칙이 말하는 대로 concatMap (\x -> ...) [1,2,3,4,5]를 거쳐 컴파일된다. 가드 자신이 무엇을 보태는지만 떼어 보려면, 가드를 뺀 같은 컴프리헨션을 컴파일해 두 컴파일된 형태를 diff한다:

cat > ComprNoGuard.hs <<'EOF'
module ComprNoGuard(module ComprNoGuard) where
import Prelude

main = do
  putStrLn $ showList showInt [ x*x | x <- [1,2,3,4,5] ]
EOF
nix develop $MICROHS -c ./bin/uhs -v -ilib ComprNoGuard
ComprNoGuard.main = ((Data.Function.$ System.IO.putStrLn) ((Text.String.showList Text.String.showInt) ((Data.List.concatMap ((($C' $O) (($S Data.Int.*) $I)) $K)) (($O 1) (($O 2) (($O 3) (($O 4) (($O 5) $K))))))))

가드 없는 본문, ((($C' $O) (($S Data.Int.*) $I)) $K) — 컴파일된 \x -> [x*x] — 는 가드가 있는 버전 안에도 바이트 하나 다르지 않게 그대로 나타나며, 한 겹의 래핑, $S (($C (($C Data.Int.>) 2)) $K) (...)만 더 둘렀을 뿐이다. 그 여분의 겹이 바로 0eb369f9의 세 번째 규칙, if x > 2 then [x*x] else []가 컴파일된 모습이다: 가드는 가드 없는 컴프리헨션이 이미 만들어 내는 것과 같은 람다 둘레에 딱 컴비네이터 한 겹만 더 두를 뿐, 그 이상은 아니다. 값을 확인해 보려면 실행한다:

nix develop $MICROHS -c ./bin/eval
[9,16,25]

3^2, 4^2, 5^2 — 1과 2는 x > 2 가드에 걸러진다. 예상대로다.

연습 문제 3: 필수 익스포트 목록, 거부되는 모습

cat > NoExport.hs <<'EOF'
module NoExport where
import Prelude

main = putStrLn "hi"
EOF
nix develop $MICROHS -c ./bin/uhs -ilib NoExport
uhs: "NoExport.hs": line 1, col 16:
   found: "where"
   expeced: white-space
   expeced: '('

CallStack (from HasCallStack):
  error, called at ./Parse.hs:124:17 in main:Parse

16열은 이미 소비된 16글자, "module NoExport "에 대한 formatFailed의 0-기반 오프셋이다 — module NoExport where에서 where가 시작하는 위치다. 1136f265:Parse.hs("무슨 일이 있었나"에 인용된)의 pModule은 모듈 이름 바로 뒤에 (를 요구하는데, 거기에 없다. 오류의 파일명 접두어("NoExport.hs":)는 그 자체로 같은 날 조금 앞서 나온 작은 기능이다: 2d818f30("Better error message.", 오류 메시지 개선)가 소스 파일명을 ParserComb.hs의 formatFailed에 실어 날라, 명령줄에 이름 붙은 모듈뿐 아니라 임포트된 어떤 모듈에서 난 실패든 어느 파일에서 왔는지 말해 준다.

정리한다:

git worktree remove --force /tmp/mhs-0eb369f9

🌐 Read in English

07 — The self-hosting push (셀프 호스팅 총력전)

현재 위치

6장은 41d6a7e8, 2023-06-07 14:13:56에서 끝난다 — 런타임의 전용 CHAR 태그가 사라지고, 모듈 시스템이 하스켈 모양을 갖췄으며(한정 임포트, as-별칭, 필수 익스포트 목록), 표층 문법이 if, 연산자 섹션, 리스트 컴프리헨션을 실어 나른다. 그 장 자체의 "왜 중요한가"가 말하듯, 이 중 어느 것도 사변적이지 않았다: 이는 "이 컴파일러가 언젠가 실제 하스켈 프로그램을 컴파일하게 만드는 것, 그리고 언젠가 이 컴파일러 자신이, 런타임이 말하는 자그마한 방언이 아니라 하스켈로 쓰인 채로"를 겨냥한 것이었다. 6장은 또한 순전한 정리 작업처럼 이름 붙은 커밋 속의 밀항자 하나를 드러냈다: 2f89971e("Move files.")는 src/MicroHs/Translate.hs를 몰래 끼워 넣었는데, 컴파일러 자신의 Exp 컴비네이터 항을 unsafeCoerce를 통해 네이티브 GHC 값으로 바꾸는 번역기의 첫, 미완성 초안이었다 — 타이핑 도중에 그대로 얼어붙은 채, 마지막 줄에 끝나지 않은 문자열 리터럴을 남기고, 아직 아무것도 임포트하지 않은 채로.

그런 다음 로그는 24일 동안 조용해진다. 이 저장소의 역사에는 그 공백을 설명하는 것이 아무것도 없다(00-overview.md 자체의 타임라인 표 참고). 41d6a7e8 이후 다음 커밋은 44004020("Small fixes.", 사소한 수정), 2023-07-01 15:41:40이며, 6분 뒤 병합 커밋 d128fb6f가 뒤따라, 06-03에 어긋난 오래된 브랜치를 다시 이어붙인다 — 6장 자체의 "현재 위치"가 그 장의 시작에 대해 서술한 것과 같은 두-체크아웃 패턴이며, 여기서 새로 재론할 것은 아니다.

로그가 재개되는 순간 정말로 바뀌는 것은 목표다. 6장은 컴파일러가 하스켈을 닮아 가는 것에 관한 이야기였다 — 모듈, Prelude, 컴프리헨션. 이 장은 컴파일러가 자기 자신이 받아들이는 방언으로 쓰인 합법적인 프로그램이 되는 것에 관한 이야기다: 타입 클래스도, deriving도, 결합성 선언도 없이, GHC가 주석이 대신 그 일을 하지 않고서는 컴파일하지 않을 만한 것은 아무것도 없이. 그 구체적이고 좁은 목표가 왜 중요한지에 관한 닭이-먼저냐-달걀이-먼저냐식 역학 — 그리고 8장이 그것들을 다 연결하고 나면 MicroHs가 이를 증명하는 데 쓰는 구체적인 make 타깃의 사다리 — 는 이 책의 새 aux 문서, bootstrapping-a-compiler.md의 몫이다. 아래의 이 장 "무슨 일이 있었나"와 나란히 읽으면 좋다. 거기서는 같은 7월의 고된 작업을 시간순으로 훑는다.

무슨 일이 있었나

41d6a7e8..a824a175 범위는 90개의 커밋(그중 하나, d128fb6f는 병합 커밋)이며, 열여드레에 걸쳐 흩어진 열하루의 날짜에 도착한다: 07-01부터 07-03까지와 07-05부터 07-07까지가 한 무더기를 이루고, 그다음 07-11에 외로운 커밋 하나, 그리고 07-15부터 07-18까지가 마무리한다 — 그 자체로 공백이 있는 모양이지, 열여드레 동안 꾸준히 타오른 것이 아니다.

귀환: 로컬 lib로 컴파일하기

돌아온 첫 저녁, 07-01은 짧고 기계적으로 열린다: 44004020("Small fixes.")는 ghc ... -o $(BIN)/uhs 빌드 줄에 -package mtl을 곧바로 추가하고 bin/*을 clean에 접어 넣는다. 6분 뒤 d128fb6f가 앞서 언급한 오래된 브랜치를 병합한다.

진짜 재출발은 같은 저녁, 그로부터 52분 뒤에 따라온다. ff3837b9 ("Allow ghc compilation with local lib.", ghc로 로컬 lib를 컴파일할 수 있게 허용)는 새 최상위 디렉터리 ghc/를 짝이 되는 lib/Primitives.hs와 함께 추가한다 — 같은 인터페이스의 구현 두 개, 청중마다 하나씩이다. lib/Primitives.hs는 uhs 자신이 읽는 방언으로, 런타임이 이미 이해하는 primitive "..." FFI 스타일 호출 위에 지어졌다(ff3837b9:lib/Primitives.hs 3–4행, 인용):

primIntAdd  = primitive "+"
primIntSub  = primitive "-"

ghc/Primitives.hs는 같은 이름들이지만 이번에는 GHC 자신의 Int와 IO 위의 진짜 GHC 함수들이다. GHC가 라이브러리를 직접 컴파일할 때를 위한 것이다(ff3837b9:ghc/Primitives.hs 10–11행, 인용):

primIntAdd :: Int -> Int -> Int
primIntAdd = (+)

lib/의 모듈들이 Primitives를 거쳐 부르는 모든 함수가 이제 두 개의 몸통을 갖게 되었고, 어느 컴파일러의 검색 경로가 먼저 찾느냐로 선택된다. 3분 뒤 2a95e71e("Allow compilation with local lib.", 로컬 lib로 컴파일할 수 있게 허용)는 새 Makefile 타깃(a.out, 나중에 이름이 바뀐다)을 연결해, lib/ 트리 전체와 Main.hs를 GHC 자신의 base 대신 ghc/ 심(shim)에 대고 컴파일하게 한다 — 이것이 aux 문서의 bootboottest가 거슬러 올라가는 "로컬 lib" 빌드다. 같은 커밋이 2f89971e가 6장에서 타이핑 도중에 멈춰 둔 것을 마침내 끝낸다: Translate.hs의 comb "IO.h — 렉싱조차 되지 않던 끝나지 않은 문자열 리터럴 — 이 진짜, 아직 부분적이긴 하지만 프리미티브-연산 표가 된다(2a95e71e:src/MicroHs/Translate.hs 60–67행, 전문 인용):

  , comb "IO.>>=" iobind
  , comb "IO.>>" iothen
  , comb "IO.return" ioret
--  , comb "IO.getChar" getc
  , comb "IO.putChar" putc
  , comb "IO.stdin" stdin
  , comb "IO.stdout" stdout
  , comb "IO.stderr" stderr

1시간 14분 뒤 fcde46f4("Make it compile.", 컴파일되게 만들기)는 네 줄짜리 Makefile 수정이다 — GHCFLAGS의 include 경로에서 빠진 $(BOOTDIR) 하나 — a.out 타깃 자신의 빌드 가능성을 되살릴 뿐, 제목이 말하는 것 이상은 없다. 그 저녁은 작고 정직하게 이름 붙은 두 개의 커밋으로 닫힌다: 27df3d65("Skip {- -} comments. Fix bugs.", {- -} 주석 건너뛰기. 버그 수정.)와, 41초 뒤 fe2ea98a("More ignore", 무시 목록 추가) — 중첩 주석 파싱 수정 하나와 .gitignore 항목 하나다. 81683158("Better Makefile", 더 나은 Makefile)가 다음 날 뒤따른다.

"uhs로 컴파일되게 만들기"

그로부터 이틀 뒤, 07-03에는 작은 리팩터 네 개짜리 연속 작업(688dfe52 "More functions.", 함수 더 추가; 4e2bb772 "Refactor symbol table handling.", 심볼 테이블 처리 리팩터; 50e30797 "Refactor symbol table creation.", 심볼 테이블 생성 리팩터; a3302100 "Make module definitions shadow imports.", 모듈 정의가 임포트를 가리게 만들기)이 새 목표를 위해 실제로 이름 붙은 첫 커밋으로 이어진다: d6c25f00 ("Make ParserComb compile with uhs.", ParserComb가 uhs로 컴파일되게 만들기), ParserComb.hs와 Parse.hs에 걸쳐 408줄이 바뀐다. 2장 자체의 aux 문서 parser-combinators.md가 상세히 훑는 Functor/Applicative/Monad/Alternative 인스턴스는 사라지고, 그 자리에는 같은 이름과 그 클래스 메서드의 예전 몸통을 가진 평범한 최상위 함수들이 들어선다. uhs에는 newtype도 없으니 newtype은 data로 강등되고, 진짜 Show 인스턴스가 있던 유일한 자리는 이제 죽은 주석으로 앉아 있다(d6c25f00:src/Text/ParserComb.hs 65–70행, 전문 인용):

data Prsr s a = P ((String, s) -> Res s a)
runP :: Prsr s a -> ((String, s) -> Res s a)
runP pp =
  case pp of
    P p -> p
--instance Show (Prsr s a) where show _ = "<<Prsr>>"

모나딕 바인드 자신은 instance Monad 메서드에서 같은 이름의 자유 함수로 옮겨 가고, 그 리스트 컴프리헨션 패턴 바인더((a, u) <- aus)는 컴프리헨션 중간의 튜플 패턴 대신 생성자 절 안의 명시적인 let-비구조화가 된다 — 이 시점의 uhs의 컴프리헨션 디슈가링은 람다 인자 하나로부터 한 번에 이름 하나만 곧바로 묶을 수 있을 뿐이다 (d6c25f00:src/Text/ParserComb.hs 75–82행, 전문 인용):

--Xinfixl 1 >>=
(>>=) :: Prsr s a -> (a -> Prsr s b) -> Prsr s b
(>>=) p k = P $ \ t ->
    case runP p t of
      Many aus plf ->
        let { xss = [ runP (k a) u | au <- aus, let { (a, u) = au } ] }
        in  case unzip [ (rs, lf) | xs <- xss, let { Many rs lf = xs } ] of
              (rss, lfs) -> Many (concat rss) (longests $ plf : lfs)

바로 위의 --Xinfixl 1 >>= 주석은 aux 문서가 전부 설명하는 그 표지다: uhs에게는 보이지 않지만, convertX.sh 전처리기가 GHC를 위해 이를 진짜 infixl 선언으로 주석을 벗겨 낸다. 이틀 뒤, f37b389f("Make it compile with uhs", uhs로 컴파일되게 만들기, Parse.hs에 397줄)는 같은 처방을 파서 본체에 반복한다 — 모든 deriving (Show)가 --Xderiving (Show)가 되고, Eq에 기댔던 elem/== 호출은 정확히 이 목적을 위해 src/Compat.hs가 제공하는 명시적이고 단형적인 elemBy/eqString 도우미로 교체된다.

같은 07-05, 조금 뒤 bd8a47d7("Make the Exp module compile with uhs.", Exp 모듈이 uhs로 컴파일되게 만들기)가 Exp.hs에 같은 일을 하며, 이번에는 커밋 자신이 본문에서 속내를 그대로 밝힌다: "This makes it quite ugly. :("(이렇게 하니 꽤 못생겨졌다. :() 2장 이래로 Exp.hs가 써 오던 PatternSynonyms 확장(pattern App2 x y z = App (App x y) z 및 그 친구들)이 통째로 주석 처리되고, 읽기 좋은 다중-절 매칭을 위해 그 패턴들에 기대던 자리마다 평범한 합타입을 돌려주는 명시적인 도우미와 그 위의 case가 자란다 (bd8a47d7:src/MicroHs/Exp.hs 19–28행, 전문 인용):

data MaybeApp = NotApp | IsApp Exp Exp

getApp :: Exp -> MaybeApp
getApp ae =
  case ae of
    Var _   -> NotApp
    App f a -> IsApp f a
    Lam _ _ -> NotApp
    Int _   -> NotApp
    Prim _  -> NotApp

2장의 최적화기가 여섯 개의 간결한 패턴 매칭 등식으로 지었던 S-컴비네이터 생성자 cS(bd8a47d7:src/MicroHs/Exp.hs 189–194행이 옛 형태를 보여 준다. 같은 diff 안에 새 형태 아래 죽은 코드로 여전히 남아 있다)는 getApp과 isPrim 호출을 둘러싼, 중첩된 if/case로 이어진 세 개의 별도 함수(cS/cS2/cS3, 합쳐서 대략 47줄)로 다시 쓰인다 — 같은 최적화이지만 코드는 훨씬 많아졌다. PatternSynonyms 생성자로 패턴 매칭하는 방법이 사라졌고, "이 표현식이 K의 적용인가"를 전체 케이스 분석을 손으로 풀어쓰지 않고 물을 다른 방법이 없기 때문이다. 세 커밋, 하나의 맥락: 여기서 바뀌는 것 중 컴파일러가 하는 일을 바꾸는 것은 없다 — 모든 재작성이 동작을 보존한다 — 오직 그것을 말하도록 허락된 어휘만 바뀔 뿐이다.

515664d1: unsafeCoerce가 제대로 동작하도록 IO를 감싸기

d6c25f00가 도착한 같은 날 늦게 — 아홉 시간 뒤, 그날 저녁 21:28의 바로 다음 커밋 — 515664d1("Make unsafeCoerce work by wrapping IO in a data constructor.", 데이터 생성자로 IO를 감싸 unsafeCoerce가 동작하게 만들기)는 더 좁은 문제 하나를 고친다: Translate.hs의 trans 함수는 컴파일된 모든 컴비네이터 항을 unsafeCoerce :: Exp -> Any를 거쳐 GHC 값으로 바꾸는데, GHC의 최적화기는 맨몸으로 강제 변환된 IO a 액션을 평범한 값과 다르게 다룰 자유가 있다 — 강제 변환 경계를 넘어서도 실제로 평가되지 않은 IO 액션인 채로 남아 있게 강제하는 것은 아무것도 없다. 그 수정은 GHC가 붙잡을 구체적인 무언가를 주는, 생성자 하나짜리 래퍼다(515664d1:src/MicroHs/Translate.hs 14행, 그 사용부 7줄 뒤와 함께 인용):

data DIO a = DIO { unDIO :: IO a }
  in  unDIO $ unsafeCoerce $ look mainName

"Running IO still doesn't work. :(" (IO 실행은 여전히 안 된다. :()

515664d1 15분 뒤, 6f08f029는 좌절감을 커밋 제목 그대로 실어 나른다: "Running IO still doesn't work. :(". 제목과 달리 diff는 그저 어깨를 으쓱하는 것이 아니다 — 515664d1의 새 DIO 래퍼가 절반만 끝내 둔 세 함수에 대한 진짜 수정이다. 이 커밋 이전에는 iobind a k = unDIO a >>= k가 평범한 GHC IO 액션의 결과를, a -> DIO b로 타입 지정된 연속으로 곧바로 바인드하려 하는데, 쓰인 그대로는 타입 검사를 통과하지 못한다. 6f08f029는 세 컴비네이터 모두를 DIO를 일관되게 벗기고 다시 씌우도록 고친다 (6f08f029:src/MicroHs/Translate.hs 79–84행, 전문 인용):

    iobind :: DIO a -> (a -> DIO b) -> DIO b
    iobind a k = DIO (unDIO a >>= \ x -> unDIO (k x))
    iothen :: DIO a -> DIO b -> DIO b
    iothen a b = DIO (unDIO a >> unDIO b)
    ioret :: a -> DIO a
    ioret a = DIO (return a)

패배처럼 읽히는 제목 아래 놓인 진짜 정확성 수정이다 — 슬픈 얼굴은 이 diff 하나의 품질이 아니라 전체적인 상태(다운스트림의 무언가는 여전히 돌아가지 않는다)에 대해 정직한 것이다.

6f08f029 뒤에도 여전히 무엇이 깨져 있었는지는 다음 세션까지 기다린다: 07-04에는 아무것도 도착하지 않고, 다음 열여섯 개의 커밋 — 67715b16("Add 'not'", 'not' 추가)부터 c275f468("More testing", 테스트 추가)까지 — 은 전부 이틀 뒤, 07-05의 12:22:03부터 12:58:43까지 36분짜리 창 안의 타임스탬프를 지닌다. 그 창 안에 b808bc73("Fix IO primitives.", IO 프리미티브 수정)가 있으며, 이곳이 실제 IO 수정이 사는 자리다. 버그는 프리미티브 경계를 가로지르는 관례의 불일치다. lib/System/IO.hs의 hGetChar 래퍼는 P.primHGetChar를 통해 문자 하나를 읽고 파일-끝 보초값을 검사한다(1abb181e:lib/System/IO.hs 25–30행, 전문 인용 — 1abb181e는 b808bc73의 부모다):

hGetChar :: Handle -> IO Char
hGetChar h = do
  c <- P.primHGetChar h
  case ord c == negate 1 of
    False -> return c
    True  -> error "hGetChar: EOF"

uhs 자신의 런타임 아래에서는 그 관례가 통한다: ord는 항등 함수이고(6장의 CHAR 제거가 그렇게 만들었다), eval.c의 IO.getChar는 정말로 파일 끝에서 -1을 돌려준다. 하지만 ghc/Primitives.hs의 심은 그저 GHC 자신의 함수들에 별칭을 붙였을 뿐이고 — 둘은 아예 쓰지 않은 채로 남겨 두었다 (1abb181e:ghc/Primitives.hs 66–69행, 전문 인용):

primHPutChar      = hPutChar
primHGetChar      = hGetChar
primOpenFile      = undefined
primIsNullHandle  = undefined

GHC의 hGetChar는 결코 보초값을 돌려주지 않는다 — 파일 끝에서 예외를 던진다 — 그래서 래퍼의 ord c == negate 1 가지는 GHC 빌드 아래에서는 결코 발동할 수 없었고(거기서는 어떤 Char의 ord도 음수가 아니다), primOpenFile은 ff3837b9가 07-01에 처음 이를 쓴 이래로 계속 undefined였다. 코드는 양쪽 경로 모두에서 타입은 잘 맞는다. 맞지 않는 것은 심의 의미가 런타임의 계약과 어긋난다는 것이다. b808bc73는 그 심이 uhs 런타임이 말하는 것과 같은 관례를 따르도록 만든다 — EOF에서 -1을 갖는 Int 문자 코드, hIsEOF로 명시적으로 검사되는(b808bc73:ghc/Primitives.hs 66–71행, 전문 인용):

primHPutChar     :: Handle -> Int -> IO ()
primHPutChar h c  = hPutChar h (toEnum c)
primHGetChar     :: Handle -> IO Int
primHGetChar h    = do eof <- hIsEOF h; if eof then pure (-1) else fromEnum <$> hGetChar h
primOpenFile     :: String -> Int -> IO Handle
primOpenFile s m  = openFile s (case m of 0->ReadMode; 1->WriteMode; 2->AppendMode; 3->ReadWriteMode)

그리고 같은 커밋에서 lib/System/IO.hs의 래퍼들도 맞춰 조정한다: 보초값 검사는 맨 Int 위에서 c == negate 1이 되고, 경계에서 명시적인 chr/ord 변환이 이루어진다 — b808bc73:lib/System/IO.hs 31행, 전문 인용: hPutChar h c = P.primHPutChar h (ord c). 6f08f029와 달리 이 제목은 자신의 diff와 정확히 들어맞는다 — 경계 양쪽 모두에서 IO 프리미티브를 고치고, 그 외에는 아무것도 몰래 끼워 넣지 않는다.

다이어트

같은 07-05의 몰아치기 안에서 aux 문서가 직접 이름 붙인 "다이어트"의 나머지 세 줄기가 마무리된다. a721e046("Use Int instead of Integer", Integer 대신 Int 사용)는 Expr의 EInt 필드를 Integer에서 Int로 좁히고(a721e046:src/MicroHs/Parse.hs 49행: | EInt Int) 디슈가러에서 더는 필요 없어진 fromInteger 호출을 없앤다 (a721e046:src/MicroHs/Desugar.hs 104행: dsExpr _ (EInt i) = Int i). 2ff2a2e9("More s/Integer/Int/", Integer/Int 치환 추가)가 같은 날 Parse.hs에 남은 자리 하나를 마저 닦는다. f113210f("Use our own Map.", 우리 자신의 Map 사용)는 qualified Data.Map을 새 src/MicroHs/Map.hs로 대체한다 — Ord 제약 대신 동등성 검사를 평범한 함수 인자로 받는 단순 연관 리스트 래퍼로, 타입 옆의 주석에서 자신의 한계를 스스럼없이 밝힌다(f113210f:src/MicroHs/Map.hs 1–33행, 전문 인용):

module MicroHs.Map(module MicroHs.Map) where
import Prelude
--Ximport Compat

-- This is a pretty bad implementation.
data Map k v = Map [(k, v)]
  --Xderiving(Show)

insert :: k -> v -> Map k v -> Map k v
insert k v (Map kvs) = Map ((k, v):kvs)

fromListWith :: (k -> k -> Bool) -> (v -> v -> v) -> [(k, v)] -> Map k v
fromListWith eq un =
  let
    ins ikv akvs =
      case akvs of
        [] -> [ikv]
        kv : kvs ->
          if eq (fst ikv) (fst kv) then
            (fst ikv, un (snd ikv) (snd kv)) : kvs
          else
            kv : ins ikv kvs
  in
     Map . foldr ins []

fromList :: [(k, v)] -> Map k v
fromList = Map

union :: Map k v -> Map k v -> Map k v
union (Map kvs1) (Map kvs2) = Map (kvs1 ++ kvs2)

lookup :: (k -> k -> Bool) -> k -> Map k v -> Maybe v
lookup eq k (Map kvs) = lookupBy eq k kvs

그리고 결합성과 우선순위: 같은 몰아치기 안의 세 커밋 — edfb9bde("Add fixties", 결합성 선언 추가), 1200d1a3/56ad54ae (둘 다 "--Yinfix") — 이 lib/Data/Bool.hs, lib/Data/Int.hs, lib/Data/Function.hs에 --Yinfixr/--Yinfixl/--Yinfix 주석을 뿌린다(edfb9bde:lib/Data/Bool.hs 7–8행, 인용):

--Yinfixr 2 ||
(||) :: Bool -> Bool -> Bool

이들은 uhs가 전혀 읽지 않는다 — uhs 자신의 파서는 모든 연산자의 우선순위를, 라이브러리 소스 안의 선언이 아니라 pExpr 안의 고정된, 손으로 쓴 사다리에서 해소한다(이 범위의 어떤 커밋도 이를 바꾸지 않는다). --Y 표지가 존재하는 것은 순전히 다른 GHC 빌드 — 2a95e71e가 마련한 "로컬 lib" 경로, convertY.sh 전처리기를 통해 lib/Data/Bool.hs를 GHC 아래에서 직접 컴파일하는 경로 — 가 진짜 infixr 2 ||를 보고 (||) 자신의 정의와 모든 호출부를 라이브러리 저자가 의도한 대로 파싱하게 하기 위해서다. 왜 두 개의 별도 GHC 빌드를 위해 두 개의 별도 표지(--X/--Y)가 존재하는지를 포함한 전체 메커니즘은 aux 문서의 주제이며, 여기서 되풀이하지 않는다.

59b8b1ab: 그저 이름 바꾸기만은 아닌 이름 바꾸기

같은 16개 커밋, 36분짜리 몰아치기(위의 67715b16부터 c275f468까지) 안에서, 59b8b1ab는 "Fix name"(이름 고치기)이라는 제목으로 12:57:39에 도착한다 — 마침표 없이. 진짜로 이름 바꾸기를 담고 있다 — lib/PreludeNOIO.hs가 lib/PreludeNoIO.hs가 되어, 일관되지 않던 대소문자를 바로잡는다. 하지만 같은 커밋은 제목이 전혀 언급하지 않는 파일 세 개도 추가한다: A.hs(9줄, +/-에 대한 연산자 섹션 테스트), Test.hs(39줄, 나중에 프로젝트의 버려도 그만인 Main.hs 스크래치 하네스가 되는 것의 초기 조상), 그리고 t.hs(2줄, Test.hs가 읽는 픽스처). 새 하스켈 50줄, 새 파일 셋, 파일 하나의 대소문자에 관한 두 단어짜리 제목 아래.

8ab69cb0: 스쿼시되지 못한 squash!

1시간 9분 뒤, 같은 날 오후의 다음 무더기(13:22:18–14:07:11) 안에서 46c4309f("infixr 9 .")가 Data.Function에 --Yinfixr 9 . 주석을 하나 더 추가한다, 14:07:00. 그로부터 11초 뒤, 8ab69cb0의 커밋 제목은 squash! infixr 9 .다 — 커밋을(여기서는 46c4309f) 나중에 git rebase --autosquash로 접어 넣기 위한, 깃 자신의 관례다. 그 리베이스는 결코 실행되지 않았다: 8ab69cb0는 자기 자신이 접혀 들어가야 했을 커밋 11초 뒤에, 이 저장소의 역사 안에 영원히 자신만의 커밋으로 앉아 있다.

이 커밋의 diff는 infixr 9 .와는 아무 관련이 없다. 파일 여섯 개가 바뀐다: 새 alltest Makefile 타깃; lib/Data/List.hs의 새 enumFrom; src/Compat.hs의 새 도우미 두 개; src/Text/ParserComb.hs 에서 지워진 죽은 --X 주석 줄 네 개; deriving이 벗겨지고 case로 다시 쓰인 Exp.hs(두 커밋 뒤 bd8a47d7가 파일 나머지에 주는 것과 같은 "uhs로 컴파일되게 만들기" 처방); 그리고 src/MicroHs/Main.hs 안에서, Lbl Int Exp 생성자의 마지막 쓰임이 조용히 사라진다 — 이름 붙은 정의를 감싸던 래퍼 App2 CT (Lbl i (subst e)) r이 손으로 지은 문자열이 되고, 옛 호출은 대체물 바로 옆에 죽은 주석으로 남겨진다 (8ab69cb0:src/MicroHs/Main.hs 31–33행, 전문 인용):

      def :: ((Ident, Exp), Int) -> String -> String
      def ((_, e), i) r = "(($T :" ++ showInt i ++ " " ++ toStringP (subst e) ++ ") " ++ r ++ ")"
        -- App2 CT (Lbl i (subst e)) r

데이터 생성자 하나 전체가 은퇴한다, 자신이 결코 병합되지도 않은 관련 없는 한 줄짜리 결합성 주석의 이름을 단 커밋 안에서 — 6장이 2f89971e의 밀항자 Translate.hs에서 끌어냈던 것과 같은 교훈이다: 제목이 아니라 diff를 읽으라.

마지막 두 주

이 범위는 더 길고 더 흩어진 구간으로 닫힌다: 07-06과 07-07은 getArgs 준비 작업과 패턴 매칭 정리, 6b5741e1("Get rid of PTuple constructor.", PTuple 생성자 없애기)를 가져온다. 그런 다음 조용한 나흘, 그 사이를 깨는 외로운 커밋 하나, 07-11의 4ae4fd20("Add copyright", 저작권 추가) — BSD 스타일 LICENSE 파일을 추가한다 — 그리고 다시 조용한 나흘 뒤 07-15가 a358eaa6("Add copyright.")로 재개되어, 27개의 소스 파일에 두 줄짜리 헤더를 찍고, 35분 뒤 52ee83e2("Start of complex patterns.", 복합 패턴의 시작)가 — 07-07의 정리 이후 재개된 패턴 매칭 작업 — 뒤따르며, 다시 28분 뒤 d514d2d9가 프로젝트 전체를 아파치 라이선스로 바꾼다 — 같은 오후에 저작권 정리와 패턴 매칭 작업이 뒤섞인다. 07-16과 07-17은 이름이 그대로 말해 주는 "uhs로 컴파일되게 만들기" 연작을 더 갈아 낸다(a36ca755 "Make Desugar compile with uhs", Desugar가 uhs로 컴파일되게 만들기; 7e7c18d1 "Make Compile module work with uhs", Compile 모듈이 uhs와 동작하게 만들기; 5e462798 "Make Main work with uhs.", Main이 uhs와 동작하게 만들기), getArgs 지원, 그리고 — 정직하게 이름 붙었고 diff에 놀랄 것이 없는 — e61ea002("A hack to make openFile compatible with uhs.", openFile을 uhs와 맞게 만드는 편법). 이 범위의 마지막 커밋, a824a175("Check for stack overflow.", 스택 오버플로 검사), 07-18 15:04:09는 C 런타임의 고정 크기 스택 배열을 새 -K 플래그로 크기를 정하는 malloc된 배열로 바꾼다 — aux 문서의 bootcombtest 타깃이 컴파일러 자신의 방대한 컴비네이터 파일을 인터프리트하고 살아남기 위해 부르는 바로 그 플래그다(-K1000000). 그 타깃이 실제로 실행되는 모습이 어떤지는 8장의 이야기다.

왜 중요한가

셀프 호스팅은 유별나게 날카로운 피드백 신호를 지닌 강제 함수다: 컴파일러 자신의 기능 집합의 공백은 누군가 생각해서 써야 하는 빠뜨린 테스트 케이스로 드러나지 않는다. 그 공백이 컴파일러와 자기 자신 사이에 놓이는 순간 "내 컴파일러 자신의 소스가 컴파일되지 않는다"로 드러난다. 이 장이 훑는 모든 줄기 — 타입 클래스 없음, deriving 없음, 손으로 만든 Map, Integer 대신 Int, 선언이 아니라 고정된 파서 사다리로 해소되는 결합성과 우선순위 — 는 컴파일러 자신의 소스가 필요로 했지만 갖고 있지 않던 것이며, 미리 계획된 것이 아니라 한 번에 하나씩 "uhs로 컴파일되게 만들기" 커밋으로 발견된 것이다. TODO 목록은 저절로 쓰인다: 컴파일러를 자기 자신의 3천 줄 남짓(이 범위가 끝날 때 src//lib를 합친 .hs 파일만)에 겨눠 보고 무엇이 깨지는지 지켜보는 것.

실험 노트 여백

  • "More functions."(함수 더 추가)(와 마침표 없는 쌍둥이, "More functions")는 이 범위의 90개 커밋 중 13개의 별개 커밋을 연다 — 6장이 처음 이름 붙인, 몰아치기 자신의 리듬이다.
  • 8ab69cb0의 제목, squash! infixr 9 .는 "이 제목을 가진 커밋에 git rebase --autosquash로 나를 접어 넣어 달라"는 깃 자신의 관례다 — 아무도 실행하지 않은 요청이다. 여전히 그 자리에, 자신이 안으로 사라지길 요청했던 커밋 11초 뒤에, 자신만의 영구적인 커밋으로 앉아 있다.
  • 67715b16부터 c275f468까지의 16개 커밋은 전부 07-05 하루, 36분짜리 창(12:22:03–12:58:43) 안에 타임스탬프가 있다 — 이 장의 범위에서 가장 빽빽한 무더기이며, 마침 진짜 IO 수정(b808bc73)과 위에서 말한, 밝히지 않은 여분을 담은 두 커밋 중 하나(59b8b1ab, 12:57:39)를 함께 담고 있다. 다른 하나, 8ab69cb0은 같은 날 오후 바로 다음 무더기(13:22:18–14:07:11)에 앉아 있다.
  • 저작권 줄기는 나흘 간격으로 두 개의 라이선스 파일을 거쳤다: 07-11의 BSD 스타일 LICENSE(4ae4fd20), 그리고 07-15의 아파치 라이선스로의 전환(d514d2d9), 27개 파일에 저작권 헤더가 찍힌 a358eaa6 64분 뒤다.

직접 해보기

닻: f113210f("Use our own Map.", 우리 자신의 Map 사용), 2023-07-05 20:55:37 — 그날의 세 번째, 늦은 저녁 세션(6c9d021f부터 f113210f까지, 19:34–20:55)의 마지막 커밋이고, bd8a47d7의 Exp-모듈 재작성 바로 뒤이자 다음 날의 getArgs 작업 이전에 자리하며, — 짐작이 아니라 직접 확인한 대로 — 쓰인 그대로 말끔히 빌드되기 때문에 골랐다. 관례(00-overview.md 참고):

git worktree add /tmp/mhs-f113210f f113210f
cd /tmp/mhs-f113210f

이 커밋의 Makefile은 aux 문서에 설명된 convertX.sh 전처리기를 거쳐 src/*/*.hs로부터 $(BIN)/uhs를, src/runtime/eval.c로부터 $(BIN)/eval을 빌드한다 — 아래에서 둘 다 쓰인 그대로 빌드하고 실행한다, 패치 없이:

mkdir -p bin
nix develop $MICROHS -c make bin/uhs
[...]
[10 of 11] Compiling Main             ( src/MicroHs/Main.hs, ghc-out/Main.o )
[11 of 11] Linking bin/uhs

(head/tail에 대한 -Wx-partial 경고 한 무더기, 호출부마다 하나씩 생략 — 앞선 장들의 빌드 노트가 이미 짚어 둔 것과 같은 무해한 부류다.)

nix develop $MICROHS -c make bin/eval
gcc -Wall -O3 src/runtime/eval.c -o bin/eval

연습 문제 1: ghc로 빌드한 uhs를 자기 자신의 소스 모듈 하나에 대해 실행해 보기

이 커밋의 최상위 Main.hs는 프로젝트 자신의 스크래치 하네스다 — 컴파일러의 실제 소스 모듈 중 하나인 src/MicroHs/ 아래의 MicroHs.Parse를 임포트하고, 작은 픽스처 파일 t.hs를 그것을 통해 다시 파싱한다. 이를 컴파일하려면 -ilib(Prelude용)와 -isrc(MicroHs.Parse 자신용) 둘 다 필요하다 — 정확히 Makefile 자체의 uhstest 타깃이 하는 대로다:

nix develop $MICROHS -c ./bin/uhs -ilib -isrc Main

이는 조용히 끝나며 out.comb를 쓴다. 파일을 다듬어 본 모습(한 줄이며, 이 빌드에서는 32590바이트다. 머리와 꼬리만 보이고, 그 사이 전부를 ...로 표시했다):

(($T :0 _198) (($T :1 (_0 (($O 117) (($O 110) (($O 100) (($O 101) (($O 102) ...
...
))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))

bin/eval로 실행해 보면 전체 파이프라인이 — GHC로 빌드한 uhs가 진짜 컴파일러 소스 파일을 임포트하는 모듈을 컴파일해, eval이 실행할 수 있는 .comb 파일을 만들어 내는 것이 — 이 커밋 시점에 정말로 동작함을 확인해 준다:

nix develop $MICROHS -c ./bin/eval
Start
File read: t.hs

module T(
module T

) where

이것은 Main.hs 자신의 putStrLn들("Start", 그다음 파일-읽음 메시지)에 이어, t.hs의 한 줄짜리 모듈 module T(module T) where를 showEModule이 렌더링한 것이다 — MicroHs.Parse가 파싱하고, GHC로 빌드한 uhs가 컴파일하고, eval이 실행한다.

연습 문제 2: src/에 남아 있는 타입 클래스 사용

nix develop $MICROHS -c grep -rn "class \|instance " src/
src/Text/ParserComb.hs:67:--instance Show (Prsr s a) where show _ = "<<Prsr>>"

src/MicroHs/와 src/Text/ParserComb.hs를 합친 모든 .hs 파일에서 단 한 건, 그마저도 주석 처리되어 있다 — d6c25f00가 ParserComb.hs의 인스턴스들을 평범한 함수로 다시 쓰면서 남긴 화석이다 ("무슨 일이 있었나"에 인용). 이 커밋 시점 컴파일러 자신의 소스 어디에도 살아 있는 class 선언도, 살아 있는 instance 선언도 하나도 없다. lib/(uhs 자신이 프로그램을 컴파일할 때 대는 라이브러리)에 대해서도 같은 검색을 해 보면 이 역시 비어 있다 — 죽은 주석조차 없다.

연습 문제 3: convertX.sh/convertY.sh가 무엇을 감춰 주는가

cat convertX.sh
cat convertY.sh
#!/bin/sh
( echo "{-# LINE 1 \"$1\" #-}" ; sed -e 's/--X//' $2 ) > $3
#!/bin/sh
( echo "{-# LINE 1 \"$1\" #-}" ; sed -e 's/--Y//' $2 ) > $3

각각 두 줄이며, 문자 하나만 빼면 똑같다: 파일의 모든 줄에서 표지 (--X 또는 --Y)를 벗겨 낸다. 다만 그전에 GHC 자신의 오류 메시지가 여전히 원래 파일과 줄 번호를 가리키도록 {-# LINE #-} 프래그마를 찍는다. 이들이 감춰 주는 공백은 uhs의 하스켈에는 말할 방법이 없지만 GHC의 하스켈에는 필요한 모든 것이다: import Prelude에 붙는 진짜 hiding 절(그래야 GHC 자신의 Monad/Applicative 메서드가 이 프로젝트가 손으로 만든 >>=/pure와 충돌하지 않는다), 진짜 infixl/infixr/infix 선언(그래야 GHC의 파서가 (||) 같은 연산자에 그 정의가 가정하는 우선순위를 준다), 진짜 deriving (Show) 절. 이 장의 소스 안에서는 이들 하나하나가 평범한 두 글자 접두어가 붙은 주석으로 쓰여 있다 — uhs 자신의 파서에게는 보이지 않으며, 그저 --를 보고 읽기를 멈춘다 — 그리고 이 두 스크립트 중 하나가 GHC 자신의 전처리기로 먼저 실행될 때만 다시 살아 있는 코드로 돌아간다.

정리한다:

git worktree remove --force /tmp/mhs-f113210f

🌐 Read in English

08 — Bootstrap (부트스트랩)

현재 위치

7장은 a824a175("Check for stack overflow.", 스택 오버플로 검사), 2023-07-18 15:04:09에서 끝난다 — 컴파일러 자신의 소스는 모듈 단위로 하나씩, 스스로 읽어낼 수 있는 좁은 방언 안으로 압축되어 있다: 타입 클래스도, deriving도, 결합성과 우선순위 선언도 없고, 손으로 짠 Map, Integer 대신 Int를 쓴다. 이 마지막 커밋은 C 런타임의 고정 크기 인터프리터 스택 배열을, 새 -K 플래그로 크기를 정하는 malloc 할당 배열로 바꾼다. 3분 20초 뒤, 456ce17c가 도착한다. 이 두 커밋과 그 이전 모든 커밋이 쌓아 올린 사다리 — GHC가 컴파일러를 컴파일하고, 그 컴파일러가 스스로를 컴파일하고, C 런타임이 그 셀프 컴파일 결과를 실행하고, 바이트 단위 cmp가 두 결과가 일치하는지 확인하는 것 — 은 aux/bootstrapping-a-compiler.md의 주제 전체이며, 먼저 그것부터 읽어야 한다. 이 장은 그 사다리가 제 몫의 make 타깃을 얻고 누군가 실제로 그것을 오르는 순간 벌어지는 일을 서술한다.

무슨 일이 있었나

a824a175..298c9824 범위는 커밋 28개이며, 전부 Lennart Augustsson이 작성했고, 이틀 반에 걸쳐 있다: 07-18에 19개(15:07–23:19), 07-19에 4개(13:11–19:29), 07-20에 5개(11:20–14:58).

456ce17c: 이정표

456ce17c("Add bootstrap test.", 부트스트랩 테스트 추가), 15:07:29는 새 Makefile 타깃 하나를 추가한다 — 네 줄이다(456ce17c:Makefile 66–69행, 전문 인용):

bootcombtest:	$(BIN)/uhs uhs.comb
	$(BIN)/uhs -ilib -isrc -omain-uhs.comb  MicroHs.Main
	$(BIN)/eval -H10000000 -K1000000 -ruhs.comb --  -ilib -isrc -omain-comb.comb MicroHs.Main
	cmp main-uhs.comb main-comb.comb

첫 줄은 GHC로 빌드한 bin/uhs가 MicroHs.Main — 컴파일러 자신의 진입점이자, 그것이 임포트하는 src/MicroHs/lib 트리 전체 — 를 곧바로 main-uhs.comb로 컴파일하게 한다. 둘째 줄은 uhs.comb(별도의, 이미 빌드된 타깃: bin/uhs가 스스로를 컴비네이터 파일로 컴파일한 것)를 C 런타임 bin/eval에 넘기고, 그 컴비네이터 파일을 프로그램으로 실행하되 -- 뒤에 동일한 컴파일러 인자(-ilib -isrc -omain-comb.comb MicroHs.Main)를 붙이라고 지시한다. uhs.comb는 uhs를 eval이 이미 실행할 줄 아는 컴비네이터 항으로 옮긴 것 — 2장의 .comb 형식이며, 그 이후로 모양이 바뀌지 않았다. 그러므로 둘째 줄은 인터프리트된 컴파일러가 같은 소스를 두 번째로 컴파일해 main-comb.comb를 쓰는 것이다. 셋째 줄은 오라클이다: 특별 취급 없는 순수한 바이트 동등성 검사다. 네이티브로, GHC로 빌드한 컴파일러와, 컴비네이터로 옮겨져 C 인터프리터가 실행한 바로 그 컴파일러가, 자신의 소스를 컴파일해 같은 출력을 낸다면 그 사다리는 견고하다. cmp가 (어떤 의미론적 검사가 아니라) 왜 옳은 오라클(정답 판별 기준)인지, 그리고 병행하는 "local lib" 단인 bootboottest에 대한 완전한 설명은 aux 문서의 몫이며, 이 장이 할 일은 bootcombtest가 실제로 도는 것을 지켜보는 것이다.

fc7a2ad7 / f06bacb7 / 1876f4c1: 컴파일러가 컴파일러를 컴파일하는 작업 부하의 실상

이전 모든 장의 "직접 해보기"가 하듯 짧은 테스트 파일 몇 개만으로 컴파일러를 돌리는 것과, 컴파일러를 자기 자신에게 돌리는 것 — src/와 lib/ 아래 .hs 파일 30개, 이 커밋 시점 합쳐서 73,856바이트(git ls-tree -r -l 456ce17c -- src lib로 센 값) — 는 서로 다른 작업 부하이며, 그 차이는 한 시간 안에 드러난다.

fc7a2ad7("Add comments.", 주석 추가), 15:36:42는 대체로 제목 그대로다: eval.c의 컴비네이터 case 옆에 붙는 짧은 /* ... */ 주석들(/* K x y = * x */ 같은 것들)이다. 하지만 추가된 줄 중 세 줄은, IO_BIND case 바로 위에 놓여 있는데, 장식이 아니다 — 실제로 예상되는 문제를 겨냥해 적어 둔 TODO 메모다(fc7a2ad7:src/runtime/eval.c 977–979행, 전문 인용):

      /* XXX could use associativity to avoid deep evalio recursion. */
      /* (m >>= g) >>= h    ===  m >>= (\ x -> g x >>= h) */
      /* BIND (BIND m g) h  ===  BIND m (\ x -> BIND (g x) h) == BIND m (C' BIND g h)*/

35분 뒤, f06bacb7("Commented out non-working alternative.", 동작하지 않는 대안을 주석 처리), 16:12:11은 수정을 시도한다 — 런타임이 아니라 라이브러리에서다. lib/System/IO.hs의 hGetContents는 파일 핸들을 한 글자씩 읽으며, 그 재귀 케이스는 평범한 do-바인드 >>=다: 한 글자를 읽을 때마다 파일의 나머지 부분을 감싸는 IO.>>=가 하나씩 더 중첩된다(f06bacb7:lib/System/IO.hs 126–135행, 전문 인용):

-- Strict hGetContents
hGetContents :: Handle -> IO String
hGetContents h = do
  c <- P.primHGetChar h
  case c == negate 1 of
    False ->
      do { cs <- hGetContents h; return (chr c:cs) }
      -- This should use less stack, but it doesn't work. :(
      --return (chr c : P.primPerformIO (hGetContents h))
    True  -> return ""

이곳이 정확히 위의 TODO 주석이 걱정하던 그 지점이다: src/MicroHs/Compile.hs의 readFilePath는 컴파일러가 컴파일하는 모든 모듈을 읽을 때 IO.hGetContents를 호출하므로, 문자 N개짜리 소스 파일은 그중 하나라도 실행되기 전에 N개가 중첩된 >>= 사슬을 만들어낸다. 주석 처리된 줄은 재귀적인 do-바인드 대신 P.primPerformIO로 이를 우회하려 하지만, 주석은 그것이 동작하지 않는다고 솔직히 적어 두었고, 그 시도는 지우지 않은 채 죽은 코드로 그 자리에 남는다.

그로부터 21초 뒤, 1876f4c1("Use associativity of IO.>>= to avoid deep evalio recursion.", IO.>>=의 결합법칙을 이용해 evalio의 깊은 재귀를 피하기), 16:12:32는 실제 메커니즘을 고친다 — TODO 주석이 있어야 한다고 말했던 바로 그 런타임에서다. 이 커밋 이전에는 evalio의 IO_BIND case가 m >>= g를 처리할 때 m에 대해 evalio를 재귀적으로 호출했다 — 그래서 왼쪽으로 중첩된 사슬 (m >>= g) >>= h의 경우, 바깥 바인드를 평가하는 것은 안쪽 것을 평가하기 위한 중첩된 C 함수 호출을 뜻했고, 사슬 안의 바인드 하나당 네이티브 스택 프레임이 하나씩 든다. 문자 하나를 손으로 읽으면 바인드 하나가 생기고, 소스 파일은 문자 수만큼의 중첩된 바인드를 만들어내는데, 이 재귀는 a824a175가 방금 키울 수 있게 만든 인터프리터 자신의 -K 크기 NODEPTR 스택이 아니라 C 프로세스 자신의 콜 스택에서 실행된다 — 그런 플래그가 없는 별개의 스택이다. 1876f4c1의 수정은 fc7a2ad7의 TODO 주석이 이미 이름 붙였던 대수 법칙 — (m >>= g) >>= h === m >>= (\x -> g x >>= h) — 을 이용해 그 항을 재귀로 파고드는 대신 다시 쓰고, 중첩 호출을 만드는 대신 goto top으로 평가기의 맨 위로 되돌아간다(1876f4c1:src/runtime/eval.c 980–998행, 전문 인용):

    case IO_BIND:
      CHECKIO(2);
      {
        /* Use associativity to avoid deep evalio recursion. */
        /* (m >>= g) >>= h      ===  m >>= (\ x -> g x >>= h) */
        /* BIND ((BIND m) g) h  ===  BIND m (\ x -> BIND (g x) h) == (BIND m) (((C' BIND) g) h)*/
        NODEPTR bm;
        NODEPTR bmg = evali(ARG(TOP(1)));
        GCCHECKSAVE(bmg, 4);
        if (TAG(bmg) == AP && TAG(bm = indir(FUN(bmg))) == AP && TAG(indir(FUN(bm))) == IO_BIND) {
          NODEPTR g = ARG(bmg);
          NODEPTR h = ARG(TOP(2));
          n = new_ap(bm, new_ap(new_ap(new_ap(combCC, combIOBIND), g), h));
          POP(3);
          goto top;
        }
      }

      x = evalio(ARG(TOP(1)));  /* first argument, unwrapped */

정확히 짚을 필요가 있다: 이것은 >>=가 틀린 답을 낸다는 보고가 아니며, 어떤 바인드가 계산하는 값도 전혀 바뀌지 않는다 — 모든 m >>= g는 여전히 m의 결과를 가지고 g의 계속(continuation)을 순서대로 실행하는 것으로 끝난다. 바뀌는 것은 왼쪽으로 중첩된 바인드 사슬을 평가하는 데 네이티브 스택 프레임이 몇 개나 드는가다: 이 커밋 이전에는 무한정 늘어나고(사슬이 깊을수록 바인드 하나당 하나씩), 이 커밋 이후에는 인터프리터 루프가 가능할 때마다 재귀 대신 반복하므로 일정하다. 이로써 닫히는 버그는 실제 소스 파일을 대상으로 한 글자씩 읽는 파일 리더 아래에서의 스택 깊이 위험이다 — 컴파일러가 스스로를 컴파일하는 것은, 이 위험을 실체화할 만큼 긴 IO 사슬을 만들어내는 이 프로젝트 역사상 첫 작업 부하다.

5d58461f: 정직하게 더 큰 정수로

결합법칙 수정 뒤 6분 48초, 5d58461f("Use bigger ints.", 더 큰 정수 쓰기), 16:19:20은 사용자 프로그램이 보는 하스켈 Int 타입에 관한 것이 아니다 — 그것은 이미 6/7장의 다이어트에서 머신 폭으로 좁혀져 있었다. 대신 런타임 자신의 집계 카운터들을 int에서 int64_t로 넓힌다: num_reductions, num_gc, num_marked, max_num_marked, start_size, 그리고 init_nodes, scan, gc, parse_top 안의 힙 인덱싱 루프 변수 여럿이다(5d58461f:src/runtime/eval.c 121–122행, 인용):

int64_t num_reductions = 0;
int64_t num_gc = 0;

테스트 프로그램의 축약 횟수와 GC 횟수는 32비트 안에 여유 있게 들어간다. 자기 소스 수천 줄을 컴파일하는 컴파일러는 그 여유 안에 오래 머물지 못한다. 프로그램 의미론에서 사용자에게 보이는 것은 아무것도 바뀌지 않는다 — 런타임 자신의 통계가 조용히 오버플로되기 전까지 얼마나 멀리 셀 수 있는가만 바뀔 뿐이다.

1a84b483: 힙이 2%까지 줄면 포기하기

6ca78e8a("Cleanup", 정리), 23:16:14, 그리고 8초 뒤 1a84b483 ("Give up when heap is down to 2%", 힙이 2%까지 줄면 포기), 23:16:22가 같은 긴 저녁의 끝자락에 도착한다. GC의 gc() 함수는 이미 마크-스윕 패스마다 몇 개의 셀이 살아남았는지 추적하고 있었다; 이 커밋은 그 수를 알게 된 직후에 검사 하나를 추가한다 (1a84b483:src/runtime/eval.c 334–338행, 전문 인용):

  if (num_marked > max_num_marked)
    max_num_marked = num_marked;
  int64_t free = heap_size - heap_start - num_marked;
  if (free < heap_size / 50)
    ERR("heap exhausted");

heap_size / 50은 정확히 힙의 2%다. 이것은 더 똑똑한 컬렉터도, 더 큰 컬렉터도 아니다 — 힙 크기는 여전히 -H가 말하는 그대로다 — 이것은 정직한 포기다: GC 패스 한 번이 끝난 뒤 힙의 여유가 2% 미만으로 남으면, 런타임은 갈수록 더 자주 모으면서 갈수록 더 적은 이득을 얻는 것을 계속하거나 진단 하나 없이 할당 도중 소진되는 대신, 멈추고 "heap exhausted"를 보고한다. 이는 런타임이 처음으로 그것을 찾아낼 만큼 큰 작업 부하 아래서 자신의 한계와 마주치는 것이다 — 그 한계가 문제가 된다는 것을 발견한 바로 그 커밋에서 정직하게 명시된 상한이다.

ee853660 / 04a4bbe4 / 8ad7094c: 실제 작업 부하가 생긴 뒤에야 나온 성능 작업

이틀 뒤 07-20, ee853660("Specialize the Map to StringMap.", Map을 StringMap으로 특수화), 11:20:05는 src/MicroHs/Map.hs — 7장이 매 호출마다 명시적 동등성 인자를 받도록 만든 범용 Map k v — 를 지우고, Compile.hs, Desugar.hs, Main.hs, Translate.hs에서 그것을 쓰던 모든 자리를 src/MicroHs/StringMap.hs로 바꾼다. 이는 String 키에 특화되어, 매 호출 지점에서 넘기는 대신 eqString을 안에 박아 넣은 버전이다: ee853660:src/MicroHs/Compile.hs 79행은 case M.lookup nm ch of로 읽히는데, 바로 앞 커밋의 같은 줄은 case M.lookup eqIdent nm ch of였다. 25분 뒤 04a4bbe4("Sort in frequency order.", 빈도 순으로 정렬), 11:45:47은 컴파일러가 읽거나 쓰는 모든 컴비네이터· 프리미티브 연산 이름에 대해 문자열 단위로 선형 탐색되는 두 조회 테이블의 순서를 바꾼다: .comb 파일의 토큰마다 parse()가 훑는 eval.c의 primops[]와, Translate.hs의 primTable이다. 두 diff 모두 같은 근거를 밝힌 주석을 덧붙인다(04a4bbe4:src/runtime/eval.c 169–190행과 04a4bbe4:src/MicroHs/Translate.hs 36–51행, 전문 인용, ...는 각 컴비네이터 테이블 뒤에 생략된 프리미티브 연산 항목을 나타낸다):

/* We use linear search in this, because almost all lookups
 * are among the combinators.
 */
struct {
  char *name;
  enum node_tag tag;
  NODEPTR node;
} primops[] = {
  /* combinators */
  /* sorted by frequency in a typical program */
  { "B", B },
  { "O", O },
  { "K", K },
  { "C'", CC },
  { "C", C },
  { "T", T },
  { "S'", SS },
  { "P", P },
  { "I", I },
  { "S", S },
  { "Y", Y },
  { "B'", BB },
  ...
-- Use linear search in this table.
-- 99% of the hits are among the combinators.
primTable :: [(String, Any)]
primTable = [
  ("B", primitive "B"),
  ("O", primitive "O"),
  ("K", primitive "K"),
  ("C'", primitive "C'"),
  ("C", primitive "C"),
  ("T", primitive "T"),
  ("S'", primitive "S'"),
  ("P", primitive "P"),
  ("I", primitive "I"),
  ("S", primitive "S"),
  ("Y", primitive "Y"),
  ("B'", primitive "B'"),
  ...

두 테이블 모두 B와 O를 앞으로 옮기고, S, Y, B' — 1장의 브래킷 추상화가 실제로는 가장 드물게 만들어내는 컴비네이터들 — 을 뒤로 미는데, 근거는 인용된 측정치가 아니라 일반적인 빈도에 대한 한 줄짜리 주석이다. 이 둘 사이, 07-18 16:50:16(재귀 수정과 같은 저녁이지, 재정렬과 같은 날은 아니다)에 8ad7094c("Add profiling option.", 프로파일링 옵션 추가)는 Makefile에 PROF 변수를 추가해, bootuhs의 빌드·링크 단계에 연결한다: GHC 자체의 -prof -fprof-auto 코스트 센터 프로파일러를, 컴파일러의 "local lib" GHC 빌드에 적용한 것이다. 아래 이 장 자신의 "직접 해보기"에서 빌드해 돌려 보면, 그 프로파일러 보고서는 Text.ParserComb.(>>=), Data.List.(++), Text.String.eqString이 지배한다 — 컴파일러 자신의 파서와 라이브러리 안, 네이티브 GHC 바이너리인 bootuhs에서 측정한 하스켈 수준 코스트 센터들이다. 어떤 컴비네이터 이름 — S, K, B, O — 이 .comb 파일에 가장 자주 나오는지, 즉 04a4bbe4의 테이블이 정렬 기준으로 삼는 것에 대해서는 아무 말도 해주지 않는다; 그것은 C 런타임 eval과 번역기 Translate.hs의 속성이며, GHC 프로파일러는 둘 중 어느 쪽도 건드리지 않는다. 짐작이 아니라 직접 확인한 결과: 이 범위의 어떤 커밋 메시지도, 코드 주석도, 파일도 둘을 연결 짓지 않는다. 같은 48시간 동안 나온 두 개의 독립적인 성능 개입으로 읽힌다 — 하나는 컴파일러 자신의 하스켈을 계측하고, 다른 하나는 완전히 다른 프로그램 안의 선형 탐색을 손으로 튜닝한다 — 한쪽이 다른 쪽에 재료를 대주는 파이프라인이 아니다.

db418344: 타입 파싱, 파싱만

db418344("Add type parsing.", 타입 파싱 추가), 18:46:43는 6장이 type Type = Expr로 납작하게 만든 이래 처음으로 Parse.hs에 진짜 타입 구문을 부여한다. 새로운 EType은 타입 변수, 생성자 적용, 단일화 변수를 구분하며, ETypeScheme은 forall을 더한다 (db418344:src/MicroHs/Parse.hs 99–107행, 전문 인용):

-- No higher order type variables (yet)
data EType
  = TVar Ident
  | TConstr Ident [EType]
  | TUVar Int
  --Xderiving (Show)

data ETypeScheme = ETypeScheme [Ident] EType
  --Xderiving (Show)

Sign :: Ident -> ETypeScheme -> EDef는 이제 타입이 없는 Expr 별칭 대신 실제로 파싱된 타입 스킴을 담는다. 하지만 이 커밋이 이 점에서 손대지 않은 Desugar.hs는 여전히 모든 Sign을 아무것도 아닌 것으로 바꾼다(db418344:src/MicroHs/Desugar.hs 151행, 인용): Sign _ _ -> []. 타입은 파서가 이해하고 EDef AST가 담을 수 있는 구문일 뿐, 그 뒤 어디서도 아직 읽지 않는다 — 검사기도, 추론도, 경고조차도 없다. 3장이 컴파일러의 가장 초기 타입 시그니처에 부여했던 것과 같은 "파싱만" 하는 자세가 한 걸음 더 나아간 것이다; 그렇게 파싱된 타입이 결국 무엇에 쓰이는가는 이 장이 아니라 9장의 주제다.

298c9824: uhs가 mhs가 되다 — 실제로 이름이 바뀌는 것

이 범위의 마지막 커밋, 298c9824("Rename uhs to mhs.", uhs를 mhs로 이름 바꾸기), 14:58:46은 정확히 두 파일만 건드린다: Makefile과 tests/Makefile이다. 어떤 파일도 git mv되지 않고, 어떤 모듈도 이름이 바뀌지 않는다 — MicroHs.Main은 이미 그 이름이었다. 바뀌는 것은, 리터럴 문자열 uhs가 빌드 산출물의 이름으로 쓰이던 모든 곳에 치환되는 새 Makefile 변수 MHS=mhs다: 바이너리($(BIN)/uhs가 $(BIN)/$(MHS)가 된다), GHC로 빌드한 로컬 라이브러리 짝(bootuhs가 boot$(MHS)가 된다), 셀프 컴파일된 컴비네이터 파일(uhs.comb가 $(MHS).comb가 된다), 가짜 테스트 타깃(uhstest가 $(MHS)test가 된다), 그리고 tests/Makefile에서는 UHS 변수 자체가 MHS=../bin/mhs로 이름이 바뀐다. 프로젝트는 자신의 이름 — MicroHs — 을 빌드 어휘 안에서 먼저 얻는다. 모든 곳에서 한 번에 얻는 것은 아니다: 이 커밋 시점에 src//lib//ghc/를 grep -rn uhs로 훑으면 여전히 한 군데가 걸린다. src/MicroHs/Main.hs 자신의 사용법 메시지로, 이 커밋으로도 바뀌지 않는다(298c9824:src/MicroHs/Main.hs 23행, 인용): error "Usage: uhs [-v] [-r] [-iPATH] [-oFILE] ModuleName". 이 커밋으로 이름이 바뀐 컴파일러는 여전히 자기 사용자들 앞에서는 스스로를 uhs라 부른다.

밝히지 않은 부가 사항

커밋 두 개가 제목이 말하는 것보다 더 많은 것을 담고 있다. 787d8842("Add an (unused) Data.Map", (쓰이지 않는) Data.Map 추가), 14:48:47은 약속한 파일 — 177줄, 균형 잡힌 진짜 2-3 트리 구현이며, 빌드는 되지만 어떤 호출 지점에도 연결되지 않는다(Makefile은 이를 bootuhs의 일부로만 컴파일할 뿐, 의미 있는 어느 곳에서도 임포트하지 않는다) — 을 실제로 추가하지만, 같은 커밋은 Parse.hs에 leIdent 헬퍼도 추가하고, StringMap.hs의 타입 시그니처들을 새로 주석 처리된 Data.Map 기반 대안 구현과 나란히 새 꼬리 블록으로 옮기는데, 제목에는 둘 다 언급되지 않는다. 8e2f7109("Change test targets.", 테스트 타깃 바꾸기), 07-18 16:25:51은 대체로 제목 그대로 한다 — alltest/everytest 타깃을 이름 바꾸고 추가한다 — 하지만 그 bootcombtest diff는 456ce17c가 1시간 18분 전에 추가했던 eval 호출에서 -K1000000을 조용히 빼기도 한다(8e2f7109:Makefile 67행, 인용): $(BIN)/eval -H10000000 -ruhs.comb -- -ilib -isrc -omain-comb.comb MicroHs.Main — -K1000000이 없고, 커밋 제목에도 언급이 없다 — 그 이후로 bootcombtest는 eval의 내장 기본값인 인터프리터 스택 셀 10,000개로 돌아가게 된다 — 명시적 안전 여유를 백 분의 일로 줄인 것이, 밝혀지지 않은 채로. 장부의 반대편도 있다: acf40ed4("Remove junk", 쓰레기 제거), 07-19 13:11:39은 A.hs와 Test.hs를 지우는데, 이는 7장 범위에서 59b8b1ab가 무관한 제목 "Fix name"(이름 고치기) 아래 밝히지 않고 추가했던 바로 그 두 파일이다. 두 주 뒤, 같은 프로젝트는 diff가 실제로 하는 일을 정직하게 말하는 제목 아래서 그 파일들을 지운다.

왜 중요한가

456ce17c는 기능 하나가 착지한 것이 아니라 고정점 하나가 닫히는 것이다. bootcombtest가 존재하고 통과하는 순간, 컴파일러 자신의 속도는 부차적인 관심사이기를 멈추고 가장 중요한 벤치마크가 된다: 런타임이나 컴파일러를 더 빠르게 만드는 모든 커밋은 다음 부트스트랩 실행도 더 빠르게 만들며, 부트스트랩 실행은 이제 프로젝트 전체 테스트 스위트에서 가장 무겁고 가장 대표적인 작업 부하다. 이는 커밋 순서 자체에서 드러난다. 이 장의 모든 성능 개입 — int64_t 확장, 힙 2% 포기, StringMap, 빈도순 정렬 조회 테이블 — 은 전부 456ce17c 이후에 오며, 그중 여럿은 몇 시간 안에, 그 이전에는 하나도 없다. 런타임은 이 저장소의 첫 커밋(ccb31388, 2023-05-25)부터 a824a175까지 54일 동안, 카운터도, GC 임계값도, 자료 구조도, 조회 순서도 손보는 커밋이 단 하나도 없었다 — 앞서 "optimiser"라는 제목을 단 커밋들(ae2a1403, d2956ee8 등)은 완전히 다른 관심사로, Exp.hs의 브래킷 추상화 출력에 대한 코드 품질 손질, 즉 aux/bracket-abstraction.md의 주제이지 런타임 엔지니어링이 아니다. 셀프 컴파일이야말로 마침내 런타임에 엔지니어링할 가치가 있는 작업 부하를 준 것이고, 프로젝트는 그것이 존재하게 되자마자 곧바로 엔지니어링했지, 그 전에 하지 않았다.

실험 노트 여백

  • 실제 셀프 컴파일을 돌려서 가장 직접적으로 강제된 세 개의 수정 — 1876f4c1의 결합법칙 재작성, 5d58461f의 더 넓은 카운터, 1a84b483의 힙 포기 — 는 가장 멀리 떨어진 경우(1876f4c1부터 1a84b483까지) 7시간 3분 50초 차이로 도착하며, 가장 가까운 쌍(f06bacb7부터 1876f4c1까지)은 21초 차이다.
  • StringMap/빈도순 정렬/프로파일링 묶음(8ad7094c, ee853660, 04a4bbe4)은 처음부터 끝까지 42시간 55분 31초에 걸쳐 있다 — 마지막 둘이 처음 것과 다른 날짜에 도착함에도 불구하고, 여전히 456ce17c로부터 이틀 안이다.
  • d8d8856b("Add version string to compiler binary.", 컴파일러 바이너리에 버전 문자열 추가), 07-20 11:53:35은 모든 .comb 파일에 다섯 바이트짜리 헤더("v1.0\n")를 부여하고, eval의 main에 파일의 나머지를 파싱하기 전에 그 바이트들을 읽어 맞춰 보는 checkversion 검사를 추가한다 — 호환되지 않는 컴파일러 버전이 만든 .comb 파일을 실행하는 상황에서 ERR("version mismatch") 하나만큼 떨어져 있다. 같은 날 오후의 787d8842("Add an (unused) Data.Map")는 이 프로젝트가 허용하는 정직한 종류의 사변적 코드다: 빌드되고, 컴파일되지만, 결코 호출되지 않으며, 제목이 그렇다고 말한다.

직접 해보기

기준점: 456ce17c("Add bootstrap test."), 2023-07-18 15:07:29 — 이 연습이 실행하는 bootcombtest 타깃을 추가하는 커밋이다. 관례(참고: 00-overview.md):

git worktree add /tmp/mhs-456ce17c 456ce17c
cd /tmp/mhs-456ce17c

타깃이 필요로 하는 두 바이너리를 쓰인 그대로 빌드한다. 이 프로젝트의 flake 툴체인(GHC 9.8.4)에는 패치가 필요 없다:

mkdir -p bin
nix develop $MICROHS -c make bin/uhs
nix develop $MICROHS -c make bin/eval
[...]
[12 of 12] Linking bin/uhs
gcc -Wall -O3 src/runtime/eval.c -o bin/eval

(이전 모든 장의 빌드 노트가 이미 짚은 head/tail에 대한 같은 -Wx-partial 경고가 bin/uhs 빌드 중에도 나타난다; 생략.)

이제 핵심 장면 — bootcombtest 자신이 실행하는 그대로 사다리 전체를, 단계별로 시간을 재며 실행한다:

nix develop $MICROHS -c bash -c 'time bin/uhs -ilib -isrc -ouhs.comb MicroHs.Main'
real	0m0.964s
nix develop $MICROHS -c bash -c 'time bin/uhs -ilib -isrc -omain-uhs.comb MicroHs.Main'
real	0m0.942s
nix develop $MICROHS -c bash -c 'time bin/eval -H10000000 -K1000000 -ruhs.comb -- -ilib -isrc -omain-comb.comb MicroHs.Main'
real	0m21.273s
nix develop $MICROHS -c cmp main-uhs.comb main-comb.comb

cmp는 아무것도 출력하지 않고 종료 코드 0으로 끝난다: main-uhs.comb와 main-comb.comb — 각각 68,799바이트 — 는 바이트 단위로 동일하다. GHC로 빌드한 bin/uhs가 자신의 소스를 컴파일했고, 그 같은 소스를 컴비네이터로 옮겨 bin/eval이 인터프리트해 다시 컴파일했으며, 두 실행 모두 정확히 같은 출력을 냈다. 그것이 bootcombtest가, 그것을 추가한 바로 그 커밋에서, 전부 실제로 실행되는 모습이다 — 스택 오버플로도, 패치도 없이. (나는 또한 이 범위의 마지막 커밋 298c9824에서 bin/uhs 대신 bin/mhs로 사다리 전체를 다시 실행해 보았다: 거기서도 main-mhs.comb와 main-comb.comb가 일치했고, 각각 75,052바이트, 인터프리트 단계는 20.715초였다 — 그러니 1876f4c1이 닫은 네이티브 스택 위험이 무엇이든, 이 특정 셀프 컴파일에 대해서는 이 환경의 기본 8MB ulimit -s가 그것을 유발하는 위험이 아니다; 수정은 실재하고 위의 메커니즘은 diff에서 직접 읽어낸 것이지만, 나는 그것이 막는 크래시를 재현해내지는 못했다.)

연습 문제 1: eval로 실행한 셀프 컴파일과 GHC 빌드 버전의 시간 비교

위의 time 실행 세 번이 이미 이 기계에서 이 질문에 답한다: GHC로 빌드한 bin/uhs는 MicroHs.Main을 1초에 한참 못 미치는 시간에 컴파일한다(실행마다 0.942초–0.964초); 같은 컴파일러의 컴비네이터 번역본을 인터프리트하는 bin/eval이 동일한 컴파일을 수행하는 데는 21.273초가 걸린다 — 22.6배의 감속이다(21.273 ÷ 0.942): 네이티브 코드 대 같은 컴비네이터 항을 걷는 그래프 축약 인터프리터의 대결이며, 이전 모든 장의 time 타깃이 측정하던 짧은 테스트 프로그램이 아니라 이 컴파일러 자신의 소스를 상대로 처음 재는 값이다. 소스가 이틀, 커밋 27개만큼 자라난 뒤인 298c9824에서 같은 3단계 타이밍을 재면(여기서는 main-uhs.comb가 68,799바이트, 그쪽은 main-mhs.comb가 75,052바이트로 대략 9% 더 큰 프로그램) 0.926초 / 0.890초 / 20.715초가 나온다 — 바이트당 처리량이 약간 더 낫다(75,052 ÷ 20.715 ≈ 3,624B/s 인터프리트, 여기서는 68,799 ÷ 21.273 ≈ 3,234B/s) — 이 앞뒤 한 쌍만으로 이 범위의 어떤 성능 커밋이 정확히 얼마를 기여했는지 가려낼 수는 없지만, ee853660/04a4bbe4의 조회 테이블 작업이 순수한 오버헤드가 아니라 스스로 값어치를 하고 있다는 것과 부합한다.

연습 문제 2: uhs.comb의 바이트 하나 손상시키기

uhs.comb의 텍스트 형식은 모든 컴비네이터를 $로 시작하는 토큰으로 이름 붙인다; 그런 토큰 하나를 찾아 바이트 하나를 뒤집는다:

(python3 역시 devShell의 buildInputs에는 없다 — 1장의 diff와 마찬가지로, nix develop이 넘겨주는 PATH에서 호스트 시스템이 무엇을 제공하든 그것을 쓴다.)

nix develop $MICROHS -c python3 -c "
data = bytearray(open('uhs.comb','rb').read())
assert data[53:54] == b'C'
data[53] = ord('Z')
open('uhs-corrupt.comb','wb').write(bytes(data))
"

바이트 오프셋 53(0부터 셈)은 파일 시작 근처의 \$C 토큰 안에 놓여 있다; 위의 수정은 이를 존재하지 않는 컴비네이터 이름인 \$Z 로 바꾼다. bootcombtest의 가운데 줄이 하듯 eval에게 이를 실행하라고 시킨다:

nix develop $MICROHS -c bin/eval -H10000000 -K1000000 -ruhs-corrupt.comb -- -ilib -isrc -ocorrupt-out.comb MicroHs.Main
eval: bad primop Z
ERR: no primop

종료 코드 1이다. parse()의 프리미티브 연산 테이블 조회(나중에 04a4bbe4가 재정렬하는 바로 그 선형 탐색)는 Z라는 이름의 항목을 찾지 못하고, fprintf/ERR 호출이 약속하는 진단을 정확히 출력하며, 축약이 단 한 번도 일어나기 전에 실행 전체가 중단된다 — 손상된 바이트 하나가, 다음 부트스트랩으로 조용히 잘못 컴파일되어 들어가는 대신, 파싱 시점에 붙잡힌다.

연습 문제 3: 프로파일링 플래그와 빈도 순서 — 짐작이 아니라 확인

8ad7094c의 PROF 플래그는 456ce17c 기준점에서는 아직 존재하지 않고, 04a4bbe4의 재정렬된 primops[] 테이블도 마찬가지다; 이 연습에는 둘 다 필요하므로, 둘이 마지막으로 함께 나타나는 커밋인 04a4bbe4 자신에 두 번째 워크트리를 쓴다:

git worktree add /tmp/mhs-04a4bbe4 04a4bbe4
cd /tmp/mhs-04a4bbe4
mkdir -p bin
nix develop $MICROHS -c make PROF="-prof -fprof-auto" bin/bootuhs
[...]
ghc -prof -fprof-auto -hide-all-packages -o bin/bootuhs ghc-boot/*.o ghc-boot/*/*.o
nix develop $MICROHS -c bin/bootuhs -ilib -isrc -omain-boot.comb +RTS -p -RTS MicroHs.Main

이는 bootuhs.prof, GHC 자신의 코스트 센터 보고서를 쓴다(상위 항목만 추림):

COST CENTRE      MODULE            SRC                                      %time %alloc

>>=.\.xss        Text.ParserComb   src/Text/ParserComb.hs:80:15-69           18.8    7.4
++               Data.List         lib/Data/List.hs:(22,1)-(25,26)           10.1   23.9
>>=.\            Text.ParserComb   src/Text/ParserComb.hs:(78,5)-(82,68)      9.9   17.0
eqString         Text.String       lib/Text/String.hs:(101,1)-(110,44)        8.7   12.9
runP             Text.ParserComb   src/Text/ParserComb.hs:(66,1)-(68,12)      6.3    0.0
foldr.rec        Data.List         lib/Data/List.hs:(57,5)-(60,30)            4.2    2.8
...

이 목록을 있는 그대로 읽으면: Text.ParserComb의 >>=, Data.List.(++), Text.String.eqString이 지배한다 — 컴파일러 자신의 파서와 프렐류드 안에 있는 하스켈 함수들로, "local lib" 빌드를 실행하는 네이티브 GHC 바이너리 bootuhs에서 측정한 값이다. 컴비네이터 이름은 — S, K, B, O, 04a4bbe4의 primops[]/primTable이 재정렬하는 그 이름들은 — 이 보고서 어디에도 단 하나도 나타나지 않는데, 그것들은 완전히 다른 프로그램에 속하기 때문이다: eval.c의 인터프리터와 Translate.hs의 번역기이며, bootuhs의 GHC 프로파일러는 둘 중 어느 쪽도 실행하지 않는다. "프로파일링 플래그를 빈도 순서와 연결 짓고 싶은" 직감은 이 실행 결과 앞에서 살아남지 못한다: 그것들은 같은 48시간 동안 우연히 함께 도착한, 서로 다른 두 프로그램을 겨냥한, 별개의 두 성능 개입이다. 이 보고서가 독립적으로 뒷받침하는 것은 채 이틀도 되지 않은 앞선 시점의 8f361a0a의 "Fix stupid eqString"(멍청한 eqString 고치기)이다 — eqString 하나만으로 이 실행 시간의 8.7%와 할당량의 12.9%를 차지하며, 이만한 크기의 작업 부하가 처음으로 드러내는 딱 그런 종류의 핫패스 비용이다.

두 워크트리를 모두 정리한다:

git worktree remove --force /tmp/mhs-456ce17c
git worktree remove --force /tmp/mhs-04a4bbe4

🌐 Read in English

09 — Types at last (마침내 타입)

현재 위치

8장은 298c9824("Rename uhs to mhs.", uhs를 mhs로 이름 바꾸기), 2023-07-20 14:58:46에서 끝난다: 컴파일러는 셀프 호스팅하고, bootcombtest는 통과하며, 바이너리는 마침내 이 책이 처음부터 써 온 이름 mhs에 답한다. 그 바이너리는 타입을 단 하나도 검사하지 않는다. f :: T는 3장의 e96d04be(2023-05-30) 이래로 파싱되어 왔고, 곧장 dsDef (Sign _ _) = []로 흘러갔다 — 받아들여졌다가 버려졌다. 8장 자신의 db418344(2023-07-18)는 시그니처에 진짜 문법을 부여했다 — Expr과 구별되는 TVar/TConstr/TUVar 타입인 EType과, forall을 위한 ETypeScheme — 하지만 디슈가러의 판정은 바꾸지 않은 채로 남겼다: Sign _ _ -> [], 여전히 그 뒤 어디서도 파싱된 것을 읽지 않는다. 이 장의 범위인 298c9824..a38c64ce, 21일에 걸친 커밋 24개는 마침내 무언가가 그렇게 하는 지점이다.

무슨 일이 있었나

"Update"라는 이름의 커밋 세 개

이 범위는 7936c701, bca0b02d, 3719955b로 열리는데, 셋 다 2023-07-20에, 모두 "Update"(갱신)라는 제목으로, 매번 정확히 파일 하나만 건드린다: README.md(순서대로 4줄 추가/13줄 삭제, 1/1, 그리고 삭제 없이 6줄 추가). 셋 중 어느 것도 타입 검사와는 무관하다 — 8장의 끝과 이 장의 첫 실질적인 커밋 사이 공백에 놓인 문서 정비일 뿐이다. 여기 실린 것은 오직 "모호하게 이름 붙은 커밋도 어쨌든 확인한다"가 이 책 자신의 기준이기 때문이지, 이들에 대해 더 할 말이 있어서가 아니다.

하루 만의 준비: 33b009fa부터 e5bc9236까지

다섯 커밋, 전부 2023-07-21, 누군가 그 위에 타입 검사기를 쓰려 시도하기 전에 하나하나 바닥을 다진다.

33b009fa("Use Expr for EType.", EType에 Expr 쓰기)는 db418344가 이틀 하고도 스무 시간 전에 도입했던 세 생성자짜리 EType을 지우고 type EType = Expr와 validType 술어로 대체한다 — aux/hindley-milner-gently.md가 이 결정으로 문을 여는데, 이것이 검사기의 나머지 전부가 그 위에 세워지는 단 하나의 결정이기 때문이다. 같은 커밋은, 제목에는 언급되지 않은 채, showEType — 사람을 위해 타입을 프리티 프린트했을 함수 — 를 결코 반환하지 않는 함수로 망가뜨린다(33b009fa:src/MicroHs/Parse.hs, 전문 인용, 이 함수에 대한 diff 전체):

 showEType :: EType -> String
-showEType at =
-  case at of
-    TVar i -> i
-    TConstr c ts ->
-      if null ts then
-        c
-      else
-        "(" ++ unwords (c : map showEType ts) ++ ")"
-    TUVar n -> "a" ++ showInt n
+showEType = showEType

더 이상 패턴 매칭할 TVar/TConstr/TUVar가 없다 — EType은 이제 그냥 Expr이다 — 그리고 showEType을 Expr을 직접 걷도록 다시 쓰는 대신, 33b009fa는 호출된다면 영원히 도는 스텁을 남긴다. 직접 확인한 결과: 33b009fa와 (아래) 617475b6 사이의 어떤 커밋에서도 TypeCheck.hs나 Main.hs가 showEType이나 showETypeScheme를 호출하지 않는다 — 이 스텁은 거의 열엿새(15일 21시간) 동안, c0b279fc의 뼈대 검사기, 두 개의 "Transfer" 커밋, f7db30f1의 라이브러리 전역 시그니처 정비를 거치는 내내, 단 한 번도 실행되지 않은 채 소스 안에 죽어 있다.

16초 뒤 45b34015는 또 다른 함수 showExpr의 진짜 다시 쓰기다 — 예전에 쓰던 (EVar "x") 식의 생성자-태그 붙은 디버그 형식 없이 항을 출력하도록 열여섯 개의 대안이 교체된다("(EVar " ++ v ++ ")"가 그냥 v가 되는 식이다) — 이는 unify 자신의 오류 메시지가 읽어내는 형식이다 (aux/hindley-milner-gently.md가 unifyR이 그것을 쓰는 대목을 직접 인용한다). 93a2f76e("Allow '()' and '[]' as uppercase identifiers.", '()'와 '[]'를 대문자 식별자로 허용하기)는 pUIdent에 새로운 대안 두 개를 가르쳐, ()와 []가 튜플/리스트 전용 문장부호로만이 아니라 평범한 생성자 이름으로도 파싱되게 한다(93a2f76e:src/MicroHs/Parse.hs, pUIdent에 추가된 두 줄, 전문 인용):

  <|> ("()" <$ (pSym '(' *> pWhite *> pSym ')'))  -- Allow () as a constructor name
  <|> ("[]" <$ (pSym '[' *> pWhite *> pSym ']'))  -- Allow [] as a constructor name

— 이것이 필요한 이유는, 타입이 이제 그냥 Expr이므로 ()나 [a] 같은 타입도 Int나 Maybe와 같은 방식으로 평범한 EVar 생성자 이름(검사기 자신의 어휘로는 tCon "()", tApps "[]" [...])으로 귀결되어야 하는데, 이 커밋 이전에는 ()/[]가 오직 식 구문 안에서만 파싱 가능했고, 타입 적용이 적용할 수 있는 맨 대문자 식별자로는 결코 파싱되지 않았기 때문이다. ca512ed2("Get rid of Data.List_Type", Data.List_Type 없애기)는 data [] a = [] | (:) a [a]를 lib/Data/List.hs 안으로 곧바로 접어 넣어, 타입 선언만 담고 있던 별도의 Data.List_Type 모듈을 지운다 — 이제 한 모듈 자신의 Data/Sign 정의와 항 수준 바인딩이 검사기가 함께 읽는 같은 EDef 목록에 살고 있으니, 이들을 따로 둘 구조적 이유가 없다. e5bc9236 ("Change ELam to abstract just one variable.", ELam이 변수 하나만 추상화하도록 바꾸기)는 ELam [Ident] Expr을 ELam Ident Expr로 좁히면서, 다중 인자 람다를 파싱 시점에 중첩된 단일 인자 람다들로 접는 새 함수 eLams :: [Ident] -> Expr -> Expr를 더한다 (e5bc9236:src/MicroHs/Parse.hs, 전문 인용):

pLam :: P Expr
pLam = eLams <$> (pSymbol "\\" *> esome pLIdent_) <*> (pSymbol "->" *> pExpr)

eLams :: [Ident] -> Expr -> Expr
eLams is e = foldr ELam e is

— 다음 날 추가되는 검사기 자신의 ELam case는 람다마다 정확히 속박 변수 하나를 기대해서 한 번에 정확히 하나의 tArrow와 단일화할 수 있게 한다; 다중 인자 ELam이었다면 대신 그 자체의 다단계 단일화 로직이 필요했을 것이다.

c0b279fc: 어디에도 연결되지 않은 뼈대

c0b279fc("Start of type checking.", 타입 검사의 시작), 2023-07-22 10:50:52는 새 파일 하나, src/MicroHs/TypeCheck.hs (57줄)를 추가한다 — 그리고 다른 어떤 것도 건드리지 않는다. Makefile도, Compile.hs도, Main.hs도 아니다. 빌드 안 어디에서도 아직 이것을 임포트하지 않는다. GHC가 그 자체로 받아들일 하스켈조차 아니다: tcExpr의 타입 시그니처는 모나드 T를 언급하지만 (tcExpr :: Maybe EType -> Expr -> T (Typed Expr)), 이 파일의 유일한 임포트는 import Control.Monad.State as T (c0b279fc:src/MicroHs/TypeCheck.hs 3행)다 — 이는 T.get 같은 이름을 쓸 수 있게 하는 한정 임포트 구문이지, T라는 이름의 맨 타입이 아니다. 몇몇 case는 그냥 undefined다(ECase _ = undefined, ELet _ = undefined, EDo _ = undefined), 그리고 함수는 정의 도중에 끝나 버린다 — 파일의 마지막 줄은 tc, 뒤에 아무것도 없는 미완의 식별자다. 이는 가장 글자 그대로의 의미에서 첫 초안이다: 검사기의 Expr에 대한 핵심 재귀가 어떤 모양이 될지에 대한 윤곽 (tcExpr, 양방향 검사를 위한 Maybe EType을 가진 EVar/EApp/ELam/EInt 순회이며, 2주 뒤 cc57346e의 동작하는 버전에서 이미 알아볼 수 있는 모양이다)이, 컴파일되기는커녕 실행되기도 전에 커밋된 것이다.

무관한 수정 하나, 그리고 "Transfer"라 불리는 커밋 두 개

7ae8c443("Make int64_t printing portable.", int64_t 출력을 이식성 있게 만들기), 2023-07-22 15:57:15는 파일 하나짜리 런타임 수정 — eval.c의 숫자 포맷팅 — 으로 타입 검사와는 아무 상관이 없다; c0b279fc와 다음 TypeCheck.hs 작업 사이에 끼어 있기 때문에 여기 실었으며, 이 프로젝트의 git 역사가 한 기능이 진행 중이라고 다른 작업을 멈추지 않는다는 것을 상기시킨다. 그런 다음, 같은 날 저녁 1분 50초 간격으로, 둘 다 "Transfer"라는 제목을 단 3437518f와 031d2125가 오직 src/MicroHs/TypeCheck.hs만 건드린다 — 114줄 변경(74줄 추가, 40줄 삭제), 그다음 6줄 더(4/2). 두 커밋 메시지 모두 무엇이 어디서 옮겨지는지 말하지 않는다; diff로 읽으면 둘 다 c0b279fc가 시작한 같은 뼈대를 단순히 다듬은 것이지(더 많은 case가 채워지고, 더 많은 헬퍼 함수가 추가된다) 전면 교체가 아니다 — diff 자체 이외에 저장소의 무엇으로도 확인되지는 않지만, 다른 어딘가 (두 번째 체크아웃, 다른 기계)에서 초안을 작성한 뒤 병합이 아니라 손으로 옮겨 붙인 작업과 부합한다.

f7db30f1: 3장의 빚을 갚다 — 라이브러리에서

f7db30f1("Add lots of type signatues and forall.", 타입 시그니처와 forall을 잔뜩 추가하기), 2023-07-23 19:28:49는 aux/hindley-milner-gently.md가 자세히 인용하는 커밋이다: 파일 9개, 134줄 추가, 74줄 삭제, forall을 담은 추가 줄 80개, ::를 담은 추가 줄 128개. 정확히 짚어 둘 가치가 있다, 그 모양을 잘못 짚기 쉽기 때문이다: 이 커밋이 건드리는 모든 파일은 lib/ 아래(Data/Either.hs, Data/Function.hs, Data/Int.hs, Data/List.hs, Data/Map.hs, Data/Maybe.hs, Data/Tuple.hs, Primitives.hs)와 Makefile이며 — 모든 MicroHs 프로그램(그것을 임포트하는 컴파일러 자신의 src/ 트리를 포함해)이 끌어오는 표준 라이브러리이지, src/MicroHs 자신이 아니다. 그리고 그 128개의 :: 줄 대부분은 완전히 새로운 시그니처가 아니다 — const :: a -> b -> a는 이미 lib/Data/Function.hs에 있었다; f7db30f1이 여기에 더하는 것은 앞에 붙는 forall a b .뿐이다. 3장의 e96d04be:Desugar.hs 주석은 "의미는 몇 달 뒤에 도착한다"고 말한다 — f7db30f1은 e96d04be로부터 54일 뒤에 도착하고, cc57346e의 동작하는 검사기는 그로부터 다시 14일 뒤, 합쳐서 68일이다. f7db30f1에서 먼저 도착하는 것은 "컴파일러가 Sign을 읽는다"가 아니다 — 검사기는 아직 동작하는 기능으로 존재하지 않는다 — 라이브러리가, 검사기의 고정 변수 메커니즘이 언젠가 다형적인 무언가라도 받아들이려면 필요할 정량자를 얻는 것이다. 바로 aux/hindley-milner-gently.md의 forall/인스턴스화 절이 하나를 제거해 보임으로써 보여 주는 것과 정확히 같다.

무관한 커밋 두 개 더, 그중 하나는 조용히 중요한 역할

677a0f0e("Handle negative literals.", 음수 리터럴 처리하기), 2023-07-25 14:58:16은 또 다른 런타임 전용 수정으로, eval.c에 12줄 추가, 1줄 삭제다. 18초 뒤 17b450fc("Add more lib modules.", 라이브러리 모듈 더 추가하기)는 파일 두 개를 완전히 새로 추가한다: lib/Control/Monad/State.hs(52줄)와 lib/Data/IntMap.hs(77줄). 커밋 메시지는 컴파일러에 왜 IntMap이 필요한지 말하지 않지만, TypeCheck.hs 자신의 상태 — TCState의 마지막 필드, 단일화 치환 (aux/hindley-milner-gently.md의 "메타변수와 치환" 절) — 은 정확히 IM.IntMap EType이고, cc57346e의 임포트 목록이 이를 확인해 준다: import qualified Data.IntMap as IM. 17b450fc는 타입 검사 작업이라는 제목을 달고 있지 않지만, 검사기의 핵심 자료 구조를 손 닿는 곳에 두는 커밋이다.

조용한 뚝심: 9a392312부터 617475b6까지

9a392312("Temp commit", 임시 커밋), 2023-07-27 13:47:19은 TypeCheck.hs에 173줄 추가, 26줄 삭제로 손대고, 그 밖에 다른 다섯 파일도 건드린다(Makefile, Compile.hs, Desugar.hs, Main.hs, Parse.hs) — 그 이전의 어떤 커밋과도 달리, 검사기를 처음으로 빌드에 연결한다. 그 뒤로는 아무 일도 없다가, 9일 21시간 51분 만에 617475b6("Temp", 임시), 2023-08-06 11:38:42가 온다 — TypeCheck.hs를 다시 한번 다시 쓰는 단일 커밋으로, 이번에는 543줄 추가, 65줄 삭제이며, Desugar.hs의 거의 전면적인 재구성(67줄 추가, 205줄 삭제)을 포함해 총 11개 파일에 걸쳐 있다. 저장소 안 어디에도 그 공백을 설명하는 것은 없다 — 7장의 24일 침묵이 00-overview.md에서 설명되지 않은 채 남는 것과 마찬가지로, 로그는 그저 다시 이어질 뿐이다. 두 커밋의 제목이 공유하는 것 — "Temp commit", "Temp" — 은 정직한 서술이다: 둘 다 끝났다고 주장하지 않으며, 둘 다 깨끗하게 동작하는 상태라기보다는 저장점으로서 커밋되어 있다. 617475b6은 또한 거의 열엿새 전 33b009fa가 남긴 showEType의 무한 루프가 마침내 고쳐지는 지점이기도 하다 — 이 커밋이 건드리는 다른 모든 것과 나란히 놓인 한 줄이다 (617475b6:src/MicroHs/Parse.hs, 전문 인용): showEType = showExpr. 재설계가 아니라, EType이 내내 Expr이었으니 타입 출력기를 (이미 45b34015에서 다시 쓰인) 항 출력기로 그저 돌려 보낸 것뿐이다.

cc57346e: "First version with type checking."(타입 검사가 있는 첫 버전)

617475b6의 다시 쓰기가 도착한 지 7시간 38분 뒤, 같은 날, cc57346e(2023-08-06 19:16:48)는 이 장과 aux/hindley-milner-gently.md 둘 다 검사기의 첫 진짜 스냅샷으로 다루는 커밋이다: 파일 16개, 187줄 추가, 78줄 삭제. src/MicroHs/TCMonad.hs가 새로 생긴다 — 13줄, 컴파일러 자신의 한정 이름 T 아래 Control.Monad.State.Strict를 얇게 재수출하는 것으로, 마침내 c0b279fc의 2주 묵은, 한 번도 컴파일된 적 없는 타입 시그니처에 가리킬 실체를 준다. 이 커밋 시점의 TypeCheck.hs 자신은 aux/hindley-milner-gently.md가 내내 인용하는 그 파일이다 — unify/unifyR, 메타변수로서의 EUVar, tInst, tcDefValue, 모두 존재하며 그 문서가 서술하는 모양 그대로다. 이 커밋 시점에 할 수 있는 것: EVar/EApp/ELam/리터럴, ECase, ELet, ETuple, EList, EDo, EIf, 리스트 컴프리헨션을 검사하는 것이다 — 2주 전 c0b279fc가 undefined로 남겨 두었던 모든 case가 채워져 있다. 그 대가로 요구하는 것은 같은 커밋 자신의 테스트 스위트에서 즉시 드러난다: tests/ 아래 여섯 파일 전부가 예전에는 없던 main :: IO () 시그니처를 얻는다(tests/Arith.hs, ListTest.hs, StringTest.hs는 각각 그 한 줄만 필요하고; Fac.hs는 res :: (Int, Int)도 필요하며; IOTest.hs는 f :: Int -> Int와 foo :: IO ()가 필요하고; Misc.hs의 기존 first :: (a, b) -> a는 앞에 forall a b .가 더해져야 한다) — aux/hindley-milner-gently.md가 lib/Data/List.hs의 null에서 하나를 제거해 보여 주는 바로 그 "모든 최상위 바인딩은 명시적 시그니처가 필요하다"는 규칙이, 검사를 켠 바로 그 커밋에서 프로젝트 자신의 테스트 파일에 그대로 나타난 모습이 이제 눈에 보인다.

수정 연속선

커밋 여섯 개, da5350d2부터 64345fe2까지가, 가운데 온전히 조용한 이틀을 사이에 두고 사흘에 걸쳐 있다 — 2023-08-06(da5350d2 19:31, 3bf3924a 23:56), 2023-08-07(a90d1175 12:21, bb8aec3b 12:22), 8일과 9일은 아무 일도 없다가, 2023-08-10(64345fe2 11:06, 이 장의 마지막 커밋과 같은 날). 함께 읽으면, 이들의 TypeCheck.hs diff 대부분은 새로운 타입 검사 로직이 전혀 아니다 — aux/bootstrapping-a-compiler.md가 컴파일러의 나머지 부분에 대해 서술하는 것과 같은 "다이어트"를, 가장 최신 모듈에도 적용해서, 이 모듈 역시 GHC뿐 아니라 결국 mhs 자신 아래서도 컴파일될 수 있게 하는 것이다.

da5350d2("Improve.", 개선하기)는 모든 HasCallStack => 제약을 --X 주석 표시 뒤로 감추고, 맨 mapM/mapM_을 한정된 T.mapM/T.mapM_로 바꾼다 — GHC.Stack과 한정되지 않은 Prelude의 mapM은 정확히 mhs 자신의 제한된 방언에는 없는 종류의 임포트다. 3bf3924a("Various type fixes.", 여러 타입 수정)는 같은 패턴을 이어간다: EApp <$> expandType f <*> expandType a 식의 Applicative 코드는 mhs가 제공할 타입 클래스가 없는 <$>/<*> 인스턴스가 필요한데, 이를 명시적인 T.do 바인드로 다시 쓰고, 맨 return/pure를 T.return으로 한정한다. a90d1175("Better import", 더 나은 임포트)는 TCMonad.hs에서 3줄 추가, 2줄 삭제인 임포트 손질이다. bb8aec3b("More type fixes", 타입 수정 더)는 Parse.hs에서 6줄 추가, 1줄 삭제로, 대부분 살아 있는 코드가 아니라 showExpr을 위한 주석 처리된(--X로 표시된) 프리티 프린터 대안들이다. 64345fe2("More type checking fixes.", 타입 검사 수정 더)는 여섯 중 가장 크다 — TypeCheck.hs 안에서만 163줄이다 — 그리고 제목만 보고 짐작하는 대신 직접 확인해 보면, 단순한 이식성 손질만이 아니라 진짜 정확성 수정도 적어도 하나 담고 있다: expandSyn이 다중 인자 타입 시노님을 다루는 방식이다. 이 커밋 이전에는 인자 두 개로 쓰인 type Pair a b = (a, b)가 타입 검사를 아예 통과하지 못한다 — cc57346e를 빌드해 그것을 쓰는 한 줄짜리 프로그램을 컴파일해 확인했다: mhs: Cannot unify SynTest.Pair and ,. 64345fe2는 expandSyn의 EApp case를 다시 써서, 잘못된 부분 항으로 재귀하는 대신 각 인자를 누산기에 밀어 넣기 전에 확장하면서 적용 스파인을 올바르게 걷는다 (64345fe2:src/MicroHs/TypeCheck.hs, 전문 인용, 바뀐 case):

        EApp f a -> T.do
          aa <- expandSyn a
          syn (aa:ts) f

— a38c64ce를 빌드하고 동일한 SynTest.hs를 컴파일해 고쳐졌음을 확인했다: 컴파일되고 실행되어 1을 출력한다. 정직한 사실 하나 더, diff만 읽지 않고 64345fe2 자체를 빌드해 보려다 발견한 것: 이 커밋은 GHC 아래서 단독으로 컴파일되지 않으며, TypeCheck.hs가 T.do 블록 안에서 패턴 매칭을 하는 곳마다 Not in scope: 'T.fail' / Not in scope: 'T.>>='로 실패한다. 그 수정은, aux/bootstrapping-a-compiler.md가 기록하는 --X/--Y 표시 기법으로, a38c64ce 자신의 TCMonad.hs 변경이며, 뒤바뀌던 도중에 붙잡힌 것이다: 64345fe2 시점에는 src/MicroHs/TCMonad.hs 16행이 --X 표시가 없는 평범한 주석 --import Control.Monad로 되어 있어서, convertX.sh가 이를 전혀 건드리지 않고, MicroHs.TCMonad의 module MicroHs.TCMonad 익스포트 항목이 재수출할 fail/(>>=)를 스코프로 들여올 Control.Monad가 GHC 빌드에는 실제로 임포트되지 않는다. a38c64ce는 그 한 줄을 --Ximport Control.Monad hiding(ap)로 바꾸고 (a38c64ce:src/MicroHs/TCMonad.hs 17행) — 이제 표시가 붙어서 convertX.sh의 sed -e 's/--X//'가 표시를 벗겨내고 bin/mhs 빌드는 실제로 import Control.Monad hiding(ap)를 보게 되어, T.fail/T.>>=가 해소된다. 00-overview.md가 이 프로젝트의 커밋 역사에 대해 짜 놓은 틀 — 깨진 상태도 깨끗해질 때까지 붙들고 있지 않고 그대로 커밋된다 — 과 부합한다.

a38c64ce: 고정점

a38c64ce("Now bootstraps with type checking.", 이제 타입 검사와 함께 부트스트랩한다), 2023-08-10 20:37:30은 파일 7개, 29줄 추가, 24줄 삭제 — 이 책 전체 이야기의 흐름을 닫는 커밋치고는, 이 장의 기준으로는 작다. Makefile 변경은 숫자 하나다: bootcombtest의 eval 호출이 힙을 -H10000000에서 -H50000000으로 키우고, 같은 커밋은 $(MHS)test 타깃(원래 uhstest, 7장의 1d6d1210, 2023-07-05, 8장에서 298c9824가 이름을 바꿈)을 지운다 — 이는 인터프리트 모드 스모크 테스트로, 인자 없는 $(BIN)/$(MHS) -ilib -isrc Main; $(BIN)/eval이며, bootcombtest 자신이 반드시 동작해야 하는 것이 되고 나자 대체된다. TypeCheck.hs에서 중요한 변경은 네 줄로, 모든 최상위 정의 자신의 검사 사이에서 호출되는 새 함수다 (a38c64ce:src/MicroHs/TypeCheck.hs 360–364행, 전문 인용):

-- Reset type variable and unification map
tcReset :: T ()
tcReset = T.do
  TC mn _ tenv venv _ <- get
  put (TC mn 0 tenv venv IM.empty)

— 모듈의 정의 목록을 걷는 두 호출 지점인 tcDefsType과 tcDefsValue에 연결되며, 둘 다 T.mapM tcDefType ds / T.mapM tcDefValue ds에서 T.mapM (\ d -> T.do {tcReset; tcDefType d}) ds와 값 검사 쪽의 동등한 형태로 바뀐다. 이 커밋 이전에는 새 메타변수 카운터와 단일화 치환 맵이 모듈 안의 모든 최상위 정의에 걸쳐, 한 번도 비워지지 않은 채 계속 쌓였다; 이 커밋 이후에는 각 정의가 EUVar 0과 빈 치환에서 다시 시작한다. 커밋 메시지는 그 리셋 없이 컴파일러 자신의 ~30개 파일짜리 소스 — 결과로 나오는 mhs.comb에서 직접 센 571개의 서로 다른 최상위 컴비네이터 레이블 — 를 검사하는 것이 실제로 무엇을 했는지 설명하지 않는다 — 단순히 느렸던 것인지, 그보다 더 나쁜 무언가였는지 — 다만 tcReset을 더하는 것이 마침내 동작하게 만든 것이라고 제목이 보고할 뿐이다. 그와 나란히, 범위가 더 좁은 정확성 수정 두 개도 있다: ESectR의 case는 검사된 연산자를 곧바로 EVar ii로 패턴 매칭하던 것이, 평범한 함수가 아니라 데이터 생성자로 만들어진 섹션이면 크래시를 냈다(검사기 자신의 EVar case는 tLookupInst에서 EVar뿐 아니라 ECon cti qi도 반환할 수 있다) — 일반적인 var와 매칭하고 getIdent로 이름을 뽑아내도록 고쳐졌다(a38c64ce:src/MicroHs/TypeCheck.hs 694행: T.return (ESectR (getIdent var) ee, t)). 그리고 primTypes는 새 항목 "Any"를 하나 얻는데, 같은 커밋이 lib/Unsafe/Coerce.hs의 unsafeCoerce :: a -> b에 forall a b . 를 더하기도 한다 — "Any" 프리미티브 타입과 unsafeCoerce의 강제된 전칭 정량화가 함께 도착한다. diff의 나머지 — StateIO.hs에서 이제 둘 다 스코프에 있는 두 return 사이에서 모호했던 맨 return을 MicroHs.StateIO.return으로 한정한 것, Compile.hs에서 한정되지 않은 Handle 임포트를 고친 것, Desugar.hs의 단일 분기 case 처리에서의 대체 분기 손질 — 은 위의 수정 연속선이 이미 이 범위의 지배적인 결이라고 확립해 놓은 것과 같은 셀프 호스팅 호환성 정비다. 이 커밋에서 bootcombtest가 그것을 실행하는 그대로 빌드하고 실행해 보면(아래 "직접 해보기" 참고): bin/mhs가 소스로부터 MicroHs.Main 을 컴파일하고; mhs 자신의 컴비네이터 번역본을 인터프리트하는 bin/eval이 같은 소스를 두 번째로 컴파일하며; 두 출력물은 각각 140,782바이트로 바이트 단위로 동일하다. 타입이 있는 컴파일러가 스스로를 타입 검사하며, 스스로를 컴파일한다.

밝히지 않은 부가 사항

제목이 아니라 diff를 읽어서 찾아낸 세 가지를 여기 모은다: 33b009fa가 (EType의 출력이 아니라 그 표현에 관한 제목 아래서) 조용히 도입하고 617475b6이 거의 열엿새 뒤 조용히 고치는, 그 사이 단 한 번도 실행되지 않는 showEType = showEType 무한 루프; 64345fe2("More type checking fixes.")가 GHC 아래서 단독으로 컴파일되지 않는다는 것으로, 다음 커밋의 메시지를 믿는 대신 직접 빌드해 봐야만 발견되는 사실; 그리고 "Transfer"라 불리는 두 커밋 3437518f/031d2125로, 제목은 무엇이 옮겨지는지 아무것도 말하지 않지만 diff — 114줄과 6줄, 둘 다 TypeCheck.hs에 국한됨 — 는 적어도 자신이 주장하는 바와 부합한다. 제목이 전혀 언급하지 않은 안전 플래그를 떨어뜨린 8장의 8e2f7109와는 다르다. 이 장에서 찾아낸 것 중 그 수준까지 올라가는 것은 없다 — 여기 있는 것은 모두 효과가 제목과 모순되는 것이 아니라, 범위가 분명히 언급되지 않은 것들이다.

왜 중요한가

타입은 가장 마지막에 왔다. 처음도 아니고, 파서와 나란히도 아니고, do-표기법이나 모듈과 나란히도 아니다 — 언어와 런타임, IO, 셀프 호스팅이 모두 동작한 뒤에, 이미 스스로를 3주 동안 컴파일해 왔고 그 소스 안의 모든 시그니처가 그동안 아무것도 하지 않던 컴파일러 위에서다. 그 순서는 어느 한 프로젝트의 우연이 아니라 이 책이 다루는 방법의 전체 모양이다: 3장의 dsDef (Sign _ _) = []는 두 달 남짓 동안 시그니처를 참이 되게 해야 한다는 어떤 의무도 없이 문서로서 적을 수 있게 해 주었고; 5장의 습관들 — 자주 커밋하기, 무엇이 깨졌는지 적어 두기, 실험 노트를 유지하기 — 은 정확히 9a392312와 617475b6이, 미완성임을 인정하는 제목 아래 아흐레 떨어져 있으면서도 프로젝트가 멎지 않게 해 준 것이며; 8장의 셀프 호스팅 총력전은 이 장에 tcReset이 애초에 필요할 만큼 큰 작업 부하 — 컴파일러 자신의 ~30개 파일짜리 소스 — 를 준 것이고, 이는 더 작은 어떤 것으로도 드러나지 않았을 수정이다. 타입 없는 컴파일러는 타입이 있는 컴파일러를 만드는 데서 벗어난 우회로가 아니었다 — 그것은, 누군가 증명해야 하기 전에 이미 동작하던 언어 위에서, 한 번에 작은 커밋 하나씩, 타입이 있는 컴파일러를 만드는 일을 다루기 쉽게 만든 발판이었다. 2023-05-25, 두 파일 Lam.hs와 eval.c. 77일 뒤 2023-08-10, a38c64ce에서, 스스로를 컴파일하며 스스로를 검사하는 셀프 호스팅 타입 컴파일러. 이 기록이 여기서 멈추는 이유는, 00-overview.md가 약속한 이야기가 전부 이야기되었다고 git log가 말해 주는 지점이 바로 여기이기 때문이다: "8월 초까지 이어지는 마지막 구간은 힌들리-밀너 타입 검사기를 더하고 셀프 호스팅 컴파일러가 타입 검사와 함께 부트스트랩하게 만든다..."

실험 노트 여백

  • "Temp commit"과 "Temp"는 채우기용 제목이 아니다 — 이들은 일부러 생각 도중에 커밋된 저장점으로, 9일 21시간 51분 떨어져 있으며 그 공백에 대한 설명은 사이에 없다. 이 책이 5장 이래로 추적해 온 습관 — 동작하지 않아도 커밋한다 — 이야말로 617475b6의 608줄짜리 TypeCheck.hs diff 같은 몇 주짜리 다시 쓰기를, 9a392312의 199줄짜리 첫 시도를 잃지 않고도 가능하게 만드는 것이다.
  • 기능보다 리팩터링이 먼저, 다시 한번: 33b009fa/e5bc9236의 준비(타입을 Expr로, 변수 하나짜리 람다)는 c0b279fc의 실제 검사기 코드 첫 줄보다 꼬박 하루 앞서 도착한다 — 3장의 "실험 노트 여백"이 4e4c5252와 ff4622e8에 대해 이름 붙였던 것과 같은 리듬이, 이제 파서가 아니라 검사기 자신의 전제 조건들 위에서 한 단계 위 수준으로 작동하고 있다.
  • 수정 연속선의 리듬: 커밋 여섯 개, 사흘, 가운데 온전히 조용한 이틀(08-08, 08-09) — 그리고 그 연속선의 바로 마지막 커밋인 64345fe2는 단독으로는 빌드조차 되지 않는다. 진짜 버그를 고치는 것(다중 인자 타입 시노님)과 셀프 호스팅 이식성을 고치는 것(한정된 T.do 바인드, 벗겨낸 HasCallStack)이 "정확성" 패스와 "정리" 패스로 나뉘지 않고 같은 커밋들 안에서 함께 일어난다.
  • 17b450fc("Add more lib modules.")는 컴파일러가 왜 IntMap을 필요한지 결코 말하지 않는다 — TypeCheck.hs 자신의 치환 맵과의 연결은 두 커밋 메시지 어느 쪽에서도 아니라, 두 파일을 모두 읽어야만 읽어낼 수 있다.

직접 해보기

기준점: a38c64ce("Now bootstraps with type checking."), 2023-08-10 20:37:30 — 타입이 있는 셀프 컴파일이 스스로와 일치하게 만드는 커밋이다. 관례(참고: 00-overview.md):

git worktree add /tmp/mhs-a38c64ce a38c64ce
cd /tmp/mhs-a38c64ce

이 커밋 시점에는 빌드 노트 패치가 필요 없다 — 2장, 3장을 비롯한 이 책의 다른 장들이 우회해야 했던 GHC-9.8/mtl 문제들은, 프로젝트의 이 지점에 이르러서는 Main.hs 자신의 임포트 목록에서 사라지고 없다. 두 바이너리를 쓰인 그대로 빌드한다:

mkdir -p bin
nix develop $MICROHS -c make bin/mhs
nix develop $MICROHS -c make bin/eval
[...]
[16 of 16] Linking bin/mhs
gcc -Wall -O3 src/runtime/eval.c -o bin/eval

(이전 모든 장의 빌드 노트가 이미 짚은 같은 -Wx-partial head/tail 경고가 bin/mhs 빌드 중에도 나타난다; 생략.) 먼저 평범한 테스트 스위트를 실행한다 — tests/*.hs 파일 일곱 개(위의 "무슨 일이 있었나"에서처럼 cc57346e가 main :: IO ()를 추가해야 했던 여섯 개에, 이미 그것이 있던 Case.hs를 더한 것)로, 각각 컴파일되어 그 출력이 저장소에 체크인된 .ref 파일과 diff된다:

nix develop $MICROHS -c make test
cd tests; make test
../bin/mhs -i../lib IOTest && (echo q | ../bin/eval -- a bb ccc > IOTest.out) && diff IOTest.ref IOTest.out
../bin/mhs -i../lib StringTest && ../bin/eval > StringTest.out && diff StringTest.ref StringTest.out
../bin/mhs -i../lib ListTest && ../bin/eval > ListTest.out && diff ListTest.ref ListTest.out
../bin/mhs -i../lib Fac && ../bin/eval > Fac.out && diff Fac.ref Fac.out
../bin/mhs -i../lib Misc && ../bin/eval > Misc.out && diff Misc.ref Misc.out
../bin/mhs -i../lib Case && ../bin/eval > Case.out && diff Case.ref Case.out
../bin/mhs -i../lib Arith && ../bin/eval > Arith.out && diff Arith.ref Arith.out

위의 diff는 모두 조용하다 — 일곱 개 전부 통과한다. 이제 핵심 장면, 8장의 "직접 해보기"가 타입 검사 없이 실행했던 그 동일한 bootcombtest 타깃이다 — 먼저 make bootcombtest 전체를, 그다음 8장 자신의 "직접 해보기"가 하듯 세 줄 각각의 시간을 따로 잰다:

nix develop $MICROHS -c bash -c 'time make bootcombtest'
bin/mhs -ilib -isrc -omhs.comb MicroHs.Main
bin/mhs -ilib -isrc -omain-mhs.comb  MicroHs.Main
bin/eval -H50000000 -rmhs.comb --  -ilib -isrc -omain-comb.comb MicroHs.Main
cmp main-mhs.comb main-comb.comb

real	0m37.674s
nix develop $MICROHS -c bash -c 'time bin/mhs -ilib -isrc -omain-mhs.comb MicroHs.Main'
real	0m1.794s
nix develop $MICROHS -c bash -c 'time bin/eval -H50000000 -rmhs.comb -- -ilib -isrc -omain-comb.comb MicroHs.Main'
real	0m32.254s

cmp는 아무것도 출력하지 않고 종료 코드 0으로 끝난다: main-mhs.comb와 main-comb.comb — 각각 140,782바이트, ls -la와 두 번째 명시적 cmp 실행으로 확인함 — 은 바이트 단위로 동일하다. 이 장 자신의 출발점인 298c9824에서의 8장 자신의 수치(75,052바이트 출력물, 네이티브 0.890초 / 인터프리트 20.715초, 타입 검사 전혀 없음)와 비교하면: 타입이 있는 컴파일러의 셀프 컴파일 출력물은 거의 두 배 크고, 네이티브 컴파일은 약 두 배 느리며(1.794초), 인터프리트 단계는 약 55% 느리다(32.254초) — 그리고 이제 그 출력물의 모든 바이트가 도중에 unify를 거쳤다. 8장이 확립한 것과 같은 컴비네이터 수준 오라클이, 이제 8장이 증명할 수 없었던 것을 증명한다: 타입이 없는 컴파일러가 그런다는 것뿐 아니라, 타입 검사기가 자신의 소스에 대해 스스로와 일치한다는 것이다.

연습 문제 1: 의도적인 타입 오류를 원인까지 추적하기

cat > TypeErr.hs <<'EOF'
module TypeErr(module TypeErr) where
import Prelude

bad :: Int
bad = 1 + "hello"

main :: IO ()
main = putStrLn (showInt bad)
EOF
nix develop $MICROHS -c ./bin/mhs -ilib TypeErr
mhs: Cannot unify [Char] and Primitives.Int
EApp (EVar "Data.List.[]") (EVar "Primitives.Char") - EVar "Primitives.Int"
Map [("Data.Char.String",ETypeScheme [] (EApp (EVar "Data.List.[]") (EVar "Primitives.Char")))]
CallStack (from HasCallStack):
  error, called at src/MicroHs/TypeCheck.hs:334:11 in main:MicroHs.TypeCheck
  unifyR, called at src/MicroHs/TypeCheck.hs:326:3 in main:MicroHs.TypeCheck
  ...

(종료 코드 1; 위에서 CallStack의 나머지 — + 자신의 두 인자 검사에서 나오는 더 많은 unify/unifyR 프레임들 — 은 생략했다.) 이 메시지는 unifyR의 bad 바인딩에서 나온다 (a38c64ce:src/MicroHs/TypeCheck.hs 334행, 인용): bad = error $ "Cannot unify " ++ showExpr a ++ " and " ++ showExpr b ++ "\n". 1 + "hello"는 "hello"의 타입인 [Char](String은 그것의 타입 시노님이라, Map 줄이 Data.Char.String이라는 이름을 담고 있다)가, +의 두 번째 인자 타입인 Int와 단일화되기를 요구하는데 — unifyR의 구조적 case가 곧바로 거부하는 EApp/EVar 불일치이며, aux/hindley-milner-gently.md의 단일화 절이 손으로 추적하는 것과 정확히 같다.

연습 문제 2: 검사기에 필요한 시그니처 지우기

(python3는 여기서도 devShell이 아니라 호스트 시스템에서 오는 것이다 — 1장이 diff에 대해 밝힌 것과 같은 주의 사항이다.)

python3 -c "
with open('lib/Data/List.hs') as f:
    lines = f.readlines()
out = [l for l in lines if l.strip() != 'null :: forall a . [a] -> Bool']
with open('lib/Data/List.hs', 'w') as f:
    f.writelines(out)
"
nix develop $MICROHS -c ./bin/mhs -ilib Example
mhs: undefined variable null
CallStack (from HasCallStack):
  error, called at src/MicroHs/TypeCheck.hs:382:16 in main:MicroHs.TypeCheck

추론은 대처하지 못한다 — 누락된 최상위 시그니처가 기댈 만한 추론 단계가 없다. addValueType(a38c64ce:src/MicroHs/TypeCheck.hs 553–559행)은 Sign을 볼 때만 값 테이블에 이름을 추가한다; 같은 모듈 안에 대응하는 Sign이 없는 Fcn은 결코 하나를 얻지 못하고, 그 이름에 대한 tcDefValue의 tLookup i는 곧장 "찾지 못함" 분기로 떨어진다 — null이 Example.hs 안 어디에서도 호출되지 않더라도, 무언가가 그것을 임포트하는 순간 lib/Data/List.hs 자신은 여전히 모듈 전체로서 타입 검사를 받아야 하고, 쓰이든 안 쓰이든 그 안의 최상위 바인딩 하나하나가 검사를 받으려면 자신의 시그니처가 있어야 한다. 계속하기 전에 파일을 되돌린다:

git checkout -- lib/Data/List.hs

연습 문제 3: 프렐류드 함수를 손으로 추론한 뒤 소스와 대조하기

lib/Data/Function.hs, flip:

flip :: forall a b c . (b -> a -> c) -> a -> b -> c
flip f a b = f b a

검사기 자신의 알고리즘에 맞춰 flip f a b = f b a의 본문을 손으로 추론해 본다(tcExpr'의 EApp case: g x를 검사하려면, 먼저 x를 기대 타입 없이 검사한 다음, g를 (x의 타입) -> (새 결과)에 대해 검사한다):

  1. f b a는 EApp (EApp f b) a다. 바깥 적용을 검사하면: a (인자)가 먼저 타입을 얻는다 — 람다로 속박된 변수이므로, 그 타입은 flip 자신의 ELam case가 a를 속박할 때 준 새 메타변수다; 이를 τ_a라 하자. 식 전체의 결과도 새 메타변수 τ_r을 얻는다. 그런 다음 f b가 기대 타입 τ_a -> τ_r에 대해 검사된다.
  2. f b 자신은 EApp f b다. b의 타입은 그 자신의 바인딩의 메타변수 τ_b다. f는 τ_b -> (τ_a -> τ_r)에 대해 검사된다. f 역시 속박 변수이며, flip의 가장 바깥 ELam에서 온 자신의 메타변수 τ_f를 가진다; τ_f를 τ_b -> (τ_a -> τ_r)에 대해 단일화하면 곧바로 이를 결정짓는다(f의 타입은 바로 τ_b -> τ_a -> τ_r이며, 더 검사할 것이 남지 않는다).
  3. 풀어내면: flip의 구성된 타입은 τ_f -> τ_a -> τ_b -> τ_r이고, 2단계의 결과를 τ_f에 대입하면: (τ_b -> τ_a -> τ_r) -> τ_a -> τ_b -> τ_r. 선언된 (b -> a -> c) -> a -> b -> c와 단일화하면: τ_b ↦ b, τ_a ↦ a, τ_r ↦ c, 모든 메타변수가 정확히 시그니처 자신의 이름들로 풀리며 모순이 없다 — aux/hindley-milner-gently.md의 const에 대한 손 풀이가 끝나는 것과 같은 모양이다.

소스와 대조하면: a38c64ce:lib/Data/Function.hs 자신의 flip 시그니처는 정확히 forall a b c . (b -> a -> c) -> a -> b -> c로, 위에서 손으로 추론한 타입과 항 단위로 일치한다. 눈으로 확인하는 데 그치지 않고, 구체적인 타입에서 그것을 쓰는 프로그램으로 실제로 확인했다:

cat > FlipDemo.hs <<'EOF'
module FlipDemo(module FlipDemo) where
import Prelude

main :: IO ()
main = putStrLn (showInt (flip (-) 3 10))
EOF
nix develop $MICROHS -c ./bin/mhs -ilib FlipDemo
nix develop $MICROHS -c ./bin/eval
7

(flip (-) 3 10 = (-) 10 3 = 7, b ↦ Int, a ↦ Int, c ↦ Int 모두 여기서는 같은 구체적 타입으로 인스턴스화되며, 그래서 이 호출 하나만으로는 flip의 타입이 단형적인 것과 구별되지 않는다 — 위 손 추적의 요점은, 검사기가 미리 그것을 알 필요가 전혀 없었다는 것이다.)

정리한다:

git worktree remove --force /tmp/mhs-a38c64ce

🌐 Read in English

1장의 배경지식.

람다 대수와 SKI 컴비네이터

이 글은 Lam.hs와 eval.c가 기반하는 이론을, 추상적으로가 아니라 실제 첫 커밋 코드를 통해 설명한다. 람다 대수나 컴비네이터를 한 번도 본 적이 없다면, 이 글이 필요한 사전 지식의 전부다.

1. Exp: 람다 대수를 위한 AST, 그리고 실용적인 확장 세 가지

ccb31388:Lam.hs (44–51행):

data Exp
  = Var Ident
  | App Exp Exp
  | Lam Ident Exp
  | Int Integer
  | Comb String
  | Prim String
  deriving (Show, Eq)

Var, App, Lam은 람다 대수의 세 가지 항 구성자를 그대로 옮긴 것이다: 변수 참조, 함수 적용, 그리고 변수를 속박하는 추상화(Lam "x" e는 \x. e)다. 순수 람다 대수에서 만들 수 있는 모든 것은 이 셋 중 하나다.

나머지 세 생성자는 람다 대수가 아니다 — 컴파일러가 쓸모 있으려면 필요한 것들이다. Int는 기계 정수 리터럴이라, 산술 연산을 처치 인코딩(Church encoding)으로 표현하지 않아도 된다. Prim은 eval.c가 네이티브로 구현하는 "+"나 "-" 같은 내장 연산의 이름이다. Comb은 컴비네이터 상수(S, K, I, ...)의 이름인데 — 이들은 compile이 도입하는 것들이다; bracket-abstraction.md를 보라. (이 파일은 App2, CI, CK 같은 패턴 동의어도 정의하는데, 이는 이 여섯 생성자 위에 가독성을 위해 얹은 설탕일 뿐, 새로운 문법이 아니다.)

2. 종이 위의 베타 축약

ccb31388:Lam.hs (36–39행):

e1 :: Exp
e1 = Lam "x" $ App2 (Prim "+") (Var "x") (Var "x")
e2 :: Exp
e2 = Lam "x" $ Lam "y" $ App2 (Prim "-") (Var "y") (Var "x")

putStrLn (toString e1)은 (\x. + x x)를 출력하고, putStrLn (toString e2)는 (\x. (\y. - y x))를 출력한다 (ghci에서 확인). 람다 대수의 유일한 계산 규칙은 베타 축약이다: 람다를 인자에 적용하면 본문 전체에서 속박 변수를 그 인자로 치환한다.

(\x. + x x) 5  -->  [x := 5](+ x x)  =  + 5 5  -->  10

이건 그냥 수학일 뿐, 코드에 대한 주장이 아니다. 다음은 코드에 대한 주장이며 기계로 검증한 것이다: Lam.hs 자체의 reduce 함수가 실제로 이 단계를 수행하는가?

ghci> pp (reduce (App e1 (Int 5)))
(\x. + x x) 5

축약되지 않는다 — 항이 그대로 돌아온다. reduce/redOne(137–161행)은 컴비네이터(CI, CK, CS, ...)와 Prim 적용에 대해서만 패턴 매칭한다; Lam에 대한 케이스도, "Lam을 인자에 적용"하는 케이스도 없다. 순수한 Var/Lam 항에 대한 베타 축약은 이 파일 어디에도 구현되어 있지 않다. 그것이 다음 절의 주제다.

3. 왜 변수는 기계에게 문제가 되는가

베타 축약을 직접 구현하려면 인터프리터는 변수 포획을 피하면서 항 안으로 치환해야 한다 — \y. x에서 x를 y로 순진하게 치환하면, 치환 대상인 x가 바깥의 \y에 포획되어서는 안 되므로, 올바른 치환은 그때그때 속박 변수의 이름을 바꿔줘야 한다. 실전에서 흔히 쓰는 해결책은 아예 치환을 하지 않는 것이다: 항을 그대로 두고, Lam에 들어갈 때마다 커지는 환경(이름을 값/클로저에 대응시키는 맵)을 따로 들고 다닌다.

eval.c의 노드 표현에는 그런 것이 없다:

typedef struct node {
  enum node_mark mark;
  enum node_tag tag;
  union {
    value_t value;
    struct {
      struct node *fun;
      struct node *arg;
    } s;
  } u;
} node;

노드는 태그 하나와, 값 또는 (fun, arg) 쌍 중 하나로 이루어진다 — 환경이나 클로저처럼 보이는 것은 전혀 없다 (539행짜리 파일 어디에도 env/closure 필드가 없다). eval.c의 축약 루프(eval 함수, 5절 참고)는 이름으로 변수를 찾아볼 필요가 전혀 없는데, 항이 eval.c에 도달할 때쯤이면 이미 변수가 하나도 남아 있지 않기 때문이다. 그 변환 — 런타임 이전에 Var/Lam을 완전히 제거하는 것 — 이 바로 Lam.hs의 compile이 하는 일이며, 이를 브래킷 추상화라 부른다 (짝을 이루는 문서 참고).

4. S, K, I

세 개의 컴비네이터만으로 닫힌 람다 항은 모두 표현할 수 있다:

컴비네이터정의축약 규칙
I\x. xI x = x
K\x y. xK x y = x
S\f g x. f x (g x)S f g x = f x (g x)

각 규칙은 순수한 그래프 재작성이다: 왼쪽 모양에 맞으면 오른쪽 모양을 만들어낸다. 이름 조회도, 치환도, 걱정할 포획도 없다 — 애초에 이름이 없기 때문이다. 이것이 핵심이다: S, K, I, 프리미티브, 적용만으로 이루어진 항은 노드 모양을 패턴 매칭하기만 하는 기계로 실행할 수 있으며, 이것이 정확히 eval.c의 eval 루프가 하는 일이다.

구체적으로, 2절에서 실패했던 사례를 컴파일된 버전과 비교해 본다:

ghci> pp (reduce (App (compile e1) (Int 5)))
10

compile e1은 \x. + x x를 S (S (K +) I) I로 바꾸는데 (유도 과정은 bracket-abstraction.md 참고) — 이 항에는 Var/Lam이 전혀 없다 — 그리고 이 항은 reduce가 문제없이 처리한다.

5. 확장 집합: B, C, S', C', B', T, Y

S/K/I만으로 원리상 충분하지만, S만 쓰는 변환은 크기가 폭발적으로 커진다 (짝을 이루는 문서 참고). Lam.hs와 eval.c는 이보다 더 큰 컴비네이터 집합을 공유한다. 다음은 컴비네이터 케이스만 추려낸 축약 스위치문이다 (ccb31388:eval.c, eval 함수, 380–474행; 각 ...는 생략된 스택 관리 코드를 나타낸다 — CHECK/GCCHECK 가드, f/g/x/k 인자 가져오기, POP/TOP 조정 — 그리고 각 GOTO 뒤에 나오는 도달 불가능한 break;도 생략했다):

    case S:
      ...
      FUN(n) = new_ap(f, x);
      ARG(n) = new_ap(g, x);
      GOTO ap;
    case SS:
      ...
      FUN(n) = new_ap(k, new_ap(f, x));
      ARG(n) = new_ap(g, x);
      GOTO ap;
    case K:
      ...
      SETIND(n, x);
      GOTO ind;
    case T:
      ...
      x = ARG(TOP(2));
      ...
      SETIND(n, x);
      GOTO ind;
    case I:
      ...
      SETIND(n, x);
      GOTO ind;
    case Y:
      ...
      FUN(n) = f;
      ARG(n) = n;
      GOTO ap;
    case B:
      ...
      FUN(n) = f;
      ARG(n) = new_ap(g, x);
      GOTO ap;
    case C:
      ...
      FUN(n) = new_ap(f, x);
      ARG(n) = g;
      GOTO ap;
    case CC:
      ...
      FUN(n) = new_ap(k, new_ap(f, x));
      ARG(n) = g;
      GOTO ap;

(pretty(), 288–316행은 태그 SS를 S'로, BB를 B'로, CC를 C'로 출력한다 — enum 이름과 소스 언어상의 이름이 서로 다르다는 점은, 존재하지도 않는 case S'를 찾아 헤매기 전에 알아두면 좋다.) Lam.hs 자체의 참조 축약기 redOne(147–161행; 마지막의 프리미티브 산술 케이스와 Nothing catch-all은 생략했으며 ...로 표시)은 같은 규칙을 순전히 Exp의 관점에서 기술한다:

redOne :: Exp -> Maybe Exp
redOne (App CI x) = Just x
redOne (App (App CK x) _) = Just x
redOne (App (App CT _) x) = Just x
redOne (App3 CS f g x) = Just $ App (App f x) (App g x)
redOne (App3 CB f g x) = Just $ App      f    (App g x)
redOne (App3 CC f g x) = Just $ App (App f x)      g
redOne (App4 CS' k f g x) = Just $ App2 k (App f x) (App g x)
redOne (App4 CB' k f g x) = Just $ App2 k      f    (App g x)
redOne (App4 CC' k f g x) = Just $ App2 k (App f x)      g
...

컴비네이터마다 규칙이 하나씩 있으며, 모두 실제로 동작하는 코드(Lam.hs의 reduce, 그리고 eval.c를 컴파일한 실제 eval 바이너리)에 대해 기계로 검증했다:

컴비네이터규칙검증
I x= xreduce (App CI (Int 9)) → 9; eval에서 (I 9) → 9
K x y= xreduce (App2 CK (Int 1) (Int 2)) → 1; eval에서 ((K 1) 2) → 1
T x y= yreduce (App2 CT (Int 1) (Int 2)) → 2; eval에서 ((T 1) 2) → 2
S f g x= f x (g x)reduce (App3 CS (Prim "+") CI (Int 5)) → 10; eval에서 (((S +) I) 5) → 10
B f g x= f (g x)reduce (App3 CB (App CK (Int 100)) CI (Int 7)) → 100; eval에서 (((B (K 100)) I) 7) → 100
C f g x= f x greduce (App3 CC (Prim "-") (Int 3) (Int 5)) → 2 (즉 5 - 3); eval에서 (((C -) 3) 5) → 2
S' k f g x= k (f x) (g x)reduce (App4 CS' (Prim "+") CI CI (Int 5)) → 10; eval에서 ((((S' +) I) I) 5) → 10
C' k f g x= k (f x) greduce (App4 CC' (Prim "-") CI (Int 3) (Int 10)) → 7; eval에서 ((((C' -) I) 3) 10) → 7
B' k f g x= k f (g x)reduce (App4 CB' (Prim "-") (Int 10) CI (Int 3)) → 7 — 단, 아래를 보라
Y f= f (Y f) (자기 적용 노드, 434행: ARG(n) = n)eNfib를 통해 실제로 실행됨; ./eval nfib.comb가 29860703을 출력 (nfib 35)

이 글의 원칙이 "가정하지 말고 검증하라"인 만큼, 짚고 넘어갈 만한 진짜 불일치가 하나 있다: B'(enum상 BB)는 eval.c의 node_tag enum에 선언되어 있고, 파서 지원도 있으며('B' 뒤에 '), pretty() 케이스도 있다 — 그러나 eval 스위치문에는 case BB:가 없다. B' 노드를 축약해야 하는 항을 eval.c에 넣으면 죽어버린다:

$ ./eval test_bprime.comb      # ((((B' -) 10) I) 3)
((((B' -) 10) I) 3)
bad tag 12
ERR: eval tag

(태그 12가 BB다 — enum node_tag 목록을 세어보라: FREE=0, IND=1, AP=2, INT=3, S=4, K=5, I=6, B=7, C=8, T=9, Y=10, SS=11, BB=12, ....) Lam.hs 자체의 redOne은 B'를 실제로 구현하고 있으니, 두 파일이 여기서 서로 어긋난다 — 첫 커밋에 있는 작지만 진짜인 틈이지, 이 글의 실수가 아니다. 짝을 이루는 문서에서 보이듯, 실제로는 문제가 되지 않는다: 이 커밋에서 compile/improve는 B'를 결코 만들어내지 않기 때문이다.

다음: bracket-abstraction.md — compile이 Var/Lam 항을 정확히 이 컴비네이터들로 바꾸는 방법.

🌐 Read in English

1장의 배경지식.

브래킷 추상화

lambda-calculus-and-ski.md에서 eval.c는 오직 컴비네이터 항만 실행할 수 있다는 것을 확인했다 — Var도 Lam도 없이. 브래킷 추상화는 하나에서 다른 하나로 가는 알고리즘이다: Lam x body가 주어지면, 자유 변수 x가 없는 컴비네이터 식 E를 만들어내는데, 이 E는 어떤 인자에 적용하든 body에서 x가 그 인자에 속박되었을 때와 똑같이 동작한다. 이를 안쪽에서 바깥쪽으로 재귀적으로 수행하면 항 안의 모든 Lam이 변수 하나씩 사라진다.

1. compile과 abstract

ccb31388:Lam.hs, 92–101행, 전문 인용 (두 함수를 합쳐 열 줄):

compile :: Exp -> Exp
compile (App f a) = App (compile f) (compile a)
compile (Lam x a) = abstract x a
compile e = e

abstract :: Ident -> Exp -> Exp
abstract x e@(Var y) | x == y = CI
abstract x (App f a) = App2 CS (abstract x f) (abstract x a)
abstract x (Lam y e) = abstract x $ improveC $ abstract y e
abstract x e = App CK e

compile은 항을 순회하며 App으로 재귀하고, Lam x a를 만나면 abstract x a에게 넘긴다 — "a에서 x를 제거하라." compile e = e는 Var/Int/Comb/Prim을 커버한다: 뿌리에 App/Lam이 없는 항에는 더 할 일이 없다.

abstract x e는 정확히 네 개의 방정식으로 이루어져 있으며, 각각이 표준 브래킷 추상화 규칙이다:

  • abstract x (Var y) | x == y = CI — x 자신에서 x를 추상화하면 항등원이 나온다: (\x. x) = I.
  • abstract x (App f a) = App2 CS (abstract x f) (abstract x a) — 적용에서 x를 추상화하려면, 양쪽 모두에서 x를 추상화한 뒤 S로 이어 붙인다. S는 정확히 "두 갈래 모두를 같은 인자에 적용"하는 것이기 때문이다: S (abstract x f) (abstract x a) y = (abstract x f) y ((abstract x a) y).
  • abstract x (Lam y e) = abstract x $ improveC $ abstract y e — 중첩된 Lam은 안쪽에서 바깥쪽으로 추상화한다: 먼저 안쪽 속박 변수 y를 (재귀적으로) 제거하고, 나온 결과를 단순화한 다음(improveC, 3절), 그다음 그것에서 x를 제거한다.
  • abstract x e = App CK e (나머지 모든 경우: e가 y /= x인 Var y이거나 Int/Comb/Prim인 경우) — x가 e에 자유 변수로 아예 나타나지 않으므로, 결과는 무엇에 적용되든 무시하고 항상 e를 내놓아야 한다: 그것이 바로 K e다, K e y = e이므로.

이 네 방정식을 합치면 abstract가 Var x를 결코 남기지 않는다는 것에 대한 완전하고 기계적인 증명이 된다: 모든 경우가 x를 직접 대체하거나(CI), 엄밀히 더 작은 부분항으로 재귀하거나(App, Lam), x가 거기 없으므로 제거한다(App CK e).

2. 예시 추적: compile e1

e1 = Lam "x" $ App2 (Prim "+") (Var "x") (Var "x"), 즉 \x. + x x다. compile e1 = abstract "x" (App (App (Prim "+") (Var "x")) (Var "x"))이다. App 방정식을 적용한 뒤 양쪽으로 재귀하면:

abstract x (App (Prim "+") (Var "x"))
  = App2 CS (abstract x (Prim "+")) (abstract x (Var "x"))
  = App2 CS (App CK (Prim "+"))     CI
  = S (K +) I

abstract x (Var "x") = CI

compile e1 = App2 CS (S (K +) I) CI = S (S (K +) I) I

기계로 검증 (nix develop $MICROHS -c ghci Lam.hs 실행 후 pp (compile e1)):

ghci> pp (compile e1)
S (S (K +) I) I

손으로 계산한 것과 정확히 일치한다. 그리고 실제로 실행된다: reduce (App (compile e1) (Int 5))는 10을 내놓는다, ghci에서 확인했다 — 컴비네이터 형태가 \x. + x x를 5에 적용했을 때와 똑같이 동작하는 것이다.

3. improve

S (S (K +) I) I는 이미 중복이 있다: S (K f) I는 고전적인 항등식 S (K f) I x = (K f) x (I x) = f (I x) = f x를 만족한다, 즉 S (K f) I와 f는 동일하게 동작한다 — S (K f) I 래퍼는 아무 이득도 없다. improveC(ccb31388:Lam.hs, 106–117행)는 바로 이런 패턴과 비슷한 패턴을 찾아내 없애버린다:

improveC :: Exp -> Exp
improveC (App f a) =
  case (improveC f, improveC a) of
    (App CS (App CK e1),     App CK e2) -> improve (App CK (App e1 e2))
    (App CS (App CK e1),     CI)        -> e1
    (App CS (App CK e1),     e2)        -> improve (App2 CB e1 e2)
    (App CS         e1,      App CK e2) -> improve (App2 CC e1 e2)
    (App CS (App2 CB e1 e2), e3)        -> improve (App3 CS' e1 e2 e3)
    (App CC (App2 CB e1 e2), e3)        -> improve (App3 CC' e1 e2 e3)
--    (CK,                     CI)        -> CT
    (e1,                     e2)        -> App e1 e2
improveC e = e

improve = improveT . improveC(104행)다; improveT(119–135행)는 같은 방식으로 한 번 더 훑는다. 그 첫 번째 방정식은 또 하나의, 더 작은 래퍼 제거 규칙이다 (improveC 자신의 사본은 위 115행에서 주석 처리되어 있다 — 이것이 실제로 동작하는 버전이다):

    (CK,                     CI)        -> CT

S/C/B로 둘러싸인 K와 I가 함께 있으면 단일 컴비네이터 T로 뭉쳐진다 (T x y = y, lambda-calculus-and-ski.md 참고). improveT의 나머지 부분은 교환 가능/뒤집기 가능한 프리미티브를 정규형으로 만들어 improveC의 CC 규칙도 적용될 수 있게 하는데 — 여기 전문을 싣지는 않지만, 같은 "패턴을 찾아 재작성한다"는 방식이다.

S (S (K +) I) I에 적용해 보면: 안쪽의 S (K +) I는 (App CS (App CK e1), CI) -> e1에 매치되어 곧바로 +로 뭉쳐진다. 이제 바깥쪽 적용은 App2 CS (Prim "+") CI인데, 이 조각들(App CS (Prim "+"), CI)은 위의 다섯 가지 특수 패턴 중 어느 것에도 매치되지 않으므로 (왼쪽은 App CS (App CK e1)도 App CS (App2 CB e1 e2)도 아니고, 오른쪽은 App CK e2도 아니다), 안쪽 뭉침 외에는 그대로 기본 케이스로 떨어져 S + I가 된다. 기계로 검증:

ghci> pp (improve (compile e1))
S + I

다섯 개 대신 세 개의 컴비네이터가 되었고, reduce (App (improve (compile e1)) (Int 5))는 여전히 10을 낸다.

4. 왜 이게 중요한가: S만 쓰는 순진한 변환은 폭발한다

순수한 S/K/I 브래킷 추상화 규칙(abstract의 네 방정식, CS/CK/CI만 사용)만으로도 그 자체로 충분하다 — App/Lam 방정식이 종료하는 데 improveC가 필요한 것은 전혀 아니다. 하지만 Lam 하나를 추상화할 때마다 그 본문 전체를 또 한 겹의 S로 감싸게 되고, 중첩된 Lam은 이를 곱해서 늘린다: abstract x (Lam y e)는 먼저 e에서 y를 완전히 추상화하고(e 크기만 한 S/K/I 항이 된다), 그다음 그 항 전체 에서 x를 추상화한다 — 그래서 k겹 중첩된 람다는 컴비네이터 개수를 k에 대해 지수적으로 부풀릴 수 있다. 구체적으로, abstract의 App 케이스에서 App2 CS (abstract x f) (abstract x a)는, x가 f나 a에 전혀 나타나지 않을 때조차 x의 최종 인자가 될 것을 S마다 한 번씩 중복시킨다 (이것이 정확히 위에서 improveC가 청소하고 있는 S (K _) I/S (K _) e 낭비다).

improveC의 추가 컴비네이터들은 표준적인 키셀료프(Kiselyov) 방식의 해법이다:

  • B f g x = f (g x) — x가 두 번째 인자에만 나타날 때 쓰인다 (왼쪽이 App CK e1이고 오른쪽이 사소하지 않은 경우): 한쪽 갈래만 x를 쓸 때는 S가 x를 양쪽 갈래에 중복시킬 필요가 없다.
  • C f g x = f x g — 거울상 경우: x가 첫 번째 인자에만 나타난다.
  • S' k f g x = k (f x) (g x)와 C' k f g x = k (f x) g — 이들은 S-노드의 왼쪽이 그 자체로 B-컴비네이터 적용일 때 발생한다 (App CS (App2 CB e1 e2) e3 / App CC (App2 CB e1 e2) e3): S 안에 B를 쌓는 대신, 합성과 분기를 한 축약 단계에서 함께 하는 컴비네이터 하나로 융합한다.

각 재작성은 중복된 S/K/I 래퍼를 아예 없애거나, 두 컴비네이터 적용을 하나로 합친다. 그래서 출력은 중첩 깊이에 따라 커지는 대신 줄어든다. 확장 집합의 구성원 중 하나는 눈에 띄게 빠져 있다: abstract, improveC, improveT 어디에서도 B'(B' k f g x = k f (g x))를 만들어내지 않는다 — 파일 전체에서 CB'가 언급되는 곳은 패턴 동의어 선언(62행과 70행)과 redOne 축약 케이스(155행)뿐이다. 그래서 이 커밋에서는 B'가 컴파일된 출력에 결코 나타날 수 없고, 이것이 eval.c 스위치문에서 case BB:가 빠져 있는 것(짝을 이루는 문서의 5절 참고)이 눈에 띄지 않는 이유다. 장난감이 아닌 실제 예시: 이 커밋의 nfib 함수(Lam.hs의 225행, eNfib)는 comp = improve . compile . toLam을 통해 다음으로 컴파일된다 (./eval nfib.comb에서):

((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)

— B, C, S', C'로 빽빽하고, 벌거벗은 S/K/I 없이 쓰인 S는 단 두 곳뿐이다. 세 개의 변수(nfib, n, 그리고 암묵적인 재귀 인자)가 여러 번 참조되는 함수치고는 그렇다. improve가 없었다면, 그 참조 하나 하나가 별도의 S (K _) _ 래퍼 비용을 치렀을 것이다.

5. 이해도 확인

e3 = Lam "x" $ Lam "y" $ Var "y", 즉 \x. \y. y — 첫 번째 인자를 무시하는 함수다. 아무것도 실행하기 전에, 1절의 abstract의 네 방정식을 이용해 compile e3를 예측해 보자. 그다음 improve가 그 답에 어떤 일을 하는지 예측해 보자 (힌트: 3절의 (CK, CI) -> CT 규칙). 그리고 직접 확인해 보자:

nix develop $MICROHS -c ghci Lam.hs
ghci> pp (compile e3)
ghci> pp (improve (compile e3))

기계로 검증된 정답 (예측하기 전에는 들여다보지 말 것): compile e3는 K I이고, improve (compile e3)는 T다 — 두 인자를 받아 "두 번째 인자를 돌려주는" 컴비네이터, T x y = y인데, lambda-calculus-and-ski.md에서 이미 다뤘다. 이 일치(improveC에서 주석 처리된 115행의 (CK, CI) -> CT 규칙은 죽어 있지만, improveT의 122행에 있는 살아 있는 (CK, CI) -> CT 규칙이 이를 잡아낸다) 자체가, abstract + improve가 \x. \y. y를 그 동작을 하는 가장 단순한 컴비네이터로 실제로 뭉쳐낸다는 작은 확인이다.

🌐 Read in English

1장의 배경지식.

그래프 축약과 지연 평가

lambda-calculus-and-ski.md와 bracket-abstraction.md는 Var/Lam이 하나도 남지 않은 컴비네이터 항까지 데려다준다. 이 글은 나머지 절반을 다룬다: ccb31388:eval.c, 그 항을 실제로 실행하는 약 540행짜리 C 프로그램이다. 핵심 아이디어는, 항이 여기저기 복사되어 다니는 트리가 아니라 — 변경 가능한 셀들로 이루어진 그래프이고, 축약은 셀을 제자리에서 재작성한다는 것이다. 바로 이 설계 선택 하나에서 공유와 지연성이 모두 나온다.

1. 셀은 태그 하나에 포인터 둘(또는 값 하나)

ccb31388:eval.c (23–33행):

typedef struct node {
  enum node_mark mark;
  enum node_tag tag;
  union {
    value_t value;
    struct {
      struct node *fun;
      struct node *arg;
    } s;
  } u;
} node;

그리고 태그 enum (17행):

enum node_tag { FREE, IND, AP, INT, S, K, I, B, C, T, Y, SS, BB, CC, ADD, SUB, MUL, DIV, MOD, SUBR, EQ, NE, LT, LE, GT, GE };

힙에 있는 모든 노드는 이 중 하나다. AP 노드는 함수 적용이다: 그 fun/arg 유니온 필드(FUN/ARG 매크로로 접근)는 다른 두 셀을 가리킨다. S나 K 같은 컴비네이터는 페이로드를 전혀 갖지 않는 노드다 — 태그 자체가 곧 값이다. INT 노드는 fun/arg 대신 value 필드를 쓴다. 컴파일된 프로그램 전체는 컴비네이터/INT/프리미티브 잎을 가진 하나의 큰 AP 셀 트리다 — 그러나 3절에서 보이듯, 축약이 시작되는 순간 "트리"라는 표현은 더 이상 정확하지 않게 되는데, 셀들이 재사용되고 서로 교차 연결되기 때문이다.

2. 스파인 스택과 S 재작성

eval의 임무는 최좌측-최외곽 리덱스를 찾는 것이다. 이를 위해 적용의 왼쪽 스파인을 따라 내려가며 — FUN, FUN, FUN, ... — 지나치는 모든 노드를 명시적 스택에 쌓다가, AP가 아닌 것을 만나면 멈춘다. 스택 매크로들(ccb31388:eval.c, 99–101행):

#define PUSH(x) stack[++stack_ptr] = (x)
#define TOP(n) stack[stack_ptr - (n)]
#define POP(n) stack_ptr -= (n)

그리고 이를 사용하는 루프 (364–377행, AP 풀어내리기 단계만):

  PUSH(n);
  for(;;) {
    num_reductions++;
    switch (TAG(n)) {
    ind:
    case IND:
      n = INDIR(n);
      TOP(0) = n;
      break;
    ap:
    case AP:
      n = FUN(n);
      PUSH(n);
      break;

TOP(0)은 방금 찾은 가장 안쪽 노드이고, TOP(1)은 한 단계 바깥, 이런 식으로 이어진다 — k번의 적용을 풀어낸 뒤에는 TOP(k)가 가장 바깥쪽 노드, 즉 리덱스의 뿌리다. 스위치문이 위에 인자가 충분히 쌓인 컴비네이터 태그에 도달하면, TOP/ARG를 통해 스택에서 인자를 읽어와 재작성한다. 다음은 case S 전문이다 (380–391행):

    case S:
      CHECK(3);
      GCCHECK(2);
      f = ARG(TOP(1));
      g = ARG(TOP(2));
      x = ARG(TOP(3));
      POP(3);
      n = TOP(0);
      FUN(n) = new_ap(f, x);
      ARG(n) = new_ap(g, x);
      GOTO ap;
      break;

CHECK(3)은 스택에 인자가 셋 미만이면 (축약하지 않고) 그냥 빠져나온다 — S는 완전히 적용됐을 때만 리덱스이기 때문이다. 그렇지 않으면 f, g, x를 스파인에서 읽어들이고, 세 개의 중간 노드를 팝한 뒤, n = TOP(0)이 리덱스의 가장 바깥쪽 노드 — 즉 원래의 ((S f) g) x 적용 셀 — 을 되찾는다. 그 셀의 FUN/ARG 필드는 그다음 new_ap(f, x)와 new_ap(g, x)로 덮어 쓰여진다: 노드가 제자리에서 f x (g x)로 바뀌는데, 이는 정확히 lambda-calculus-and-ski.md의 S 규칙이다. GOTO ap는 이 새로 재작성된 노드에서부터 풀어내리기를 다시 시작한다 — 3절에서 그다음에 무슨 일이 일어나는지 추적한다.

3. 공유: x는 한 번만 가져오지, 복사하지 않는다

case S를 다시 보자: x = ARG(TOP(3))는 포인터 하나를 읽어들이고, 바로 그 포인터 값이 new_ap(f, x)와 new_ap(g, x) 둘 다에 쓰인다. f x (g x)에 있는 두 개의 x 출현은 부분항의 두 복사본이 아니다 — 이들은 arg 필드가 우연히 동일한 힙 셀을 가리키는 두 개의 AP 셀이다. 그것이 C 코드 한 줄로 표현되는 공유의 메커니즘 전부다.

이를 발휘하는 가장 작은 항, S f g x 자체를 (((S +) I) ((+ 2) 3))로 놓아보자 — 즉 x = 2 + 3일 때의 + x (I x)다. S-축약 이전, 이 항은 잎 S 위에 세 개의 AP 셀이 쌓인 것이고(가장 바깥쪽, 즉 마지막에 적용된 것을 n_c라 하자), 거기에 ((+ 2) 3)에 대한 별도의 부분그래프 X가 딸려 있다:

n_c ──fun──▶ n_b ──fun──▶ n_a ──fun──▶ [S]
 │            │            └──arg──▶ [+]
 │            └──arg──▶ [I]
 └──arg──▶ X            where X = AP(AP(+, 2), 3)

2절에서와 정확히 같은 방식으로 이 그래프에 대해 case S를 추적해 보면: f = ARG(n_a) = +, g = ARG(n_b) = I, x = ARG(n_c) = X, 그리고 n = TOP(0) = n_c — 답으로 재사용되는 가장 바깥쪽 셀이다. 재작성 이후:

n_c ──fun──▶ n_d = AP(+, X)
 └──arg──▶ n_e = AP(I, X)
                    │
        both n_d.arg and n_e.arg point at the SAME cell X

n_c 자신은 해제되고 재할당된 적이 없다 — 그 fun/arg 필드가 덮어 쓰였을 뿐이므로, 그래프 어디서든 n_c를 가리키는 다른 포인터는 공짜로 재작성된 형태를 보게 된다. 그리고 n_d/n_e는 둘 다 하나의 X 셀을 가리키지, ((+ 2) 3)의 두 복사본을 가리키지 않는다.

이제 그 대가를 보자. 풀어내리기가 n_c에서 다시 시작되어 ADD 프리미티브에 도달하는데, 그 ARITH2 매크로(476행)는 두 피연산자를 모두 강제 평가한다: r = evalint(ARG(TOP(1))) op evalint(ARG(TOP(2))); (op = +) — 여기서 ARG(TOP(1)) = X이고 ARG(TOP(2)) = n_e = AP(I, X)다. 정직하게 짚어둘 것 하나: C는 +의 피연산자 평가 순서를 규정하지 않으므로, 두 evalint 호출 중 어느 것이 먼저 실행되는지는 컴파일러의 선택이다 — 그리고 공유에 대한 결론은 여기에 의존하지 않는다. 두 피연산자 모두 동일한 셀 X로 이어진다: 첫 번째는 직접, 두 번째는 n_e를 통해서인데, case I가 이를 X로의 간접 참조로 바꿔놓는다 (4절). 어느 쪽이 먼저 강제되든 X를 완전히 축약한다 — 안쪽 덧셈의 SETINT(n, r)(475행)가 X 자신의 태그/값 필드를 제자리에서 INT 5로 덮어쓴다. 그다음 다른 쪽이 강제되면, 그것은 같은 셀에 도달한다 — 직접이든, n_e의 간접 참조를 거치는 evali의 while (TAG(n) == IND) 루프(335–336행)를 통해서든 — 그리고 이미 거기 있는 태그 INT를 발견한다: 즉시 반환하고, 재계산은 없다. 어느 순서든 2 + 3은 정확히 한 번만 실행된다; 두 번째 소비자는 그저 누군가 이미 계산을 끝낸 셀로 이어지는 포인터를 따라갈 뿐이다.

실제 항을 실행해서 검증했다(shared2.comb, 축약 카운터에서 눈에 띄게 보일 만큼 효과를 키우기 위해 2 + 3 자리에 20단 깊이의 덧셈 사슬을 넣은 같은 아이디어, 아래에 등장). 작은 경우:

$ ./eval shared.comb        # (((S +) I) ((+ 2) 3))
(((S +) I) ((+ 2) 3))
10
node size=24, heap size=100000
16 reductions, 0 GCs
$ ./eval duplicated.comb    # ((+ ((+ 2) 3)) ((+ 2) 3)) -- two independent copies of (+ 2 3)
((+ ((+ 2) 3)) ((+ 2) 3))
10
node size=24, heap size=100000
13 reductions, 0 GCs

둘 다 마땅히 10을 낸다. 그러나 여기서는 공유 버전이 축약을 더 많이 한다 (16 대 13) — 두 단계짜리 덧셈에서는, 추가된 S와 I 재작성의 고정 오버헤드가 절약하는 덧셈 한 번보다 더 크기 때문이다. 공유는 공짜가 아니다; 간접 참조 하나와 추가 적용 노드 하나의 비용이 든다. 공유가 실제로 이기는 것을 보려면, 공유된 부분항이 S/I 오버헤드보다 재계산 비용이 더 클 만큼 비싸야 한다. 2 + 3을 20단 깊이로 중첩된 덧셈으로 바꾸면(((...((+ 1) 1)...) 1), 여전히 같은 S + I 모양을 통한 x + x다) 그 절약이 확연해진다:

$ ./eval shared2.comb
42
node size=24, heap size=100000
92 reductions, 0 GCs
$ ./eval dup2.comb          # x + x with x written out twice (no sharing)
42
node size=24, heap size=100000
165 reductions, 0 GCs

같은 답, 92 대 165 축약 — 중복된 버전은 20단 깊이의 사슬 비용을 두 번 치르고, 공유된 버전은 한 번만 치른다. 그것이 측정된 "한 번만 평가한다"이지, 그냥 주장이 아니다.

4. 간접 노드: 그렇다, 커밋 1에도 존재한다

첫 커밋의 평가기는 간접 노드를 다루기엔 너무 조악할 거라고 추측하기 쉽지만 — 그렇지 않다. IND는 1절에서 인용한 enum에서 태그 1이며, 여러 곳에서 만들어지고 따라가진다.

생성은 SETIND 매크로를 통해 이루어진다 (361행):

#define SETIND(n, x) do { TAG((n)) = IND; INDIR((n)) = (x); } while(0)

case K, case T, case I(항등 재작성들 — 각각 결과를 복사해 넣는 대신 리덱스 셀을 결과로의 포인터로 바꾼다)와, 결과 셀을 불리언 컴비네이터 T/K로 간접 참조하게 만드는 공유 매크로(496행)를 가진 비교 프리미티브들이 이를 사용한다:

#define CMP(op) do { CHECK(2); r = evalint(ARG(TOP(1))) op evalint(ARG(TOP(2))); n = TOP(2); SETIND(n, r ? combT : combK); POP(2); } while(0)

case I 자체 (421–428행):

    case I:
      CHECK(1);
      x = ARG(TOP(1));
      POP(1);
      n = TOP(0);
      SETIND(n, x);
      GOTO ind;
      break;

— I x는 x의 새 복사본을 할당하지 않는다; I x 적용 셀 자체를 x로의 간접 참조로 바꾼다. 이는 3절의 S와 같은 제자리 갱신 트릭을, "새 그래프"가 단지 기존 인자일 뿐인 경우에 특수화한 것이다.

따라가기는 세 곳에서 일어난다: eval 자신의 루프 안(2절에서 인용한 case IND, n = INDIR(n); TOP(0) = n;), evali 안(327–338행, eval이 반환한 뒤의 while (TAG(n) == IND) n = INDIR(n); — 호출자가 "진짜" 답을 얻을 때 쓰는 것으로, 결코 간접 참조 셀을 얻지 않는다), 그리고 가비지 컬렉터의 mark 안(다음 절) — 이는 부수 효과로 간접 참조 사슬을 압축하기도 하며, 뿌리 포인터를 그 사슬 너머로 재작성한다 (165–171행):

  if (TAG(n) == IND) {
    /* Skip indirections, and redirect start pointer */
    while (TAG(n) == IND) {
      n = INDIR(n);
    }
    *np = n;
  }

5. 메모리: 할당과 진짜 마크-스캔 GC

셀은 한 번 할당되는 고정 크기 배열에서 나온다 (11행, 46행):

#define HEAP_CELLS 100000
#define ALLOC_HEAP(n) do { cells = malloc(n * sizeof(node)); memset(cells, 0x55, n * sizeof(node)); } while(0)

100,000개의 셀을 미리 malloc하고 다시는 늘리지 않는다 — 이 파일 어디에도 개별 셀에 대한 free()는 없다; 셀이 다시 쓸 수 있게 되는 유일한 방법은 컬렉터를 거치는 것이다. alloc_node(108–120행)는 그저 프리 리스트의 머리를 팝할 뿐이다:

NODEPTR
alloc_node(enum node_tag t)
{
  NODEPTR n = next_free;
  if (n == NIL) {
    ERR("out of memory");
  }
  if (TAG(n) != FREE)
    abort();
  next_free = NEXT(n);
  TAG(n) = t;
  return n;
}

그리고 커밋 1은 진짜로 컬렉터를 갖고 있다 — 마크-스캔 방식으로, 할당이 많은 축약 전에 GCCHECK를 통해 호출된다 (예: case S, 2절: 두 번의 new_ap 호출 전에 GCCHECK(2)). gc_check는 프리 리스트가 짧아 보일 때만 실제로 수거한다 (212–222행); gc, mark, scan(158–210행)은 교과서적인 스톱더월드 마크/스윕이다: mark는 모든 살아 있는 뿌리 — 현재 stack[0..stack_ptr]에 있는 모든 셀 — 로부터 시작해 AP 스파인을 재귀적으로 마킹한다 (그리고 위에서 보인 것처럼 IND 사슬을 압축한다); 그다음 scan은 힙 전체를 한 번 선형으로 훑으며, 마킹되지 않은 모든 셀을 프리 리스트에 꿰어 넣고 나머지의 마크를 지운다 (198–210행, 주석 처리된 fprintf 디버그 줄 세 개는 생략했으며 ...로 표시):

void
gc(void)
{
  num_gc++;
  num_marked = 0;
  // ...
  for (int i = 0; i <= stack_ptr; i++)
    mark(&stack[i]);
  // ...
  scan();

  // ...
}

이는 원리로만 존재하는 게 아니라 실제로 실행되어 검증된다: nfib 35를 실행하면 (see y-combinator-and-recursion.md) 십억 번이 넘는 축약 동안 1944 GCs가 보고된다.

커밋 1에 없는 것: 압축이나 복사가 없고(셀은 결코 이동하지 않는다 — scan은 프리 리스트를 다시 연결할 뿐, 힙 배열을 줄이거나 조각 모음하지 않는다), 세대도, 참조 카운팅도, 늘어나는 힙도 없다 (alloc_node의 out of memory는 메모리를 더 요청하는 게 아니라 그냥 하드한 ERR/exit다). 파일 상단의 #elif 분기에 스케치된 NODE_SPLIT과 NODE_64 표현은 쓰이지 않는 대안들이다 — NODE_NAIVE (9행: #define NODE_NAIVE)가 #define되어 컴파일되는 것이며, 1절의 struct node가 그 표현이다.

6. 지연성은 이로부터 저절로 나오는 것이지, 나중에 덧붙인 게 아니다

위 내용 중 어느 것도 인자를 요청받기 전에 평가하지 않는다. case S는 new_ap(f, x)와 new_ap(g, x)를 만들고 즉시 GOTO ap로 왼쪽 스파인에 돌아간다 — x를 결코 들여다보지 않는다. x는 이후의 어떤 축약 단계가 그것을 INT로 필요로 할 때(evalint를 통해, 예를 들면 ARITH2, 476행 안에서), 또는 그 가장 바깥쪽 태그를 패턴 매칭해야 할 때에만 강제된다. 결코 요구되지 않는 인자는 그저 손대지지 않은 채 남는다 — 그것은 차지하는 메모리 외에는 아무 비용 없이 영원히 평가되지 않은 AP/컴비네이터 부분그래프로 남는다. 이를 3절의 공유와 결합하면 call-by-need를 공짜로 얻는다: 인자는 그래프이고, 무언가 그것을 강제할 때(그리고 그럴 경우에만) 정확히 한 번 평가된다.

다음: y-combinator-and-recursion.md — 같은 제자리 갱신 트릭을, 재귀를 위해 진짜로 순환하는 그래프를 만드는 데 적용한 것.

🌐 Read in English

1장의 배경지식.

Y 컴비네이터와 재귀

bracket-abstraction.md는 항 안의 모든 Lam을 제거한다. 그런데 nfib는 자기 자신을 호출한다 — compile 이후에는 이름이 아예 남아 있지 않은 언어로의 변환에서, 자기 호출은 어떻게 살아남는가?

1. 이름이 없으면 자기 참조도 없다

abstract(참고: bracket-abstraction.md 1절)는 어떤 항에 들어 있는 속박된 변수를 제거하는 방법만 알 뿐이다. nfib n = ... nfib ... 같은 재귀 정의는 이런 식으로 자연스럽게 표현되지 않는다: nfib는 무엇의 매개변수도 아니다 — 그것은 지금 정의되고 있는 이름이며, 자기 자신의 본문 안에서 자기 자신을 가리킨다. compile이 Var/Lam을 지우고 나면, 런타임에는 환경도 이름 테이블도 없어서(참고: lambda-calculus-and-ski.md 3절) 이름이 통해서 참조할 대상 자체가 없다. 컴비네이터 항은 구성상 닫혀 있으므로, 다른 무언가가 자기 참조를 공급해야 한다.

2. Y f = f (Y f), 그리고 축약이 실제로 만들어내는 것

표준적인 트릭은 고정점 컴비네이터 Y다: Y f는 정확히 f (Y f)처럼 동작하도록 정의된다 — f를 "지금 만들고 있는 바로 그 재귀적인 값"에 적용하는 것이다. nfib의 본문을, 자신의 재귀적 호출 대상을 추가 인자로 받는 평범한(비재귀적인) 함수 nfibF = \nfib n -> ...로 바꾸면, Y nfibF는 nfibF (Y nfibF)와 같은 값이다 — nfibF에게 호출할 자기 자신의 사본을 먹여주는 것이다.

ccb31388:eval.c의 Y에 대한 축약 규칙 (429–437행):

    case Y:
      CHECK(1);
      f = ARG(TOP(1));
      POP(1);
      n = TOP(0);
      FUN(n) = f;
      ARG(n) = n;
      GOTO ap;
      break;

여기서 n은 TOP(0)이다 — 리덱스의 가장 바깥쪽 셀, 즉 Y f 자체에 대한 AP(Y, f) 노드다 ( graph-reduction-and-laziness.md 2절의 case S와 비교해 보라, 같은 패턴이다: 인자를 읽고, 그다음 바깥쪽 셀을 답으로 재사용한다). 이 재작성은 FUN(n) = f를 설정하고, 결정적으로 ARG(n) = n도 설정한다 — 노드 자신의 인자 필드가 자기 자신을 가리키도록 설정되는 것이다. 이는 "f (Y f)의 또 다른 사본을 얻기 위해 Y f를 다시 호출"하는 것이 아니다 — 그것은 제자리에서 변경된 셀 하나이며, 이제 말 그대로 AP(f, n)인데, 여기서 n은 바로 그 AP를 담고 있는 노드 자신이다. n에서 ARG를 따라가면 다시 n으로 돌아온다. Y f의 그래프는 진짜로 순환적이다: 한 번 만들어진 단일한 자기 참조 적용 셀이다. 펼치는 것은 그저 같은 셀에 다시 들어가는 것일 뿐이므로, 몇 번이든 "펼칠" 수 있다는 데 한계가 없다.

GOTO ap는 이제 f (Y f)가 된 n에서부터 스파인 순회를 다시 시작한다 — 정확히 고정점 방정식이지만, 반복해서 재작성해야 하는 관계가 아니라 그래프상에서 이미 성립하는 항등 관계다. f의 본문이 더 안쪽에서(재귀 호출 지점에서) 두 번째 인자를 다시 요구하면, 같은 자기 순환 셀 n을 역참조하여 찾아내는데 — 그 시점에는 — 아직 같은 AP(f, n)이거나 (재귀가 더 강제되지 않았다면), 아니면 같은 자기 연결을 따라 이미 더 축약되어 있는 상태다. 어느 쪽이든, f 자신은 한 번만 가져와졌고 결코 복사되지 않는다; 오직 자기 참조 주변의 스파인만이 축약이 진행됨에 따라 자라난다.

3. eNfib: 재귀 정의와 Y가 들어오는 지점

ccb31388:Lam.hs (193–194행):

fix :: Exp -> Exp
fix e = App CY e

fix는 그저 "Y로 감싸기"일 뿐이다 — CY는 Comb "Y" 패턴 동의어이고(ccb31388:Lam.hs, 72행: pattern CY = Comb "Y"), App CY e는 항 Y e다. eNfib(225행):

eNfib = fix $ comp $ \ nfib n -> iF (n .< 2) 1 (nfib $$ (n-1) + nfib $$ (n-2) + 1)

\nfib n -> ...는 2절의 비재귀적인 nfibF를, 하스켈 자체의 람다 문법으로 쓰고 ToLam 장치를 통해 Exp로 바꾼 것이다 (comp = improve . compile . toLam, 219행 — compile/improve는 bracket-abstraction.md 참고). nfib $$ (n-1)과 nfib $$ (n-2)는 두 번의 재귀 호출인데, 둘 다 이름으로 자기를 참조하는 게 아니라 매개변수 nfib에 대한 것이다. fix는 컴파일된 함수 전체에 Y를 적용하므로 eNfib = Y nfibF가 되고 — 본문 안에서 nfib이 나타나는 모든 자리는, compile 이후, 요구될 때 Y의 자기 순환 셀(2절)이 무엇으로 풀리든 그것을 가리키게 된다. ./eval nfib.comb가 축약 전에 출력하는 그 컴파일된 항은, 유도 과정이 예측하는 바로 그 자리에 Y를 갖고 있다 — 전체 재귀 함수를 감싸고, 35에 적용된 채로:

((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)

(이것은 bracket-abstraction.md 4절이 이미 improve 출력의 실행 예시로 인용한 것과 같은 출력된 항이다; 맨 앞의 Y가 이 글이 다루는 부분이다.)

4. 공유로서의 재귀

2절의 자기 순환 셀은 graph-reduction-and-laziness.md가 평범한 컴비네이터(S, K, I)에 대해 설명하는 것과 똑같은 제자리 갱신 메커니즘이다 — Y의 축약은 그저 이를 이용해 셀이 다른 기존 노드가 아니라 자기 자신을 가리키게 할 뿐이다. 실행 중인 nfib의 모든 재귀 호출은 case Y가 처음에 가져온 그 f 포인터 하나를 재사용한다; f의 코드(컴파일된 컴비네이터 그래프)는 호출마다 결코 중복되지 않는다. 오직 인자 스파인 — n-1/n-2 적용이 대기 중인 사슬 — 만이 재귀가 내려갈수록 자라나며, 그 각각은 그래프의 나머지 부분과 정확히 마찬가지로 평범한 AP 축약에 의해 만들어지고 해체된다.

5. 검증: ./eval nfib.comb는 29860703을 출력한다

$ cd /tmp/mhs-ccb31388 && nix develop $MICROHS -c make test
./eval nfib.comb
((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)
29860703
node size=24, heap size=100000
1045124618 reductions, 1944 GCs

29860703은 nfib 35다 — 이 커밋에 대한 이전 모든 과제의 검증된 결과와 일치한다. 이 실행은 또한 graph-reduction-and-laziness.md의 주장, 즉 GC가 소스에 그냥 존재하기만 하는 게 아니라 실제로 실행된다는 주장도 확인해 준다: 이 한 번의 계산이 진행되는 동안 1944 GCs가 실제로 발동했다.

6. 나중에 바뀌는 것

이 Y — 위에서 보인 그대로 case Y에 의해 축약되는 자기 순환 셀 하나 — 은 첫 커밋이 하는 일이다. 이것이 마지막 말은 아니다: 커밋 d2956ee8, "Optimize Y better."(Y를 더 잘 최적화하기, 2023-06-02)가 이 구현을 다시 살펴본다. 무엇이 바뀌는지는 여기서 다룰 범위 밖이다; 4장에서 다룬다.

🌐 Read in English

2장의 배경지식.

파서 컴비네이터

커밋 a9178695("Make a real compiler."(진짜 컴파일러 만들기), 2023-05-29)는 새 모듈 ParserComb.hs(165줄)를 추가하는데, 그 이름은 은유가 아니다: 작고 독립적인 파서 컴비네이터 라이브러리로, 파서 생성기나 외부 문법 파일이 아니라 평범한 하스켈 타입 클래스(Functor, Applicative, Monad, Alternative)로 지은, Wadler/Hutton 파싱 계보의 Prsr 스타일 툴킷이다. 두 번째 새 모듈 Parse.hs(204줄)는 이를 이용해 2장이 소개하는 표면 문법을 파싱한다. 이 문서는 라이브러리 자체를 훑고, 2장은 그것이 무엇에 쓰이는지를 훑는다.

1. 파서 타입

a9178695:ParserComb.hs, 22–44행(LastFail, Res, Prsr를 함께, 전문 인용):

data LastFail
  = LastFail Int [(String, [String])]
  deriving (Show)

noFail :: LastFail
noFail = LastFail maxBound []

longest :: LastFail -> LastFail -> LastFail
longest lf1@(LastFail l1 x1) lf2@(LastFail l2 x2) =
  case compare l1 l2 of
    LT -> lf1
    GT -> lf2
    EQ -> LastFail l1 (x1 ++ x2)

longests :: [LastFail] -> LastFail
longests [] = undefined
longests xs = foldl1 longest xs

data Res s a = Many [(a, (String, s))] LastFail
  deriving (Show)

newtype Prsr s a = P { runP :: (String, s) -> Res s a}
instance Show (Prsr s a) where show _ = "<<Prsr>>"

오류 기록 부분을 걷어내면 교과서적인 형태가 남는다: 파서란 남은 입력을 결과로 바꾸는 함수다. 다른 무엇보다 먼저 눈여겨볼 만한 점이 이 특정 함수에는 두 가지 있다:

  • String -> Maybe (a, String)가 아니다. Res s a는 Many [(a, (String, s))] LastFail로, 하나가 아니라 (결과, 나머지) 쌍의 리스트다. 이 파서는 첫 번째로 성공한 파스에 곧바로 정착하지 않는다 — 여러 개를 돌려줄 수 있으며, 그 결과가 두 개 이상일 때 무엇을 할지 결정하는 곳이 parseDie(a9178695:Parse.hs, 82–87행)다: 결과가 정확히 하나면 받아들이고, 없으면 정형화된 "found X, expected Y"(X를 찾았는데 Y를 기대함) 메시지가 되며(formatFailed, a9178695:ParserComb.hs 101–113행 — 그 출력의 실제 트랜스크립트는 2장의 "직접 해보기" 연습 문제 2에 있다), 둘 이상이면 error $ "Ambiguous:\n" ++ ...가 된다.
  • Prsr s a는 두 번째 타입 매개변수 s를 문자열과 나란히 꿰어 나른다. 이는 상태를 갖는 파서다 — s는 라이브러리가 고정하지 않는 임의의 파서 상태다. Parse.hs는 이를 type P a = Prsr [Int] a로, 즉 들여쓰기 칼럼 스택으로 인스턴스화하여, ParserComb.hs가 레이아웃에 대해 아무것도 모른 채로도 완전한 하스켈식 레이아웃 규칙(아래 4절)을 얻어낸다.
  • LastFail Int [(String, [String])]는 최종적으로 어느 파스가 성공하든 상관없이, 파서가 실패하기 전에 도달했던 가장 먼 지점과 거기서 무엇을 기대하고 있었는지를 기록한다(Int는 길이다 — 파서는 "입력을 얼마나 들어갔는지"를 남은 문자열의 양으로 측정하므로, 남은 길이가 짧을수록 더 멀리 진행한 것이다; longest라는 이름은 오해의 소지가 있다 — 실제로는 가장 짧은 나머지, 즉 가장 많이 소비한 파스를 고르고 있다). 이것이 "found X, expected Y" 오류 메시지의 원재료이며, 성공하든 실패하든 모든 컴비네이터를 관통해 나른다.

2. 순차 결합과 선택: 타입 클래스 배관

그 아래로 이어지는 모든 것 — do 표기법, <*>, <|> — 은 네 개의 인스턴스에서 나온다, 전문 인용(a9178695:ParserComb.hs, 46–75행):

instance Functor (Prsr s) where
  fmap f p = P $ \ t ->
    case runP p t of
      Many aus lf -> Many [ (f a, u) | (a, u) <- aus ] lf

instance Applicative (Prsr s) where
  (<*>) = ap
  pure a = P $ \ t -> Many [(a, t)] noFail

instance Monad (Prsr s) where
  return = pure
  p >>= k = P $ \ t ->
    case runP p t of
      Many aus plf ->
        let xss = [ runP (k a) u | (a, u) <- aus ]
        in  let (rss, lfs) = unzip [ (rs, lf) | Many rs lf <- xss ]
            in  Many (concat rss) (longests $ plf : lfs)

instance MonadFail (Prsr s) where
  fail m = P $ \ (r, _) -> Many [] (LastFail (length r) [("fail", [m])])

instance MonadState s (Prsr s) where
  get = P $ \ t@(_, s) -> Many [(s, t)] noFail
  put s = P $ \ (f, _) -> Many [((), (f, s))] noFail

instance Alternative (Prsr s) where
  empty = P $ \ (r, _) -> Many [] (LastFail (length r) [("empty", ["<empty>"])])
  p <|> q = P $ \ t ->
    case (runP p t, runP q t) of
      (Many a lfa, Many b lfb) -> Many (a ++ b) (longest lfa lfb)

pure a는 입력을 소비하지 않고 성공해 a를 돌려준다 — 순차 결합의 기저 사례다. p >>= k는 "p로 파싱한 다음, p가 만들어낸 모든 결과 각각에 대해 k를 실행하고 그 결과로 나온 파서로 파싱하라"는 뜻이다 — 결과가 하나뿐인 파서라면 이는 그저 함수 합성이겠지만, Many가 (a, leftover) 쌍을 여러 개 담을 수 있으므로 >>=는 결과마다 한 번씩 k를 실행하고 이어 붙인다. Alternative의 p <|> q는 p와 q 둘 다를 같은 입력에 대해 실행하고, 둘의 성공 결과를 모두 이어 붙여 (a ++ b) 돌려준다 — 이는 보통 뜻하는 "p를 시도하고 실패하면 q로 넘어간다"가 아니다; 두 갈래가 항상 다 실행되고, 두 갈래의 결과가 모두 유지되며(단순한 백트래킹이 아니라 진짜 모호성이다), 두 개의 LastFail 기록만 longest로 병합된다.

마지막 대목은 라이브러리가 바로 옆에 다르게 동작하는 두 번째 선택 연산자를 함께 배치할 만큼 중요하다(a9178695:ParserComb.hs, 77–84행):

-- Left biased choice
(<|<) :: Prsr s a -> Prsr s a -> Prsr s a
p <|< q = P $ \ t ->
  case runP p t of
    Many [] lfa ->
      case runP q t of
        Many b lfb -> Many b (longest lfa lfb)
    r -> r

<|<는 p가 결과를 전혀 만들어내지 못했을 때만 q를 시도한다 — 대부분의 파서 컴비네이터 튜토리얼이 <|>라고 부르는, 평범한 확정 선택이다. Parse.hs는 결정성이 중요한 곳(실제 중괄호 {와 들여쓰기로 촉발되는 가상의 중괄호를 가르는 것, 4절)에서는 정확히 <|<를 쓰고, 문법이 모호한 채로 있어도 되어 parseDie가 나중에 정리하게 둘 수 있는 곳에서는 그냥 <|>를 쓴다.

3. 문자를 직접 다루는 프리미티브

타입 클래스 계층 위에서, ParserComb.hs는 실제로 입력을 들여다보는 몇 안 되는 함수들을 정의한다. satisfy, char, eof (a9178695:ParserComb.hs, 119–141행, string은 생략 — 같은 모양):

satisfy :: String -> (Char -> Bool) -> Prsr s Char
satisfy msg f = P $ \ t ->
  case t of
    (c:cs, s) | f c -> Many [(c, (cs, s))] noFail
    (cs, _) -> Many [] (LastFail (length cs) [("satisfy", [msg])])

char :: Char -> Prsr s Char
char c = P $ \ t ->
  case t of
    (c':cs, s) | c == c' -> Many [(c, (cs, s))] noFail
    (cs, _) -> Many [] (LastFail (length cs) [("char", [show c])])

...

eof :: Prsr s ()
eof = P $ \ t ->
  case t of
    ("", _) -> Many [((), t)] noFail
    (cs, _) -> Many [] (LastFail (length cs) [("eof", ["end-of-file"])])

이들 모두 똑같이 네 줄이다: 첫 글자(또는 eof의 경우 문자열 전체)를 보고, 성공하며 소비하거나 실패하며 이유를 기록한다. satisfy는 자신의 실패 기록을 위해 명시적인 msg 문자열을 받는다 — Parse.hs의 오류 출력(2장의 "직접 해보기", 연습 문제 2)에 보이는 "lower case", "upper case", "digit", "symbol" 문자열은 모두 이 msg 인자에서 비롯된다.

그 위로, 세 가지 배관이 더 있다(a9178695:ParserComb.hs, 86–90행과 143–147행, 파일 내 서로 다른 두 지점, 각각 전문 인용):

emany :: Prsr s a -> Prsr s [a]
emany p = esome p <|< pure []

esome :: Prsr s a -> Prsr s [a]
esome p = (:) <$> p <*> emany p

...

(<?>) :: Prsr s a -> String -> Prsr s a
p <?> e = P $ \ t ->
--  trace ("<?> " ++ show e) $
  case runP p t of
    Many rs (LastFail l xs) -> Many rs (LastFail l [(m, e:es) | (m, es) <- xs ])

emany/esome은 "0개 이상"과 "1개 이상" — 통상적인 방식으로 상호 재귀하는 표준 클레이니 스타와 플러스다(esome p는 p를 하나 파싱한 다음 나머지를 emany p로 처리한다; emany p는 esome p이거나 아무것도 아니다). 라이브러리의 익스포트 목록은 Control.Applicative의 평범한 many와 some도 그대로 다시 익스포트한다(import Control.Applicative 줄의 --hiding (many, some) 주석은 주석 처리되어 있다 — 실제로는 임포트가 제한되어 있지 않다) — 그래서 Parse.hs에는 Alternative에서 나온 many/some과 이 모듈 자체의 즉시 실행형 emany/esome이 나란히 스코프에 들어와 있으며, 이 범위에서는 일관되게 e로 시작하는 쪽을 쓴다. <?>는 라벨을 붙이는 컴비네이터다: p를 실행하고, 실패 기록이 하나라도 생겼다면 그 안의 모든 기대 문자열 앞에 e를 붙인다 — 이 방식으로 pKeyword, pSymbol 등이, 그러지 않았다면 그냥 "symbol"이나 "lower case"라고만 했을 실패에 사람이 읽을 수 있는 이름("module", "=", "int")을 붙인다.

4. 생성 규칙 하나를 소리 내어 읽기

Parse.hs는 문법 전체를 이런 식으로 짓는다 — pModule, pDef, pExpr, pCase 등등 — 각각이 더 작은 파서들로 지은 단일한 <$>/<*>/<|> 식이다. 맨 위의 것을 전문 인용한다 (a9178695:Parse.hs, 94–95행):

pModule :: P Module
pModule = Module <$> (pKeyword "module" *> pUIdent <* pKeyword' "where") <*> pBlock pDef

소리 내어 읽으면: 모듈을 파싱하려면, 먼저 키워드 module을 파싱하고 (*>는 그 결과를 버린다 — 키워드는 데이터를 담지 않으므로), 대문자 식별자를 파싱하고(pUIdent — 이것이 모듈 이름이 된다), 키워드 where를 파싱하고(<*가 이번에도 결과를 버린다), 정의들의 블록을 파싱한다(pBlock pDef — 레이아웃으로 경계 짓고 세미콜론으로 나눈 pDef의 목록, 아래에서 설명). 식별자와 정의 목록을 Module <$> ... <*> ...로 Module 값에 담는다. 여기에 새로 지은 파싱 코드는 하나도 없다 — 이미 정의된 파서 네 개(pKeyword, pUIdent, pKeyword', pBlock)를 이미 정의된 컴비네이터 세 개(<$>, *>/<*, <*>)로 접착한 것뿐이다.

pBlock과 그 이웃들은 다시 볼 만한 가치가 있다 — 1절의 타입 매개변수 s가 제 몫을 하는 모습을 보여주기 때문이다: 하스켈식 레이아웃 규칙 (실제 {/;/} 또는 들여쓰기로 추론된 것)이 ParserComb.hs의 변경 없이 Prsr [Int] a 위의 사용자 수준 파서만으로 온전히 구현된다 (a9178695:Parse.hs, 176–204행, 전문 인용):

pLCurl :: P ()
pLCurl = pSym '{' <|< softLC
  where softLC = do
          msp <- pIndent
          case msp of
            Nothing -> fail "\\n"
            Just sp -> do
--              traceM ("push " ++ show (length sp))
              modify $ \ st -> length sp : st

pRCurl :: P ()
pRCurl = pSym '}' <|> softRC
  where softRC = do
          is <- get
          if null is then
            fail "}"
           else do
            put (tail is)
            eof

pSemi :: P ()
pSemi = pSym ';'

pBlock :: P a -> P [a]
pBlock p = do
  pLCurl
  as <- esepBy p pSemi
  pRCurl
  pure as

pLCurl은 블록을 연다: 실제 { 리터럴이거나, 아니면 — 실제 {가 들여쓰기 경로도 함께 시도해서는 절대 안 되므로 <|<를 통한 확정 선택으로 — softLC인데, 이는 다음 줄의 들여쓰기를 읽고(여기서는 보이지 않는 pIndent) 그 칼럼을 modify로 상태 스택에 밀어 넣는다. pRCurl은 블록을 닫는다: 실제 } 리터럴이거나, 스택을 팝하는 softRC인데 — 눈여겨볼 점은, 그 팝 뒤에 곧바로 eof가 따라올 때만 성공한다는 것이다. 이 덕분에 이 장난감 전체가 지금은 파일 내부에서 들여쓰기가 줄어들 때 가상의 닫는 중괄호를 결코 내보내지 않고도 버틴다 (일반적인 경우를 위해서는 Parse.hs의 다른 곳에 있는 pWhite가 inject를 통해 } 문자를 입력 스트림에 직접 주입한다). esepBy(여기 보이지 않음; esepBy1에 빈 리스트로의 대체 경로를 더한 것으로, 둘 다 emany와 <|>로 지은 평범한 사용자 코드다)가 세미콜론으로 나뉜 가운데 부분을 처리한다. pLCurl, pRCurl, pSemi, esepBy 어느 것도 ParserComb.hs가 특별한 지위로 익스포트하는 것이 아니다 — 이들은 pModule과 같은 방식으로, 2–3절의 같은 열한 개 남짓한 프리미티브로 지은 Parse.hs-지역 함수다.

5. 이 방식이 점진적 성장에 어울리는 이유

이것이야말로 상태 기계를 손으로 짜거나 파서 생성기 도구를 쓰는 대신 이런 식으로 파서를 작성하는 진짜 이득이다: 새 문법 조각은 같은 구성 요소로 지은 새 컴비네이터 식이 되어 다른 것들 옆에 끼워 넣어질 뿐, 재귀 하강 함수를 열두 가지에서 열여섯 가지 케이스로 밀어붙이는 재작성이 아니다. 바로 이 범위 안에서 이미 한 번 일어난 일이다: 커밋 4030f721("Parse more"(더 파싱하기))은 괄호로 감싼 연산자 형태((+), 연산자를 평범한 식별자로 쓰는 표기)를 총 열두 줄 추가로 붙인다: 파일의 다른 모든 파서와 똑같이 esome/satisfy/guard로 지은 새 pOper/ pOper' 쌍(열두 줄 중 아홉 줄), 정의를 반복하는 대신 pOper'를 재사용하도록 조정된 pSymbol, 그리고 <|>로 pAExpr에 덧붙인 새 대안 하나. 3장은 이를 더 큰 규모로 반복한다 — let, 중위 연산자, 튜플, 리스트 문법, do 표기법이 각각 p로 시작하는 새 정의 몇 개로 안착할 뿐, ParserComb.hs의 재구조화는 전혀 없다. 그 파일은 이 과정 내내 손대지 않은 채로 남는다. 1절의 파서 타입은 이후의 문법이 필요로 할 모든 것(순차 결합, 선택, 상태, 오류 추적)을 이미 갖추고 있다; 언어를 키우는 것은 문법 계층에서 순수하게 덧붙이기만 하면 되는 일이다.

🌐 Read in English

7장의 배경지식.

컴파일러 부트스트랩

"셀프 호스팅"은 자기 자신의 소스 코드를 컴파일할 수 있는 컴파일러를 뜻한다. 순환적으로 들리는데, 처음 겪을 때는 실제로 그렇다: 어떤 버전의 uhs도 존재하기 전에는, uhs 자신의 소스를 컴파일할 수 있는 것이 아무것도 없다 — 그것들은 그저 디스크에 놓인 하스켈 텍스트일 뿐이다. MicroHs는 셀프 호스팅 컴파일러라면 모두가 쓰는 평범한 방법으로 이 순환을 깬다: 같은 언어의 (충분히 큰 부분집합을 지원하는) 다른 이미 동작하는 구현으로 첫 번째 동작 사본을 만들고, 그 첫 사본이 이후를 넘겨받게 하는 것이다. 이 프로젝트에서 그 "다른 구현"은 GHC이며, 이 책의 모든 장이 빌드에 쓰는 nix develop 셸에 이미 들어 있다.

사다리, 7장이 끝난 시점의 모습

8장이 이를 위한 전용 make 타깃을 배선할 즈음에는, 부트스트랩 사다리에 세 개의 가로대가 있고, Makefile이 이들을 직접 이름 붙여 부른다(456ce17c:Makefile 66–69행, 전문 인용):

bootcombtest:	$(BIN)/uhs uhs.comb
	$(BIN)/uhs -ilib -isrc -omain-uhs.comb  MicroHs.Main
	$(BIN)/eval -H10000000 -K1000000 -ruhs.comb --  -ilib -isrc -omain-comb.comb MicroHs.Main
	cmp main-uhs.comb main-comb.comb

사다리로 읽으면:

  1. GHC가 컴파일러를 컴파일한다. $(BIN)/uhs — 평범한 네이티브 바이너리다 — 는 src/MicroHs/Main.hs와 그것이 임포트하는 모든 것을 GHC가 빌드해 만든다(456ce17c:Makefile 23행: $(GHCE) -package mtl -isrc -Wall -O src/MicroHs/Main.hs -main-is MicroHs.Main -o $(BIN)/uhs). 아직 자기 참조는 전혀 없다 — 앞선 모든 장의 "직접 해보기"가 이미 해 온 것과 똑같은 "GHC가 하스켈 프로그램을 빌드한다"일 뿐이다.
  2. bin/uhs가 같은 소스를 컴파일한다. uhs.comb 규칙 (456ce17c:Makefile 80–81행)은 $(BIN)/uhs -ilib -isrc -ouhs.comb MicroHs.Main을 실행한다 — GHC로 빌드된 컴파일러가 자기 자신의 src/MicroHs/*.hs 소스를 읽어 uhs.comb를 내놓는데, 이는 2장 이래 각 장의 .comb 출력이 써 온 것과 같은 텍스트 형식의 컴비네이터 파일이다. 이것이 진정한 의미의 셀프 호스팅 첫 단계다: 컴파일러가 자기 자신을 컴파일하고 있지만, 아직 자기 자신으로 실행되는 것은 아니다.
  3. bin/eval이 uhs.comb를 실행하고, 그것이 소스를 두 번째로 컴파일한다. bootcombtest의 가운데 줄이 uhs.comb를 C 런타임에 건넨다($(BIN)/eval ... -ruhs.comb -- ... MicroHs.Main) — 런타임은 2장이 그 첫 버전을 빌드했던 그 컴비네이터 파일 형식을 해석하는데, 지금 해석하고 있는 것은 다름 아닌 컴파일러 자신이다. 그렇게 해석 실행된 컴파일러는 MicroHs.Main을 소스로부터 다시 한 번 컴파일해 main-comb.comb를 쓴다. 한편 같은 타깃의 첫 줄인 $(BIN)/uhs -ilib -isrc -omain-uhs.comb MicroHs.Main은 원래의 GHC로 빌드된 바이너리가 독립적으로 동일한 소스를 main-uhs.comb로 컴파일하게 한다.
  4. 비교한다. cmp main-uhs.comb main-comb.comb — 평범한 바이트 단위 동등성 비교이며, 특별 취급은 없다.

이것과 나란히 존재하는 두 번째 검사가 있는데, 이는 이 장 자체의 7월 1일 커밋들이 추가하는 "로컬 lib" 하부구조로 지어졌다 (7장의 "무슨 일이 있었나" 참고): bootboottest(456ce17c:Makefile 61–64행, 전문 인용):

bootboottest:	$(BIN)/uhs $(BIN)/bootuhs
	$(BIN)/uhs     -ilib -isrc -omain-uhs.comb  MicroHs.Main
	$(BIN)/bootuhs -ilib -isrc -omain-boot.comb MicroHs.Main
	cmp main-uhs.comb main-boot.comb

$(BIN)/bootuhs는 두 번째 네이티브 바이너리다 — 여전히 GHC가 빌드하지만, GHC 자체의 base 패키지 대신 MicroHs 자신의 손으로 지은 lib/ 소스(ghc/의 프리미티브 심, -XNoImplicitPrelude, 그리고 아래 설명할 convertY.sh 전처리기를 거쳐)를 대상으로 모듈 단위로 컴파일된다. bootboottest는 이 대안적인, 라이브러리를 바꿔 낀 GHC 빌드가 보통의 빌드와 일치하는지 검사한다 — 컴비네이터 파일을 인터프리터로 돌리는 것과는 별개의, "이것이 여전히 같은 것을 뜻하는가" 의 다른 축이다.

왜 바이트 단위로 동일한 출력이 오라클인가

두 검사 어느 것도 "출력이 맞아 보이는가"를 묻지 않는다. 둘 다 그저 두 파일을 cmp할 뿐이다. 이는 의도적이며, 여기서 규모가 통하는 유일한 오라클(정답 판별 기준)이다: 컴파일러 버그 — 이를테면 케이스 절이 잘못된 순서로 처리되거나, 연산자가 잘못된 결합 방향으로 컴파일되는 것 — 는 버그가 되기 위해 무언가를 크래시시킬 필요가 없다. 잘 실행되지만, 같은 컴파일러의 다른 빌드가 같은 소스를 컴파일해 내놓는 것과 다른 값을 계산하는 .comb 파일을 만들어낼 수 있다. 잡아내지 못하고 넘어가면, 그런 종류의 버그는 이후 모든 빌드에 태연히 스스로를 컴파일해 넣을 것이다. 각 가로대에서의 바이트 단위 비교는 "컴파일러가 미묘하게 틀렸다"를 "이 두 파일이 다르다"로 바꾼다 — 잘못 컴파일된 정확한 경로를 우연히 건드리는 테스트를 누군가 작성해야만 드러나는 버그 대신, cmp가 즉시 알려주는 차이가 된다. 7장의 고된 작업 전체 — 실제로 bootcombtest를 진짜로 실행하는 것(8장의 이야기)만 빼고 모든 것 — 는 컴파일러 자신의 소스를 위 사다리의 2단계(bin/uhs가 자기 자신을 컴파일하는 것)가 파싱되고 실행되기라도 하는 uhs.comb를 내놓을 수 있는 상태로 만드는 작업이다.

제약: uhs는 오직 uhs만 쓸 수 있다

위 사다리가 무언가를 증명하려면, 컴파일러 자신의 소스가 GHC와, 결국에는 자기 자신 양쪽 모두에 대해 합법적인 입력이어야 한다. 이는 평범한 하스켈보다 훨씬 좁은 방언이다 — 타입 클래스가 없고(이 장 자체의 "직접 해보기"에서 src/MicroHs/와 src/Text/ParserComb.hs 전체에 grep -rn "class \|instance "를 돌려도 살아 있는 것이 하나도 나오지 않으며, lib/ 전체에 대한 같은 검색도 마찬가지다), deriving이 없고, 결합성 선언이 없다. 7장은 대체로 이 제약이 강제하는 다이어트이며, 이를 구체화하는 세 가지 예시는 다음과 같다:

  • Integer가 아니라 Int다. a721e046("Use Int instead of Integer"(Integer 대신 Int 쓰기))는 src/MicroHs/Parse.hs/Desugar.hs 전체에서 EInt의 페이로드를 Integer에서 Int로 좁힌다 — uhs 자신의 Exp 타입은 이미 6장의 Translate.hs에서 기계 폭의 Int로 정한 바 있고(그 산술 테이블은 Int -> Int -> Int 타입이다), 프론트엔드가 뒤늦게 이를 맞춘다.
  • 손으로 지은 Map. Data.Map은 Ord 인스턴스가 필요하지만 uhs에는 인스턴스가 아예 없다 — 그래서 f113210f이 src/MicroHs/Map.hs(33줄)를 추가하며, 타입 바로 옆 주석에서 스스로의 한계를 인정한다(f113210f:src/MicroHs/Map.hs 5행): -- This is a pretty bad implementation. 모든 조회와 병합 함수는 타입 클래스 제약을 통해 (==)에 손을 뻗는 대신, 동등성 검사를 명시적 인자로 받는다(lookup :: (k -> k -> Bool) -> k -> Map k v -> Maybe v).
  • 결합성 메커니즘이 없다 — 그래서 uhs 자신의 파서는 infixl/infixr 선언을 읽는 대신, pExpr 안의 pLeftAssoc/ pRightAssoc/pNonAssoc 호출로 손으로 짠 하나의 사다리에서 모든 연산자의 우선순위를 알아낸다(f113210f:src/MicroHs/Parse.hs 450–462행) — 읽을 선언이 아예 없다; 이 장의 커밋 범위 전체에서 src/MicroHs/Parse.hs에 infix를 프로젝트 전체로 검색해도 아무것도 나오지 않는다. 이 범위의 lib/*.hs에 실제로 나타나는 --Yinfixr 2 || 스타일 주석들(7장의 "무슨 일이 있었나"가 하나를 인용한다)은 아래의 "로컬 lib" 빌드 경로에서 GHC를 위한 것이지, uhs 자신의 파서를 위한 것이 아니다.

--X/--Y 트릭

같은 소스 파일이 서로 다른 필요를 가진 두 컴파일러를 동시에 만족시켜야 한다 — GHC는 진짜 hiding 절, 진짜 결합성 선언, 진짜 deriving을 원하고; uhs는 이들 중 무엇도 이해하지 못하며 시도했다간 막혀 버릴 것이다. 해법은 텍스트 층위다: GHC 전용 문법을 두 글자짜리 마커 접두사가 붙은 평범한 주석으로 적어 두고, GHC가 파일을 파싱하기 직전에 GHC 전처리기(-F -pgmF, 컴파일 전에 소스를 재작성하는 단계)가 그 마커를 벗겨내게 한다. uhs 자신은 이 전처리기를 결코 실행하지 않으므로, 그저 평범한 주석으로 보고 건너뛴다. 두 스크립트 모두 두 줄이다(b71e0e90:convert.sh가 67715b16:convertX.sh와 bea8283d:convertY.sh로 나뉜다; 여기서는 둘 다 존재하게 된 시점의 모습인 f113210f:convertX.sh와 f113210f:convertY.sh를 각각 전문 인용한다):

#!/bin/sh
( echo "{-# LINE 1 \"$1\" #-}" ; sed -e 's/--X//' $2 ) > $3
#!/bin/sh
( echo "{-# LINE 1 \"$1\" #-}" ; sed -e 's/--Y//' $2 ) > $3

convertX.sh는 $(BIN)/uhs 빌드 자체(GHCE, 위의 1단계)에 공급된다: import Prelude --Xhiding (Monad(..), ...) 같은 줄은 GHC가 보는 순간 import Prelude hiding (Monad(..), ...)가 된다 — uhs의 소스는 자신만의 >>=/pure/<*>를 평범한 최상위 함수로 손수 지어 쓰는데(7장 참고), 그렇지 않으면 GHC의 진짜 Prelude가 그 이름들을 두고 충돌할 것이므로 필요하다. convertY.sh는 "로컬 lib" 빌드(GHCC, bootboottest의 $(BIN)/bootuhs가 의존하는 장치)에 공급된다: lib/Data/Bool.hs의 --Yinfixr 2 || 같은 줄은 진짜 infixr 2 || 선언이 되는데, 그 빌드는 lib/의 손으로 지은 (||)를 GHC 자신의 식 문법 아래서 컴파일하며, uhs 자신의 파서와 달리 이 문법은 선언된 결합성을 정말로 신경 쓰므로 필요하다. 같은 소스, 두 개의 마커, 두 종류의 독자.

여기에 붙일 이름이 하나 더 있다

이 두 단계짜리 자기 검사 — 한 단계 건너서 컴파일러 출력이 컴파일러 출력과 일치하는 것 — 는 켄 톰프슨(Ken Thompson)의 1983년 튜링상 강연 "Reflections on Trusting Trust"(1984년 CACM에 게재)가 제기하는 것과 같은 모양의 신뢰 문제다: 통과한 cmp는 두 빌드가 서로 일치한다는 것을 증명할 뿐, 둘 중 어느 쪽이 옳다는 것을 증명하지 않는다 — 그래서 최초의 GHC로 빌드된 bin/uhs에 심어진 버그(혹은 그보다 더한 무언가)는 원리상 이후의 모든 부트스트랩을 아무도 눈치채지 못한 채 살아남을 수 있다.

🌐 Read in English

9장의 배경지식.

힌들리-밀너, 차근차근

이 글은 힌들리-밀너 일반에 대한 튜토리얼이 아니다 — 교과서적인 HM에는 일반화, 발생 검사(occurs check), let 다형성이 처음부터 갖춰져 있다. 이 글이 다루는 것은 MicroHs가 실제로 처음 내놓은 특정 검사기, 즉 cc57346e("First version with type checking."(타입 검사가 들어간 첫 버전), 2023-08-06) 시점의 src/MicroHs/TypeCheck.hs다. 아래의 모든 주장은 그 파일, 그 커밋을 기준으로, 또는 cc57346e 빌드를 통해 실제로 실행한 프로그램을 기준으로 검증되었다.

타입도 항이다

핵심이자 다른 모든 절이 의지하는 사실: MicroHs에는 별도의 타입 구문용 AST가 없다. 33b009fa("Use Expr for EType."(EType에 Expr을 쓰기), 2023-07-21)는 8장의 db418344 이래로 있어 왔던 세 생성자짜리 EType(TVar/TConstr/TUVar)을 지우고, 이미 항을 표현하고 있는 바로 그 Expr에 대한 타입 동의어로 바꿔친다 (33b009fa:src/MicroHs/Parse.hs 99–111행, 전문 인용):

-- Expr restricted to
--  * after desugaring: EApp and EVar
--  * before desugaring: EApp, EVar, ETuple, EList
type EType = Expr

validType :: Expr -> Bool
validType ae =
  case ae of
    EVar _ -> True
    EApp f a -> validType f && validType a
    EList es -> length es <= 1 && all validType (take 1 es)
    ETuple es -> all validType es
    _ -> False

타입이란 그저 validType을 만족하는 Expr일 뿐이다 — 타입 변수와 타입 생성자는 둘 다 EVar이고, 타입 적용(Maybe Int, [a], a -> b)은 EApp이며, 타입을 위한 별도의 파서 생성 규칙은 아예 없다: pType은 평범한 식 파서를 그대로 돌려서 돌아온 결과에 validType을 검사할 뿐이다(33b009fa:src/MicroHs/Parse.hs 450–454행). 이는 검사기 안까지 그대로 이어진다 — 메타변수(교과서 HM의 α, β, ...)는 같은 Expr 타입 위의 생성자 하나가 더 있는 것일 뿐이다 (cc57346e:src/MicroHs/Parse.hs 56–78행, 일부 인용, ...는 중간에 생략된 생성자를 표시):

data Expr
  = EVar Ident
  | EApp Expr Expr
  ...
  | EBad
  -- Only while type checking
  | EUVar Int
  -- Constructors after type checking
  | ECon ConTyInfo Ident
  --Xderiving (Show, Eq)

Type 모듈이 따로 없을 뿐 아니라 별도의 Kind 타입조차 없다 — type EKind = EType(cc57346e:src/MicroHs/Parse.hs 133행). 항, 타입, 카인드가 처음부터 끝까지 하나의 재귀적 데이터 타입이다.

메타변수와 치환, 구현된 그대로

미지의 타입은 EUVar Int, 그저 정수 태그일 뿐이다. 그 태그를 풀어내는 치환은 검사기 자신의 상태 안에 있는 단 하나의 IM.IntMap EType이다 (cc57346e:src/MicroHs/TypeCheck.hs 132행):

data TCState = TC IdentModule Int TypeTable ValueTable (IM.IntMap EType)

그 옆의 Int는 새 이름을 위한 카운터다; newUVar가 이를 하나 올리고 EUVar n을 돌려준다(cc57346e:src/MicroHs/TypeCheck.hs 338–342행). 해를 기록하는 것은 평범한 맵 삽입인 addUVar다 (cc57346e:src/MicroHs/TypeCheck.hs 254–262행, 전문 인용):

addUVar :: Int -> EType -> T ()
addUVar i t = T.do
  let
    add = T.do
      TC mn n tenv venv sub <- get
      put (TC mn n tenv venv (IM.insert i t sub))
  case t of
    EUVar j -> if i == j then T.return () else add
    _ -> add

여기서의 유일한 검사는 i == j다 — i ↦ EUVar i 삽입을 거부한다. 이것이 안전망의 전부다; 더 깊은 발생 검사는 없으며, 아래 "이 검사기가 빠뜨린 것"이 그 대가를 보여준다. 기록된 해를 다시 읽어내는 것은 맵을 재귀적으로 따라간다(derefUVar, cc57346e:src/MicroHs/TypeCheck.hs 291–300행, 전문 인용):

derefUVar :: EType -> T EType
derefUVar at =
  case at of
    EApp f a -> EApp <$> expandType f <*> expandType a
    EUVar i -> T.do
      sub <- gets uvarSubst
      case IM.lookup i sub of
        Nothing -> T.return at
        Just t -> derefUVar t
    _ -> T.return at

expandType(270–273행)은 derefUVar 다음에 expandSyn을 잇는 것으로, 다른 맵 — 타입 동의어 — 에 대해 같은 종류의 조회를 한다. 그리고 이름 있는 타입 변수를 치환하는 것(forall 인스턴스화가 필요로 하는 것)은 또 별개다: subst, 평범한 구문적 순회다 (cc57346e:src/MicroHs/Parse.hs 759–768행, 전문 인용):

subst :: [(Ident, Expr)] -> Expr -> Expr
subst s =
  let
    sub ae =
      case ae of
        EVar i -> fromMaybe ae $ lookupBy eqIdent i s
        EApp f a -> EApp (sub f) (sub a)
        EUVar _ -> ae
        _ -> error "subst unimplemented"
  in sub

세 가지 메커니즘, 세 조각의 상태, 모두 하나의 Expr 타입 위에 있다.

단일화, 손으로 추적하기

unify/unifyR(cc57346e:src/MicroHs/TypeCheck.hs 302–330행, 전문 인용)이 알고리즘의 전부다 — 양쪽을 역참조한 다음, 구조적으로 비교한다:

unify :: HasCallStack => EType -> EType -> T ()
unify a b = T.do
  aa <- expandType a
  bb <- expandType b
  unifyR aa bb
  
unifyR :: HasCallStack => EType -> EType -> T ()
unifyR a b = T.do
  venv <- gets valueTable
  tenv <- gets typeTable
  let bad = error $ "Cannot unify " ++ showExpr a ++ " and " ++ showExpr b ++ "\n" ++
                    show a ++ " - " ++ show b ++ "\n" ++
                    show tenv ++ "\n" ++
                    show venv
  case a of
    EVar ia ->
      case b of
        EVar ib  -> if eqIdent ia ib then T.return () else bad
        EApp _ _ -> bad
        EUVar i  -> addUVar i a
        _        -> impossible
    EApp fa xa ->
      case b of
        EVar _     -> bad
        EApp fb xb -> T.do { unify fa fb; unify xa xb }
        EUVar i    -> addUVar i a
        _          -> impossible
    EUVar i -> addUVar i b
    _ -> impossible

디슈가링된 EType은 EVar/EApp/EUVar만 담으므로, 케이스는 네 개뿐이다: 두 생성자 이름은 식별자로 정확히 일치해야 한다; 두 적용은 머리와 인자를 따로따로 단일화한다(tArrow a r = EApp (EApp (EVar "Primitives.->") a) r이므로, a -> b와 Int -> Char가 이렇게 둘로 갈라진다); 어느 한쪽이라도 메타변수면 그저 바인딩을 기록할 뿐이다.

추적 1 — 호출 const 5 'x'에서, const :: forall a b . a -> b -> a는 사용 지점에서 EUVar 2 -> EUVar 3 -> EUVar 2로 새로 인스턴스화된 상태다(아래 tInst 참고) — 이 적용의 결과 메타변수 τ(EUVar 1)는 tInst가 스킴을 인스턴스화하기 전에 할당되므로, 가장 높은 번호가 아니라 가장 낮은 새 번호를 갖는다. 이 적용을 검사하는 것은 새로 인스턴스화된 스킴을 Int -> (Char -> τ)에 대해 단일화한다: unify (EUVar 2) Int → addUVar 2 Int; unify (EUVar 3) Char → addUVar 3 Char; unify (EUVar 1) (EUVar 2) → unify는 먼저 양쪽에 expandType을 돌리는데, 2 ↦ Int가 이미 맵에 있으므로 기록되는 바인딩은 곧바로 addUVar 1 Int다 — 두 단계를 거쳐 따라갈 필요가 없다. 이후 τ를 역참조하면 1 ↦ Int를 읽는다 — 이 호출의 결과 타입은 Int다.

추적 2 — 실제로 실행해 본, 실패하는 단일화: bad2 :: forall a b . a -> b -> a; bad2 x y = x + 1은 Cannot unify a and Primitives.Int로 실패한다 — bad2 자신의 본문 안에서는 시그니처의 a가 그대로, 인스턴스화되지 않은 EVar 이름이기 때문이다(이유는 다음 절에서). unifyR의 EVar/EVar 케이스가 ia = "a", ib = "Primitives.Int"로 실행되고, eqIdent가 거짓이 되어 bad가 발동한다. 메타변수는 전혀 관여하지 않는다.

forall, 인스턴스화, 그리고 고정된 타입 변수

f7db30f1("Add lots of type signatues and forall."(타입 시그니처와 forall을 왕창 추가), 2023-07-23)는 표준 라이브러리 전체에 forall을 추가한다 — 파일 9개, 추가 134줄, 삭제 74줄, 전부 lib/ 및 Makefile 아래다. 손댄 함수 대부분은 이미 시그니처를 갖고 있었다; 새로 생긴 것은 한정 기호다(f7db30f1:lib/Data/Function.hs, 일부 인용: 첫 번째 ... 뒤에 .와 id가, 두 번째 ... 뒤에 flip이, fix 뒤에 uncurry가 생략됨 — 이 넷은 각각 ($)와 같은 방식으로 그저 forall을 얻을 뿐이다):

-($) :: (a -> b) -> a -> b
+($) :: forall a b . (a -> b) -> a -> b
...
-const :: a -> b -> a
+const :: forall a b . a -> b -> a
...
+fix :: forall a . (a -> a) -> a
 fix = primFix

한정 기호가 중요한 이유는 기계적이다. ETypeScheme [Ident] EType은 속박된 이름의 명시적인 목록을 나른다(cc57346e:src/MicroHs/Parse.hs 130행), 그리고 파서는 그 키워드를 볼 때만 이를 채운다 (cc57346e:src/MicroHs/Parse.hs 484–488행, 전문 인용):

pTypeScheme :: P ETypeScheme
pTypeScheme = P.do
  vs <- (pKeyword "forall" *> esome pLIdent <* pSym '.') <|< pure []
  t <- pType
  pure $ ETypeScheme vs t

시그니처를 검사하는 것(TypeCheck.hs의 tcTypeScheme, 485–488행)은 정확히 vs 안의 이름들만 스코프에 들여온다; 한 번도 한정되지 않은 이름은 그냥 스코프에 없다. cc57346e 워크트리에서 const의 시그니처에서 한정 기호를 지우고(const :: a -> b -> a, forall 없음) 이를 사용하는 프로그램을 컴파일해 확인했다:

mhs: undefined variable a
CallStack (from HasCallStack):
  error, called at src/MicroHs/TypeCheck.hs:354:16 in main:MicroHs.TypeCheck

— 검사기는 const의 시그니처에서 실패하고, 본문에는 아예 도달하지 못한다. forall이 없으면 다형적으로 보이는 시그니처는 그 자체로 잘못된 형태이며, 이것이 cc57346e가 무엇이든 받아들일 수 있게 되기 두 주 전에 f7db30f1이 라이브러리를 훑어야 했던 이유다.

그렇다면 이 파일 어디에도 일반화 단계가 없는데, 무엇이 정의를 다형적으로 만드는가? tInst는 스킴의 속박된 이름들을 오직 사용 지점에서만 새 메타변수로 새로 고친다 — 모든 EVar 참조는 tLookupInst를 거친다(cc57346e:src/MicroHs/TypeCheck.hs 364–371행, 전문 인용):

tInst :: ETypeScheme -> T EType
tInst as =
  case as of
    ETypeScheme vs t ->
      if null vs then T.return t
      else T.do
        us <- mapM (const newUVar) (replicate (length vs) ())
        T.return (subst (zip vs us) t)

하지만 검사기가 정의 자체를 검증할 때는, tcDefValue(526–536행)가 본문을 선언된 타입에 대해 그대로 검사한다 — 여전히 새 메타변수가 아니라 리터럴 식별자 a/b를 그대로 담은 채로. unifyR의 EVar/EVar 케이스가 동일한 이름을 요구하므로, a는 그 정의 하나를 검사하는 동안 고정되고 불투명한 상수처럼 동작한다 — 정확히 위의 bad2 실패가 보여주는 것이다. 두 가지 결과가 따른다: (1) 다형성 = 정의에서의 고정 검사 더하기 모든 호출 지점에서의 새 인스턴스화 (검증됨: const 5 'x'와 const "hi" 42를 호출하는 프로그램 하나가 컴파일되어 5, 그다음 "hi"를 출력한다); (2) 일반화가 없으므로, 모든 최상위 바인딩이 검사되려면 명시적 시그니처가 필요하다 — 다음 절이 시그니처가 없을 때의 대가를 보여준다.

이 검사기가 빠뜨린 것

파일을 직접 확인한 것이지, "진짜" 힌들리-밀너에 대한 익숙함에서 가정한 것이 아니다:

  • 발생 검사가 없다. addUVar의 유일한 방어는 i ↦ EUVar i 그 자체이며, t 안에 EUVar i가 더 깊이 중첩되어 있는지는 결코 검사하지 않는다. \x -> x x는 x의 메타변수를 x의-메타변수 -> τ와 단일화하는데, addUVar는 이 순환을 태연히 기록한다 — "무한 타입" 오류는 없다. 실제로 실행해 보면(cc57346e 빌드, ulimit -v 2000000): 그 람다를 쓰지 않는 let 안에 넣은 프로그램은 mhs: out of memory로 죽는데, derefUVar가 치환 자신의 순환을 뒤쫓는 것이다. (한도 없이 두면, 같은 실행이 죽임당하기 전까지 호스트 RAM을 수십 기가바이트 소비했다 — 가상의 이야기가 아니라 진짜 종료되지 않는 것이다.)
  • 지역 바인딩에는 let 다형성이 없다. tcBinds는 각 지역 바인딩에 빈 스킴 ETypeScheme [] 안의 새 메타변수 하나를 준다 (cc57346e:src/MicroHs/TypeCheck.hs 708행), 그래서 이에 대한 tInst는 null vs 케이스가 되어 — 새로 고침이 결코 일어나지 않는다. 두 개의 다른 타입으로 쓰이는 지역 함수 — 교과서적인 let 일반화가 존재하는 이유 그 자체 — 는 여기서 그냥 "Cannot unify" 오류가 된다.
  • 상호 재귀는, 코드 스스로 인정하듯, 열린 구멍이다. tcBinds는 -- XXX No mutual recursion yet(703행)를 달고 있다. cc57346e에서 테스트해 보면: 단독으로 자기 재귀하는 지역 함수는 동작하지만 (let countdown n = if n == 0 then 0 else countdown (n - 1)은 0까지 실행된다), 서로를 호출하는 두 지역 함수는 실패한다 — 타입 검사 다음 단계인 이름 해석에서: src/MicroHs/Main.hs의 링킹 패스가 내는 mhs: undefined: isOdd.
  • 타입 클래스가 없다. cc57346e 시점의 Parse.hs와 TypeCheck.hs에 grep -in "class"를 돌려도 아무것도 나오지 않는다 — AST 케이스도, 딕셔너리도 없다. do 표기법은 여전히 Monad 제약이 아니라 명시적 모듈 한정자(3장)로 (>>=)/(>>)를 해소한다.
  • 카인드가 있긴 하지만, 겨우 있는 정도다. EKind는 다시 EType이며, 단일 센티널 kType = EVar "Type"(228–229행)에 대해 검사된다 — data Foo a = Foo (a Int) 같은 애리티 불일치를 거부할 정도는 되지만, 모든 타입 변수는 카인드 Type을 가지며 * -> * 이상을 다루는 장치는 없다.

예제로 풀어보기: const

lib/Data/Function.hs(cc57346e):

const :: forall a b . a -> b -> a
const x _ = x

tcDefValue가 하는 방식대로 손으로 추론해 보면 — ELam "x" (ELam "_" (EVar "x"))를 선언된 a -> b -> a에 대해 순회하며, a/b는 고정된 이름으로 스코프에 있다:

  1. 바깥쪽 ELam "x" e, 기대 타입은 a -> (b -> a). 새 메타변수 τ₁이 x의 타입을 나타낸다; 본문은 기대 타입 없이 검사되고 (ELam 케이스는 Nothing으로 재귀하고 나중에 단일화한다), 환경에는 x : τ₁이 들어간다.
  2. 안쪽 ELam "_" (EVar "x"): _를 위한 새 τ₂; 본문은 x를 찾아보고 τ₁을 돌려준다. 안쪽 람다의 타입은 τ₂ -> τ₁이다.
  3. 바깥쪽 람다의 구성된 타입 τ₁ -> (τ₂ -> τ₁)을 a -> (b -> a)에 대해 단일화한다: τ₁ ↦ a, τ₂ ↦ b, 그리고 반환 위치의 unify τ₁ a는 unifyR (EVar "a") (EVar "a")로 귀결되어 — eqIdent에 의해 성공한다.

모든 메타변수가 시그니처 자신의 a/b로 해소되고, 모순은 없다 — 본문은 소스와 일치하는 forall a b . a -> b -> a에 대해 검사를 통과한다. 실제 검사기를 통해 확인(cc57346e 워크트리, 실제 lib/ 트리에 -ilib):

$ cat > ConstDemo.hs <<'EOF'
module ConstDemo(module ConstDemo) where
import Prelude

main :: IO ()
main = do
  putStrLn (showInt (const 5 'x'))
  putStrLn (const "hi" 42)
EOF
$ nix develop $MICROHS -c ./bin/mhs -ilib ConstDemo
$ nix develop $MICROHS -c ./bin/eval
5
hi

const 5 'x'는 a ↦ Int, b ↦ Char로 인스턴스화되고; const "hi" 42는 a ↦ [Char], b ↦ Int로 인스턴스화된다 — 고정 검사된 하나의 정의에 대한 두 개의 독립적인 인스턴스화이며, tInst가 예측하는 그대로다.

🌐 Read in English

부록 — 이 책은 어떻게 만들어졌나

이 책은 MicroHs의 역사에서 배우고 싶어 한 — 그리고 마땅히도, 남의 작업에 대한 AI의 검증되지 않은 서술을 그대로 신뢰하고 싶어 하지 않은 — 한 사람의 지시에 따라 AI(앤트로픽의 클로드)가 썼다. 이 부록은 제작 과정을 서술하는데, 그 과정 자체가 재사용 가능한 기법이기 때문이다: 풍부한 역사를 가진 어떤 저장소든 겨냥하면 검증된 교재 한 권이 나온다. 이 판본을 위한 설계 명세와 실행 계획은 이 저장소의 docs/superpowers/ 디렉터리에 보존되어 있다.

과정의 모양

  1. 이정표에서 범위를 정한다. git log --reverse를 훑어 자연스러운 호를 찾는다: 여기서는, "First version"(첫 버전, 파일 두 개, 저녁 한 번) → "Make a real compiler"(진짜 컴파일러 만들기) → 셀프 호스팅 → "Now bootstraps with type checking."(이제 타입 검사와 함께 부트스트랩된다) 순서다. 장(章)들은 그 호의 주제별 국면이지 — 장당 커밋 하나도, 장당 하위 시스템 하나도 아니다. 작업 방식이 깃든 곳이 시간 순서이며, 그 방식이야말로 요점이었기 때문이다.

  2. 명세, 그다음 계획, 그다음 장마다 새로운 시선. 짧은 설계 명세가 독자상(하스켈에 익숙하지만 컴파일러는 처음인 사람), 장 구성, 규칙을 확정했다. 계획이 이를 열네 개의 작업으로 바꾸었다. 각 장은 그다음 새로운 AI 에이전트가 썼는데, 이 에이전트는 오직 자신의 브리핑만 보았다: 커밋 범위, 깊이 있게 다뤄야 할 커밋들, 전역 제약을 그대로, 그리고 앞선 장들이 확립한 사실들. 새로운 시선은 갖고 있지 않은 맥락에 기댈 수 없다 — 주장하는 모든 것을 역사 자체에서 끌어와야 한다.

  3. 모든 장에 적대적 리뷰. 각 장을 그 브리핑에 대해 다른 에이전트가 검토했는데, 작성자의 보고서를 검증되지 않은 주장으로 취급하라고 명시적으로 지시받았고, 이름 붙은 위험 요소의 짧은 목록을 받아 이를 diff에서 다시 유도해 검사했다 — 작성자의 서술을 다시 읽어서가 아니라. 발견 사항에는 file:line 증거가 필요했다. 작성자가 고쳤고, 리뷰어가 그 수정을 다시 검토했다. 열네 개 작업 중 열 개는 최소 한 번의 수정 라운드가 필요했다.

  4. 최종 전체 책 리뷰(가용한 가장 강력한 모델로)는 장 단위 게이트로는 검사할 수 없는 것을 검사했다: 장들 사이의 연속성, uhs→mhs 이름 변경의 시간대, 확고한 경계(a38c64ce 이후로는 아무것도 서술하지 않음), 그리고 장을 가로지르는 새로운 사실 표본들.

중요했던 규칙들

품질을 위한 장치 대부분은 돌이켜 보면 당연하다 — 코드를 바이트 그대로 인용하고, 주장하는 것을 실제로 실행해 보라. 아래 규칙들은 배워서 얻은 것들로, 각각 이 책의 초고에서 리뷰가 실제로 실패를 잡아낸 데서 나왔다:

  • 인용은 바이트 그대로이거나, 그렇지 않다면 표시한다. 개관의 초기 초고가 두 행을 뺀 뒤 칼럼을 다시 맞춘 git show --stat 출력을 인용한 적이 있다. 바이트 비교가 이를 잡아냈다. 규칙은 이렇게 됐다: 실제 바이트를 인용하고, 모든 생략을 명시적인 ...로 표시하며, commit:file을 인용할 때는 그 커밋 시점에 실제로 존재하던 경로를 쓴다(뒤쪽의 한 장이 이미 src/runtime/eval.c로 옮겨진 뒤의 커밋에서 eval.c를 인용한 적이 있는데 — 그 인용이 풀리지 않았다).

  • 검증되지 않은 메커니즘을 사실처럼 서술하지 않는다. 한 초고는 x + y에서 C가 어느 피연산자를 먼저 평가하는지 단언했다. C 표준은 이를 규정하지 않고, 어떤 트랜스크립트도 이를 보여주지 않았다; 그 대목은 어느 순서든 성립하도록 다시 쓰였다. 다른 초고는 그럴듯한 메커니즘으로 버그 수정을 서술했는데 — 커밋은 맞지만 파일이 틀리고 버그의 종류도 틀린 — 리뷰어가 diff에서 이를 다시 유도해 봤을 때만 무너졌다. 그럴듯하지만 틀린 것은 체크리스트는 통과하지만 재유도는 통과하지 못한다.

  • 인쇄된 텍스트 자체가 테스트 산출물이다. 모든 "직접 해보기" 블록은 출판 전에 실행되었다 — 그런데 그것으로는 충분하지 않았다. 어느 장의 연습 문제들은 각각 따로는 동작했지만, 인쇄된 순서가 그 사이의 재빌드를 빠뜨려서, 텍스트를 순서대로 따라간 독자는 오래된 산출물을 diff하게 되었을 것이다. 다른 장은 아직 워크트리가 필요한 연습 문제들보다 워크트리 정리 블록을 앞에 배치했다. 고친 방법: 리뷰어가 순진한 독자로서, 인쇄된 그대로 연습 문제를 종이 위에서 따라가 본다.

  • 커밋 제목은 실제보다 덜 말한다; diff를 감사한다. 단순한 파일 이동이라고 제목 붙은 어느 커밋이 조용히 완전히 새로운 모듈(네이티브 평가에 대한 초기 실험)을 들여왔다 — 게다가 종료되지 않은 문자열 리터럴까지 딸려서. 서술은 처음에 그 제목의 이야기를 그대로 반복했다. 규칙: 서술하는 모든 커밋에 대해 diff 전체를 읽고 제목이 말하지 않는 것을 밝힌다. 이 책의 각 장은 지금 아우구스트손 (Augustsson)의 역사에 대해 이 원칙을 실천하고 있으며, 리뷰들은 이 책 자체에 대해 그것을 실천했다.

  • 재현되지 않는 것은 재현되지 않는다고 말한다. 기록으로 남은 2023년의 스택 오버플로 크래시가 최신 시스템의 기본 한도 아래서는 일어나기를 거부했다. 그 장은 그 사실을 그대로 말하고, 대신 관찰된 것을 보여준다. 정직한 엔지니어링을 다루는 책이라면 자신의 환경이 가진 차이를 얼버무릴 수 없다.

  • 부정적인 주장도 검증한다. 8장은 프로파일링 플래그가 컴비네이터 빈도 순서에 반영되는지 확인한다 — diff는 아무 연관도 보여주지 않고, 그 장은 실제로 살펴본 뒤 정확히 그렇게 말한다.

숫자로 가늠하기

대략: 책 제작에 커밋 28개; 열네 개 작업 중 열 개가 수정 라운드를 필요로 함; 리뷰에서 발견된 사항은 바이트 수준(다시 맞춘 stat 칼럼)부터 구조적인 것(연습 문제의 전제를 무효화한 누락된 재빌드 단계)까지 걸쳐 있었다. 모든 수정은 그 자체로 다시 리뷰되었다. 최종 전체 책 리뷰는 여덟 개의 추가 문제 — 두 개의 aux 문서가 이탈해 버린 관례를 가정한 명령들, 범위 서술 하나의 오류 — 를 찾아냈고, 그 수정 역시 다시 리뷰되었다. 이 어느 것도 정확성을 보장하지는 않는다; 다만 확인할 수 있는 모든 주장이 이미 한 번은 적대자의 검사를 견뎌냈다는 뜻이다.

이 과정을 재사용하기

이 과정을 압축한, 실행 가능한 형태 — 장 브리핑과 리뷰어 배정 템플릿, 그리고 위의 실패에서 얻은 규칙들 — 은 이 책과 함께 에이전트 스킬 파일로 배포된다: skills/writing-teaching-books-from-git-history/. 평범한 마크다운 파일이며, 그 디렉터리의 README가 스킬 파일이 무엇이고 어떻게 설치하는지 설명한다. 이 방법에서 AI에만 해당하는 것은 하나도 없다. docs/superpowers/의 명세와 계획은 이 책을 만들어낸 구체적인 사례를 보여준다.

다른 곳에 적용하려면: 그 역사에서 배우고 싶은 저장소를 찾고, 끝점을 정하고, 이 책이 여러분과 맺는 것과 같은 약속을 모든 장에 적용한다 — 인용된 모든 것은 실재하고, 주장된 모든 것은 실제로 실행되었으며, 그 나머지는 모두 표시되어 있다는 약속을.

🌐 Read in English

용어집 — Korean translation glossary

Confirmed 2026-07-20: 독자가 별도 선호 없음을 확인하여 초안 선택을 그대로 확정함. 전 문서에 이 표기를 일관 적용한다.

Register (문체)

  • Book prose: 문어체 평서형("~한다/~이다"). Korean CS textbook standard.
  • Reader-addressing imperatives in exercises: "~해 보자" style.

Structural headings (chapter anatomy)

EnglishKorean
Where we are현재 위치
What happened무슨 일이 있었나
Why it matters왜 중요한가
Notebook margin실험 노트 여백
Try it직접 해보기
Background for chapter NN장의 배경지식
Build note빌드 노트

Protected (never translated)

Code blocks, transcripts, commands, file paths, identifiers, commit shas, sha:path citations, flag names, and quoted commit subjects (e.g. "Make a real compiler.") — commit subjects get a Korean gloss in parentheses at first occurrence per document.

Terms

EnglishKoreanNote
lambda calculus람다 대수
combinator컴비네이터
bracket abstraction브래킷 추상화확정
graph reduction그래프 축약reduction = 축약 (PL 관례)
beta-reduction베타 축약
reduction rule축약 규칙
redex리덱스first use: 리덱스(축약 가능한 식)
lazy evaluation지연 평가
strict / eager엄격한 / 즉시 평가
sharing공유
thunk썽크first use: 썽크(미평가 식)
spine (stack)스파인 (스택)
indirection node간접 노드
cell / node셀 / 노드
heap / stack힙 / 스택
mark-scan garbage collection마크-스캔 가비지 컬렉션GC = 가비지 컬렉션
free / bound variable자유 변수 / 속박 변수
variable capture변수 포획
environment환경
fixed point고정점
Y combinatorY 컴비네이터
recursion재귀
parser / parsing파서 / 파싱
pretty-printer프리티 프린터확정
desugaring디슈가링first use: 디슈가링(문법 설탕 풀어내기)
self-hosting셀프 호스팅
bootstrap부트스트랩
type checking타입 검사
type signature타입 시그니처확정
pattern matching패턴 매칭
fixity결합성과 우선순위문맥에 따라 풀어 씀
commit커밋
worktree워크트리
branch브랜치
repository저장소
chapter / appendix장 / 부록
exercise연습 문제
the seed (ch. 01 title)씨앗제목은 "The seed (씨앗)" 병기
in-place update제자리 갱신하이픈 없이
call-by-need필요에 의한 호출확정
self-application자기 적용

Chapter titles (canonical; H1은 "NN — English (한국어)" 병기, SUMMARY와 표에서는 한국어)

DocKorean title
00 — Overview개관
01 — The seed씨앗
02 — A real compiler진짜 컴파일러
03 — Growing the language언어 키우기
04 — Talking to the world세상과 대화하기
05 — Lab notebook: the working habits실험 노트: 작업 습관
06 — Becoming Haskell하스켈이 되어 가기
07 — The self-hosting push셀프 호스팅 총력전
08 — Bootstrap부트스트랩
09 — Types at last마침내 타입
Lambda calculus and SKI combinators람다 대수와 SKI 컴비네이터
Bracket abstraction브래킷 추상화
Graph reduction and laziness그래프 축약과 지연 평가
The Y combinator and recursionY 컴비네이터와 재귀
Parser combinators파서 컴비네이터
Bootstrapping a compiler컴파일러 부트스트랩
Hindley-Milner, gently힌들리-밀너, 차근차근
How this book was made이 책은 어떻게 만들어졌나

Additional terms (chapters 02-09, aux, appendix)

EnglishKoreanNote
typeclass타입 클래스
module / import / export list모듈 / 임포트 / 익스포트 목록
qualified import한정 임포트
list comprehension리스트 컴프리헨션
operator section연산자 섹션
serialization / deserialization직렬화 / 역직렬화
monadic IO모나딕 IO
primitive프리미티브
unification단일화PL 관례
metavariable메타변수
occurs check발생 검사first use: 발생 검사(occurs check)
instantiation / generalization인스턴스화 / 일반화
substitution치환
type inference타입 추론
let-polymorphismlet 다형성
forallforall코드 표기 유지
use-after-free해제 후 사용first use: 해제 후 사용(use-after-free)
stack overflow스택 오버플로
profiling프로파일링
verbosity / verbose flag상세 출력 / 상세 출력 플래그
Makefile target타깃
milestone이정표
savepoint저장점
scope check스코프 검사
desugarer디슈가러
symbol table심볼 테이블
fixpoint (of a build)고정점ch08 은유 포함
oracle (correctness)오라클first use: 오라클(정답 판별 기준)
Build note빌드 노트blockquote 제목 > **빌드 노트:**
Habit N (ch05 구조)습관 N

확정된 관례 (settled conventions)

  • nfib.comb 뒤 조사는 모음 종성 읽기("…콤비")를 따라 가/를/는을 쓴다. 전 문서 일괄 적용.
  • .comb 파일, uhs/mhs, GHC, 파일 경로, 플래그, 커밋 제목: 원문 유지(보호 대상).