기본 데이터 타입과 패턴 매칭

기본 데이터 타입과 패턴 매칭 (Basic Data Types and Pattern Matching)

이 문서는 정수나 불리언 같은 원자 타입, 문자열과 리스트 같은 미리 정의된 복합 타입, 그리고 variant와 record 같은 사용자 정의 타입을 다뤄요. 각 타입에 대해 패턴 매칭을 하는 방법도 살펴볼게요.

OCaml에서는 런타임에 타입 검사가 없고, 명시적으로 변환하지 않는 한 값의 타입이 바뀌지 않아요. 이것이 정적 타입(static)이면서 강타입(strong)이라는 뜻이에요. 덕분에 구조화된 데이터를 안전하게 처리할 수 있죠.

출처: OCaml 공식 문서

본문

참고: 이전 튜토리얼들처럼 # 다음에 오고 ;;로 끝나는 표현식들은 UTop 같은 toplevel을 위한 것이에요.

정수, 실수, 불리언, 문자 (Integers, Floats, Booleans, and Characters)

정수 (Integers)

int 타입은 OCaml의 기본 정수 타입이에요. 정수를 입력하면 OCaml은 그 값을 정수로 인식해요. 다음 예시를 볼게요.

# 42;;
- : int = 42

int 타입은 플랫폼에 의존하는 부호 있는 정수를 나타내요. 즉 int가 항상 같은 비트 수를 가지는 건 아니에요. 프로세서 아키텍처나 운영체제 같은 기반 플랫폼의 특성에 따라 달라져요. int 값에 대한 연산은 StdlibInt 모듈이 제공해요.

보통 int는 32비트 아키텍처에서 31비트, 64비트 아키텍처에서 63비트인데, 한 비트가 OCaml 런타임 동작을 위해 예약되어 있기 때문이에요. 표준 라이브러리는 또 32비트·64비트 부호 있는 정수에 플랫폼 독립적 연산을 지원하는 Int32Int64 모듈도 제공해요. 이 모듈들은 이 튜토리얼에서 자세히 다루진 않아요.

OCaml에는 부호 없는 정수를 위한 전용 타입이 없어요. int에 대한 비트 연산은 부호 비트를 다른 비트와 똑같이 취급해요. 이항 연산자는 표준 기호를 사용해요. 부호 있는 나머지 연산자는 mod로 써요. OCaml의 정수에는 미리 정의된 거듭제곱 연산자가 없어요.

실수와 타입 변환 (Floats and Type Conversions)

실수는 float 타입이에요.

OCaml은 값 사이의 암시적 타입 변환을 수행하지 않아요. 그래서 산술 표현식은 정수와 실수를 섞을 수 없어요. 인자는 모두 int이거나 모두 float이어야 해요. 실수에 대한 산술 연산자는 정수와 같지 않은데, 뒤에 점을 붙여 써요: +., -., *., /..

# let pi = 3.14159;;
val pi : float = 3.14159

# let tau = 2.0 *. pi;;
val tau : float = 6.28318

# let tau = 2 *. pi;;
Error: This expression has type int but an expression was expected of type
         float

# let tau = 2 * pi;;
Error: This expression has type float but an expression was expected of type
         int

float에 대한 연산은 StdlibFloat 모듈이 제공해요.

불리언 (Booleans)

불리언 값은 bool 타입으로 나타내요.

# true;;
- : bool = true

# false;;
- : bool = false

# false < true;;
- : bool = true

bool에 대한 연산은 StdlibBool 모듈이 제공해요. 논리곱 "and"는 &&로, 논리합 "or"는 ||로 써요. 둘 다 단락(short-circuit) 평가를 하는데, 왼쪽 인자의 값만으로 전체 표현식의 값을 결정할 수 있으면 오른쪽 인자를 평가하지 않는다는 뜻이에요.

OCaml에서 if … then … else …조건 표현식(conditional expression)이에요. 각 분기와 같은 타입을 가져요.

# 3 * if "foo" = "bar" then 5 else 5 + 2;;
- : int = 21

검사용 하위 표현식은 bool 타입이어야 하고, 분기 하위 표현식들은 같은 타입이어야 해요.

조건 표현식과 불리언에 대한 패턴 매칭은 같아요.

# 3 * match "foo" = "bar" with true -> 5 | false -> 5 + 2;;
- : int = 21

문자 (Characters)

char 타입의 값은 Latin-1 집합의 256개 기호와 대응해요. 문자 리터럴은 아래처럼 작은따옴표로 감싸요.

# 'd';;
- : char = 'd'

char 값에 대한 연산은 StdlibChar 모듈이 제공해요.

Uchar 모듈은 유니코드 문자를 지원해요.

문자열과 바이트 시퀀스 (Strings & Byte Sequences)

문자열 (Strings)

