🌐 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