Jihyeok's Blog OCaml과의 첫 시간 (A First Hour with OCaml)
Jihyeok's Blog
Cancel

OCaml과의 첫 시간 (A First Hour with OCaml)

이 문서는 원문 내용을 기반으로 작성자의 보충설명이 추가되어 있습니다.

이 글에서는 프로그래밍 언어인 OCaml에 대해서 간략하게 살펴보는 시간을 가져보려고 합니다.

이 글에서 설명하는 모든 내용은 OCaml이 컴퓨터에 설치가 되어 있어야 합니다. 간단하게 웹 브라우저에서 돌아가는 TryOCaml을 사용해도 되지만, 외부 모듈에 대한 설명부터는 컴퓨터에 OCaml이 설치가 되어있어야 합니다.

프로그램 실행하기 (Running OCaml programs)

utop 혹은 TryOCaml을 이용해서 OCaml top-level을 사용해볼 수 있습니다. 이 때, #use를 통해서 OCaml 파일을 불러올 수도 있습니다.

1
# #use "program.ml";;

여기서 주의할 점은 #useutop이나 TryOCaml과 같은 OCaml top-level에서만 사용할 수 있습니다.

표현식 (Expressions)

우선, OCaml을 표현식(expression)에 대해서 알아보자

다음과 같이 실행을 변수 let을 통해서 표현식에 이름을 부여하는 표현식을 정의할 수 있습니다. 여기서 주의할 점은, let은 문장(statement)이 아닌 표현식을 만든다는 점입니다.

1
2
3
4
# let x = 50;;
val x : int = 50
# x * x;;
- : int = 2500

이렇게 붙여진 이름은 다른 언어의 변수와 다르게 수정이 불가능 하고, 다음과 같이 letin … 을 통해서 정의할 수도 있습니다.

1
2
# let x = 50 in x * x;;
- : int = 2500

그리고, 다음과 같이 한번에 여러 개의 이름을 붙일 수도 있습니다.

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

또한, 다음과 같이 let을 통해서 함수도 정의할 수 있습니다.

1
2
3
4
# let square x = x * x;;
val square : int -> int = <fun>
# square 50;;
- : int = 2500

여기서 살펴볼 점은 OCaml은 정적 타입 언어(statically typed language)입니다. 그런데, 타입에 대한 표시를 해주지 않았는데도 불구하고, 자동으로 square의 타입을 int 타입을 하나 받아서 int 타입을 반환하는 함수로 정해집니다: square: int -> int. 이렇게 타입에 대한 표기가 없음에도 불구하고 타입을 자동으로 유추해주는 것을 타입 유추(type inference)라고 합니다. 함수 호출 시, 인자는 괄호 없이 공백으로 구분해서 넣어주면 됩니다.

이제 다음과 같이 square_is_even 함수를 정의하면, 주어진 int 타입의 값이 짝수인지를 판별해서 bool 타입의 값으로 반환하는 함수가 됩니다.

1
2
3
4
5
6
7
# let square_is_even x =
    square x mod 2 = 0;;
val square_is_even : int -> bool = <fun>
# square_is_even 50;;
- : bool = true
# square_is_even 3;;
- : bool = false

함수는 여러개의 인자를 받을 수도 있고, 다른 명령형 언어(imperative language)와는 다르게, comma (,) 대신 공백으로 구분하면 됩니다.

1
2
3
4
5
# let ordered a b c =
    a <= b && b <= c;;
val ordered : 'a -> 'a -> 'a -> bool = <fun>
# ordered 1 1 2;;
- : bool = true

OCaml에서 연산자(operator)는 각자 허용하는 타입이 정해져있습니다. 앞에서 사용하였던 +, *, 그리고 mod는 오직 int 타입 사이에서만, &&bool 타입 사이에서만 사용이 가능합니다. 더 나아가서, float 타입에 대한 덧셈과 곱셈을 하기 위해서는 +. 이나 *. 등의 연산자를 사용해야 합니다.

1
2
3
4
# 1.2 +. 2.3;;
- : float = 3.5
# 2.3 *. 3.4;;
- : float = 7.8199999999999994

만약, 연산자가 기대하는 타입에 맞지 않는 값에 사용하면 오류가 발생하게 됩니다.

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

따라서, int 타입과 float 사이의 연산을 진행하기 위해서는, float_of_int와 같은 타입 변환 연산자를 사용해야 합니다.

1
2
# float_of_int 3 +. 3.2;;
- : float = 6.2

재귀 함수 (Recursive functions)