문자열은 불변(immutable)이에요. 즉 문자열 안에 있는 문자의 값을 바꾸는 건 불가능해요.

# "hello" ^ " " ^ "world!";;
- : string = "hello world!"

문자열은 char 값들의 유한하고 고정된 크기의 시퀀스예요. 문자열 연결 연산자 기호는 ^이에요.

문자열 문자에 인덱스로 접근하는 것도 가능해요.

# "buenos dias".[4];;
- : char : 'o'

string 값에 대한 연산은 StdlibString 모듈이 제공해요.

바이트 시퀀스 (Byte Sequences)

# String.to_bytes "hello";;
- : bytes = Bytes.of_string "hello"

바이트 시퀀스는 문자열처럼 유한하고 고정된 크기를 가져요. 각각의 개별 바이트는 char 값으로 나타내요. 배열처럼 바이트 시퀀스는 가변적(mutable)이에요. 즉 늘이거나 줄일 수는 없지만 각 구성 바이트는 갱신할 수 있어요. 본질적으로 바이트 시퀀스(타입 bytes)는 출력할 수 없는 가변 문자열이에요. bytes를 리터럴로 쓸 방법은 없어서 반드시 함수로 만들어야 해요.

bytes 값에 대한 연산은 StdlibBytes 모듈이 제공해요. 오직 Bytes.get 함수만이 바이트 시퀀스에 포함된 문자에 직접 접근할 수 있어요. 배열과 달리 바이트 시퀀스에는 직접 접근 연산자가 없어요.

bytes의 메모리 표현은 char array보다 4배 더 조밀해요.

배열과 리스트 (Arrays & Lists)

배열 (Arrays)

배열은 같은 타입의 값들로 이루어진 유한하고 고정된 크기의 시퀀스예요. 몇 가지 예시를 볼게요.

# [| 0; 1; 2; 3; 4; 5 |];;
- : int array = [|0; 1; 2; 3; 4; 5|]

# [| 'x'; 'y'; 'z' |];;
- : char array = [|'x'; 'y'; 'z'|]

# [| "foo"; "bar"; "baz" |];;
- : string array = [|"foo"; "bar"; "baz"|]

배열은 어떤 타입의 값도 담을 수 있어요. 위에서는 int array, char array, string array지만, 어떤 타입의 데이터든 배열에 쓸 수 있어요. 보통 array는 다형적 타입이라고 말해요. 엄밀히 말하면 타입 연산자(type operator)인데, (여기서는 int, char, string 같은) 타입을 인자로 받아 다른 타입을 만들어 내는 거예요. 다음은 빈 배열이에요.

# [||];;
- : 'a array = [||]

기억하세요. 'a("알파")는 다른 타입으로 대체될 타입 매개변수예요.

stringbytes처럼 배열도 직접 접근을 지원하지만, 문법은 같지 않아요.

# [| 'x'; 'y'; 'z' |].(2);;
- : char = 'z'

배열은 가변적이에요. 즉 늘이거나 줄일 수는 없지만, 각 요소는 갱신할 수 있어요.

# let letter = [| 'v'; 'x'; 'y'; 'z' |];;
val letter : char array = [|'v'; 'x'; 'y'; 'z'|]

# letter.(2) <- 'F';;
- : unit = ()

# letter;;
- : char array = [|'v'; 'x'; 'F'; 'z'|]

왼쪽 화살표 <-는 배열 갱신 연산자예요. 위에서 인덱스 2의 칸에 값 'F'를 설정한다는 뜻이에요. Array.set letter 2 'F'라고 쓰는 것과 같아요. 배열 갱신은 부작용(side effect)이고, unit 값이 반환돼요.

배열에 대한 연산은 Array 모듈이 제공해요. 전용 Arrays 튜토리얼도 있어요.

리스트 (Lists)

리터럴로서 리스트는 배열과 아주 비슷해요. 앞의 예시를 리스트로 바꾼 모습이에요.

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

# [ 'x'; 'y'; 'z' ];;
- : char list = ['x'; 'y'; 'z']

# [ "foo"; "bar"; "baz" ];;
- : string list = ["foo"; "bar"; "baz"]

배열처럼 리스트도 같은 타입 값들의 유한한 시퀀스이고, 역시 다형적이에요. 하지만 리스트는 확장 가능(extensible)하고 불변이며, 담고 있는 모든 값에 직접 접근하는 것을 지원하지 않아요. 리스트는 함수형 프로그래밍에서 중심적인 역할을 하기 때문에 전용 튜토리얼이 있어요.

리스트에 대한 연산은 List 모듈이 제공해요. List.append 함수는 리스트 두 개를 이어 붙여요. @ 기호로 연산자처럼 쓸 수도 있어요.

리스트와 관련해 특히 중요한 기호들이 있어요.

빈 리스트는 []라고 쓰고, 타입은 'a list'이며 "nil"이라고 읽어요.

