시퀀스

시퀀스 (Sequences)

OCaml에서 가장 널리 쓰이는 내장 데이터 타입 중 하나인 시퀀스(sequence)에 대해 배워볼게요. 시퀀스는 리스트와 아주 비슷하지만, 실용적인 관점에서 보면 '유한할 수도 있고 무한할 수도 있다'고 상상하는 게 핵심이에요. 이런 직관을 이해하면 시퀀스를 쓰고 다루는 일이 훨씬 수월해져요. 이걸 가능하게 하기 위해 시퀀스의 각 요소는 메모리에 저장되는 대신, 필요할 때 그때그때 계산돼요. 덕분에 메모리 사용량을 선형에서 상수 공간으로 줄여주기도 하죠.

출처: OCaml 공식 문서 - Sequences

소개 (Introduction)

시퀀스는 리스트와 매우 비슷해요. 하지만 실용적인 관점에서, 시퀀스는 유한할 수도 있고 무한할 수도 있다고 상상해야 해요. 시퀀스를 이해하고 사용하는 데 있어 이게 핵심 직관이에요. 이를 위해 시퀀스의 요소들은 메모리에 저장되는 대신 필요할 때(on demand) 계산됩니다. 더 자주, 시퀀스는 메모리 소비를 선형에서 상수 공간으로 줄이는 데도 쓰여요.

'a Seq.t 타입의 값을 이해하는 한 가지 방법은, 그것을 "지연 리스트(lazy list)"로 보는 거예요. 각 요소는 값을 드러내기 위해 '호출'되어야 하는 함수 안에 감싸져 있는 거죠.

표준 라이브러리에서 시퀀스가 어떻게 정의되는지 보면:

type 'a node =
  | Nil
  | Cons of 'a * 'a t
and 'a t = unit -> 'a node

이건 두 타입의 상호 재귀(mutually recursive) 정의예요. Seq.node는 거의 list와 같고:

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

Seq.t는 그저 unit -> 'a Seq.node 타입 별칭일 뿐이에요. 이 정의의 핵심은 Seq.Cons 두 번째 성분의 타입인데, 리스트 쪽은 리스트인 반면 시퀀스 쪽은 '시퀀스를 반환하는 함수'라는 점이에요. listSeq.node의 생성자를 비교해 볼까요:

  1. 빈 리스트와 빈 시퀀스는 같은 방식으로 정의돼요. 매개변수가 없는 생성자 Seq.Nil[]이에요.
  2. 비어 있지 않은 리스트와 시퀀스는 모두 쌍(pair)이며, 앞쪽 성분은 데이터 한 조각이에요.
  3. 다만 리스트에서 뒤쪽 성분은 재귀적으로 list인 반면, 시퀀스에서는 Seq.node를 반환하는 함수예요.

Seq.t 타입의 값은 리스트가 아니에요. 왜냐하면 그 안에 담긴 데이터가 즉시 사용 가능하지 않기 때문이에요. 그것을 되찾으려면 unit 값을 제공해야 해요. 이는 리스트의 첫 요소를 꺼내기 위해 강제로 평가(evaluation)를 진행하는 것으로 볼 수 있어요. 하지만 이것은 시퀀스의 꼭대기만 접근할 수 있게 해줄 뿐인데, Seq.Cons의 두 번째 인자도 함수이기 때문이에요.

이것이 시퀀스가 잠재적으로 무한하다고 여겨지는 이유를 설명해요. 시퀀스 안에서 Seq.Nil 값을 찾기 전까지는, 그런 값이 언젠가 나타날지 확실히 말할 수 없어요. 시퀀스는 서버로 들어오는 요청의 스트림일 수도, 임베디드 센서의 측정값일 수도, 시스템 로그일 수도 있어요. 이 모든 것은 종료 시점을 예측할 수 없어서, 잠재적으로 무한하다고 보는 게 더 쉬워요.

OCaml에서 타입 t의 어떤 값 afun _ -> a 또는 fun () -> a라고 쓰면 상수 함수로 바꿀 수 있어요. 후자의 함수를 thunk라고 불러요. 이 용어를 쓰면, Seq.t 값들은 thunk예요.

시퀀스 만들기 (Constructing Sequences)

이제 이렇게 이해했으니, 시퀀스를 직접 만들어 볼 수 있어요:

