스코프가 아닌 수명
작은 앱의 구독 누수가 언어에 대한 질문으로 바뀌었고, 답은 '아니오'였다. 출발점에 있는 두 전제 — 같은 처리라면 빠른 쪽이 낫다, GC에 의존하지 않는다 — 는 하나의 구매였고, Mere가 할 수 없는 일들은 그 청구서가 한 장씩 도착하고 있을 뿐이다.
데모 앱에 탭 바가 돋아났다. 하나의 스토어 위에 두 개의 뷰 — 스키마로부터 컨트롤을 조립하는 폼과, 읽기 전용 요약이다. 탭을 바꾸면 이전 뷰의 DOM은 통째로 버려진다. 거기까지가 쉬운 부분이었다. 노드를 버려도 watcher는 버려지지 않았다. 탭을 세 번 왕복하면 watcher가 11개 돌고 있었고, 그중 9개는 이미 document에 없는 노드에 성실하게 그려 넣고 있었다. 알아챌 수 있었던 이유는 탭 바에 카운터를 두었기 때문이고, 그것이 유일한 이유다.
수정은 평범했다. 구독 호출이 토큰을 반환하게 하고, 대응하는 해제를 마련하고, 뷰는 자기 토큰을 모아 닫을 때 돌려준다. 11이 2가 되었고, 화면을 얼마나 오래 돌아다녀도 2로 유지된다.
흥미로운 부분은 이것을 언어가 막아야 했는지 되물었을 때였다. 답은 ’아니오’였고, 이 아니오는 수정 자체보다 값이 있다.
유일한 기구가, 왜 닿지 않는가
Mere가 해제를 강제하는 수단은 정확히 하나 있다. drop 타입을 with로 묶고, 스코프가 끝날 때 close가
실행되는 방식이다. 좋은 기구다. 그리고 구독에 대해서는 모양이 맞지 않는다.
뷰는 어떤 이벤트 안에서 조립되고, 다른 이벤트 안에서 해체된다. with 블록이 핸들을 닫을 시점에는
그 뷰가 아직 표시조차 되지 않았다. 여기서 문제가 되는 수명은 그것을 만든 스코프가 아니다. 이벤트
루프의 서로 다른 두 차례에 걸쳐 있고, 그 사이에는 완전히 사용자에게 달린, 기간이 정해지지 않은
공백이 있다.
조용한 장애물이 하나 더 있다. drop 타입은 region에 놓을 수 없다. 그리고 watcher 목록은 region 위에 있다. 설령 타이밍이 맞았다 해도, 놓을 자리가 맞지 않는다.
그래서 해제는 규약으로 남겨 두고, 모양이 맞지 않는 기구로 덮어 가리는 대신 라이브러리의 README에 적었다. 아니오라는 답을 솔직하게 기록해 두는 일은 언어 프로젝트가 할 수 있는 꽤 유용한 일 중 하나이며, 동시에 가장 생략하기 쉬운 일 중 하나라고 생각하게 되었다.
두 전제는 선불이었다
이것을 ’미구현 기능’으로 분류하기는 쉽지만, 그렇지 않다. 언어의 출발점에서 도출된다.
Mere는 작은 전제들의 집합에서 시작하며, 그중 둘이 대부분의 일을 한다. 같은 것을 계산하는 두 프로그램 이라면 빠른 쪽이 낫고, 컴파일 시점에 해결할 수 있는 것을 실행 시점으로 넘기지 않는다. 그리고 메모리는 수집기에 의존하지 않고 관리하며, 소유와 수명은 정적으로 추적한다.
이것들은 기능이 아니다. 구매다. 산 것은 진짜다. 쓰지 않으면 비용이 0인 추상, 멈춤 시간이 없는 실행, 그리고 같은 언어가 브라우저 탭에서부터 아래에 운영체제가 없는 베어메탈 RISC-V 코어까지 닿을 만큼 작은 값 모델을 얻었다.
지불한 것은 동적성이고, 그것을 전부, 미리 냈다. 나중에 나타나는 제약은 모두 이미 끝난 구매에 대한 청구서다. watcher 건은 그 청구서가 놀랄 만큼 구체적인 형태로 도착한 한 장이었다 — 어떤 스코프에도 담기지 않는 수명이, 강제 기구가 스코프 모양밖에 없는 언어에 나타났다.
더 큰 청구서 — 모든 것이 컴파일 시점에 갖춰져 있어야 한다
두 번째 귀결은 더 크고, 일상적으로는 잘 보이지 않는다.
Mere의 제네릭 디스패치는 타입을 통해 해결된다. 즉 제네릭은 인스턴스화마다 구체화되고, 구체화에는 정의 본문이 필요하다. 이 하나의 사실이 보기보다 훨씬 멀리까지 퍼진다.
먼저 제네릭 경계를 넘는 안정된 인터페이스가 존재하지 않는다. 안정된 인터페이스가 없으면 컴파일러가 보지 않은 코드를 불러올 수 없다. 플러그인도 없고, 나중에 구현을 교체할 수도 없고, 누군가 뒤에 쓴 확장을 받아들이는 호스트 프로그램도 만들 수 없다. 언어가 임베디드 스크립트 층으로 먹고사는 자리가 바로 이 칸인데, Mere는 거기에 앉을 수 없다. 아직이 아니라, 구조적으로.
그리고 크기와 컴파일 시간이 소스의 양이 아니라 인스턴스화의 수에 비례한다. 올해의 측정값 두 개가 이 모양을 구체적으로 만들어 준다. 셀프호스트 컴파일러를 플랫한 RV32IM 기계어로 낮추면 약 380KB가 된다 — 컴파일러 한 개분이지만, 동시에 ’쓰는 제네릭을 전부 그 자리에서 구체화한 컴파일러 한 개분’이기도 하다. 또 하나, Mere로 쓰인 Ruby 서브셋 처리계인 mere-ruby는 한동안 12개의 gem이 SIGSEGV를 냈다. 메모리 버그처럼 보였지만 진짜 원인은 프로그램 크기였다. 128MB 스택으로 링크하기까지 멈추지 않았다. 같은 청구가 두 번, 다른 통화로 도착한 셈이다.
순환은 키워드가 아니라 설계를 요구한다
세 번째 귀결은 가장 가볍다. 적어도 기구가 준비되어 있기 때문이다.
소유권과 region의 조합은 순환하는 것이나 자기 참조하는 것을 ‘그냥 쓰는’ 것이 아니라 ‘배치하는’ 것을
요구한다. 그래프. 트리의 부모 포인터. 이중 연결 리스트. 관측 대상으로 되돌아가는 참조를 가진 observer.
Mere의 답은 region 안쪽에 만드는 묶음 타입이며, immutable이고 이동하지 않으며, unsafe에 손을 뻗지
않고 자기 참조를 표현할 수 있다. 이것은 작동한다. 그리고 매번 그것을 설계할 필요가 있다. 수집기가 있는
언어라면 소박한 것을 타이핑하고 앞으로 나아갈 수 있는 장면에서.
이것을 세 번째에 둔 이유는 벽이 아니라 세금이기 때문이다. 다만 같은 구매에서 나온, 같은 세금이다.
다섯 개의 백엔드는 가벼운 자신을 위한 자리를 남기지 않는다
비싼 전제를 가진 언어는 대개 탈출구를 얻는다. 더 가볍고 동적인 두 번째 구현이 나타나 보장을 내주는 대신 도달 범위를 버는 형태다. 흔한 패턴이니 여기서도 쓸 수 있는지 확인할 값은 있다.
거의 쓸 수 없다. 본류가 이미 흡수해 버렸기 때문이다. Mere에는 백엔드가 다섯 개 있다 — 인터프리터, C 백엔드, LLVM, WebAssembly, 그리고 직접적인 RV32IM 기계어다. 빠른 시작, 작은 타깃, 브라우저, 베어메탈. 그 전부가 형제 프로젝트가 아니라 주 구현 안에 있다. 작은 Mere를 위한 자리는 보이지 않는다. 작은 Mere가 이미 같은 Mere이기 때문이다.
누군가 원한다고 하면, 남아 있는 것은 인터프리터가 아니다. 수집기를 갖고, 인스턴스화마다의 구체화가 아니라 타입 소거를 쓰고, 제네릭 경계를 넘어 분리 컴파일할 수 있는 Mere다. 그 판은 플러그인을 불러올 수 있고, 어떤 스코프에도 담기지 않는 수명의 구독을 해제할 수 있다. 동시에 그것은 언어의 출발점에 있는 전제를 둘 다 버렸다 — 구현이라고 부르는 것이 눈속임이 될 정도로, 다른 언어에 가깝다.
그 지점이 알아 둘 값이 있는 곳이라고 생각한다. 이 세 제약은 백로그가 아니다. 나머지를 그대로 두고 이것들을 없애는 무언가가 나중에 도착하지는 않는다. 설계의 구멍이 아니라, 선택의 모양이 바깥에서 보이고 있을 뿐이기 때문이다.
그래서 아니오라는 답을 확신이 들 때까지 공을 들여 적어 둔 데에 의미가 있었다. 명세란 언어가 약속하는 것만이 아니다. 무엇을 거절하는가, 그리고 얼마나 이르게 그것을 거절했는가도 명세다. 11개의 watcher는 값이 싸다. 같은 아니오를 두 번째, 세 번째로 발견하는 것은 그렇지 않았을 것이다.