리스트 생성자 연산자는 ::라고 쓰고 "cons"라고 읽으며, 리스트의 머리에 값을 추가하는 데 사용해요.

이 둘은 함께 리스트를 만들고 저장된 데이터에 접근하는 기본 수단이 돼요. 예를 들어 cons(::) 연산자를 연속적으로 적용해 리스트를 만드는 방법이에요.

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

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

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

패턴 매칭은 리스트 안에 저장된 데이터에 접근하는 기본 수단을 제공해요.

# match [1; 2; 3] with
  | x :: u -> x
  | [] -> raise Exit;;
- : int = 1

# match [1; 2; 3] with
  | x :: y :: u -> y
  | x :: u -> x
  | [] -> raise Exit;;
- : int = 2

위 표현식들에서 [1; 2; 3]은 매칭이 수행되는 값이에요. |-> 기호 사이의 각 표현식이 패턴이에요. 그것들은 []::, 그리고 리스트가 가질 수 있는 여러 형태를 나타내는 바인딩 이름만으로 만들어진 list 타입의 표현식이에요. [] 패턴은 "리스트가 비어 있으면"이라는 뜻이고, x :: u 패턴은 "리스트에 데이터가 있으면 x를 리스트의 첫 요소로, u를 리스트의 나머지로 두자"라는 뜻이에요. -> 기호 오른쪽의 표현식들은 각각 해당하는 경우에 반환되는 결과예요.

옵션과 결과 (Options & Results)

옵션 (Options)

option 타입도 다형적 타입이에요. 옵션 값은 어떤 종류의 데이터든 저장할 수 있고, 그런 데이터가 없음을 나타낼 수도 있어요. 옵션 값은 두 가지 방법으로만 만들 수 있어요. 데이터가 없을 땐 None, 그 외에는 Some이에요.

# None;;
- : 'a option = None

# Some 42;;
- : int option = Some 42

# Some "hello";;
- : string option = Some "hello"

옵션 값에 대한 패턴 매칭 예시예요.

# match Some 42 with None -> raise Exit | Some x -> x;;
- : int = 42

옵션에 대한 연산은 Option 모듈이 제공해요. 옵션은 Error Handling 가이드에서 자세히 다뤄요.

결과 (Results)

result 타입은 함수의 결과가 성공이거나 실패일 수 있음을 표현할 때 사용해요. 결과 값을 만드는 방법은 OkError를 쓰는 두 가지뿐이에요. 두 생성자 모두 어떤 종류의 데이터든 담을 수 있어요. result 타입은 다형적이지만 타입 매개변수가 두 개예요. 하나는 Ok 값을 위한 것이고, 다른 하나는 Error 값을 위한 것이에요.

