옵션
옵션 (Options)
옵션(option) 값은 다른 값을 감싸거나, 감쌀 게 없으면 아무것도 담지 않는 값이에요. 미리 정의된 배리언트 타입 option이 바로 그런 값을 나타내요.
# #show option;;
type 'a option = None | Some of 'a
옵션 값의 예시를 몇 개 볼게요.
# Some 42;;
- : int option = Some 42
# None;;
- : 'a option = None
여기서 정리하면 이렇게 돼요.
- 42는
Some생성자로option안에 저장됐고 None값은 아무것도 저장하지 않아요.
옵션 타입은 데이터가 없음을 예외(exception)보다 특별한 값 None으로 다루는 게 나을 때 유용해요. 에러 값을 돌려주는 것의 타입 안전한 버전이라고 생각하면 돼요. 옵션에 감싸인 데이터는 특별한 의미를 가지므로, 값과 값의 부재를 혼동할 일이 없어요.
출처: OCaml 공식 문서
본문
예외 vs 옵션 (Exceptions vs Options)
표준 라이브러리의 Sys.getenv : string -> string 함수는 환경 변수의 값을 조회해요. 그런데 변수가 정의되어 있지 않으면 예외를 던져요. 반면 Sys.getenv_opt : string -> string option 함수는 같은 일을 하되, 변수가 정의되어 있지 않으면 None을 돌려줘요. 정의되지 않은 환경 변수에 접근하려 할 때 어떤 일이 생기는지 볼게요.
# Sys.getenv "UNDEFINED_ENVIRONMENT_VARIABLE";;
Exception: Not_found.
# Sys.getenv_opt "UNDEFINED_ENVIRONMENT_VARIABLE";;
- : string option = None
옵션과 예외, 그리고 다른 수단을 이용한 에러 처리에 대한 더 긴 논의는 Error Handling을 참고하세요.
표준 라이브러리의 Option 모듈 (The Standard Library Option Module)
이 섹션의 함수 대부분과 그 밖의 유용한 함수들은 OCaml 표준 라이브러리의 Stdlib.Option 모듈에서 제공돼요.
옵션에 맵하기 (Map Over an Option)
패턴 매칭을 이용하면 옵션 값을 다루는 함수를 정의할 수 있어요. 예를 들어 타입 ('a -> 'b) -> 'a option -> 'b option의 커스텀 map 함수를 정의해 볼게요. 이 함수를 쓰면 option 안에 감싸인 값(존재한다면)에 함수를 적용할 수 있어요.
let map f = function
| None -> None
| Some v -> Some (f v)
우리의 커스텀 map 함수는 표준 라이브러리의 Option.map과 같아요.
# Option.map (fun x -> x * x) (Some 3);;
- : int option = Some 9
# Option.map (fun x -> x * x) None;;
- : int option : None
다음은 Option.map 함수가 서로 다른 타입과 변환과 함께 어떻게 동작하는지 보여주는 간단한 예시 몇 개예요.
예시 1: 정수 증가시키기
Some 안에 값이 있으면 그 값이 증가해요. None이면 None으로 남아요.
# Option.map (fun x -> x + 1) (Some 5);;
- : int option = Some 6
# Option.map (fun x -> x + 1) None;;
- : int option = None
예시 2: 문자열의 첫 글자 추출하기
Some 안에 문자열이 있으면 첫 글자를 추출해요. None이면 None으로 남아요.
# Option.map (fun s -> String.get s 0) (Some "hello");;
- : char option = Some 'h'
# Option.map (fun s -> String.get s 0) None;;
- : char option = None
예시 3: 리스트 옵션의 각 요소 두 배로 만들기
여기서는 Some 안에 있는 리스트에 map을 적용해 각 숫자를 두 배로 만들어요. None이면 리스트 변환이 일어나지 않아요.
# Option.map (List.map (fun x -> x * 2)) (Some [1; 2; 3]);;
- : int list option = Some [2; 4; 6]
# Option.map (List.map (fun x -> x * 2)) None;;
- : int list option = None
이중으로 감싼 옵션 벗겨내기 (Peel Off Doubly Wrapped Options)
가끔은 여러 옵션에 감싸인 값을 만나기도 해요. 이중으로 감싸진 옵션에서 한 겹을 벗겨 내는, 타입 'a option option -> 'a option의 커스텀 join 함수를 정의해 볼게요.
let join = function
| Some Some v -> Some v
| Some None -> None
| None -> None
우리의 커스텀 join 함수는 표준 라이브러리의 Option.join과 같아요.
# Option.join (Some (Some 42));;
- : int option = Some 42
# Option.join (Some None);;
- : 'a option = None
# Option.join None;;
- : 'a option = None
Option.join이 다른 시나리오에서 어떻게 동작하는지 보여주는 추가 예시를 볼게요.
예시 1: 감싸기 한 겹 제거하기
Some (Some v)이면 join은 v를 꺼내 Some v를 돌려줘요. Some None이나 None이면 None을 돌려줘요.
# Option.join (Some (Some "hello"));;
- : string option = Some "hello"
# Option.join (Some None);;
- : 'a option = None
# Option.join None;;
- : 'a option = None
예시 2: 계산이 들어 있는 중첩 옵션
여기서 safe_divide는 Some이나 None을 돌려줄 수 있어요. Option.join은 Some None이 나오지 않게 보장해 줘요.
# let safe_divide x y = if y = 0 then None else Some (x / y);;
val safe_divide : int -> int -> int option = <fun>
# Option.join (Some (safe_divide 10 2));;
- : int option = Some 5
# Option.join (Some (safe_divide 10 0));;
- : int option = None
# Option.join None;;
- : int option = None
옵션의 내용 꺼내기 (Extract the Content of an Option)
또한 옵션에 감싸인 값의 내용물을 꺼내고 싶을 때가 있어요. option 안에 담긴 값에 접근하게 해 주는, 타입 'a option -> 'a의 커스텀 get 함수를 정의해 볼게요.
# let get = function
| Some v -> v
| None -> raise (Invalid_argument "option is None");;
val get : 'a option -> 'a = <fun>
주의하세요. o가 None이면 get o는 예외를 던져요. 예외를 던질 위험 없이 option의 내용물에 접근하려면, 타입 'a option -> 'a -> 'a의 value 함수를 쓸 수 있어요.
# let value opt ~default = match opt with
| Some v -> v
| None -> default;;
val value : 'a option -> default:'a -> 'a = <fun>
다만 이 함수는 추가 인자로 기본값(default value)이 필요해요.
표준 라이브러리에서 이 함수들은 Option.get과 Option.value예요.
Option.get과 Option.value의 사용을 보여주는 예시를 볼게요.
예시 1: Option.get으로 값 꺼내기
# Option.get (Some 42);;
- : int = 42
이 함수는 Some 42 안의 값을 성공적으로 꺼내 와요.
# Option.get None;;
Exception: Invalid_argument "Option.get".
None에는 값이 없으므로 Option.get은 예외를 던져요.
예시 2: Option.value와 기본값으로 값 꺼내기
# Option.value (Some "hello") ~default:"default";;
- : string = "hello"
옵션에 값이 들어 있으므로 함수는 "hello"를 돌려줘요.
# Option.value None ~default:"default";;
- : string = "default"
옵션이 None이므로 제공된 기본값을 돌려줘요.
예시 3: 안전한 추출에 Option.value 사용하기
# let username = Some "alice";;
val username : string option = Some "alice"
# let display_name = Option.value username ~default:"Guest";;
val display_name : string = "alice"
# let missing_username = None;;
val missing_username : string option = None
# let display_name = Option.value missing_username ~default:"Guest";;
val display_name : string = "Guest"
이 예시들은 차이를 분명히 보여줘요. Option.get은 안전하지 않아서 None에서 예외를 던지지만, Option.value는 대체(fallback) 동작을 허용해요.
옵션 폴딩하기 (Fold an Option)
타입 none:'a -> some:('b -> 'a) -> 'b option -> 'a의 fold 함수는 map과 value를 결합한 것이라고 볼 수 있어요.
let fold ~none ~some o = o |> Option.map some |> Option.value ~default:none
표준 라이브러리에서 이 함수는 Option.fold예요.
Option.fold 함수는 패턴 매칭을 직접 쓰지 않고 대체(fall-back) 로직을 구현하는 데 쓸 수 있어요. 예를 들어 $PATH 환경 변수의 내용을 문자열 리스트로 바꾸는 함수가 있는데, 변수가 정의되어 있지 않으면 빈 리스트를 돌려줘요. 이 버전은 패턴 매칭을 사용해요.
# let path () =
let split_on_colon = String.split_on_char ':' in
let opt = Sys.getenv_opt "PATH" in
match opt with
| Some s -> split_on_colon s
| None -> [];;
val path : unit -> string list = <fun>
같은 함수를 표준 라이브러리의 Option.fold로 쓴 버전이에요.
# let path () =
let split_on_colon = String.split_on_char ':' in
Sys.getenv_opt "PATH" |> Option.fold ~some:split_on_colon ~none:[];;
val path : unit -> string list = <fun>
Option.fold 사용을 보여주는 추가 예시들을 볼게요.
예시 1: Some에 함수 적용, None에는 기본값 사용
# Option.fold ~none:0 ~some:(fun x -> x * 2) (Some 5);;
- : int = 10
옵션에 Some 5가 들어 있으므로 fold는 함수 fun x -> x * 2를 적용해 10을 만든다.
# Option.fold ~none:0 ~some:(fun x -> x * 2) None;;
- : int = 0
옵션이 None이므로 fold는 기본값 0을 돌려줘요.
예시 2: 문자열 길이 추출하거나 0으로 기본값 정하기
# let string_length_opt = Some "hello";;
val string_length_opt : string option = Some "hello"
# Option.fold ~none:0 ~some:String.length string_length_opt;;
- : int = 5
여기서 Option.fold는 "hello"에 String.length를 적용해 5를 돌려줘요.
# let missing_string = None;;
val missing_string : string option = None
# Option.fold ~none:0 ~some:String.length missing_string;;
- : int = 0
None이 주어졌으므로 기본값 0을 돌려줘요.
예시 3: 선택적인 사용자 입력 다루기
이 함수는 대체 인사말을 제공해서 선택적인 이름을 안전하게 다뤄요.
# let greet name_opt =
Option.fold ~none:"Hello, Guest!" ~some:(fun name -> "Hello, " ^ name ^ "!")
;;
val greet : string option -> string = <fun>
# greet (Some "Alice");;
- : string = "Hello, Alice!"
# greet None;;
- : string = "Hello, Guest!"
옵션 바인딩하기 (Bind an Option)
타입 'a option -> ('a -> 'b option) -> 'b option의 bind 함수는 map과 조금 비슷하게 동작해요. 다만 map은 타입 b의 감싸지 않은 값을 돌려주는 함수 파라미터 f를 기대하는 반면, bind는 이미 옵션 'b option에 감싸진 값을 돌려주는 함수 f를 기대해요.
커스텀 bind 함수를 구현해 볼게요.
# let bind o f =
match o with
| Some v -> (f v)
| None -> None;;
val bind : 'a option -> ('a -> 'b option) -> 'b option = <fun>
표준 라이브러리에서 이 함수는 Option.bind이에요.
Option.bind 사용을 보여주는 간단한 예시를 볼게요.
예시 1: 안전한 나눗셈
여기서 bind는 0으로 나누면 예외를 던지는 대신 안전하게 None을 돌려주도록 보장해요.
# let safe_div x y =
if y = 0 then None else Some (x / y);;
val safe_div : int -> int -> int option = <fun>
# Option.bind (Some 10) (fun x -> safe_div x 2);;
- : int option = Some 5
# Option.bind (Some 10) (fun x -> safe_div x 0);;
- : int option = None
# Option.bind None (fun x -> safe_div x 2);;
- : int option = None
예시 2: 중첩된 옵션 추출하고 변환하기
여기서 bind는 함수가 돌려준 값을 옵션에 감싸는 데 사용돼요.
# let find_user user_id =
if user_id = 1 then Some "Alice" else None;;
val find_user : int -> string option = <fun>
# let user_to_email username =
if username = "Alice" then Some "[email protected]" else None;;
val user_to_email : string -> string option = <fun>
# Option.bind (find_user 1) user_to_email;;
- : string option = Some "[email protected]"
# Option.bind (find_user 2) user_to_email;;
- : string option = None
user_to_email 함수가 find_user가 돌려준 옵션에 명시적으로 패턴 매칭을 하지 않는다는 점에 주목하세요. Option.bind를 쓰면 감싸기를 푸는 일을 bind 함수가 대신 처리해 줘요.
예시 3: 계산 체이닝하기
이 예시는 설정값을 가져와서 그 값이 유효한 범위 안에 들어오는지 확인해요.
# let lookup_config key =
if key = "timeout" then Some 30 else None;;
val lookup_config : string -> int option = <fun>
# let validate_timeout t =
if t > 0 && t < 60 then Some t else None;;
val validate_timeout : int -> int option = <fun>
# Option.bind (lookup_config "timeout") validate_timeout;;
- : int option = Some 30
# Option.bind (lookup_config "unknown_key") validate_timeout;;
- : int option = None
결론 (Conclusion)
OCaml의 option 타입은 없을 수도 있는 값을 표현하는 강력하고 타입 안전한 방법을 제공하고, 예외의 함정을 피하게 해 줘요. map, join, get, value, fold, bind 같은 함수를 활용하면 구조적이고 표현력 있게 선택적인 값을 다룰 수 있어요. 이 유틸리티들은 안전성과 합성성(composability)을 유지하면서 함수형 스타일 프로그래밍을 가능하게 해 줘요. 표준 라이브러리의 Option 모듈은 이런 패턴들을 한데 모아, 선택적인 값을 다룰 때 명확하고 간결하며 견고한 코드를 쓰기 쉽게 만들어 줘요.
더 알아보기
- OCaml 공식 문서 - Options
- Error Handling — 옵션과 예외로 에러를 다루는 더 긴 논의
- Option 모듈 — 표준 라이브러리의 옵션 유틸리티 함수 모아보기
- 연습문제: Last Two Elements of a List, N'th Element of a List