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