# let seq_123  =
    fun () -> Seq.Cons (1,
      fun () -> Seq.Cons (2,
        fun () -> Seq.Cons (3,
          fun () -> Seq.Nil)));;
val seq_123 : unit -> int Seq.node = <fun>

참고:Seq.Cons 튜플의 두 번째 성분은 함수예요. 이것은 값을 직접 제공하는 대신 값을 얻을 수 있는 수단을 제공하는 효과가 있어요.

함수를 사용해서 시퀀스를 만들 수도 있어요. 정수의 무한 시퀀스를 만드는 방법은 다음과 같아요:

# let rec ints n : int Seq.t = fun () -> Seq.Cons (n, ints (n + 1));;
val ints : int -> int Seq.t = <fun>

함수 ints n은 무한 수열 (n; n + 1; n + 2; n + 3;...)을 만드는 것처럼 보여요. 실제로는 기계 정수(machine integer)에 한계가 있어서, 이 수열은 무한히 커지지 않아요. 기술적인 이유로 max_int에 도달하면 min_int로 되돌아갑니다.

OCaml 표준 라이브러리에는 시퀀스를 위한 모듈인 Seq가 있어요. 여기에는 방금 우리가 직접 구현한 Seq.ints가 들어 있습니다.

시퀀스 반복하기 (Iterating Over Sequences)

OCaml 표준 라이브러리에는 List.iter와 같은 동작을 하는 Seq.iter 함수도 있어요. 이렇게 쓰면:

# Seq.iter print_int (ints 0);;

OCaml toplevel에서 "정수를 영원히 출력하라"는 뜻이 되고, 실행을 중단하려면 Ctrl-C를 눌러야 해요. 아래 코드는 출력 없이 같은 무한 루프를 도는 버전이에요:

# Seq.iter ignore (ints 0);;

핵심은 메모리가 새지(leak) 않는다는 것이에요. 이 예제는 상수 공간에서 실행돼요. 사실상 무한 루프에 불과한데, 프로그램의 공간 소비를 모니터링하면서 크래시 없이 영원히 돈다는 걸 확인하면 알 수 있어요. 반면 리스트 버전 let rec ints n = n :: ints (n + 1)은 실행 시간에 비례하는 길이의 리스트를 할당하기 때문에, 메모리 부족으로 꽤 빨리 크래시가 날 거예요.

시퀀스의 일부 가져오기 (Taking Parts of a Sequence)

OCaml 표준 라이브러리의 Seq 모듈에는 Seq.take 함수가 정의되어 있어요. 시퀀스의 시작 부분에서 지정한 개수의 요소를 반환하는 함수죠. 간단화된 구현은 다음과 같아요:

# let rec take n seq () =
    if n <= 0 then
      Seq.Nil
    else
      match seq () with
      | Seq.Cons (x, seq) -> Seq.Cons (x, take (n - 1) seq)
      | _ -> Seq.Nil;;
val take : int -> 'a Seq.t -> 'a Seq.t = <fun>

take n seq는 시퀀스 seq의 처음 n개 요소를, 많아야 그만큼 반환해요. seqn개보다 적은 요소를 담고 있다면 그 시퀀스와 동일한 시퀀스가 반환돼요. 특히 seq가 비어 있거나 n이 음수라면 빈 시퀀스가 반환됩니다.

take 함수의 첫 줄을 보세요. 시퀀스를 다루는 재귀 함수에서 흔히 쓰는 패턴이에요. 마지막 두 매개변수는:

  • seq라고 하는 시퀀스
  • unit

실행될 때 함수는 먼저 seq를 '언프리즈'(즉 seq () 호출)한 다음, 패턴 매칭으로 그 데이터 안을 들여다봐요. 하지만 이것은 takeunit 매개변수가 전달되지 않는 한 일어나지 않아요. take 10 seq라고 쓰는 것은 아무것도 계산하지 않아요. 그것은 부분 적용(partial application)이며, 결과를 내기 위해 unit이 필요한 함수를 반환할 뿐이에요.

이것은 앞에서 봤듯이 무한히 돌지 않고 정수를 출력하는 데 쓸 수 있어요:

# Seq.ints 0 |> Seq.take 43 |> List.of_seq;;
- : int list =
[0; 1; 2; 3; 4; 5; 6; 7; 8; 9; 10; 11; 12; 13; 14; 15; 16; 17; 18; 19; 20; 21;
 22; 23; 24; 25; 26; 27; 28; 29; 30; 31; 32; 33; 34; 35; 36; 37; 38; 39; 40;
 41; 42]

(Seq.ints ii에서 시작해 위로 세는 정수의 무한 시퀀스예요.)

시퀀스 걸러내기 (Filtering a Sequence)

Seq 모듈에는 Seq.filter 함수도 있어요:

# Seq.filter;;
- : ('a -> bool) -> 'a Seq.t -> 'a Seq.t = <fun>

조건을 만족하는 요소들로 시퀀스를 만드는 함수예요.

Seq.filter를 쓰면, 시험 나눗셈(trial division) 알고리즘에서 영감을 얻어, 모든 소수들의 목록을 만들어내는 것처럼 보이는 함수를 정의할 수 있어요.

let rec trial_div seq () = match seq () with
  | Seq.Cons (m, seq_rest) -> Seq.Cons (m, trial_div (Seq.filter (fun n -> n mod m > 0) seq_rest))
  | Seq.Nil -> Seq.Nil
let primes = Seq.ints 2 |> trial_div;;
val trial_div : int Seq.t -> int Seq.t = <fun>
val primes : int Seq.t = <fun>

예를 들어, 처음 100개의 소수 목록은 다음과 같아요:

# primes |> Seq.take 100 |> List.of_seq;;
- : int list =
[2; 3; 5; 7; 11; 13; 17; 19; 23; 29; 31; 37; 41; 43; 47; 53; 59; 61; 67; 71;
 73; 79; 83; 89; 97; 101; 103; 107; 109; 113; 127; 131; 137; 139; 149; 151;
 157; 163; 167; 173; 179; 181; 191; 193; 197; 199; 211; 223; 227; 229; 233;
 239; 241; 251; 257; 263; 269; 271; 277; 281; 283; 293; 307; 311; 313; 317;
 331; 337; 347; 349; 353; 359; 367; 373; 379; 383; 389; 397; 401; 409; 419;
 421; 431; 433; 439; 443; 449; 457; 461; 463; 467; 479; 487; 491; 499; 503;
 509; 521; 523; 541]

함수 trial_div는 OCaml의 재귀 함수예요. 구성 요소로 나눠서 이해하면 돼요. rec 키워드를 사용해 정의되므로, 함수가 스스로를 호출할 수 있어요. 재귀 호출의 각 루프마다 Seq.Cons (m, seq) 또는 시퀀스의 끝인 Seq.Nil 중 하나를 패턴 매칭해요.

첫 번째 분기 Seq.Cons (m, seq)에 매칭되면, m으로 나누어 떨어지는 모든 정수를 남은 시퀀스에서 걸러낸 다음(filter), 걸러진 시퀀스로 trial_div를 재귀 호출해요. 이 분기는 모든 재귀 호출에서 시퀀스 끝에 도달할 때까지 매칭되어요.

여기까지가 시퀀스에서 100번째 소수까지 재귀적으로 내려간 거예요. 이제 재귀 경로를 따라 위로 되돌아가면서, m과 그 전에 만들어진 걸러진 시퀀스에 Seq.Cons를 호출해 결과를 구성해요. 걸러진 시퀀스는 Seq.Nil에서 시작하죠.

참고: trial_div는 흔히 재귀라고 부를 수 있지만, 코재귀(corecursion)라고 하는 재귀의 일종이라는 점이 흥미로워요. 코재귀는 입력을 점진적으로 소비하는 대신 결과를 점진적으로 구성한다는 점에서 재귀와 달라요. 기저 사례(base case)를 향해 작업하는 전통적인 재귀와 달리, 코재귀 함수는 스트림처럼 값을 무한히 만들어내야 해요. trial_div는 소수의 완전한 수열을 즉시 계산하지 않고, 더 많은 요소가 요청될 때까지 필터링과 계산을 미루며 소수를 요청에 따라 만들어내기 때문에 코재귀적이에요. 이를 통해 시퀀스를 처음에 전체 순회를 요구하는 대신 점진적으로 처리할 수 있어요.

시퀀스 펼치기 (Unfolding Sequences)

시퀀스에는 표준적인 고차 반복 함수들이 제공돼요. 예를 들면:

  • Seq.iter
  • Seq.map
  • Seq.fold_left

이런 고차 함수들은 모두 Array, List, Set에도 제공되며 본질적으로 동일하게 동작해요. fold_right 함수가 없다는 점을 눈여겨보세요. OCaml 4.11부터는 다른 타입에는 (아직) 없는 것이 하나 있는데, 바로 unfold예요. 이것이 어떻게 구현되는지 볼게요:

# let rec unfold f x () = match f x with
  | None -> Seq.Nil
  | Some (x, seq) -> Seq.Cons (x, unfold f seq);;
val unfold : ('a -> ('b * 'a) option) -> 'a -> 'b Seq.t = <fun>

앞서 언급한 반복 함수들과 달리, Seq.unfold는 시퀀스 매개변수를 받지 않고 시퀀스 결과를 내놓아요. unfold는 시퀀스를 만드는 일반적인 수단을 제공해요. Seq.unfold f x가 반환하는 결과는, fNone을 반환할 때까지 f에 대한 연속 호출의 결과를 누적해 만든 시퀀스예요. 이를 나타내면:

(fst p₀, fst p₁, fst p₂, fst p₃, fst p₄, ...)

여기서 Some p₀ = f x이고 Some pₙ₊₁ = f (snd pₙ)이에요.

예를 들어 Seq.intsSeq.unfold로 꽤 간결하게 구현할 수 있어요:

# let ints = Seq.unfold (fun n -> Some (n, n + 1));;
val ints : int -> int Seq.t = <fun>

재미로, 시퀀스에 대한 mapSeq.unfold로 구현할 수 있어요. 이렇게 쓰면 되죠:

# let map f = Seq.unfold (fun seq -> seq |> Seq.uncons |> Option.map (fun (x, seq) -> (f x, seq)));;
val map : ('a -> 'b) -> 'a Seq.t -> 'b Seq.t = <fun>

제곱 함수를 시퀀스에 적용해서 우리 map을 확인할 수 있어요:

# Seq.ints 0 |> map (fun x -> x * x) |> Seq.take 10 |> List.of_seq;;
- : int list = [0; 1; 4; 9; 16; 25; 36; 49; 64; 81]

Seq.uncons 함수는 시퀀스가 비어 있지 않으면 머리와 꼬리를 반환하고, 비어 있으면 None을 반환해요.

Seq.unfold로 파일 읽기 (Reading a File with Seq.unfold)

다음 예제에서는 Seq.unfold로 파일을 읽으면서 그 다재다능함을 보여줄게요.

그 전에, 주어진 채널에서 파일의 줄을 읽는 함수를 정의해 볼게요. Seq.unfold가 필요로 하는 타입 시그니처를 갖춘 함수예요.

# let input_line_opt chan =
    try Some (In_Channel.input_line chan, chan)
    with End_of_file -> None;;
val input_line_opt : in_channel -> (string * in_channel) option = <fun>

참고: 다음 섹션의 코드가 동작하도록, "README.md"라는 파일을 만들어 더미 내용을 넣어 두세요. 아래 명령으로 생성한 파일을 사용해요:

cat > README.md <<EOF
This is the first line.
This is the second line.
EOF

마지막으로 Seq.unfold로 파일 내용을 읽어 볼게요. cin은 지역 정의(local definition)라는 점을 유의하세요.

# let cin = open_in "README.md" in
    cin |> Seq.unfold In_channel.input_line_opt |> Seq.iter print_endline;
    close_in cin;;
This is the first line.
This is the second line.
- : unit = ()

참고: 실제 코드에서는 파일 열기 오류를 처리해야 해요. 이 예제는 파일이 시퀀스와 어떻게 관련되는지에만 집중하려고 짧게 유지했어요.

소비자와 생산자 (Consumers vs Producers)

시퀀스 매개변수를 갖는 함수는 그것을 소비해요. 즉 시퀀스 소비자(consumer)예요. 시퀀스 결과를 내는 함수는 그것을 생산해요. 즉 시퀀스 생산자(producer)예요. 두 경우 모두, 소비와 생산은 나머지를 계속하기 전에 한 요소에 대해서만 일어나요.

시퀀스 소비자: 부분 적용 함수를 매개변수로 (Sequence Consumers: Partially Applied Functions as Parameters)

소비자는 시퀀스를 처리하며 그 요소를 소비하는 함수예요. 소비자는 함수 매개변수를 받는 고차 함수로 작성되어야 해요. 이렇게 하면 지연 평가(deferred evaluation)가 가능해져, 시퀀스 전체를 미리 강제로 평가하는 대신 요소를 한 번에 하나씩 가져오게 보장해요.

소비자 예제: Seq.iter

# let print_seq = Seq.iter print_int;;
val print_seq : int Seq.t -> unit = <fun>

print_seq에서 Seq.iterprint_int 함수를 받아 각 요소가 생성될 때 그것에 적용해요. 만약 List.iter를 쓴다면, 출력을 시작하기 전에 정수 리스트 전체가 필요할 거예요.

시퀀스 생산자: 함수를 결과로 (Sequence Producers: Functions as Results)

생산자는 시퀀스를 생성하는 함수예요. 생산자는 요소가 필요할 때만 계산되도록 함수를 반환해요. 이것은 지연 평가를 보장하고 불필요한 계산을 피하게 해줘요.

생산자 예제: Seq.unfold

 # let naturals =
  Seq.unfold (fun x -> Some (x, x + 1)) 0;;
val naturals : int Seq.t = <fun>

Seq.unfold 적용은 unit -> int Seq.node 타입을 가지므로, 함수, 즉 지연된 생산자예요. 이 함수가 호출될 때마다 새 요소가 생성돼요.

Seq.Cons vs Seq.cons 주의하기 (Be Aware of Seq.Cons vs Seq.cons)

OCaml 표준 라이브러리의 Seq 모듈에는 "cons-ing"의 두 가지 버전이 있어요. 이름이 비슷해서 특별히 주의해야 하는데, 동작은 뚜렷하게 달라요.

우리는 이미 Seq.Cons 변형 생성자(variant constructor)를 봤어요. 복습 삼아, 그 정의를 표시하는 방법은 다음과 같아요:

# #show Seq.node;;
type 'a node = 'a Seq.node = Nil | Cons of 'a * 'a Seq.t

"cons-ing"의 다른 버전은 (소문자 c를 쓰는) 함수 Seq.cons이며, 다음과 같은 값 선언을 가져요:

val cons : 'a -> 'a Seq.t -> 'a Seq.t

시그니처에서 알 수 있듯이, 이것은 값과 시퀀스라는 두 매개변수를 받는 함수예요. 그 정의는 다음과 같아요:

 # let cons x next () = Cons (x, next);;
val cons : 'a -> 'a t -> unit -> 'a node = <fun>

Seq.ConsSeq.cons는 둘 다 새 시퀀스를 만들 수 있다는 점에서 비슷해요:

# let ints_from_2 = Seq.ints 2
 let ints_a () = Seq.Cons (1, ints_from_2)  (* With Seq.Cons *)
 let ints_b = Seq.cons 1 ints_from_2;;      (* With Seq.cons *)
val ints_from_2 : int Seq.t = <fun>
val ints_a : unit -> int Seq.node = <fun>
val ints_b : int Seq.t = <fun>

# ints_a |> Seq.take 3 |> List.of_seq;;
- : int list = [1; 2; 3]

# ints_b |> Seq.take 3 |> List.of_seq;;
- : int list = [1; 2; 3]

이 두 "cons-ing" 버전이 같은 시퀀스를 만들 수 있다는 걸 봤으니, 질문이 생겨요. 그렇다면 Seq.consSeq.Cons는 무엇이 다른 걸까요?

Seq.ConsSeq.cons를 혼동하면 의도하지 않은 동작이 생길 수 있다는 점을 살펴볼게요.

Seq.cons로 피보나치 (Fibs with Seq.cons)

아래는 피보나치 수열을 정의하는 가능한 방법처럼 보이지만, 문제를 일으켜요:

# let rec fibs_v1 m n = Seq.cons m (fibs_v1 n (n + m));;
val fibs : int -> int -> int Seq.t = <fun>

# let fibs_v1 0 1;;
Stack overflow during evaluation (looping recursion?).

이것은 끝나지 않는 재귀를 만들어 스택 오버플로를 일으켜요.

Seq.Cons로 피보나치 (Fibs with Seq.Cons)

이제 생성자 Seq.Cons를 사용해 fibs_v2를 정의해 볼게요:

# let rec fibs_v2 m n () = Seq.Cons (m, fibs_v2 n (n + m));;
val fibs_v2 : int -> int -> int Seq.t = <fun>

이 구현은 피보나치 수를 만들고 소비할 수 있는 지연 시퀀스의 생산자를 성공적으로 정의해요:

# fibs_v2 0 1 |> Seq.take 10 |> List.of_seq;;
- : int list = [0; 1; 1; 2; 3; 5; 8; 13; 21; 34]

차이 이해하기 (Understanding the Difference)

왜 그럴까요? fibs_v1의 문제는 재귀 호출 fibs_v1 n (n + m)에 있어요. fibs_v1에 기대되는 모든 인자가 제공되었으므로 함수 적용이 완료되어, 기저 사례 없이 통제 불능의 재귀가 촉발되요. 후자의 fibs_v2 정의에서는 () 인자가 빠져 있어서 함수 적용이 부분적이에요. OCaml의 평가는 즉시(eager)이므로, 전자의 경우 재귀 호출의 평가가 촉발되어 끝나지 않는 루프가 발생해요. 반대로 후자의 경우, 부분 적용된 함수는 즉시 클로저(closure)로 반환돼요.

이 때문에 Seq.cons로 피보나치 함수를 만드는 것은 불가능해요.

만약 그 구분이 여전히 수수께끼라면, 잠시 Seq.Cons 생성자와 Seq.cons 함수의 입력을 비교해 보세요. 겉보기에는 기만적으로 비슷해 보이지만, 하나는 'a * 'a t 타입의 값을 입력으로 받고, 다른 하나는 'a'a t 인자를 입력으로 받아요.

Seq.Cons vs Seq.cons의 멘탈 모델 (A Mental Model for Seq.Cons vs Seq.cons)

Seq.ConsSeq.cons가 서로 다른 작업을 수행한다고 생각하는 게 유용해요. Seq.Cons는 시퀀스 생성기를 재귀적으로 정의하는 편리한 수단이자, 시퀀스에 값을 앞에 붙이는 어색한 수단을 제공해요. 반대로 Seq.cons는 시퀀스에 값을 앞에 붙이는 편리한 수단이자, 시퀀스 생성기를 재귀적으로 정의하는 불가능한 수단을 제공해요.

변환을 위한 시퀀스 (Sequences for Conversions)

OCaml 표준 라이브러리 전반에 걸쳐 시퀀스는 많은 데이터 타입들 사이의 변환을 수행하는 다리 역할을 해요. 예를 들어 그런 함수들의 시그니처가 있어요:

  • 리스트 (Lists)

    val List.to_seq : 'a list -> 'a Seq.t
    val List.of_seq : 'a Seq.t -> 'a list
    
  • 배열 (Arrays)

    val Array.to_seq : 'a array -> 'a Seq.t
    val Array.of_seq : 'a Seq.t -> 'a array
    
  • 문자열 (Strings)

    val String.to_seq : string -> char Seq.t
    val String.of_seq : char Seq.t -> string
    

집합(set), 맵(map), 해시 테이블(Hashtbl) 등에도 비슷한 함수가 제공돼요. 데이터 타입 모듈을 구현할 때는 to_seqof_seq 함수를 노출하는 것이 권장돼요.

기타 고려 사항 (Miscellaneous Considerations)

큰 규모의 데이터 흐름을 다루는 수단을 제공하는 관련 라이브러리들이 몇 가지 있어요:

  • Rizo I의 Streaming
  • Simon Cruanes와 Gabriel Radanne의 Iter
  • Simon Cruanes의 OSeq (더 많은 함수가 추가된 Seq의 확장판)
  • Jane Street의 Base.Sequence

OCaml 표준 라이브러리에는 한때 Stream이라는 모듈이 있었어요. 이것은 2021년 OCaml 4.14 릴리스와 함께 제거되었어요. 그보다 오래된 책이나 문서에서는 여전히 이 모듈을 언급할 수 있으니 주의하세요.

더 알아보기 (Learn more)