# Ok 42;;
- : (int, 'a) result = Ok 42

# Error "Sorry";;
- : ('a, string) result = Error "Sorry"

결과에 대한 연산은 Result 모듈이 제공해요. 결과는 Error Handling 가이드에서 다뤄요.

튜플 (Tuples)

두 값을 담은 튜플이에요. 쌍(pair)이라고도 불러요.

# (3, 'K');;
- : int * char = (3, 'K')

그 쌍은 정수 3과 문자 'K'를 담고 있고, 타입은 int * char이에요. * 기호는 곱 타입(product type)을 뜻해요.

이것은 요소가 3개 이상인 튜플로 일반화돼요. 예를 들어 (6.28, true, "hello")는 타입이 float * bool * string이에요. int * charfloat * bool * string 같은 타입을 곱 타입이라고 불러요. * 기호는 곱에 묶인 타입들을 나타내는 데 쓰여요.

미리 정의된 함수 fst는 쌍의 첫 번째 요소를, snd는 두 번째 요소를 돌려줘요.

# fst (3, 'g');;
- : int = 3

# snd (3, 'g');;
- : char = 'g'

표준 라이브러리에서 둘 다 패턴 매칭으로 정의돼 있어요. 네 타입의 곱에서 세 번째 요소를 추출하는 함수는 이렇게 작성해요.

# let f x = match x with (h, i, j, k) -> j;;
val f : 'a * 'b * 'c * 'd -> 'c = <fun>

타입 int * char * bool, int * (char * bool), (int * char) * bool이 서로 같지 않다는 점을 주의하세요. 값 (42, 'h', true), (42, ('h', true)), ((42, 'h'), true)도 서로 같지 않아요. 수학적 언어로 말하면 곱 타입 연산자 *결합적(associative)이지 않아요.

함수 (Functions)

타입 m에서 타입 n으로 가는 함수의 타입은 m -> n이라고 써요. 몇 가지 예시를 볼게요.

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

# (fun x -> x * x) 9;;
- : int = 81

첫 번째 표현식은 타입 int -> int인 익명 함수(anonymous function)예요. 타입은 x * x 표현식에서 추론되는데, *int를 반환하는 연산자이므로 반드시 int 타입이어야 해요. 값 자리에 출력된 <fun>은 토큰으로, 함수는 표시할 값이 없다는 뜻이에요. 컴파일된 경우 함수의 코드를 더 이상 사용할 수 없기 때문이에요.

두 번째 표현식은 함수 적용(application)이에요. 인자 9가 적용되고 결과 81이 반환돼요.

# fun x -> x;;
- : 'a -> 'a = <fun>

# (fun x -> x) 42;;
- : int = 42

# (fun x -> x) "This is really disco!";;
- : string = "This is really disco!"

첫 번째 표현식은 또 다른 익명 함수예요. 이것은 항등(identity) 함수로, 어떤 것에든 적용할 수 있고 인자를 그대로 돌려줘요. 즉 인자는 어떤 타입이든 될 수 있고, 결과는 같은 타입이에요. 같은 코드가 서로 다른 타입의 데이터에 적용될 수 있죠. 이것을 다형성(polymorphism)이라고 불러요.

기억하세요. 'a타입 매개변수라서 어떤 타입의 값이든 함수에 전달될 수 있고, 그 타입이 타입 매개변수를 대체해요. 항등 함수는 입력과 출력 타입이 무엇이든 같아요.

다음 예시는 항등 함수가 서로 다른 타입의 인자에 적용될 수 있음을 보여줘요.

# let f = fun x -> x;;
val f : 'a -> 'a = <fun>

# f 9;;
- : int = 9

# f "hello";;
- : string = "hello"

함수를 정의하는 것은 값을 이름 짓는 것과 같아요. 첫 번째 표현식이 그걸 보여줘요.

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

# g 9;;
- : int = 81

실행 가능한 OCaml 코드는 주로 함수로 구성되므로, 함수를 최대한 간결하고 명확하게 만드는 게 좋아요. 위에서 g 함수는 더 짧고 더 흔하며 어쩌면 더 직관적인 문법으로 정의돼 있어요.

OCaml에서 함수는 예외(타입 exn)를 던져 예상한 타입의 값을 반환하지 않고 끝날 수 있어요. 이 예외는 함수의 타입에 나타나지 않아요. 코드를 살펴보지 않고는 함수가 예외를 일으킬 수 있는지 알 방법이 없어요.

# raise;;
- : exn -> 'a' = <fun>

예외는 Error Handling 가이드에서 다뤄요.

함수는 여러 매개변수를 가질 수 있어요.

# fun s r -> s ^ " " ^ r;;
- : string -> string -> string = <fun>

# let mean s r = (s + r) / 2;;
val mean : int -> int -> int = <fun>

곱 타입 기호 *처럼, 함수 타입 기호 ->도 결합적이지 않아요. 다음 두 타입은 같지 않아요.

(int -> int) -> int: 이 함수는 타입 int -> int의 함수를 매개변수로 받아 결과로 int를 반환해요.

int -> (int -> int): 이 함수는 int를 매개변수로 받아 결과로 타입 int -> int의 함수를 반환해요.

Unit

독특하게도 unit 타입은 값이 하나뿐이에요. ()라고 쓰고 "unit"이라고 읽어요.

unit 타입은 여러 용도가 있어요. 주로 함수가 데이터를 전달받을 필요가 없거나, 계산을 마친 뒤 반환할 데이터가 없을 때 토큰 역할을 해요. 이런 일은 함수가 OS 수준의 I/O 같은 부작용을 가질 때 생겨요. 함수는 계산이 촉발되려면 뭔가에 적용되어야 하고, 또 뭔가를 반환해야 해요. 의미 있는 값을 전달하거나 반환할 게 없을 때 ()를 쓰면 돼요.

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

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

read_line 함수는 표준 입력에서 줄바꿈으로 끝나는 문자 시퀀스를 읽어 문자열로 반환해요. ()를 전달하면 입력 읽기가 시작돼요.

 # read_line ();;
foo bar
- : string = "foo bar"

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

참고: foo bar를 직접 입력한 텍스트로 바꾸고 Return을 누르세요.

print_endline 함수는 문자열 다음에 줄바꿈을 붙여 표준 출력에 출력해요. unit 값이 반환된다는 것은 출력 요청이 운영체제에 의해 큐에 들어갔다는 뜻이에요.

사용자 정의 타입 (User-Defined Types)

사용자 정의 타입은 항상 type … = … 문으로 도입돼요. type 키워드는 반드시 소문자로 써야 해요. 첫 번째 타원은 타입 이름이고 대문자로 시작하면 안 돼요. 두 번째 타원은 타입 정의예요. 세 가지 경우가 가능해요.

  • Variant
  • Record
  • Alias (타입 별칭)

이 세 종류의 타입 정의를 다음 세 절에서 다룰게요.

Variants

Variant는 태그된 유니온(tagged unions)이라고도 불러요. 서로소 합집합(disjoint union)이라는 개념과 관련이 있어요.

열거형 데이터 타입 (Enumerated Data Types)

variant 타입의 가장 단순한 형태는 열거형 타입에 해당해요. 이름 붙은 값들의 명시적 목록으로 정의돼요. 정의된 값들을 생성자(constructor)라고 부르고 대문자로 표기해야 해요.

예를 들어 던전 앤 드래곤의 캐릭터 클래스와 성향을 나타내기 위해 variant 데이터 타입을 다음과 같이 정의할 수 있어요.

# type character_class =
    | Barbarian
    | Bard
    | Cleric
    | Druid
    | Fighter
    | Monk
    | Paladin
    | Ranger
    | Rogue
    | Sorcerer
    | Wizard;;
type character_class =
    Barbarian
  | Bard
  | Cleric
  | Druid
  | Fighter
  | Monk
  | Paladin
  | Ranger
  | Rogue
  | Sorcerer
  | Wizard

# type rectitude = Evil | R_Neutral | Good;;
type rectitude = Evil | R_Neutral | Good

# type firmness = Chaotic | F_Neutral | Lawful;;
type firmness = Chaotic | F_Neutral | Lawful

이런 종류의 variant 타입은 요일이나 방위, 또는 이름을 붙일 수 있는 고정 크기의 값 집합 같은 것을 나타내는 데도 쓸 수 있어요. 값에는 정의 순서에 따라 순서가 정해져요 (예: Druid < Ranger).

위에서 정의한 타입에 대해 패턴 매칭을 수행할 수 있어요.

# let rectitude_to_french = function
    | Evil -> "Mauvais"
    | R_Neutral -> "Neutre"
    | Good -> "Bon";;
val rectitude_to_french : rectitude -> string = <fun>

다음을 주의하세요.

  • unit은 데이터를 담지 않는 유일한 생성자 ()를 가진 variant예요.
  • bool도 데이터를 담지 않는 두 생성자 truefalse를 가진 variant예요.
데이터를 담는 생성자 (Constructors With Data)

생성자에 데이터를 감쌀 수 있어요. 다음 타입은 데이터를 담는 생성자들(예: Hash of string)과 담지 않는 생성자들(예: Head)을 여럿 가져요. Git 개정을 가리키는 여러 방법을 나타내요.

# type commit =
  | Hash of string
  | Tag of string
  | Branch of string
  | Head
  | Fetch_head
  | Orig_head
  | Merge_head;;
type commit =
    Hash of string
  | Tag of string
  | Branch of string
  | Head
  | Fetch_head
  | Orig_head
  | Merge_head

패턴 매칭으로 commitstring으로 변환하는 방법이에요.

# let commit_to_string = function
  | Hash sha -> sha
  | Tag name -> name
  | Branch name -> name
  | Head -> "HEAD"
  | Fetch_head -> "FETCH_HEAD"
  | Orig_head -> "ORIG_HEAD"
  | Merge_head -> "MERGE_HEAD";;
val commit_to_string : commit -> string = <fun>

위에서 이전에 사용한 match … with … 대신 function … 구문을 사용했어요.

let commit_to_string' x = match x with
  | Hash sha -> sha
  | Tag name -> name
  | Branch name -> name
  | Head -> "HEAD"
  | Fetch_head -> "FETCH_HEAD"
  | Orig_head -> "ORIG_HEAD"
  | Merge_head -> "MERGE_HEAD";;
val commit_to_string' : commit -> string = <fun>

match … with … 구문에는 조사할 표현식을 전달해야 해요. function …은 매개변수를 하나 받아 그걸 match … with … 구문에 넘겨주는 익명 함수의 특별한 형태예요. 위 예시가 그렇죠.

경고: 곱 타입을 괄호로 감싸면 단일 매개변수가 돼요.

# type t =
  | C1 of int * bool
  | C2 of (int * bool);;
type t = C1 of int * bool | C2 of (int * bool)

# let p = (4, false);;
val p : int * bool = (4, false)

# C1 p;;
Error: The constructor C1 expects 2 argument(s),
       but is applied here to 1 argument(s)

# C2 p;;
- : t = C2 (4, false)

생성자 C1은 타입 intbool인 매개변수 두 개를 갖는 반면, 생성자 C2는 타입 int * bool인 매개변수 하나를 가져요.

재귀적 Variants (Recursive Variants)

자기 자신을 가리키는 variant 정의는 재귀적이에요. 생성자는 정의 중인 타입의 데이터를 감쌀 수 있어요.

JSON 값을 저장하는 데 쓸 수 있는 다음 정의가 그 경우예요.

# type json =
  | Null
  | Bool of bool
  | Int of int
  | Float of float
  | String of string
  | Array of json list
  | Object of (string * json) list;;
type json =
    Null
  | Bool of bool
  | Int of int
  | Float of float
  | String of string
  | Array of json list
  | Object of (string * json) list

ArrayObject 두 생성자 모두 타입 json의 값을 담고 있어요.

재귀적 variant에 대한 패턴 매칭으로 정의한 함수는 종종 재귀적이에요. 다음 함수는 이름이 전체 JSON 트리에 존재하는지 검사해요.

# let rec has_field name = function
  | Array u ->
      List.fold_left (fun b obj -> b || has_field name obj) false u
  | Object u ->
      List.fold_left
        (fun b (key, obj) -> b || key = name || has_field name obj) false u
  | _ -> false;;
val has_field : string -> json -> bool = <fun>

여기서 마지막 패턴은 모든 것을 잡아내는 _ 기호를 사용해요. ArrayObject도 아닌 모든 데이터에 대해 false를 반환해요.

다형적 데이터 타입 (Polymorphic Data Types)

미리 정의된 타입 다시 보기 (Revisiting Predefined Types)

미리 정의된 option 타입은 SomeNone 두 생성자를 가진 variant 타입이에요. Some 42Some "hola"처럼 어떤 타입의 값이든 담을 수 있어요. 그런 의미에서 option은 다형적이에요. 표준 라이브러리에서 이렇게 정의돼 있어요.

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

미리 정의된 list 타입도 같은 의미에서 다형적이에요. 두 생성자를 가진 variant이고 어떤 타입의 데이터든 담을 수 있어요. 표준 라이브러리에서 이렇게 정의돼 있어요.

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

여기서 유일한 마법은 생성자를 기호로 바꾸는 것인데, 이 튜토리얼에서는 다루지 않아요. boolunit 타입도 같은 마법이 적용된 일반적인 variant예요.

# #show unit;;
type unit = ()

# #show bool;;
type bool = false | true

암시적으로 곱 타입도 variant 타입처럼 동작해요. 예를 들어 쌍은 다음 타입의 원소로 볼 수 있어요.

# type ('a, 'b) pair = Pair of 'a * 'b;;
type ('a, 'b) pair = Pair of 'a * 'b

(int, bool) pairint * bool로 쓸 수 있고, Pair (42, true)(42, true)로 쓸 수 있어요. 개발자 관점에서는 가능한 모든 곱의 형태에 대해 그런 타입이 선언된 것처럼 모든 것이 일어나요. 이것이 곱에 대한 패턴 매칭을 가능하게 해요.

정수와 실수조차도 생성자가 많고 멋진 문법적 설탕이 있는 열거형 비슷한 variant로 볼 수 있어요. 덕분에 그 타입들에도 패턴 매칭을 할 수 있어요.

결국 variant로 환원되지 않는 유일한 타입 구성은 함수 화살표 타입이에요. 패턴 매칭은 함수를 제외한 어떤 타입의 값이라도 조사할 수 있어요.

사용자 정의 다형적 타입 (User-Defined Polymorphic Types)

데이터를 담는 생성자, 데이터를 담지 않는 생성자, 다형성, 재귀를 모두 결합한 variant 타입의 예시예요.

# type 'a tree =
  | Leaf
  | Node of 'a * 'a tree * 'a tree;;
type 'a tree = Leaf | Node of 'a * 'a tree * 'a tree

이 타입은 임의로 라벨이 붙은 이진 트리를 나타내는 데 쓸 수 있어요. 그런 트리에 정수들이 라벨로 붙어 있다고 가정하면, 재귀와 패턴 매칭을 사용해 정수들의 합을 계산하는 방법이 있어요.

# let rec sum = function
  | Leaf -> 0
  | Node (x, lft, rht) -> x + sum lft + sum rht;;
val sum : int tree -> int = <fun>

이 타입에서 map 함수를 정의하는 방법이에요.

# let rec map f = function
  | Leaf -> Leaf
  | Node (x, lft, rht) -> Node (f x, map f lft, map f rht);;
val map : ('a -> 'b) -> 'a tree -> 'b tree = <fun>

OCaml 커뮤니티와 더 넓은 함수형 프로그래밍 커뮤니티에서 다형성이라는 단어는 관대하게 쓰여요. 다양한 타입과 비슷한 방식으로 동작하는 것들에 적용되죠. 이런 넓은 의미에서 OCaml의 여러 기능이 다형적이에요. 각각은 특정한 형태의 다형성을 사용하고 이름도 있어요. 요약하면 OCaml에는 여러 형태의 다형성이 있어요. 대부분의 경우 그 개념들 사이의 구분은 흐릿하지만, 때로는 구분할 필요가 있어요.

데이터 타입에 적용되는 용어들이에요.

  • 'a list, 'a option, 'a tree는 종종 다형적 타입이라고 불려요. 형식적으로 bool listint option이 타입이고, listoption은 타입 매개변수를 받아 타입을 만드는 타입 연산자예요. 이것은 매개변수적 다형성(parametric polymorphism)의 한 형태예요. 'a list'a option은 타입 매개변수를 연산자에 적용해 만들어지는 모든 타입인 타입 족(type family)을 나타내요.

Records

Record는 여러 값을 묶는다는 점에서 튜플과 비슷해요. 튜플에서 요소는 해당 곱 타입에서의 위치로 식별돼요. 첫 번째, 두 번째, 세 번째, 또는 다른 위치가 될 수 있죠. Record에서는 각 요소에 이름과 값이 있어요. 이 이름-값 쌍을 필드(field)라고 불러요. 그래서 record 타입은 사용하기 전에 선언해야 해요.

예를 들어 던전 앤 드래곤 캐릭터 클래스를 부분적으로 나타내기 위한 record 타입의 정의예요. 다음 코드는 이 튜토리얼 앞부분의 정의에 의존하니, 열거형 데이터 타입 절의 정의를 먼저 입력했는지 확인하세요.

# type character = {
  name : string;
  level : int;
  race : string;
  class_type : character_class;
  alignment : firmness * rectitude;
  armor_class : int;
};;
type character = {
  name : string;
  level : int;
  race : string;
  class_type : character_class;
  alignment : firmness * rectitude;
  armor_class : int;
}

타입 character의 값은 이 곱의 원소들과 같은 데이터를 담아요: string * int * string * character_class * character_alignment * int.

아래처럼 점 표기법으로 필드에 접근해요.

# let ghorghor_bey = {
    name = "Ghôrghôr Bey";
    level = 17;
    race = "half-ogre";
    class_type = Fighter;
    alignment = (Chaotic, R_Neutral);
    armor_class = -8;
  };;
