연산자

연산자 (Operators)

OCaml에서 연산자는 단순한 문법 기호가 아니라, 함수의 다른 이름이에요. 이번 장에서는 연산자를 함수처럼, 또 함수를 연산자처럼 사용하는 법, 그리고 커스텀 연산자에 올바른 결합성(associativity)과 우선순위(precedence)를 부여하는 법, 커스텀 let 바인더까지 하나씩 배워볼게요.

출처: OCaml 공식 문서 - Operators

학습 목표 (Goals)

이 튜토리얼의 학습 목표는 다음과 같아요:

  • 연산자를 함수처럼, 또 그 반대로 함수를 연산자처럼 사용하기
  • 커스텀 연산자에 올바른 결합성과 우선순위 부여하기
  • 커스텀 let 바인더 사용하기와 정의하기

이항 연산자 사용하기 (Using Binary Operators)

OCaml에서 거의 모든 이항 연산자는 평범한 함수예요. 연산자 뒤에 숨은 함수는 연산자 기호를 괄호로 감싸서 가리킬 수 있어요. 덧셈, 문자열 결합, 그리고 동등 비교 함수는 각각 이렇게 생겼어요:

# (+);;
- : int -> int -> int = <fun>
# (^);;
- : string -> string -> string = <fun>
# (=);;
- : 'a -> 'a -> bool = <fun>

참고: 곱셈의 연산자 기호는 *지만, (*)라고 가리킬 수는 없어요. OCaml에서 주석은 (**)로 구분되기 때문이에요. 이 파싱 모호성을 해결하려면 곱셈 함수를 얻기 위해 공백 문자를 넣어야 해요.

# ( * );;
- : int -> int -> int = <fun>

연산자를 함수로 사용하는 것은 부분 적용(partial application)과 함께 쓸 때 편리해요. 예를 들어 정수 리스트에서 10보다 크거나 같은 값들을 List.filter 함수와 연산자를 사용해 얻는 방법이 다음과 같아요.

