OCaml 둘러보기

OCaml 둘러보기

이 튜토리얼은 OCaml의 기본 기능을 소개해요. 값, 표현식, 리스트, 함수, 패턴 매칭 같은 것들이죠. OCaml이나 함수형 프로그래밍에 대한 지식은 전혀 없어도 괜찮지만, 기본적인 소프트웨어 개발 경험이 있다고 가정해요. 그리고 Install OCaml 페이지에서 설명한 대로 OCaml을 설치하고 환경을 준비해 두세요.

예제를 직접 실행해 보면서 OCaml로 코딩하는 감각을 익혀보길 권해요. 그때 쓸 수 있는 도구가 UTop(Universal Toplevel)이에요. UTop은 사용자가 OCaml과 상호작용할 수 있게 해주는 도구로, 표현식이나 값 정의 같은 OCaml 문구를 읽고 평가한 뒤 그 결과를 화면에 출력해 줘요. utop 명령으로 실행하고, Ctrl+D를 눌러 종료해요. 더 자세한 내용은 OCaml Toplevel 소개에서 읽어볼 수 있어요.

이 둘러보기의 예제 중에는 주석(comment)이 포함된 것도 있어요. OCaml 주석은 (*로 시작해 *)로 끝나고, 중첩할 수도 있어요. OCaml이 주석을 무시하기 때문에 공백이 허용되는 곳이라면 어디에든 쓸 수 있어요. 아래 코드를 UTop에 입력할 때는 주석을 빼도 상관없어요. 예시를 볼게요.

(* Here is a comment *)
(* Outside of the nested comment is still a comment. (* Here is a nested comment *) Outside of the nested comment again. *)
# 50 + (* A comment in between parts of an expression *) 50;;
- : int = 100

출처: A Tour of OCaml

식과 정의 (Expressions and Definitions)

먼저 간단한 표현식부터 시작해 볼게요.

# 50 * 50;;
- : int = 2500

OCaml에서는 모든 것이 값을 갖고, 모든 값은 타입을 가져요. 위 예시는 "50 * 50int(정수) 타입을 갖고 2500으로 평가되는 표현식"이라는 뜻이에요. 이 표현식은 이름이 없는 익명 표현식이라서, 이름 대신 - 문자가 나타나요.

끝에 붙는 이중 세미콜론 ;;은 toplevel에게 해당 문구를 평가하고 결과를 출력하라고 지시해요.

다른 기본 값과 타입의 예시도 살펴볼게요.

# 6.28;;
- : float = 6.28

# "This is really disco!";;
- : string = "This is really disco!"

# 'a';; (* Note the single quotes *)
- : char = 'a'

# true;;
- : bool = true

OCaml에는 *타입 추론(type inference)*이 있어요. 프로그래머가 별다른 힌트를 주지 않아도 표현식의 타입을 자동으로 결정해 주죠. 리스트에 대해서는 전용 튜토리얼이 따로 있어요. 지금은 아래 두 표현식이 모두 리스트라는 것만 알아둘게요. 앞쪽은 정수, 뒤쪽은 문자열을 담고 있어요.

# let u = [1; 2; 3; 4];;
val u : int list = [1; 2; 3; 4]

# ["this"; "is"; "mambo"];;
- : string list = ["this"; "is"; "mambo"]

리스트의 타입 int list, string list는 요소의 타입에서 추론된 거예요. 리스트는 비어 있을 수도 있는데, 그건 []로 쓰고 "nil"이라고 읽어요. 앞쪽 리스트 이름은 아래에서 자세히 설명할 let … = …을 써서 붙인 거예요. 리스트에서 가장 기본적인 연산은 기존 리스트 맨 앞에 새 요소를 추가하는 것이고, 이중 콜론 연산자 ::로 쓰는 "cons" 연산자가 그 역할을 해요.

# 9 :: u;;
- : int list = [9; 1; 2; 3; 4]

OCaml에서 if … then … else …는 명령문(statement)이 아니라 표현식이에요.

# 2 * if "hello" = "world" then 3 else 5;;
- : int = 10

if로 시작해 5로 끝나는 부분은 하나의 정수 표현식으로 파싱되어 2와 곱해져요. OCaml에는 두 가지 종류의 조건문이 필요 없어요. 삼항 조건 연산자if … then … else …가 같은 거예요. 그리고 여기서 괄호가 필요 없다는 점도 눈여겨볼게요. OCaml에서는 흔히 그런 경우가 많아요.

값은 let 키워드로 이름을 붙일 수 있어요. 이를 값을 이름에 *바인딩(binding)*한다고 해요. 예시를 볼게요.

# let x = 50;;
val x : int = 50

# x * x;;
- : int = 2500