만약에 재귀 함수(recursive function), 즉, 자기 자신을 함수 내부에서 호출하는 함수를 정의하고 싶으면, let 대신에 let rec을 사용해야 합니다.

1
2
3
4
5
6
# let rec range a b =
    if a > b then []
    else a :: range (a + 1) b;;
val range : int -> int -> int list = <fun>
# let digits = range 0 9;;
val digits : int list = [0; 1; 2; 3; 4; 5; 6; 7; 8; 9]

여기서 ifthenelse 구문이 사용되었는데, let 구문과 마찬가지로 이는 문장(statement)이 아니라 표현식(expression)입니다. 그리고, 여기서 []는 비어있는 리스트(list)를 의미하고, x :: y는 왼쪽의 값(x)을 오른쪽 리스트(y)의 왼쪽에 추가해서 새로운 리스트(x :: y)를 만듭니다.

타입 (Types)

OCaml은 기본적인 타입(type)은 다음과 같습니다.

OCaml typeRange
int64-bit 프로세서에서는 63-bit signed int, 32-bit 프로세서에서는 31-bit signed int를 의미합니다.
floatIEEE double-precision floating point로 정의되는 값을 의미한다.
boolBoolean 값으로 true와 false를 값으로 가진다.
char8-bit 문자를 의미한다.
string문자열을 의미하며 문자(char)의 나열로 정의된다.

OCaml에서는 문자가 8-bit로 정의가 되어있기에, 한글과 같은 유니코드(Unicode)를 사용하기 위해서는 Camomile이라는 외부 라이브러리를 사용하여야 합니다.

string은 단순히 문자의 나열이 아니라 더 효율적인 표현을 내부적으로 가지지만 여기서는 자세한 설명을 생략하겠습니다. 다만, string이 불변(immutable)이라는 점은 유의하세요!

utop과 같은 OCaml top-level 환경 혹은 Read-eval-print loop (REPL)에서는 각각의 값에 대한 타입을 다음과 같이 화면에 출력해 줍니다.

1
2
3
4
5
6
7
8
9
10
# 1 + 2;;
- : int = 3
# 1.0 +. 2.0;;
- : float = 3.
# false;;
- : bool = false
# 'c';;
- : char = 'c'
# "Help me!";;
- : string = "Help me!"

앞서 설명 하였듯이, OCaml에서는 타입을 대부분 자동으로 유추해주지만, 직접 작성해야 되는 경우도 있습니다. 그렇기에, 타입을 적는 방법 또한 알아둬야 합니다. 만약 arg1, arg2, …, argn이라는 타입을 인자로 받고 rettype이라는 타입을 반환하는 함수 f를 정의한다고 하면, 다음과 같이 표현할 수 있습니다.

1
f : arg1 -> arg2 -> ... -> argn -> rettype

여기서 화살표(->)가 무슨 의미이며, 왜 여러번 사용 되는 것이 궁금할 수 있지만, 여기서는 일단 넘어가고 뒤에서 설명하도록 하겠습니다.

여기서 다시 한번 몇가지 중요한 개념을 잡고 가봅시다.

  • OCaml은 강한 정적 타입 언어(strongly statically typed language)입니다. 즉, 하나의 expression은 오직 하나의 타입만을 가지고, 프로그램 실행 전에 모두 정해집니다.
  • OCaml은 타입을 유추(type inference) 해줍니다. 즉, 직접 타입을 적지 않아도 알아서 타입을 찾아줍니다.
  • OCaml은 자동 형 변환(implicit conversion)을 하지 않습니다. 즉, 연산자나 함수의 타입에 맞게 값을 자동으로 바꿔주는 행위를 하지 않는다. 예를 들어, 2 +. 3.0이라고 하면, 이를 float 타입의 연산자에 맞게 int 타입인 2float으로 자동으로 변환해주는 언어들도 존재하지만, OCaml은 오류를 내뱉게 됩니다. 이로 인해서, OCaml에서는 같은 이름의 다른 타입을 가지도록 정의하는 함수의 오버로딩(function overloading)이 존재하지 않습니다.

패턴 매칭 (Pattern matching)

matchwith … 구문을 통해서 패턴 매칭(pattern matching)을 사용할 수 있습니다.

1
2
3
4
5
# let rec factorial n =
    match n with
    | 0 | 1 -> 1
    | x -> x * factorial (x - 1);;
val factorial : int -> int = <fun>