val ghorghor_bey : character =
  {name = "Ghôrghôr Bey"; level = 17; race = "half-ogre";
   class_type = Fighter; alignment = (Chaotic, R_Neutral); armor_class = -8}

# ghorghor_bey.alignment;;
- : firmness * rectitude = (Chaotic, R_Neutral)

# ghorghor_bey.class_type;;
- : character_class = Fighter

# ghorghor_bey.level;;
- : int = 17

바뀌지 않는 필드를 다시 입력하지 않고 일부 필드 값이 변경된 새 record를 만들려면 아래처럼 record 갱신 문법을 사용할 수 있어요.

# let togrev  = { ghorghor_bey with name = "Togrev"; level = 20; armor_class = -6 };;
val togrev : character =
  {name = "Togrev"; level = 20; race = "half-ogre"; class_type = Fighter;
   alignment = (Chaotic, R_Neutral); armor_class = -6}

record는 단일 생성자 variant처럼 동작한다는 점을 주의하세요. 덕분에 record에도 패턴 매칭을 할 수 있어요.

# match ghorghor_bey with { level; _ } -> level;;
- : int = 17

별칭 (Aliases)

타입 별칭 (Type Aliases)

값처럼 어떤 타입에도 이름을 붙일 수 있어요.

# type latitude_longitude = float * float;;
type latitude_longitude = float * float

