Lowent 매뉴얼←↑→

11 답을 담는 타입 — option 과 result

먼저 알아야 할 것

7장 흐름 · guard 와 panic
10장 묶음 · 값을 지닌 enum 과 match

돌아보기

10장의 case rect w h . 는 무엇을 하는가? 그리고 match 가 갈래 하나를 빠뜨리면 어떻게 되는가?

답. 갈래가 rect 이면 그 갈래가 지닌 두 값을 w 와 h 에 묶는다. 갈래를 빠뜨리면 E-MATCH-INEXHAUSTIVE 로 거절된다. 이 장의 option 과 result 는 언어가 미리 만들어 둔 두 갈래짜리 열거형처럼 행동한다 — 값이 있거나 없거나, 성공이거나 실패거나.

이 장의 필요성과 맥락

실패를 −1 이나 널 포인터 같은 특별한 값으로 알리는 관습은 “이 −1 은 오류인가 그냥 −1 인가” 를 소스만 보고 알 수 없게 만든다. 그리고 확인을 빠뜨려도 아무도 모른다. option 과 result 는 그 물음을 타입으로 옮긴다. 데이터를 다루는 제3부의 한가운데에 이 장이 있는 것은, 조회·변환·파싱처럼 데이터를 꺼내는 거의 모든 op 이 “없을 수 있음” 이나 “실패할 수 있음” 을 돌려주기 때문이다.

이 장이 끝나면

option 을 some·none 으로 만들고, 묻고 꺼내거나 value_or 로 대신할 값을 주거나 match 로 가르는 법을 익힌다. 확인 없이 꺼내면 멈춘다는 것을 알게 된다. result 와 errors 절이 짝을 이루고, try 가 실패를 위로 넘기며, else_none·else_error 꼬리가 둘 사이를 건넌다는 것도 보게 된다. 실패를 말하는 세 길 — result·option·panic — 을 가르는 기준을 세우고, or 패턴·겹친 패턴·번역 시점에 접히는 match 도 보게 된다.

이 장에서 답할 질문

  1. value_or 의 기본값 자리에 비싼 계산을 적으면 매번 계산되는가?

11.1 option — 값이 있거나 없거나#

option t 는 t 값이 있거나(some v) 없거나(none) 둘 중 하나다. 뒤에서 볼 result 와 함께, 값을 담는 상자 두 종류로 그려 두면 구별이 쉽다.

 option u64         ┌──────────┐              result u64 e     ┌──────────────┐
                    │ some 20  │  값이 있다                     │ ok 20        │  값이 있다
                    ├──────────┤                               ├──────────────┤
                    │ none     │  비어 있다                     │ error bad    │  실패했다 ---
                    └──────────┘  (찾는 것이 없었을 뿐)          └──────────────┘  왜(bad)를 함께 든다

none 은 이상한 일이 아니라 «없었다» 는 정상적인 답이고, error 는 «하려던 일이 실패했다» 는 답이다. 어느 상자든 열어 보기 전에는 안의 값을 쓸 수 없다.

examples/ch11/lookup.low

module lookup .
rem run: find 2
rem run: find 7
rem run: find_or 7
rem run: find_asked 2
rem run: find_match 7
rem trap: find_raw 7

fn find input k u8 . output option u8 .
do
  guard lt k 3 . else return none .
  return some (mul k 10) .
end

fn find_or input k u8 . output u8 .
do
  return value_or (find k) 99 .
end

fn find_asked input k u8 . output u8 .
do
  let r option u8 be find k .
  guard is_some r . else return 255 .
  return some_value r .
end

fn find_match input k u8 . output u8 .
do
  match find k do
    case some v . do return v . end
    case none . do return 0 . end
  end
end

fn find_raw input k u8 . output u8 .
do
  return some_value (find k) .
end

실행 결과

$ lowentc --run find lookup.low 2
find(2) = some 20
$ lowentc --run find lookup.low 7
find(7) = none
$ lowentc --run find_or lookup.low 7
find_or(7) = 99
$ lowentc --run find_asked lookup.low 2
find_asked(2) = 20
$ lowentc --run find_match lookup.low 7
find_match(7) = 0
$ lowentc --run find_raw lookup.low 7
== ir diagnostics (1) ==
0:0 E-VM-NONE: some_value of none (panic)

