PolySubML가 가진 구조적 서브타이핑·존재 타입을 바탕으로, OCaml의 모듈 시스템을 X에서 값/타입 세계와 최대한 통합하는 설계를 설명한다. 레코드 내 타입 별칭 멤버, struct/sig 구문, open/include의 명시적 임포트 대체, 모듈 확장(extends/with), 존재 타입을 통한 추상화와 let mod 구문, 전방형 타입 전파, 그리고 펑터의 생성성/적용성 문제까지 다룬다.
2020년에 나는 ML 계열 언어에서 완전한 타입 추론과 구조적 서브타이핑을 결합하는 법을 보여주는 Cubiml을 공개했고, 올해 초에는 여기에 고계 랭크 타입과 존재 타입 등 여러 기능을 확장한 PolySubML을 뒤이어 공개했다. 다음 언어(아직 이름을 정하지 않아 여기서는 X라고 부르겠다)에서는 PolySubML이 이미 지원하는 모든 것 위에 OCaml의 가장 주목할 만한 기능들을 전부 지원하는 것을 야심찬 목표로 삼았다. 이 글에서는 추가해야 할 OCaml의 가장 큰 기능, 바로 모듈에 대해 이야기하겠다.
OCaml의 모듈은 다른 언어에서 흔히 보는 모듈과 다르다. 기본 아이디어는 데이터와 타입을 묶어 레코드 같은 객체로 전달하고 다루는 것이다. 꽤 독특한 기능이며, 하스켈의 타입클래스처럼 더 단순하고 쓰기 쉬운 시스템과 비교해 그 가치에 대한 논쟁도 많다. 하지만 X의 목표는 OCaml의 주요 기능을 모사하는 것이므로, 그 방향으로 가겠다.
OCaml의 문법은 사실상 두 개의 언어가 한데 섞인 것과 비슷하다. 한쪽에는 타입/값/함수로 이루어진 보통의 언어가 있고, 다른 한쪽에는 완전히 별개의 문법과 개념(모듈 타입, 모듈 값, 펑터)이 있는 모듈 시스템이 있다. 이 둘을 통합하고 싶다는 꿈은 오래전부터 있었고, 1ML 프로젝트가 2015년에 이를 시도한 바 있다.
자연스레, 나도 X에서 둘을 통합하고 싶었고, 이 글에서 그 방법과 난점을 설명하려 한다. 결론적으로는, 대부분 통합할 수 있지만 일부 사소한 부분은 별도 문법이 여전히 필요하다. 그 전에 몇 가지 주의사항부터:
X는 아직 기획 단계에 있으며 현재는 내 머릿속에만 존재한다. 여기서 설명하는 것은 X의 계획된 설계이며, 예기치 못한 문제로 설계를 바꾸게 될 가능성도 있다. 또한 이 글에 대한 피드백을 반영해 변경할 수도 있다.
또한, 나는 OCaml 전문가가 아니다. 모든 동작 방식을 최대한 조사했지만 세부를 일부 잘못 이해했을 가능성도 있다. 그리고 OCaml에 대해 이야기할 때는, OCaml 자체의 관점이나 용어(예: “type abbreviations” 대신 “type aliases”)와 다를지라도 X/PolySubML에서 동등한 기능이 어떻게 구현될지를 기준으로 설명할 것이다.
구현 방법을 논하기 전에, 먼저 OCaml 모듈이 실제로 어떻게 동작하는지 이해해야 한다. 간단한 예제로 시작하자:
module M: sig
type t
val zero: t
val add: (t * t) -> t
end = struct
type t = int
let zero = 0
let add (x, y) = x + y
end
(* 모듈 M 사용 예 *)
let foo = M.zero
let bar = M.add (foo, foo)
let baz: M.t = M.add (foo, bar)
이는 t 타입과 그 타입을 다루는 값 zero와 함수 add를 포함한 모듈 M을 정의한다. 위에서 보듯 M.t, M.zero, M.add로 접근할 수 있다.
여기서 M.t는 새로운 불투명(opaque) 타입으로, 다른 모든 타입과 구별된다. M.t의 실제 정의는 int이지만, 시그니처에서 추상 type t를 사용하면 그 사실이 가려지고 시그니처를 적용할 때마다 새로운 불투명 타입이 생성된다. 따라서 int와 M.t를 섞어 쓰면 컴파일 오류가 난다:
(* 컴파일 오류 *)
let x: int = M.zero
(* 컴파일 오류 *)
let y: M.t = M.add (1, 2)
이제 기존 언어 PolySubML에서 가장 가까운 동등물을 보자:
let {
type t;
zero: t;
add: (t * t) -> t
} = {
zero = 0;
add = fun (x, y) -> x + y
};
let foo = zero;
let bar = add (foo, foo);
let baz: t = add (foo, bar);
이미 OCaml 모듈 예제와 매우 유사하다. 위의 M.t와 마찬가지로, 여기의 t도 int와 섞을 수 없는 별도의 불투명 타입이다. 기능적으로는 동일하지만, 문법적으로 몇 가지 차이가 있다.
첫 번째 눈에 띄는 차이는 레코드 리터럴에 type t = int가 없다는 점이다. PolySubML에서는 존재 타입의 구체화가 완전히 추론 가능하므로, 타입을 명시적으로 적을 필요가 없다. 다만 원한다면 선택적으로 적을 수도 있다. 타입을 명시하는 방법은 다음과 같다:
let {
type t;
zero: t;
add: (t * t) -> t
} = {
type t = int; (* <--- 명시적 타입 *)
zero = 0;
add = fun (x, y) -> x + y
};
또 하나 훨씬 중요한 차이가 있다. PolySubML 예제에서는 언팩된 값과 타입이 레코드처럼 감싸지지 않고 직접 상위 스코프에 바인딩된다. 즉, OCaml 예제처럼 M.t, M.zero, M.add가 아니라 t, add, zero로 접근한다는 뜻이다.
값 바인딩에 대해서는 다음처럼 다시 레코드로 감싸 해결할 수 있다:
let {
type t;
zero: t;
add: (t * t) -> t
} = {
zero = 0;
add = fun (x, y) -> x + y
};
let M = {zero; add};
let foo = M.zero;
let bar = M.add (foo, foo);
let baz: t = M.add (foo, bar);
하지만 타입 t에는 이 방법이 통하지 않는다. 이상적으로는 let M = {type t=t; zero; add} 같은 걸 하고 싶다. 그렇게 하면 t 대신 M.t로 참조할 수 있기 때문이다. 하지만 PolySubML은 이를 지원하지 않는다. X에서 해결해야 할 첫 번째 큰 간극이다. 레코드 타입 안에 타입 별칭 멤버를 지원해야 한다.
그 얘기를 더 하기 전에, OCaml의 또 다른 모듈 예제를 보자:
module M = struct
type t = int
end
(* M.t와 int는 상호 변환 가능 *)
let x: M.t = 42
let y: int = x
첫 번째 예제에서는 시그니처를 사용해 t를 추상 타입으로 바꿨는데, 이것이 모듈의 일반적인 사용법이다. 하지만 OCaml은 이 예제처럼 추상화 없이 모듈 안에서 일반 타입 별칭을 쓰는 것도 허용한다. 여기서 M.t는 int의 _별칭_이다. 같은 타입에 대한 두 개의 다른 이름이며 완전히 호환된다. 이것은 X가 레코드 타입 안의 타입 별칭 멤버를 지원해야 하는 또 다른 이유를 보여준다.
앞에서 X가 레코드 안에 타입 별칭을 허용해야 한다고 봤다. 이제 이를 실제로 어떻게 구현할지의 문제다.
참고: OCaml 모듈을 완전히 모사하려면 일반 타입 별칭뿐 아니라 타입 생성자 별칭도 필요하다. 타입 생성자 이야기는 다음 글에서 다룰 예정이며, 여기서는 단순화를 위해 양쪽을 모두 “타입 별칭”이라 부르겠다.
다음 함수를 생각해 보자. M의 타입조차 모르는 함수 안에서 M.t는 어떻게 다뤄져야 할까?
fun M -> (
let x: M.t = M.zero;
x
)
레코드의 별칭 멤버를 구현하는 자연스러운 방법은, 일반 레코드 필드와 동일하게 완전한 타입 추론을 적용하는 것이다. 좀 더 구체적으로, 별칭 멤버는 불변(invariant)으로 만들고자 한다. 이는 일반 레코드 필드(공변)보다는 가변 레코드 필드(역시 불변)에 가깝게 동작한다는 뜻이다.
즉 위 예제에서 M.t는 단지 하나의(정확히는 한 쌍의) 추론 변수이고, 타입 추론이 M.zero와 마찬가지 방식으로 모든 것을 알아서 해결한다.
전반적으로는 간단하지만, 레코드 필드와 비교했을 때 눈여겨볼 만한 작은 차이가 하나 있다. 편의를 위해, 별칭 멤버는 레코드 _리터럴_에서 생략 가능하며 필요할 때 추론된다.
즉, 다음 코드는 X에서 문제 없이 컴파일되고 타입검사가 통과된다:
let M = {
foo=4
};
let h: M.t = "hello!";
이미 명시적 별칭 멤버가 있으면 그것이 사용된다. 아래는 h에서 int/str 불일치로 타입 오류가 난다:
let M = {
type t=int;
foo=4
};
let h: M.t = "hello!";
다만, 별칭 멤버는 레코드 _타입_에는 추론으로 추가되지 않는다. 레코드 값의 타입을 명시적으로 지정한다면, 타입 주석에도 모든 멤버를 명시해야 한다. 따라서 아래 코드는 존재하지 않는 M.t 멤버 때문에 타입 오류가 발생한다:
let M: {
foo: int
} = {
foo=4
};
let h: M.t = "hello!";
이는 타입 주석에 별칭 멤버를 명시하여 해결할 수 있다:
let M: {
type t: str;
foo: int
} = {
foo=4
};
let h: M.t = "hello!";
이 동작은 OCaml에서 영감을 받은 것이 아니다. OCaml은 이런 걸 하지 않는다. 대신, 기존 PolySubML의 동작과 일치시키기 위해서이며, PolySubML의 이 측면은 보편 양화 함수 타입과 존재 양화 레코드 타입 간의 이중성(duality)을 유지하려는 의도에서 비롯됐다.
OCaml에서는 일반 함수의 제네릭 타입 매개변수는 추론 가능하고(심지어 명시적으로 지정하는 방법조차 없다), 모듈 타입의 멤버(레코드에 해당)는 반드시 명시해야 한다. PolySubML에서는 두 경우 모두 동일하게 다루기로 했는데—둘 다 완전 추론 가능하지만, 원하면 명시도 할 수 있게 한 것이다.
또 한 가지 주목할 점은 데이터 멤버와 타입 멤버가 서로 다른 네임스페이스를 가진다는 것이다. 즉 {type foo=int; foo="hello"}는 문제없이 동작한다. 타입 주석 문맥에서의 M.foo는 타입 int를 가리키고, 식 문맥에서의 M.foo는 값 "hello"를 가리킨다. 이 점에서 OCaml도 비슷하게 동작한다.
OCaml은 모듈과 일반 값을 서로 다른 문법으로 쓴다. OCaml에서는 모듈 값을 정의하려면 struct .. end, 모듈 타입을 정의하려면 sig .. end를 사용해야 한다.
지금까지 X 코드 예제에서는 모듈이 그냥 일반 값임을 강조하기 위해 일반 레코드 리터럴 문법을 사용했다. 하지만 통합을 하더라도, 어떤 경우에는 OCaml의 struct 문법이 더 편리하므로 X에서는 두 문법을 모두 지원한다.
struct 문법에서는 struct와 end 사이에 임의의 문(statement) 목록을 넣을 수 있으며, 이는 블록 식으로 간주되어 블록 스코프 내에서 도입된 각 변수/타입 바인딩에 대해 필드를 가진 레코드로 귀결된다.
예를 들어 다음 코드는:
let M = struct
type t = int;
let x = 42;
let y: t = x * 3;
let x = 1;
let z = x + y;
end;
설탕 문법으로서, 다음의 일반 레코드 문법과 동등하다:
let M = (
type t = int;
let x = 42;
let y: t = x * 3;
let x = 1;
let z = x + y;
{type t=t; x; y; z}
);
struct 버전은 마지막에 변수 이름을 일일이 반복하지 않아도 된다.
sig 문법은 값이 아니라 타입에 해당한다. 예를 들어,
type S = sig
type a;
type b = int;
val x: str;
val y: b -> a;
val z: a -> int;
end;
이는 다음의 일반 레코드 타입 문법과 동등하다:
type S = {
type a;
type b = int;
x: str;
y: int -> a;
z: a -> int
};
보듯, struct와 달리 sig 문법을 쓰는 이점은 매우 미미하다.
X에서는 struct/sig와 일반 레코드 문법이 동일한 기능에 대한 대안 문법일 뿐이며 완전히 상호 교환 가능하다:
let M: {x: int} = struct
let x = 4;
end;
let M2: sig
val x: int
end = {x=5};
일반 레코드 문법이 기능적으로 sig/struct 문법의 _상위집합_이라는 점에 유의하자. 구체적으로, struct/sig 문법은 가변 필드를 지원하지 않는다. OCaml에서 모듈은 가변 필드를 담을 수 없기 때문이다. X에서 가변 필드를 쓰고 싶다면 일반 레코드 문법으로 돌아가야 한다.
OCaml은 한 모듈의 내용을 다른 곳으로 open 하거나 include 할 수 있다. include는 참조한 모듈의 모든 항목을 복사해 스코프로 가져온다:
let M1 = {
x=4;
};
let M2 = {
include M1;
y=7;
};
(* M2 = {x=4; y=7} *)
open은 include와 비슷하지만 새 바인딩이 재수출되지 않는다. 대신, 각 이름에 대해 열지 않은 바인딩 중 마지막 것이(있다면) 수출된다:
module M = struct
let x = 1
let y = 2
end
module M2 = struct
let x = 3
open M
let z = x * 10 + y
end
(* M2 = {x=3; z=12} *)
X에서 OCaml식 open과 include를 구현하는 것은, 포함할 값에 비추론형 타입(OCaml 모듈이 항상 그러하듯)이 요구된다면 가능 하다. 그러나 나는 다소 논쟁적일 수 있겠지만, 여기서의 OCaml 동작은 좋지 않은 설계이며 그대로 모방해서는 안 된다고 말하고 싶다.
대부분의 언어는 명시 임포트와 와일드카드 임포트 둘 다 허용한다. 예컨대 파이썬에서는 from foo import bar, baz처럼 특정 멤버만 임포트할 수도 있고, from foo import *처럼 (거의) 모두 임포트할 수도 있다. 자바도 import foo.Bar;와 import foo.*; 중에서 선택할 수 있다. 러스트 등 다른 언어도 비슷하다.
하지만 모든 경우에 와일드카드 임포트는 강력히 비추천된다. 이런 언어들은 와일드카드 임포트 기능을 가지고는 있지만, 실제로 _쓰지 말라_고 하거나 매우 제한적인 경우에만 쓰라고 한다.
문제 중 하나는, 와일드카드 임포트를 하면(IDE 지원이 없다면) 주어진 식별자가 어디에서 정의되었는지 알 수 없다는 점이다. 소스 코드를 훑다가 foo를 봤을 때, 그것이 실제로 어디에 정의되어 있는지 찾을 방법이 없을 수 있다. 더 큰 문제는 네임스페이스를 불필요하게, 그리고 예측 불가능하게 오염시킨다는 점이다.
다음 OCaml 코드는 무엇을 출력할까?
let x = 1
open M
let _ = Printf.printf "x = %d\n" x
정답은 알 수 없다는 것이다! open M이 x를 가릴지 말지 알 수 없으므로, M의 정의를 찾아보기 전에는 판단할 방법이 없다.
나는 오래전부터 선택적 타입 주석이 코드의 관찰 가능한 동작에 영향을 주어서는 안 된다고 주장해 왔다. 현실 세계의 언어들이 이 원칙을 자주 어기긴 하지만, 고급 타입 시스템 기능과 정교한 전역 타입 추론을 사용하면서도 X를 간명하고 이해하기 쉽게 만들기 위해서는 이 원칙이 핵심이다. X의 코드가 무엇을 하는지 이해하고자 한다면, 타입 시스템을 무시하고 그냥 _코드_만 봐도 된다.
하지만 OCaml식 open을 쓰면 더 이상 그렇지 않다. 다음 예제를 보자:
module M: sig
(* val x: int *)
end = struct
let x = 2
end
let x = 1
open M
let _ = Printf.printf "x = %d\n" x
이 코드는 1을 출력하지만, val x: int를 주석 해제하면 2를 출력한다. _타입 주석_을 바꿨더니 다른 부분의 _런타임 동작_이 바뀐 것이다!
마지막으로, 와일드카드 임포트는 하위 호환성과 라이브러리 버전 관리의 악몽이다. 와일드카드 임포트를 쓰면, 모듈에 새 멤버를 _추가_하는 것만으로도 예기치 못한 섀도잉이 발생해 이전에 잘 동작하던 코드가 깨질 수 있다. 이 Stack Overflow 답변은 자바 1.2에서 java.util.List가 추가되며, 당시 흔했던 와일드카드 임포트를 통해 java.awt.List를 가려버려 생긴 문제를 예로 든다.
분명, 모듈 전체를 마구 쏟아붓는 대신, 이름이 지정된 특정 멤버만 open/include 할 수 있어야 한다. OCaml에는 이를 지원하는 문법이 아예 없다.
새 문법을 만들 수도 있다(예: open M{foo; bar; type t} 같은). 하지만 그럴 필요가 없다. X에서는 모듈이 이미 일반 레코드이므로, let {foo; bar; type t} = M;처럼 이미 같은 목적을 달성할 수 있기 때문이다.
다만 사소한 문제들이 있다. 첫째, 타입 _별칭_을 바인딩하는 것과 존재 타입 매개변수를 고정(pinning)하는 것을 구분할 방법이 필요하다. 전자는 type t, 후자는 newtype t를 쓰자(새 타입을 도입하므로).
둘째, OCaml의 open처럼 바인딩을 재수출하지 않도록 표시할 방법이 필요하다. 이를 위해 let 앞에 private를 붙이자. 앞서의 OCaml open 예제를 X에서 private let을 써서 다시 쓰면 다음과 같다:
let M = struct
let x = 1;
let y = 2;
end;
let M2 = struct
let x = 3;
private let {x; y} = M;
let z = x * 10 + y;
(* M2 = {x=3; z=12} *)
(* M에서 들여온 x, y는 private이므로 내보내지지 않는다 *)
end;
이로써 struct 문법에서의 임포트는 해결했는데, sig 문법은 어떨까?
struct 문법에서는 이를 지원하는 이점이 분명하지만, sig 문법의 경우는 설계 여지가 많다. 따라서 실제로 사람들이 OCaml에서 시그니처 확장을 어떻게 사용하는지 더 잘 이해하기 전까지는 X에서 이를 지원하는 최선의 방법을 정하기 어렵다. 그러므로 지금은 X에서 sig용 include/open 전용 문법을 일절 넣지 않겠다.
OCaml 쪽에서도 이런 것들은 매우 까다롭고 아직 완전히 정리된 것도 아니다. 예컨대 OCaml 4.08은 모듈 타입 동작 방식에 다수의 역호환 변경을 도입했다.
OCaml은 let open M in expr 또는 M.(expr)처럼, expr의 스코프 안에서만 M을 여는 _로컬 open_도 허용한다. 위에서 설명했듯, 이런 와일드카드 open을 지원하는 것은 X의 설계 원칙에 어긋나며 예기치 않은 섀도잉 문제를 일으킨다.
다만 X에서는 모듈을 짧은 별칭에 할당해 거의 비슷한 간결함을 얻을 수 있다. 예를 들어 LongModule.(add one zero) 대신 let M = LongModule in (M.add M.one M.zero)라고 쓸 수 있다.
또는 let {add; one; zero} = LongModule in (add one zero)처럼 “명시 임포트” 방식도 쓸 수 있는데, 소수의 식별자를 반복해 참조할 때는 이쪽이 더 나을 수 있다.
OCaml식 완전 와일드카드 임포트에는 부정적인 면이 많지만, 그 단점 없이 가장 흔한 사용 사례는 지원할 수 있으면 좋겠다. OCaml에서 include의 가장 흔한 용례는 모듈을 _확장_하는 것이다. 즉 기존 멤버들을 모두 가진 새 모듈을 만들되, 일부 멤버를 추가하거나 오버라이드하는 경우다:
module M = struct
type t = int
let x = 2
let z = 3
end
module M2 = struct
include M
let y: t = x + 1
let z = 42 (* M.z를 오버라이드 *)
end
다행히 이 용례는 몇 가지 제약을 더하면 좋은 설계 원칙을 지키면서도 지원할 수 있다. X에서는 이를 위해 extends를 도입한다:
let M = struct
type t = int;
let x = 2;
let z = 3;
end;
let M2 = struct
extends M;
let y: M.t = M.x + 1;
let z = 42; (* M.z 오버라이드 *)
end;
필요한 규칙은 다음과 같다
각 struct당 extends는 최대 한 번만 사용 가능
extends는 정의보다 먼저, 맨 위에 위치해야 함
extends는 상속된 바인딩을 내보내는 레코드에 추가만 하며, struct 블록 자신의 스코프로는 들여오지 않음
이 규칙은, 확장 대상 모듈의 정의를 몰라도 변수 조회가 명확하고 일의적으로 되도록 보장한다. 이 규칙이 없으면 변수 조회가 모호해질 수 있다.
struct
(* 변수 "foo"를 어디에서 찾는가? *)
extends M1;
extends M2;
end;
만약 여러 extends를 허용하면, 기반 모듈들의 정의를 알지 않고는 어떤 변수든 어디에서 찾아야 하는지 알 수 없게 된다.
struct
(* 변수 "foo"를 어디에서 찾는가? *)
let foo = 4;
extends M;
end;
extends 전에 정의를 허용하면, 기반 모듈의 정의를 알지 않고는 그 정의가 섀도잉되는지 아닌지 알 수 없다.
let foo = 4;
struct
extends M;
let bar = foo; (* 이 foo는 어디의 foo를 가리키는가? *)
end;
마지막으로, extends가 상속된 바인딩을 struct 블록 _자체_의 스코프로 끌어오면, 부모 스코프의 바인딩이 섀도잉되는지 아닌지를 기반 모듈 정의를 알지 않고는 판단할 수 없게 된다.
이 규칙들 덕분에, 어떤 값이든 그 타입을 몰라도(가장 흔한 OCaml 사용 사례를 충분히 포괄하면서) 안전하게 모듈을 확장할 수 있다. X에서는 추론된 타입을 가진 함수 매개변수 같은 값도 확장 가능하다:
let f = fun M -> struct
extends M; (* M의 타입을 몰라도 확장 가능 *)
let x = 42;
end;
행 폴리모르피즘이 없다면 큰 의미는 없겠지만, 원한다면 이렇게도 할 수는 있다.
이제 같은 기능을 일반 레코드 문법에도 추가해야 한다. struct 문법은 모듈 같은 레코드의 특수한 경우에 대한 설탕 문법이고, 일반 레코드 문법은 완전한 일반성을 가지므로, struct 문법으로 할 수 있는 일은 일반 레코드 문법으로도 가능해야 한다.
다행히 OCaml에는 이를 베낄 수 있는 적합한 문법이 이미 있다. with 문법은 기존 레코드의 모든 필드를 복사하면서 새 필드를 추가하거나 기존 필드를 오버라이드할 수 있게 한다. (OCaml 자체는 명목 타이핑된 레코드라 기존 필드만 오버라이드할 수 있지만, X는 구조적 타이핑된 레코드이므로 새 필드 추가도 가능하다.) {foo with a=4}라고 쓰면 foo의 기존 필드를 모두 복사하되 a=4를 추가하는 의미다.
with 문법을 이용해 앞의 extends 예제를 “디슈가”할 수 있다.
let M = struct
type t = int;
let x = 2;
let z = 3;
end;
let M2 = struct
extends M;
let y: M.t = M.x + 1;
let z = 42; (* M.z 오버라이드 *)
end;
이는 다음처럼 디슈가된다
let M = (
type t = int;
let x = 2;
let z = 3;
{type t=t; x; z}
);
let M2 = (
let y: M.t = M.x + 1;
let z = 42; (* M.z 오버라이드 *)
{M with y; z}
);
또 하나 주의할 점: X에서는 extends/with를 이렇게 정의하려 한다. 만약 데이터 멤버를 하나도 지정하지 않는다면(즉, 타입 멤버만 추가·변경한다면), 보통처럼 값을 새 객체로 복사하는 대신 같은 객체를 그대로 반환한다. 확장하려는 객체에 가변 필드가 있을 때만 의미가 있다:
let M = {mut x=4; y=7};
let M2 = struct
extends M;
(* 새 데이터 멤버가 없으므로 M2 == M *)
type t=int;
end;
let M3 = struct
extends M;
(* 새 데이터 멤버가 있으므로 M3 != M *)
let y = 1;
end;
M.x <- 9;
print M2.x; (* 9, M2는 M과 같은 객체 *)
print M3.x; (* 4, M3는 M을 *복사*함 *)
한 축에서 작은 비일관성이 생기지만, 경우에 따라 두 동작이 모두 필요하므로 불가피해 보인다. 사용자에게는 타입 별칭 멤버만 추가/변경하며 레코드를 재해석 할 방법도 필요하고, 데이터 필드를 추가/변경하며 레코드를 복사 할 방법도 필요하기 때문이다. 이 방식이 가장 깔끔해 보인다.
어차피 OCaml 모듈은 가변성을 허용하지 않으므로, “모듈” 용례에서는 이 문제가 드러나지 않는다. 하지만 일반 레코드 값은 가변 필드를 허용하므로, X처럼 모듈과 레코드를 통합하려면 모든 “모듈” 관련 기능에서도 가변 필드 가능성을 고려해야 한다.
레코드 안에서 타입 별칭을 쓰는 것은 좋지만, 이 경우 구현 타입이 모든 사용자에게 다 노출된다는 문제가 있다.
예를 들어, 정수 단일 연결 리스트를 push/pop 메서드로 다루는 IntList 모듈을 작성한다고 하자:
let IntList = struct
type t = rec list = [`Some {val: int; tail: list} | `None any];
let empty: t = `None 0;
let push = fun {list: t; val: int} : t -> `Some {val; tail=list};
let pop = fun (list: t): [`Some (int, t) | `None any] -> (
match list with
| `Some {val; tail} -> (val, tail)
| `None _ -> `None 0
);
end;
제공된 IntList의 함수들을 사용해 리스트를 만들고 다룰 수 있다:
let list = IntList.empty;
let list = IntList.push {list; val=1};
let list = IntList.push {list; val=2};
let list = IntList.push {list; val=3};
print (IntList.pop list |> unwrap)._0; (* 3 출력 *)
하지만 올바른 타입만 맞춘다면, 사용자는 구현을 통하지 않고 직접 객체를 만들고 조작할 수 있으며, 이를 IntList의 메서드와 자유롭게 섞어 쓸 수 있다:
(* IntList를 거치지 않고도 리스트 값을 만들 수 있음 *)
let list = `Some {val=11; tail=`Some {val=42; tail=`None {}}};
(* 이것도 IntList 메서드들과 함께 사용 가능 *)
let list = IntList.push {list; val=3};
let (_, list) = IntList.pop list |> unwrap;
(* 리스트 객체를 직접 들여다볼 수도 있음 *)
print (list |> unwrap).val (* 11 출력 *)
이는 종종 바람직하지 않다. 모듈의 구현 세부가 완전히 노출되어 내부 불변식을 보장할 수 없고, 구현을 바꾸면 사용자가 깨질 위험이 있다. 또한 구조만 호환되면 어떤 값이든 IntList로, 그 반대도 가능해지므로, 사용자의 의도와 무관하게 타입 안정성이 떨어진다.
따라서 보통은 구현 타입을 사용자에게 숨기는 _추상화_를 사용하고 싶다. 사용자는 불투명(opaque) 타입과, 그 타입 값을 다루는 메서드만 보게 된다. 이를 위해 _존재 타입_을 사용한다.
PolySubML과 X에서는 레코드 타입이 선택적으로 존재 타입 매개변수(type a)를 가질 수 있다. 예: {type t; foo: t}. 존재 타입 매개변수는, 가능한 각 값에 대해 그 타입 서명을 만족시키는 어떤 타입이 _존재_함을 나타낸다.
예를 들어:
let foo: {type t; zero: t; add: (t, t) -> t} =
if whatever then
{zero=0; add: fun (a, b) -> a + b}
else
{zero=0.0; add: fun (a, b) -> a +. b}
;
이는 foo가 {type t; zero: t; add: (t, t) -> t} 타입임을 선언한다. foo의 값이 {zero: t; add: (t, t) -> t}와 호환되게 만드는 어떤 타입 t가 _존재_함은 알지만, 그 타입이 무엇인지는 알 수 없다.
이 예제에서는 if 표현식으로 정의되므로, 런타임에 두 값 중 하나를 가질 수 있다. 첫 번째 분기를 타면 {zero=0; add: fun (a, b) -> a + b}이고 이때 t = int다. 두 번째 분기를 타면 {zero=0.0; add: fun (a, b) -> a +. b}이고, 이때 t = float다.
필드의 타입을 모르는 이상, 존재 타입의 필드를 직접 사용할 수 없다. 대신, 패턴 매칭으로 존재 타입 매개변수를 새 불투명 타입으로 고정(pin) 해야 한다:
let {newtype t; zero: t; add: (t, t) -> t} = foo;
let a: t = zero;
let b: t = add (a, a);
이 패턴 매칭은 다른 모든 타입과 구별되는 새 불투명 타입 t를 생성한다. 이제 foo의 필드를 사용할 수 있지만, 그 타입이 불투명 타입 t를 참조하므로 다른 어떤 타입과도 섞을 수 없다. 이 덕분에, foo의 원래 값과 기반 타입이 무엇이었든 사용자 코드가 항상 안전하게 동작한다.
원래의 IntList 예제에서도 존재 타입을 써서 추상화를 적용할 수 있다:
let {
newtype t;
empty: t;
push: {list: t; val: int} -> t;
pop: t -> [`Some (int, t) | `None any]
} = struct
type t = rec list = [`Some {val: int; tail: list} | `None any];
let empty: t = `None 0;
let push = fun {list: t; val: int} : t -> `Some {val; tail=list};
let pop = fun (list: t): [`Some (int, t) | `None any] -> (
match list with
| `Some {val; tail} -> (val, tail)
| `None _ -> `None 0
);
end;
이렇게 하면 실제 구현 타입이 사용자에게 숨겨져, 우연히 구조가 같은 외부 값과 섞을 수 없게 된다. 이제 empty/push/pop 메서드를 통하지 않고는 t 값을 만들거나 다룰 방법이 없다:
let list = `Some {val=11; tail=`Some {val=42; tail=`None {}}};
(* list는 variant이고 push는 불투명 타입 t를 기대하므로 타입 오류 *)
let list = push {list; val=3};
let (_, list) = pop list |> unwrap;
(* list의 타입이 불투명 t이므로 내부를 들여다볼 수 없어 타입 오류 *)
print (list |> unwrap).val
섹션 I에서 이미 논의했듯, 이 패턴 매칭 방식은 모듈 필드가 t, empty, push, pop으로 바인딩되어 IntList.t, IntList.empty처럼 접근하지 못한다는 점이 아쉽다.
이제 레코드에 타입 별칭을 추가할 수 있으므로, 다시 레코드로 감싸 이 문제를 해결할 수 있다:
let {
newtype t;
empty: t;
push: {list: t; val: int} -> t;
pop: t -> [`Some (int, t) | `None any]
} = (* 모듈 구현 ... *);
let IntList = {type t=t; empty; push; pop};
(* 이제 IntList.t 등으로 접근 가능 *)
let list: IntList.t = IntList.empty;
이를 struct 문법으로 더 간단히 감싸 자동으로 레코드를 만들 수도 있다:
let IntList = struct
let {
newtype t;
empty: t;
push: {list: t; val: int} -> t;
pop: t -> [`Some (int, t) | `None any]
} = (* 모듈 구현 ... *);
end;
(* 이제 IntList.t 등으로 접근 가능 *)
let list: IntList.t = IntList.empty;
이 방식은 동작 하지만 약간 장황하다. 전용 축약 문법을 도입해 더 나아질 수 있을지 보자.
앞서 본 struct 내부의 패턴 매칭 방식에는 사소한 단점이 몇 가지 있다.
첫째, 객체를 디스트럭처링한 뒤 새 객체를 만든다. 우리가 정말 원하는 것은 객체의 타입을 제자리에서 재해석하는 것뿐인데, 이는 낭비처럼 보이고(가변 상태도 잃는다) 바람직하지 않다.
둘째, _패턴 매칭_이 필요해, 매칭할 타입 매개변수와 필드 타입을 명시적으로 나열해야 한다. 이는 OCaml 모듈에서 시그니처를 적는 자리를 사실상 대체하는데, 가능하다면 패턴 대신 _타입 주석_을 실제로 쓰고 싶다.
그래서 두 문제를 모두 해결하는 let mod 문법을 도입한다:
let mod IntList: sig
type t;
val empty: t;
val push: {list: t; val: int} -> t;
val pop: t -> [`Some (int, t) | `None any];
end = (* 모듈 구현 ... *);
(* 이제 IntList.t 등으로 접근 가능 *)
let list: IntList.t = IntList.empty;
let mod는 기본적으로 앞서의 패턴 매칭 접근법에 대한 설탕 문법인데, a) 새 객체를 만들지 않고 제자리에서 객체 타입을 바꾸며, b) 패턴 대신 일반 _타입 주석_을 사용한다는 점이 다르다.
여담으로, mod 대신(예: pin) 다른 키워드를 쓸지 고민했다. mod 문법의 _실제 기능_은 존재 타입 매개변수를 새 타입으로 치환하는 것이다. 존재 타입을 쓰지 않는다면, 모듈 같은 객체(타입 별칭을 담은 레코드)에도 mod는 필요 없다. 기능을 설명하기에는 pin이 더 정확하지만, OCaml과의 유사성을 위해, 그리고 mod가 이미 키워드이기도 해서, 우선 mod를 쓰기로 했다. 너무 혼란을 주지 않기를 바란다.
또한 이 mod 문법은 let 정의 전체가 아니라 패턴 안의 변수 _바인딩_을 수정한다. 복잡한 패턴 안에 중첩하여 쓸 수도 있다(예: let {a; mod b; c; mod d} = ...).
이제 _펑터_에 대해 이야기할 시간이다.
이쯤에서 “펑터는 어쩌고?”라고 생각할 수 있다. 아니면 OCaml을 모른다면 “펑터가 도대체 뭐지?”라고 생각할 수도 있다.
펑터는 복잡해 보이지만, 본질적으로는 _모듈을 인자로 받는 함수_일 뿐이다. OCaml은 일반 값/타입/함수와 모듈 값/모듈 타입/펑터를 엄격히 분리하는 2계층 설계를 가지고 있어, “모듈 위에서의 함수”에 해당하는 별도의 명칭이 필요하다. 하지만 X에서는 모듈에 해당하는 것이 그저 일반 레코드 값이므로, 그냥 일반 함수를 쓰면 된다!
그래도 논의할 점이 몇 가지 있다. 다음은 OCaml에서 펑터를 사용하는 전형적 예시다:
module IntSet = Set.Make (struct
type t = int
let compare x y = x - y
end)
Set.Make는 OCaml 표준 라이브러리가 제공하는 펑터다. 하나의 모듈(struct ... end 부분)을 인자로 받아, 새 모듈 객체를 반환하고(여기서는 IntSet에 바인딩)한다.
여기서 IntSet에는 명시적 타입 주석이 없다. 펑터를 정의할 때는 타입 시그니처가 정해져 있어야 하며, 펑터를 호출 할 때는 그 반환 타입 시그니처가 호출 지점으로 전파된다. OCaml에서 모듈은 항상 알려진 타입을 가져야 하므로, 호출 지점에서 명시적 타입 주석이 필요 없어진다.
반면, 이전 섹션에서 제시한 X의 let mod 문법은 모듈이 바인딩되는 지점에 명시적 타입 주석을 요구한다.
let mod IntSet: (* 여기 타입 주석 *) = Set.Make {
compare=fun (x, y) -> x - y
};
가능하다면 OCaml처럼 타입 시그니처 반복을 피하고 싶다. 첫 질문은, 애초에 타입 시그니처가 정말 필요한가? 그냥 없애고 타입 추론에 맡길 수는 없을까?
문제는 let mod가 오른쪽 식의 타입을 알아야, 어떤 존재 타입 매개변수를 어디에서 치환해야 하는지 알 수 있다는 점이다. 이를 어느 정도 추론으로 때울 수는 있지만, 모든 경우에 통하지는 않는다.
특히 오른쪽 식이 여러 개의 구별되는 존재 타입의 합(예: 직접 혹은 간접적으로 if나 match로 정의)인 경우에는, 타입을 추론하려면 모든 분기의 타입에 대한 가장 일반적인 공통 상위타입을 찾아야 한다. 그런데 존재 레코드 타입의 경우에는 그런 가장 일반적인 상위타입이 실제로 존재하지 않을 수 있다.
이는 a) 존재 타입 매개변수는 잊혀질 수 있고(예: {type t; foo: t}는 {}의 서브타입), b) 모든 타입 매개변수는 종류(kinds)에 상관없이 단일 네임스페이스를 공유한다는 사실의 결과다. OCaml 식으로 말하면, 추상 type t를 담은 모듈 타입과 type 'a t를 담은 모듈 타입을 합치는 방법이 없다는 뜻이다. 이름은 같지만 종류가 다르기 때문이다. 둘 중 하나를 택해야 하고, 따라서 가능한 공통 상위타입이 두 개 생기지만 서로 더 일반적이지 않다.
즉, let mod에 대해 완전한 타입 추론을 갖추는 것은 불가능하므로, 명시적 타입 주석을 요구해야 한다. 다만 OCaml 펑터 예시처럼, 모듈을 바인딩하는 그 지점에서 타입 주석을 생략할 수 있으면 좋겠다. 해법은 전방형 타입 전파 시스템을 추가하는 것이다.
어딘가에는 명시적 타입 주석이 있어야 하지만, 모듈 바인딩 바로 그 지점일 필요는 없다. 대신, 전체 타입 추론을 수행하지 않고도 가능한 한 멀리 타입을 앞쪽으로 전파하는 간단한 규칙을 추가할 수 있다.
어떻게 전파되는지 규칙을 직접 정의하기보다는, 전파되지 않는 경우를 정의하는 편이 쉽다. 전방 전파는 다음과 같은 경우에 멈춘다:
let x: _ = ...의 _) 또는 결과적으로 추론 변수가 되는 타입으로 주석됨.if 또는 match). 단순화를 위해 loop나 필드 대입 식도 다루지 않는다.foo.bar)이며, foo의 타입이 추론 변수임.foo bar)이며, foo의 타입이 추론 변수임.대략 말해, 전체 타입 추론이나 타입 합집합을 수행하지 않고 한 번의 전방 패스로 알 수 있는 타입 정보만 사용하겠다는 뜻이다. if와 match를 제외하는 것은 이 규칙 아래에서는 타입 합집합을 결코 수행하지 않게 하려는 것이고, 앞 절에서 설명했듯 존재 레코드 타입에서는 타입 합집합이 정의되지 않는 문제가 있기 때문이다.
그리고 let mod의 오른쪽 식은 전방 전파로 알려진 타입을 가져야 한다고 요구하자. 이렇게 하면 명시적 타입 시그니처를 추가하면 언제나 동작하고, 펑터 예시처럼(펑터가 명시적 반환 타입으로 정의되어 있다면) 모듈 바인딩 지점에서는 타입 주석을 생략할 수 있다.
let mod IntSet = Set.Make {
compare=fun (x, y) -> x - y
};
이제 마지막으로 펑터에 관한 남은 이슈 하나: _생성성_이다.
OCaml은 생성적 펑터와 적용적 펑터 중에서 고를 수 있게 한다. 이름이 어렵게 들릴 수 있으나, 기본 아이디어는 생성적 펑터는 일반 함수처럼 동작하고, 적용적 펑터는 메모이즈된 함수처럼 동작한다는 것이다.
생성적 펑터는 일반 함수처럼 동작한다. 호출할 때마다 새 결과를 돌려준다. 즉 반환 시그니처에 추상 타입이 있다면, 호출 때마다 새 타입이 생성된다. 아래에서 M1.t와 M2.t는 서로 독립적인 타입으로 호환되지 않는다:
module GenerativeFunctor () : sig
type t
val foo: t
end = struct
type t = int
let foo = 42
end
module M1 = GenerativeFunctor ()
module M2 = GenerativeFunctor ()
(* 오류: 이 식의 타입은 M2.t인데, 기대된 타입은 M1.t *)
let x: M1.t = M2.foo
반대로 적용적 펑터는 메모이즈된 함수처럼 동작한다. 같은 인자로 여러 번 호출하면 같은 결과를 돌려준다. 아래 예시에서는 ApplicativeFunctor를 Arg로 두 번 호출하면 같은 모듈을 반환하므로, M1.t와 M2.t는 같은 타입이며 자유롭게 섞을 수 있다.
module ApplicativeFunctor (_: sig end) : sig
type t
val foo: t
end = struct
type t = int
let foo = 42
end
module Arg = struct end
module M1 = ApplicativeFunctor (Arg)
module M2 = ApplicativeFunctor (Arg)
(* 두 모듈 모두 같은 타입 `t`를 가지므로 유효 *)
let x: M1.t = M2.foo
이것이 가능한 이유는 OCaml의 펑터 문법이 제한적이기 때문이다. 반면 X에서 펑터는 그냥 일반 함수이므로 임의의 코드를 담을 수 있고 순수하거나 결정적임이 보장되지 않는다. 따라서 X의 모든 “펑터”는 반드시 생성적이어야 한다.
이 글에서는 OCaml의 값/모듈 레벨 방언을 어떻게 통합할 수 있는지 살펴봤다. PolySubML처럼 구조적 타이핑과 존재 타입이 이미 있다면 대부분은 자연스럽게 따라오지만, 몇 가지 사소한 새 기능이 추가로 필요하다. 모듈의 대부분 측면은 완전히 추론 가능하지만, 추상화를 사용할 때는 언제/어떻게 새로운 추상 타입을 생성할지 지시하기 위해 명시적 타입 주석이 여전히 필요하다.
값과 모듈 레벨을 통합하면, X는 동일한 표현력을 더 단순한 구조로 제공할 수 있어 개발과 학습이 쉬워진다. 이는 X처럼 1인 취미 언어에게 특히 중요하지만, 더 큰 규모의 언어에도 유용할 수 있다.
모듈을 정리했으니, 이제 X에 통합해야 할 다른 OCaml 기능들—특히 명명형 타입(레코드와 variant 포함)과 GADT—이 남았다. 이는 X 설계에 관한 다음 글에서 다루고자 한다.