이 때, 마지막 줄에서 x라는 이름을 정의하고 매칭을 한 값을 받아와서 사용하였는데, 결국 n과 같기 때문에, 굳이 이름을 다시 정의하지 않아도 됩니다. 이 경우에는 _를 통해서 이름을 새로 정의하지 않고, 나머지 경우를 매칭할 수 있습니다.

1
2
3
4
5
# let rec factorial n =
    match n with
    | 0 | 1 -> 1
    | _ -> n * factorial (n - 1);;
val factorial : int -> int = <fun>

그리고, function이라는 키워드를 통해서 좀더 간결하게 패턴 매칭을 할 수도 있습니다.

1
2
3
4
# let rec factorial = function
    | 0 | 1 -> 1
    | n -> n * factorial (n - 1);;
val factorial : int -> int = <fun>

리스트 (Lists)

앞서 살펴 보았던 리스트(list)는 순서가 있는 컬렉션(collection)으로 다음과 같이 정의할 수 있습니다.

1
2
3
4
5
6
7
8
# [];;
- : '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]

여기서 결과를 보면, 리스트는 list라는 타입으로만 정의되는 것이 아니라 원소에 대한 타입도 포함하는 것을 알 수 있습니다. 즉 int를 원소로 가지는 리스트면, int list로 표현되고, bool을 원소로 가지면, bool list, 그리고 int 리스트를 원소로 가지는 리스트면, int list list가 됩니다. 그리고, 빈 리스트 []는 원소에 대한 타입에 대해서 규정짓지 않고 'a list로 정의가 되어있는데, 이에 대한 자세한 내용은 여기서는 넘어가기로 하겠습니다.

:: 연산자는 하나의 원소를 앞에 추가해서 새로운 리스트를 만들어주고, @ 연산자는 두 개의 리스트를 결합해서 새로운 리스트를 만들어줍니다.

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

다음과 같이 리스트에 대해서도 패턴 매칭이 가능합니다.

1
2
3
4
5
6
7
# let rec total l =
    match l with
    | [] -> 0
    | h :: t -> h + total t;;
val total : int list -> int = <fun>
# total [1; 3; 5; 3; 1];;
- : int = 13

만약에 패턴 매칭을 할 때, 모든 경우에 대해서 정의하지 않으면 경고(warning)가 발생하게 됩니다. 그리고, 정의되지 않은 경우에 대해서 실행 도중 접근이 이뤄지면, 예외(exception)가 발생합니다.

1
2
3
4
5
6
7
8
9
# let rec total_wrong l =
    match l with
    | h :: t -> h + total_wrong t;;
*Warning 8: this pattern-matching is not exhaustive.
Here is an example of a case that is not matched:
[]*
val total_wrong : int list -> int = <fun>
# total_wrong [1; 3; 5; 3; 1];;
Exception: Match_failure ("", 2, 2).

이제 한번 리스트의 길이를 구하는 함수를 정의해 봅시다.

1
2
3
4
5
# let rec length l =
    match l with
    | [] -> 0
    | _ :: t -> 1 + length t;;
val length : 'a list -> int = <fun>

이 때, 함수 내부에서 원소의 타입에 대한 어떤 제한도 없기 때문에 임의의 원소 타입을 가진 리스트를 인자로 받을 수 있습니다.

1
2
3
4
5
6
# length [1; 2; 3];;
- : int = 3
# length ["cow"; "sheep"; "cat"];;
- : int = 3
# length [[]];;
- : int = 1

이제 한번 @ 연산자가 하는 일을 한번 append라는 함수로 직접 구현해 봅시다.

1
2
3
4
5
6
7
# let rec append a b =
    match a with
    | [] -> b
    | h :: t -> h :: append t b;;
val append : 'a list -> 'a list -> 'a list = <fun>
# append [1; 2] [3; 4; 5];;
- : int list = [1; 2; 3; 4; 5]

이 구현을 잘 살펴보면, 새로 생긴 리스트는 b에 주어진 리스트는 공유하고 있습니다. 즉, 다음과 같은 구조인 것입니다.

이 그림에서 @ret는 함수 호출로 생성된 새로운 리스트를 나타내고 b로 넘겨 졌던 리스트를 공유한 다는 것을 알 수 있습니다. 이는 불변(immutable)한 구조의 특성이라고 볼 수 있습니다.

OCaml에서는 고차 함수(higher-order function)를 제공합니다. 이 말은 함수를 하나의 값으로 들고 다닐 수 있다는 의미로, 함수를 변수에 할당하거나 다른 함수의 인자로 넘기는 등의 행동을 할 수 있습니다. 따라서, 다음과 같이 주어진 리스트의 원소 마다 동일한 행동을 반복하는 함수를 인자로 받아서 새로운 리스트를 생성하는 함수도 정의할 수 있습니다.