이것은 주로 문서화 수단이거나 긴 타입 표현식을 줄이는 데 유용해요.

함수 매개변수 별칭 (Function Parameter Aliases)

함수 매개변수도 튜플과 record에 대한 패턴 매칭으로 이름을 붙일 수 있어요.

(* Tuples as parameters *)
# let tuple_sum (x, y) = x + y;;
val tuple_sum : int * int -> int = <fun>

# let f ((x, y) as arg) = tuple_sum arg;;
val f : int * int -> int = <fun>

(* Records as parameters *)
# type dummy_record = {a: int; b: int};;
type dummy_record = { a : int; b : int; }

# let record_sum ({a; b}: dummy_record) = a + b;;
val record_sum : dummy_record -> int = <fun>

# let f ({a;b} as arg) = record_sum arg;;
val f : dummy_record -> int = <fun>

이것은 매개변수의 variant 값을 매칭할 때 유용해요.

# let meaning_of_life = function Some _ as opt -> opt | None -> Some 42;;
val meaning_of_life : int option -> int option = <fun>

완전한 예시: 수학 표현식 (A Complete Example: Mathematical Expressions)

이 예시는 n * (x + y) 같은 간단한 수학 표현식을 나타내고, 기호적으로 전개해서 n * x + n * y를 얻는 방법을 보여줘요.

