쪼갤 수 없었던 54,000줄짜리 파일

직접 만든 Ruby 처리계가 왜 54,063줄 한 파일에 들어 있는지 알아봤더니, 답은 "그게 더 편해서"가 아니라 "언어가 분할을 표현하지 못해서"였다. 30,392줄까지는 언어 변경 없이 갔고, 남은 29,606줄을 위해 새 선언 문법을 구현했더니 초록불 단위 테스트 7개 아래에서 결함 4개가 살아 있었다.

merecompilersrubymutual-recursiontype-inferencerefactoring

Mere는 제가 저를 위해 만드는 작은 언어입니다. mere-ruby는 그 언어로 쓴 Ruby 처리계로, ruby/spec의 약 1,100개 예제가 CRuby와 바이트 단위로 일치하고, 207개짜리 코퍼스가 매번 진짜 ruby와 일치해야 합니다.

그게 전부 한 파일에 있었습니다. main.mere, 54,063줄.

당연한 질문을 받았습니다. 한 파일이 정말 더 나은 건가, 아니면 그냥 안 쪼갠 건가? 저는 “손을 안 댔을 뿐”이라고 생각했습니다. 아니었습니다. 언어가 그 분할을 표현하지 못했습니다. 그걸 알아내는 데 하루가 걸렸습니다.

이 글은 측정 기록입니다. 모든 숫자는 이 기계에서 나왔고, 제가 틀렸던 순간들도 맞았던 순간들과 같은 표에 들어 있습니다.

1. 다섯 가지 표기, 전부 불가능

처리계는 처리계답게 상호 재귀합니다. eval_ecall_method를 부르고, 그게 eval_body를 부르고, 그게 다시 eval_e를 부릅니다. 그래서 첫 질문은 언어가 상호 재귀가 넘을 수 없는 선을 어디에 긋는가입니다.

파일이거나 모듈일 거라고 짐작했습니다. 대신 측정했습니다.

어떻게 써봤는가 결과
한 파일 안의 let rec 사슬 두 개 unbound variable: g
module A { }module B { } unknown constructor: B
import를 정의 에 두기 unbound variable: is_odd
import를 정의 에 두기 같음 — splice 지점 이전은 보이지 않는다
and로 시작하는 파일을 import parse error: expected literal, identifier, or '('

경계는 파일도 모듈도 아니고 let rec ... and ... 사슬입니다. import는 파서가 수행하는 splice — import 위치에 선언들을 끼워 넣는 것 — 이라서, 사슬은 splice가 일어난 곳에서 닫히고 어떤 import도 그것을 넘지 못합니다.

따라서 처리계는 하나의 사슬로 써야 합니다. 그리고 사슬은 하나의 구문 요소이므로, 사슬은 곧 한 파일입니다.

2. 첫 번째 벽 뒤에 또 한 겹이 있었다

상호 재귀하지 않는 60%는 떼어낼 수 있어 보였습니다. 각 묶음을 파일로 만들고 위상 순서로 import하면 됩니다. 처음 자르고 싶었던 줄에서 시도했더니 parse error: expected literal, identifier, or '('.

원인은 코드가 아니라 파서에 있었습니다.

| _ -> let main, toks = expr toks

parse_decls는 선언을 계속 읽다가 선언을 시작할 수 없는 토큰을 만나면 그것을 프로그램 main 식의 시작으로 읽습니다 — 그 뒤는 전부 그 하나의 식에 속합니다. 그래서 import선언 전치부 안에서만 합법입니다.

main.mere의 선언 전치부는 555번째 줄에서 끝났습니다. 남은 53,500줄은 하나의 식입니다. import를 놓을 자리가 없었습니다.

최상위 let ... in이 전치부를 끝내는 구문 중 하나였기에 let ...;로 바꿨습니다. 전치부가 490줄에서 555줄로 옮겨가더니 또 다른 구문에 부딪혔고, 저는 “하나 없앨 때마다 다음 게 나온다”고 결론지었습니다.

그건 틀렸고, 틀린 건 제 검색이었습니다. 패턴이 ^let .* in이라서 in이 같은 줄에 있는 것만 잡았습니다. 남은 것은 정확히 3개, 전부 여러 줄에 걸친 것이었습니다. 31개를 전부 바꾸면 전치부가 파일 끝까지 이어지고, 54,000번째 줄의 import가 통과합니다.