만드는 쪽인 find 는 return none . 과 return some (mul k 10) . 으로 값을 싼다. VM 은 결과를 some 20 과 none 으로 보여 준다. 받는 쪽은 세 가지로 쓸 수 있다.

find_raw 는 묻지 않고 바로 꺼낸다. 번역은 통과하지만 값이 없는 7 에서 실행이 멈춘다(E-VM-NONE). 꺼내는 연산은 부분 연산이다. 어느 길에서 값이 있는지는 저자가 아는 것이고 처리기가 언제나 알 수는 없으므로 번역이 막지 않는다. 대신 틀렸을 때 조용하지 않다 — 0 을 내주고 계속 가지 않는다.

문. value_or 의 기본값 자리에 비싼 계산을 적으면 매번 계산되는가?

답. 아니다. 기본값은 값이 없을 때만 계산된다. value_or (some 7) (div 1 0) 은 7 이고 0 나누기는 일어나지 않는다. 그래서 기본값 자리에 실패할 수 있는 계산을 적어도 된다. 이 동작은 한때 반대였다가 명세에 맞게 고쳐졌다.

11.2 result 와 errors 절#

result t e 는 성공한 값(ok v) 또는 오류(error <갈래>) 중 하나다. 오류 타입 e 는 보통 enum 이다. 그리고 result 를 돌려주는 op 은 언제 어떤 오류를 내는지 errors 절에 적는다.

examples/ch11/halve.low

module halving .
rem run: halve 100
rem run: halve 250
rem run: halve 101
rem run: halve_plus_one 100
rem run: halve_plus_one 250
rem run: halve_or_zero 250

enum io_error do
  too_big .
  odd .
end

fn halve input a u8 . output result u8 io_error .
  errors too_big gt a 200 .
  errors odd ne (mod a 2) 0 .
do
  guard le a 200 . else return error too_big .
  guard eq (mod a 2) 0 . else return error odd .
  return ok (div a 2) .
end

fn halve_plus_one input a u8 . output result u8 io_error .
  errors too_big gt a 200 .
  errors odd ne (mod a 2) 0 .
do
  let v u8 be try halve a .
  return ok (add v 1) .
end

fn halve_or_zero input a u8 . output u8 .
do
  let r result u8 io_error be halve a .
  guard not (is_error r) . else return 0 .
  return ok_value r .
end

실행 결과

$ lowentc --run halve halve.low 100
halve(100) = ok 50
$ lowentc --run halve halve.low 250
halve(250) = err too_big
$ lowentc --run halve halve.low 101
halve(101) = err odd
$ lowentc --run halve_plus_one halve.low 100
halve_plus_one(100) = ok 51
$ lowentc --run halve_plus_one halve.low 250
halve_plus_one(250) = err too_big
$ lowentc --run halve_or_zero halve.low 250
halve_or_zero(250) = 0

halve 의 머리에는 오류가 둘 적혀 있다. errors too_big gt a 200 . 은 “a 가 200 보다 크면 too_big 을 낸다” 는 약속이다. 이 절은 나가는 쪽의 계약이다. 조건이 참인데 op 이 정상으로 돌아오거나, 적지 않은 오류를 돌려주면 계약 위반이다. 적지 않은 오류를 돌려주는 코드는 번역에서 거절된다.

examples/ch11/undeclared.low

module undeclared .
rem expect: E-ERR-UNDECLARED

enum parse_error do
  empty .
  too_long .
end

fn first_byte input s slice u8 . output result u8 parse_error .
  errors empty eq (len s) 0 .
do
  guard gt (len s) 0 . else return error empty .
  guard le (len s) 16 . else return error too_long .
  return ok (index s 0) .
end

실행 결과

$ lowentc --check undeclared.low
undeclared.low:13:0 E-ERR-UNDECLARED: op returns an error not in its errors clause

too_long 은 parse_error 의 갈래이지만 first_byte 의 errors 절에는 없다. 부르는 쪽은 머리만 보고 empty 만 다루면 된다고 믿을 것이다. 적은 것과 내는 것이 어긋나면 한쪽은 못 본 실패를, 다른 쪽은 오지 않을 실패를 다루게 된다.

11.3 try — 실패를 위로 넘긴다#