# type expr =
  | Plus of expr * expr        (* a + b *)
  | Minus of expr * expr       (* a - b *)
  | Times of expr * expr       (* a * b *)
  | Divide of expr * expr      (* a / b *)
  | Var of string              (* "x", "y", etc. *);;
type expr =
    Plus of expr * expr
  | Minus of expr * expr
  | Times of expr * expr
  | Divide of expr * expr
  | Var of string

표현식 n * (x + y)는 이렇게 써요.

# let e = Times (Var "n", Plus (Var "x", Var "y"));;
val e : expr = Times (Var "n", Plus (Var "x", Var "y"))

Times (Var "n", Plus (Var "x", Var "y"))n * (x + y)처럼 더 읽기 좋게 출력하는 함수예요.

# let rec to_string = function
  | Plus (e1, e2) -> "(" ^ to_string e1 ^ " + " ^ to_string e2 ^ ")"
  | Minus (e1, e2) -> "(" ^ to_string e1 ^ " - " ^ to_string e2 ^ ")"
  | Times (e1, e2) -> "(" ^ to_string e1 ^ " * " ^ to_string e2 ^ ")"
  | Divide (e1, e2) -> "(" ^ to_string e1 ^ " / " ^ to_string e2 ^ ")"
  | Var v -> v;;