let x = 50;;을 입력하면 OCaml이 val x : int = 50이라고 응답하는데, 이는 x가 값 50에 바인딩된 식별자라는 뜻이에요. 그래서 x * x;;50 * 50;;과 같은 결과로 평가돼요.

OCaml의 바인딩은 *불변(immutable)*이에요. 이름에 할당된 값이 절대 바뀌지 않는다는 뜻이죠. x를 흔히 변수라고 부르지만 사실은 아니에요. 실제로는 상수에 가까워요. 조금 단순화해도 괜찮은 표현으로 말하자면, OCaml에서는 모든 변수가 불변이라고 할 수 있어요. 물론 값을 바꿀 수 있는 이름을 붙이는 것도 가능하긴 한데, OCaml에서 그건 *참조(reference)*라고 부르고 Working With Mutable State 섹션에서 다룰 거예요.

OCaml에는 오버로딩이 없어서, 하나의 어휘 스코프 안에서 이름은 정의에 의존하는 단 하나의 값만 가져요.

이름에 대시(dash)를 쓰지 마세요. 대신 밑줄(underscore)을 쓰세요. 예를 들어 x_plus_y는 되지만 x-plus-y는 안 돼요.

바인딩에는 에디터와 도구가 바인딩과 관련된 것으로 취급하는 특별한 주석("docstring"이라고도 불러요)을 달 수 있어요. 주석 여는 부분에 *를 하나 더 추가하면 돼요. 예를 들면:

# (** Feet in a mile *)
  let feet = 5280;;
val feet : int = 5280

이 내용은 odoc for Authors: Special Comments에서 더 다뤄요.

이름은 let … = … in … 문법으로 표현식 안에 지역적으로 정의할 수도 있어요.

# let y = 50 in y * y;;
- : int = 2500

# y;;
Error: Unbound value y

이 예시는 이름 y를 정의하고 값을 50에 바인딩한 뒤, y * y 표현식에서 사용해 2500이라는 결과를 만든 거예요. yin 키워드 뒤에 오는 표현식 안에서만 정의된다는 점을 기억해 두세요.

let … = … in …은 그 자체가 표현식이기 때문에, 다른 표현식 안에 넣어 이름을 가진 여러 값을 한 번에 만들 수도 있어요.

# let a = 1 in
  let b = 2 in
    a + b;;
- : int = 3

이렇게 두 이름 a(값 1), b(값 2)를 정의하고, 그 둘을 a + b 표현식에서 사용해 값 3을 얻었어요.

OCaml에서 등호 기호는 두 가지 의미를 가져요. 정의와 동등성 테스트에 각각 쓰이죠.

# let dummy = "hi" = "hello";;
val dummy : bool = false

이는 "dummy를 문자열 "hi""hello" 사이의 구조적 동등성 테스트 결과로 정의한다"로 해석돼요. OCaml에는 또 이중 등호 연산자 ==도 있는데, 이건 물리적 동등성(physical equality)을 뜻해요. 다만 이 튜토리얼에서는 쓰지 않아요. 연산자 <>=의 부정이고, !===의 부정이에요.

함수 (Functions)

OCaml에서는 모든 것이 값이므로 함수도 값이에요. 함수는 let 키워드로 정의해요.

# let square x = x * x;;
val square : int -> int = <fun>

# square 50;;
- : int = 2500

이 예시는 단일 매개변수 x를 갖는 square라는 함수를 정의한 거예요. 함수 *본문(body)*은 x * x라는 표현식이죠. OCaml에는 "return" 키워드가 없어요.

square50에 적용하면 x * x50 * 50으로 평가되어 2500이 돼요.

REPL이 square의 타입을 int -> int라고 알려주는데, 이는 int를 인자(입력)로 받아 int를 결과(출력)로 돌려주는 함수라는 뜻이에요. 함수 값은 화면에 표시할 수 없어서 대신 <fun>이 출력돼요.

# String.ends_with;;
- : suffix:string -> string -> bool = <fun>

# String.ends_with ~suffix:"less" "stateless";;
- : bool = true

String.ends_with 같은 일부 함수는 라벨 붙은 매개변수(labelled parameter)를 갖고 있어요. 라벨은 함수에 같은 타입의 매개변수가 여럿 있을 때 유용한데, 인자에 이름을 붙이면 그 용도를 짐작할 수 있으니까요. 위 예시에서 ~suffix:"less""less"가 라벨 붙은 인자 suffix로 전달된다는 뜻이에요. 라벨 인자에 대한 자세한 내용은 Labelled Arguments 튜토리얼에서 다뤄요.

익명 함수 (Anonymous Functions)

익명(anonymous) 함수는 이름이 없고, fun 키워드로 정의해요.

# fun x -> x * x;;
- : int -> int = <fun>

