불리언 없이 상상하는 언어

ko생성일: 2025. 9. 23.갱신일: 2025. 9. 24.

불리언을 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의 문법은 다를 것이다.)

일반적으로, 조건식이 trueifSome을, 아니면 None을 평가 결과로 낸다:

if (true)  e  ⟼  Some(e)
if (false) e  ⟼  None

그래서 if의 타입은, boolT를 받아 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()와 정확히 같은 동작인데, 표현식을 클로저로 감싸 줄 필요가 없다는 점만 다르다.)

물론 보통은 elseif와 짝지어 쓴다. 타입 규칙으로 확인해 보면, 기대대로 잘 동작한다:

let abs_num = if (num > 0) num else -num;
                 --------  ---
                   bool    i32
              ----------------      ----
                Option<i32>         i32
              ---------------------------
                          i32

우리가 한 일을 보라: ifelse를 모두 _이항 연산자_로 바꾸었다.

선택적 andor

더 나아갈 수 있을까? andor는 어떨까 — 옵션에서 동작하게 할 수 있을까? 이들은 옵션을 받아 옵션을 반환하게 된다.

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)
}

좀 더 형식적으로, andor의 평가 규칙은 다음과 같다:

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>

(이는 andor가 Rust의 and_thenor_else와 동등함을 의미한다.)

수학적 직관이 좋은 사람이라면 andor의 타입 규칙 사이의 비대칭을 보고, 우리가 어떤 일반화를 놓치고 있다고 의심할지도 모른다. 걱정 마라, 곧 나온다.

그런데 불리언은?

잠깐! 그래도 if의 조건 안에서 andor를 쓸 수 있어야 한다. 예컨대 if (x >= 0 and x < len). and가 옵션을 만든다면 이건 동작하지 않는다!

하지만 쉬운 해결책이 있다. 불리언과 옵션 사이에는 다음과 같은 동치가 있다:

bool  = Option<()>
true  = Some(())
false = None

그래서 가상의 언어에서 불리언을 없애고, 항상 그에 상응하는 옵션을 대신 쓸 수 있다.

모두 합치기

이제 불리언이 없는 프로그래밍 언어가 어떤 모습일지 상상해 보자.

방금 boolOption<()>으로 치환한다고 했는데, 이 타입들은 동치이기 때문이다. 하지만 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

우리는 ifelse를 이항 연산자로 바꾸었으니, 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와 정확히 똑같이 동작한다! orelse보다 더 강하게 결합한다는 사실을 이용한다:

if (n > 0) 1 or if (n < 0) -1 else 0
    ------------    -------------
    -----------------------------
    ------------------------------------

처음엔 “WTF” 반응을 넘기기까지 좀 걸렸지만, 이제는 “or if”라고 말하는 게 꽤 마음에 든다.

truefalse

truefalse는 단지 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
}

더 적은 OkErr

Rust로 썼다면 결과를 OkErr 또는 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) 투기적 실행을 수반한다는 점에서 꽤 다르다. 이와 더 비슷한 것을 본 적이 있다면 알려 주시길.

2025년 9월 22일 Image 1