🌐 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