13장: 사용자 정의 연산자 (User-Defined Operators)
프로그래밍 언어가 제공하는 내장 연산자들은 범용적으로 설계되어 있습니다. +는 숫자를 더하고, =는 값을 비교합니다. 하지만 실제 코드를 쓰다 보면 “이 두 리스트를 이어붙이는 연산을 append xs ys 대신 xs ++ ys로 쓸 수 있으면 얼마나 좋을까“라는 생각이 자연스럽게 드는 순간이 있습니다.
사용자 정의 연산자는 바로 그 생각을 실현시켜주는 도구입니다. 함수 호출의 의미를 유지하면서, 코드가 마치 그 도메인의 고유한 언어처럼 읽히게 만들 수 있습니다. 파서 조합기 라이브러리에서 <|>로 대안을 표현하거나, 수식 라이브러리에서 **로 거듭제곱을 표현하는 것처럼, 연산자는 코드와 개념 사이의 거리를 좁혀줍니다.
FunLang는 기호 문자로 구성된 사용자 정의 중위 연산자를 지원합니다. 이를 통해 DSL(도메인 특화 언어)을 만들거나, 반복적인 함수 호출을 간결한 표현으로 대체할 수 있습니다.
연산자 정의하기
let (op) a b = body 구문으로 사용자 정의 중위 연산자를 선언합니다:
$ cat op_basic.l3
let (++) xs ys = append xs ys
let result = [1; 2] ++ [3; 4]
$ fn op_basic.l3
[1; 2; 3; 4]
눈여겨볼 점은 정의할 때는 (++) 형태로 괄호 안에 연산자를 쓰지만, 사용할 때는 xs ++ ys처럼 중위 형태로 자연스럽게 쓸 수 있다는 것입니다. 이는 F#과 OCaml이 공유하는 관습입니다.
연산자 이름은 기호 문자(! $ % & * + - . / < = > ? @ ^ | ~)의 조합입니다. 최소 2문자 이상이어야 합니다 (단일 문자 +, * 등은 내장 연산자).
단일 문자 연산자를 재정의할 수 없는 이유는 간단합니다. +의 의미를 바꾸는 순간 코드를 읽는 사람은 그 파일 안에서 +가 어떤 의미인지 항상 확인해야 합니다. 기존 연산자의 의미를 유지하는 것은 코드 가독성의 기본입니다.
let rec으로 재귀 연산자도 정의할 수 있습니다.
우선순위 규칙
연산자를 직접 정의할 수 있게 되면 곧 이런 질문이 생깁니다: a ++ b ** c처럼 여러 연산자가 섞이면 어떤 순서로 계산될까요?
FunLang는 OCaml에서 물려받은 우아한 해답을 씁니다. 연산자를 직접 파싱해서 우선순위를 결정하지 않고, 연산자의 첫 번째 문자만으로 우선순위 레벨을 결정합니다. 덕분에 여러분이 만드는 모든 연산자는 그 첫 글자를 통해 자동으로 적절한 우선순위 그룹에 속하게 됩니다.
연산자의 우선순위는 첫 번째 문자로 결정됩니다 (F#/OCaml 규칙):
| 레벨 | 첫 문자 | 결합성 | 예제 |
|---|---|---|---|
| INFIXOP0 (낮음) | = < > | & $ ! | 좌결합 | <|>, ===, != |
| INFIXOP1 | @ ^ | 우결합 | ^^, @> |
| INFIXOP2 | + - | 좌결합 | ++, +. |
| INFIXOP3 | * / % | 좌결합 | */, %% |
| INFIXOP4 (높음) | ** | 우결합 | **, *** |
같은 레벨의 연산자는 같은 우선순위를 가집니다. 예: ++ (INFIXOP2)는 +와 같은 우선순위, <|> (INFIXOP0)는 비교 연산자와 같은 우선순위.
이 체계가 갖는 실용적인 의미를 생각해봅시다. <|> 연산자를 만들면 그것은 자동으로 INFIXOP0, 즉 비교 연산자(<, >, =)와 같은 낮은 우선순위를 가집니다. 이는 직관적으로도 맞습니다. a < b <|> c < d라는 표현을 쓸 때 <가 먼저 평가되길 기대하는 것이 자연스럽기 때문입니다.
반면 ++는 INFIXOP2이므로 +와 같은 우선순위입니다. 수학적으로도 덧셈 수준의 연산이라는 뜻이고, x ++ y ** z에서 **가 먼저 결합됩니다 (INFIXOP4가 더 높으므로).
결합성도 중요합니다. ^^는 ^로 시작하므로 INFIXOP1이고, 우결합(right-associative)입니다. "a" ^^ "b" ^^ "c"는 "a" ^^ ("b" ^^ "c")로 파싱됩니다. 문자열 연결에서 우결합이 유리한 이유는, 오른쪽에서 차곡차곡 쌓는 방식이 임시 문자열 생성을 줄이기 때문입니다.
연산자를 함수로 사용하기
연산자가 단순히 “중위 문법“이기만 하다면 그다지 강력하지 않을 것입니다. FunLang 연산자의 진가는 다시 일반 함수처럼 다룰 수 있다는 데 있습니다.
괄호로 감싸면 연산자를 일반 함수처럼 사용할 수 있습니다:
$ cat op_as_func.l3
let (++) xs ys = append xs ys
let result = fold (++) [] [[1; 2]; [3]; [4; 5]]
$ fn op_as_func.l3
[1; 2; 3; 4; 5]
(++)는 fun xs -> fun ys -> append xs ys와 동일합니다.
fold (++) []는 리스트들의 리스트를 하나의 리스트로 평탄화합니다. (++)를 fold의 첫 번째 인자로 바로 넘길 수 있다는 것은, 연산자가 1급 값(first-class value)임을 의미합니다. Haskell에서 foldr (++) []와 정확히 같은 방식입니다.
이 패턴은 map, filter, fold와 조합할 때 특히 강력합니다. 연산자를 정의하면 함수의 세계와 연산자의 세계 사이를 자유롭게 오갈 수 있습니다.
Prelude 연산자
FunLang를 시작하는 순간부터 바로 쓸 수 있는 세 개의 연산자가 Prelude에 정의되어 있습니다. 이 세 연산자는 각각 다른 우선순위 레벨에 걸쳐 있어서, 연산자 분류 체계를 이해하는 데도 좋은 예시입니다.
| 연산자 | 타입 | 설명 | 예제 |
|---|---|---|---|
++ | 'a list -> 'a list -> 'a list | 리스트 연결 | [1;2] ++ [3;4] → [1;2;3;4] |
<|> | Option<'a> -> Option<'a> -> Option<'a> | Option 대안 | None <|> Some 42 → Some 42 |
^^ | string -> string -> string | 문자열 연결 | "a" ^^ "b" → "ab" |
<|>는 Haskell의 <|> (Alternative)와 같은 개념입니다. “왼쪽이 None이면 오른쪽을 시도한다“는 폴백(fallback) 패턴을 체인으로 연결할 때 코드가 훨씬 읽기 좋아집니다.
실용 예제
리스트 정렬 (quicksort with ++)
퀵정렬은 ++를 사용하면 알고리즘의 구조가 코드에 그대로 드러납니다. “피벗보다 작은 것들을 정렬한 결과” 더하기 “[피벗]” 더하기 “피벗보다 크거나 같은 것들을 정렬한 결과“라는 설명이 코드와 일대일로 대응합니다.
$ cat qsort_op.l3
let rec qsort xs =
match xs with
| [] -> []
| p :: rest ->
qsort (filter (fun x -> x < p) rest) ++ [p] ++ qsort (filter (fun x -> x >= p) rest)
let result = qsort [5; 3; 8; 1; 9; 2; 7]
$ fn qsort_op.l3
[1; 2; 3; 5; 7; 8; 9]
append 함수를 직접 썼다면 중첩된 호출이 알고리즘의 흐름을 가렸을 것입니다. ++를 쓰면 세 부분의 연결이 왼쪽에서 오른쪽으로 자연스럽게 읽힙니다.
문자열 포매팅
^^는 문자열 연결을 파이프라인과 자연스럽게 조합할 수 있게 해줍니다. 복잡한 문자열 포매팅 로직을 함수로 캡슐화하고, 그것을 파이프라인의 끝에 붙이는 패턴은 FunLang에서 자주 나타납니다.
$ cat format_op.l3
// 리스트를 "[1, 2, 3]" 형태의 문자열로 변환
let formatList xs = "[" ^^ fold (fun acc -> fun x -> if acc = "" then to_string x else acc ^^ ", " ^^ to_string x) "" xs ^^ "]"
let result = [1..5] |> filter (fun x -> x > 2) |> formatList
$ fn format_op.l3
"[3, 4, 5]"
여기서 ^^의 우결합성이 "[" ^^ ... ^^ "]" 전체를 올바른 순서로 결합시켜줍니다.
Option fallback 체인
<|>의 진가는 여러 소스에서 값을 시도할 때 드러납니다. 여러 파싱 전략을 순서대로 시도하고, 처음 성공한 결과를 택하는 이 패턴은 실제 파서나 설정 읽기 코드에서 자주 등장합니다.
$ cat fallback_op.l3
let tryParse s =
match s with
| "42" -> Some 42
| "0" -> Some 0
| _ -> None
let result = tryParse "abc" <|> tryParse "xyz" <|> tryParse "42" <|> Some 0
$ fn fallback_op.l3
Some 42
tryParse "abc"는 None을 반환하므로 <|>가 다음으로 넘어갑니다. tryParse "xyz"도 None이고, tryParse "42"에서 드디어 Some 42가 나오므로 여기서 체인이 멈춥니다. 만약 모든 시도가 실패했다면 마지막 Some 0이 기본값으로 사용됩니다.
이것을 if-else나 match로 쓰면 들여쓰기가 깊어지거나 임시 변수가 늘어납니다. <|>는 그 구조를 평평하게 만들어줍니다.
나만의 연산자 정의
어떤 연산자를 만들지는 도메인과 용도에 따라 달라집니다. 여기서 =?는 비교를 문자열로 변환하는 연산자입니다. 테스트나 디버깅 출력을 생성할 때 유용할 수 있습니다.
$ cat custom_op.l3
let (=?) a b = if a = b then "equal" else "not equal"
let r1 = 1 =? 1
let r2 = 1 =? 2
let result = r1 ^^ ", " ^^ r2
$ fn custom_op.l3
"equal, not equal"
=?는 =로 시작하므로 INFIXOP0입니다. 비교 연산자와 같은 낮은 우선순위를 가집니다. a + b =? c + d라고 쓰면 덧셈이 먼저 계산되고 그 결과를 =?로 비교합니다. 직관적으로 맞는 동작입니다.
연산자를 쓸 때와 쓰지 말아야 할 때
연산자를 정의할 수 있다고 해서 항상 정의해야 하는 것은 아닙니다. 연산자는 그것이 나타내는 연산이 해당 도메인에서 자주 사용되고, 수학적이거나 조합적인 의미가 명확할 때 빛납니다.
연산자가 도움이 되는 경우는 코드가 도메인 전문가도 읽을 수 있을 만큼 표현이 명확해질 때입니다. xs ++ ys는 수학의 집합 연결 표기와 유사하고, a <|> b는 논리합의 변형처럼 읽힙니다.
반면 연산자가 혼란을 주는 경우는 이름이 그 의미를 짐작하기 어려울 때입니다. a >>= b나 a ==> b 같은 연산자는 Haskell 배경이 없는 독자에게는 수수께끼입니다. 그런 경우에는 bind a b나 implies a b처럼 이름이 있는 함수가 낫습니다.
좋은 기준은 이렇습니다: 그 연산자를 처음 보는 팀원이 컨텍스트를 보고 30초 안에 의미를 짐작할 수 있다면 쓰세요. 그렇지 않다면 함수를 쓰세요.
Fixity 속성: 우선순위와 결합성 직접 지정
기본 규칙(첫 번째 문자 기반)으로 충분하지 않을 때가 있습니다. 예를 들어 파이프 연산자 |>는 |로 시작하므로 기본적으로 INFIXOP0(비교 수준)에 배치되지만, 실제로는 가장 낮은 우선순위를 가져야 합니다. FunLang의 fixity 속성은 이런 상황에서 연산자의 우선순위와 결합성을 직접 지정할 수 있게 합니다.
#[left N] 또는 #[right N] 형태의 속성을 연산자 정의 바로 앞에 붙입니다:
$ cat fixity_attr.l3
#[left 6]
let ($>) x f = f x
let double x = x * 2
let result = 5 $> double
let _ = println (to_string result)
$ fn fixity_attr.l3
10
여기서 #[left 6]은 $> 연산자를 좌결합, 우선순위 레벨 6(덧셈 수준)으로 지정합니다. 속성이 없었다면 $로 시작하므로 INFIXOP0(레벨 4)이 되었을 것입니다.
Fixity 레벨
| 레벨 | 대응하는 기본 연산자 |
|---|---|
| 1 | |>, <| (파이프) |
| 2 | >>, << (합성) |
| 3 | || (논리 OR) |
| 4 | INFIXOP0 (비교 수준) |
| 5 | INFIXOP1, :: (연결 수준) |
| 6 | +, -, INFIXOP2 (덧셈 수준) |
| 7 | *, /, %, INFIXOP3 (곱셈 수준) |
| 8 | INFIXOP4 (지수 수준) |
결합성 제어
#[right N]으로 기본 좌결합 연산자를 우결합으로 변경할 수 있습니다:
$ cat fixity_right.l3
#[right 6]
let (+->) a b = a + b
let result = 1 +-> 2 +-> 3
let _ = println (to_string result)
$ fn fixity_right.l3
6
#[right 6]으로 선언했으므로 1 +-> 2 +-> 3은 1 +-> (2 +-> 3) = 1 +-> 5 = 6으로 계산됩니다. 기본적으로 +로 시작하는 연산자는 좌결합이지만, fixity 속성으로 우결합으로 변경했습니다.
Prelude의 활용
Prelude의 |>, <|, >>, << 연산자도 이 fixity 속성으로 정의되어 있습니다. Prelude/Core.fun을 열어보면:
#[left 1]
let (|>) x f = f x
#[right 1]
let (<|) f x = f x
#[right 2]
let (>>) f g = fun x -> g (f x)
#[left 2]
let (<<) f g = fun x -> f (g x)
이처럼 fixity 속성은 특별한 컴파일러 마법이 아니라, 모든 사용자가 동일하게 활용할 수 있는 일반적인 메커니즘입니다.
주의 사항
(*는 블록 코멘트 시작이므로,*로 시작하는 연산자를 함수로 사용할 때는 공백이 필요합니다:( ** )(not(**)).- 단일 문자 연산자 (
+,*,=등)는 재정의할 수 없습니다. 이들은 내장 연산자입니다. - 연산자 이름은 2문자 이상이어야 합니다.