메타프로그래밍
메타프로그래밍 (Preprocessors and PPXs)
전처리기(preprocessor)는 컴파일 시점에 호출되어, 실제 컴파일이 일어나기 전에 프로그램을 바꿔 주는 프로그램이에요. 파일 포함, 조건부 컴파일, 보일러플레이트 생성, 언어 확장 등 여러 가지에 아주 유용하죠.
본문
예시부터 시작할게요. 다음 소스 코드는 이 전처리기에 의해 이렇게 바뀌어요.
Printf.printf "This program has been compiled by user: %s" [%get_env "USER"]
전처리기는 컴파일 시점에 [%get_env "USER"]를 USER 환경 변수의 내용으로 바꿔 줘요. 이 환경 변수는 런타임에는 아무 효과가 없죠. 예를 들어 USER가 "JohnDoe"로 설정되어 있으면 그 줄은 이렇게 됩니다.
Printf.printf "This program has been compiled by user: %s" "JohnDoe"
이 가이드에서는 OCaml의 전처리기 뒤에 있는 서로 다른 메커니즘들을, 가능한 한 적은 전제 조건으로 설명할게요. 프로젝트에서 PPX를 사용하는 방법에만 관심이 있다면 이 섹션이나 dune 문서로 바로 가면 돼요. PPX를 작성하는 데 관심이 있다면 이 섹션으로 가면 돼요.
OCaml에서의 전처리
어떤 언어들은 전처리를 내장으로 지원해요. 언어의 작은 일부가 컴파일 시점에 실행되도록 전용되어 있는 식이죠. C가 그런 경우예요. C 전처리기 문법과 의미론이 언어의 일부죠. 매크로 시스템을 가진 Rust도 그렇고요.
OCaml에는 언어의 일부인 매크로 시스템이 없어요. 모든 전처리기는 독립 실행형 프로그램이에요. 하지만 언어의 일부는 아니더라도, OCaml 플랫폼은 그런 전처리기를 작성하기 위한 라이브러리를 공식적으로 지원해요.
OCaml은 두 가지 종류의 전처리기 실행을 지원해요. 하나는 (C에서처럼) 소스 수준에서 동작하고, 다른 하나는 (Rust의 매크로처럼) 프로그램의 더 구조화된 표현, 즉 AST 수준에서 동작해요. 후자는 "PPX"라고 불러요. Pre-Processor eXtension(전처리 확장)의 약자죠.
두 종류의 전처리 모두 각자의 사용 사례가 있지만, OCaml에서는 여러 이유로 가능하면 PPX를 쓰는 걸 권장해요.
- Merlin과 Dune와 아주 잘 통합되고, 편집기의 오류 보고나 Merlin의 "정의로 이동" 같은 기능을 방해하지 않아요.
- 적절히 작성되면 빠르고 잘 합성돼요(compose).
- OCaml에 특히 잘 맞아요.
이 가이드는 OCaml의 두 종류 전처리기의 현황을 보여주되, PPX에 중점을 둘게요. PPX가 전처리기 작성의 권장 방식이긴 하지만, 왜 PPX가 OCaml에서 필요한지 더 잘 이해하기 위해 소스 전처리부터 시작할게요.
소스 전처리기
서론에서 언급했듯이, 소스 파일 전처리는 파일 포함, 조건부 컴파일, 매크로 확장처럼 문자열 조작으로 해결할 수 있는 일에 유용해요. C 전처리기나 m4 같은 일반 전처리기 등 어떤 전처리기든 쓸 수 있어요. 다만 cppo 같은 어떤 전처리기들은 OCaml과 잘 통합되도록 특별히 만들어졌어요.
OCaml에서 텍스트 파일 전처리는 언어의 특별한 지원이 없어요. 대신 전처리를 조종하는 건 빌드 시스템의 역할이에요. 그래서 전처리기를 적용하는 일은 결국 Dune에게 그걸 알려 주는 것으로 귀결돼요. 교육 목적으로만 다음 섹션에서 OCaml 컴파일러로 파일을 전처리하는 방법을 보여줄게요. 더 관련 있는 부분은 Dune으로 전처리하기예요.
ocamlc와 ocamlopt로 전처리하기
OCaml의 컴파일러 ocamlc와 ocamlopt는 컴파일 단계에서 파일을 전처리하는 -pp 옵션을 제공해요. (하지만 Dune으로 전처리를 조종하길 권장한다는 걸 기억하세요.) 문자열 "World"를 문자열 "Universe"로 바꾸는, 셸 스크립트 형태의 간단한 전처리기를 생각해 볼게요.
$ cat preprocessor.sh
#!/bin/sh
sed 's/World/Universe/g' $1
고전적인 "Hello, World!" 프로그램을 이 옵션으로 컴파일하면 컴파일이 바뀌어요.
$ cat hello.ml
print_endline "Hello, World!";;
$ ocamlopt -pp ./preprocessor.sh hello.ml
$ ./a.out
Hello, Universe!
Dune으로 전처리하기
Dune의 빌드 시스템에는 파일에 전처리를 적용하는 특별한 스탠자가 있어요. 전체 문서는 Dune 전처리 가이드에서 찾을 수 있고, 참고 자료로 삼아야 해요. 이 섹션에서는 예제 몇 개만 보여줄게요.
소스 파일에 전처리를 적용하는 스탠자는 (preprocess (action (<action>)))예요. <action> 부분은 (system <program>)으로 지정되는 외부 프로그램 호출처럼, 어떤 사용자 정의 액션이든 될 수 있어요.
이걸 다 모으면, 다음 dune 파일은 앞서 작성한 preprocessor.sh로 해당 모듈 파일들을 다시 써 줄 거예요.
(executable
(name hello)
(preprocess
(action
(run ./preprocessor.sh %{input-file}))))
텍스트 파일 조작의 한계
프로그래밍 언어 문법의 복잡함 때문에, 문법과 결부된 방식으로 텍스트를 조작하는 건 아주 어려워요. 앞선 예제와 비슷하게 "World"의 모든 출현을 "Universe"로 다시 쓰고 싶은데, 프로그램의 OCaml 문자열 안에서만 바꾸고 싶다고 해 볼게요. 이건 꽤 까다롭고 OCaml 문법에 대한 좋은 지식이 필요해요. 문자열에는 여러 구분자({| ...|} 같은)가 있고 줄 바꿈이나 주석이 방해할 수도 있으니까요...
다른 예제를 생각해 볼게요. 타입을 정의했고, 컴파일 시점에 이 특정 타입에서 json 형식의 인코딩(예: Yojson)으로의 직렬화기를 생성하고 싶다고 해 볼게요. (컴파일 시점에 생성해야 하는 이유는 PPX가 OCaml에서 특히 유용한 이유를 참고하세요.) 이 직렬화 코드는 전처리기로 작성할 수 있는데, 전처리기는 파일에서 타입을 찾아 타입 구조에 따라, 즉 변형 타입인지 레코드 타입인지, 하위 타입의 구조는 어떤지 등에 따라 다르게 직렬화할 거예요.
이 모든 어려움은 프로그램을 생성하고 싶은데, 그것을 평문 텍스트라는 평평한 표현으로 조작하고 있다는 사실에서 옵니다. 이 표현의 구조 부족은 여러 단점이 있어요.
- 위 예제의 직렬화기를 생성할 타입 같은 프로그램의 일부를 읽기 어려워요.
- 평문 텍스트로 프로그램을 쓰는 건 오류가 나기 쉬워요. 생성된 코드가 항상 프로그래밍 언어의 문법을 존중한다는 보장이 없거든요. 코드 생성의 그런 오류는 디버깅하기 어려울 수 있어요!
프로그램의 훨씬 더 구조화된 표현으로 작업하면 읽기와 쓰기 문제 둘 다 해결돼요. 이것이 정확히 PPX가 하는 일이에요!
PPX
PPX는 다른 종류의 전처리기예요. 텍스트 소스 코드가 아니라 파싱 결과, 즉 추상 구문 트리(Abstract Syntax Tree, AST)에서 동작하죠. OCaml 컴파일러에서는 이걸 Parsetree라고 불러요. PPX를 잘 이해하려면 이 Parsetree가 무엇인지 이해해야 해요.
OCaml의 Parsetree: OCaml AST
컴파일 단계에서 OCaml 컴파일러는 입력 파일을 내부 표현으로 파싱하는데, 그걸 Parsetree라고 불러요. 프로그램은 트리로 표현되며, 복잡한 OCaml 타입을 가지는데 그 타입은 Parsetree 모듈에서 찾을 수 있어요.
이 트리의 성질 몇 가지를 살펴볼게요.
- AST의 각 노드는 "let 정의", "표현식", "패턴" 등 서로 다른 역할에 해당하는 타입을 가져요.
- 트리의 루트는
structure_item들의 리스트예요. structure_item은 톱레벨 표현식, 타입 정의, let 정의 등을 나타낼 수 있어요. 이건 변형(variant) 타입으로 결정돼요.
Parsetree 타입을 파악하는 보완적인 방법이 여러 개 있어요. 하나는 API 문서를 읽는 것이고, 각 타입과 값이 무엇을 나타내는지 예시를 담고 있어요. 다른 하나는 조작한 OCaml 코드의 Parsetree 값을 검사하는 거예요. 이건 외부 도구 astexplorer를 쓰거나, 우리의 OCaml VSCode 확장(명령 팔레트에서 OCaml: Open AST explorer를 열어서)을 쓰거나, 심지어 -dparsetree 옵션(UTop에서도 가능해요)으로 OCaml 톱레벨에서 직접 할 수 있어요.
$ ocaml -dparsetree
[Omitted output]
# let x = 1 + 2 ;;
Ptop_def
[
structure_item (//toplevel//[1,0+0]..[1,0+13])
Pstr_value Nonrec
[
<def>
pattern (//toplevel//[1,0+4]..[1,0+5])
Ppat_var "x" (//toplevel//[1,0+4]..[1,0+5])
expression (//toplevel//[1,0+8]..[1,0+13])
Pexp_apply
expression (//toplevel//[1,0+10]..[1,0+11])
Pexp_ident "+" (//toplevel//[1,0+10]..[1,0+11])
[
<arg>
Nolabel
expression (//toplevel//[1,0+8]..[1,0+9])
Pexp_constant PConst_int (1,None)
<arg>
Nolabel
expression (//toplevel//[1,0+12]..[1,0+13])
Pexp_constant PConst_int (2,None)
]
]
]
val x : int = 3
Parsetree는 프로그램에 타입을 붙이기 전에 일어나는 코드의 내부 표현이므로, 잘못 타입된 프로그램도 다시 쓸 수 있다는 점을 기억하세요. 타입을 붙인 뒤의 내부 표현은 Typedtree라고 불러요. ocaml의 -dtypedtree 옵션으로 검사할 수 있죠. utop-full을 쓸 때는 REPL 안에서 Clflags.dump_typedtree := true를 실행해서 -dtypedtree를 켤 수 있어요. 이어지는 내용에서는 AST라고 하면 parsetree를 가리키는 것으로 할게요.
PPX 리라이터
핵심적으로 PPX 리라이터는 Parsetree를 받아서 (아마도) 수정된 Parsetree를 돌려주는 변환일 뿐인데, 미묘한 점이 있어요. 첫째, PPX는 OCaml 파싱 결과인 Parsetree에서 동작하므로, 소스 파일은 유효한 OCaml 문법이어야 해요. 그래서 C 전처리기의 #if 같은 사용자 정의 문법을 도입할 수 없어요. 대신 OCaml 4.02에서 도입된 두 가지 특별한 문법, 즉 확장 노드(extension node)와 속성(attribute)을 쓸 거예요.
둘째, 대부분의 PPX 코드 변환은 전체 AST를 받을 필요가 없어요. AST의 하위 부분에서 지역적으로 동작할 수 있죠. 일반적인 PPX 리라이터에 대한 이런 지역적 제한에는 두 종류가 있는데, 대부분의 사용 사례를 커버해요: 익스텐더(extender)와 디라이버(deriver)예요. 이 둘은 각각 방금 언급한 OCaml 4.02의 두 새 문법, 즉 확장 노드와 속성에 대응해요.
속성과 디라이버
속성(attribute)은 어떤 AST 노드에든 붙일 수 있는 추가 정보예요. 그 정보는 컴파일러 자체가 쓰거나(예: 경고 켜기/끄기), "deprecated" 경고를 추가하거나, 함수가 인라인되도록 강제/확인할 수 있어요. 컴파일러가 쓰는 속성의 전체 목록은 매뉴얼에 있어요.
속성의 문법은 노드에 [@attribute_name payload]를 접미사로 붙이는 것이고, 여기서 payload는 그 자체가 OCaml AST예요. @의 개수는 속성이 어느 노드에 붙는지 결정해요. @는 가장 가까운 노드(표현식, 패턴 등)에, @@는 가장 가까운 블록(타입 선언, 클래스 필드 등)에, @@@는 떠다니는 속성(floating attribute)이에요. 문법에 대한 더 많은 정보는 매뉴얼에서 찾을 수 있어요.
module X = struct
[@@@warning "+9"] (* locally enable warning 9 in this structure *)
…
end
[@@deprecated "Please use module 'Y' instead."]
앞선 예제는 컴파일러용으로 예약된 속성을 쓰지만, 어떤 속성 이름이든 소스 코드에 넣고 PPX가 그 전처리에 쓸 수 있어요.
type int_pair = (int * int) [@@deriving yojson]
속성과 연결된 특정한 종류의 PPX가 있는데, 바로 디라이버(deriver)예요. 타입 정의 같은 구조나 시그니처 항목에서 어떤 코드를 생성(또는 파생 derive)하는 PPX죠. 위 문법으로 적용되며, 디라이버 여러 개는 쉼표로 구분해요. 생성된 코드는 입력 코드 뒤에 추가되고, 입력 코드는 수정되지 않은 채 남는다는 점에 주목하세요.
디라이버는 정의된 타입의 구조에 의존하는 함수를 생성하는 데 아주 좋아요. (이건 텍스트 파일 조작의 한계에서 든 예시였죠.) 실제로 정확히 알맞은 양의 정보가 PPX에 전달되고, PPX가 소스의 어떤 부분도 수정하지 않는다는 것도 알 수 있으니까요.
디라이버의 예는 다음과 같아요.
ppx_show는 타입에서 그 타입 값의 예쁜 출력기(pretty printer)를 생성해요.- 타입에서 다른 형식으로의 직렬화기를 파생하는 디라이버들. 예: JSON —
ppx_yojson_conv, YAML —ppx_deriving_yaml, SEXP —ppx_sexp_conv. ppx_accessor는 주어진 레코드 타입의 필드에 대한 접근자를 생성해요.
확장 노드와 익스텐더
확장 노드(extension node)는 Parsetree의 "구멍"이에요. 파서가 패턴, 표현식, 코어 타입, 모듈 타입 등 많은 곳에서 받아들여요. 어떤 장소가 확장 노드를 허용하는지 알아내려면, Parsetree에서 해당 노드가 extension 생성자를 가지는지 보면 돼요. 하지만 확장 노드는 나중에 컴파일러가 거부해요. 그래서 컴파일을 진행하려면 반드시 PPX가 다시 써야 해요.
확장 노드의 문법은 [%extension_name payload]예요. 여기서도 %의 개수는 확장 노드의 종류를 결정해요. %는 "내부 노드"(표현식, 패턴 같은), %%는 "톱레벨 노드"(구조/시그니처 항목, 클래스 필드 같은)용이에요. payload는 구조 노드예요. 즉 파서가 확장 노드의 payload로 .ml 파일에 대해 받아들이는 것과 같은 것을 받아들여요. 형식 문법을 보세요.
(* An extension node as an expression *)
let v = [%html "<a href='ocaml.org'>OCaml!</a>"]
(* An extension node as a let-binding *)
[%%html let v = "<a href='ocaml.org'>OCaml!</a>"]
확장 노드와 payload가 같은 타입일 때는 더 짧은 중위(infix) 문법을 쓸 수 있어요. 이 문법은 확장 노드의 이름을 블록을 정의하는 키워드(let, begin, module, val 등)에 붙일 것을 요구하며, 블록 전체가 payload에 감싸인 것과 동등해요. 이 문법의 형식 정의는 OCaml 매뉴얼에서 찾을 수 있어요.
(* An equivalent syntax for [%%html let v = ...] *)
let%html v = "<a href='ocaml.org'>OCaml!</a>"
payload의 기대 타입을 바꾸는 방법도 있어요. 확장 이름 뒤에 :를 추가하면 기대 payload가 시그니처 노드가 돼요. (즉 .mli 파일에서 받아들이는 것과 같아요.) 마찬가지로 ?는 기대 payload를 패턴 노드로 바꿔요.
(* Two equivalent syntaxes, with signatures as payload *)
[%ext_name: val foo : unit]
val%ext_name foo : unit
(* An extension node with a pattern as payload *)
let [%ext_name? a :: _ ] = ()
확장 노드는 PPX가 다시 쓰도록 되어 있고, 이런 점에서 특정한 종류의 PPX, 즉 익스텐더(extender)에 대응해요. 익스텐더는 이름이 맞는 모든 확장 노드의 출현을, payload에만 의존하는 생성 코드로(확장 노드의 문맥 정보 없이, 다시 말해 나머지 코드에 대한 접근 없이) 그리고 다른 것은 아무것도 수정하지 않고 대체하는 PPX 리라이터예요.
익스텐더의 예는 다음과 같아요.
- 다른 언어를 나타내는 OCaml 값을 그 언어로 직접 작성하게 해 주는 익스텐더. 예:
- JSON 코드를 작성해
Yojson값을 생성하는ppx_yojson - HTML 코드를 작성해
Tyxml.Html값을 생성하는tyxml-ppx - MySQL 쿼리를 작성해
ocaml-mysql쿼리를 생성하는ppx_mysql
- JSON 코드를 작성해
- 확장 노드의 payload에서 CRAM 테스트를 생성하는
ppx_expect.
PPX 사용하기
텍스트 소스 코드용 전처리기와 유사하게, OCaml 컴파일러는 컴파일 단계에서 PPX를 실행하는 -ppx 옵션을 제공해요. PPX는 파일에서 Parsetree를 읽는데, 여기 marshalled 값이 덤프되어 있고, 다시 쓴 Parsetree를 같은 방식으로 출력해요.
하지만 컴파일을 조종하는 도구는 Dune이므로, PPX 사용은 dune 파일을 쓰는 문제일 뿐이에요. 앞선 것과 같은 preprocess 스탠자를 쓰되, 이번엔 pps를 써요.
(preprocess (pps ppx1 ppx2))
그리고 그게 전부예요! 대부분의 PPX에서는 이 지침이 동작하지만, 일부 PPX는 Dune과 통합하려면 특별한 주의가 필요할 수 있으니 첫 번째 정보 출처는 패키지 문서라는 점을 기억하세요.
[@@deriving_inline]로 PPX 의존성 없애기
어떤 디라이버는 보일러플레이트 생성에만 필요해요. 그런 경우에는 하드 의존성으로 포함해야 한다는 강한 요구가 없어요. 추가된 보일러플레이트 코드는 PPX가 예쁜 출력을 해서 소스 코드에 추가할 수 있으니까요. 이 메커니즘은 Dune과 ppxlib으로 구현할 수 있어요.
항목에 [@@deriving_inline <deriver_name>]을 붙이면, 일반적인 [@@deriving <deriver_name>] 속성처럼 항목에서 어떤 코드를 파생해요. 다만 생성된 코드를 속성 항목 뒤에 추가하는 대신, 생성된 코드가 속성 항목 뒤에 이미 있는지 확인 해요. 있으면 아무것도 할 필요가 없어요. 없으면 올바른 파일을 생성하고, Dune은 이 올바른 파일로 소스 코드를 갱신할 기회를 제공해요. (dune promote 명령을 써요.)
새 파일이 생성된 코드를 담고 있으므로, 더는 PPX가 전처리할 필요가 없어요. 그대로 컴파일·배포할 수 있고, PPX를 의존성에서 뺄 수 있죠. 하지만 속성이 붙은 항목이 바뀔 때마다 PPX를 여전히 실행해야 해요. 이건 @lint 대상에서 PPX를 실행하면 돼요. 다음 파일들로 예를 볼게요.
$ cat dune
(library
(name library_name)
(lint (pps ppx_yojson_conv)))
$ cat lib.ml
type t = int [@@deriving_inline yojson]
[@@@deriving.end]
이제 PPX를 실행하고 생성된 코드를 원본 파일에 반영(promote)해요.
$ opam exec -- dune build @lint
File "lib/lib.ml", line 1, characters 0-0:
diff --git a/_build/default/lib/lib.ml b/_build/default/lib/lib.ml.lint-corrected
index 4999e06..5516d41 100644
--- a/_build/default/lib/lib.ml
+++ b/_build/default/lib/lib.ml.lint-corrected
@@ -1,3 +1,8 @@
type t = int [@@deriving_inline yojson]
+let _ = fun (_ : t) -> ()
+let t_of_yojson = (int_of_yojson : Ppx_yojson_conv_lib.Yojson.Safe.t -> t)
+let _ = t_of_yojson
+let yojson_of_t = (yojson_of_int : t -> Ppx_yojson_conv_lib.Yojson.Safe.t)
+let _ = yojson_of_t
[@@@deriving.end]
Promoting _build/default/lib/lib.ml.lint-corrected to lib/lib.ml.
이제 파일은 생성된 값을 담고 있어요. 아직 개발 의존성인 동안에는, 프로젝트를 컴파일하는 데 PPX 의존성을 뺄 수 있어요.
$ cat lib.ml
type t = int [@@deriving_inline yojson]
let _ = fun (_ : t) -> ()
let t_of_yojson = (int_of_yojson : Ppx_yojson_conv_lib.Yojson.Safe.t -> t)
let _ = t_of_yojson
let yojson_of_t = (yojson_of_int : t -> Ppx_yojson_conv_lib.Yojson.Safe.t)
let _ = yojson_of_t
[@@@deriving.end]
이건 프로젝트의 의존성을 ppxlib와 사용된 PPX 모두에서 빼도록 해 주지만, deriving_inline을 쓰면 단점도 오는 점을 기억하세요. 코드베이스의 크기(와 가독성)를 늘릴 수 있고, AST에서 소스 코드로 가는 출력기에 의존하는데, 이건 신뢰할 수 없을 수 있어요. 어쨌든 인라인이 실패하면 ppxlib가 생성된 소스 코드를 파싱해 생성된 AST와 대응하는지 확인하는 왕복(roundtrip) 검사로 감지해요. 이렇게 해서 오류가 컴파일 시점에 잡혀요.
PPX가 OCaml에서 특히 유용한 이유
이제 PPX가 무엇인지 알고 예시도 봤으니, OCaml에서 특히 유용한 이유를 볼게요.
우선, 타입은 실행 시점에 사라져요. 그 말은 타입의 구조를 실행 시점에 분해해서 흐름을 제어할 수 없다는 뜻이에요. 그래서 컴파일된 바이너리에는 일반적인 to_string : 'a -> string이나 print : 'a -> () 함수가 존재할 수 없어요. (톱레벨에서는 타입이 유지되죠.)
그래서 타입의 구조에 의존하는 어떤 일반 함수든, 타입이 아직 존재하는 컴파일 시점에 작성되어 야만 해요.
둘째, OCaml의 강력한 특징 중 하나는 타입 시스템인데, 많은 것의 건전성을 확인하는 데 쓸 수 있어요. Tyxml 라이브러리가 그 예시인데, 타입 시스템을 써서 결과 HTML 값이 대부분의 W3C 표준을 만족하는지 보장해요. 하지만 HTML 문법을 쓰는 것에 비하면 Tyxml 값을 쓰는 건 번거로울 수 있어요. HTML 코드를 컴파일 시점에 OCaml 값으로 변환하면, 사용자는 생성된 값의 타입 검사기와 HTML 작성의 익숙함을 모두 유지할 수 있어요.
ppx_expect 같은 다른 리라이터들은, PPX 리라이터로 문법을 풍부하게 만들 수 있다는 게 OCaml의 특수성 밖에서도 아주 유용하다는 것을 보여줘요.
PPX 생태계 제어의 필요성: ppxlib
PPX는 컴파일 시점에 코드를 생성하는 데 아주 좋지만, 여러 PPX 리라이터가 있을 때 특히 몇 가지 질문이 생겨요.
- 여러 PPX의 합성(composition)의 의미론은 무엇인가요? 순서가 중요할 수 있어요.
- 서드파티 PPX를 쓸 때 내 코드의 어떤 부분이 수정되지 않는다고 어떻게 신뢰할 수 있나요?
- 리라이터를 많이 쓸 때 어떻게 컴파일 시간을 짧게 유지할 수 있나요?
- AST 파싱이나 demarshalling을 다뤄야 한다면 PPX를 어떻게 쉽게 작성할 수 있나요?
- Parsetree처럼 길고 복잡한 타입을 어떻게 다룰 수 있나요?
- 새 OCaml 버전이 언어에 새 기능을 더하면서 Parsetree 타입을 풍부하게 하고 깨뜨리기 쉬운 문제를 어떻게 해결할 수 있나요?
이 질문 중 상당수는 OCaml에 매크로 언어가 없고 전처리기가 항상 독립 실행형 프로그램이라는 사실에서 온 것이에요. 그 말은 그들이 아무것이나 할 수 있다는 뜻이고(매크로는 보통 전처리의 표현력을 제한하지만), 컴파일러는 그들을 통제하지 못해요. 하지만 OCaml 플랫폼은 PPX 작성용 라이브러리를 포함하는데, PPX의 완전한 일반성을 잃지 않으면서 어떻게든 매크로 언어처럼 동작해요. 바로 ppxlib예요. 이 라이브러리는 익스텐더와 디라이버를 작성하는 일반적인 방법을 제공해서, 그것들이 잘 함께 동작하도록 보장하고, 여러 임의 변환에서 생기는 합성 문제를 제거해요. ppxlib는 또한 여러 변환이 등록된 경우에도 하나의 단일 바이너리를 출력하는 드라이버를 제공해요.
ppxlib를 쓰면 PPX 작성자는 자신의 부분, 즉 리라이팅 로직에 집중할 수 있어요. 그런 다음 자신의 변환을 등록하면, ppxlib가 모든 PPX가 해야 하는 나머지 작업, 즉 Parsetree 얻기, 컴파일러에 돌려주기, 좋은 CLI로 실행 파일 만들기를 책임져요.
OCaml의 역사에 관심이 있다면, ppxlib 이전에는 PPX를 다루는 다른 "공식" 라이브러리들이 있었다는 점을 기억하세요. Camlp4는 추가 구조로 OCaml 파서를 확장하고, 다시 쓰고, 일반 OCaml 문법으로 예쁜 출력하는 방법이었어요. OMP는 PPX를 OCaml 버전들 사이에서 호환되게 만드는 도구였고, 이제 ppxlib에 포함돼요.
여러 OCaml 버전을 위한 하나의 PPX
PPX를 작성할 때의 한 가지 미묘한 점은 언어에 새 기능이 추가되면 Parsetree 모듈의 타입이 바뀔 수 있다는 거예요. PPX를 새 버전과 호환되게 유지하려면 변환을 옛 타입에서 새 타입으로 갱신해야 해요. 하지만 그러면 옛 OCaml 버전과의 호환성을 잃게 되죠. 이상적으로는 PPX 한 버전이 서로 다른 OCaml 버전을 전처리할 수 있으면 좋아요.
ppxlib는 Parsetree 타입을 최신 버전으로, 그리고 그 반대로 변환해서 이 문제를 다뤄요. 그러면 PPX 작성자는 OCaml 최신 버전을 위한 변환만 유지하고, 어떤 OCaml 버전에서든 동작하는 PPX를 얻을 수 있어요. 예를 들어 OCaml 5.0용으로 작성된 PPX를 OCaml 4.08로 컴파일할 때 적용하면, ppxlib는 4.08 Parsetree를 얻어서 5.00 Parsetree로 변환하고, 등록된 PPX로 Parsetree를 변환하고, 다시 4.08 Parsetree로 변환해서 컴파일을 계속해요.
합성, 속도, 보안을 위한 PPX 제한
ppxlib는 익스텐더와 디라이버에 대응하는 제한된 변환 등록을 명시적으로 지원해요. 이런 제한된 PPX를 작성하는 데는 장점이 많아요.
- 익스텐더와 디라이버는 확장 노드 외에는 기존 코드를 수정하지 않아요. 오류가 나기 어렵고, 버그의 치명적 영향이 적으며, 사용자는 자신의 코드의 중요한 부분이 바뀌지 않는다고 확신할 수 있어요.
- 익스텐더와 디라이버는 "문맥 자유(context-free)"인데, AST의 제한된 부분만 입력으로 실행된다는 뜻이에요. 그래서 AST를 한 번만 훑어 모든 것을 실행할 수 있어요. 게다가 "하나씩 차례로" 실행되는 게 아니라 모두 동시에 실행되므로, 그 합성 의미론이 실행 순서에 의존하지 않아요.
- 이 한 번 훑기는 더 빠른 재작성을 뜻하고, 따라서 여러 PPX를 쓰는 프로젝트의 컴파일 시간도 더 짧아져요.
이에 비해, AST 전체에서 동작하는 리라이터도 ppxlib에 등록할 수 있고, 문맥 자유 패스 이후 이름의 알파벳 순서로 실행돼요.
Dune은 서로 다른 패키지에서 온 PPX들이라도 ppxlib로 작성된 모든 PPX를 단일 전처리기 바이너리로 결합한다는 점을 기억하세요.
PPX 작성하기
나만의 PPX를 쓰고 싶다면, 시작점은 ppxlib의 문서예요.