1
2
3
4
5
# let rec map f l =
    match l with
    | [] -> []
    | h :: t -> f h :: map f t;;
val map : ('a -> 'b) -> 'a list -> 'b list = <fun>

여기서 첫번째 인자 f의 타입을 보면 'a -> 'b로 특정 타입 'a를 받아서 다른 타입 'b를 반환하는 함수를 의미합니다. 그리고, 함수는 두번 째 인자로 l'a를 원소로 가지는 리스트('a list)를 받아서, 이 리스트의 각 원소에 f를 각자 적용해서 'b로 이루어진 리스트('b list)를 반환합니다.

예를 들어, 다음과 같이 map 함수를 이용해서 1) 리스트에 들어있는 int 원소들을 두 배로 바꾼 리스트를 생성하거나, 2) 리스트에 들어있는 int list 원소들의 길이를 가진 리스트를 생성할 수도 있습니다.

1
2
3
4
# map (fun x -> x * 2) [1; 2; 3];;
- : int list = [2; 4; 6]
# map length [[]; [1; 2; 3]; [4]];;
- : int list = [0; 3; 1]

여기서, fun은 이름이 없는 함수를 생성하는 키워드입니다.

함수를 호출할 때, 한번에 모든 인자를 꼭 넣을 필요는 없습니다.

1
2
3
4
5
6
7
8
# let add a b = a + b;;
val add : int -> int -> int = <fun>
# add;;
- : int -> int -> int = <fun>
# let f = add 6;;
val f : int -> int = <fun>
# f 7;;
- : int = 13

이 예시에서 addab 두 개의 매개 변수에 int 타입의 인자를 받아 int 타입의 값을 반환하는 함수입니다. 하지만, 위에서 보았듯이 이는 int -> int -> int로 표시가 됩니다. 이 말은 int 타입의 인자를 하나 받으면 int -> int 타입의 함수를 반환하는 것으로 이해할 수 있습니다. 이러한 개념을 커링(currying)이라고 부르며, 이러한 상황에서는 인자를 부분 적용(partial application)하는 것이 가능합니다. 이 예제에서도, add 6을 호출하면, 매개 변수 a6을 할당하고, 남은 매개 변수 b는 나중에 받도록 남겨두고 함수를 반환하게 됩니다. 그래서 fint -> int 타입의 함수가 되는 것입니다. 그 후, f 7을 호출하면, 그제야 매개 변수 b7이 할당되고, 둘을 더한 결과인 13이 반환되게 되는 것입니다.

이 개념을 이용하면 다음과 같이 각 원소에 6을 더하는 작업을 할 수도 있고,

1
2
# map (add 6) [1; 2; 3];;
- : int list = [7; 8; 9]

더 나아가서는 다음과 같이 map에도 부분 적용을 할 수도 있습니다.

1
2
# map (map (fun x -> x * 2)) [[1; 2]; [3; 4]; [5; 6]];;
- : int list list = [[2; 4]; [6; 8]; [10; 12]]

즉, int 타입의 값을 두 배 해주는 함수(fun x -> x * 2)를 map을 이용해서 리스트의 원소 마다 적용해주는 함수(map (func x -> x * 2))를 만들고, 다시 한 번 map을 이용해서, int list를 원소로 가지는 리스트의 원소 마다 적용해서 새로운 리스트를 만드는 것입니다.

다른 내장 함수 (Other built-in types)

int와 같은 기본적인 데이터 타입 외에도 list와 같은 복합적인 데이터 타입이 존재합니다. 우선, 고정된 길이의 원소를 가진 튜플(tuple)이라는 타입이 있습니다.

1
2
# let t = (1, "one", '1');;
val t : int * string * char = (1, "one", '1')

위에서 정의한 튜플은 길이가 3이고 int, string, 그리고 char로 이루어진 타입입니다. 보다시피, 튜플은 리스트와 다르게 서로 다른 타입을 가질 수 있습니다.

그리고, 레코드(record)는 튜플과 비슷하지만, 각 원소에 대해 이름을 가지고 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
# type person =
    {first_name : string;
     surname : string;
     age : int};;
type person = { first_name : string; surname : string; age : int; }
# let frank =
    {first_name = "Frank";
     surname = "Smith";
     age = 40};;
