기본사항

Ocaml 코드 실행하기

브라우져상에서 인터엑티브한 세션을 이용하여 코드를 실행하는 가장 손 쉬운 방법은 https://ocsigen.org/js_of_ocaml/2.7/files/toplevel/index.html을 이용하는 것이다.

여러분의 컴퓨터에 OCaml을 설치 하려면 Install 문서를 참고하기 바란다.

간단한 OCaml 코드를 실행해보려면 toplevel 이나 REPL(Read-Eval-Print Loop)를 이용하면 된다. ocaml 명령어를 통해 기본적인 기능의 toplevel을 이용할수 있다 (여러분의 시스템의 페키지 메니저를 이용하여 rlwrap을 설치하고 rlwrap ocaml을 실행함으로서 커맨드 히스토리를 이용할수 있다). 만약 여러분 시스템의 패키지 메니저나 OPAM를 이용하여 utop를 설치 할수 있다면, ocaml 명령어보단 utop toplevel을 이용하기를 추천하는 바이다. utopocaml의 기본적인 인터페이스에 추가적으로 여러 편리한 툴들(history navigation, auto-completion, etc.)을 제공한다.

각 스테이트먼트가 끝났을때에는 이를 알리는 ;;를 이용하도록 한다.

$ ocaml
        OCaml version 4.10.0

# 1+1;;
- : int = 2
───────┬────────────────────────────────────────────────────────────┬─────
       │ Welcome to utop version 1.18 (using OCaml version 4.02.3)! │     
       └────────────────────────────────────────────────────────────┘     

Type #utop_help for help about using utop.

─( 10:12:16 )─< command 0 >───────────────────────────────────────────────
utop # 1 + 1;;
- : int = 2

my_prog.ml이라는 OCaml 프로그램을 네이티브 실행이 가능한 형태로 컴파일 하려면 ocamlbuild my_prog.native를 이용한다:

$ mkdir my_project
$ cd my_project
$ echo 'let () = print_endline "Hello, World!"' > my_prog.ml
$ ocamlbuild my_prog.native
Finished, 4 targets (0 cached) in 00:00:00.
$ ./my_prog.native
Hello, World!

더 자세한 사항은 Compiling OCaml projects을 읽어보기 바란다.

주석

OCaml 주석은 다음과 같이 (**) 사이에 들어간다.

(* 이 주석은 한 줄 주석입니다. *)

(* 이 주석은
 * 여러 줄
 * 주석입니다.
 *)