# List.filter;;
- : ('a -> bool) -> 'a list -> 'a list = <fun>

# List.filter (( <= ) 10);;
- : int list -> int list = <fun>

# List.filter (( <= ) 10) [6; 15; 7; 14; 8; 13; 9; 12; 10; 11];;
- : int list = [15; 14; 13; 12; 10; 11]

# List.filter (fun n -> 10 <= n) [6; 15; 7; 14; 8; 13; 9; 12; 10; 11];;
- : int list = [15; 14; 13; 12; 10; 11]

처음 두 줄과 마지막 줄은 정보 제공용이에요.

  1. 첫 번째는 List.filter 타입을 보여줘요. 두 매개변수를 받는 함수인데, 첫 번째 매개변수는 함수이고 두 번째는 리스트예요.
  2. 두 번째는 List.filter( <= ) 10으로 부분 적용한 것이에요. ( <= ) 10은 10보다 크거나 같은 숫자에 적용되면 true를 반환하는 함수예요.

마지막으로 세 번째 줄에서 List.filter가 기대하는 모든 인자가 제공돼요. 반환된 리스트는 ( <= ) 10 함수를 만족하는 값들을 담고 있어요.

이항 연산자 정의하기 (Defining Binary Operators)

이항 연산자를 정의하는 것도 가능해요. 다음은 그 예시예요:

# let cat s1 s2 = s1 ^ " " ^ s2;;
val cat : string -> string -> string = <fun>

# let ( ^? ) = cat;;
val ( ^? ) : string -> string -> string = <fun>
# "hi" ^? "friend";;
- : string = "hi friend"

연산자는 예시처럼 두 단계로 정의하는 것이 권장돼요. 첫 번째 정의는 함수의 로직을 담아요. 두 번째 정의는 첫 번째의 단순한 별칭이에요. 이렇게 하면 연산자에 기본적인 '발음'을 제공하고, 연산자가 문법적 설탕(syntactic sugar) 임을 분명히 나타내는 효과가 있어요. 즉 텍스트를 더 간결하게 만들어 읽기 쉽게 하려는 수단이라는 뜻이에요.

단항 연산자 (Unary Operators)

단항 연산자는 접두 연산자(prefix operator)라고도 불러요. 어떤 맥락에서는 함수 이름을 기호로 줄이는 것이 말이 될 때가 있어요. 인자에 대해 어떤 종류의 변환을 수행하는 함수의 이름을 줄이는 방법으로 자주 사용돼요.

# let ( !! ) = Lazy.force;;
val ( !! ) : 'a lazy_t -> 'a = <fun>

# let rec transpose = function
   | [] | [] :: _ -> []
   | rows -> List.(map hd rows :: transpose (map tl rows));;
val transpose : 'a list list -> 'a list list = <fun>

# let ( ~: ) = transpose;;
val ( ~: ) : 'a list list -> 'a list list

이렇게 하면 사용자가 더 간결한 코드를 쓸 수 있어요. 하지만 지나치게 축약된 코드를 쓰지 않도록 주의하세요. 유지보수가 더 어려워지거든요. 대부분의 독자가 연산자를 이해할 수 있어야 해요. 그렇지 않으면 연산자가 도움이 되기보다 오히려 해가 돼요.

허용되는 연산자 (Allowed Operators)

OCaml은 미묘한 문법을 가지고 있어요. 모든 것이 연산자 기호로 허용되는 건 아니에요. 연산자 기호는 특별한 문법을 가진 식별자이므로, 다음과 같은 구조를 가져야 해요:

접두 연산자 (Prefix Operator)

  1. 첫 번째 문자는 다음 중 하나:
    • ? ~
    • !
  2. 다음 문자들은, 첫 문자가 ?~라면 최소 하나가 필요하고 그 외에는 선택적:
    • $ & * + - / = > @ ^ |
    • % <

이항 연산자 (Binary Operator)

  1. 첫 번째 문자는 다음 중 하나:
    • $ & * + - / = > @ ^ |
    • % <
    • #
  2. 다음 문자들은, 첫 문자가 #라면 최소 하나가 필요하고 그 외에는 선택적:
    • $ & * + - / = > @ ^ |
    • % <
    • ! . : ? ~

이것은 OCaml 매뉴얼의 Prefix and Infix symbols 섹션에 정의되어 있어요.

팁들:

  • 넓은 범위(wide scope)의 연산자는 정의하지 마세요. 범위를 모듈이나 함수로 제한하세요.
  • 연산자를 많이 사용하지 마세요.
  • 커스텀 이항 연산자를 정의하기 전에 그 기호가 이미 사용되고 있지 않은지 확인하세요. 두 가지 방법으로 확인할 수 있어요:
    • UTop에서 후보 기호를 괄호로 감싸고, 타입으로 응답하는지 Unbound value 오류로 응답하는지 보세요.
    • Sherlocode를 사용해 어떤 OCaml 프로젝트에서 이미 쓰이는지 확인하세요.
  • 기존 연산자를 가리는(shadow) 것은 피하세요.

연산자 결합성과 우선순위 (Operator Associativity and Precedence)

예시로 연산자 결합성을 설명해 볼게요. 다음 함수는 문자열 인자들을 결합하는데, | 문자로 감싸고 _ 문자로 구분해요.

# let par s1 s2 = "|" ^ s1 ^ "_" ^ s2 ^ "|";;
val par : string -> string -> string = <fun>

# par "hello" "world";;
- : string = "|hello_world|"

par를 두 개의 다른 연산자로 바꿔 볼게요:

# let ( @^ ) = par;;
val ( @^ ) : string -> string -> string = <fun>

# let ( &^ ) = par;;
val ( &^ ) : string -> string -> string = <fun>

언뜻 보면 연산자 @^&^는 같아 보여요. 하지만 OCaml 파서는 괄호 없이 여러 연산자를 사용해 표현식을 만들 수 있게 해줘요.

# "foo" @^ "bar" @^ "bus";;
- : string = "|foo_|bar_bus||"

# "foo" &^ "bar" &^ "bus";;
- : string = "||foo_bar|_bus|"

두 표현식 모두 같은 함수(par)를 호출하지만, 서로 다른 순서로 평가돼요.

  1. 표현식 "foo" @^ "bar" @^ "bus""foo" @^ ("bar" @^ "bus")인 것처럼 평가돼요. 괄호가 오른쪽에 추가되므로 @^오른쪽으로 결합 돼요.
  2. 표현식 "foo" &^ "bar" &^ "bus""foo" &^ "bar") &^ "bus"인 것처럼 평가돼요. 괄호가 왼쪽에 추가되므로 &^왼쪽으로 결합 돼요.