교훈은 단순하고 이미 적어뒀습니다. 패턴이 낸 개수는 그 패턴이 맞힌 개수일 뿐이다. 저는 28에서 멈추고 거기서 구조에 대한 결론을 끌어냈습니다.

3. 검증 규칙, 그리고 그것이 한 번 깨졌을 때

파일 사이로 코드를 옮기는 일이 프로그램을 바꿔서는 안 됩니다. 컴파일러는 하나의 C 파일(58 MB, 36초)을 내놓으므로 강한 검사가 가능합니다 — 전후로 emit해서 diff.

바이트 단위는 아닙니다. 컴파일러가 임시 변수와 region에 일련번호를 붙이기 때문에 (_uq214, __rp7, __v2) 무언가를 옮기면 그 뒤가 전부 다시 번호를 받습니다. 제가 쓴 규칙은 “생성된 C의 차이는 번호뿐”. 일곱 번의 추출 중 여섯 번은 이것을 정확히 만족했습니다. 앞 절의 let ... in 변환도 같은 검사를 거쳐 전역 map 183개 완전 일치, 함수 5,817개 일치로 돌아왔고, 차이는 사슬 간 중복 이름 두 개의 mangling이 _uq214에서 __v2로 바뀐 것뿐이었습니다.

일곱 번째는 만족하지 못했고, 게다가 저는 그걸 잘못 보고했습니다.

“함수 4개가 __direct를 잃고 4개가 얻었다”고 썼습니다. __direct는 포화된 호출이 클로저 환경을 만들지 않고 바로 갈 수 있는 비커링 진입점입니다. 그럴듯했습니다 — 호출이 정적으로 해결되는지는 피호출자의 위치에 달렸고, 840개의 정의가 움직였으니까요.

제 정규화 탓이었습니다. emit된 이름에서 _uq\d+__v\d+를 벗기면서 단상화 접미사를 잊었습니다. 그래서

mu_bnd_snapshot__direct
mu_bnd_snapshot__Map_str_Val__Map_str_Val__direct

이 서로 다른 함수로 읽혔습니다 — 하나는 잃고 하나는 얻은 것으로. 같은 함수입니다. 소스 수준 이름으로 비교하면 전 2,549개, 후 2,547개, 그 전부가 두 빌드 모두에서 __direct를 가집니다. 실제로 바뀐 것은 죽어 있던 함수 두 개가 더 이상 emit되지 않게 된 것뿐이었습니다.

이유까지 적어 정정 커밋을 남겼습니다. 기록에 남은 잘못된 측정값은 측정값이 없는 것보다 나쁘기 때문입니다.

4. 언어를 전혀 바꾸지 않고 진행한 분할

사슬의 멤버는 1,451개. 그중 867개는 어떤 순환에도 속하지 않았습니다. let rec eval_e = ... and ... 안에 쓰여 있던 이유는 그것을 필요로 하는 코드가 거기 있었기 때문이지, 거기 있어야만 했기 때문이 아니었습니다.

main.mere 파일 수
그날 아침 54,063 1
프런트엔드와 드라이버를 빼냄 46,168 3
모듈 네 개 더 39,651 7
비순환 사슬 멤버 840개를 빼냄 30,610 8
죽은 코드 제거 30,535 8
마지막 다섯 개 30,392 8

⚠ 분할의 단위는 주제가 아니라 파일입니다. 처음에는 840개를 그 아래 붙어 있던 절 주석(Struct, Zlib, Marshal, sprintf)으로 묶으려 했고, 묶음들 사이에 순환이 13개 나왔습니다. 그 파일들을 나열할 순서는 존재하지 않습니다. 하나의 let rec ... and ... 그룹으로 만들면 내부 순서는 전혀 문제되지 않고, 840개는 서로 비순환(그 안의 강연결 성분은 모두 단일 원소)이라 파일 내부에도 순서 제약이 없습니다.

한 파일 = 하나의 상호 재귀 그룹. 이것이 이 언어가 실제로 가진 입자 크기입니다.

5. 벗겨내기는 고정점에 도달한다

840개가 나간 뒤, 방금 쓴 게이트가 아무도 부르지 않는 최상위 함수 30개를 찾아냈습니다. 그것을 지우자 다섯 개가 더 사슬 안쪽에서 도달 불가능해졌고 (arr_product2, errno_check, lp_seed, params_wo_defaults, register_builtin_consts) 이들도 나갈 수 있게 되었습니다.