익명 함수를 바로 값에 적용할 수도 있어요.

# (fun x -> x * x) 50;;
- : int = 2500

여러 매개변수를 갖는 함수와 부분 적용 (Functions with Multiple Parameters and Partial Application)

함수는 공백으로 구분된 여러 매개변수를 가질 수 있어요.

# let cat a b = a ^ " " ^ b;;
val cat : string -> string -> string = <fun>

cat 함수는 string 타입의 매개변수 a, b 두 개를 받아 string 타입의 값을 돌려줘요.

# cat "ha" "ha";;
- : string = "ha ha"

함수는 기대하는 인자를 모두 전달하지 않고 호출할 수도 있어요. b 없이 acat에 전달할 수 있죠.

# let cat_hi = cat "hi";;
val cat_hi : string -> string = <fun>

이렇게 하면 문자열 하나를 기대하는 함수가 반환되는데, 여기서 그 문자열이 바로 cat 정의에서의 b예요. 이를 *부분 적용(partial application)*이라고 해요. 위 예시에서 cat"hi"에 부분 적용된 거예요.

cat의 부분 적용으로 만들어진 cat_hi는 이렇게 동작해요.

# cat_hi "friend";;
- : string = "hi friend"

타입 매개변수와 고차 함수 (Type Parameters and Higher-Order Functions)

함수가 함수를 매개변수로 기대할 수 있는데, 그런 함수를 고차(higher-order) 함수라고 해요. 고차 함수의 대표적인 예가 List.map이에요. 이렇게 사용해요.

# List.map;;
- : ('a -> 'b) -> 'a list -> 'b list = <fun>

# List.map (fun x -> x * x);;
- : int list -> int list = <fun>

# List.map (fun x -> x * x) [0; 1; 2; 3; 4; 5];;
- : int list = [0; 1; 4; 9; 16; 25]

이 함수 이름이 List.으로 시작하는 이유는 리스트에 작용하는 함수들의 사전 정의된 라이브러리 일부이기 때문이에요. 이 부분은 나중에 더 자세히 다룰게요. List.map은 매개변수 두 개를 갖는데, 두 번째는 리스트이고 첫 번째는 리스트의 요소가 무엇이든 그 요소에 적용할 수 있는 함수예요. List.map은 입력 리스트의 각 요소에 인자로 받은 함수를 적용해 만든 리스트를 반환해요.

List.map은 어떤 종류의 리스트에도 적용할 수 있어요. 여기서는 정수 리스트에 주었지만, float이나 문자열, 그 밖의 무엇이든 될 수 있어요. 이를 *다형성(polymorphism)*이라고 해요. List.map은 다형적 함수로, 두 개의 암묵적 타입 변수(type variable) 'a'b("알파"와 "베타"라고 읽어요)를 갖고 있다는 뜻이에요. 이 둘은 무엇이든 될 수 있는데, 다만 List.map에 전달된 함수에 관해서는 다음 두 가지가 성립해요.

  1. 입력 리스트의 요소는 그 함수의 입력과 같은 타입이다.
  2. 출력 리스트의 요소는 그 함수의 출력과 같은 타입이다.

부수 효과와 unit 타입 (Side-Effects and the unit Type)

운영체제 수준의 입출력 연산을 수행하는 것도 함수로 해요. 각각의 예시를 볼게요.

# read_line;;
- : unit -> string = <fun>

# read_line ();;
caramba
- : string = "caramba"

# print_endline;;
- : string -> unit = <fun>

# print_endline "¿Cuándo se come aquí?";;
¿Cuándo se come aquí?
- : unit = ()

read_line 함수는 표준 입력에서 문자를 읽어서 줄의 끝(EOL)에 도달하면 문자열로 반환해요. print_endline 함수는 표준 출력에 문자열을 출력한 뒤 EOL을 붙여요.

read_line 함수는 진행하는 데 필요한 데이터가 없고, print_endline은 반환할 의미 있는 데이터가 없어요. 이런 데이터의 부재를 나타내는 역할이 시그니처에 나오는 unit 타입이에요. unit 타입은 ()라고 쓰고 "unit"이라고 읽는 단 하나의 값만 갖고 있어요. 처리에 필요한 어떤 토큰은 아직 전달돼야 하지만 데이터는 전달되거나 반환되지 않을 때, 그 자리를 채우는 플레이스홀더로 쓰이는 거예요.

입출력은 함수를 실행할 때 일어나면서도 함수 타입에는 나타나지 않는 것의 예시예요. 이를 *부수 효과(side-effect)*라고 하고, I/O에서만 일어나는 건 아니에요. unit 타입은 항상 그런 건 아니지만 부수 효과의 존재를 나타내는 데 자주 쓰여요.

