가변성과 명령형 제어 흐름
가변성과 명령형 제어 흐름 (Mutability and Imperative Control Flow)
OCaml에서 상태를 가진 프로그램을 작성하는 법을 배워볼게요. for 루프와 while 루프, if-then-else, 가변 레코드 필드, 그리고 reference를 사용하는 방법을 다룰게요. 명령형 프로그래밍과 함수형 프로그래밍을 효율적으로 조합하는 법도 함께 살펴봐요.
명령형 프로그래밍과 함수형 프로그래밍은 각각 독특한 장점이 있고, OCaml은 그것들을 효율적으로 결합할 수 있게 해줘요. 이 튜토리얼의 첫 번째 부분에서는 가변 상태(mutable state)와 명령형 제어 흐름을 소개해요. 권장하거나 권장하지 않는 사용 예시들은 두 번째 부분에서 볼 수 있어요.
불변 vs 가변 데이터 (Immutable vs Mutable Data)
let … = …로 값을 이름에 바인딩하면, 이 이름-값 바인딩은 불변(immutable)이에요. 그래서 이름에 할당된 값을 변경(mutate) 하는 것은 불가능해요. '변경'은 "바꾸다", "갱신하다", "수정하다" 같은 멋진 표현이에요.
다음 섹션들에서는 가변 상태를 다루는 OCaml의 언어 기능을 소개할게요.
References
내용을 갱신할 수 있는 특별한 종류의 값, reference 가 있어요:
# let d = ref 0;;
val d : int ref = {contents = 0}
# d := 1;;
- : unit = ()
# !d;;
- : int = 1
이 예제에서 일어나는 일은 다음과 같아요:
- 값
{ contents = 0 }이 이름d에 바인딩돼요. 이것은 평범한 정의예요. 다른 정의와 마찬가지로 불변이에요. 하지만d의contents필드에 있는 값0은 가변 이라서 갱신할 수 있어요. - 할당(assignment) 연산자
:=가d안의 가변 값을0에서1로 갱신하는 데 사용돼요. - 역참조(dereference) 연산자
!가d안의 가변 값의 내용을 읽어요.
위의 ref 식별자는 두 가지 서로 다른 것을 가리켜요:
- reference를 만드는 함수
ref : 'a -> 'a ref - 가변 reference의 타입:
'a ref
할당 연산자 (Assignment Operator)
# ( := );;
- : 'a ref -> 'a -> unit = <fun>
할당 연산자 :=는 그저 함수예요. 그것은 다음을 받아요:
- 갱신할 reference, 그리고
- 이전 내용을 대체할 값.
갱신은 부수 효과(side effect)로 일어나고, 값 ()이 반환돼요.
역참조 연산자 (Dereference Operator)
# ( ! );;
- : 'a ref -> 'a = <fun>
역참조 연산자는 reference를 받아 내용을 반환하는 함수예요.
OCaml에서 단항 연산자와 이항 연산자가 어떻게 동작하는지에 대한 자세한 내용은 Operators 문서를 참고하세요.
OCaml에서 가변 데이터를 다룰 때:
- 초기화되지 않은 reference를 만드는 것은 불가능하고,
- 가변 내용과 reference는 다른 문법과 타입을 가지므로, 둘 사이에 혼동이 있을 수 없어요.
가변 레코드 필드 (Mutable Record Fields)
레코드의 어떤 필드든 mutable 키워드를 사용해 표시할 수 있어요. 그런 필드는 갱신할 수 있어요.
# type book = {
series : string;
volume : int;
title : string;
author : string;
mutable stock : int;
};;
type book = {
series : string;
volume : int;
title : string;
author : string;
mutable stock : int;
}
예를 들어 서점이 재고를 어떻게 추적하는지 보면:
- 필드
title,author,volume,series는 상수예요. - 필드
stock는 가변인데, 이 값은 판매나 재입고 때마다 바뀌기 때문이에요.
그런 데이터베이스는 이런 항목을 가져야 해요:
# let vol_7 = {
series = "Murderbot Diaries";
volume = 7;
title = "System Collapse";
author = "Martha Wells";
stock = 0
};;
val vol_7 : book =
{series = "Murderbot Diaries"; volume = 7; title = "System Collapse";
author = "Martha Wells"; stock = 0}
서점이 이 책 10권을 배송받으면, 가변 stock 필드를 갱신해요:
# vol_7.stock <- vol_7.stock + 10;;
- : unit = ()
# vol_7;;
- : book =
{series = "Murderbot Diaries"; volume = 7; title = "System Collapse";
author = "Martha Wells"; stock = 10 }
가변 레코드 필드는 왼쪽 화살표 기호 <-로 갱신해요. 표현식 vol_7.stock <- vol_7.stock + 10에서 vol_7.stock의 의미는 맥락에 따라 달라져요:
<-의 왼쪽에서는 갱신할 가변 필드를 가리켜요.<-의 오른쪽에서는 가변 필드의 내용을 뜻해요.
reference와 달리, 가변 레코드 필드를 역참조하는 특별한 문법은 없어요.
참고: 가변 레코드 필드 값을 변경하는 왼쪽 화살표 기호 <-는 ref에 대한 할당 연산자 ( := ) 같은 연산자 함수가 아니에요. 그것은 언어의 구문 요소(construct) 이며, 타입이 없어요.
참고: reference는 단일 필드 레코드다 (Remark: References Are Single Field Records)
OCaml에서 reference는 단일 가변 필드를 가진 레코드예요:
# #show_type ref;;
type 'a ref = { mutable contents : 'a; }
타입 'a ref는 contents라는 단일 필드를 가진 레코드이며, mutable 키워드로 표시돼 있어요.
reference가 단일 필드 레코드이므로, 가변 레코드 필드 갱신 문법을 사용해 create, assign, deref 함수를 정의할 수 있어요:
# let create v = { contents = v };;
val create : 'a -> 'a ref = <fun>
# let assign f v = f.contents <- v;;
val assign : 'a ref -> 'a -> unit = <fun>
# let deref f = f.contents;;
val deref : 'a ref -> 'a = <fun>
# let f = create 0;;
val f : int ref = {contents = 0}
# deref f;;
- : int = 0
# assign f 2;;
- : unit = ()
# deref f;;
- : int = 2
이 함수들은:
create는 표준 라이브러리가 제공하는ref함수와 같은 일을 해요.assign은( := )연산자와 같은 일을 해요.deref는( ! )연산자와 같은 일을 해요.
배열 (Arrays)
OCaml에서 배열은 가변적이고 고정 크기인 데이터 구조로, 같은 타입의 요소 시퀀스를 저장할 수 있어요. 배열은 정수로 인덱싱되고, 상수 시간 접근을 제공하며, 요소 갱신을 허용해요.
# let g = [| 2; 3; 4; 5; 6; 7; 8 |];;
val g : int array = [|2; 3; 4; 5; 6; 7; 8|]
# g.(0);;
- : int = 2
# g.(0) <- 9;;
- : unit = ()
# g.(0);;
- : int = 9
왼쪽 화살표 기호 <-는 주어진 인덱스의 배열 요소를 갱신하는 데 사용돼요. 배열 인덱스 접근 문법 g.(i)(g는 array 타입의 값, i는 정수)는 다음 중 하나를 뜻해요:
- 갱신할 배열 위치(
<-의 왼쪽일 때), 또는 - 셀의 내용(
<-의 오른쪽일 때).
배열에 대한 더 자세한 논의는 Arrays 튜토리얼을 참고하세요.
바이트 시퀀스 (Byte Sequences)
bytes 타입은 OCaml에서 고정 길이의 가변 바이트 시퀀스를 나타내요. bytes 타입의 값에서 각 요소는 8비트예요. OCaml에서 문자는 8비트로 표현되므로, bytes 값은 가변 char 시퀀스예요. 배열처럼 바이트 시퀀스는 인덱스 접근을 지원해요.
# let h = Bytes.of_string "abcdefghijklmnopqrstuvwxyz";;
val h : bytes = Bytes.of_string "abcdefghijklmnopqrstuvwxyz"
# Bytes.get h 10;;
- : char = 'k'
# Bytes.set h 10 '_';;
- : unit = ()
# h;;
- : bytes = Bytes.of_string "abcdefghij_lmnopqrstuvwxyz"
바이트 시퀀스는 Bytes.of_string 함수로 string 값에서 만들 수 있어요. 시퀀스의 개별 요소는 Bytes.set과 Bytes.get으로 인덱스별로 갱신하거나 읽을 수 있어요.
바이트 시퀀스는 다음 중 하나로 생각할 수 있어요:
- 출력할 수 없는 갱신 가능한 문자열, 또는
- 인덱스 읽기와 갱신을 위한 문법적 설탕이 없는
char배열.
참고: bytes 타입은 char array보다 훨씬 더 압축된 메모리 표현을 사용해요. 이 튜토리얼을 쓰는 시점에 bytes와 char array 사이에는 8배 차이가 있어요. 전자가 항상 선호되어야 해요. 단, 배열을 처리하는 다형성 함수가 array를 요구하는 경우는 예외예요.
예제: get_char 함수 (Example: get_char Function)
이 섹션에서는 get_char 함수를 구현하는 두 가지 방법을 비교해요. 이 함수는 키가 눌릴 때까지 기다렸다가, 에코 없이 그에 해당하는 문자를 반환해요. 이 함수는 나중에 이 튜토리얼에서도 사용될 거예요.
표준 입력과 연관된 터미널의 속성을 읽고 갱신하기 위해 Unix 모듈의 두 함수를 사용해요:
tcgetattr stdin은 터미널 속성을 레코드로 반환해요(deref와 비슷).tcsetattr stdin TCSAFLUSH는 터미널 속성을 갱신해요(assign과 비슷).
이 속성들을 원하는 방식으로 읽으려면 제대로 설정해야 해요 (즉 에코를 끄고 정식(canonical) 모드를 비활성화). 두 구현 모두에서 로직은 같아요:
- 터미널 속성을 읽고 기록
- 터미널 속성 설정
- 키가 눌릴 때까지 기다리고, 문자로 읽기
- 초기 터미널 속성 복원
- 읽은 문자 반환
OCaml 표준 라이브러리의 input_char 함수로 표준 입력에서 문자를 읽어요.
아래는 첫 번째 구현이에요. macOS에서 작업 중이라면, Unbound module 오류를 피하려면 먼저 #require "unix";;를 실행하세요.
# let get_char () =
let open Unix in
let termio = tcgetattr stdin in
let c_icanon, c_echo = termio.c_icanon, termio.c_echo in
termio.c_icanon <- false;
termio.c_echo <- false;
tcsetattr stdin TCSAFLUSH termio;
let c = input_char (in_channel_of_descr stdin) in
termio.c_icanon <- c_icanon;
termio.c_echo <- c_echo;
tcsetattr stdin TCSAFLUSH termio;
c;;
val get_char : unit -> char = <fun>
이 구현에서 우리는 termio의 필드를 갱신해요.
input_char전에,c_icanon과c_echo를 모두false로 설정하고,input_char후에, 초기 값들을 복원해요.
여기 두 번째 구현이 있어요:
# let get_char () =
let open Unix in
let termio = tcgetattr stdin in
tcsetattr stdin TCSAFLUSH
{ termio with c_icanon = false; c_echo = false };
let c = input_char (in_channel_of_descr stdin) in
tcsetattr stdin TCSAFLUSH termio;
c;;
val get_char : unit -> char = <fun>
이 구현에서는 tcgetattr 호출이 반환한 레코드를 수정하지 않아요. { termio with c_icanon = false; c_echo = false }로 복사본을 만들어요. 이 복사본은 c_icanon과 c_echo 필드에서만 termio 값과 다르고, 나머지는 같아요.
두 번째 tcsetattr 호출에서 터미널 속성을 초기 상태로 복원해요.
명령형 제어 흐름 (Imperative Control Flow)
OCaml은 표현식을 순서대로 평가할 수 있게 해주고, 코드 블록을 반복 실행하기 위해 for와 while 루프를 제공해요.
표현식을 순서대로 평가하기 (Evaluating Expressions in Sequence)
let … in
# let () = print_string "This is" in print_endline " really Disco!";;
This is really Disco!
- : unit = ()
let … in 구문을 사용한다는 것은 두 가지를 뜻해요:
- 이름이 바인딩될 수 있어요. 예제에서는
()가 사용되므로 이름이 바인딩되지 않아요. - 부수 효과가 순서대로 일어나요. 바인딩된 표현식(
print_string "This is")이 먼저 평가되고, 참조하는 표현식(print_endline " really Disco!")이 두 번째로 평가돼요.
세미콜론 (Semicolon)
단일 세미콜론 ; 연산자는 시퀀스(sequence) 연산자로 알려져 있어요. 여러 표현식을 순서대로 평가할 수 있게 해주며, 마지막 표현식의 값이 전체 시퀀스의 값이 돼요.
이전 표현식들의 값은 버려져요. 따라서 마지막 표현식을 제외하고는 부수 효과가 있는 표현식을 사용하는 것이 말이 돼요. 마지막 표현식은 부수 효과가 없어도 돼요.
# let _ =
print_endline "Hello,";
print_endline "world!";
42;;
Hello,
world!
- : int = 42
이 예제에서 처음 두 표현식은 print_endline 함수 호출로, 부수 효과(콘솔에 출력)를 만들어요. 마지막 표현식은 그냥 정수 42로, 전체 시퀀스의 값이 돼요. ; 연산자는 이 표현식들을 구분하는 데 사용돼요.
참고: 시퀀스 연산자라고 불리지만, 세미콜론은 진짜 연산자가 아니에요. 타입 unit -> 'a -> 'a의 함수가 아니기 때문이에요. 그것은 언어의 구문 요소 에 가까워요. 시퀀스 표현식의 끝에 세미콜론을 추가하는 것을 허용해요.
# (); 42; ;;
- : int = 42
여기서 42 뒤의 세미콜론은 무시돼요.
begin … end 표현식
OCaml에서 begin … end와 괄호는 같아요.
다음과 같은 함수를 쓰고 싶다고 상상해 볼게요:
- n 값을 담은
intreference 매개변수를 갖는다. - reference의 내용을 2 × (n + 1) 로 갱신한다.
이것은 아마도 지나치게 꼬여 있고, 동작하지 않아요:
# let f r = r := incr r; 2 * !r;;
Error: This expression has type unit but an expression was expected of type int
하지만 이렇게 하면 동작하게 만들 수 있어요:
# let f r = r := begin incr r; 2 * !r end;;
val f : int ref -> unit = <fun>
오류는 할당 연산자 := 때문이었는데, 그것은 세미콜론 ;보다 더 강하게 결합해요. 우리가 하려는 일을 순서대로 적으면:
r증가2 * !r계산r에 할당
세미콜론으로 구분된 시퀀스의 값은 마지막 표현식의 값이라는 점을 기억하세요. 처음 두 단계를 begin … end로 묶으면 오류가 해결돼요.
재미있는 사실: begin … end와 괄호는 문자 그대로 같아요:
# begin end;;
- : unit = ()
if … then … else …와 부수 효과 (if … then … else … and Side Effects)
OCaml에서 if … then … else …는 표현식이에요.
# 6 * if "foo" = "bar" then 5 else 5 + 2;;
- : int = 42
조건 표현식의 반환 타입은 양쪽 분기가 모두 unit이라면 unit이 될 수 있어요.
# if 0 = 1 then print_endline "foo" else print_endline "bar";;
bar
- : unit = ()
위는 이렇게도 표현할 수 있어요:
# print_endline (if 0 = 1 then "foo" else "bar");;
bar
- : unit = ()
unit 값 ()은 한 분기에만 실행할 것이 있을 때 no-op 역할을 할 수 있어요.
# if 0 = 1 then print_endline "foo" else ();;
- : unit = ()
하지만 OCaml은 else 분기 없이 if … then … 표현식을 쓰는 것도 허용해요. 위와 같아요.
# if 0 = 1 then print_endline "foo";;
- : unit = ()
파싱에서, 조건 표현식은 시퀀싱보다 더 강하게 묶여요:
# if true then print_endline "A" else print_endline "B"; print_endline "C";;
A
C
- : unit = ()
여기서 ; print_endline "C"는 print_endline "B" 뒤가 아니라, 전체 조건 표현식 뒤에 실행돼요.
조건 표현식 분기에서 출력을 두 번 하고 싶다면 begin … end를 사용하세요.
# if true then
print_endline "A"
else begin
print_endline "B";
print_endline "C"
end;;
A
- : unit = ()
만날 수 있는 오류가 하나 있어요:
# if true then
print_endline "A";
print_endline "C"
else
print_endline "B";;
Error: Syntax error
첫 분기에서 묶는 데 실패하면 구문 오류가 발생해요. 세미콜론 앞의 것은 else 표현식이 없는 if … then …으로 파싱돼요. 세미콜론 뒤의 것은 갈래 달린(dangling) else로 나타나요.
For 루프 (For Loop)
for 루프는 타입 unit의 표현식이에요. 여기서 for, to, do, done은 키워드예요.
# for i = 0 to 5 do Printf.printf "%i\n" i done;;
0
1
2
3
4
5
- : unit = ()
여기서:
i는 루프 카운터예요. 매 반복 후 증가해요.0은i의 첫 번째 값이에요.5는i의 마지막 값이에요.- 표현식
Printf.printf "%i\n" i는 루프의 본문이에요.
반복은 i가 5에 도달할 때까지 본문 표현식(i를 포함할 수 있음)을 평가해요.
for 루프의 본문은 타입 unit의 표현식이어야 해요:
# let j = [| 2; 3; 4; 5; 6; 7; 8 |];;
val j : int array = [|2; 3; 4; 5; 6; 7; 8|]
# for i = Array.length j - 1 downto 0 do 0 done;;
Line 1, characters 39-40:
Warning 10 [non-unit-statement]: this expression should have type unit.
- : unit = ()
downto 키워드(대신 to 키워드)를 사용하면, 루프의 매 반복마다 카운터가 감소해요.
for 루프는 배열을 반복하고 수정하는 데 편리해요:
# let sum = ref 0 in
for i = 0 to Array.length j - 1 do sum := !sum + j.(i) done;
!sum;;
- : int = 35
참고: 반복자 함수를 사용해 같은 일을 하는 방법은 다음과 같아요:
# let sum = ref 0 in Array.iter (fun i -> sum := !sum + i) j; !sum;;
- : int = 35
While 루프 (While Loop)
while 루프는 타입 unit의 표현식이에요. 여기서 while, do, done은 키워드예요.
# let i = ref 0 in
while !i <= 5 do
Printf.printf "%i\n" !i;
i := !i + 1;
done;;
0
1
2
3
4
5
- : unit = ()
여기서:
!i <= 5는 조건이에요.- 표현식
Printf.printf "%i\n" !i; i := !i + 1;은 루프의 본문이에요.
반복은 조건이 참으로 유지되는 한 본문 표현식을 실행해요.
이 예제에서 while 루프는 reference i가 담은 값이 5보다 작은 한 계속 실행돼요.
예외로 루프 벗어나기 (Breaking Loops Using Exceptions)
Exit 예외를 던지는 것은 루프에서 즉시 빠져나오는 권장되는 방법이에요.
다음 예제는 앞서 정의한 get_char 함수를 사용해요 (섹션 Example: get_char Function).
# try
print_endline "Press Escape to exit";
while true do
let c = get_char () in
if c = '\027' then raise Exit;
print_char c;
flush stdout
done
with Exit -> ();;
이 while 루프는 키보드에서 입력된 문자를 에코해요. ASCII Escape 문자가 읽히면 Exit 예외가 던져지고, 반복이 종료되며 REPL 응답인 - : unit = ()이 표시돼요.
클로저 안의 references (References Inside Closures)
다음 예제에서 함수 create_counter는 가변 reference counter를 "숨기는" 클로저를 반환해요. 이 클로저는 counter가 정의된 환경을 포착하며, 호출될 때마다 counter를 수정할 수 있어요. counter reference는 클로저 안에 "숨겨져" 있어서 그 상태를 캡슐화해요.
# let create_counter () =
let counter = ref (-1) in
fun () -> incr counter; !counter;;
val create_counter : unit -> unit -> int = <fun>
먼저 인자를 받지 않는 create_counter라는 함수를 정의해요. create_counter 안에서 reference counter가 -1로 초기화돼요. 이 reference는 카운터의 상태를 담을 거예요. 다음으로 인자를 받지 않는 클로저 (fun () ->)를 정의해요. 클로저는 incr counter로 counter를 증가시키고, !counter로 counter의 현재 값을 반환해요.
# let c1 = create_counter ();;
val c1 : unit -> int = <fun>
# let c2 = create_counter ();;
val c2 : unit -> int = <fun>
이제 부분 적용을 사용해, 카운터를 캡슐화하는 두 클로저 c1과 c2를 만들어요. c1 ()을 호출하면 c1과 연관된 카운터를 증가시키고 현재 값을 반환해요. 마찬가지로 c2 ()를 호출하면 그 자신만의 독립적인 카운터를 갱신해요.
# c1 ();;
- : int = 0
# c1 ();;
- : int = 1
# c2 ();;
- : int = 0
# c1 ();;
- : int = 2
c1 ()을 호출하면 c1과 연관된 카운터를 증가시키고 현재 값을 반환해요. 첫 번째 호출이므로 카운터는 0에서 시작해요. c1 ()을 다시 호출하면 카운터를 다시 증가시키므로 1을 반환해요.
c2 ()를 호출하면 c2와 연관된 카운터를 증가시켜요. c2는 그 자신만의 독립적인 카운터를 가지므로 0에서 시작해요. c1 ()을 다시 호출하면 그 카운터를 증가시켜 2가 돼요.
가변 상태와 부수 효과에 대한 권장 사항 (Recommendations for Mutable State and Side Effects)
함수형 프로그래밍과 명령형 프로그래밍 스타일은 자주 함께 사용돼요. 하지만 모든 결합 방식이 좋은 결과를 주는 것은 아니에요. 이 섹션에서는 가변 상태와 부수 효과와 관련된 패턴과 안티 패턴 몇 가지를 보여줄게요.
좋음: 함수로 캡슐화된 가변성 (Good: Function-Encapsulated Mutability)
다음은 정수 배열의 합을 계산하는 함수예요.
# let sum m =
let result = ref 0 in
for i = 0 to Array.length m - 1 do
result := !result + m.(i)
done;
!result;;
val sum : int array -> int = <fun>
함수 sum은 가변 데이터 구조와 for 루프를 사용하는 명령형 스타일로 작성됐어요. 하지만 어떤 가변성도 노출되지 않아요. 그것은 완전히 캡슐화된 구현 선택이에요. 이 함수는 사용하기에 안전하고, 문제가 예상되지 않아요.
좋음: 애플리케이션 전역 상태 (Good: Application-Wide State)
어떤 애플리케이션은 실행되는 동안 어떤 상태를 유지해요. 몇 가지 예는:
- Read-Eval-Print-Loop (REPL). 상태는 값들이 이름에 바인딩되는 환경이에요. OCaml에서 환경은 추가 전용(append-only)이지만, 다른 언어들은 이름-값 바인딩을 대체하거나 제거하는 것을 허용해요.
- 상태 있는 프로토콜용 서버. 각 세션은 상태를 가져요. 전역 상태는 모든 세션 상태로 구성돼요.
- 텍스트 편집기. 상태에는 가장 최근 명령(실행 취소를 위해), 열린 파일들의 상태, 설정, UI의 상태가 포함돼요.
- 캐시.
다음은 앞서 정의한 get_char 함수를 사용하는 장난감 줄 편집기예요. 표준 입력에서 문자를 기다리고, 파일 끝, 캐리지 리턴, 또는 줄바꿈에서 종료해요. 그 외에는, 문자가 출력 가능하면 그것을 출력하고 스택으로 사용되는 가변 리스트에 기록해요. 문자가 삭제 코드이면 스택을 팝하고 마지막으로 출력된 문자를 지워요.
# let record_char state c =
(String.make 1 c, c :: state);;
val record_char : char list -> char -> string * char list = <fun>
# let remove_char state =
("\b \b", if state = [] then [] else List.tl state);;
val remove_char : 'a list -> string * 'a list = <fun>
# let state_to_string state =
List.(state |> rev |> to_seq |> String.of_seq);;
val state_to_string : char list -> string = <fun>
# let rec loop state =
let c = get_char () in
if c = '\004' || c = '\n' || c = '\r' then raise Exit;
let s, new_state = match c with
| '\127' -> remove_char !state
| c when c >= ' ' -> record_char !state c
| _ -> ("", !state) in
print_string s;
state := new_state;
flush stdout;
loop state;;
val loop : char list ref -> 'a = <fun>
# let state = ref [] in try loop state with Exit -> state_to_string !state;;
이 마지막 명령 후에, 텍스트 한 줄을 입력하고 편집할 수 있어요. 그런 다음 return을 눌러 REPL로 돌아가면 돼요.
이 예제는 다음을 보여줘요:
- 함수
record_char와remove_char는 상태를 갱신하지도 않고 부수 효과를 만들지도 않아요. 대신 각각 문자열과 다음 상태new_state로 구성된 값 쌍을 반환해요. - I/O와 상태 갱신 부수 효과는
loop함수 안에서 일어나요. - 상태는
loop함수에 인자로 전달돼요.
이것은 애플리케이션 전역 상태를 다루는 한 가지 가능한 방법이에요. Function-Encapsulated Mutability 예제와 마찬가지로, 상태를 아는 코드는 좁은 범위에 담겨 있고, 나머지 코드는 순수 함수형이에요.
참고: 여기서 상태는 복사되는데, 이는 메모리 효율적이지 않아요. 메모리를 고려한 구현에서는 상태 갱신 함수가 "diff"(상태의 이전 버전과 갱신 버전 사이의 차이를 기술하는 데이터)를 만들어내요.
좋음: 값 미리 계산하기 (Good: Precomputing Values)
각도를 원의 분율로 8비트 무부호 정수에 저장하고, char 값으로 저장한다고 상상해 봐요. 이 시스템에서 64는 90도, 128은 180도, 192는 270도, 256은 완전한 원이에요. 그런 식으로 이어져요. 그 값들에 코사인을 계산해야 한다면, 구현은 이렇게 보일 수 있어요:
# let char_cos c =
c |> int_of_char |> float_of_int |> ( *. ) (Float.pi /. 128.0) |> cos;;
val char_cos : char -> float = <fun>
하지만 모든 가능한 값을 미리 계산해 놓으면 더 빠른 구현을 만들 수 있어요. 그 값은 256개뿐이에요. 첫 번째 결과 아래에 목록으로 나타나는 걸 볼 수 있어요:
# let char_cos_tab = Array.init 256 (fun i -> i |> char_of_int |> char_cos);;
val char_cos_tab : float array =
# let char_cos c = char_cos_tab.(int_of_char c);;
val char_cos : char -> float = <fun>
좋음: 메모이제이션 (Good: Memoization)
메모이제이션(memoization) 기법은 이전 섹션 예제와 같은 생각에 의존해요. 이전에 계산된 값들의 표에서 결과를 조회하는 것이죠.
하지만 모든 것을 미리 계산하는 대신, 메모이제이션은 함수를 호출할 때 채워지는 캐시를 사용해요. 제공된 인자들이:
- 캐시에서 발견되면(히트) 저장된 결과가 반환되거나,
- 캐시에서 발견되지 않으면(미스) 결과가 계산되어 캐시에 저장되고 반환돼요.
메모이제이션의 구체적인 예제와 심층 설명은 "OCaml Programming: Correct + Efficient + Beautiful."의 Memoization 장에서 찾을 수 있어요.
좋음: 기본은 함수형 (Good: Functional by Default)
기본적으로 OCaml 프로그램은 대부분 함수형 스타일로 작성되어야 해요. 이것은 가능한 곳에서 부수 효과를 피하고, 가변 상태 대신 불변 데이터에 의존하는 것을 의미해요.
타입 안전성과 메모리 안전성의 이점을 잃지 않고 명령형 프로그래밍 스타일을 사용하는 것은 가능해요. 하지만 오직 명령형 스타일로만 프로그래밍하는 것은 보통 말이 되지 않아요. 함수형 프로그래밍 관용구를 전혀 사용하지 않으면 관용적이지 않은(비표준적인) OCaml 코드가 돼요.
대부분의 기존 모듈은 함수형 방식으로 사용되도록 설계된 인터페이스를 제공해요. 어떤 것들은 명령형 환경에서 사용되려면 래퍼 라이브러리의 개발과 유지보수가 필요하며, 그런 사용은 비효율적인 코드를 초래해요.
상황에 따라 다름: 모듈 상태 (It Depends: Module State)
모듈은 여러 방식으로 상태를 노출하거나 캡슐화할 수 있어요:
- 좋음: 상태를 나타내는 타입을 상태 생성 또는 리셋 함수와 함께 노출하기
- 상황에 따라 다름: 상태 초기화만 노출하기, 이는 상태가 하나뿐임을 의미
- 나쁨: 명시적 초기화 함수도, 가변 상태를 가리키는 이름도 없는 가변 상태
예를 들어 Hashtbl 모듈은 첫 번째 종류의 인터페이스를 제공해요. 가변 데이터를 나타내는 타입 Hashtbl.t가 있어요. 또한 create, clear, reset 함수를 노출해요. clear와 reset 함수는 unit을 반환해요. 이것은 독자에게 그들이 가변 데이터를 갱신하는 부수 효과를 수행한다는 강한 신호를 주어요.
# #show Hashtbl.t;;
type ('a, 'b) t = ('a, 'b) Hashtbl.t
# Hashtbl.create;;
- : ?random:bool -> int -> ('a, 'b) Hashtbl.t = <fun>
# Hashtbl.reset;;
- : ('a, 'b) Hashtbl.t -> unit = <fun>
# Hashtbl.clear;;
- : ('a, 'b) Hashtbl.t -> unit = <fun>
반면, 모듈이 인터페이스에 노출하지 않고 내부적으로 가변 데이터를 정의해 동작에 영향을 줄 수도 있어요. 이것은 권장되지 않아요.
나쁨: 문서화되지 않은 변경 (Bad: Undocumented Mutation)
참고: 다음 예제 코드는 의도적으로 REPL에서 실행되지 않아요. 함수 Array.truncate는 정의되어 있지 않아요. 그것은 숙고하고 피하기 위해 제공된 예제예요.
나쁜 코드의 예는 다음과 같아요:
# let partition p k =
let m = Array.copy k in
let k_len = ref 0 in
let m_len = ref 0 in
for i = 0 to Array.length k - 1 do
if p k.(i) then begin
k.(!k_len) <- k.(i);
incr k_len
end else begin
m.(!m_len) <- k.(i);
incr m_len
end
done;
(Array.truncate k_len k, Array.truncate m_len m);;
Error: Unbound value Array.truncate
왜 이것이 나쁜 코드인지 이해하려면, 함수 Array.truncate가 타입 int -> 'a array -> 'a array를 가진다고 가정해 보세요. 그것은 Array.truncate 3 [5; 6; 7; 8; 9]가 [5; 6; 7]을 반환하도록 동작하고, 반환된 배열은 입력 배열의 처음 3개 셀에 물리적으로 대응해요.
partition의 타입은 ('a -> bool) -> 'a array -> 'a array * 'a array이고, 다음과 같이 문서화될 수 있어요:
partition p k는 배열 쌍(m, n)을 반환해요. 여기서m은k의 요소들 중 술어p를 만족하는 모든 요소를 담은 배열이고,n은k의 술어p를 만족하지 않는 요소들을 담은 배열이에요. 입력 배열의 요소 순서는 보존돼요.
언뜻 보면 이것은 Function-Encapsulated Mutability를 적용한 것처럼 보여요. 하지만 그렇지 않아요. 입력 배열이 수정돼요. 이 함수는 다음 중 하나인 부수 효과를 가져요:
- 의도되지 않았거나,
- 문서화되지 않았거나.
후자의 경우, 함수는 다르게 이름 지어져야 해요 (예: partition_in_place 또는 partition_mut), 그리고 입력 배열에 대한 효과는 문서화되어야 해요.
나쁨: 문서화되지 않은 부수 효과 (Bad: Undocumented Side Effects)
이 코드를 고려해 보세요:
참고: 다음 예제는 REPL에서 실행되지 않아요. Analytics 모듈이 정의되어 있지 않아요. 그것은 숙고하기 위해 제공된 예제예요. Analytics는 원격 모니터링 라이브러리예요.
# module Array = struct
include Stdlib.Array
let copy a =
if Array.length a > 1000000 then Analytics.collect "Array.copy" a;
copy a
end;;
Error: Unbound module Analytics
Array라는 모듈이 정의돼요. 그것은 Stdlib.Array 모듈을 가리고(shadow) 포함해요. 이 패턴에 대한 자세한 내용은 Modules 튜토리얼의 Module Inclusion 부분을 참고하세요.
이 코드가 나쁜 이유를 이해하려면, Analytics.collect가 원격 서버로 데이터를 전송하기 위해 네트워크 연결을 만드는 함수임을 알아내 보세요.
이제, 새로 정의된 Array 모듈은 잠재적으로 예상치 못한 부수 효과를 가진 copy 함수를 포함해요. 단, 복사할 배열이 1백만 개 이상의 셀을 가진 경우에만요.
명확하지 않은 부수 효과를 가진 함수를 작성한다면, 기존 정의를 가리지 마세요. 대신 함수에 설명적인 이름을 주고 (예: Array.copy_with_analytics), 호출자가 알지 못할 수 있는 부수 효과가 있다는 사실을 문서화하세요.
나쁨: 평가 순서에 의존하는 부수 효과 (Bad: Side Effects Depending on Order of Evaluation)
이 코드를 고려해 보세요:
# let id_print s = print_string (s ^ " "); s;;
val id_print : string -> string = <fun>
# let s =
Printf.sprintf "%s %s %s"
(id_print "Monday")
(id_print "Tuesday")
(id_print "Wednesday");;
Wednesday Tuesday Monday val s : string = "Monday Tuesday Wednesday "
함수 id_print는 입력을 바꾸지 않고 반환해요. 하지만 부수 효과가 있어요. 인자로 받은 문자열을 먼저 출력해요.
두 번째 줄에서 우리는 id_print를 인자 "Monday", "Tuesday", "Wednesday"에 적용해요. 그런 다음 Printf.sprintf "%s %s %s"를 그 결과들에 적용해요.
OCaml에서 함수 인자의 평가 순서는 명시적으로 정의되지 않으므로, id_print 부수 효과가 일어나는 순서는 신뢰할 수 없어요. 이 예제에서 인자는 오른쪽에서 왼쪽으로 평가되지만, 이것은 미래의 컴파일러 릴리스에서 바뀔 수 있어요.
이 문제는 인자를 변형 생성자에 적용할 때, 튜플 값을 만들 때, 또는 레코드 필드를 초기화할 때도 발생해요. 여기서는 튜플 값에 대해 설명하고 있어요:
# let r = ref 0 in ((incr r; !r), (decr r; !r));;
- : int * int = (0, -1)
이 표현식의 값은 하위 표현식 평가의 순서에 의존해요. 이 순서는 지정되어 있지 않으므로, 이 값이 무엇인지 알 수 있는 신뢰할 수 있는 방법이 없어요. 이 튜토리얼을 쓰는 시점에 평가는 (0, -1)을 만들었지만, 다른 것을 본다면 그것은 버그가 아니에요. 그런 신뢰할 수 없는 값은 피해야 해요.
평가가 특정 순서로 일어나도록 보장하려면, let … in 표현식이나 세미콜론 시퀀스 연산자(;)로 표현식을 시퀀스에 넣는 수단을 사용하세요. Evaluating Expressions in Sequence 섹션을 확인하세요.
결론 (Conclusion)
가변 상태는 좋지도 나쁘지도 않아요. 가변 상태가 훨씬 더 단순한 구현을 가능하게 하는 경우, OCaml은 그것을 다룰 훌륭한 도구를 제공해요. 우리는 reference, 가변 레코드 필드, 배열, 바이트 시퀀스, 그리고 for와 while 루프 같은 명령형 제어 흐름 표현식을 살펴봤어요. 마지막으로 권장되고 권장되지 않는 부수 효과와 가변 상태 사용의 여러 예를 논의했어요.