일급 모듈

일급 모듈 (First-Class Modules)

일급 모듈(first-class module)을 쓰면 모듈을 값처럼 다룰 수 있어요. 모듈을 함수에 넘기고, 함수에서 돌려받고, 데이터 구조 안에 저장할 수도 있죠. 이건 어떤 경우엔 펑터(functor)의 대안이 되고, API를 더 단순하게 만들어 주기도 해요.

출처: OCaml 공식 문서 — First-Class Modules

본문

선수 지식: 모듈(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 제약을 사용해요.
  • 일급 모듈은 객체 없이 런타임 다형성을 가능하게 해 줘요.
  • 펑터에 비해 런타임 오버헤드가 작아요.

결론

일급 모듈 덕분에 실행 시점에 구현을 고르는 유연한 코드를 쓸 수 있어요. 특히 플러그인 시스템, 설정 기반 선택, 서로 다른 모듈 구현을 데이터 구조에 저장해야 할 때 유용하죠.

컴파일 시점에 모듈을 생성하거나 성능을 최대화해야 한다면, 대신 펑터를 사용하세요.

더 알아보기