모듈
모듈 (Modules)
OCaml 소프트웨어를 조직하는 가장 기본적인 수단이 바로 모듈(module)이에요. 모듈은 정의(definitions)를 한데 묶어 놓은 것이죠. 서로 다른 관심사는 서로 다른 모듈로 분리하는 게 좋아요.
본문
이 튜토리얼에서는 모듈을 사용하고 정의하는 방법을 살펴볼게요.
참고: 이 튜토리얼에서 쓰는 예제 파일들은 Git 저장소에 있어요.
기본 사용
파일 기반 모듈
OCaml에서는 모든 코드가 어떤 모듈 안에 감싸여 있어요. 모듈 자체도 선택적으로 또 다른 모듈의 하위 모듈이 될 수 있는데, 마치 파일 시스템의 디렉토리처럼 동작하죠.
athens.ml과 berlin.ml 두 파일을 쓰는 프로그램을 볼게요. 각 파일은 각각 Athens와 Berlin이라는 모듈을 정의해요.
athens.ml 파일이에요:
let hello () = print_endline "Hello from Athens"
berlin.ml 파일이에요:
let () = Athens.hello ()
이들을 Dune으로 컴파일하려면 설정 파일이 최소 두 개 필요해요.
-
dune-project파일은 프로젝트 전체 설정을 담아요.(lang dune 3.7) -
dune파일은 실제 빌드 지시어를 담아요. 프로젝트는 빌드할 대상이 있는 디렉토리마다 하나씩 여러dune파일을 가질 수 있어요. 이 예제에서는 이 한 줄이면 충분해요.(executable (name berlin))
파일들을 만든 뒤 빌드하고 실행해 볼게요.
$ opam exec -- dune build
$ opam exec -- dune exec ./berlin.exe
Hello from Athens
참고: Dune은 빌드 산출물과 소스 사본을
_build디렉토리에 저장해요. 그 안은 절대 수정하면 안 돼요. OCaml 프로젝트는bin과lib디렉토리를 자주 쓰는데, Unix와 달리 여기엔 컴파일된 바이너리가 아니라 프로그램과 라이브러리의 소스 코드가 들어 있어요.
사실 opam exec -- dune build는 필수가 아니에요. opam exec -- dune exec ./berlin.exe를 실행하면 컴파일이 알아서 일어나죠. 참고로 opam exec -- dune exec 명령에서 ./berlin.exe는 파일 경로가 아니에요. 이 명령은 "./berlin.ml 파일의 내용을 실행하라"는 뜻이에요. 실제 실행 파일은 이름이 다르게 저장돼요.
프로젝트에서는 dune init project 명령으로 dune 설정 파일과 디렉토리 구조를 만드는 걸 권장해요. 이에 대한 자세한 내용은 Dune 문서를 참고하세요.
이름 짓기와 스코프
berlin.ml에서 Athens.hello를 써서 athens.ml의 hello를 참조했어요. 일반적으로 모듈의 무언가에 접근할 때는 모듈 이름(항상 대문자로 시작해요: Athens) 다음에 점을 찍고 사용할 대상(hello)을 쓰면 돼요. 값이 될 수도, 타입 생성자가 될 수도, 모듈이 제공하는 무엇이든 될 수 있어요.
모듈을 많이 쓴다면 open으로 그 모듈을 열 수 있어요. 그러면 모듈의 정의가 스코프 안으로 들어와요. 우리 예제에서 berlin.ml은 이렇게 쓸 수도 있었어요.
open Athens
let () = hello ()
open은 선택 사항이에요. 보통 List 같은 모듈은 열지 않아요. 그런 이름은 Array나 Option처럼 다른 모듈에서도 제공하니까요. 반면 Printf 같은 모듈은 printf처럼 충돌이 생기지 않는 이름을 제공해요. 파일 맨 위에 open Printf를 두면 Printf.printf를 반복해서 쓰지 않아도 돼요.
open Printf
let data = ["a"; "beautiful"; "day"]
let () = List.iter (printf "%s\n") data
표준 라이브러리는 Stdlib라는 모듈이에요. 여기 하위 모듈 List, Option, Either 등이 들어 있어요. 기본적으로 OCaml 컴파일러는 마치 모든 파일 맨 위에 open Stdlib을 쓴 것처럼 표준 라이브러리를 열어 줘요. 이걸 끄고 싶으면 Dune 문서를 참고하세요.
정의 안에서 let open ... in 구문으로 모듈을 열 수도 있어요.
# let list_sum_sq m =
let open List in
init m Fun.id |> map (fun i -> i * i) |> fold_left ( + ) 0;;
val list_sum_sq : int -> int = <fun>
모듈 접근 표기법은 전체 표현식에 적용할 수도 있어요.
# let array_sum_sq m =
Array.(init m Fun.id |> map (fun i -> i * i) |> fold_left ( + ) 0);;
val array_sum_sq : int -> int = <fun>
인터페이스와 구현
기본적으로 모듈 안에 정의된 것은 무엇이든 다른 모듈에서 접근할 수 있어요. 값, 함수, 타입, 하위 모듈까지 전부 public이죠. 바깥에서 볼 필요가 없는 정의를 숨기기 위해 이걸 제한할 수 있어요.
이를 위해 두 가지를 구분해야 해요.
- 모듈 안의 정의들 (모듈 구현, implementation)
- 모듈의 공개 선언들 (모듈 인터페이스, interface)
.ml 파일은 모듈 구현을 담고, .mli 파일은 모듈 인터페이스를 담아요. 기본적으로 대응하는 .mli 파일이 없으면 구현은 모든 것이 public인 기본 인터페이스를 가져요.
athens.ml 파일을 cairo.ml로 복사하고 내용을 바꿔 볼게요.
let message = "Hello from Cairo"
let hello () = print_endline message
지금 상태에서 Cairo는 다음과 같은 인터페이스를 가져요.
val message : string
val hello : unit -> unit
모듈 인터페이스를 명시적으로 정의하면 기본 인터페이스를 제한할 수 있어요. 인터페이스는 모듈 구현 위에 씌우는 마스크처럼 동작하죠. cairo.ml 파일은 Cairo의 구현을 정의해요. cairo.mli 파일을 추가하면 Cairo의 인터페이스가 정의돼요. 확장자를 뺀 파일명은 같아야 해요.
message를 private 정의로 만들려면 cairo.mli 파일에 그걸 적지 않으면 돼요.
val hello : unit -> unit
(** [hello ()] displays a greeting message. *)
참고: 맨 앞의 별 두 개(
**)는odoc같은 API 문서 도구를 위한 주석임을 나타내요..mli파일은 이 도구가 지원하는 형식으로 문서화하는 게 좋은 습관이에요.
delhi.ml 파일은 Cairo를 호출하는 프로그램을 정의해요.
let () = Cairo.hello ()
dune 파일을 이 예제가 앞선 것과 별도로 컴파일되도록 갱신할게요.
(executables (names berlin delhi))
두 프로그램을 컴파일하고 실행해 볼게요.
$ opam exec -- dune exec ./berlin.exe
Hello from Athens
$ opam exec -- dune exec ./delhi.exe
Hello from Cairo
Cairo.message가 public이 아니라는 걸 이렇게 확인할 수 있어요. 다음 내용을 담은 delhi.ml을 컴파일해 보면 돼요.
let () = print_endline Cairo.message
이건 컴파일 오류를 일으켜요.
추상 타입과 읽기 전용 타입
함수와 값 정의는 public 또는 private예요. 타입 정의에도 그게 적용되지만, 여기엔 경우가 두 가지 더 있어요.
exeter.mli와 exeter.ml 파일을 다음 내용으로 만들어 보세요.
인터페이스: exeter.mli
type aleph = Ada | Alan | Alonzo
type gimel
val gimel_of_bool : bool -> gimel
val gimel_flip : gimel -> gimel
val gimel_to_string : gimel -> string
type dalet = private Dennis of int | Donald of string | Dorothy
val dalet_of : (int, string) Either.t option -> dalet
구현: exeter.ml
type aleph = Ada | Alan | Alonzo
type bet = bool
type gimel = Christos | Christine
let gimel_of_bool b = if (b : bet) then Christos else Christine
let gimel_flip = function Christos -> Christine | Christine -> Christos
let gimel_to_string x = "Christ" ^ match x with Christos -> "os" | _ -> "ine"
type dalet = Dennis of int | Donald of string | Dorothy
let dalet_of = function
| None -> Dorothy
| Some (Either.Left x) -> Dennis x
| Some (Either.Right x) -> Donald x
dune 파일을 세 개의 대상, 즉 berlin·delhi 두 실행 파일과 exeter 라이브러리를 갖도록 갱신해요.
(executables (names berlin delhi) (modules athens berlin cairo delhi))
(library (name exeter) (modules exeter))
opam exec -- dune utop 명령을 실행해 볼게요. Exeter의 컴파일을 일으키고, utop을 실행한 뒤 Exeter를 로드해요.
# open Exeter;;
# #show aleph;;
type aleph = Ada | Alan | Alonzo
aleph 타입은 public이에요. 값을 만들거나 접근할 수 있죠.
# #show bet;;
Unknown element.
bet 타입은 private이에요. 정의된 구현(Exeter) 밖에서는 쓸 수 없어요.
# #show gimel;;
type gimel
# Christos;;
Error: Unbound constructor Christos
# #show_val gimel_of_bool;;
val gimel_of_bool : bool -> gimel
# true |> gimel_of_bool |> gimel_to_string;;
- : string = "Christos"
# true |> gimel_of_bool |> gimel_flip |> gimel_to_string;;
- : string = "Christine"
gimel 타입은 추상(abstract) 타입이에요. 값을 만들거나 다룰 수는 있지만, 오직 함수의 결과나 인자로만 다룰 수 있죠. 제공된 gimel_of_bool, gimel_flip, gimel_to_string 함수나 다형 함수만이 gimel 값을 받거나 돌려줄 수 있어요.
# #show dalet;;
type dalet = private Dennis of int | Donald of string | Dorothy
# Donald 42;;
Error: Cannot create values of the private type Exeter.dalet
# dalet_of (Some (Either.Left 10));;
- : dalet = Dennis 10
# let dalet_to_string = function
| Dorothy -> "Dorothy"
| Dennis _ -> "Dennis"
| Donald _ -> "Donald";;
val dalet_to_string : dalet -> string = <fun>
dalet 타입은 읽기 전용(read-only) 타입이에요. 패턴 매칭은 가능하지만, 값은 제공된 함수(여기서는 dalet_of)로만 만들 수 있어요.
추상 타입과 읽기 전용 타입은 이 섹션에서 본 변형(variant)일 수도 있고, 레코드나 별칭(alias)일 수도 있어요. 읽기 전용 레코드 필드의 값에는 접근할 수 있지만, 그런 레코드를 만들려면 제공된 함수를 써야 해요.
하위 모듈
하위 모듈 구현
모듈은 다른 모듈 안에 정의될 수 있어요. 그걸 하위 모듈(submodule) 이라고 해요. florence.ml과 glasgow.ml 파일을 볼게요.
florence.ml
module Hello = struct
let message = "Hello from Florence"
let print () = print_endline message
end
let print_goodbye () = print_endline "Goodbye"
glasgow.ml
let () =
Florence.Hello.print ();
Florence.print_goodbye ()
하위 모듈의 정의에는 모듈 이름을 체이닝해서 접근해요. 여기서는 Florence.Hello.print죠. 실행 파일을 하나 더한 갱신된 dune 파일이에요.
dune
(executables (names berlin delhi) (modules athens berlin cairo delhi))
(executable (name glasgow) (modules florence glasgow))
(library (name exeter) (modules exeter))
서명을 가진 하위 모듈
하위 모듈의 인터페이스를 정의하려면 모듈 서명(module signature) 을 제공하면 돼요. florence.ml 파일의 두 번째 버전에서 그렇게 해요.
module Hello : sig
val print : unit -> unit
end = struct
let message = "Hello"
let print () = print_endline message
end
let print_goodbye () = print_endline "Goodbye"
첫 버전은 Florence.Hello.message를 public으로 만들었어요. 이 버전에서는 glasgow.ml에서 접근할 수 없죠.
모듈 서명은 타입이다
모듈 서명이 구현에 대해 하는 역할은 타입이 값에 대해 하는 역할과 비슷해요. florence.ml 파일을 쓰는 세 번째 방법이에요.
module type HelloType = sig
val print : unit -> unit
end
module Hello : HelloType = struct
let message = "Hello"
let print () = print_endline message
end
let print_goodbye () = print_endline "Goodbye"
먼저 HelloType이라는 module type을 정의해요. 이건 앞선 것과 같은 모듈 인터페이스를 정의하죠. Hello 모듈을 정의할 때 서명을 직접 제공하는 대신 HelloType 모듈 타입을 사용해요.
이렇게 하면 여러 모듈이 공유하는 인터페이스를 쓸 수 있어요. 어떤 구현이든 그 내용의 일부를 나열하는 모듈 타입을 만족시켜요. 즉, 모듈은 여러 타입을 가질 수 있고 모듈 타입 사이에는 서브타입 관계가 있다는 뜻이에요.
모듈 다루기
모듈 인터페이스 표시하기
OCaml 톱레벨에서 기존 모듈(예: Unit)의 내용을 볼 수 있어요.
# #show Unit;;
module Unit :
sig
type t = unit = ()
val equal : t -> t -> bool
val compare : t -> t -> int
val to_string : t -> string
end
OCaml 컴파일러 도구 체인을 쓰면 .ml 파일의 기본 인터페이스를 덤프할 수 있어요.
$ ocamlc -i cairo.ml
val message : string
val hello : unit -> unit
같은 일을 하는 Anil Madhavapeddy의 ocaml-print-intf 도구도 쓸 수 있어요. opam install ocaml-print-intf로 설치하면 돼요. 두 가지 방법이 있어요.
.cmi파일(컴파일된 ML 인터페이스)에 적용:ocaml-print-intf cairo.cmi- Dune으로 적용:
dune exec -- ocaml-print-intf cairo.ml
Dune을 쓴다면 .cmi 파일은 _build 디렉토리에 있어요. 아니라면 수동으로 컴파일해서 만들 수 있죠. ocamlc -c cairo.ml 명령은 cairo.cmo(실행 가능한 바이트코드)와 cairo.cmi(컴파일된 인터페이스)를 만들어요. Dune 없이 컴파일하는 자세한 내용은 Compiling OCaml Projects를 참고하세요.
모듈 포함(Include)
List 모듈에 함수가 하나 빠져 있는 것 같은데, 정말로 그 모듈의 일부인 것처럼 쓰고 싶다고 해 볼게요. extlib.ml 파일에서 include 지시어를 쓰면 이 효과를 낼 수 있어요.
module List = struct
include Stdlib.List
let uncons = function
| [] -> None
| hd :: tl -> Some (hd, tl)
end
이건 표준 List 모듈이 가진 모든 것에 새 uncons 함수가 더해진 Extlib.List 모듈을 만들어요. 다른 .ml 파일에서 기본 List 모듈을 덮어쓰려면 맨 앞에 open Extlib을 추가해야 해요.
상태를 가진 모듈
모듈은 내부 상태를 가질 수 있어요. 표준 라이브러리의 Random 모듈이 그런 경우죠. Random.get_state와 Random.set_state 함수는 이름이 없고 추상 타입인 내부 상태에 대한 읽기·쓰기 접근을 제공해요.
# let s = Random.get_state ();;
val s : Random.State.t = <abstr>
# Random.bits ();;
- : int = 89809344
# Random.bits ();;
- : int = 994326685
# Random.set_state s;;
- : unit = ()
# Random.bits ();;
- : int = 89809344
이 코드를 실행할 때마다 Random.bits가 돌려주는 값은 달라져요. 첫 번째와 세 번째 호출이 같은 결과를 돌려주는 건 내부 상태가 리셋됐기 때문이에요.
결론
OCaml에서 모듈은 소프트웨어를 조직하는 가장 기본적인 수단이에요. 요약하자면, 모듈은 이름 아래에 묶인 정의들의 모임이에요. 이 정의들은 하위 모듈이 될 수 있어서, 모듈의 계층 구조를 만들 수 있죠. 최상위 모듈은 반드시 파일이어야 하고, 컴파일의 단위가 돼요. 모든 모듈은 인터페이스를 가지는데, 이건 모듈이 노출하는 정의들의 목록이에요. 기본적으로 모듈의 인터페이스는 모든 정의를 노출하지만, 인터페이스 문법을 쓰면 이를 제한할 수 있어요.
더 나아가 OCaml 소프트웨어 컴포넌트를 다루는 다른 수단들은 이래요.
- 펑터(Functors): 모듈에서 모듈로 가는 함수처럼 동작해요.
- 라이브러리: 함께 묶여 컴파일된 모듈들이에요.
- 패키지: 설치와 배포의 단위예요.