val to_string : expr -> string = <fun>

n * (x + y)(x + y) * n 형태의 표현식을 전개하는 함수를 작성할 수 있어요. 중첩 패턴을 사용할 거예요.

# let rec distrib = function
  | Times (e1, Plus (e2, e3)) ->
     Plus (Times (distrib e1, distrib e2),
           Times (distrib e1, distrib e3))
  | Times (Plus (e1, e2), e3) ->
     Plus (Times (distrib e1, distrib e3),
           Times (distrib e2, distrib e3))
  | Plus (e1, e2) -> Plus (distrib e1, distrib e2)
  | Minus (e1, e2) -> Minus (distrib e1, distrib e2)
  | Times (e1, e2) -> Times (distrib e1, distrib e2)
  | Divide (e1, e2) -> Divide (distrib e1, distrib e2)
  | Var v -> Var v;;
val distrib : expr -> expr = <fun>

이렇게 사용해요.

# e |> distrib |> to_string |> print_endline;;
((n * x) + (n * y))
- : unit = ()

처음 두 패턴이 distrib 함수가 어떻게 동작하는지의 핵심이에요. 첫 번째 패턴은 Times (e1, Plus (e2, e3))로, e1 * (e2 + e3) 형태의 표현식과 일치해요. 이 첫 패턴의 오른쪽은 (e1 * e2) + (e1 * e3)와 동등해요. 두 번째 패턴은 (e1 + e2) * e3 형태의 표현식을 제외하고는 같은 일을 해요.

나머지 패턴들은 표현식의 형태를 바꾸지 않지만, 하위 표현식에 대해 distrib 함수를 재귀적으로 호출해요. 그래야 모든 하위 표현식까지 전개되는 게 보장되죠. (표현식의 최상위 단계만 전개하고 싶다면 나머지 패턴들을 단순한 e -> e 규칙으로 모두 대체해도 돼요.)

역연산, 즉 공통 하위 표현식을 인수분해하는 것도 비슷한 방식으로 구현할 수 있어요. 다음 버전은 최상위 표현식에만 동작해요.

# let top_factorise = function
  | Plus (Times (e1, e2), Times (e3, e4)) when e1 = e3 ->
     Times (e1, Plus (e2, e4))
  | Plus (Times (e1, e2), Times (e3, e4)) when e2 = e4 ->
     Times (Plus (e1, e3), e4)
  | e -> e;;
val top_factorise : expr -> expr = <fun>
# top_factorise (Plus (Times (Var "n", Var "x"),
                   Times (Var "n", Var "y")));;
- : expr = Times (Var "n", Plus (Var "x", Var "y"))

위 factorise 함수는 또 다른 기능을 소개해요. 각 패턴에 가드(guard)를 붙이는 거예요. 조건은 when 다음에 오고, 패턴이 일치하면서 when 절의 조건도 충족될 때만 반환 코드가 실행된다는 뜻이에요.

결론 (Conclusion)

이 튜토리얼은 OCaml의 기본 데이터 타입과 그 사용법에 대한 포괄적인 개요를 제공했어요. 정수, 실수, 문자, 리스트, 튜플, 문자열 같은 내장 타입과 record, variant 같은 사용자 정의 타입을 살펴봤어요. Record와 튜플은 이질적인 데이터를 응집된 단위로 묶는 메커니즘이고, variant는 이질적인 데이터를 일관된 대안들로 드러내는 메커니즘이에요.

이 튜토리얼에서 variant와 *곱(products)*을 다뤘는데, 이것들은 대수적 데이터 타입에 해당해요. 이 수준에서는 명목적(nominal) 타입 검사 알고리즘이 사용돼요. 역사적으로 이것은 OCaml의 첫 번째 타입 시스템인데, OCaml의 조상인 ML 프로그래밍 언어에서 왔기 때문이에요. OCaml에는 다른 타입 시스템도 있지만, 이 문서는 이 알고리즘으로 타입이 정해진 데이터에 초점을 맞췄어요.

더 알아보기