일급 모듈
일급 모듈 (First-Class Modules)
일급 모듈(first-class module)을 쓰면 모듈을 값처럼 다룰 수 있어요. 모듈을 함수에 넘기고, 함수에서 돌려받고, 데이터 구조 안에 저장할 수도 있죠. 이건 어떤 경우엔 펑터(functor)의 대안이 되고, API를 더 단순하게 만들어 주기도 해요.
본문
선수 지식: 모듈(Modules), 펑터(Functors).
기본 예제
모듈을 값으로 묶는 방법(패킹)과 다시 푸는 방법(언패킹)을 먼저 볼게요.
# module type Printer = sig
val print : string -> unit
end;;
module type Printer = sig val print : string -> unit end
# module SimplePrinter = struct
let print s = print_endline ("Message: " ^ s)
end;;
module SimplePrinter : sig val print : string -> unit end
# (* Pack a module into a value *)
let printer = (module SimplePrinter : Printer);;
val printer : (module Printer) = <module>
# (* Unpack and use it *)
let module P = (val printer : Printer) in
P.print "Hello";;
Message: Hello
- : unit = ()
(module SimplePrinter : Printer) 표현식은 모듈을 (module Printer) 타입의 값으로 바꿔 줘요. 반대로 (val printer : Printer)를 쓰면 다시 모듈로 되돌릴 수 있죠. 타입이 추론될 수 있는 경우에는 타입 표기를 생략하는 경우가 많아요.
함수에 모듈 넘기기
모듈을 매개변수로 받는 함수를 쓸 수 있어요.
# let print_with (module P : Printer) message =
P.print message;;
val print_with : (module Printer) -> string -> unit = <fun>
# print_with printer "Works!";;
Message: Works!
- : unit = ()
(module P : Printer) 패턴은 일급 모듈을 자동으로 풀어 줘서, 함수 안에서는 P를 평범한 모듈처럼 쓸 수 있어요.
실전 예제: 여러 구현 선택하기
실행 시점(runtime)에 구현을 골라야 할 때 일급 모듈이 빛을 발해요.
# module type Database = sig
val name : string
val execute : string -> string list
end;;
module type Database =
sig val name : string val execute : string -> string list end
# let postgres_connect host db_name =
(module struct
let name = "PostgreSQL"
let execute query =
Printf.printf "[PostgreSQL] %s\n" query;
["result"]
end : Database);;
val postgres_connect : 'a -> 'b -> (module Database) = <fun>
# let mysql_connect host db_name =
(module struct
let name = "MySQL"
let execute query =
Printf.printf "[MySQL] %s\n" query;
["result"]
end : Database);;
val mysql_connect : 'a -> 'b -> (module Database) = <fun>
# let execute (module D : Database) query =
Printf.printf "Using %s\n" D.name;
D.execute query;;
val execute : (module Database) -> string -> string list = <fun>
이제 실행 시점에 데이터베이스를 고르고, 서로 다른 구현을 데이터 구조에 저장할 수 있어요.
# let db1 = postgres_connect "localhost" "mydb";;
val db1 : (module Database) = <module>
# let db2 = mysql_connect "localhost" "backup";;
val db2 : (module Database) = <module>
# execute db1 "SELECT * FROM users";;
Using PostgreSQL
[PostgreSQL] SELECT * FROM users
- : string list = ["result"]
# (* Store in a list *)
let all_dbs = [db1; db2];;
val all_dbs : (module Database) list = [<module>; <module>]
# List.iter (fun db -> ignore (execute db "SELECT 1")) all_dbs;;
Using PostgreSQL
[PostgreSQL] SELECT 1
Using MySQL
[MySQL] SELECT 1
- : unit = ()
실행 시점 선택
설정(configuration)에 따라 구현을 고르는 것도 흔한 패턴이에요.
# type config = { db_type : string; host : string };;
type config = { db_type : string; host : string; }
# let get_database config =
match config.db_type with
| "postgres" -> postgres_connect config.host "mydb"
| "mysql" -> mysql_connect config.host "mydb"
| _ -> failwith "Unknown database type";;
val get_database : config -> (module Database) = <fun>
# let config = { db_type = "mysql"; host = "localhost" };;
val config : config = {db_type = "mysql"; host = "localhost"}
# let db = get_database config;;
val db : (module Database) = <module>
# execute db "SELECT * FROM config";;
Using MySQL
[MySQL] SELECT * FROM config
- : string list = ["result"]
타입 제약 사용하기
일급 모듈에서 타입을 바깥으로 드러내야 할 때는 with type으로 타입 제약(constraint)을 걸어요.
# module type Comparable = sig
type t
val compare : t -> t -> int
end;;
module type Comparable = sig type t val compare : t -> t -> int end
# let int_comparable = (module struct
type t = int
let compare = Int.compare
end : Comparable with type t = int);;
val int_comparable : (module Comparable with type t = int) = <module>
# let sort (type a) (module C : Comparable with type t = a) list =
List.sort C.compare list;;
val sort : (module Comparable with type t = 'a) -> 'a list -> 'a list = <fun>
# sort int_comparable [3; 1; 4; 1; 5];;
- : int list = [1; 1; 3; 4; 5]
(type a) 문법은 지역 추상 타입(locally abstract type)을 만들어서, 모듈의 타입 t와 리스트 요소 타입을 서로 연결해 줘요.
일급 모듈을 써야 할 때
다음과 같은 경우에는 일급 모듈을 쓰는 게 좋아요.
- 서로 다른 모듈 구현을 같은 함수에 넘겨야 할 때
- 모듈을 데이터 구조(리스트, 해시 테이블)에 저장해야 할 때
- 설정에 따라 실행 시점에 구현을 골라야 할 때
- 플러그인 시스템을 만들 때
이런 경우에는 펑터를 쓰는 게 좋아요.
- 컴파일 시점에 비슷한 모듈을 많이 만들어야 할 때
- 성능을 최대화해야 할 때 (일급 모듈은 런타임 오버헤드가 조금 있어요)
- 의존성이 여러 개 얽힌 복잡한 모듈 관계
흔한 패턴
플러그인 레지스트리
# module type Plugin = sig
val name : string
val run : unit -> unit
end;;
module type Plugin = sig val name : string val run : unit -> unit end
# let plugins = ref [];;
val plugins : '_weak1 list ref = {contents = []}
# let register (module P : Plugin) =
plugins := (module P : Plugin) :: !plugins;
Printf.printf "Registered: %s\n" P.name;;
val register : (module Plugin) -> unit = <fun>
# register (module struct
let name = "Logger"
let run () = print_endline "Logging..."
end);;
Registered: Logger
- : unit = ()
# List.iter (fun (module P : Plugin) -> P.run ()) !plugins;;
Logging...
- : unit = ()
이질적 컬렉션
# module type Formatter = sig
val format : string -> string
end;;
module type Formatter = sig val format : string -> string end
# let formatters = [
("upper", (module struct let format = String.uppercase_ascii end : Formatter));
("lower", (module struct let format = String.lowercase_ascii end : Formatter));
];;
val formatters : (string * (module Formatter)) list =
[("upper", <module>); ("lower", <module>)]
# let apply name text =
match List.assoc_opt name formatters with
| Some (module F) -> F.format text
| None -> text;;
val apply : string -> string -> string = <fun>
# apply "upper" "hello";;
- : string = "HELLO"
핵심 포인트
(module M : ModuleType)로 모듈을 패킹해요.let module M = (val x : ModuleType) in ...로 언패킹해요.- 함수는 바로 패턴 매칭할 수 있어요:
fun (module M : ModuleType) -> ... - 추상 타입을 드러낼 때는
with type제약을 사용해요. - 일급 모듈은 객체 없이 런타임 다형성을 가능하게 해 줘요.
- 펑터에 비해 런타임 오버헤드가 작아요.
결론
일급 모듈 덕분에 실행 시점에 구현을 고르는 유연한 코드를 쓸 수 있어요. 특히 플러그인 시스템, 설정 기반 선택, 서로 다른 모듈 구현을 데이터 구조에 저장해야 할 때 유용하죠.
컴파일 시점에 모듈을 생성하거나 성능을 최대화해야 한다면, 대신 펑터를 사용하세요.