실패를 확인하는 코드를 매번 손으로 쓰면 길어지고, 길어지면 빼먹는다. try 는 result 를 받아 성공이면 값을 꺼내고, 실패면 그 오류를 그대로 돌려주며 op 을 떠난다. halve.low 의 halve_plus_one 이 그 모양이다.

let v u8 be try halve a .
return ok (add v 1) .

halve_plus_one 이 자기 머리에도 errors 를 적은 것에 주목한다. try 로 오류를 넘기려면 자기도 그 오류를 돌려줄 수 있어야 하고, 그 사실을 계약에 적어야 한다. 실패가 조용히 사라지는 길이 없다. 실패를 위로 넘기지 않고 이 자리에서 다루려면 halve_or_zero 처럼 is_error 로 묻고 ok_value 로 꺼낸다.

흔한 오해. try 는 예외 처리의 try 다

Java 나 C++ 의 try 는 블록 안 어디서든 던져진 예외를 잡는 자리다. Lowent 의 try 는 식 하나에 붙어서 그 식의 실패를 위로 넘기는 표시이고, Rust 의 ? 에 가깝다. 던지고 잡는 제어 흐름은 없다. 실패는 언제나 값으로 돌아오고, 어느 식에서 넘어갈 수 있는지가 소스에 적혀 있다.

11.4 두 채널 사이를 건넌다#

부르는 op 과 불리는 op 의 채널이 다를 때가 있다. try 뒤에 꼬리를 붙이면 담는 그릇을 바꾼다.

examples/ch11/tails.low

module tails .
rem run: maybe_half 100
rem run: maybe_half 250
rem run: must_find 2
rem run: must_find 9

enum lookup_error do
  too_big .
  not_found .
end

fn halve input a u8 . output result u8 lookup_error .
  errors too_big gt a 200 .
do
  guard le a 200 . else return error too_big .
  return ok (div a 2) .
end

fn find input k u8 . output option u8 .
do
  guard lt k 3 . else return none .
  return some (mul k 10) .
end

fn maybe_half input a u8 . output option u8 .
do
  return try (halve a) else_none .
end

fn must_find input k u8 . output result u8 lookup_error .
  errors not_found .
do
  return try (find k) else_error not_found .
end

실행 결과

$ lowentc --run maybe_half tails.low 100
maybe_half(100) = some 50
$ lowentc --run maybe_half tails.low 250
maybe_half(250) = none
$ lowentc --run must_find tails.low 2
must_find(2) = ok 20
$ lowentc --run must_find tails.low 9
must_find(9) = err not_found
모양방향무엇을 잃거나 얻나
try <식> else_noneresult → option오류를 버린다. 왜 실패했는지 더는 말하지 않는다
try <식> else_error <갈래>option → result없음에 이름을 붙인다

표 11.1 — try 의 꼬리로 채널을 바꾼다

꼬리를 붙인 try 는 타입도 바꾼다. try (halve a) else_none 의 타입은 option u8 이지 u8 이 아니다. 그래서 maybe_half 는 그것을 그대로 돌려주고, let v u8 be try … else_error … 처럼 값 타입에 담으려 하면 거절된다.

else_none 은 정보를 버리는 선택이다. 편해서 습관이 되기 쉬운데, 그 순간부터 호출자는 “왜” 를 물을 수 없다. 버릴 만한 자리에서만 버린다.

11.5 실패를 말하는 세 길#

무엇무엇을 말하나부르는 쪽이 하는 일
result t e고칠 수 있는 실패어느 쪽인지 묻고 다룬다. 안 다루면 위로 넘긴다
option t값이 없음있는지 묻고 꺼내거나, 대신 쓸 값을 준다
panic · 계약 위반약속이 깨졌다다룰 수 없다. 프로그램이 멈춘다

표 11.2 — 실패를 말하는 세 길

셋을 가르는 물음은 “부르는 쪽이 무엇을 할 수 있는가” 다. 파일이 없으면 다른 파일을 열어 볼 수 있으니 result 다. 찾는 것이 목록에 없으면 그냥 없는 것이니 option 이다. 부르는 쪽이 계약을 어겼다면 이미 약속이 깨진 것이라 고칠 수 있는 일이 아니고, 그래서 멈춘다. panic 은 되돌아 풀리지 않는다. 중간에 잡아 이어 가는 길은 없다. 이 기준으로 op 의 실패를 설계하는 법은 17장에서 다시 다룬다.