남은 것은 596 멤버 / 29,606줄이고, 더 이상 하나도 움직일 수 없습니다. 평가기 사슬은 이제 정확히 그 기약한 부분 그 자체입니다.

6. 게이트 세 개, 그리고 쓴 날 각자가 잡은 것

파일 분할은 리팩터링이고, 리팩터링에는 제 주의력 말고 다른 감시자가 필요합니다.

dup_defs_check.sh — 하나의 사슬 안에서 같은 최상위 이름을 두 번 정의할 수 없다. 모든 호출은 나중 정의로 해결되므로 앞의 정의는 초록불 빌드에서 조용히 죽습니다. 이미 있는 이름 옆에 같은 이름의 helper를 더하다가 발견했습니다. 타입이 맞으니 빌드는 초록이고, 제 수정이 효과가 없었습니다 — 모든 호출이 옛 함수로 가고 있었으니까요. 오후를 거의 다 썼습니다. 6건이 쌓여 있었고, 그중 2건은 살아 있는 쪽과 동작이 달랐으며 한 번도 실행된 적이 없었습니다.

dead_defs_check.sh — 정의되고 한 번도 호출되지 않는 최상위 함수를 금지한다. 누군가 끝내지 않은 재작성에 남겨진 것이 30개 있었습니다.

⚠ 이걸 쓰다가 살아 있는 전역을 지울 뻔했습니다. 정의가 끝나는 지점을 “다음 = fn“으로 정해뒀는데, GC 루트 테이블인 let gc_unsafe = map_new ();가 두 함수 사이에 끼어 있다가 함께 삼켜졌습니다. 컴파일러가 잡아줬습니다 (unbound variable: gc_unsafe). 올바른 규칙은 “종류를 불문한 다음 최상위 항목” 입니다.

gen_structure_map.py — 어떤 정의가 어느 사슬에 있고 순환 안에 있는지의 지도. ⚠ 이것은 프로그램을 splice 순서(import를 펼친 순서)로 읽어야 합니다. 파일을 알파벳 순으로 읽으면 평가기의 순환이 376개 함수에서 383개로 움직입니다 — 다른 프로그램을 보고 있는 셈입니다.

7. 언어 변경: let fn

남은 72%를 쪼개려면 “이 둘은 서로를 부른다”를 “이 둘은 한 사슬에 있다”고 말하지 않고 표현할 방법이 필요했습니다. 후보는 넷.

  1. 전방 선언let fn f: A -> B;를 먼저 두고 정의는 나중에.
  2. rec { ... } 묶음 — 파일을 넘나드는 명시적 상호 재귀 블록.
  3. 사슬 이어가기 — “이 import는 사슬을 잇는다”고 선언하는 import.
  4. 유닛 단위 순서 무관 — 최상위 전체를 하나의 재귀 스코프로.

한동안 4번이 가장 유력하다고 생각했습니다. 그러다 측정하지 않았던 것을 측정했습니다.

let rec ident = fn x -> x
and useit = fn (n: int) -> ident n;
let b = ident "s";        → type error: expected `int`, got `str`

let rec ... and 그룹은 안팎으로 단상(monomorphic)입니다. 후보 4는 최상위 전체를 그런 그룹 하나로 만들므로 모든 최상위 함수가 서로 단상이 됩니다. 다형 helper를 프로그램 어디에서도 두 타입으로 쓸 수 없게 됩니다. 후보 2와 3도 같은 모양입니다.

남는 것은 후보 1이고, 이유는 취향이 아니라 타입 시스템에 있습니다. 선언에 쓴 타입은 프로그래머가 양화하므로 정의는 다형인 채로 남습니다. mere-ruby는 잃는 게 전혀 없습니다 — 평가기 사슬은 이미 하나의 그룹이라 그 376개 함수는 이미 서로 단상입니다.

문법은 Mere 바깥에 있는 이름을 선언하는 기존 extern fn <name>: <ty>;와 짝을 이룹니다. 이쪽은 안에 있고 나중에 정의됩니다. 새 키워드를 쓰지 않습니다 — let fn은 오늘의 Mere에서 선언의 시작으로 합법이 아니기 때문입니다(패턴이 fn이 될 수 없음). (val로 했다면 그것을 평범한 식별자로 쓰는 6개 파일 14곳이 깨졌습니다.)

