OCaml의 다형 변이형을 소개합니다. 기본 사용법부터 고급 타입 추론, 강제 변환(coercion), 패턴 약어(#type) 활용, 그리고 코어 변이형과 비교한 성능·정적 검증상의 차이와 주의점까지 다룹니다.
☰OCaml 소개
섹션 1.4에서 소개한 변이형(variant)은 자료구조와 알고리즘을 만드는 강력한 도구입니다. 하지만 모듈식 프로그래밍에서 사용할 때 유연성이 부족할 때가 있습니다. 이는 각 생성자(constructor)가 정의·사용 시점에 고유한 하나의 타입에만 귀속되기 때문입니다. 여러 타입 정의에 같은 이름이 나타나더라도, 그 생성자 자체는 단 하나의 타입에만 속합니다. 따라서 특정 생성자가 여러 타입에 동시에 속하도록 하거나, 어떤 타입의 값을 더 많은 생성자를 가진 다른 타입에 속한다고 간주할 수는 없습니다.
다형 변이형(polymorphic variants)에서는 이러한 제약이 사라집니다. 즉, 변이 태그(variant tag)는 특정 타입에 속하지 않으며, 타입 시스템은 사용 맥락에 따라 해당 값이 허용되는지만 검사합니다. 변이 태그를 사용하기 전에 반드시 타입을 정의할 필요가 없습니다. 변이 타입은 각 사용 위치에서 독립적으로 추론됩니다.
프로그램에서 다형 변이형은 보통의 변이형처럼 동작합니다. 단지 이름 앞에 백틱(`)을 붙이면 됩니다.
ocaml# [`On; `Off];; - : [> `Off | `On ] list = [`On; `Off] # `Number 1;; - : [> `Number of int ] = `Number 1 # let f = function `On -> 1 | `Off -> 0 | `Number n -> n;; val f : [< `Number of int | `Off | `On ] -> int = <fun> # List.map f [`On; `Off];; - : int list = [1; 0]
[>Off|On] list는 이 리스트를 매칭하려면 적어도 인자 없는 Off와 On을 매칭할 수 있어야 함을 의미합니다. [<On|Off|Number of int]는 함수 f가 인자 없는 Off, On 또는 정수 n을 담은 Number n에 적용될 수 있음을 뜻합니다. 변이 타입 안의 >와 <는 이 타입이 더 많은 태그를 허용하거나 더 적게 허용하는 쪽으로 여전히 정제(refine)될 수 있음을 나타냅니다. 따라서 이러한 타입들은 암묵적인 타입 변수를 내포합니다. 각 변이 타입이 전체 타입식에서 한 번만 등장하는 경우에는 이러한 암묵적 타입 변수가 표시되지 않습니다.
위의 변이 타입들은 더 정제될 수 있는 다형 타입이었습니다. 타입 주석을 작성할 때는 대개 정제 불가능한, 즉 고정된 변이 타입을 기술하게 됩니다. 타입 약어(별칭)에서도 마찬가지입니다. 이런 타입들은 <나 > 없이, 일반 데이터타입 정의처럼 태그와 그에 연관된 타입들의 나열만을 포함합니다.
ocaml# type 'a vlist = [`Nil | `Cons of 'a * 'a vlist];; type 'a vlist = [ `Cons of 'a * 'a vlist | `Nil ] # let rec map f : 'a vlist -> 'b vlist = function | `Nil -> `Nil | `Cons(a, l) -> `Cons(f a, map f l) ;; val map : ('a -> 'b) -> 'a vlist -> 'b vlist = <fun>
다형 변이형의 타입 검사는 미묘하며, 어떤 식은 더 복잡한 타입 정보를 낳을 수 있습니다.
ocaml# let f = function `A -> `C | `B -> `D | x -> x;; val f : ([> `A | `B | `C | `D ] as 'a) -> 'a = <fun> # f `E;; - : [> `A | `B | `C | `D | `E ] = `E
여기서는 두 가지 현상을 볼 수 있습니다. 첫째, 매칭이 닫혀 있지 않고(마지막 케이스가 모든 태그를 잡아냄) 열려 있으므로, 닫힌 매칭에서의 [< A | B]가 아니라 [> A | B]를 얻게 됩니다. 둘째, x를 그대로 반환하므로 입력 타입과 반환 타입이 동일합니다. as 'a 표기는 이러한 타입 공유를 나타냅니다. f를 또 다른 태그 `E에 적용하면, 그 태그가 목록에 추가됩니다.
ocaml# let f1 = function `A x -> x = 1 | `B -> true | `C -> false let f2 = function `A x -> x = "a" | `B -> true ;; val f1 : [< `A of int | `B | `C ] -> bool = <fun> val f2 : [< `A of string | `B ] -> bool = <fun> # let f x = f1 x && f2 x;; val f : [< `A of string & int | `B ] -> bool = <fun>
여기서 f1과 f2는 모두 A와 B를 받아들이지만, A의 인자 타입은 f1에서는 int, f2에서는 string입니다. f의 타입에서 f1에만 허용되던 C는 사라지고, A의 인자 타입은 int & string으로 둘 다 나타납니다. 이는 f에 A를 넘길 때 그 인자가 int이면서 동시에 string이어야 함을 의미합니다. 그런 값은 존재하지 않으므로 f는 A에 적용될 수 없고, B만이 허용되는 입력입니다.
값이 고정된 변이 타입을 갖더라도, 강제 변환(coercion)을 통해 더 큰 타입을 부여할 수 있습니다. 보통 강제 변환은 원천 타입과 목표 타입을 함께 적지만, 단순한 경우에는 원천 타입을 생략할 수 있습니다.
ocaml# type 'a wlist = [`Nil | `Cons of 'a * 'a wlist | `Snoc of 'a wlist * 'a];; type 'a wlist = [ `Cons of 'a * 'a wlist | `Nil | `Snoc of 'a wlist * 'a ] # let wlist_of_vlist l = (l : 'a vlist :> 'a wlist);; val wlist_of_vlist : 'a vlist -> 'a wlist = <fun> # let open_vlist l = (l : 'a vlist :> [> 'a vlist]);; val open_vlist : 'a vlist -> [> 'a vlist ] = <fun> # fun x -> (x :> [`A|`B|`C]);; - : [< `A | `B | `C ] -> [ `A | `B | `C ] = <fun>
패턴 매칭을 통해 선택적으로 값을 강제 변환할 수도 있습니다.
ocaml# let split_cases = function | `Nil | `Cons _ as x -> `A x | `Snoc _ as x -> `B x ;; val split_cases : [< `Cons of 'a | `Nil | `Snoc of 'b ] -> [> `A of [> `Cons of 'a | `Nil ] | `B of [> `Snoc of 'b ] ] = <fun>
변이 태그로만 이루어진 or-패턴이 별칭 패턴(alias-pattern) 안에 감싸여 있으면, 그 별칭에는 or-패턴에 나열된 태그만을 포함하는 타입이 부여됩니다. 이는 점진적으로 함수를 정의하는 등 많은 유용한 관용구를 가능하게 합니다.
ocaml# let num x = `Num x let eval1 eval (`Num x) = x let rec eval x = eval1 eval x ;; val num : 'a -> [> `Num of 'a ] = <fun> val eval1 : 'a -> [< `Num of 'b ] -> 'b = <fun> val eval : [< `Num of 'a ] -> 'a = <fun> # let plus x y = `Plus(x,y) let eval2 eval = function | `Plus(x,y) -> eval x + eval y | `Num _ as x -> eval1 eval x let rec eval x = eval2 eval x ;; val plus : 'a -> 'b -> [> `Plus of 'a * 'b ] = <fun> val eval2 : ('a -> int) -> [< `Num of int | `Plus of 'a * 'a ] -> int = <fun> val eval : ([< `Num of int | `Plus of 'a * 'a ] as 'a) -> int = <fun>
이를 더욱 편하게 하기 위해, 타입 정의를 or-패턴의 약어로 사용할 수 있습니다. 예를 들어, type myvariant = [Tag1 of int | Tag2 of bool]를 정의했다면, 패턴 #myvariant는 (Tag1(_ : int) | Tag2(_ : bool))을 쓴 것과 동일합니다.
이러한 약어는 단독으로 사용할 수도 있고,
ocaml# let f = function | #myvariant -> "myvariant" | `Tag3 -> "Tag3";; val f : [< `Tag1 of int | `Tag2 of bool | `Tag3 ] -> string = <fun>
별칭과 함께 조합해서 사용할 수도 있습니다.
ocaml# let g1 = function `Tag1 _ -> "Tag1" | `Tag2 _ -> "Tag2";; val g1 : [< `Tag1 of 'a | `Tag2 of 'b ] -> string = <fun> # let g = function | #myvariant as x -> g1 x | `Tag3 -> "Tag3";; val g : [< `Tag1 of int | `Tag2 of bool | `Tag3 ] -> string = <fun>
다형 변이형의 강력함을 본 뒤에는, 왜 코어 언어의 변이형을 대체하지 않고 여기에 추가되었는지 궁금할 수 있습니다.
그 이유는 두 가지입니다. 첫째, 다형 변이형은 상당히 효율적이긴 하지만, 정적 타입 정보가 부족해 최적화 여지가 줄어들고 코어 변이형에 비해 약간 더 무거워집니다. 다만 유의미한 차이는 아주 큰 자료구조에서만 드러날 것입니다.
더 중요한 점은 다형 변이형이 타입 안전(type-safe)하긴 하지만, 더 약한 타입 규율을 초래한다는 것입니다. 즉, 코어 언어의 변이형은 단순히 타입 안전을 보장하는 것을 넘어서, 선언된 생성자만 사용했는지 확인하고, 하나의 자료구조에 존재하는 모든 생성자가 서로 양립 가능한지 검사하며, 생성자 매개변수에 대한 타이핑 제약을 강제합니다.
이런 이유로 다형 변이형을 사용할 때는 타입을 더 명시적으로 만드는 데 주의를 기울여야 합니다. 라이브러리를 작성할 때는 인터페이스에 정확한 타입을 기재할 수 있어 어렵지 않지만, 간단한 프로그램이라면 코어 언어의 변이형을 사용하는 편이 아마 더 낫습니다.
또한 어떤 관용구는 사소한 오류를 매우 찾기 어렵게 만들 수 있음을 조심해야 합니다. 예를 들어, 아래 코드는 아마도 잘못되었지만 컴파일러는 이를 알아차릴 방법이 없습니다.
ocaml# type abc = [`A | `B | `C] ;; type abc = [ `A | `B | `C ] # let f = function | `As -> "A" | #abc -> "other" ;; val f : [< `A | `As | `B | `C ] -> string = <fun> # let f : abc -> string = f ;; val f : abc -> string = <fun>
정의 자체에 주석을 달아 이런 위험을 피할 수 있습니다.
ocaml# let f : abc -> string = function | `As -> "A" | #abc -> "other" ;; Error: This pattern matches values of type [? `As ] but a pattern was expected which matches values of type abc The second variant type does not allow tag(s) `As
저작권 © 2025 Institut National de Recherche en Informatique et en Automatique