FFI: 불러내는 것과, 불려 들어오는 것

다른 언어와 말하는 것은 두 다른 문제다. C 함수를 불러내는 것은 한 줄 —— `extern fn time: unit -> int;` —— 이고, 그것은 언어의 보장이 끝나는 정직한 경계다. 브라우저의 JavaScript에서 불려 들어오는 것은, 더 어렵다: host는 Mere가 표현할 수 없는 객체를 쥐고, Mere의 클로저를 기동할 수 있어야 한다. 두 방향이, 같은 하나의 트릭으로 귀착하게 된다 —— 상대편이 소유하는 테이블로의 인덱스를 교환하는 것.

mereffiwebassemblyinteroplanguage-design

네 backend는 어느 Mere 프로그램도 돌릴 수 있고, 동일한 출력에 유지된다. 그 어느 것도 아직 할 수 없는 것이, 다른 언어로 쓰인 코드와 말하는 것 —— C 라이브러리 함수를 부르는 것, 혹은 브라우저가 Mere의 클로저에 이벤트를 건네는 것 —— 이다. 그것이 외부 함수 인터페이스의 용도이고, 그것은 실은 반대를 향한 두 별개의 문제다: 외부 코드로 불러내는 것과, 그것에 불려 들어오는 것. Mere는 저마다를 풀고, 두 해는, 주목할 만한 방식으로 운을 맞춘다.

불러내기: extern fn

이 시점까지, Mere가 바깥 세계에 할 수 있는 것은 모두 builtin이었다 —— 아흔쯤, 그 저마다가 네 backend 모두에 걸쳐 컴파일러에 하드코딩되어 있었다. 하나를 더하는 것은, 컴파일러를 편집하고 새 릴리스를 내는 것을 뜻했다. 닫힌 언어에는 괜찮지만, 실용적인 언어에는 성립하지 않는다: time()이나 getenv()sqrt()를 원하는 모두에게, 컴파일러에 패치를 대라고 부탁할 수는 없다.

수정은, 평범한 사용자 코드에서 외부 함수를 선언하는 길이다:

extern fn time:   unit -> int;
extern fn getenv: str -> str;

let now = time () in ...

한 줄이 함수와 그 타입을 이름 붙이고, 그것이 불릴 수 있게 된다. 골라진 구문은 소박한 것 —— Rust 풍의 extern "C" 블록도, 이펙트 표기된 형태도 아니다 —— 이제 익숙한 원칙, 최소의 정직한 판을 내고, 의식은 실제 사용이 요구하면 더한다, 에 서서. 각 backend는 이미 호출을 행하는 기구를 가지고 있었다: C는 extern 선언과 직접 호출을 뱉고, Wasm은 지난 회의 바로 그 host-import 기구를 재사용한다 —— Wasm의 extern fn은 그저 이름 붙은 (import "env" ...), print가 이미 통과한 같은 문이, 이제 host가 제공하는 임의의 함수로 열린다.

extern fn에 대해 가장 중요한 것은, 그것이 무엇을 인정하는가다. 언어 전체가, 컴파일러가 검증할 수 있는 것을 최대화하도록 세워졌다 —— 명시적 타입, 명시적 이펙트, 바이트 단위로 동일한 backend. 외부 C 함수는 그 모든 것의 밖에 있다: 컴파일러는, 그것이 무엇을 하는지, 당신이 아직 쥔 포인터를 free하는지, 선언한 타입에 맞는지를, 검사할 수 없다. FFI는 보장의 진짜 구멍이다. 언어의 응답은, 그렇지 않은 척하는 것이 아니라, 그 구멍에 이름을 붙이는 것이다: extern 키워드가, 검증 가능성이 어디서 끝나는지를 꼭 짚고, 읽는 이가 어느 호출이 안전한 세계를 떠나는지를 볼 수 있게 한다. 이것은 프로젝트가 곳곳에서 적용하는 같은 정직함이다 —— feature를 완료라 부르지 않는 커버리지 장부, “충분히 가깝다”를 동일과 통과시키지 않는 거부. 보장이 끝나는 곳에서, 언어는 경계를 흐리는 것이 아니라, 그렇다고 소리 내어 말한다.

불려 들어오기: 브라우저의 문제

