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개 파일에 저작권 헤더가 찍힌a358eaa664분 뒤다.
직접 해보기
닻: 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