바꿔 말해서, 주석 표기법은 기존 C 언어의 (/* ... */)와 매우 유사하다. 현재 (Perl의 # ... 이나, C99/C++/Java의 // ... )같은 한 줄 주석은 지원되지 않는다.

OCaml은 (* ... *) 로 둘러싸인 수를 세기 때문에, 쉽게 코드 영역을 주석화 시킬 수 있다.

(* 망가진 코드 ...

(* 최초 테스트. *)
let is_prime n =
  (* 내가 기억할 것 : 이것에 대해서는 메일링 리스트에 질문할 것. *) XXX;;
*)

함수 호출하기

함수를 하나 작성했다고 가정해보자. repeated 함수는 문자열 s와 숫자 n를 받아서 sn번 반복한 문자열을 돌려준다.

C 언어에서 파생된 대부분의 언어에서는 이 함수를 다음과 같이 부를 것이다.

repeated ("hello", 3)  /* 이것은 C 코드이다 */

이 호출은 "repeated 함수를 두 개의 인수로 호출함을 뜻한다. 첫 인수로 문자열 "hello"를 두 번째 인수로 숫자 3을 넘긴다".

OCaml은 다음 함수형 언어와 마찬가지로 함수 호출 시 괄호를 다르게 묶는데, 이는 많은 실수의 원인이 된다. OCaml에서 같은 함수를 호출하려면 다음과 같이 된다.

repeated "hello" 3  (* 이것은 OCaml 코드이다 *)

주의 - 괄호가 없고, 인수 사이에 쉼표(,)도 없다.

지금은 혼란스럽겠지만 OCaml에서는 repeated ("hello", 3) 또한 나름의 의미가 있다. 이는 "두 원소로 구성된 '쌍(pair)'이라는 하나의 원소를 인수로 주어repeated 함수를 호출하라"를 의미한다. 물론 이는 잘못된 호출인데, 왜냐하면 repeated 함수는 하나가 아닌 두 개의 인수를 기대하고 있고, 첫 번째 인수는 '쌍(pair)'이 아닌 문자열이 되어야 하기 때문이다. 하지만 지금은 쌍("순서쌍(tuples)")에 대해서는 신경쓰지 말자. 대신 괄호를 넣거나 함수 인수 사이에 쉼표를 넣는 것은 잘못된 것이라고만 알고 있으면 된다.

이번에는 다른 함수를 살펴보자. get_string_from_user는 프롬프트 문자열을 받아서 사용자가 입력한 문자열을 돌려준다. 그리고 이 문자열을 repeated 함수에 넘기도록 해보자.

/* C 코드 */
repeated (get_string_from_user ("Please type in a string."), 3)
(* OCaml 코드: *)
repeated (get_string_from_user "Please type in a string.") 3

괄호와 쉼표가 없다는 사실을 주의 깊게 관찰하자. 일반적인 법칙은 "함수 호출 전체에 괄호를 넣되, 함수 호출 인수 사이에 괄호를 넣지 말아야 한다"는 것이다. 여기 몇 개의 예제가 더 있다.

f 5 (g "hello") 3    (* f는 3개의 인수가 있고, g는 1개의 인수가 있다 *)
f (g 3 4)            (* f는 1개의 인수가 있고, g는 2개의 인수가 있다 *)

# repeated ("hello", 3);;     (* OCaml은 이 실수를 잡아낸다 *)

이 표현(expression)은 string * int 타입을 가지는데, 여기서는 string 타입으로 사용되었다.

함수 정의하기

여러분은 모두들 기존의 다른 언어에서 함수(자바에서는 정적 메쏘드)를 어떻게 정의하는지 알고 있을 것이다. 그렇다면 OCaml에서는 함수를 어떻게 정의할까?

OCaml 문법은 매우 간결하다. 다음은 두 개의 실수를 받아서 평균을 구하는 함수이다.

let average a b =
  (a +. b) /. 2.0

이 코드를 OCaml의 "toplevel" (유닉스의 경우, 쉘에서 ocaml 명령을 친다)에 넣으면, 다음과 같은 결과를 볼 수 있을 것이다.

# let average a b =
    (a +. b) /. 2.0;;
val average : float -> float -> float = <fun>

이 함수 정의와 OCaml이 출력한 내용을 자세히 살펴보면, 몇가지 의문이 생길것이다.

  • 코드에 사용한 점(.)은 모두 무엇일까?
  • float -> float -> float 같은 것들은 무엇을 뜻하는 것일까?

이 질문에 대해서는 다음 절에서 답하도록 하겠다. 대신에 같은 함수를 C에서 정의해보자(자바 정의는 C와 매우 유사할 것이다). 아마 이는 더 많은 질문을 유발할 것이다. 다음은 average함수의 C 버전이다.

double average (double a, double b)
{
  return (a + b) / 2;
}

이제 위 보다 훨신 짧은 코드를 OCaml 로 정의한 것을 보기 바란다. 아마 다음과 같은 질문을 던질 것이다.

  • 왜 OCaml 버전에서는 ab의 타입을 정의하지 않아도 되는 것일까? OCaml은 타입을 어떻게 아는 것일까? (정말로 OCaml이 어떤 타입인지 알기는 하는 것일까? 아니면 완전히 동적 타이핑을 하는 것일까?)
  • C에서는 2는 묵시적으로 double 타입으로 변환되는데, OCaml은 왜 그렇게 해주지 않을까?
  • OCaml은 return을 어떻게 작성할까?

이제, 이 질문들에 답해보자.

  • OCaml은 강한 정적 타입 언어이다. (바꿔 말해서, 타입에 관해서는 Perl처럼 동적으로 동작하는 것이 하나도 없다.)
  • OCaml은 타입 추론(type inference)을 사용하기 때문에, 여러분이 직접 지정하지 않아도 된다. 만약 위와 같이 OCaml 인터프리터(toplevel)를 사용한다면, OCaml은 여러분의 함수의 정확한 타입[정확하다고 생각하는 타입]을 말해줄 것이다.
  • OCaml은 어떤 묵시적 캐스팅(implicit casting)도 하지 않는다. 실수(float)를 사용하려면 2.0이라고 써야한다. 2는 정수(int)이기 때문이다.
  • OCaml은 연산자 오버로딩(operator overloading)을 지원하지 않기 때문에 두 정수를 더하는 연산자(+)와 두 실수를 더하는 연산자(+., 뒤따라오는 점(.)에 유의하자)가 별도로 존재한다. 이는 다른 수치 연산자들도 마찬가지이다.
  • OCaml은 함수의 마지막 표현식의 결과를 리턴한다. 따라서 여러분은 C에서처럼 return을 사용할 필요가 없다.

자세한 내용은 뒷 부분을 참조하기 바란다.

기본 타입들

OCaml의 기본 타입은 다음과 같다.

OCaml 타입      범위

int            32 비트 프로세서의 경우 31 비트 부호 있는 정수 (대략 +/- 10억),
               64 비트 프로세서의 경우 63 비트 부호 있는 정수
float          IEEE 2배 정밀도(double-precision)를 갖는 부동 소수점 숫자, C의 double과 동일
bool           논리값(boolean), true 혹은 false
char           8 비트 문자
string         문자열
unit           () 로 표기되는 하나의 값

OCaml은 자동으로 메모리를 관리(가비지 콜렉션)하기 위해 int의 한 비트를 사용한다. 이것이 int가 32 비트가 아니고 31비트인 이유이다. (64 비트 플랫폼이라면 63 비트일 것이다.) 이는 실제로 특수한 몇 가지 경우를 제외하고는 큰 문제가 되지 않는다. 예를 들어, 루프에서 숫자를 세면 OCaml은 20억이 아닌 10억이 한계이다. 어떤 언어라도 이 정도로 큰 값을 사용한다면 bignum를 사용하는 것이 맞다. (OCaml의 경우 NatBig_int 모듈이 있다.) 하지만 32 비트 타입을 처리해야할 필요가 있을 경우는(예를 들어, 암호 코드나 네트워크 스택을 작성할 때), OCaml은 여러분 플랫폼의 네이티브 정수 타입에 해당하는 nativeint를 제공한다.

OCaml은 기본적인 부호 없는 정수 타입이 없지만, 같은 효과를 nativeint로 낼 수 있다. 그리고 OCaml은 1배 정밀도(single precision, c의 float에 해당하는) 부동 소수점 숫자는 제공하지 않는다.

OCaml은 문자를 표기하는데 char 타입을 제공하며, 'x' 형태로 쓰인다. 불행히도 char는 유니코드(Unicode)나 UTF-8을 지원하지 않는다. 이는 반드시 고쳐져야할 OCaml의 치명적인 문제인데, 당분간은 comprehensive Unicode libraries를 사용해서 이 문제를 우회할 수 있다.

문자열은 단순히 문자의 리스트가 아니다. 문자열은 문자열 나름의 효율적인 내부적인 표현 방식이 존재한다.

unit 타입은 말하자면 C에서 void같은 것이라고 볼 수 있다. 하지만 이에 대해서는 이후에 좀 더 자세히 설명하겠다.

묵시적(implicit) vs 명시적(explicit) 캐스트

C 계열의 언어에서는, 특정 상황에서 int는 float로 프로모트(promote)된다. 예를 들어 여러분이 1 + 2.5를 작성하면, 첫 번째 인수(정수형)은 실수로 프로모트되고, 결과 역시 실수가 된다. ((double) 1) + 2.5로 쓴 것과 마찬가지다. 하지만, 이 과정은 모두 묵시적으로 이루어진다.

OCaml은 절대로 이와 같은 묵시적 캐스트를 하지 않는다. OCaml에서 1 + 2.5는 타입 오류이다. OCaml의 + 연산자는 두 개의 정수를 인수로 요구한다. 따라서 우리가 int와 float을 인수로 주면, 다음과 같이 오류 메시지를 보게 된다.

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

(이 오류 메시지는 "int를 필요로 하는 곳에 float을 집어 넣었다"는 뜻이다.)

두 실수를 더하기 위해서는, 다른 연산자인 +.를 사용해야 한다(뒤에 붙은 점(.)에 유의하자).

OCaml은 int를 float으로 자동으로 프로모트시키지 않는데, 따라서 다음 코드 역시 오류이다.

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

여기서는 OCaml이 첫 번째 인수에 대해 불평하고 있다.

만약 실제로 정수와 실수를 더하고 싶다면 어떻게 해야 할까? (if 변수에 저장되었다고 생각해보자.) OCaml에서는 명시적 캐스트가 필요하다.

float_of_int i +. f

float_of_intint를 받아서 float을 돌려주는 함수이다. int_of_float, char_of_int, int_of_char, string_of_int처럼 이런 역할을 하는 함수들이 많이 있고, 함수 이름에서 어떤 동작을 하는지 유추할 수 있다.

intfloat으로 변환하는 일은 특히 빈번한 연산이기 때문에 float_of_int 함수는 짧은 별칭(alias)를 가지고 있다. 위 예제는 다음과 같이 간결하게 작성할 수 있다.

float i +. f

(C 언와는 달리 OCaml에서는 타입과 함수가 같은 이름을 가질 수 있다.)

묵시적 캐스트와 명시적 캐스트 어떤 것이 더 좋은가?

여러분은 아마 명시적 캐스트가 사용하기 불편하고, 시간이 많이 드는 일이라고 생각할 수도 있다.(일리가 있는 말이다.) 하지만 명시적 캐스트를 옹호하는 데는 세 가지 이유가 있다. 우선, OCaml은 타입 추론(아래 참조)을 하기 위해 명시적 캐스트가 필요하다. 타입 추론은 명시적 캐스트를 하느라 추가적으로 드는 타이핑 노동을 상쇄할 만큼 매력적인 기능이다. 다음으로, 여러분이 C 프로그램을 디버깅하면서 시간을 보냈다면 (a) 묵시적 캐스트는 찾아내기 어려운 버그를 만들어 내고 (b) 많은 시간을 묵시적 캐스트가 제대로 동작하는지 알아보는 데 사용해야 한다는 사실을 잘 알고 있을 것이다. 이런 캐스트를 명시적으로 만들면 디버깅을 노력을 줄일 수 있다. 마지막으로, 몇 개의 캐스트(특히 int<->float)는 매우 비싼 연산이므로, 이런 연산을 숨겨서 좋을 것이 없다.

보통 함수와 재귀 함수

C 계열의 언어와 달리, OCaml에서 재귀 함수를 정의하려면 let 대신에 let rec를 사용해 명시적으로 재귀 함수임을 밝혀야 한다. 다음은 재귀 함수의 예제이다.

let rec range a b =
  if a > b then []
  else a :: range (a+1) b

range는 스스로를 호출하고 있다.

letlet rec의 유일한 차이점은 함수 이름의 유효범위(scope)에 있다. 만약 위 함수가 let으로 정의되었다면, range 호출은 현재 호출 중인 함수가 아닌 range라는 이름을 가진 이미 있는(그전에 정의된) 함수를 찾으려고 할 것이다. let을 사용해 작성된 함수와 let rec를 사용해 작성된 함수에 성능 차이는 없으므로, C 언어와 유사한 의미(semantic)를 얻고 싶다면 항상 let rec를 사용하면 된다.

함수의 타입

타입 추론 덕택에 여러분은 함수의 타입을 명시적으로 써야할 필요는 거의 없을 것이다. 하지만, OCaml은 여러분 함수의 타입이라고 생각하는 값을 표시해주므로, 타입을 표시하는 문법을 이해할 필요가 있다. 인수 arg1, arg2... argn를 받고, 리턴 타입으로 rettype를 돌려주는 함수 f의 경우, 컴파일러는 다음과 같이 출력해준다.

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

화살표 문법이 지금은 이상해 보이겠지만, 나중에 소위 "커링(currying)"을 접하게 되면, 왜 이런 방식을 택했는지 알게 될 것이다. 일단은 몇 가지 예제를 살펴보도록 하자.

문자열과 정수를 인수로 받아 문자열을 돌려주는 repeated 함수는 다음과 같은 타입을 가진다.

repeated : string -> int -> string

두 개의 실수를 받아 실수를 리턴하는 average 함수의 타입은 다음과 같다.

average : float -> float -> float

OCaml의 표준 int_of_char 캐스트 함수는 다음과 같다.

int_of_char : char -> int

만약 함수가 아무 것도 리턴하지 않는다면(C와 자바의 경우 void), OCaml에서는 unit을 리턴한다고 쓴다. 예를 들어, fputc에 해당하는 OCaml 함수는 다음과 같다.

output_char : out_channel -> char -> unit

다형 함수(Polymorphic functions)

이제 조금 더 이상한 녀석을 살펴보자. 아무 것이나 인수로 받을 수 있는 함수는 어떨까? 다음은 인수를 하나 받아서 그 인수를 무시하고 무조건 3을 돌려주는 이상한 함수이다.

let give_me_a_three x = 3

이 함수의 타입은 무엇일까? OCaml은 "당신이 상상하는 어떤 타입이나"을 뜻하는 특별한 방법이 있다. 작은따옴표(')와 문자를 쓰는 것이다. 위 함수의 타입은 보통 다음과 같이 표현된다.

give_me_a_three : 'a -> int

여기서 'a는 실제로 어떤 타입이든 가능함을 의미한다. 예를 들어, 여러분은 이 함수를 give_me_a_three "foo"로 호출할 수도 있고, give_me_a_three 2.0로 호출할 수도 있다. 둘 다 OCaml에서 문제 없는 표현이다.

여러분은 아직 다형 함수가 왜 유용한지 잘 모르겠지만, 다형 함수는 매우 유용하고 매우 일반적이다. 따라서 이에 대해서는 이후에 다시 다루겠다. (힌트: 다형성은 C++의 템플릿(template)이나 Java 1.5의 제네릭스(generics)와 유사하다.)

타입 추론(type inference)

이 튜토리얼의 주제는 함수형 언어가 매우 멋진 기능을 많이 가지고 있고, OCaml은 이런 멋진 기능을 하나로 통합한 언어이며, 따라서 실제 개발자들이 사용하기에 매우 실용적인 언어라는 점이다. 하지만 이상한 점은 이런 멋진 기능의 대부분이 "함수형 프로그래밍"과는 전혀 관계가 없다는 사실이다. 사실, 첫 번째 정말 멋진 기능을 소개하기까지, 아직도 왜 함수형 프로그래밍이 "함수형"이라고 불리는지 설명하지 않았다. 어쨌든, 이쯤에서 첫 번째 멋진 기능을 소개하고자 하는데, 바로 타입 추론이다.

쉽게 말해서, 여러분은 함수와 변수의 어떤 타입도 선언할 필요가 없다. OCaml이 알아서 해주기 때문이다.

게다가 OCaml은 여러분의 모든 타입이 맞는지도 확인해준다(파일 경계를 넘어서까지 말이다).

하지만 OCaml은 또한 실용적인 언어이므로, 특수한 경우에 한해서 타입 시스템을 우회할 수 있는 뒷구멍(backdoor)를 제공하기도 한다. 아마 구루(guru)만이 타입 검사를 우회하는 기능이 필요할 것이다.

우리가 OCaml 인터프리터(toplevel)에 타이핑해 넣은, average 함수로 돌아가보자.

# let average a b =
    (a +. b) /. 2.0;;
val average : float -> float -> float = <fun>

OCaml은 함수가 두 개의 float 인수를 받아서 float를 리턴한다는 사실을 스스로 알아냈다.

어떻게 했을까? 우선 OCaml은 ab가 사용된 위치((a +. b))를 살펴본다. 그리고 +.는 그 자체가 항상 두 개의 float 인수를 받는 함수이다. 따라서 간단한 연역법에 의해 ab는 모두 float 타입이 되어야 한다.

다음으로, /. 함수는 float을 리턴하고 이는 average 함수의 리턴 타입과 동일하다. 결론은 average와 다음과 같은 타입 시그너처(signature)를 가진다는 것이다.

average : float -> float -> float

타입 추론은 이와 같이 짧은 프로그램에서는 무척 간단하고, 큰 프로그램에서도 문제 없이 동작한다. 이는 개발자의 시간을 절약해 주는 주요 기능이다. 왜냐하면 다른 언어에서 발생하는 세그멘테이션폴트(segfault)와 NullPointerException, ClassCastException (혹은 Perl 등에서 종종 무시되는 런타임 경고) 등을 완전히 제거해 주기 때문이다.