연산자 우선순위 는 괄호 없이 서로 다른 연산자를 결합한 표현식이 어떻게 해석되는지 좌우해요. 예를 들어 같은 연산자들을 사용해서, 두 연산자를 모두 쓴 표현식은 이렇게 평가돼요:

# "foo" &^ "bar" @^ "bus";;
- : string = "|foo_|bar_bus||"

# "foo" @^ "bar" &^ "bus";;
- : string = "||foo_bar|_bus|"

두 경우 모두 값들이 &^보다 @^에 먼저 전달돼요. 따라서 @^&^에 대해 우선순위 를 가진다고 말해요. 연산자 우선순위 규칙은 OCaml 매뉴얼의 Expressions 섹션에 상세히 설명돼 있어요. 다음과 같이 요약할 수 있어요. 연산자의 첫 번째 문자가 그 결합성과 우선순위를 결정해요. 각 그룹의 연산자들의 첫 번째 문자들은 다음과 같아요. 각 그룹은 같은 결합성과 우선순위를 가져요. 그룹들은 우선순위가 증가하는 순서로 정렬되어 있어요.

  1. 왼쪽 결합: $ & < = > |
  2. 오른쪽 결합: @ ^
  3. 왼쪽 결합: + -
  4. 왼쪽 결합: % * /
  5. 왼쪽 결합: #

완전한 우선순위 목록은 더 길어요. 커스텀 연산자로 사용할 수 없게 되어 있는 미리 정의된 연산자들이 포함되기 때문이에요. OCaml 매뉴얼에는 연산자 결합성 규칙을 정리한 가 있어요.

바인딩 연산자 (Binding Operators)

OCaml은 커스텀 let 연산자를 만들 수 있게 해줘요. 이는 Option.bind이나 List.concat_map 같은 모나드 관련 함수에서 자주 사용돼요. 이 주제에 대한 자세한 내용은 Monads를 참고하세요.

doi_parts 함수는 Digital Object Identifier (DOI)가 들어 있을 것으로 예상되는 문자열에서 등록자(registrant)와 식별자(identifier) 부분을 추출하려고 해요.

# let ( let* ) = Option.bind;;
val ( let* ) : 'a option -> ('a -> 'b option) -> 'b option = <fun>

# let doi_parts s =
  let open String in
  let* slash = rindex_opt s '/' in
  let* dot = rindex_from_opt s slash '.' in
  let prefix = sub s 0 dot in
  let len = slash - dot - 1 in
  if len >= 4 && ends_with ~suffix:"10" prefix then
    let registrant = sub s (dot + 1) len in
    let identifier = sub s (slash + 1) (length s - slash - 1) in
    Some (registrant, identifier)
  else
    None;;

# doi_parts "doi:10.1000/182";;
- : (string * string) option = Some ("1000", "182")

# doi_parts "https://doi.org/10.1000/182";;
- : (string * string) option = Some ("1000", "182")

이 함수는 rindex_optrindex_from_opt 호출에 대해 Option.bind를 커스텀 바인더로 사용해요. 이렇게 하면 두 검색이 모두 성공해서 찾은 문자의 위치를 반환하는 경우만 고려하게 돼요. 둘 중 하나라도 실패하면 doi_parts는 암묵적으로 None을 반환해요.

let open String in 구문을 사용하면 doi_parts의 정의 범위 안에서 rindex_opt, rindex_from_opt, length, ends_with, sub 함수를 각각 String. 접두사를 붙이지 않고 호출할 수 있어요.

함수의 나머지 부분은 관련 구분 문자가 발견된 경우에 적용돼요. 추가 검사를 수행하고, 가능하다면 문자열 s에서 등록자와 식별자를 추출해요.

더 알아보기 (Learn more)