불리언을 Option/Result로 대체하는 가상의 언어를 구상하며, if/else/and/or의 이항 연산자적 의미, 타입·평가 규칙, 그리고 nil 기반 bool 대체를 탐구한다. Rust와의 대응과 실제 코드 예시를 통해 개념을 전개한다.
내가 시작한 곳에서 시작해 보자. if
문을 생각해 보자.
else
가 있는 if
는 값을 만들어 낼 수 있다:
// C
int x = -5;
int abs_x = x > 0 ? x : -x;
# Python
x = -5
abs_x = x if x > 0 else -x
// Rust
let x = -5;
let abs_x = if x > 0 { x } else { -x };
그렇다면 else
가_없는_ if
는 어떨까?
// C
int x = -5;
int abs_x = x > 0 ? x; // 문법 오류!
# Python
x = -5
abs_x = x if x > 0 # 문법 오류!
// Rust
let x = -5;
let abs_x = if x > 0 { x }; // 타입 오류 — 결과가 ()!
방법이 없다.
if
그렇지만 if (x > 0) x
에는 의미 있는 값이 있음을 깨달았다. 바로 Option<i32>
다:
// Hypothetical-Lang
let x = -5;
let pos_x = if (x > 0) x; // = None
let y = 7;
let pos_y = if (y > 0) y; // = Some(7)
(이 가상의 언어는 Rust와 많이 닮았지만, if
의 의미를 다르게 줄 것이므로 if
의 문법은 다를 것이다.)
일반적으로, 조건식이 true
면 if
는 Some
을, 아니면 None
을 평가 결과로 낸다:
if (true) e ⟼ Some(e)
if (false) e ⟼ None
그래서 if
의 타입은, bool
과 T
를 받아 Option<T>
를 만든다:
if (bool) T : Option<T>
(즉, expr_1
의 타입이 bool
이고 expr_2
의 타입이 T
라면, if (expr_1) expr_2
의 타입은 Option<T>
다.)
이렇게 일반화된 if
는, 조건이 참일 때만 유효한 연산을 수행하는 데 쓸 수 있다:
fn strip_prefix(string: &str, prefix: &str) -> Option<&str> {
if (string.starts_with(prefix)) &string[prefix.len()..]
}
혹은 filter_map
같은 메서드와 함께 사용할 수도 있다:
let numbers = vec![9.0, -20.0, 16.0, -16.0];
let square_roots = numbers
.into_iter()
.filter_map(|x| if (x > 0) x.sqrt());
assert_eq!(square_roots.next(), Some(3.0));
assert_eq!(square_roots.next(), Some(4.0));
assert_eq!(square_roots.next(), None);
else
else
에도 이에 대응되는 해석이 있다. 타입은 이렇다:
Option<T> else T : T
그리고 평가 규칙은 다음과 같다:
None else e ⟼ e
Some(x) else e ⟼ x
즉, 옵션에 기본값을 제공한다:
fn get_name(personal_info: &HashMap<String, String>) -> &str {
personal_info.get("name") else "Whoever you are"
}
(이는 Rust의 unwrap_or_else()
와 정확히 같은 동작인데, 표현식을 클로저로 감싸 줄 필요가 없다는 점만 다르다.)
물론 보통은 else
를 if
와 짝지어 쓴다. 타입 규칙으로 확인해 보면, 기대대로 잘 동작한다:
let abs_num = if (num > 0) num else -num;
-------- ---
bool i32
---------------- ----
Option<i32> i32
---------------------------
i32
우리가 한 일을 보라: if
와 else
를 모두 _이항 연산자_로 바꾸었다.
and
와 or
더 나아갈 수 있을까? and
와 or
는 어떨까 — 옵션에서 동작하게 할 수 있을까? 이들은 옵션을 받아 옵션을 반환하게 된다.
or
는 첫 번째 옵션이 Some
이면 그것을 취하고, 아니면 두 번째 것을 취한다. 성공할 수도 실패할 수도 있는 여러 옵션을 차례로 시도하는 데 쓸 수 있다:
fn get_color(attrs: &HashMap<String, String>) -> Option<&str> {
attrs.get("color") or attrs.get("colour")
}
and
는 첫 번째 옵션이 Some
일 때 두 번째 옵션을 취한다. 어떤 조건이 성립할 때만 무엇인가를 시도하는 데 쓸 수 있다. 예컨대 문자열이 #
로 시작하면 "#a52a2a"
같은 16진 색상을, 아니면 "brown"
같은 색상 이름을 파싱하고 싶다면 이렇게 쓸 수 있다:
fn parse_color(color: &str) -> Option<Color> {
(color.starts_with("#") and parse_color_hex(color))
or parse_color_name(color)
}
좀 더 형식적으로, and
와 or
의 평가 규칙은 다음과 같다:
None and e ⟼ None
Some(x) and e ⟼ e
None or e ⟼ e
Some(x) or e ⟼ Some(x)
그리고 타입 규칙은:
Option<A> and Option<B> : Option<B>
Option<A> or Option<A> : Option<A>
(이는 and
와 or
가 Rust의 and_then
과 or_else
와 동등함을 의미한다.)
수학적 직관이 좋은 사람이라면 and
와 or
의 타입 규칙 사이의 비대칭을 보고, 우리가 어떤 일반화를 놓치고 있다고 의심할지도 모른다. 걱정 마라, 곧 나온다.
잠깐! 그래도 if
의 조건 안에서 and
와 or
를 쓸 수 있어야 한다. 예컨대 if (x >= 0 and x < len)
. and
가 옵션을 만든다면 이건 동작하지 않는다!
하지만 쉬운 해결책이 있다. 불리언과 옵션 사이에는 다음과 같은 동치가 있다:
bool = Option<()>
true = Some(())
false = None
그래서 가상의 언어에서 불리언을 없애고, 항상 그에 상응하는 옵션을 대신 쓸 수 있다.
이제 불리언이 없는 프로그래밍 언어가 어떤 모습일지 상상해 보자.
방금 bool
을 Option<()>
으로 치환한다고 했는데, 이 타입들은 동치이기 때문이다. 하지만 Result와도 더 나아간 동치가 있다:
Option<T> = Result<T, ()>
bool = Option<()>
= Result<(), ()>
불리언과 옵션 사이의 동치를 사용할 거라면, 아예 옵션과 결과 사이의 동치를 끝까지 사용하는 편이 낫다. 어디에나 결과(Result)!
이렇게 널리 쓰일 것이므로, 짧은 표기를 하나 만들자. T ? E
는 “성공 값 T
혹은 오류 E
”를 의미하도록 하자. Rust의 Result<T, E>
에 해당한다.
그렇다면 bool
은 ()?()
의 타입 별칭이다. 그런데… 이건 파리 얼굴 ASCII 그림처럼 보인다. 그래서 단위 값(과 단위 타입)을 nil
이라고 부르자. 그러면 다음이 된다:
bool = nil?nil
true = Ok(nil)
false = Err(nil)
이들은 언어에 내장된 별칭이 될 것이다.
앞서의 모든 타입 규칙을 옵션 대신 결과로 다시 쓰면 이렇게 된다:
if (A?E) B : B?E
A?E else A : A
A?E and B?E : B?E
A?E or A?F : A?F
not A?E : nil?nil
(이제 이전에 부족했던 예쁜 대칭성이 생겼다.)
그리고 모든 평가 규칙을 쓰면:
if (Ok(x)) e ⟼ Ok(e)
if (Err(x)) e ⟼ Err(x)
Ok(x) else e ⟼ x
Err(x) else e ⟼ e
Ok(x) and e ⟼ e
Err(x) and e ⟼ Err(x)
Ok(x) or e ⟼ Ok(x)
Err(x) or e ⟼ e
not Ok(x) ⟼ false
not Err(x) ⟼ true
(사실 if (A?E) B
가 올바른 타입인지 확신은 없다. 아니면 if
가 조건의 오류 타입이 nil
이길 요구해야 할지도 모르겠다. 예컨대 if (A?nil) B
처럼.)
이제 이 언어에서 조건식이 어떻게 생길지 상상해 볼 수 있다.
else if
우리는 if
와 else
를 이항 연산자로 바꾸었으니, else if
가 원래 의도대로 동작하는지 확인해야 한다:
let sign = if (n > 0)
1
else if (n < 0)
-1
else
0;
동작한다. 단, else
가 오른쪽 결합을 해야 한다. 즉, 괄호 묶음은 다음과 같아야 한다:
if (n > 0) 1 else if (n < 0) -1 else 0
------------ -------------
--------------------
--------------------------------------
or if
놀랍게도 다중 분기 조건을 쓰는 또 다른 방법이 있다:
let sign = if (n > 0)
1
or if (n < 0)
-1
else
0;
이건 else if
와 정확히 똑같이 동작한다! or
가 else
보다 더 강하게 결합한다는 사실을 이용한다:
if (n > 0) 1 or if (n < 0) -1 else 0
------------ -------------
-----------------------------
------------------------------------
처음엔 “WTF” 반응을 넘기기까지 좀 걸렸지만, 이제는 “or if”라고 말하는 게 꽤 마음에 든다.
true
와 false
true
와 false
는 단지 Ok(nil)
과 Err(nil)
의 축약일 뿐임을 기억하자. 그래서 이렇게 쓸 수 있다:
fn delete_file(path: FilePath) -> nil ? IOError {
if (not file_exists(path)) return true;
...
}
또는:
fn pop(vec: &mut Vec<i32>) -> i32 ? nil {
if (vec.len == 0) return false;
...
}
is
조건식 안에서 패턴을 바인딩할 수 있으면 아주 유용하다. Rust는 이를 위해 if let
문법을 쓰는데, 여기서는 is
문법을 쓰자:
fn parse_and_clamp(s: &str, min: i32, max: i32) -> i32 ? nil {
if (parse_num(s) is Ok(n)) {
if (n < min) min
or if (n > max) max
else n
}
}
try / else
Python은 for
루프 뒤에 else
절을 붙일 수 있다. else
절은 for
루프가 break
하지 않았을 때 실행된다. 예를 들면:
for elem in list:
if predicate(elem):
print("Found!", elem)
break
else:
print("Not found :-(")
우리 가상 언어에는 이에 대한 자연스러운 확장이 있다 — for
루프가 값과 함께 break
할 수 있고, 그렇게 하면 Ok
를 만들어 낸다:
fn find(list: &Vec<i32>, predicate: fn(i32) -> bool) -> i32 ? nil {
for elem in list {
if (predicate(elem)) break elem;
}
}
이는 기존의 else
구성과 합성될 수 있다:
fn find_with_default(
list: &Vec<i32>,
predicate: fn(i32) -> bool,
default: i32
) -> i32 {
for elem in list {
if (predicate(elem)) break elem;
} else default
}
Ok
와 Err
Rust로 썼다면 결과를 Ok
나 Err
또는 Some
으로 감싸야 하는 많은 함수가, 이제는 그럴 필요가 없다. 예를 들어 Rust에서의 불리언 파싱:
fn parse_bool(s: &str) -> Option<bool> {
if s == "true" {
Some(true)
} else if s == "false" {
Some(false)
} else {
None
}
}
우리 가상 언어에서는 다음과 같다:
fn parse_bool(s: &str) -> bool ? nil {
if (s == "true") true
or if (s == "false") false
}
지금까지의 예시는 모두 짧았다. 이제 내 개인 프로젝트의 깊숙한 곳에서 가져온 실제 코드 몇 개를 보자. 무엇을 하는지 이해할 필요는 없다(나도 자세한 건 기억 안 난다). 이 가상 언어로 다시 써 보고 비교만 해 보자.
다음은 한 조각이다:
if !node.can_have_children(s) {
return None;
}
if let Some(last_child) = node.last_child(s) {
Some(Location(AtNode(last_child)))
} else {
Some(Location(BelowNode(node)))
}
이제 이렇게 쓸 수 있다:
if (node.can_have_children(s)) {
if (node.last_child(s) is Ok(last_child))
Location(AtNode(last_child))
else
Location(BelowNode(node))
}
또 다른 예시:
if let Some(menu) = &mut self.active_menu {
if let Some(key_prog) = menu.lookup(key) {
return Some(KeyLookupResult::KeyProg(key_prog));
}
if let Some(ch) = key.as_plain_char() {
if menu.execute(MenuSelectionCmd::Insert(ch)) {
return Some(KeyLookupResult::Redisplay);
}
}
} else {
let layer = self.composite_layer(doc_name);
let keymap = layer.keymaps.get(&KeymapLabel::Mode(mode));
if let Some(key_prog) = keymap.lookup(key, None) {
return Some(KeyLookupResult::KeyProg(key_prog));
}
if mode == Mode::Text {
if let Some(ch) = key.as_plain_char() {
return Some(KeyLookupResult::InsertChar(ch));
}
}
}
None
이건 이렇게 쓸 수 있다:
if (&mut self.active_menu is Ok(menu)) {
if (menu.lookup(key) is Ok(key_prog)) {
KeyLookupResult::KeyProg(key_prog)
} or if (key.as_plain_char() is Ok(ch)
and menu.execute(MenuSelectionCmd::Insert(ch))) {
KeyLookupResult::Redisplay
}
} else {
let layer = self.composite_layer(doc_name);
let keymap = layer.keymaps.get(&KeymapLabel::Mode(mode));
if (keymap.lookup(key, false) is Ok(key_prog)) {
KeyLookupResult::KeyProg(key_prog)
} or if (mode == Mode::Text and key.as_plain_char() is Ok(ch)) {
KeyLookupResult::InsertChar(ch)
}
}
주의할 점은, 여기서는 결과를 Some
으로 감쌀 필요도, 이른 return
문을 쓸 필요도 없어진다는 것이다.
분명 일관된 설계처럼 보인다! 확실히 조건문을 생각하는 방식에 영향을 줄 것이다.
내가 본 것 중 가장 가까운 것은 Verse의 실패 가능한 식(fallible expressions)인데, (i) else
없는 if
에 값을 부여하지 않고, (ii) 투기적 실행을 수반한다는 점에서 꽤 다르다. 이와 더 비슷한 것을 본 적이 있다면 알려 주시길.