val frank : person = {first_name = "Frank"; surname = "Smith"; age = 40}
# let s = frank.surname;;
val s : string = "Smith"

이 예제에서 person은 세 개의 원소를 가진 레코드 타입이고, 각각 first_name라는 이름의 string, surname라는 이름의 string, 그리고 age라는 이름의 int 타입을 가지고 있습니다.

당연하게도, 튜플과 레코드에 대해서도 패턴매칭이 가능하지만, 여기서는 자세히 다루지 않도록 하겠습니다.

사용자 정의 데이터 타입 (Our own data types)

기존에 정의가 된 데이터 타입 외에도 사용자가 직접 type 키워드를 사용해서 자신만의 데이터 타입을 정의할 수도 있습니다.

1
2
3
4
# type color = Red | Blue | Green | Yellow;;
type color = Red | Blue | Green | Yellow
# let l = [Red; Blue; Red];;
val l : color list = [Red; Blue; Red]

여기서는 color라는 타입을 새로운 타입을 만들었고, Red, Blue, Green, 그리고 Yellow라는 이름의 타입 생성자(type constructor)를 만들었습니다. 이 때, 주의할 점은, 타입 생성자의 이름은 무조건 대문자로 시작해야 한다는 점입니다.

타입 생성자는 선택적으로 또 다른 타입을 가질 수도 있습니다.

1
2
3
4
5
6
7
8
9
# type color =
    | Red
    | Blue
    | Green
    | Yellow
    | RGB of int * int * int;;
