펑터
펑터 (Functors)
이름에서 짐작할 수 있듯, 펑터(functor) 는 거의 함수와 비슷해요. 다만 함수의 입력과 출력이 값이라면, 펑터는 모듈과 모듈 사이에서 동작하죠. 펑터는 모듈을 매개변수로 받고 결과로 모듈을 돌려줘요. OCaml에서 펑터는 매개변수화된 모듈(parammetrised module)이에요. 수학의 펑터와 혼동하면 안 돼요.
본문
이 튜토리얼에서는 펑터를 적용하는 방법과 직접 작성하는 방법을 살펴볼게요. 펑터와 관련된 몇 가지 사용 사례도 보여드릴게요.
참고: 이 튜토리얼을 설명하는 파일들은 Git 저장소에 있어요.
프로젝트 설정
이 튜토리얼은 Dune 빌드 도구를 사용해요. 버전 3.7 이상이 설치되어 있어야 해요. 새 프로젝트부터 만들어 볼게요. funkt라는 디렉토리가 필요하고, 그 안에 dune-project, dune, funkt.ml 파일이 있어야 해요.
mkdir funkt; cd funkt
dune-project 파일에 다음을 넣어요.
(lang dune 3.7)
(package (name funkt))
dune 파일의 내용은 이래야 해요.
(executable
(name funkt)
(public_name funkt)
(libraries str))
빈 파일 funkt.ml을 만들어요.
opam exec -- dune exec funkt 명령으로 잘 동작하는지 확인해 볼게요. 아무것도 하지 않아야 하지만(빈 파일은 유효한 OCaml 문법이에요), 실패해서도 안 돼요. libraries str 스탠자는 Str 모듈(나중에 쓸 거예요)을 사용할 수 있게 해 줘요.
기존 펑터 사용하기: Set.Make
표준 라이브러리에는 집합을 다루기 위한 Set 모듈이 있어요. 이 모듈로 집합에 대한 합집합, 교집합, 차집합 같은 연산을 할 수 있어요. Set 튜토리얼에서 이 모듈에 대해 더 배울 수 있지만, 이 튜토리얼을 따라가는 데 꼭 필요한 건 아니에요.
주어진 요소 타입에 대한 집합 모듈(제공된 타입과 그와 연결된 함수들을 쓸 수 있게 해 주는)을 만들려면 Set 모듈이 제공하는 펑터 Set.Make를 써야 해요. 여기 Set의 인터페이스를 단순화한 버전이 있어요.
module type OrderedType = sig
type t
val compare : t -> t -> int
end
module Make : functor (Ord : OrderedType) -> Set.S
이걸 어떻게 읽으면 될까요 (아래에서 위로 읽어요):
- 함수처럼(화살표
->로 표시), 펑터Set.Make는OrderedType시그니처를 가진 모듈을 받고Set.S시그니처를 가진 모듈을 돌려줘요.
OrderedType모듈 타입은 타입t와 함수compare를 요구해요. 이 둘은 집합의 요소를 비교하는 데 쓰여요.
참고: 대부분의 집합 연산은 요소가 같은지 확인하기 위해 요소를 비교해야 해요. 사용자가 정의한 비교 알고리즘을 쓸 수 있도록,
Set.Make펑터는 요소 타입t와compare함수를 모두 명시하는 모듈을 받아요.Array.sort에서처럼 비교 함수를 고차 매개변수로 넘기면 보일러플레이트 코드가 엄청 늘어나요. 집합 연산을 펑터로 제공하면 비교 함수를 한 번만 명시하면 되죠.
Set.Make를 사용하는 예제예요.
funkt.ml
module StringCompare = struct
type t = string
let compare = String.compare
end
module StringSet = Set.Make(StringCompare)
이건 Funkt.StringSet 모듈을 정의해요. Set.Make가 필요로 하는 건:
- 타입
t, 여기선string - 타입
t의 두 값을 비교하는 함수, 여기선String.compare
참고: 매개변수 모듈에 타입
t가 정의되어 있어야 해요. 여기선StringCompare죠. 이 예제처럼t는 대개 다른 타입의 별칭이며, 여기선string이에요. 그 다른 타입도t라고 불린다면 컴파일러는 “The type abbreviation t is cyclic” 오류를 일으켜요. 이건 타입 정의에nonrec키워드를 추가해서 우회할 수 있어요.
type nonrec t = t
익명 모듈(anonymous module) 표현식으로 더 단순하게 할 수 있어요.
module StringSet = Set.Make(struct
type t = string
let compare = String.compare
end)
struct ... end 모듈 표현식이 Set.Make 호출에 인라인됐어요.
그런데 String 모듈이 이미 다음과 같은 걸 정의하니까,
- 타입 이름
t(이건string의 별칭이에요) - 타입
t -> t -> int의 함수compare(두 문자열을 비교해요)
이렇게 더 단순하게 줄일 수 있어요.
module StringSet = Set.Make(String)
어느 버전이든, 펑터 적용 Set.Make에서 나온 모듈은 StringSet이라는 이름에 바인딩되고 Set.S 시그니처를 가져요. StringSet 모듈은 문자열 집합에 대한 연산을 제공하고, 내부적으로 String.compare 함수를 사용해요.
opam exec -- dune exec funkt를 실행하면 아무것도 하지 않지만 실패해서도 안 돼요.
funkt.ml에 무언가 하는 코드를 좀 추가해 볼게요.
funkt.ml
module StringSet = Set.Make(String)
let _ =
In_channel.input_lines stdin
|> List.concat_map Str.(split (regexp "[ \t.,;:()]+"))
|> StringSet.of_list
|> StringSet.iter print_endline
이 코드가 어떻게 동작하는지 볼게요.
In_channel.input_lines: 표준 입력에서 텍스트 줄을 읽어요.List.concat_map: 줄을 단어로 나누고 단어 리스트를 만들어요.StringSet.of_list : string list -> StringSet.t: 단어 리스트를 집합으로 변환해요.StringSet.iter : StringSet.t -> unit: 집합의 요소를 표시해요.
StringSet.of_list와 StringSet.iter 함수는 펑터 적용 결과에서 쓸 수 있어요.
$ opam exec -- dune exec funkt < dune
executable
libraries
name
public_name
str
funkt
Set에는 중복이 없어요. 그래서 "funkt" 문자열은 dune 파일에 두 번 나오는데도 한 번만 표시돼요.
표준 라이브러리 펑터로 모듈 확장하기
include 문을 쓰면 Set.Make(String)이 만든 모듈을 노출하는 다른 방법이 있어요.
funkt.ml
module String = struct
include String
module Set = Set.Make(String)
end
let _ =
stdin
|> In_channel.input_lines
|> List.concat_map Str.(split (regexp "[ \t.,;:()]+"))
|> String.Set.of_list
|> String.Set.iter print_endline
이렇게 하면 사용자가 String 모듈에 하위 모듈 Set을 덧붙여 확장한 것처럼 보이게 할 수 있어요. opam exec -- dune exec funkt < dune로 동작을 확인해 보세요.
펑터로 모듈 매개변수화하기
표준 라이브러리의 펑터
펑터는 거의 모듈에 가까운데, 다만 모듈에 적용되어야 한다는 차이가 있어요. 그래야 모듈이 되죠. 그런 의미에서 펑터는 모듈 매개변수화를 가능하게 해 줘요.
표준 라이브러리가 제공하는 집합, 맵, 해시 테이블이 그런 경우예요. 펑터와 개발자 사이의 계약(contract)처럼 동작하죠.
- 매개변수 인터페이스가 기대하는 것을 구현하는 모듈을 제공하면,
- 결과 인터페이스가 약속하는 것을 구현하는 모듈을 펑터가 돌려줘요.
Set.Make와 Map.Make 펑터가 기대하는 모듈의 시그니처예요.
module type OrderedType = sig
type t
val compare : t -> t -> int
end
Hashtbl.Make 펑터가 기대하는 모듈의 시그니처예요.
module type HashedType = sig
type t
val equal : t -> t -> bool
val hash : t -> int
end
Set.Make, Map.Make, Hashtbl.Make 펑터는 각각 Set.S, Map.S, Hashtbl.S 인터페이스를 만족하는 모듈을 돌려줘요. 이 인터페이스들은 모두 추상 타입 t와 관련 함수를 담고 있어요. 무엇을 제공하는지 자세한 내용은 문서를 참고하세요.
나만의 펑터 작성하기
펑터를 작성하는 이유 중 하나는 매개변수화된 데이터 구조를 제공하기 위해서예요. Set과 Map이 그런 것처럼 다른 데이터 구조에도 그렇게 할 수 있죠. 이 섹션에서는 힙(heap)을 예로 들게요.
힙 데이터 구조에는 여러 종류가 있어요. 이진 힙, 좌편향 힙(leftist heap), 이항 힙(binomial heap), 피보나치 힙 등이 예시죠.
힙을 구현할 때 쓰는 데이터 구조와 알고리즘의 종류는 이 문서에서 다루지 않아요.
어떤 힙이든 구현하는 데 공통으로 필요한 선수 조건은, 담는 요소들을 비교할 수단이에요. 그건 Set.Make와 Map.Make의 매개변수와 같은 시그니처예요.
module type OrderedType = sig
type t
val compare : t -> t -> int
end
이런 매개변수를 쓰면, 힙 구현은 최소한 이 인터페이스를 제공해야 해요.
module type HeapType = sig
type elt
type t
val empty : t
val is_empty : t -> bool
val insert : t -> elt -> t
val merge : t -> t -> t
val find : t -> elt
val delete : t -> t
end
힙 구현은 OrderedType에서 HeapType으로 가는 펑터로 표현할 수 있어요. 힙 종류마다 서로 다른 펑터가 되는 거죠.
가능한 구현의 뼈대가 여기 있어요.
heap.ml
module type OrderedType = sig
type t
val compare : t -> t -> int
end
module type S = sig
type elt
type t
val empty : t
val is_empty : t -> bool
val insert : t -> elt -> t
val merge : t -> t -> t
val find : t -> elt
val delete : t -> t
end
module Binary(Elt: OrderedType) : S = struct
type elt (* Add your own type definition *)
type t (* Add your own type definition *)
(* Add private functions here *)
let empty = failwith "Not yet implemented"
let is_empty h = failwith "Not yet implemented"
let insert h e = failwith "Not yet implemented"
let merge h1 h2 = failwith "Not yet implemented"
let find h = failwith "Not yet implemented"
let delete h = failwith "Not yet implemented"
end
여기선 이진 힙만 제안된 구현이에요. Heap.Leftist, Heap.Binomial, Heap.Fibonacci처럼 종류마다 펑터를 하나씩 추가하면 다른 구현으로 확장할 수 있어요.
펑터로 의존성 주입하기
모듈 사이의 의존성
funkt 프로그램의 새 버전이에요.
funkt.ml
module StringSet = Set.Make(String)
module IterPrint : sig
val f : string list -> unit
end = struct
let f = List.iter (fun s -> Out_channel.output_string stdout (s ^ "\n"))
end
let _ =
stdin
|> In_channel.input_lines
|> List.concat_map Str.(split (regexp "[ \t.,;:()]+"))
|> StringSet.of_list
|> StringSet.elements
|> IterPrint.f
이건 string list -> unit 타입의 함수 f 하나만 노출하는 추가 IterPrint 모듈을 담고 있어요. 그리고 의존성이 두 개 있죠.
List.iter과f의 타입을 통한 모듈ListOut_channel.output_string을 통한 모듈Out_channel
opam exec -- dune exec funkt < dune로 프로그램의 동작을 확인해 보세요.
의존성 주입(Dependency Injection)
의존성 주입은 어떤 의존성 위에서 매개변수화하는 방법이에요.
IterPrint 모듈을 의존성 주입을 쓰도록 리팩터링한 버전이에요.
iterPrint.ml
module type Iterable = sig
type 'a t
val iter : ('a -> unit) -> 'a t -> unit
end
module type S = sig
type 'a t
val f : string t -> unit
end
module Make(Dep: Iterable) : S with type 'a t := 'a Dep.t = struct
let f = Dep.iter (fun s -> Out_channel.output_string stdout (s ^ "\n"))
end
IterPrint 모듈은 iter 함수를 제공하는 모듈을 매개변수로 받는 펑터로 리팩터링됐어요. with type 'a t := 'a Dep.t는 "파괴적 타입 치환(destructive type substitution)"이라고 불려요. 매개변수 Dep의 타입 t가 결과 모듈의 타입 t를 대체한다는 제약이죠. 덕분에 f의 타입이 Dep의 t 타입을 쓸 수 있어요. 이 리팩터링으로 IterPrint는 의존성이 하나뿐이에요. 이걸 컴파일하는 시점에는 아직 iter 함수의 구현이 없죠.
참고: OCaml 파일(
.ml)은 모듈을 정의하지 펑터를 정의하지 않아요. 펑터는 반드시 모듈 안에 들어 있어야 해요. 펑터 이름은Make로 짓는 게 관례예요. 그래야module IntSet = Set.Make(Int)처럼 쓸 수 있죠.
funkt.ml
module StringSet = Set.Make(String)
module IterPrint = IterPrint.Make(List)
let _ =
stdin
|> In_channel.input_lines
|> List.concat_map Str.(split (regexp "[ \t.,;:()]+"))
|> StringSet.of_list
|> StringSet.elements
|> IterPrint.f
의존성 List는 Funkt 모듈을 컴파일할 때 주입(injected) 돼요. IterPrint를 쓰는 코드는 변하지 않았죠. opam exec -- dune exec funkt < dune로 프로그램의 동작을 확인해 보세요.
의존성 교체하기
이제 IterListPrint 안의 iter 구현을 교체하는 건 더 이상 리팩터링이 아니에요. 다른 의존성과 함께 펑터를 적용하는 것일 뿐이죠. 여기서 Array가 List를 대체해요.
funkt.ml
module StringSet = Set.Make(String)
module IterPrint = IterPrint.Make(Array)
let _ =
stdin
|> In_channel.input_lines
|> List.concat_map Str.(split (regexp "[ \t.,;:()]+"))
|> StringSet.of_list
|> StringSet.elements
|> Array.of_list
|> IterPrint.f
opam exec -- dune exec funkt < dune로 프로그램의 동작을 확인해 보세요.
참고:
IterPrint.Make가 받아서 돌려주는 모듈은 둘 다 타입t를 가져요.with type ... := ...제약은 두 타입t가 같다는 것을 드러내 줘요. 이렇게 주입된 의존성과 결과 모듈의 함수가 정확히 같은 타입을 쓰게 해 주죠. 매개변수의 포함 타입이 결과 모듈에 드러나지 않을 때(즉 *구현 세부사항(implementation detail)*일 때)는with type제약이 필요 없어요.
이름 짓기와 스코프
with type 제약은 펑터의 매개변수 모듈과 결과 모듈 안의 타입들을 통일해요. 우리는 앞 섹션에서 그걸 썼어요. 이 섹션에서는 이 제약의 이름 짓기와 스코프 메커니즘을 다루게요.
단순하게 생각하면 Iter.Make를 이렇게 정의할 수도 있었어요.
module Make(Dep: Iterable) : S = struct
type 'a t = 'a Dep.t
let f = Dep.iter (fun s -> Out_channel.output_string stdout (s ^ "\n"))
end
함수 f를 쓰지 않으면 프로젝트는 오류 없이 컴파일돼요.
하지만 Make가 funkt.ml에서 IterPrint 모듈을 만드는 데 사용되므로, 프로젝트는 다음 오류 메시지와 함께 컴파일에 실패해요.
5 | ..stdin
6 | |> In_channel.input_lines
7 | |> List.concat_map Str.(split (regexp "[ \t.,;:()]+"))
8 | |> StringSet.of_list
9 | |> StringSet.elements
Error: This expression has type string list
but an expression was expected of type string IterPrint.t
펑터 밖에서는 type 'a t가 Dep.t로 설정됐다는 걸 알 수 없어요. funkt.ml에서 IterPrint.t는 Make 결과가 노출하는 추상 타입으로 보이죠. 그래서 with type 제약이 필요한 거예요. IterPrint.t가 Dep.t(이 경우 List.t)와 같은 타입이라는 사실을 전파해 주거든요.
with type으로 제약된 타입은 펑터 본문 안의 정의에 의해 가려지지 않아요. 예제에서 Make 펑터는 이렇게 재정의할 수 있어요.
module Make(Dep: Iterable) : S with type 'a t := 'a Dep.t = struct
type 'a t = LocalType
let g LocalType = "LocalType"
let f = Dep.iter(fun s ->
Out_channel.output_string stdout (g LocalType ^ "\n");
Out_channel.output_string stdout (s ^ "\n"))
end
위 예제에서 with type의 t는 지역 t보다 우선하며, 지역 t는 지역 스코프만 가져요.
경고: 이름을 너무 자주 가리면(shadow) 코드를 이해하기 어려워지니 주의하세요.
확장 펑터 작성하기
이 섹션에서 우리는 여러 모듈을 같은 방식으로 확장하는 펑터를 정의해요. 표준 라이브러리 펑터로 모듈 확장하기에서 본 것과 같은 아이디어인데, 이번엔 펑터를 직접 쓴다는 차이가 있어요.
이 예제에서 List와 Array 모듈을 함수 scan_left로 확장해요. 이 함수는 fold_left과 거의 똑같은데, 마지막 값 하나만 돌려주는 fold_left와 달리 중간 값들을 모두 돌려줘요.
새 디렉토리를 만들고 다음 파일들을 만들어요.
dune-project
(lang dune 3.7)
dune
(library (name scanLeft))
scanLeft.ml
module type LeftFoldable = sig
type 'a t
val fold_left : ('b -> 'a -> 'b) -> 'b -> 'a t -> 'b
val of_list : 'a list -> 'a t
end
module type S = sig
type 'a t
val scan_left : ('b -> 'a -> 'b) -> 'b -> 'a t -> 'b t
end
module Make(F: LeftFoldable) : S with type 'a t := 'a F.t = struct
let scan_left f b u =
let f (b, u) a =
let b' = f b a in
(b', b' :: u) in
u |> F.fold_left f (b, []) |> snd |> List.rev |> F.of_list
end
dune utop 명령을 실행해요. 톱레벨에 들어가면 다음 명령을 입력하세요.
# module Array = struct
include Stdlib.Array
include ScanLeft.Make(Stdlib.Array)
end;;
# module List = struct
include List
include ScanLeft.Make(struct
include List
let of_list = Fun.id
end)
end;;
# Array.init 10 Fun.id |> Array.scan_left ( + ) 0;;
- : int array = [|0; 1; 3; 6; 10; 15; 21; 28; 36; 45|]
# List.init 10 Fun.id |> List.scan_left ( + ) 0;;
- : int list = [0; 1; 3; 6; 10; 15; 21; 28; 36; 45]
모듈 Array와 List는 Array.scan_left과 List.scan_left이 붙어서 확장된 것처럼 보여요. 간결함을 위해 처음 두 톱레벨 명령의 출력은 여기 보여주지 않을게요.
상태를 가진 모듈의 초기화
모듈은 상태를 담을 수 있어요. 펑터는 상태를 가진 모듈을 초기화하는 수단을 제공할 수 있죠. 그 예로, 난수 생성 시드를 상태로 다루는 방법이 있어요.
random.ml
module type SeedType = sig
val v : int array
end
module type S = sig
val reset_state : unit -> unit
val bits : unit -> int
val bits32 : unit -> int32
val bits64 : unit -> int64
val nativebits : unit -> nativeint
val int : int -> int
val int32 : int32 -> int32
val int64 : int64 -> int64
val nativeint : nativeint -> nativeint
val full_int : int -> int
val float : float -> float
val bool : unit -> bool
end
module Make(Seed: SeedType) : S = struct
let state = Seed.v |> Random.State.make |> ref
let reset_state () = state := Random.State.make Seed.v
let bits () = Random.State.bits !state
let bits32 () = Random.State.bits32 !state
let bits64 () = Random.State.bits64 !state
let nativebits () = Random.State.nativebits !state
let int = Random.State.int !state
let int32 = Random.State.int32 !state
let int64 = Random.State.int64 !state
let nativeint = Random.State.nativeint !state
let full_int = Random.State.full_int !state
let float = Random.State.float !state
let bool () = Random.State.bool !state
end
이 파일을 만들고 utop을 실행하세요.
# #mod_use "random.ml";;
# module R1 = Random.Make(struct let v = [|0; 1; 2; 3|] end);;
# module R2 = Random.Make(struct let v = [|0; 1; 2; 3|] end);;
# R1.bits ();;
- : int = 75783189
# R2.bits ();;
- : int = 75783189
# R1.bits ();;
- : int = 774473149
# R1.reset_state ();;
- : unit = ()
# R2.bits ();;
- : int = 774473149
# R1.bits ();;
- : int = 75783189
모듈 R1과 R2는 같은 상태로 만들어졌어요. 그래서 R1.bits와 R2.bits의 첫 호출은 같은 값을 돌려줘요.
R1.bits를 두 번째 호출하면 R1의 상태가 한 단계 전진하고 그에 해당하는 비트를 돌려줘요. R1.reset_state를 호출하면 R1의 상태를 초기 값으로 되돌려요.
R2.bits를 두 번째 호출하면 모듈들이 상태를 공유하지 않는다는 걸 보여줘요. 그렇지 않았다면 첫 bits 호출의 값이 나왔을 거예요.
R1.bits를 세 번째 호출하면 첫 호출과 같은 결과가 나와요. 상태가 실제로 리셋됐음을 보여주죠.
결론
펑터 적용은 기본적으로 함수 적용과 똑같이 동작해요. 매개변수를 넘기고 결과를 받는 거죠. 차이는 값 대신 모듈을 넘긴다는 점이에요. 편리함을 넘어서, 펑터는 모듈이 가능하게 한 것처럼 관심사를 실로(silo)로 나누는 데 그치지 않고, 그 위에 단계를 쌓는 설계 접근을 가능하게 해 줘요.