재귀 함수 (Recursive Functions)

재귀 함수는 자기 자신의 본문 안에서 자신을 호출하는 함수예요. 이런 함수는 보통 let 대신 let rec … = …으로 선언해야 해요. OCaml에서 반복 계산을 수행하는 방법이 재귀뿐인 건 아니에요. forwhile 같은 루프도 있지만, 그것들은 변경 가능한 데이터와 함께 명령형 OCaml을 작성할 때 쓰라고 있는 거예요. 그 외에는 재귀 함수를 선호하는 편이 좋아요.

두 경계 사이의 연속된 정수 리스트를 만드는 함수 예시를 볼게요.

# let rec range lo hi =
    if lo > hi then
      []
    else
      lo :: range (lo + 1) hi;;
val range : int -> int -> int list = <fun>

# range 2 5;;
- : int list = [2; 3; 4; 5]

타입 int -> int -> int list가 보여주듯, range 함수는 정수 두 개를 인자로 받아 정수 리스트를 결과로 돌려줘요. 첫 번째 int 매개변수 lo는 범위의 하한이고, 두 번째 int 매개변수 hi는 상한이에요. lo > hi면 빈 범위가 반환돼요. 그게 if … then … else 표현식의 첫 번째 분기예요. 그렇지 않으면 lo 값을 range 자신을 호출해서 만든 리스트 앞에 추가하는데, 이게 재귀예요. 앞에 추가하는 것은 OCaml의 cons 연산자인 ::로 해요. 기존 리스트 맨 앞에 요소를 넣어 새 리스트를 만드는 거죠. 호출할 때마다 진행이 이뤄져요. lo가 막 리스트 머리에 추가됐으니, rangelo + 1로 호출돼요. 이렇게 그려볼 수 있어요(이건 OCaml 문법이 아니에요):

   range 2 5
=> 2 :: range 3 5
=> 2 :: 3 :: range 4 5
=> 2 :: 3 :: 4 :: range 5 5
=> 2 :: 3 :: 4 :: 5 :: range 6 5
=> 2 :: 3 :: 4 :: 5 :: []
=> [2; 3; 4; 5]

마지막 =>를 제외하고 각 => 기호는 한 번의 재귀 단계 계산에 해당해요. OCaml은 마지막에서 두 번째 표현식에 보인 것처럼 리스트를 내부적으로 다루지만, 마지막 표현식처럼 표시해요. 이건 그저 예쁘게 출력하는 것뿐이고, 마지막 두 단계 사이에 어떤 계산도 일어나지 않아요.

데이터와 타이핑 (Data and Typing)

타입 변환과 타입 추론 (Type Conversion and Type-Inference)

OCaml에는 float 타입의 부동소수점 값이 있어요. float끼리 더할 때는 + 대신 +.을 써야 해요.

# 2.0 +. 2.0;;
- : float = 4.

OCaml에서 +.는 float끼리의 덧셈이고, +는 정수끼리의 덧셈이에요.

많은 프로그래밍 언어에서는 한 타입의 값을 다른 타입으로 자동 변환할 수 있어요. 여기에는 암묵적 타입 변환과 *승격(promotion)*이 포함돼요. 예를 들어 그런 언어에서 1 + 2.5라고 쓰면 첫 번째 인자(정수)가 부동소수점 숫자로 승격되어 결과도 부동소수점 숫자가 돼요.

OCaml은 값을 한 타입에서 다른 타입으로 절대 암묵적으로 변환하지 않아요. float와 정수의 덧셈은 수행할 수 없어요. 아래 두 예시 모두 오류를 던져요.

# 1 + 2.5;;
Error: This expression has type float but an expression was expected of type
         int