let fn is_even: int -> bool;
let is_odd  = fn (n: int) -> if n == 0 then false else is_even (n - 1);
let is_even = fn (n: int) -> if n == 0 then true  else is_odd  (n - 1);

구현은 조사가 예측한 대로 작았습니다. C backend는 손댈 필요가 없었습니다 — 원래 모든 함수를 앞부분에서 전방 선언합니다. 인터프리터는 약속을 자리표시자 ref에 묶고 정의가 그것을 채웁니다. let rec 그룹이 이미 하는 후처리와 같습니다.

읽어서는 알 수 없었던 것 둘. 첫째, 쓰인 타입 변수는 다형성의 약속이지 강체 이름이 아닙니다. 그리고 region 파라미터 때문에 그게 예외가 아니라 기본입니다 — mere-ruby에 필요한 161개 선언 중 117개가 타입 변수를 포함합니다. Map이나 Vec을 받는 함수는 전부 그렇기 때문입니다. 단상 버전으로는 한 줄도 쓸 수 없었습니다. 둘째, let rec 그룹의 멤버가 지킨 약속도 “지켜진 것”으로 세야 합니다. 사슬은 그룹으로 이루어져 있으므로 그쪽이 본 경로입니다.

mere --decls <file>을 더했습니다. 그 파일이 정의하는 최상위 함수들의 선언을 출력합니다. 161개를 쓰는 일은 판단이 아니라 옮겨 적기입니다.

실전 검증: mere-ruby의 38,856줄짜리 평가기 사슬을 생성한 선언 6개로 둘로 갈랐습니다. 생성된 C는 번호를 빼면 동일.

8. 그리고 그 기능에 결함이 넷 있었다

단위 테스트 7개를 붙여 착지시켰습니다. 전부 초록. 그리고 결함 넷이 전부 그 아래 살아 있었습니다.

찾아낸 것은 또 다른 단위 테스트가 아니었습니다. mere --decls의 출력을, 그것이 나온 파일 앞에 붙여 넣고 출력이 바이트 단위로 같을 것을 요구한 것 — 178개의 실제 프로그램에 대해서.

결함 증상
builtin을 가리는 이름의 약속이 지켜지지 않음 let fn odd; + let odd = ...가 “정의되지 않음“으로 거부
선언보다 구체적인 정의가 통과 정의 위의 호출이 정의에 본체가 없는 타입으로 호출 가능. 타입 검사는 통과하고 C backend가 컴파일 실패
타입을 선언하면 다형성이 사라짐 선언한 'a -> 'a가 첫 호출 지점에서 고정 — 선언하지 않는 편이 더 일반적이었다
--decls가 prelude 이름 70개를 출력하고 프로그램을 실행 process_decls가 모든 최상위 let을 평가하므로, 선언을 물으면 프로그램이 돌고 그 표준출력이 섞임

첫 번째는 builtin을 가리는 최상위 바인딩의 이름을 바꾸는 패스입니다. 전방 선언을 몰랐기 때문에 약속은 odd로, 정의는 odd__v2로 등록되었습니다. 선언과 정의는 하나의 바인딩이므로, 이제 이름 변경은 선언 위치에서 일어나고 정의가 그것을 물려받습니다.

가운데 둘은 같은 뿌리입니다. 선언과 정의를 **구체화(instantiation)**로 이었는데, 맞는 것은 **포섭(subsumption)**이었습니다. 선언 스킴의 새 인스턴스에 정의를 unify하면 정의가 약속보다 좁아도 되고, 게다가 그 인스턴스의 변수는 바깥 레벨에서 만들어지므로 정의 자신의 변수를 일반화가 닿지 않는 곳까지 끌어내립니다. 쓰인 그대로의 선언에 unify하면 둘이 한 번에 고쳐집니다. 파서가 'a를 자기 자신이나 미결속 변수하고만 단일화하는 강체 파라미터로 만드는데, 그게 바로 skolem이기 때문입니다.

