고차 함수
고차 함수 (Higher Order Functions)
함수는 세상을 묘사해요. 고차 함수(higher-order function)는 매개변수로 함수를 받는 함수예요. 이번 장에서는 함수를 조작하고 활용해서, 재사용 가능하고 조합 가능한 코드를 작성하는 법을 배워볼게요.
소개 (Introduction)
OCaml에서 함수로 작업하는 것은 금방 몸에 배게 돼요. 우리는 함수가 세상을 묘사한다 고 생각하길 좋아해요. 그래서 보통 어떤 것이 어떻게 동작하는지를 묘사하는 함수를 작성해요.
예를 들어 이름으로 사람에게 인사하는 함수를 들어 볼게요:
# let say_hi name = print_string ("Hello, " ^ name ^ "!\n") ;;
val say_hi : string -> unit = <fun>
이 함수를 여러 번 호출해서 여러 사람에게 "hello"라고 인사할 수 있어요:
#say_hi "Xavier";;
Hello, Xavier!
- : unit = ()
# say_hi "Sabine";;
Hello, Sabine!
- : unit = ()
# say_hi "Joe";;
Hello, Joe!
- : unit = ()
같은 사람에게 여러 번 인사하고 싶다면, 같은 코드 줄을 반복 하면 돼요.
# say_hi "Camel";;
Hello, Camel!
- : unit = ()
# say_hi "Camel";;
Hello, Camel!
- : unit = ()
# say_hi "Camel";;
Hello, Camel!
- : unit = ()
이 줄들을 매번 반복하지 않는 한 가지 방법은 "hi"를 3번 말하는 함수를 작성하는 거예요:
# let say_hi_3_times name =
say_hi name;
say_hi name;
say_hi name
;;
val say_hi_3_times : string -> unit = <fun>
이 함수에서 몇 가지 동작을 볼 수 있어요:
- 같은 이름에 "hi"라고 말해요.
- 정확히 3번 반복해요.
그런데 "hi"를 2번, 혹은 4번이나 12번 말하고 싶다면 어떻게 될까요?
이런 일이 생기면, 보통 함수가 해서는 안 될 어떤 결정을 내리고 있다는 뜻이에요. 다시 말해, 함수가 무언가(예: 횟수)를 알고 있다는 거예요.
그래서 대신, 호출자가 결정하게 하는 함수를 만들 거예요. 즉 "hi"를 몇 번 말할지를요. 이를 위해 새 인자, 여기서는 times를 요구해요:
# let rec say_many_hi times name =
if times < 1 then ()
else begin
say_hi name;
say_many_hi (times - 1) name
end
;;
val say_many_hi : int -> string -> unit = <fun>
훨씬 낫죠. 이제 이렇게 호출할 수 있어요:
# say_many_hi 3 "Xavier";;
Hello, Xavier!
Hello, Xavier!
Hello, Xavier!
- : unit = ()
# say_many_hi 12 "Camel";;
Hello, Camel!
Hello, Camel!
Hello, Camel!
Hello, Camel!
Hello, Camel!
Hello, Camel!
Hello, Camel!
Hello, Camel!
Hello, Camel!
Hello, Camel!
Hello, Camel!
Hello, Camel!
- : unit = ()
안타깝게도, 이 반복 동작을 재사용하는 것은 쉽지 않아요. say_hi 호출을 하드 코딩했기 때문이에요.
이것을 재사용 가능하게 만들려면, 호출자가 우리 함수가 무엇을 하게 할지를 결정하게 하면 돼요:
# let rec repeat times thing_to_do =
if times < 1 then ()
else begin
thing_to_do;
repeat (times - 1) thing_to_do
end
;;
val repeat : int -> 'a -> unit = <fun>
하지만 thing_to_do는 무엇이어야 할까요? 직관적으로는 이렇게 호출할 수 있을 것 같아요:
# repeat 3 (say_hi "Camel");;
Hello, Camel!
- : unit = ()
하지만 우리 프로그램은 인사말을 하나만 출력해요:
Hello, Camel!
그것은 우리가 원하는 게 아니에요! Camel에게 "hello"라고 3번 말하길 원해요.
OCaml에서는 함수 자체보다 먼저 인자가 평가돼요. 그래서 이 경우, repeat 함수에 도달하기도 전에 "hi"라고 말해버린 거예요.
repeat이 say_hi를 여러 번 호출하게 하려면, 함수를 다른 함수로 감싸 실행을 지연 시키면 돼요:
# repeat 3 (
fun () ->
say_hi "Camel");;
이것은 우리 repeat 함수를 리팩터링해야 한다는 뜻이에요. thing_to_do를 thing_to_do ()로 바꿔서 새 함수를 호출 하게요:
let rec repeat times thing_to_do =
if times < 1 then ()
else (
thing_to_do ();
repeat (times - 1) thing_to_do)
;;
thing_to_do를 fn으로 이름을 바꾸면 멋진 작은 repeat 함수가 돼요:
# let rec repeat times fn =
if times < 1 then ()
else begin
fn ();
repeat (times - 1) fn
end
;;
val repeat : int -> (unit -> 'a) -> unit = <fun>
그리고 repeat을 사용해 원래 say_many_hi를 다시 만들거나, 어떤 작업이든 몇 번이고 반복할 수 있어요:
# let say_many_hi times name = repeat times (fun () -> say_hi name);;
val say_many_hi : int -> string -> unit = <fun>
# let print_big_space () = repeat 10 print_newline;;
val print_big_space : unit -> unit = <fun>
이것이 고차 함수(Higher-Order Functions) 의 힘이에요. 고차 함수는 더 단순한 함수들로부터 복잡한 동작을 만들 수 있게 해줘요.
실제 세계의 다른 예를 몇 가지 들어 볼게요:
# let say_hi_to_many names = List.iter say_hi names;;
val say_hi_to_many : string list -> unit = <fun>
# module StringSet = Set.Make(String);;
module StringSet :
sig
type elt = string
type t = StringSet.t
val empty : t [...]
end
# let only_once fn names =
names
|> StringSet.of_list
|> StringSet.iter fn;;
val only_once : (string -> unit) -> string list -> unit = <fun>
# let yell_hi name =
name
|> String.uppercase_ascii
|> say_hi;;
val yell_hi : string -> unit = <fun>
# let call_for_dinner names = only_once yell_hi names;;
val call_for_dinner : string list -> unit = <fun>
흔한 고차 함수들 (Common Higher-Order Functions)
실제 코드를 보면 반복해서 나타나는 특정 패턴들이 있어요. 그것들을 아는 것은 유용한데, 함수형 프로그래머의 공통 어휘에 속하기 때문이에요. 그중 몇 가지는:
- 커링(currying)과 언커링(uncurrying)
- 파이프라이닝, 합성(composition), 체이닝(chaining)
- 반복(Iterating)
- 필터링(Filtering)
- 매핑(Mapping)
- 폴딩(Folding)
- 정렬(Sorting)
- 바인딩(Binding)
커링과 언커링 (Currying and Uncurrying)
OCaml에서는 모든 함수가 실제로 한 개의 매개변수만 받으니까, add x y를 호출하면 실제로는 두 함수를 호출하는 거예요! ((add x) y) 말이죠.
때로는 함수의 일부 를 다른 순서로 적용하는 것이 도움이 되고, 때로는 함수가 모든 매개변수를 한 번에 진짜로 받게 만드는 것이 도움이 돼요.
이것을 커링과 언커링이라고 불러요:
- 커링된
add함수는add x y처럼 호출돼요. - 언커링된
add함수는add (x, y)처럼 호출돼요. 이것은 실제로 하나의 인자라는 점을 눈여겨보세요!
예제로 들어가기 전에, 함수를 커링하고 언커링하는 데 도움이 되는 헬퍼 함수 몇 개를 정의할게요.
언커링 (Uncurrying)
우리의 언커리 헬퍼는 하나의 함수를 입력으로 받아 또 다른 함수를 반환하는 함수예요. 본질적으로 래퍼(wrapper)예요.
입력 함수는 타입 'a -> 'b -> 'c여야 해요. 이것은 2개의 매개변수를 받는 모든 함수의 타입이에요.
출력 함수는 타입 ('a * 'b) -> 'c를 가져요. 이제 인자 'a와 'b가 튜플로 묶여 있다는 점을 눈여겨보세요!
우리의 헬퍼는 다음과 같아요:
(* [uncurry] takes a function that is normally curried,
and returns a function that takes all arguments at once. *)
# let uncurry f (x, y) = f x y;;
val uncurry : ('a -> 'b -> 'c) -> 'a * 'b -> 'c = <fun>
더 많은 인자에 대한 uncurry를 쓰고 싶다면, 똑같이 동작하는 uncurry3이나 uncurry4, 심지어 uncurry5 함수를 새로 만들면 돼요:
# let uncurry4 f (w, x, y, z) = f w x y z;;
val uncurry4 : ('a -> 'b -> 'c -> 'd -> 'e) -> 'a * 'b * 'c * 'd -> 'e = <fun>
언커링은 리스트를 다룰 때(OCaml에서 자주 하는 일이죠) 그리고 리스트에 튜플들이 들어 있을 때 아주 유용해요.
이름과 좋아하는 이모지의 튜플 목록을 예로 들어 볼게요:
# let people = [
"🐫", "Sabine";
"🚀", "Xavier";
"✨", "Louis";
]
;;
이 요소들 중 어떤 것으로 무언가 하려면, 튜플을 분해하고 함수를 호출해야 해요:
# let greet emoji name =
Printf.printf "Glad to see you like %s, %s!\n" emoji name
;;
let emoji, name = List.hd people in
# greet emoji name
;;
하지만 greet 함수를 언커링해서 전체 튜플에 대해 동작하게 만들 수도 있어요!
# uncurry greet (List.hd people)
;;
커링 (Currying)
반대로, 때로는 이미 튜플로 작업하는 함수가 있고, 그것을 여러 인자를 받는 함수처럼 사용하고 싶을 때가 있어요.
그럴 때는 함수를 입력으로 받아 또 다른 함수를 출력으로 반환하는 작은 curry 헬퍼를 정의하면 돼요. 그것도 본질적으로 래퍼예요.
입력 함수는 타입 ('a * 'b) -> 'c여야 해요. 2개의 매개변수를 가진 튜플 하나를 받는 모든 함수의 타입이죠.
출력 함수는 타입 'a -> 'b -> 'c를 가져요. 이제 인자 'a와 'b가 묶이지 않았다는 점을 눈여겨보세요!
우리의 헬퍼는 다음과 같아요:
(* [curry] takes a function that is normally curried,
and returns a function that takes all arguments at once. *)
let curry f x y = f (x, y)
;;
더 많은 인자에 대한 curry를 쓰고 싶다면, 똑같이 동작하는 curry3이나 curry4, 심지어 curry5 함수를 새로 만들면 돼요:
let curry4 f w x y z = f (w, x, y, z)
;;
커링은 리스트를 다룰 때(OCaml에서 자주 하는 일이죠) 그리고 리스트에 단일 값이 있는데 함수가 여러 개를 받을 때 아주 유용해요.
이름 목록과 언커링된 계시(revealing) 함수를 예로 들어 볼게요:
let names = [
"Sabine";
"Xavier";
"Louis";
]
;;
let reveal (title, name) =
Printf.printf "But it was %s, %s!\n" title name
;;
이름에 reveal을 사용하려면, 그것을 튜플에 넣고 호출해야 해요. 이렇게요:
List.iter (fun name ->
let title = "The OCamler" in
reveal (title, name)) names
;;
하지만 reveal 함수를 커링해서 2개의 인자를 받게 만들 수도 있어요!
List.iter (curry reveal "The OCamler") names
;;
가독성 노트 (Readability Notes)
커링과 언커링은 코드가 정말 읽기 어려워질 때까지는 모두 재미인데요.
때로는 함수를 커링하는 게 말이 되고, 때로는 함수를 직접 수동으로 래핑하는 게 더 명확해요.
예를 들어, 이것은 커링/언커링을 많이 사용하는 파이프라인인데, 아마 래퍼 함수를 수동으로 써 주면 더 읽기 쉽고 유지보수하기 쉬울 거예요:
let do_and_return f x = f x; x
;;
let flip (x, y) = (y, x)
;;
names
|> List.map (do_and_return (greet "👋🏼"))
|> List.map (Fun.flip List.assoc (List.map flip people))
|> List.iter (curry reveal "The OCamler")
;;
대비되게, 훨씬 읽기 쉽고 유지보수하기 쉬운 버전이 있어요:
let find_by_name name1 =
List.find (fun (_emoji, name2) -> name1 = name2) people
;;
(* first iterate over the names, greeting them *)
names |> List.iter (greet "👋🏼")
;;
(* then find the right emoji by name *)
names
|> List.map find_by_name
|> List.iter (fun (emoji, _name) -> reveal ("The OCamler", emoji))
;;
파이프라인, 합성, 체이닝 (Pipelines, Composition, and Chaining)
OCaml에서는 함수를 많이 사용하니까, 값들이 한 함수에서 다른 함수로 흘러가며 우리가 파이프라인(pipelines) 이라고 부르는 것을 만드는 경우가 많아요.
let a = foo () in
let b = bar a in
let c = baz b in
(* ... *)
물론, 추가 변수와 그 모든 타이핑을 피하려면 함수를 항상 중첩 방식으로 호출할 수 있어요:
let c = baz (bar (foo ())) in
(* ... *)
하지만 이것은 때로 읽기가 그렇게 쉽지 않아요. 특히 함수 수가 늘어날수록, 안쪽에서 바깥쪽으로 진행되기 때문이에요.
이를 피하려면 |> 연산자를 사용해야 해요:
let c = foo () |> bar |> baz in
(* ... *)
이 연산자는 우리가 손으로 했을 중첩 호출과 정확히 같은 것으로 번역돼요 (정말로 마법은 없어요). 함수로 정의되어 있어요:
(* the pipeline operator *)
let (|>) x fn = fn x
그것은 값 x와 함수 fn을 받아, 둘 다 갖게 되자마자 fn을 x로 호출해요. 이것은 순서를 뒤집어 안쪽에서 바깥쪽이 아니라 왼쪽에서 오른쪽 또는 위에서 아래로 읽히는 파이프라인을 만들 수 있게 해줘요.
그런데 함수가 인자를 하나 이상 가질 때는 어떻게 될까요?
문자열 조작의 예를 살펴볼게요. 이메일에서 도메인 이름을 얻으려고 해요.
let email = "[email protected]"
;;
email
|> String.split_on_char '@'
|> Fun.flip List.nth 0
|> Option.map (fun str -> String.sub str 0 5)
|> Option.get
;;
OCaml이 기본적으로 함수를 커링하는 덕분에, 함수를 인자의 일부로만 부분 적용 하고 마지막 인자는 파이프라인에서 전달되도록 남겨 두는 것이 실용적이에요.
이것은 가장 중요한 인자가 마지막 위치에 있는 함수(t-last라고 부름)와, 레이블이 붙은 인자를 사용하며 모든 이름 인자를 먼저 전달함으로써 가장 중요한 인자를 마지막에 전달할 수 있는 함수(보통 t-first라고 부름)에 모두 해당돼요.
참고:
"t-first"와 "t-last"의 "t"는 "타깃 인자(target argument)"를 뜻해요. 즉 "타깃 인자 우선(target argument first)"과 "타깃 인자 마지막(target argument last)"이라는 뜻이에요.
이 두 경우는 아주 비슷해 보이지만, 사용성 측면에서 실질적인 차이가 커요. 위의 예를 그 함수들의 레이블 버전을 사용해 다시 살펴볼게요:
open StdLabels
module List = struct
include List
let nth_opt t ~at = nth_opt at t
end
email
|> String.split_on_char ~sep:'@'
|> List.nth_opt ~at:0
|> Option.map (String.sub ~off:0 ~len:5)
|> Option.value ~default:"new-user"
;;
반복하기 (Iterating)
보통 반복이라고 하면 루프를 돌고, 무언가의 모음을 순회하는 것을 떠올려요:
- 리스트의 요소들을 루프
- 맵의 키들을 순회
하지만 OCaml에서 반복 패턴은 선택적 값이나 결과, 트리, 지연 시퀀스 같은 다른 종류의 데이터 타입으로 확장될 수 있어요.
OCaml에서 반복한다는 것은, 값이 하나(또는 그 이상) 있으면 그 값에 함수를 적용하고 싶다는 뜻이에요.
리스트 반복하기 (Iterating over Lists)
OCaml의 리스트는 머리(head, 첫 요소)와 꼬리(tail, 나머지)로 구성된 연결 리스트예요.
리스트에 대해 패턴 매칭을 하면 반복할 수 있어요. 그렇게 하면 빈 리스트([])를 얻거나, 머리와 꼬리를 가진 패턴(n :: rest)을 얻어요. 머리와 꼬리를 가진 분기에서는 머리 값을 직접 사용해 함수를 적용한 다음 꼬리로 재귀할 수 있어요.
let rec print_nums nums =
match nums with
(* if the list is empty, we do nothing *)
| [] -> ()
(* if the list is not empty... *)
| n :: rest ->
(* we print the first element *)
Printf.printf "%d\n" n;
(* and repeat over the rest of the list *)
print_nums rest
이제 리스트의 각 요소로 다른 무언가를 하고 싶다면, 요소 위에서 실행될 함수를 요청 하면 돼요:
let rec print_all fn nums =
match nums with
| [] -> ()
| n :: rest ->
fn n;
print_all fn rest
이렇게 하면 어떤 함수 fn이든 print_all과 함께 호출할 수 있어요. 그래서 정말로 리스트를 반복하며 모든 요소 위에서 함수를 실행하는 것일 뿐이에요.
이것이 바로 표준 라이브러리에서 List.iter가 정의된 방식이에요.
선택적 값과 결과 반복하기 (Iterating over Optionals and Results)
OCaml에서 반복하는 것이 흔한 또 다른 데이터 종류는 선택적 값과 결과 값이에요. 보통 우리는 옵션에 Some 값 이 있거나 Ok 값 이 있을 때만 함수를 실행하고 싶어요. 값이 없거나 Error 가 있다면 아무것도 하고 싶지 않아요.
값에 대해 패턴 매칭을 하고, Some 또는 Ok 분기에서 내부 값에 함수를 호출하면 돼요.
let run_if_some opt fn =
match opt with
| Some value -> fn value
| None -> ()
;;
let run_if_ok res fn =
match res with
| Ok value -> fn value
| Error _ -> ()
;;
이것이 표준 라이브러리에서 Option.iter와 Result.iter가 정의된 방식이에요.
맵과 집합 반복하기 (Iterating over Maps and Sets)
맵과 집합 같은 더 큰 데이터 모음도 OCaml에서 흔해요. 전용 모듈이 있지만 펑터(functor) 인터페이스 를 가져요. 즉 Set나 Map을 직접 사용할 수는 없고, 저장하고 싶은 특정 타입에 대한 나만의 커스텀 Set 모듈 버전을 만들려면 모듈 수준 함수 Set.Make를 호출해야 해요.
Set이나 Map 모듈을 만들고 나면, 값을 리스트로 변환하는 함수를 제공한다는 것을 알게 돼요.
그 함수들 중 하나로, 맵이나 집합 위의 반복기를 만들 수 있어요:
let iter values collection fn =
let values : 'a list = values collection in
List.iter fn values
;;
module StringSet = Set.Make(String);;
module IntMap = Map.Make(Int);;
let iter_map map fn = iter IntMap.bindings map fn ;;
let iter_set set fn = iter StringSet.elements set fn ;;
이번에는 Map이나 Set의 값을 직접 반복하는 데 패턴 매칭을 사용하지 않았다는 점을 눈여겨보면 좋아요. Set과 Map의 표현(representation)이 비공개이기 때문이에요.
Map과 Set의 반복 함수의 실제 구현은 내부적으로 패턴 매칭을 사용해요.
지연 시퀀스 반복하기 (Iterating over Lazy Sequences)
보통 어떤 데이터 타입을 구현하는 모듈은 그것을 반복하는 함수를 제공해요.
하지만 어떤 데이터는 지연(lazy) 되어서 한 번에 한 요소씩만 접근하게 해줘요. 그래서 데이터가 [1,2,3]처럼 생겼다면, 첫 번째와 두 번째에 접근한 뒤에야 세 번째 값을 계산 할 수 있어요.
OCaml의 지연 시퀀스는 Seq 모듈로 표현되는데, 다음 요소를 얻는 uncons라는 함수가 있어요. 이 함수는 두 번째 요소를 얻는 데 사용할 수 있는 새 시퀀스도 반환해요. 그런 식으로 계속되죠.
let rec iter seq fn =
match Seq.uncons seq with
| None -> ()
| Some (value, seq2) ->
fn value;
iter seq2 fn
;;
이 함수는 시퀀스의 첫 요소를 얻으려고 시도하고, 요소가 있으면 함수 fn을 그것 위에서 실행해요. 그런 다음 두 번째 요소에서 시작하는 새 시퀀스(seq2)에 대해 반복해요.
이것은 표준 라이브러리에서 Seq.iter가 정의된 방식과 거의 똑같아요.
커스텀 데이터 타입 반복하기 (Iterating over custom data types)
지금까지 표준 라이브러리의 데이터 타입을 반복하는 방법을 봤어요. 이제 트리(tree)에 대한 우리만의 데이터 타입을 반복하는 방법을 볼게요.
트리 타입을 2개의 생성자를 포함하도록 정의할게요. 하나는 잎(leaf) 노드(트리 끝 에 있는 노드)용이고, 다른 하나는 자식을 가진 노드용이에요.
type 'value tree =
| Leaf of 'value
| Node of 'value tree * 'value
;;
그래서 우리 데이터 타입은 트리와 같은 데이터를 표현할 수 있게 해줘요:
graph TD
Node1([Node]) --> ML
Node1 --> Node2
Node2([Node]) --> Caml((Caml))
Node2 --> Node3
Node3([Node]) --> CamlLight([CamlLight])
Node3 --> Leaf
Leaf --> OCaml
이제 반복 함수를 정의하기 전에, 우리 데이터 타입에서 반복이 무엇을 뜻하는지 정의하는 것이 중요해요. 트리의 아래쪽에서부터 반복하고 싶을까요? 위쪽에서부터? 중간부터 밖으로?
우리 예제에서는 위에서 아래로 내려가며 반복할게요:
let rec iter tree fn =
match tree with
| Leaf value -> fn value
| Node (tree2, value) ->
fn value;
iter tree2 fn
;;
다시, 우리는 값에 대해 패턴 매칭을 하고, 분해된 값에 함수를 적용하고, 남은 데이터에 대해 재귀하는 방식으로 반복해요.
매핑 (Mapping)
반복과 대조적으로, 때로는 어떤 데이터에 함수를 적용하고 싶지만 데이터의 형태(shape)는 바꾸지 않고 결과를 유지하고 싶을 때가 있어요.
예를 들어 사용자 리스트가 있다면 사용자 이름 리스트를 얻고 싶을 수도 있어요. 또는 선택적 비밀번호가 있다면, 설정된 경우에만 암호화하고 싶을 수도 있어요.
이것을 매핑(mapping) 이라고 불러요.
리스트 매핑 (Mapping Lists)
리스트 매핑은 리스트 반복과 매우 비슷해요. 리스트에 패턴 매칭을 하고, 머리를 가져와 그 위에서 함수를 실행하고, 본문에 대해 재귀해요.
주요 차이점은 요소들 위에서 함수를 실행해 얻은 결과 값을 버리는 대신, 그로부터 리스트를 재구성 한다는 것이에요.
let rec map list fn =
match list with
| [] -> []
| head :: tail -> (fn head) :: (map tail fn)
;;
리스트를 분해하고 재구성하는 데 :: 생성자를 사용한다는 점을 눈여겨보세요.
옵션 매핑 (Mapping Options)
선택적 값을 매핑하는 것은, 옵션 안에 값이 있을 때 그 내용을 바꾸고 싶은 경우에만 의미가 있어요. 즉 None이 있으면 매핑할 것이 없으므로, 오직 Some x 값만 매핑할 수 있어요.
let map opt fn =
match opt with
| Some value -> Some (fn value)
| None -> None
;;
매치의 양쪽이 같은 것을 반환한다는 점을 눈여겨보세요. None이 있으면 None을 반환하고, Some이 있으면 Some을 반환해요. 이렇게 해서 구조가 보존돼요.
결과 매핑 (Mapping Results)
result가 있을 때, 매핑은 좀 더 까다로워져요. 이제 내부 값을 바꿀 수 있는 가능한 방법이 2개 있는데, 둘 다 완전히 유효한 매핑이에요!
Ok value 생성자의 값을 매핑할 수 있고, Error reason 생성자의 오류 값을 매핑할 수도 있어요.
(* maps a result over the ok value *)
let map_ok res fn =
match res with
| Ok value -> Ok (fn value)
| Error reason -> Error reason
;;
(* maps a result over the error value *)
let map_err res fn =
match res with
| Ok value -> Ok value
| Error reason -> Error (fn reason)
;;
둘 다 오류의 타입을 바꾸고 싶을 때, 또는 Ok 값을 가진 후에만 연산을 수행하고 싶을 때처럼 서로 다른 상황에서 유용해요.
커스텀 데이터 타입 매핑 (Mapping Custom Data Types)
반복 섹션에서 사용한 tree 같은 우리만의 커스텀 데이터 타입을 다룰 때는, 항상 데이터의 구조를 보존하려고 노력해야 해요. 즉, 매핑하면 노드 사이의 연결은 같은 것을 기대하되, 그 안의 값은 달라야 해요.
let rec map tree fn =
match tree with
| Leaf value -> Leaf (fn value)
| Node (tree2, value) -> Node (map tree2 fn, fn value)
;;
트리의 구조는 보존되지만, value를 만날 때마다 (fn value)로 갱신한다는 점을 눈여겨보세요.
폴딩 (Folding)
때로는 데이터를 반복하면서 요소에서 요소로 이동하며 결과를 모으고 싶을 때가 있어요. 이 동작은 "폴딩(folding)" 또는 "리듀싱(reducing)"이라고 불러요. 데이터를 요약하는 데 매우 유용해요.
예를 들어 1부터 10까지의 숫자 리스트가 있다면, 폴드를 통해 그것들을 더할 수 있어요:
let sum = List.fold_left (+) 0 [1;2;3;4;5;6;7;8;9;10];;
반복 장에서 본 커스텀 트리 타입에 대한 합을 구현하고 싶다면, 이렇게 할 수 있어요:
type 'value tree =
| Leaf of 'value
| Node of 'value tree * 'value
;;
let rec sum_tree tree =
match tree with
| Leaf value -> value
| Node (tree2, value) -> value + (sum_tree tree2)
;;
그리고 이것을 일반화해서 트리에 어떤 변환이든 적용하고 단일 값으로 줄이려면, + 함수를 인자 fn으로 바꿔야 해요:
let rec fold_tree tree fn =
match tree with
| Leaf value -> fn value
| Node (tree2, value) -> fn value (fold_tree tree2 fn)
;;
하지만 곧 문제에 부딪혀요. 우리의 fn 함수는 두 항목을 결합하기 위한 것인데, Leaf 분기에서 두 번째 항목은 무엇일까요?
폴딩은 0 값(zero value) , 즉 누산기(accumulator)의 시작점을 정의해야 해요. 이것은 모음이나 데이터 타입이 "비어 있을" 때 사용돼요.
어떤 데이터 타입은 좋은 "빈" 값이 없어요. 예를 들어 우리 트리에는 없어요. 리스트는 빈 리스트가 있고, 옵션은 None 생성자가 있어요. 결과(result)에는 좋은 "빈" 값이 없어요.
그래서 fold_tree를 고치려면 0 값이나 누산기 값을 전달하기만 하면 돼요:
let rec fold_tree tree fn acc =
match tree with
| Leaf value -> fn value acc
| Node (tree2, value) -> fn value (fold_tree tree2 fn acc)
;;
그리고 짠! 우리 함수는 이제 타입 검사를 올바르게 통과하고, 트리를 어떤 값으로든 줄이는 데 사용할 수 있어요.
정렬 (Sorting)
고차 함수로 구현하는 또 다른 흔한 동작은 모음 정렬이에요.
Array.sort와 List.sort는 모두, 두 요소를 비교하는 방법을 아는 함수를 전달하면 정렬이 이 비교를 사용하도록 적응하는 인터페이스를 구현해요.
배열의 경우 이 연산은 배열을 제자리에서(in-place) 변경해요:
let array = [| 4;9;1;10 |];;
(* sorts the array in ascending order *)
Array.sort (fun a b -> a - b) array;;
(* sorts the array in descending order *)
Array.sort (fun a b -> b - a) array;;
리스트의 경우 이 연산은 새로 정렬된 리스트를 반환해요:
let list = [4;9;1;10] ;;
let asc = List.sort (fun a b -> a - b) list ;;
let desc = List.sort (fun a b -> b - a) list ;;
대부분의 OCaml 모듈은 sort에 전달할 수 있는 compare 함수를 포함해요:
let int_array = [|3;0;100|];;
Array.sort Int.compare int_array;;
List.sort String.compare ["z";"b";"a"];;
List.sort Bool.compare [true;false;false];;
바인딩 (Binding)
함수형 프로그래밍에서 마지막으로 흔한 고차 패턴은 데이터를 내부에서 결합 하는 능력이에요. 역사적인 이유로 이것을 보통 바인드(bind) 라고 불러요.
예를 들어, 리스트가 있고 그것을 리스트를 반환하는 함수로 매핑하면, 리스트들의 리스트를 얻게 돼요. 때로는 이것을 원하지만, 때로는 새 리스트가 중첩(nested) 되기보다 평탄화(flattened) 되길 원해요.
리스트로 이렇게 하려면 concat_map 함수를 사용할 수 있어요. 다음과 같이 생겼어요:
let twice x = [x;x];;
let double ls = List.concat_map twice ls;;
double [1;2;3];; (* [1;1;2;2;3;3] *)
조기 반환으로서의 바인딩 (Binding as early returns)
같은 패턴은 특정 값에서 단락(short circuit) 되는 함수 체인을 만드는 데 유용해요.
예를 들어 데이터베이스에서 사용자를 가져와야 하고, 사용자가 있을 때만 사용자의 이메일에 접근하려고 한다면, Option.bind을 사용해 첫 연산에서 단락시킬 수 있어요:
type user = {
email: string option
}
let get_user () = None ;;
let get_email user = user.email ;;
let email = Option.bind (get_user ()) get_email ;;
이 예제에서 get_user () 호출이 None을 반환하므로, get_email은 결코 호출되지 않아요. get_user가 Some user를 반환할 때만 get_email이 호출돼요.
이것은 result 값에도 적용돼요:
type user = {
email: string
}
let get_user () = Error `no_database ;;
let get_email user = Ok user.email ;;
let email = Result.bind (get_user ()) get_email ;;
주요 차이점은 이 경우 Result.bind가 Ok value에 편향되어 있어서, result 값이 Error reason이면 bind들을 통해 단락되어 처음 나타나는 오류를 그냥 반환한다는 것이에요.
Let-연산자 (Let-ops)
안타깝게도 Result.bind를 호출하는 것은 좀 어색할 수 있어요. map 호출처럼 bind들의 연속을 통해 값을 파이프할 수 없어요. 예를 들어, 이것은 유효하지 않아요:
let email = get_user ()
|> Result.bind get_email
|> Result.bind extract_domain
|> Result.bind validate_domain
;;
다행히 OCaml은 let-연산자 라고 불리는 연산자들의 부분집합을 재정의할 수 있게 해줘요. 이것은 bind 호출을 평범한 let-바인딩과 아주 비슷하게 보이게 만들어 사용을 간소화할 수 있어요:
(* first we will declare out let* operator to be equal to Result.bind *)
let (let*) = Result.bind
let* user = get_user () in
let* email = get_email user in
let* domain = extract_domain email in
validate_domain domain
이것은 bind 호출에서 기대하던 동작을 바꾸지 않으면서 코드를 훨씬 읽기 쉽게 만드는 이점이 있어요.
비동기 코드 (Async code)
Promise/Future을 구현하는 OCaml용 비동기 라이브러리들은 보통 계산을 체이닝할 수 있게 하는 bind 함수도 가져요.