# 1 +. 2.5;;
Error: This expression has type int but an expression was expected of type
         float
  Hint: Did you mean `1.'?

첫 번째 예시에서 +는 정수에 쓰라고 있는 거라서 2.5라는 float와는 쓸 수 없어요. 두 번째 예시에서 +.는 float에 쓰라고 있어서 1이라는 정수와는 쓸 수 없어요.

OCaml에서는 float_of_int 함수로 정수를 명시적으로 부동소수점 숫자로 변환해야 해요.

# float_of_int 1 +. 2.5;;
- : float = 3.5

OCaml이 명시적 변환을 요구하는 데는 여러 이유가 있어요. 가장 중요한 건 타입을 자동으로 알아낼 수 있게 해준다는 점이에요. OCaml의 타입 추론 알고리즘은 각 표현식에 타입을 계산해 주는데, 다른 언어와 비교하면 주석이 거의 필요 없어요. 더 명시적으로 쓰느라 잃는 시간보다 이게 아끼는 시간이 더 크다고 할 만해요.

리스트 (Lists)

리스트는 OCaml에서 가장 흔한 데이터 타입일 거예요. 같은 타입의 값들을 순서대로 모은 컬렉션이죠. 몇 가지 예시를 볼게요.

# [];;
- : 'a list = []

# [1; 2; 3];;
- : int list = [1; 2; 3]

# [false; false; true];;
- : bool list = [false; false; true]

# [[1; 2]; [3]; [4; 5; 6]];;
- : int list list = [[1; 2]; [3]; [4; 5; 6]]

위 예시는 이렇게 읽어요.

  1. 빈 리스트, nil
  2. 숫자 1, 2, 3을 담은 리스트
  3. 불리언 false, false, true를 담은 리스트. 반복은 허용돼요.
  4. 리스트들의 리스트

리스트는 []로 쓰는 빈 리스트이거나, 다른 리스트 u의 앞에 요소 x를 추가한, 즉 x :: u로 쓰는 형태로 정의돼요(이중 콜론 연산자는 "cons"이라고 읽어요).

# 1 :: [2; 3; 4];;
- : int list = [1; 2; 3; 4]

OCaml에서 *패턴 매칭(pattern matching)*은 함수를 제외한 어떤 종류의 데이터를 살펴볼 수 있는 수단을 제공해요. 이 섹션에서는 리스트에 대해 소개하고, 다음 섹션에서 다른 데이터 타입으로 일반화할 거예요. 패턴 매칭으로 정수 리스트의 합을 계산하는 재귀 함수를 정의하는 방법을 볼게요.

# let rec sum u =
    match u with
    | [] -> 0
    | x :: v -> x + sum v;;
val sum : int list -> int = <fun>

# sum [1; 4; 3; 2; 5];;
- : int = 15

두 번째 매칭 표현식의 x :: v 패턴은 리스트를 머리(head) x와 꼬리(tail) v로 분해해요. 여기서 머리는 리스트의 첫 요소이고 꼬리는 리스트의 나머지예요.

리스트의 다형적 함수 (Polymorphic Functions on Lists)

리스트의 길이를 계산하는 재귀 함수를 이렇게 쓸 수 있어요.

# let rec length u =
    match u with
    | [] -> 0
    | _ :: v -> 1 + length v;; (* _ doesn't define a name; it can't be used in the body *)
val length : 'a list -> int = <fun>

# length [1; 2; 3; 4];;
- : int = 4

# length ["cow"; "sheep"; "cat"];;
- : int = 3

# length [[]];;
- : int = 1

이 함수는 정수 리스트뿐 아니라 어떤 종류의 리스트에도 작동해요. 다형적 함수인 거죠. 타입이 'a list라는 입력을 나타내는데, 여기서 'a는 어떤 타입이든 대신하는 타입 변수예요. 빈 리스트 패턴 []는 어떤 요소 타입이든 될 수 있어요. 그래서 _ :: v 패턴은 리스트 머리의 값이 무관하다는 뜻인데, _ 패턴이 그 값을 살펴보지 않는다는 뜻이기 때문이에요. 두 패턴 모두 같은 타입이어야 하므로, 타이핑 알고리즘은 'a list -> int 타입을 추론해요.

고차 함수 정의하기 (Defining a Higher-Order Function)

함수를 다른 함수의 인자로 전달할 수 있어요. 다른 함수를 매개변수로 갖는 함수를 고차 함수라고 해요. 앞서 List.map 함수로 이미 보여드렸죠. 리스트에 대한 패턴 매칭을 써서 map을 이렇게 직접 작성할 수 있어요.

# let square x = x * x;;
val square : int -> int

# let rec map f u =
    match u with
    | [] -> []
    | x :: u -> f x :: map f u;;
val map : ('a -> 'b) -> 'a list -> 'b list = <fun>

# map square [1; 2; 3; 4;];;
- : int list = [1; 4; 9; 16]

패턴 매칭, 계속 (Pattern Matching, Cont'd)

패턴 매칭은 리스트에만 국한되지 않아요. 함수를 제외한 어떤 종류의 데이터든 살펴볼 수 있어요. 패턴은 살펴볼 값과 비교되는 표현식이에요. if … then … else …으로도 할 수 있지만 패턴 매칭이 더 편리해요. 아래는 Modules and the Standard Library 섹션에서 자세히 다룰 option 데이터 타입을 쓴 예시예요.

# #show option;;
type 'a option = None | Some of 'a

# let f opt = match opt with
    | None -> None
    | Some None -> None
    | Some (Some x) -> Some x;;
val f : 'a option option-> 'a option = <fun>

살펴볼 값은 option 타입의 opt예요. 이 값을 위에서 아래로 패턴과 비교해요. optNone 옵션이면 첫 번째 패턴과 일치하고, Some None 옵션이면 두 번째 패턴과 일치해요. 값을 담은 이중으로 감싼 옵션이면 세 번째 패턴과 일치하죠. 패턴은 let처럼 이름을 도입할 수 있어요. 세 번째 패턴에서 x는 이중으로 감싼 옵션 안의 데이터를 가리켜요.

패턴 매칭은 Basic Datatypes 튜토리얼과 각 데이터 타입별 튜토리얼에서 자세히 다뤄요.

아래 다른 예시는 if … then … else …와 패턴 매칭으로 같은 비교를 한 거예요.

# let g x =
  if x = "foo" then 1
  else if x = "bar" then 2
  else if x = "baz" then 3
  else if x = "qux" then 4
  else 0;;
val g : string -> int = <fun>

# let g' x = match x with
    | "foo" -> 1
    | "bar" -> 2
    | "baz" -> 3
    | "qux" -> 4
    | _ -> 0;;
val g' : string -> int = <fun>

밑줄 기호는 catch-all 패턴이에요. 무엇과도 일치하죠.

참고로 OCaml은 패턴 매칭이 모든 경우를 잡지 못하면 경고를 던져요.

# fun i -> match i with 0 -> 1;;
Line 1, characters 9-28:
Warning 8 [partial-match]: this pattern-matching is not exhaustive.
Here is an example of a case that is not matched:
1
- : int -> int = <fun>

쌍과 튜플 (Pairs and Tuples)

튜플은 어떤 타입의 요소든 담는 고정 길이 컬렉션이에요. 쌍(pair)은 요소가 둘인 튜플이죠. 여기 3-튜플과 쌍이 있어요.

# (1, "one", 'K');;
- : int * string * char = (1, "one", 'K')

# ([], false);;
- : 'a list * bool = ([], false)

튜플의 구성 요소에 접근하는 것은 패턴 매칭으로 해요. 예를 들어 사전 정의된 함수 snd는 쌍의 두 번째 구성 요소를 반환해요.

# let snd p =
    match p with
    | (_, y) -> y;;
val snd : 'a * 'b -> 'b = <fun>

# snd (42, "apple");;
- : string = "apple"

참고: snd 함수는 OCaml 표준 라이브러리에 사전 정의되어 있어요.

튜플의 타입은 구성 요소 타입 사이에 *를 써서 표기해요.

변형 타입 (Variant Types)

패턴 매칭이 switch 문을 일반화하듯, 변형 타입은 열거형과 합집합 타입을 일반화해요.

열거형 데이터 타입 역할을 하는 변형 타입의 정의를 볼게요.

# type primary_colour = Red | Green | Blue;;
type primary_colour = Red | Green | Blue

# [Red; Blue; Red];;
- : primary_colour list = [Red; Blue; Red]

합집합 타입 역할을 하는 변형 타입의 정의예요.

# type http_response =
    | Data of string
    | Error_code of int;;
type http_response = Data of string | Error_code of int

# Data "<!DOCTYPE html>
<html lang=\"en\">
  <head>
    <meta charset=\"utf-8\">
    <title>Dummy</title>
  </head>
  <body>
    Dummy Page
  </body>
</html>";;

- : http_response =
Data
 "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\">\n    <title>Dummy</title>\n  </head>\n  <body>\n    Dummy Page\n  </body>\n</html>"

# Error_code 404;;
- : http_response = Error_code 404

그 사이쯤 되는 것도 있어요.

# type page_range =
    | All
    | Current
    | Range of int * int;;
type page_range = All | Current | Range of int * int

앞선 정의들에서 대문자로 시작하는 식별자를 *생성자(constructor)*라고 해요. 변형 값을 만드는 데 쓰이죠. 이건 객체 지향 프로그래밍과는 관계가 없어요.

이 섹션 첫 문장에서 시사했듯, 변형 타입은 패턴 매칭과 함께 쓰여요. 몇 가지 예시를 볼게요.

# let colour_to_rgb colour =
    match colour with
    | Red -> (0xff, 0, 0)
    | Green -> (0, 0xff, 0)
    | Blue -> (0, 0, 0xff);;
val colour_to_rgb : primary_colour -> int * int * int = <fun>

# let http_status_code response =
    match response with
    | Data _ -> 200
    | Error_code code -> code;;
val http_status_code : http_response -> int = <fun>

# let is_printable page_count cur range =
    match range with
    | All -> true
    | Current -> 0 <= cur && cur < page_count
    | Range (lo, hi) -> 0 <= lo && lo <= hi && hi < page_count;;
val is_printable : int -> int -> page_range -> bool = <fun>

변형 타입은 함수처럼 자신의 정의 안에서 자기 자신을 가리키면 재귀적일 수 있어요. 사전 정의된 타입 list가 그런 변형 타입의 예시예요.

# #show list;;
type 'a list = [] | (::) of 'a * 'a list

앞서 보여드린 sum, length, map 함수가 리스트 변형 타입에 대한 패턴 매칭의 예시라는 걸 기억해 둘게요.

레코드 (Records)

튜플처럼 레코드도 여러 타입의 요소를 함께 묶어요. 다만 각 요소에 이름이 붙는다는 점이 다르죠. 변형 타입처럼 레코드 타입도 사용하기 전에 먼저 정의해야 해요. 레코드 타입, 값, 구성 요소 접근, 같은 레코드에 대한 패턴 매칭의 예시를 볼게요.

# type person = {
    first_name : string;
    surname : string;
    age : int
  };;
type person = { first_name : string; surname : string; age : int; }

# let gerard = {
     first_name = "Gérard";
     surname = "Huet";
     age = 76
  };;
val gerard : person = {first_name = "Gérard"; surname = "Huet"; age = 76}

gerard를 정의할 때는 타입을 선언할 필요가 없어요. 타입 체커가 이름과 타입이 정확히 일치하는 필드 세 개를 가진 레코드를 찾아내기 때문이에요. 레코드 사이에는 타이핑 관계가 없다는 점을 주의하세요. 필드를 추가해 다른 레코드 타입을 확장하는 레코드 타입은 선언할 수 없어요. 레코드 타입 검색은 정확히 일치하는 것을 찾으면 성공하고, 그 외의 경우에는 실패해요.

# let s = gerard.surname;;
val s : string = "Huet"

# let is_teenager person =
    match person with
    | { age = x; _ } -> 13 <= x && x <= 19;;
val is_teenager : person -> bool = <fun>

# is_teenager gerard;;
- : bool = false

여기서 { age = x; _ } 패턴은 age 필드가 int 타입인 가장 최근에 선언된 레코드 타입으로 타이핑돼요. int 타입은 13 <= x && x <= 19 표현식에서 추론되는 거예요. is_teenager 함수는 찾아낸 레코드 타입, 여기서는 person에서만 동작해요.

오류 다루기 (Dealing With Errors)

예외 (Exceptions)

계산이 중단되면 예외가 던져져요. 예를 들면:

# 10 / 0;;
Exception: Division_by_zero.

예외는 raise 함수로 발생시켜요.

# let id_42 n = if n <> 42 then raise (Failure "Sorry") else n;;
val id_42 : int -> int = <fun>

# id_42 42;;
- : int = 42

# id_42 0;;
Exception: Failure "Sorry".

예외는 함수 타입에 나타나지 않는다는 점을 주의하세요.

예외는 try … with … 구성으로 잡아요.

# try id_42 0 with Failure _ -> 0;;
- : int = 0

표준 라이브러리는 여러 사전 정의된 예외를 제공하고, 예외를 직접 정의할 수도 있어요.

result 타입 사용하기 (Using the result Type)

OCaml에서 오류를 다루는 또 다른 방법은 정확한 결과나 오류를 나타낼 수 있는 result 타입의 값을 반환하는 거예요. 정의는 이렇게 생겼어요.

# #show result;;
type ('a, 'b) result = Ok of 'a | Error of 'b

그래서 이렇게 쓸 수 있어요.

# let id_42_res n = if n <> 42 then Error "Sorry" else Ok n;;
val id_42_res : int -> (int, string) result = <fun>

# id_42_res 42;;
- : (int, string) result = Ok 42

# id_42_res 0;;
- : (int, string) result = Error "Sorry"

# match id_42_res 0 with
  | Ok n -> n
  | Error _ -> 0;;
- : int = 0

변경 가능한 상태 다루기 (Working with Mutable State)

OCaml은 명령형 프로그래밍을 지원해요. 보통 let … = … 문법은 변수를 정의하는 게 아니라 상수를 정의해요. 하지만 OCaml에는 변경 가능한 변수도 존재해요. 그걸 *참조(reference)*라고 불러요. 정수에 대한 참조를 만드는 방법을 볼게요.

# let r = ref 0;;
val r : int ref = {contents = 0}

초기화되지 않았거나 null인 참조를 만드는 것은 문법적으로 불가능해요. r 참조는 정수 0으로 초기화돼요. 참조의 내용에 접근할 때는 ! 역참조 연산자를 써요.

# !r;;
- : int = 0

!rr은 타입이 다르다는 점에 주목하세요. 각각 intint ref예요. 정수와 float를 곱할 수 없는 것처럼, 정수를 갱신하거나 참조를 곱하는 것도 불가능해요.

r의 내용을 갱신해 볼게요. 여기서 :=는 할당 연산자이고, "받는다(receives)"라고 읽어요.

# r := 42;;
- : unit = ()

참조의 내용을 바꾸는 것은 부수 효과이기 때문에 ()을 반환해요.

# !r;;
- : int = 42

; 연산자로 한 표현식을 다른 표현식 뒤에 이어서 실행해요. a; b라고 쓰면 a를 실행하고, 끝나면 b를 실행하며 b의 값만 반환한다는 뜻이에요.

# let text = ref "hello ";;
val text : string ref = {contents = "hello "}

# print_string !text; text := "world!"; print_endline !text;;
hello world!
- : unit = ()

두 번째 줄에서 일어나는 부수 효과는 이래요.

  1. 참조 text의 내용을 표준 출력에 표시한다
  2. 참조 text의 내용을 갱신한다
  3. 참조 text의 내용을 표준 출력에 표시한다

이 동작은 명령형 언어에서와 같아요. 게다가 ;는 함수로 정의되지 않았지만, 타입이 unit -> unit -> unit인 함수처럼 동작해요.

모듈과 표준 라이브러리 (Modules and the Standard Library)

OCaml에서 소스 코드를 조직화하는 방법은 *모듈(module)*이라는 것을 쓰는 거예요. 모듈은 정의들의 묶음이에요. 표준 라이브러리는 모든 OCaml 프로그램이 사용할 수 있는 모듈들의 집합이고요. 표준 라이브러리의 Option 모듈에 담긴 정의를 나열하는 방법을 볼게요.

# #show Option;;
module Option :
  sig
    type 'a t = 'a option = None | Some of 'a
    val none : 'a t
    val some : 'a -> 'a t
    val value : 'a t -> default:'a -> 'a
    val get : 'a t -> 'a
    val bind : 'a t -> ('a -> 'b t) -> 'b t
    val join : 'a t t -> 'a t
    val map : ('a -> 'b) -> 'a t -> 'b t
    val fold : none:'a -> some:('b -> 'a) -> 'b t -> 'a
    val iter : ('a -> unit) -> 'a t -> unit
    val is_none : 'a t -> bool
    val is_some : 'a t -> bool
    val equal : ('a -> 'a -> bool) -> 'a t -> 'a t -> bool
    val compare : ('a -> 'a -> int) -> 'a t -> 'a t -> int
    val to_result : none:'e -> 'a t -> ('a, 'e) result
    val to_list : 'a t -> 'a list
    val to_seq : 'a t -> 'a Seq.t
  end

모듈이 제공하는 정의는 그 이름에 모듈 이름을 접두사로 붙여 참조해요.

# Option.map;;
- : ('a -> 'b) -> 'a option -> 'b option = <fun>

# Option.map (fun x -> x * x);;
- : int option -> int option = <fun>

# Option.map (fun x -> x * x) None;;
- : int option = None

# Option.map (fun x -> x * x) (Some 8);;
- : int option = Some 64

여기서 Option.map 함수의 사용을 여러 단계로 보여드렸어요.

  1. 타입을 표시한다. 두 매개변수를 갖는데, 하나는 'a -> 'b 타입의 함수이고 다른 하나는 'a option이에요.
  2. 부분 적용을 써서 fun x -> x * x만 전달한다. 결과 함수의 타입을 확인한다.
  3. None으로 적용한다.
  4. Some 8로 적용한다.

주어진 옵션 값이 실제 값을 담고 있으면(즉 Some 무엇이면) 주어진 함수를 적용하고 그 결과를 옵션에 감싸 반환해요. 옵션 값이 아무것도 담고 있지 않으면(즉 None이면) 결과도 아무것도 담지 않아요(즉 None이에요).

이 섹션에서 앞서 사용한 List.map 함수도 List 모듈이라는 모듈의 일부예요.

# List.map;;
- : ('a -> 'b) -> 'a list -> 'b list = <fun>

# List.map (fun x -> x * x);;
- : int list -> int list = <fun>

이 예시는 OCaml 모듈 시스템의 첫 번째 특징을 보여줘요. 이름 충돌을 막아 관심사를 분리하는 수단을 제공하죠. 타입이 다른 두 함수가 서로 다른 모듈에서 제공된다면 같은 이름을 가질 수 있어요.

모듈은 또 효율적인 개별 컴파일(separate compilation)을 가능하게 해요. 이 내용은 다음 튜토리얼에서 다뤄요.

결론 (Conclusion)

이 튜토리얼에서는 OCaml을 대화형으로 사용해 봤어요. 다음 튜토리얼인 Your First OCaml Program은 OCaml 파일을 작성하고, 컴파일하고, 프로젝트를 시작하는 방법을 보여줘요.

더 알아보기 (Learn more)