왕복 검사가 찾은 다섯 번째는 결함이 아닙니다. builtin을 가리는 이름에 선언을 붙이면 그 가림이 선언 위치까지 올라오므로, 정의보다 위에 쓴 호출이 builtin을 보지 못하게 됩니다. 이는 기능이 제대로 작동하는 것이고(코퍼스에는 바로 그 순서를 붙들기 위한 프로그램이 있습니다), 그러나 생성된 파일을 붙여 넣는 사람이 기대하는 바는 아닙니다. 그런 줄들은 이제 이유와 함께 주석 처리해서 출력합니다.

168개 중 167개가 왕복합니다. 통과하지 못하는 하나는 module 안에 선언한 record 타입을 가리키는데, 그것은 선언이 있든 없든 module 바깥에서는 애초에 주석으로 이름 지을 수 없습니다. 이름을 명시해 예외 처리했고, 통과하기 시작하면 게이트가 실패하도록 해뒀습니다. 예외가 그 이유보다 오래 살지 않도록.

9. 그리고 쓰지 않기로 했다

let fn이 있으면 남은 29,606줄은 쪼갤 수 있습니다. 쪼개기 전에 그 대가를 측정했습니다.

멤버 N 뒤에서 자르기 파일 A 파일 B 필요한 선언
50 3,579 26,027 91
200 13,404 16,202 147
250 16,289 13,317 156
300 21,143 8,463 126
450 25,588 4,018 53

균형 잡힌 절단은 손으로 유지하는 타입 시그니처 150개를 요구하고, 얻는 것은 15,000줄짜리 파일 두 개입니다. 15,000줄은 30,000줄보다 더 읽기 쉬운 파일이 아니고, 150개의 시그니처는 선언과 정의가 어긋날 수 있는 자리가 150곳이라는 뜻입니다.

그래서 하지 않았습니다. 핵심은 하나로 남습니다. 기능은 있고, 정확하고, 문서와 게이트도 있습니다 — 그리고 **여기서의 올바른 사용법은 “쓰지 않기”**였습니다.

이건 진짜 결론이라 분명히 적어둡니다. 이 이야기의 솔깃한 버전은 파일이 반으로 갈라지며 끝나니까요.

무엇을 치르고 무엇을 얻었나

아래는 전부 바뀌지 않았습니다. 그게 요점입니다. ruby/spec 1,094 일치 · 125 차이 · 0 크래시, 코퍼스 207/207, CLI 테스트 17개. 전후 동일함은 새 컴파일러로 빌드한 처리계와 옛 컴파일러로 빌드한 처리계를 모든 코퍼스 프로그램에 대해 맞대어 확인했습니다 — 차이 0.

main.mere 54,063줄 30,392
파일 수 1 8
합계 54,063 53,960
죽은 최상위 함수 30 0, 이후 게이트
같은 사슬의 중복 이름 6 0, 이후 게이트

사라진 103줄이 죽은 코드입니다. 분할 자체는 줄을 옮길 뿐 줄이지 않습니다. 정직한 요약은 가장 먼저 여는 파일이 54,063줄에서 30,392줄이 되었다는 것, 그리고 나머지 23,568줄이 “무엇을 하는지”로 이름 붙은 일곱 파일에 있다는 것입니다.

같은 하루를 시작하는 사람에게 하고 싶은 말이 셋 있습니다.

현명한지 묻기 전에 가능한지 물어라. 저는 첫 한 시간을, 언어가 수행할 수 없는 리팩터링을 두고 가독성과 churn을 저울질하며 보냈습니다. 정작 중요했던 측정은 10분과 세 줄짜리 프로그램 다섯 개로 끝났습니다.

리팩터링의 오라클은 테스트가 아니라 산출물이다. “전후로 emit해서 번호를 빼고 diff”는 제가 가진 어떤 테스트도 다루지 않는 것을 잡았습니다. 그리고 실제로 차이가 났을 때, 컴파일러보다 제 정규화가 먼저 틀려 있었습니다.

생성된 기술(description)의 정직한 검사는 하나뿐 — 입력으로 되돌리는 것. 기능을 쓴 본인이 쓴 단위 테스트 7개는 그 본인이 상상한 세 줄짜리 프로그램만 봅니다. --decls의 출력이 원래 파일에 붙여 넣어질 수 있어야 한다는 요구를, 전혀 다른 이유로 쓰인 프로그램들에 대해 걸었더니 한 번의 실행으로 결함 넷이 나왔습니다.

← Back to Notes