type color = Red | Blue | Green | Yellow | RGB of int * int * int
# let l = [Red; Blue; RGB (30, 255, 154)];;
val l : color list = [Red; Blue; RGB (30, 255, 154)

이 예제에서는 RGB라는 이름의 타입 생성자를 추가하였고, 세 개의 int로 구성된 튜플을 포함하고 있습니다.

더 나아가, 데이터 타입은 다형성(polymorphism)을 가질 수 있고, 재귀적(recursive)으로 정의될 수도 있습니다.

1
2
3
4
5
6
7
8
# type 'a tree =
    | Leaf
    | Node of 'a tree * 'a * 'a tree;;
type 'a tree = Leaf | Node of 'a tree * 'a * 'a tree
# let t =
    Node (Node (Leaf, 1, Leaf), 2, Node (Node (Leaf, 3, Leaf), 4, Leaf));;
val t : int tree =
  Node (Node (Leaf, 1, Leaf), 2, Node (Node (Leaf, 3, Leaf), 4, Leaf))

이 예시에서는 tree라는 타입으로 이진 트리(binary tree)를 정의하고 있습니다. Leaf 타입 생성자는 빈 리스트처럼 아무 정보도 가지고 있지 않지만, Node 타입 생성자는 재귀적으로 왼쪽 서브 트리, 'a 타입의 값, 그리고 오른쪽 서브 트리를 가지고 있습니다.

이렇게 사용자가 직접 정의한 데이터 타입에 대해서도 다음과 같이 패턴 매칭이 가능합니다.

1
2
3
4
5
6
7
8
9
10
# let rec total t =
    match t with
    | Leaf -> 0
    | Node (l, x, r) -> total l + x + total r;;
val total : int tree -> int = <fun>
# let rec flip t =
    match t with
    | Leaf -> Leaf
    | Node (l, x, r) -> Node (flip r, x, flip l);;
val flip : 'a tree -> 'a tree = <fun>

totalint 타입의 트리를 인자로 받아서 이 트리가 갖고 있는 Node 내의 int 값을 모두 더해주는 함수입니다. 그리고, flip은 각 Node의 왼쪽과 오른쪽 서브 트리를 서로 바꿔주는 함수입니다.

이 함수들을 위에서 생성한 트리에 적용해보면 다음과 같이 결과가 나옵니다.

1
2
3
4
5
6
7
# let all = total t;;
val all : int = 10
# let flipped = flip t;;
val flipped : int tree =
  Node (Node (Leaf, 4, Node (Leaf, 3, Leaf)), 2, Node (Leaf, 1, Leaf))
# t = flip flipped;;
- : bool = true

여기서 잠깐 언급하고 넘어가야 할 점은, OCaml은 Java와 같은 프로그래밍 언어처럼 쓰레기 수집(garbage collection)을 제공합니다. 따라서, 직접 메모리를 해제(free)할 필요가 없습니다. 이 예제에서, flip flipped로 생성된 트리는 t = flip flipped가 실행 된 후에는 접근이 불가능하므로 이 트리가 할당된 메모리가 자동으로 해제됩니다.

오류 처리 (Dealing with errors)

OCaml은 예외 처리를 두 가지 방법으로 합니다. 하나는 예외(Exception)로, type 키워드와 비슷하게 exception 키워드를 사용해서 특정 예외를 정의할 수 있습니다. 그리고, 선택적으로 다른 데이터 타입을 포함할 수도 있습니다.

1
2
3
4
# exception E;;
exception E
# exception E2 of string;;
exception E2 of string

예외는 다음과 같이 raise 키워드를 사용해서 발생시킬 수 있습니다.

1
2
3
4
5
# let f a b =
    if b = 0 then raise (E2 "division by zero") else a / b;;
val f : int -> int -> int = <fun>
# f 10 0;;
Exception: E2 "division by zero".

이 때, 발생한 예외는 trywith … 구문을 기반으로 패턴 매칭을 하여 처리할 수 있습니다.

1
2
# try f 10 0 with E2 _ -> 0;;
- : int = 0

OCaml에서 예외 처리를 하는 또 다른 방법은 다음과 같이 언어에 미리 내장된 다형성 타입인 option 타입을 이용하는 것입니다.

1
type 'a option = None | Some of 'a

위의 예제에서 예외 대신 option 타입을 이용하면, 다음과 같이 정의할 수 있습니다.

1
2
3
# let f a b =
    if b = 0 then None else Some (a / b);;
val f : int -> int -> int option = <fun>

즉, 값이 예외 상황을 표시하고 싶을 때는 None 타입 생성자를 사용하고, 일반적인 값을 나타낼 때는 Some 타입 생성자를 이용해서 묶어주는 것입니다.

또 다른 예시로, 주어진 테스트 함수를 만족하는 첫 번째 원소를 리스트에서 찾아주는 List.find를 이용해 봅시다. 이 함수는 만약 만족하는 원소를 찾지 못하면 Not_found 예외가 발생하게 됩니다. 다음과 같이, try 구문을 이용하면 이러한 상황을 예외가 아닌 option을 이용하도록 바꿀 수 있습니다.

1
2
3
4
# let list_find_opt p l =
    try Some (List.find p l) with
      Not_found -> None;;
val list_find_opt : ('a -> bool) -> 'a list -> 'a option = <fun>

또 다른 방법으로는, match 구문을 사용해서 값과 예외를 모두 패턴 매칭할 수도 있습니다.

1
2
3
4
5
# let list_find_opt p l =
    match List.find p l with
    | v -> Some v
    | exception Not_found -> None;;
val list_find_opt : ('a -> bool) -> 'a list -> 'a option = <fun>

명령형 OCaml (Imperative OCaml)

OCaml은 함수형 언어(functional language)이기도 하지만, 명령형 언어(imperative language)이기도 합니다. 대부분의 OCaml 프로그래머들은 명령형 언어의 기능을 가능한 사용하지 않으려고 합니다. 그러나 거의 대부분의 OCaml 프로그래머들은 이러한 기능들을 종종 사용하고 있는데요. 만약 단순히 값에 불변한 이름을 주는 것을 넘어, 변수(variable)를 정의하려면 참조(reference)를 사용해야만 합니다.

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

변수 r은 하나의 참조를 가리키고, 이 참조는 처음에 0이라는 값을 가지고 있습니다. 그리고, 다음과 같이 := 연산자를 사용하면 참조에 100이라는 값을 새롭게 할당할 수도 있습니다.

1
2
# r := 100;;
- : unit = ()

이제 ! 연산자를 통해 참조가 가리키는 값을 다시 살펴보면, 할당된 값이 100으로 바뀐 것을 확인해 볼 수 있습니다.

1
2
# !r;;
- : int = 100

참조는 필요할 때가 분명히 있지만, 지역 변수를 정의하는 등을 위해서는 let=in … 구문을 사용하는 것이 더 일반적인 방법입니다.

앞에서 설명한 명령형 표현식들은 ;를 통해서 결합할 수 있습니다. 예를 들어서, 다음과 같이 두 참조에 들어 있는 값들을 서로 치환하는 함수를 정의할 수 있습니다.

1
2
3
4
5
# let swap a b =
    let t = !a in
      a := !b;
      b := t;;
val swap : 'a ref -> 'a ref -> unit = <fun>

명령형 표현식은 아무런 값을 나타내지 않는 unit 타입의 값인 ()을 반환합니다. 따라서, 위의 swap 함수의 반환 타입도 unit입니다. 또한, 아무런 인자도 받지 않고 사이드 이펙트(side effect)를 가진 함수를 정의할 때도 매개 변수의 타입을 unit으로 정의합니다. 예를 들어, 줄바꿈(newline) 문자를 출력을 해주는 print_newline라는 함수는 인자로 아무것도 받지 않습니다.

1
2
3
4
5
# print_newline;;
- : unit -> unit = <fun>
# print_newline ();;

- : unit = ()

따라서, print_newline라고만 적으면 함수를 의미하고, ()를 인자로 넘겨주어야 실제로 줄바꿈 문자를 출력해 줍니다.

이 외에도, OCaml은 for 반복문과,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# let table n =
    for row = 1 to n do
      for column = 1 to n do
        print_string (string_of_int (row * column));
        print_string " "
      done;
      print_newline ()
    done;;
val table : int -> unit = <fun>
# let () = table 10;;
1 2 3 4 5 6 7 8 9 10
2 4 6 8 10 12 14 16 18 20
3 6 9 12 15 18 21 24 27 30
4 8 12 16 20 24 28 32 36 40
5 10 15 20 25 30 35 40 45 50
6 12 18 24 30 36 42 48 54 60
7 14 21 28 35 42 49 56 63 70
8 16 24 32 40 48 56 64 72 80
9 18 27 36 45 54 63 72 81 90
10 20 30 40 50 60 70 80 90 10

while 반복문을 제공합니다.

1
2
3
4
5
6
7
# let smallest_power_of_two x =
    let t = ref 1 in
      while !t < x do
        t := !t * 2
      done;
      !t;;
val smallest_power_of_two : int -> int = <fun>

더 나아가, 불변인 list가 아닌 변경이 가능한 형태의 나열인 array 타입도 제공합니다.

1
2
3
4
5
6
7
8
# let arr = [|1; 2; 3|];;
val arr : int array = [|1; 2; 3|]
# arr.(0);;
- : int = 1
# arr.(0) <- 0;;
- : unit = ()
# arr;;
- : int array = [|0; 2; 3|]

레코드는 변경 가능한 필드(field)를 가질 수도 있습니다.

1
2
3
4
5
6
7
8
# type person =
    {first_name : string;
     surname : string;
     mutable age : int};;
type person = { first_name : string; surname : string; mutable age : int; }
# let birthday p =
    p.age <- p.age + 1;;
val birthday : person -> unit = <fun>

표준 라이브러리 (The Standard Library)

OCaml은 유용한 모듈(module)의 라이브러리(library)를 어디서든 사용할 수 있도록 제공합니다. 예를 들어, Map이나 Set과 같은 함수형 데이터 구조, 해쉬 테이블(hash table)과 같은 명령형 데이터 구조, 그리고 운영 체제(operating system)와의 상호 작용을 위한 표준 라이브러리 등이 있습니다. 이러한 라이브러리가 제공하는 함수를 사용하기 위해서는 모듈의 이름 뒤에 .와 함수의 이름을 적으면 됩니다. 예를 들어, List 모듈에서 제공하는 함수들을 다음과 같이 사용할 수 있습니다.

1
2
3
4
5
6
# List.concat [[1; 2; 3]; [4; 5; 6]; [7; 8; 9]];;
- : int list = [1; 2; 3; 4; 5; 6; 7; 8; 9]
# List.filter (( < ) 10) [1; 4; 20; 10; 9; 2];;
- : int list = [20]
# List.sort compare [1; 6; 2; 2; 3; 56; 3; 2];;
- : int list = [1; 2; 2; 2; 3; 3; 6; 56]

Printf 모듈은 타입 검사를 하면서 출력을 해주는 기능을 제공합니다. 따라서, 이 모듈을 사용하면 컴파일(compile) 도중에 올바른 출력을 했는가를 알 수 있습니다.

1
2
3
4
5
6
7
8
# let print_length s =
    Printf.printf "%s has %i characters\n" s (String.length s);;
val print_length : string -> unit = <fun>
# List.iter print_length ["one"; "two"; "three"];;
one has 3 characters
two has 3 characters
three has 5 characters
- : unit = ()

외부 모듈 (A module from OPAM)

OCaml의 표준 라이브러리 외에도, OCaml 패키치 관리자인 OPAM을 이용하면 다양한 종류의 모듈을 사용할 수 있습니다.

이후의 과정은 TryOCaml이 아닌 컴퓨터에 OCaml 설치가 되어있어야 됩니다.

지금부터는 예시로 Graphicsocamlfind를 사용해보도록 하겠습니다. 두 모듈을 설치하기 위해서는 다음의 명령어를 쉘에서 실행해주면 됩니다.

1
$ opam install graphics ocamlfind

Graphics 모듈은 교차 플랫폼을 지원하는 매우 단순한 그래픽 시스템입니다. 이 모듈을 사용하는 방법은 두 가지가 있는데, 하나는 프로그램 처음에 open Graphics를 선언하거나, 앞서 배운 배운 방법대로 Graphcis.open_graph처럼 매번 호출해 주는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
open Graphics;;

open_graph " 640x480";;

for i = 12 downto 1 do
  let radius = i * 20 in
    set_color (if i mod 2 = 0 then red else yellow);
    fill_circle 320 240 radius
done;;

read_line ();;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Random.self_init ();;

Graphics.open_graph " 640x480";;

let rec iterate r x_init i =
  if i = 1 then x_init else
    let x = iterate r x_init (i - 1) in
      r *. x *. (1.0 -. x);;

for x = 0 to 639 do
  let r = 4.0 *. (float_of_int x) /. 640.0 in
  for i = 0 to 39 do
    let x_init = Random.float 1.0 in
    let x_final = iterate r x_init 500 in
    let y = int_of_float (x_final *. 480.) in
      Graphics.plot x y
  done
done;;

read_line ();;

만약에, Graphics 모듈을 top level에서 사용하고 싶으면, 다음과 같이 라이브러리를 불러오면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
# #use "topfind";;
- : unit = ()
Findlib has been successfully loaded. Additional directives:
...
  #require "package";;      to load a package
...

- : unit = ()
# #require "graphics";;
/Users/me/.opam/4.12.0/lib/graphics: added to search path
/Users/me/.opam/4.12.0/lib/graphics/graphics.cma: loaded

OCaml 프로그램 컴파일하기 (Compiling OCaml programs)

지금까지는 OCaml의 top level에서 다양한 실행을 해보았습니다. 지금부터는 어떻게 OCaml 프로그램을 독립적으로 빠르게 실행가능하도록 컴파일을 해보도록 하겠습니다. 다음과 같이 helloworld.ml이라는 프로그램을 우선 작성해 봅니다.

1
print_endline "Hello, World!"

여기서 ;;를 사용하지 않는 이유는 우리가 top level을 더 이상 사용하지 않기 때문입니다.

이제 우리는 다음과 같은 명령어를 통해 이 프로그램을 컴파일할 수 있습니다.

1
$ ocamlopt -o helloworld helloworld.ml

참고로 -o helloworld는 최종 결과로 뽑을 실행 파일의 이름을 hellowolrd로 설정한다는 의미입니다. 이 명령어를 실행하고 나면, 같은 디렉토리에 네 개의 파일이 생긴 것을 확인할 수 있습니다.

1
2
3
4
5
6
.
├── helloworld
├── helloworld.cmi
├── helloworld.cmx
├── helloworld.ml
└── helloworld.o

여기서 helloworld.cmi, helloworld.cmi, 그리고 helloworld.o는 컴파일 과정 중에 생성된 파일이고, helloworld가 실제 실행 파일입니다. 이제 이렇게 생성된 실행 파일을 다음과 같이 샐행해보면 잘 작동하는 것을 확인할 수 있습니다.

1
2
3
$ ./helloworld
Hello, World!
$

만약 하나 이상의 파일을 가지고 있다면, 다음과 같이 .ml.mli 파일을 작성하여 자신만의 모듈을 작성할수도 있습니다.

1
let to_print = "Hello, World!"
1
val to_print : string
1
print_endline Data.to_print

.mli 파일은 구현체의 타입을 정의한 인터페이스를 의미하고, .ml 파일이 실제 구현체를 의미합니다. 이 때, 파일 이름을 작성할 때 사용한 이름(data)에서 첫 문자를 대문자로 바꾼 이름(Data)이 모듈의 이름이 됩니다. 이제, 다음과 같이 쉘에 명령어를 실행하면 컴파일러가 정상으로 작동하고 main이라는 이름의 실행 파일이 생성되는 것을 확인할 수 있습니다.

1
$ ocamlopt -o main data.mli data.ml main.ml

하지만, 대부분의 OCaml 사용자들은 컴파일러를 직접 부르기 보다는 dune과 같은 빌드 시스템을 이용해서 컴파일을 관리합니다.

This post is licensed under CC BY 4.0 by the author.
Trending Tags