11.6 패턴을 묶고 겹친다#

option·result·enum 을 모두 보았으니 match 의 패턴을 더 넓게 쓸 수 있다.

examples/ch11/patterns.low

module patterns .
rem run: warm 1
rem run: combine 1
rem run: pick 1
rem run: choose
rem run: half 200

enum color do
  red .
  green .
  blue .
end

enum node do
  lit v u32 .
  plus l u32 r u32 .
  times l u32 r u32 .
end

rem 어느 하나라도 맞으면 그 갈래 --- red·green·blue 를 모두 덮으므로 _ 가 필요 없다
fn warm input k u8 . output u8 . do
  let c color be green .
  match c do
    case red or green . do return 1 . end
    case blue . do return 2 . end
  end
end

rem or 로 묶은 갈래는 같은 이름을 묶어야 한다
fn combine input k u8 . output u32 . do
  let e node be node.times 6 7 .
  match e do
    case plus l r or times l r . do return add l r . end
    case lit v . do return v . end
  end
end

rem result 안의 option 을 한 번에 가른다
fn pick input k u8 . output u32 . do
  let r result option u32 color be ok (some 42) .
  match r do
    case ok (some x) . do return x . end
    case ok none . do return 0 . end
    case error . do return 99 . end
  end
end

rem 가르는 값이 번역 시점 상수면 맞는 갈래 하나로 접힌다
fn choose output u32 . do
  match comptime (add 2 3) do
    case 0 . do return 100 . end
    case 5 . do return 500 . end
    case _ . do return 999 . end
  end
end

rem 두 범위가 u8 전체를 빈틈 없이 덮으므로 _ 가 필요 없다
fn half input b u8 . output u8 . do
  match b do
    case 0 to 127 . do return 0 . end
    case 128 to 255 . do return 1 . end
  end
end

실행 결과

$ lowentc --run warm patterns.low 1
warm(1) = 1
$ lowentc --run combine patterns.low 1
combine(1) = 13
$ lowentc --run pick patterns.low 1
pick(1) = 42
$ lowentc --run choose patterns.low
choose() = 500
$ lowentc --run half patterns.low 200
half(200) = 1

_ 뒤에 갈래를 두면 거절된다.

examples/ch11/arm_after_wild.low

module arm_after_wild .
rem expect: E-MATCH-REDUNDANT

enum light do
  lit .
  dark .
end

fn name input k u8 . output u8 . do
  let s light be lit .
  match s do
    case _ . do return 0 . end
    case lit . do return 1 . end
  end
end

실행 결과

$ lowentc --check arm_after_wild.low
arm_after_wild.low:13:0 E-MATCH-REDUNDANT: this `case` comes AFTER a `_` (wildcard) arm — the wildcard already matched, so this arm can never run. A dead arm is an error here, not a warning (RFC-0020 §6.4)

_ 가 이미 모두 받았으므로 뒤의 갈래는 영영 돌지 않는다. 죽은 갈래는 경고가 아니라 오류다 — 읽을 때 각 경우가 한 번씩이 아닌 match 는 결함을 숨긴다.

11.7 흔한 실수#

반례. option 을 수처럼 계산에 넣는다

examples/ch11/mistake_optarith.low

module mistake_optarith .
rem expect: E-TYPE-RETURN

fn find input k u64 . output option u64 .
do
  guard lt k 5 . else return none .
  return some (mul k 10) .
end

fn plus_one input k u64 . output u64 .
do
  rem ✘ `option u64` 는 수가 아니다 --- 있는지 먼저 묻고 꺼내야 더할 수 있다
  return add (find k) 1 .
end

실행 결과

$ lowentc --check mistake_optarith.low
mistake_optarith.low:13:0 E-TYPE-RETURN: the returned value does not match the op's `output` — expected `u64`, found `option …`

find k 가 돌려주는 것은 u64 가 아니라 “u64 가 있을 수도 없을 수도 있는 상자” 다. 상자에 1 을 더할 수는 없다. 다른 언어라면 없음(null)이 계산 속으로 흘러 들어가 한참 뒤에 터지지만, Lowent 는 여기서 E-TYPE-RETURN 으로 멈춰 세운다. 고치는 길은 셋이다. value_or (find k) 0 으로 대신할 값을 주거나, is_some 으로 묻고 some_value 로 꺼내거나, match 로 가른다. 어느 것을 고를지는 “없을 때 무엇을 해야 하는가” 가 정한다.

