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