불러내기는 쉬운 방향이다. Mere가 제어하고 있기 때문이다 —— 인자를 마셜링하고, 결과를 믿는다. 어려운 방향은 그 역이다: 브라우저의 JavaScript가, 도는 Mere 프로그램으로 불려 들어오고, Mere가 쥐도록 만들어지지 않은 것을 들고 다닌다. Mere는 이미 Wasm으로 컴파일되어 페이지에서 돈다; 프론트엔드에서 쓸모 있으려면, DOM에 닿고 이벤트에 응해야 하며, 그것이 스칼라 extern fn으로는 풀 수 없는 두 문제를 떠오르게 한다.

첫째는, Mere가 표현할 수 없는 객체다. DOM 요소는 JavaScript 객체다; 모든 Mere 값은 4바이트 워드다. <div>i32에 넣을 수 없다. 답은 불투명 핸들이다: 새로운 extern type JsRef, 그것은 와이어 상에서는 i32지만 Mere 안에서는 불투명 —— 쥐고 넘겨 되돌릴 수 있지만, 결코 들여다 볼 수 없다. 트릭은 host 쪽에 산다: JavaScript는 실 객체의 테이블을 쥐고, 그 안으로의 인덱스를 나눠 준다. dom_get_by_id는 요소를 저장하고 그 인덱스를 반환하고, dom_set_text는 인덱스를 받아 요소를 끌어 작용한다. Mere는, Mere에게 무의미하고 host에게 유의미한 수를 들고 다닌다.

둘째는, JavaScript가 Mere 함수를 부를 수 있게 하는 것이다 —— 이벤트 핸들러는, 클릭이 일어났을 때 Mere 코드를 돌려야 한다. 그리고 여기서, 두 회 전에 세워진 무언가가 보답받는다. Wasm backend는 이미 모든 클로저를 함수 테이블로의 인덱스로 표현한다. Wasm에 함수 포인터가 없기 때문이다; 내부에서, 클로저를 부르는 것은 그 테이블 너머의 call_indirect다. Mere로의 콜백은, 그저 그 같은 인덱스를, 경계를 넘어 export한 것이다: Mere는 JavaScript에 클로저의 테이블 인덱스를 건네고, 이벤트가 발화하면, JavaScript는 그것으로 함수 테이블 너머로 불러 되돌린다. Mere 자신의 클로저를 위해서만 존재하던 기구가, 바깥 세계가 Mere를 부르는 메커니즘이 된다.

하나의 트릭, 두 번

둘을 나란히 두면, 그것들은 같은 아이디어다. Mere의 선형 메모리와 JavaScript의 객체 힙은, 포인터를 공유할 수 없는 두 세계다 —— 어느 쪽도 다른 쪽으로의 생 주소를 쥘 수 없다. 그래서 두 방향이, 같은 방식으로 경계를 넘는다: 상대편이 소유하는 테이블로의 인덱스를 교환함으로써. DOM 요소는 host의 핸들 테이블로의 인덱스다. Mere의 콜백은 Mere의 함수 테이블로의 인덱스다. 저마다의 경우, 넘어가는 값은, 받는 쪽에게 무의미한 수이고, 발행한 쪽에게 조회 키다. 메모리를 공유할 수 없는 두 세계도, 정수는 공유할 수 있고, 양 끝의 정수와 테이블이, 무엇이든 넘기는 데 족하다.

그 대칭성이야말로, 프론트엔드 인터페이스가 이미 손에 있는 부품에서 조립된 이유다: JavaScript 쪽에 거울처럼 비친 핸들 테이블, Wasm backend 안에 이미 세워진 함수 테이블, 그리고 그것들을 나르도록 일반화된 extern fn의 import 기구. 구체적인 결과는, 작은 DOM 바인딩과, 브라우저에서 도는 카운터 —— Mere의 클로저가 살아 있는 클릭 핸들러를 맡는다 —— 이고, 프로젝트 전체의 움직임에 따라, 거친 모서리를 숨기는 것이 아니라 이름 붙인 최소 MVP로서 도달되었다(핸들은 페이지의 수명 동안 누수한다; 안전성의 이야기는, 사용이 요구할 때까지 미뤄진다).

언어는 이제 네 방식으로 돌고, 그 양쪽의 코드와 말할 수 있다. 자연스러운 다음 물음은, 그것이 진짜 무게를 짊어질 수 있는가 —— 카운터가 아니라, 언어가 실제로 만들고 싶은 무언가에 쓸 수 있다고 증명할 만큼 실질적인 프로그램 —— 다. 프로젝트의 답은, 자기 자신의 문서 사이트를 Mere로 세우는 것이었다. 다음 회, Part IV의 마무리: 문서 사이트를 언어 자신으로 쓰기, 그리고 그 규모에서의 dogfood가 무엇을 드러냈는가.

← Back to Mere: 언어를 만들다