반례. option 을 돌려주는 op 에서 some 을 빠뜨린다

examples/ch11/mistake_nosome.low

module mistake_nosome .
rem expect: E-TYPE-RETURN

fn find input k u64 . output option u64 .
do
  guard lt k 5 . else return none .
  rem ✘ 값을 `some` 으로 싸지 않았다
  return mul k 10 .
end

실행 결과

$ lowentc --check mistake_nosome.low
mistake_nosome.low:8:0 E-TYPE-RETURN: the returned value does not match the op's `output` — expected `option …`, found `u64`

머리에 output option u64 라고 적었으면 돌려주는 값도 상자여야 한다. none 은 상자이지만 mul k 10 은 맨 수라서 E-TYPE-RETURN 이다. 몇몇 언어는 값을 알아서 감싸 주지만 Lowent 는 감싸지 않는다. return some (mul k 10) . 처럼 “있다” 를 적어야 읽는 사람이 두 갈래를 모두 본다.

반례. result 를 돌려주지 않는 op 에서 try 를 쓴다

examples/ch11/mistake_trynoresult.low

module mistake_trynoresult .
rem expect: E-TRY-NORESULT

enum half_error do
  too_big .
end

fn halve input a u8 . output result u8 half_error .
errors too_big gt a 200 .
do
  guard le a 200 . else return error too_big .
  return ok (div a 2) .
end

rem ✘ `try` 를 썼는데 이 op 은 `result` 를 돌려주지 않고 `errors` 절도 없다
fn plus input a u8 . output u8 .
do
  let v u8 be try halve a .
  return add v 1 .
end

실행 결과

$ lowentc --check mistake_trynoresult.low
mistake_trynoresult.low:18:0 E-TRY-NORESULT: `try` may only be used in an op that can RETURN that error (§6.5.8(2)): this op's output is not a `result`, so there is nowhere for the failure to go. It used to pass, and the VM then returned the ERROR VALUE in the plain output slot while the native build would not compile at all. Declare `output result <T> <E> .` (and the matching `errors` clause), or handle the failure here (`guard is_ok …` / `value_or` / a `case error` arm)

try 는 실패를 위로 넘긴다. 그러려면 이 op 도 실패를 돌려줄 수 있어야 한다(§6.5.8(2)). 그런데 plus 는 u8 만 돌려주고 errors 절도 없어서 E-TRY-NORESULT 로 거절된다. 2026-09-16 까지는 통과했고, plus 250 은 u8 자리에 err too_big 을 냈으며 네이티브는 C 컴파일 단계에서 지어지지도 않았다. 꼬리를 붙여 채널을 바꾸거나(else_none·else_error), is_ok 처럼 여기서 다루면 result 를 돌려주지 않아도 된다. 실패를 위로 넘길 생각이라면 머리를 result 와 errors 로 맞춘다.

examples/ch11/trynoresult_fixed.low

module trynoresult_fixed .
rem run: plus 10
rem run: plus 250

enum half_error do
  too_big .
end

fn halve input a u8 . output result u8 half_error .
errors too_big gt a 200 .
do
  guard le a 200 . else return error too_big .
  return ok (div a 2) .
end

rem 실패를 위로 넘기는 op 은 자기도 result 를 돌려주고, 넘길 수 있는 오류를 errors 에 적는다
fn plus input a u8 . output result u8 half_error .
errors too_big gt a 200 .
do
  let v u8 be try halve a .
  return ok (add v 1) .
end

실행 결과

$ lowentc --run plus trynoresult_fixed.low 10
plus(10) = ok 6
$ lowentc --run plus trynoresult_fixed.low 250
plus(250) = err too_big

겹친 패턴도 망라에 센다. 바깥 꼬리표는 안쪽이 스스로 빠짐없을 때 덮인 것으로 세어진다.

examples/ch11/nestedwild.low

module nestedwild .
rem run: via_match

enum read_error do
  broken .
end

