값과 함수
값과 함수 (Values and Functions)
OCaml에서 함수는 값으로 취급되기 때문에, 함수를 다른 함수의 인자로 쓰거나 함수에서 반환할 수 있어요. 이 튜토리얼은 표현식(expression), 값(value), 이름(name) 사이의 관계를 소개해요. 처음 네 절은 함수가 아닌 값을 다루고, 함수는 값부터 시작하는 다음 절들이 함수를 다뤄요.
이 개념들을 예시로 이해하기 위해 UTop을 사용해요. 예시를 직접 바꿔 보면 이해에 도움이 되니 권장할게요.
출처: OCaml 공식 문서
본문
값이란 무엇인가? (What is a Value?)
대부분의 함수형 프로그래밍 언어처럼 OCaml은 표현식 지향(expression-oriented) 프로그래밍 언어예요. 즉 프로그램이 표현식이라는 뜻이에요. 사실 거의 모든 것이 표현식이에요. OCaml에서 문(statement)은 데이터에 대해 취할 동작을 지정하지 않아요. 모든 계산은 표현식 평가를 통해 이뤄져요. 표현식을 계산하면 값이 생기죠. 아래에 표현식 몇 개와 그 타입, 결과 값이 있어요. 어떤 것은 계산을 포함하고 어떤 것은 포함하지 않아요.
# "Every expression has a type";;
- : string = "Every expression has a type"
# 2 * 21;;
- : int = 42
# int_of_float;;
- : float -> int = <fun>
# int_of_float (3.14159 *. 2.0);;
- : int = 6
# fun x -> x * x;;
- : int -> int = <fun>
# print_endline;;
- : string -> unit = <fun>
# print_endline "Hello!";;
Hello!
- : unit
표현식의 타입(평가 전)과 결과 값의 타입(계산 후)은 같아요. 이 덕분에 컴파일러는 바이너리에서 런타임 타입 검사를 피할 수 있어요. OCaml에서 컴파일러는 타입 정보를 제거하므로 런타임에 이용할 수 없어요. 프로그래밍 이론에서 이를 주체 축약(subject reduction)이라고 불러요.
전역 정의 (Global Definitions)
모든 값에 이름을 붙일 수 있어요. 이것이 let … = … 문의 목적이에요. 이름은 왼쪽, 표현식은 오른쪽에 있어요.
표현식이 평가될 수 있으면 평가돼요.
그렇지 않으면 표현식은 그대로 값으로 변환돼요. 함수 정의가 그 경우예요.
UTop에서 정의를 작성할 때 이런 일이 일어나요.
# let the_answer = 2 * 3 * 7;;
val the_answer : int = 42
전역 정의는 최상위 레벨에서 입력되는 정의예요. 여기서 the_answer는 전역으로 정의돼요.
지역 정의 (Local Definitions)
지역 정의는 표현식 안에서 이름을 바인딩해요.
# let d = 2 * 3 in d * 7;;
- : int = 42
# d;;
Error: Unbound value d
지역 정의는 let … = … in … 표현식으로 도입돼요. in 키워드 앞에서 바인딩된 이름은 in 키워드 뒤의 표현식 안에서만 바인딩돼 있어요. 여기서 이름 d는 표현식 d * 7 안에서 6에 바인딩돼 있어요.
몇 가지 알아둘 점이 있어요.
- 이 예시에서는 전역 정의가 도입되지 않아서 에러가 나는 거예요.
2 * 3의 계산은 항상d * 7보다 먼저 일어나요.
지역 정의는 이어 붙이거나(chained, 하나씩 차례로) 중첩할 수 있어요(nested, 하나 안에 다른 하나). 이어 붙이기 예시예요.
# let d = 2 * 3 in
let e = d * 7 in
d * e;;
- : int = 252
# d;;
Error: Unbound value d
# e;;
Error: Unbound value e
스코프가 이렇게 동작해요.
d는let e = d * 7 in d * e안에서6에 바인딩e는d * e안에서42에 바인딩
중첩 예시예요.
# let d =
let e = 2 * 3 in
e * 5 in
d * 7;;
- : int = 210
# d;;
Error: Unbound value d
# e;;
Error: Unbound value e
스코프가 이렇게 동작해요.
e는e * 5안에서6에 바인딩d는d * 7안에서30에 바인딩
이어 붙이기와 중첩을 임의로 조합할 수 있어요.
두 예시에서 d와 e는 모두 지역 정의예요.
패턴 매칭의 형태 (Forms of Pattern Matching)
패턴 매칭은 case 분석을 일반화한 프로그래밍 언어 구조예요. 하위 표현식 검사를 가능하게 하고 어떤 타입의 값에도 적용돼요.
다음 절들에서는 특수한 경우인 let 바인딩 안에서의 매칭을 살펴볼게요. 다음 장인 기본 데이터 타입과 패턴 매칭에서는 match...with를 이용한 일반적인 경우를, Error Handling 장에서는 try...with를 사용할 때 값의 구조 분해가 에러 처리에 어떻게 도움이 되는지 살펴봐요.
정의에서의 패턴 매칭 (Pattern Matching in Definitions)
패턴 매칭이 딱 한 경우만 가질 때는 이름 정의와 let ... =, fun ... -> 표현식에서 쓸 수 있어요. 이때 두 개보다 많거나 적은 이름이 정의될 수 있어요. 이것은 튜플, record, 그리고 사용자 정의 단일 variant 타입에 적용돼요.
튜플에 대한 패턴 매칭 (Pattern Matching on Tuples)
흔한 경우가 튜플이에요. 하나의 let으로 이름 두 개를 만들 수 있어요.
# List.split;;
- : ('a * 'b) list -> 'a list * 'b list
# let (x, y) = List.split [(1, 2); (3, 4); (5, 6); (7, 8)];;
val x : int list = [1; 3; 5; 7]
val y : int list = [2; 4; 6; 8]
List.split 함수는 쌍들의 리스트를 리스트들의 쌍으로 바꿔요. 여기서 결과로 나온 각 리스트가 이름에 바인딩돼요.
record에 대한 패턴 매칭 (Pattern Matching on Records)
record에도 패턴 매칭을 할 수 있어요.
# type name = { first : string; last: string };;
type name = { first : string; last : string; }
# let robin = { first = "Robin"; last = "Milner" };;
val robin : name = {first = "Robin"; last = "Milner"}
# let { first = given_name; last = family_name } = robin;;
val given_name : string = "Robin"
val family_name : string = "Milner"
함수 매개변수에서의 패턴 매칭 (Pattern Matching in Function Parameters)
단일 case 패턴 매칭은 매개변수 선언에도 쓸 수 있어요.
튜플 예시예요.
# let get_country ((country, { first; last }) : string * name) = country;;
val get_country : string * name -> string = <fun>
name record 예시예요.
# let introduce {first; last} = "I am " ^ first ^ " " ^ last;;
val introduce : name -> string = <fun>
참고 매개변수 선언에 discard 패턴을 사용할 수도 있어요.
# let get_meaning _ = 42;;
val get_meaning : 'a -> int = <fun>
unit에 대한 패턴 매칭 (Pattern Matching on unit)
정의와 패턴 매칭을 결합한 특수한 경우가 unit 타입과 관련돼요.
# let () = print_endline "ha ha";;
ha ha
참고: OCaml 둘러보기 튜토리얼에서 설명했듯이 unit 타입은 단일 값 ()를 가지며 "unit"이라고 읽어요.
위에서 패턴에는 식별자가 없으므로 정의되는 이름이 없어요. 표현식이 평가되고 부작용(표준 출력에 ha ha 출력)이 일어나요.
참고: 컴파일된 파일이 오직 부작용을 위해서만 표현식을 평가하게 하려면 let () = 다음에 표현식을 써야 해요.
사용자 정의 타입에 대한 패턴 매칭 (Pattern Matching on User-Defined Types)
이것은 사용자 정의 타입에도 동작해요.
# type citizen = string * name;;
type citizen = string * name
# let ((country, { first = forename; last = surname }) : citizen) = ("United Kingdom", robin);;
val country : string = "United Kingdom"
val forename : string = "Robin"
val surname : string = "Milner"
사용자 정의 타입에 대한 중첩 패턴 매칭 (Nested Pattern Matching on User-Defined Types)
패턴 매칭은 중첩된 사용자 정의 타입에도 동작해요. 아래 예시에서 중첩된 튜플을 분해해요.
# let (name, (street, city, zip), (email, phone)) =
("John Doe", ("123 Elm St", "Springfield", 12345), ("[email protected]", 1234567890));;
val name : string = "John Doe"
val street : string = "123 Elm St"
val city : string = "Springfield"
val zip : int = 12345
val email : string = "[email protected]"
val phone : int = 1234567890
다음 예시에서는 record 안에 중첩된 튜플을 분해해요.
record는 필드 이름에 타입이 주석으로 붙는 명시적 타입 정의를 요구해요. 먼저 person record 타입을 정의하고, jane이라는 그 타입의 인스턴스를 만들어요.
# type person = {
name : string;
street : string;
city : string;
zip : int;
contact: string * int;
};;
type person = {
name : string;
street : string;
city : string;
zip : int;
contact : string * int;
}
# let jane = {
name = "Jane Doe";
street = "123 Elm St";
city = "Springfield";
zip = 12345;
contact = ("[email protected]", 1234567890);
};;
val jane : person =
{name = "Jane Doe"; street = "123 Elm St"; city = "Springfield"; zip = 12345;
contact = ("[email protected]", 1234567890)}
다음 예시들은 중첩된 contact 튜플에서 Jane의 email과 phone 데이터를 추출하는 두 가지 방법을 보여줘요. 먼저 중첩 분해를 사용하고, 그다음엔 우선 contact를 추출한 뒤 contact를 분해해 email과 phone에 접근하는 방법을 보여줄게요.
먼저 중첩 분해로 contact 튜플의 내용에 직접 접근할게요.
# let { name; street; city; zip; contact = (email, phone) } = jane;;
val name : string = "Jane Doe"
val street : string = "123 Elm St"
val city : string = "Springfield"
val zip : int = 12345
val email : string = "[email protected]"
val phone : int = 1234567890
contact가 최상위 스코프에는 없다는 걸 주목하세요.
# contact;;
Error: Unbound value contact
이것은 "contact"가 최상위 정의가 아니라, 구조 분해 패턴 매칭에서 나온 지역 정의이기 때문이에요.
다음으로 email과 phone에 접근하는 두 단계 접근법을 보여드릴게요. 이 방식은 contact를 최상위 스코프로 가져오고 두 단계가 필요해요.
(* Step 1: deconstruct all record fields *)
# let { name; street; city; zip; contact } = jane;;
val name : string = "Jane Doe"
val street : string = "123 Elm St"
val city : string = "Springfield"
val zip : int = 12345
val contact : string * int = ("[email protected]", 1234567890)
(* Step 2: deconstruct the tuple *)
# let ( email, phone ) = contact;;
val email : string = "[email protected]"
val phone : int = 1234567890
이제 contact가 바인딩된 변수로 최상위 레벨에 사용 가능해졌음을 주목하세요.
# contact;;
- : string * int = ("[email protected]", 1234567890)
패턴 매칭으로 값 버리기 (Discarding Values Using Pattern Matching)
패턴 매칭을 할 때 원하지 않는 값을 버리거나 무시할 수 있어요. 그 방법은 구조 분해하는 자료구조에 따라 달라져요.
위의 jane record 예시를 이어서, zip 필드를 변수에 바인딩하지 않으려면 패턴에서 그 필드를 그냥 생략할 수 있어요.
# let { name; street; city; contact = (email, phone) } = john;;
val name : string = "Nohn Doe"
val street : string = "123 Elm St"
val city : string = "Springfield"
val email : string = "[email protected]"
val phone : int = 1234567890
튜플은 record와 다르게 동작해요. 담긴 데이터는 익명이고, 그 위치로 접근해요. contact 필드의 튜플에서 email 값을 버리려면 만능 패턴(_)을 사용해야 해요.
# let { name; street; city; contact = (_, phone) } = john;;
val name : string = "Jane Doe"
val street : string = "123 Elm St"
val city : string = "Springfield"
val phone : int = 1234567890
스코프와 환경 (Scopes and Environments)
너무 단순화하지 않으면, OCaml 프로그램은 표현식이나 전역 let 정의의 연속이에요.
실행은 각 항목을 위에서 아래로 평가해요.
평가 중 어느 시점에라도 환경(environment)은 사용 가능한 정의들의 순서 있는 나열이에요. 환경은 다른 언어에서는 컨텍스트라고도 불러요.
여기서 이름 twenty가 최상위 환경에 추가돼요.
# let twenty = 20;;
val twenty : int = 20
twenty의 스코프는 전역이에요. 이 이름은 정의 이후 어디서든 사용할 수 있어요.
여기서는 전역 환경이 바뀌지 않아요.
# let ten = 10 in 2 * ten;;
- : int = 20
# ten;;
Error: Unbound value ten
ten을 평가하면 에러가 나는 이유는 그것이 전역 환경에 추가되지 않았기 때문이에요. 다만 2 * ten 표현식 안에서 지역 환경은 ten의 정의를 포함해요.
OCaml은 표현식 지향 언어지만 몇몇 문을 가지고 있어요. 전역 let은 이름-값 바인딩을 추가해 전역 환경을 수정해요.
최상위 표현식도 문이에요. let _ = 정의와 동등하기 때문이에요.
# (1.0 +. sqrt 5.0) /. 2.0;;
- : float = 1.6180339887498949
# let _ = (1.0 +. sqrt 5.0) /. 2.0;;
- : float = 1.6180339887498949
내부 가림 (Inner Shadowing)
이름을 만들고 정의해 값을 바인딩하면, 그 이름은 바뀌지 않아요. 하지만 같은 이름을 다시 정의해 새 바인딩을 만들 수는 있어요.
# let i = 21;;
val i : int = 21
# let i = 7 in i * 2;;
- : int = 14
# i;;
- : int = 21
두 번째 정의는 첫 번째를 가려요(shadows). 내부 가림(inner shadowing)은 지역 정의의 스코프로 제한돼요. 그래서 그 뒤에 쓰인 것은 위에서처럼 여전히 이전 정의를 참조해요. 여기서 i의 값은 바뀌지 않았어요. 첫 번째 표현식에서 정의한 대로 여전히 21이에요. 두 번째 표현식은 i를 전역이 아니라 i * 2 안에서 지역적으로 바인딩해요.
같은 레벨 가림 (Same-Level Shadowing)
또 다른 종류의 가림은 같은 이름의 정의 두 개가 같은 레벨에 있을 때 일어나요.
# let h = 2 * 3;;
val h : int = 6
# let e = h * 7;;
val e : int = 42
# let h = 7;;
val h : int = 7
# e;;
- : int = 42
이제 환경에 h의 정의가 두 개 있어요. 첫 번째 h는 바뀌지 않았어요. 두 번째 h가 정의되면 첫 번째는 도달할 수 없게 돼요.
함수는 값 (Function as Values)
OCaml에서 함수는 값이에요. 이것이 함수형 프로그래밍의 핵심 개념이에요. 이 맥락에서 OCaml에는 일급(first-class) 함수가 있다고 말하기도 해요.
함수 적용 (Applying Functions)
여러 표현식을 나란히 쓸 때, 가장 왼쪽의 것이 함수여야 해요. 나머지는 모두 인자예요. OCaml에서 함수에 인자를 전달한다는 것을 표현하는 데 괄호는 필요 없어요. 괄호는 단 하나의 목적을 가져요: 하위 표현식을 만들기 위해 표현식을 묶는 일이에요.
# max (21 * 2) (int_of_string "713");;
- : int = 713
max 함수는 두 인자 중 더 큰 것을 반환해요. 두 인자는 다음과 같아요.
42,21 * 2의 결과713,int_of_string "713"의 결과
하위 표현식을 만들 때 begin ... end를 사용할 수도 있어요. 이는 괄호 ( ... )와 같아요. 그래서 위 코드는 이렇게 다시 써도 같은 결과를 얻어요.
# max begin 21 * 2 end begin int_of_string "713" end;;
- : int = 713
# String.starts_with ~prefix:"state" "stateless";;
- : bool = true
String.starts_with 같은 일부 함수는 라벨이 붙은 매개변수를 가져요. 라벨은 함수가 같은 타입의 매개변수를 여러 개 가질 때 유용해요. 인자에 이름을 붙이면 그 목적을 짐작할 수 있죠. 위에서 ~prefix:"state"는 "state"가 라벨 붙은 인자 prefix로 전달된다는 것을 나타내요.
라벨 붙은 인자와 선택적 인자는 Labelled Arguments 튜토리얼에서 자세히 다뤄요.
함수를 적용하는 두 가지 대안적 방법도 있어요.
적용 연산자 (The Application Operator)
적용 연산자는 @@예요.
# sqrt 9.0;;
- : float = 3.
# sqrt @@ 9.0;;
- : float = 3.
@@ 적용 연산자는 인자(오른쪽)를 함수(왼쪽)에 적용해요. 여러 호출을 이어 붙일 때 유용한데, 괄호를 쓰지 않아도 돼서 더 읽기 쉬운 코드가 만들어져요. 괄호가 있는 경우와 없는 경우의 예시예요.
# int_of_float (sqrt (float_of_int (int_of_string "81")));;
- : int = 9
# int_of_float @@ sqrt @@ float_of_int @@ int_of_string "81";;
- : int = 9
파이프 연산자 (The Pipe Operator)
파이프 연산자(|>)도 괄호를 피하게 해주지만 순서는 반대예요. 함수가 오른쪽, 인자가 왼쪽이에요.
# "81" |> int_of_string |> float_of_int |> sqrt |> int_of_float;;
- : int = 9
유닉스 셸 파이프와 아주 비슷해요.
익명 함수 (Anonymous Functions)
함수는 재귀적이지 않다면 이름에 바인딩될 필요가 없어요. 다음 예시들을 볼게요.
# fun x -> x;;
- : 'a -> 'a = <fun>
# fun x -> x * x;;
- : int -> int = <fun>
# fun s t -> s ^ " " ^ t ;;
- : string -> string -> string = <fun>
# function [] -> None | x :: _ -> Some x;;
- : 'a list -> 'a option = <fun>
이름에 바인딩되지 않은 함수 값을 익명 함수라고 불러요.
차례대로 무엇인지 볼게요.
- 항등 함수: 무엇이든 받아 그대로 반환
- 제곱 함수: 정수를 받아 제곱해 반환
- 문자열 두 개를 받아 그 사이에 공백 문자를 넣어 연결해 반환하는 함수
- 리스트를 받아 비어 있으면
None을, 아니면 첫 번째 요소를 반환하는 함수
익명 함수는 종종 다른 함수의 인자로 전달돼요.
# List.map (fun x -> x * x) [1; 2; 3; 4];;
- : int list = [1; 4; 9; 16]
전역 함수 정의 (Defining Global Functions)
전역 정의로 함수를 이름에 전역적으로 바인딩할 수 있어요.
# let f = fun x -> x * x;;
val f : int -> int = <fun>
우연히 함수인 표현식이 값으로 변환되어 이름에 바인딩돼요. 같은 일을 하는 또 다른 방법이에요.
# let g x = x * x;;
val g : int -> int = <fun>
전자는 익명 함수를 이름에 명시적으로 바인딩해요. 후자는 더 간결한 문법을 사용하고 fun 키워드와 화살표 기호를 피해요.
지역 함수 정의 (Defining Local Functions)
함수는 지역적으로 정의될 수 있어요.
# let sq x = x * x in sq 7 * sq 7;;
- : int = 2401
# sq;;
Error: Unbound value sq
sq를 호출하면 에러가 나요. 지역적으로만 정의됐기 때문이에요.
함수 sq는 sq 7 * sq 7 표현식 안에서만 사용할 수 있어요.
지역 함수는 흔히 함수의 스코프 안에서 정의되지만, 반드시 그래야 하는 건 아니에요.
클로저 (Closures)
이 예시는 같은 레벨 가림을 사용한 클로저를 보여줘요.
# let j = 2 * 3;;
val j : int = 6
# let k x = x * j;;
val k : int -> int = <fun>
# k 7;;
- : int = 42
# let j = 7;;
val j : int = 7
# k 7;; (* What is the result? *)
- : int = 42
이것이 어떻게 성립하는지 볼게요.
- 상수
j가 정의되고 그 값은 6. - 함수
k가 정의됨. 단일 매개변수x를 가지며x * j의 값을 반환. k에 7을 적용하면 값은 42.- 첫 번째를 가리는 새 정의
j를 만듦. k에 다시 7을 적용해도 결과는 같음: 42.
새 j 정의가 첫 번째를 가리지만, 원본이 함수 k가 사용하는 것이에요. k 함수의 환경은 j의 첫 번째 값을 포착하므로, 언제 k를 적용해도(j의 두 번째 정의 후에도) 함수가 똑같이 동작한다는 걸 확신할 수 있어요.
하지만 이후의 모든 표현식은 새 j 값(7)을 사용해요. 아래처럼요.
# let m = j * 3;;
val m : int = 21
함수에 인자를 부분 적용하는 것도 새 클로저를 만들어요.
# let max_42 = max 42;;
val max_42 : int -> int = <fun>
max_42 함수 내부의 환경에는 max의 첫 번째 매개변수와 값 42 사이의 추가 바인딩이 있어요.
재귀 함수 (Recursive Functions)
반복 계산을 수행하기 위해 함수가 자신을 호출할 수 있어요. 그런 함수를 재귀적이라고 불러요.
# let rec fibo n =
if n <= 1 then n else fibo (n - 1) + fibo (n - 2);;
val fibo : int -> int = <fun>
# let u = List.init 10 Fun.id;;
val u : int list = [0; 1; 2; 3; 4; 5; 6; 7; 8; 9]
# List.map fibo u;;
- : int list = [0; 1; 1; 2; 3; 5; 8; 13; 21; 34]
이것은 피보나치 수를 계산하는 고전적인(그리고 매우 비효율적인) 방법이에요. 각 호출에서 만들어지는 재귀 호출 수가 두 배로 늘어나 지수적 성장을 만들어 내요.
OCaml에서 재귀 함수는 let rec를 사용해 정의하고 명시적으로 선언해야 해요. 실수로 재귀 함수를 만들 수는 없고, 재귀 함수는 익명일 수 없어요.
참고: List.init은 정수 시퀀스에 주어진 함수를 적용해 리스트를 만들 수 있게 해주는 표준 라이브러리 함수이고, Fun.id는 인자를 그대로 반환하는 항등 함수예요. 우리는 0~9의 숫자가 든 리스트를 만들어 u라고 이름 붙였어요. 그리고 List.map을 사용해 리스트의 모든 요소에 fibo 함수를 적용했어요.
이 버전이 더 잘 동작해요.
# let rec fib_loop m n i =
if i = 0 then m else fib_loop n (n + m) (i - 1);;
val fib_loop : int -> int -> int -> int = <fun>
# let fib = fib_loop 0 1;;
val fib : int -> int = <fun>
# List.init 10 Fun.id |> List.map fib;;
- : int list = [0; 1; 1; 2; 3; 5; 8; 13; 21; 34]
첫 번째 버전 fib_loop는 매개변수 두 개가 추가됐어요. 이전에 계산된 피보나치 수 두 개예요.
두 번째 버전 fib는 첫 두 피보나치 수를 초기 값으로 사용해요. 재귀 호출에서 돌아올 때 계산할 것이 없으므로, 컴파일러가 꼬리 호출 제거(tail call elimination)라는 최적화를 수행할 수 있어요.
참고: fib_loop 함수는 m n i 매개변수 세 개를 가지지만 fib를 정의할 때 부분 적용을 사용해 인자 두 개만 0 1 전달된 것을 주목하세요.
여러 매개변수를 가진 함수 (Functions with Multiple Parameters)
여러 매개변수 함수 정의 (Defining Functions with Multiple Parameters)
여러 매개변수를 가진 함수를 정의하려면 각 매개변수를 함수 이름(let 키워드 바로 뒤)과 등호 사이에 공백으로 구분해 나열해야 해요.
# let sweet_cat x y = x ^ " " ^ y;;
val sweet_cat : string -> string -> string = <fun>
# sweet_cat "kitty" "cat";;
- : string = "kitty cat"
여러 매개변수를 가진 익명 함수 (Anonymous Functions with Multiple Parameters)
익명 함수를 사용해 같은 함수를 다른 방식으로 정의할 수 있어요.
# let sour_cat = fun x -> fun y -> x ^ " " ^ y;;
val sour_cat : string -> string -> string = <fun>
# sour_cat "kitty" "cat";;
- : string = "kitty cat"
sweet_cat과 sour_cat이 같은 몸통 x ^ " " ^ y를 가진 것을 관찰하세요. 매개변수를 나열하는 방식만 다를 뿐이에요.
sweet_cat에서 이름과=사이에x y로sour_cat에서=뒤에fun x -> fun y ->로 (=앞에는 이름만)
또한 sweet_cat과 sour_cat의 타입이 같다는 것도 관찰하세요: string -> string -> string.
compiler explorer에서 생성된 어셈블리 코드를 확인하면 두 함수가 같다는 걸 볼 수 있어요.
sour_cat이 쓰인 방식이 두 함수의 동작을 더 명시적으로 나타내요. 이름 sour_cat은 매개변수 x를 가지며 매개변수 y를 가진 익명 함수를 반환하고 x ^ " " ^ y를 반환하는 익명 함수에 바인딩돼 있어요.
sweet_cat이 쓰인 방식은 sour_cat의 축약 버전이에요. 이렇게 문법을 줄이는 방식을 문법적 설탕(syntactic sugar)이라고 불러요.
부분 적용과 클로저 (Partial Application and Closures)
인자 앞에 "kitty "를 붙이는 타입 string -> string의 함수를 정의하고 싶어요. sour_cat과 sweet_cat으로 할 수 있어요.
# let sour_kitty x = sour_cat "kitty" x;;
val sour_kitty : string -> string = <fun>
# let sweet_kitty = fun x -> sweet_cat "kitty" x;;
val sweet_kitty : string -> string = <fun>
# sour_kitty "cat";;
- : string = "kitty cat"
# sweet_kitty "cat";;
- : string = "kitty cat"
하지만 두 정의 모두 부분 적용이라는 것을 사용해 줄일 수 있어요.
# let sour_kitty = sour_cat "kitty";;
val sour_kitty : string -> string = <fun>
# let sweet_kitty = sweet_cat "kitty";;
val sweet_kitty : string -> string = <fun>
여러 매개변수 함수는 단일 인자 함수들이 중첩된 연속이므로, 모든 인자를 한 번에 전달할 필요가 없어요.
sour_kitty나 sweet_kitty에 인자 하나를 전달하면 타입 string -> string의 함수가 반환돼요. 첫 번째 인자인 여기서는 "kitty"가 포착되고 결과는 클로저예요.
이 표현식들은 같은 값을 가져요.
fun x -> sweet_cat "kitty" xsweet_cat "kitty"
여러 매개변수 함수의 타입 (Types of Functions of Multiple Parameters)
여기서 타입을 살펴볼게요.
# let dummy_cat : string -> (string -> string) = sweet_cat;;
val dummy_cat : string -> string -> string = <fun>
여기서 타입 주석 : string -> (string -> string)은 dummy_cat의 타입을 명시적으로 나타내기 위해 사용돼요.
하지만 OCaml은 새 정의가 타입 string -> string -> string이라고 답해요. 타입 string -> string -> string과 string -> (string -> string)이 같기 때문이에요.
괄호를 사용하면 여러 인자 함수가 매개변수 하나짜리 함수로, 매개변수 하나가 빠진 익명 함수를 반환하는 함수임이 분명해져요.
괄호를 반대로 두면 동작하지 않아요.
# let bogus_cat : (string -> string) -> string = sweet_cat;;
Error: This expression has type string -> string -> string
but an expression was expected of type (string -> string) -> string
Type string is not compatible with type string -> string
타입 (string -> string) -> string을 가진 함수는 함수를 매개변수로 받아요. sweet_cat 함수는 함수를 결과로 가지지, 함수를 매개변수로 가지지 않아요.
타입 화살표 연산자는 오른쪽으로 결합해요. 괄호 없는 함수 타입은 위에서 dummy_cat의 타입을 선언한 것처럼 오른쪽에 괄호가 있는 것처럼 취급돼요. 다만 표시되지만 않을 뿐이에요.
튜플을 함수 매개변수로 (Tuples as Function Parameters)
OCaml에서 튜플은 서로 다른 타입일 수 있는 고정된 수의 값을 묶는 데 사용하는 자료구조예요. 튜플은 괄호로 감싸고 요소는 쉼표로 구분해요. OCaml에서 튜플을 만들고 다루는 기본 문법이에요.
# ("felix", 1920);;
- : string * int = ("felix", 1920)
함수 매개변수를 지정하는 데 튜플 문법을 사용할 수 있어요. 돌고 있는 예시의 또 다른 버전을 정의하는 데 이렇게 쓸 수 있어요.
# let spicy_cat (x, y) = x ^ " " ^ y;;
val spicy_cat : string * string -> string = <fun>
# spicy_cat ("hello", "world");;
- : string = "hello world"
인자 두 개 "hello"와 "world"가 전달된 것처럼 보여요. 하지만 실제로는 하나, 즉 ("hello", "world") 튜플만 전달됐어요. 생성된 어셈블리를 살펴보면 sweet_cat과 같은 함수가 아님을 알 수 있어요. 코드가 조금 더 들어 있어요. spicy_cat에 전달된 튜플의 내용(x와 y)은 x ^ " " ^ y 표현식을 평가하기 전에 추출되어야 해요. 추가된 어셈블리 명령의 역할이 바로 그것이에요.
많은 명령형 언어에서 spicy_cat ("hello", "world") 문법은 인자 두 개를 가진 함수 호출로 읽혀요. 하지만 OCaml에서 그것은 "hello"와 "world"를 포함한 튜플에 함수 spicy_cat을 적용하는 것을 나타내요.
커링과 언커링 (Currying and Uncurrying)
이전 절들에서 두 종류의 여러 매개변수 함수를 소개했어요.
sweet_cat와sour_cat같은 함수를 반환하는 함수spicy_cat처럼 튜플을 매개변수로 받는 함수
흥미롭게도 두 종류의 함수 모두 단일 매개변수를 가진 함수이면서 여러 데이터를 전달하는 방법을 제공해요. 이런 관점에서 "모든 함수는 인자가 하나다"라고 말하는 게 말이 돼요.
이것은 더 나아가요. sweet_cat(또는 sour_cat)처럼 보이는 함수와 spicy_cat처럼 보이는 함수 사이를 항상 오갈 수 있어요.
이 변환들에는 이름이 있어요.
- 커링(Currying)은
spicy_cat형태에서sour_cat(또는sweet_cat) 형태로 가요. - 언커링(Uncurrying)은
sour_cat(또는sweet_cat) 형태에서spicy_cat형태로 가요.
또한 sweet_cat과 sour_cat은 커리된(curried) 함수이고 spicy_cat은 언커리된(uncurried) 함수라고 말해요.
다음 타입을 가진 함수들은 서로 변환될 수 있어요.
string -> (string -> string)— 커리된 함수 타입string * string -> string— 언커리된 함수 타입
이 변환들은 20세기 논리학자 Haskell Curry의 이름을 땄어요.
여기서는 string을 예시로 보여주지만, 어떤 세 타입의 묶음에도 적용돼요.
리팩터링할 때 커리된 형태를 언커리된 형태로 바꾸거나 그 반대로 바꿀 수 있어요.
하지만 둘 다 갖고 싶다면 하나로부터 다른 것을 구현하는 것도 가능해요.
# let uncurried_cat (x, y) = sweet_cat x y;;
val uncurried_cat : string * string -> string = <fun>
# let curried_cat x y = uncurried_cat (x, y);;
val curried_cat : string -> string -> string = <fun>
실제로 커리된 함수가 기본인데, 그 이유는.
- 부분 적용을 허용
- 괄호나 쉼표가 없음
- 튜플에 대한 패턴 매칭이 일어나지 않음
부작용을 가진 함수 (Functions With Side Effects)
부작용을 설명하려면 정의역(domain)과 공역(codomain)이 무엇인지 정의해야 해요. 예시를 볼게요.
# string_of_int;;
- : int -> string = <fun>
string_of_int 함수에 대해.
- 정의역은
int, 매개변수의 타입 - 공역은
string, 결과의 타입
다시 말해 정의역은 ->의 왼쪽, 공역은 오른쪽이에요. 이 용어들은 함수 타입 화살표의 "오른쪽 타입"이나 "왼쪽 타입"이라고 말하는 것을 피하게 해줘요.
일부 함수는 정의역이나 공역 밖의 데이터에 대해 동작해요. 이런 동작을 효과(effect) 또는 부작용(side effect)이라고 불러요.
운영체제와의 입출력(I/O)이 가장 흔한 부작용 형태예요. 난수를 반환하는 함수(예: Random.bits)나 현재 시각을 반환하는 함수(예: Unix.time)의 결과는 외부 요인의 영향을 받는데, 이것도 효과라고 불러요.
마찬가지로 함수의 계산이 촉발하는 관찰 가능한 현상은 모두 공역 밖의 출력이에요.
실제로 무엇을 효과로 볼지는 공학적 선택이에요. 대부분의 상황에서 시스템 I/O 연산은 (무시되지 않는 한) 효과로 간주돼요. 함수를 계산할 때 프로세서에서 방출되는 열은, 에너지 효율 설계를 고려하지 않는 한 보통 관련 있는 부작용으로 여겨지지 않아요.
OCaml 커뮤니티와 더 넓은 함수형 프로그래밍 커뮤니티에서 함수는 종종 순수(pure)하거나 불순(impure)하다고 말해요. 전자는 부작용이 없고 후자는 있어요. 이 구분은 말이 되고 유용해요. 효과가 무엇이고 언제 일어나는지 아는 것은 핵심 설계 고려 사항이에요. 하지만 이 구분은 항상 어떤 종류의 맥락을 가정한다는 것을 기억하는 게 중요해요. 모든 계산은 효과가 있고, 무엇을 관련 효과로 볼지는 설계 선택이에요.
정의상 효과는 함수 타입 밖에 있으므로, 함수 타입은 함수의 가능한 효과를 반영할 수 없어요. 하지만 함수의 의도된 부작용을 문서화하는 것은 중요해요. Unix.time 함수를 생각해 보세요. 1970년 1월 1일부터 경과한 초 수를 반환해요.
# Unix.time ;;
- : unit -> float = <fun>
참고: macOS에서 Unbound module 에러가 나면 먼저 #require "unix";;를 실행하세요.
Unix.time 함수의 결과는 오직 외부 요인에 의해서만 결정돼요. 부작용을 수행하려면 함수를 인자에 적용해야 해요. 전달할 데이터가 없으므로 인자는 () 값이에요.
print_endline을 생각해 보세요. 전달받은 문자열을 줄바꿈과 함께 표준 출력에 출력해요.
# print_endline;;
- : string -> unit = <fun>
함수의 목적이 오직 효과를 만들기 위한 것이므로, 반환할 의미 있는 데이터가 없어요. 그래서 () 값을 반환해요.
이것은 부작용을 가진 함수와 unit 타입 사이의 관계를 보여줘요. unit 타입이 있다고 해서 부작용이 있다는 뜻은 아니고, unit 타입이 없다고 해서 부작용이 없다는 뜻도 아니에요. 하지만 입력으로 전달하거나 출력으로 반환할 데이터가 없을 때 unit 타입을 사용해요.
함수를 다른 값과 다르게 만드는 것 (What Makes Functions Different From Other Values)
함수는 다른 값과 같아요. 하지만 제약이 있어요.
함수 값은 대화형 세션에서 표시할 수 없어요. 대신 자리 표시자 <fun>이 표시돼요. 출력할 의미 있는 것이 없기 때문이에요. 일단 파싱되고 타입 검사되면 OCaml은 함수의 소스 코드를 버리므로 출력할 것이 남지 않아요.
# sqrt;;
- : float -> float = <fun>
함수 사이의 동등성은 검사할 수 없어요.
# pred;;
- : int -> int = <fun>
# succ;;
- : int -> int = <fun>
# pred = succ;;
Exception: Invalid_argument "compare: functional value".
이유는 크게 두 가지예요.
- 두 함수를 받아 같은 입력에 대해 같은 출력을 반환하는지 판단하는 알고리즘은 없어요.
- 가능하다고 가정해도, 그런 알고리즘은 퀵 정렬과 버블 정렬의 구현이 같다고 선언할 거예요. 그건 하나가 다른 하나를 대체할 수 있다는 뜻이고, 그건 현명하지 않을 수 있어요.
결론 (Conclusion)
OCaml의 핵심에는 환경(environment)이라는 개념이 있어요. 환경은 순서 있는, 추가 전용의, 키-값 저장소로 동작해요. 즉 항목을 제거할 수 없어요. 게다가 사용 가능한 정의들의 시퀀스를 보존함으로써 순서를 유지해요.
let 문을 사용하면 환경에 이름-값 쌍을 0개, 1개, 혹은 그 이상 도입해요. 마찬가지로 함수를 어떤 인자들에 적용할 때 인자에 해당하는 이름과 값을 추가해 환경을 확장해요.