fn pick input r result (option u64) read_error . output u64 .
do
  rem 겹친 패턴 셋이 모든 경우다 --- `_` 를 따로 두지 않는다
  match r do
    case ok (some x) . return x .
    case ok none . return 0 .
    case error e . return 1 .
  end
end

fn via_match output u64 .
do
  return pick (ok (some 42)) .
end

실행 결과

$ lowentc --run via_match nestedwild.low
via_match() = 42

ok (some x) 와 ok none 이 함께 ok 를 덮고 error e 가 나머지를 덮으므로 _ 가 필요 없다. 하나라도 빠지면 E-MATCH-INEXHAUSTIVE 로 거절된다 — 쓸모없는 _ 를 두는 것보다 낫다. _ 는 나중에 갈래가 늘어도 아무 말을 하지 않는다.

흔한 오해. value_or 를 쓰면 없는 경우도 알 수 있다

examples/ch11/valueor_blind.low

module valueor_blind .
rem run: find_or_zero 0
rem run: find_or_zero 7
rem run: found 0
rem run: found 7

fn find input k u8 . output option u8 .
do
  guard lt k 3 . else return none .
  return some (mul k 10) .
end

rem 0 번 칸에는 진짜 0 이 있고, 7 번 칸은 없다 --- 그런데 둘 다 0 으로 보인다
fn find_or_zero input k u8 . output u8 .
do
  return value_or (find k) 0 .
end

rem 있는지를 알고 싶으면 대신할 값으로 덮기 전에 묻는다
fn found input k u8 . output bool .
do
  return is_some (find k) .
end

실행 결과

$ lowentc --run find_or_zero valueor_blind.low 0
find_or_zero(0) = 0
$ lowentc --run find_or_zero valueor_blind.low 7
find_or_zero(7) = 0
$ lowentc --run found valueor_blind.low 0
found(0) = 1
$ lowentc --run found valueor_blind.low 7
found(7) = 0

value_or 는 없음을 대신할 값으로 덮는다. 덮는 값이 진짜 값과 겹치면 둘을 가를 수 없다. 위에서 0 번 칸의 진짜 0 과 7 번 칸의 없음이 똑같이 0 이다. “없으면 0 으로 쳐도 된다” 가 맞는 자리에서만 value_or 를 쓰고, 있는지가 중요하면 덮기 전에 is_some 이나 match 로 묻는다.

11.8 이 장의 문법 한눈에#

모양뜻왜 이렇게
output option u8 .값이 있거나 없다없음(null)을 타입에 드러낸다
some v · none있다 · 없다“있다” 도 적어야 두 갈래가 모두 보인다
output result u8 e .값이거나 오류(e 의 갈래)실패도 값으로 돌려준다 — 예외가 없다
ok v · error too_big성공 · 실패어느 갈래인지 소스에 적힌다
errors too_big <조건> .어떤 오류가 언제 나는지 약속계약에 적어 부르는 쪽이 대비한다
is_some r · some_value r있는지 묻기 · 꺼내기꺼내기는 부분 연산 — 묻고 꺼낸다
is_error r · ok_value r실패인지 묻기 · 성공 값 꺼내기같은 이유
value_or r 99없으면 대신할 값한 줄로 끝나지만 없음을 덮는다
try <식>실패면 그 오류를 돌려주며 떠난다확인 코드를 빼먹지 않게 — Rust 의 ?
try <식> else_none · else_error eresult → option · option → result채널을 바꿀 때 무엇을 잃는지 드러낸다
case ok (some x) . · case a or b .겹친 패턴 · 여러 갈래 묶기한 번에 가르되 모든 경우를 덮는다

표 11.3 — 답을 담는 타입의 문법 — 모양 · 뜻 · 왜 이렇게 생겼나

복습 정리

option 은 some·none, result 는 ok·error 로 만든다. 받는 쪽은 묻고 꺼내거나(is_some·some_value · is_error·ok_value), value_or 로 대신할 값을 주거나, match 로 가른다. 꺼내기는 부분 연산이라 없는 쪽을 꺼내면 멈춘다. result 를 돌려주는 op 은 errors 절로 오류를 약속하고, try 는 실패를 위로 넘기며, else_none·else_error 꼬리는 채널과 타입을 바꾼다. 패턴은 or 로 묶고(같은 이름을 묶는다) 겹쳐 쓸 수 있으며, 상수를 가르는 match 는 번역에서 접힌다.