11. Absence, failure, and traps
Base separates three conditions: absence is data, T?; recoverable failure crosses a function boundary as T! with an Error; a trap is a violated invariant and is not catchable. There are no nullable references, no exceptions, and no error hierarchies.
11.1 Optionals#
func find(id: UserId) -> User*?
if let user = find(id):
show(user)
let count = parse(text) else 0 # fallback value
let n = parse(text) else trap("not a number") # assert with a reason
let w = create_window() else error(no_window, "no window") # absence becomes failurenone needs an expected optional type. T promotes to T? where expected; the reverse needs if let, match, or the three-arm else. Optionals are one layer (§5.8). There is no force-unwrap operator; else trap("reason") is the explicit spelling.
else also supplies the alternative for a fallible result (§11.2): let n = read() else 0 recovers from a failure the way it recovers from an absence, and read() else return or else trap(...) leaves. It handles the operand in place, so the enclosing function need not be fallible; when the failure value itself is needed, use catch, which binds it (§11.4). A fallible operand already under a surrounding try is propagated by that try, so try (next() else none) propagates a failure and takes the fallback only for an absence.
11.2 Fallible functions#
import luce_std.files
func load(path: c.str) -> Config!:
let data = try files.read(path)
defer data.release()
return try config.parse(data.value)T! means "returns T or an Error". One try expression checks all fallible operations evaluated within that expression, including receivers, arguments, constructors, operators, and conversions. For example, try combine(read(), parse()) needs one marker. The first failure stops the expression and forwards the same error to the nearest enclosing catch whose operand contains that operation, or to the current function, which must be fallible. A nonfallible caller must provide a handler. A try must cover at least one fallible operation.
Evaluation remains left to right, exactly once, including named arguments in source order; omitted defaults follow in declaration order. Lazy operators and conditional branches evaluate only the selected operands. A lambda or callback body starts a fresh failure context: creating one inside try does not authorize failures when it runs later. Cleanup runs only for scopes the failure actually leaves; a handler that recovers inside a scope does not trigger that scope's errdefer. try does not provide automatic rollback of earlier mutations.
T! is a result effect, not a storable type: it cannot be a parameter, field, or element; a program that must hold a result declares an enum with a success and a failure case. T?! is a fallible optional.
Representation: a T! is returned as the value and a flag, in registers where they fit, and a failure's Error travels beside them: the native backends leave it in the thread's core.failure, where try passes it up untouched, and return a result of up to four words in registers, a word holding one float in the next of the first four float result registers and every other word in the next of the first four integer ones (x0–x3 and d0–d3 on arm64; rax, rdx, rcx and r8 and xmm0–xmm3 on x86-64). A tuple of up to four words comes back the same way (§9.3). Exported fallible functions use the status form of §17.6.
11.3 Errors#
pub let not_found: ErrorCode = ErrorCode.package(1)
error(not_found, "configuration file does not exist")error(code, message) has type never and requires an enclosing failure destination: a fallible function or an outer handled operand. A handler can forward an error to that outer destination; it cannot catch its own failure. error(failure) forwards a caught failure whole, as Python's bare raise does in an except block. Error has four read-only fields: code: ErrorCode, message: str, at: str and called_at: str. ErrorCode is a package identity plus a u32. The identity is the package name from the manifest (§16.4), so codes never collide across packages; ErrorCode.package(n) may appear only as a top-level constant initialiser, and the compiler rejects two constants in one package with the same n. The message is a str view: a literal costs nothing, and a formatted message uses format on any buffer, a local one included, since error copies it.
The message's lifetime. error copies a message that is not a literal into storage the thread keeps for errors in flight, before any cleanup the failure runs, so the message may be made from storage that cleanup frees: text an outcome owns and a defer releases, a buffer the handler frees on its way out. The copy is valid until the handler that catches the error finishes: until its catch block ends by recover, by reaching its end, by return, break or continue, and until an else over the failure takes its alternative. A handler that passes a failure on, by error or by a failure leaving it, does not finish its error: the new message takes the old one's place, copied before the old one is given up, so error(failure.code, failure.message) and a message formatted from failure.message both read it whole, and the outer handler's copy lives as long as that handler. Errors raised and handled inside a handler, or inside the cleanup of a failure, leave the messages already in flight alone. A literal is not copied and lives as long as the program. A message needed after its handler finishes is copied by the handler.
The message stays in its handler. The compiler treats failure.message, and the failure itself, as a view whose lifetime is the handler, and rejects letting it leave uncopied: returning it; recovering it from its own handler (a handler nested in it may recover it, and that inner catch expression's value then views it); and storing it in a variable declared outside the handler, a global, self, or anything reached through a pointer or a view. The check follows the view as §6.6 follows a local's: through let names, fields, slices, &, tuples, arrays, constructions and enum cases, and through a store into a local declared in the handler, which then views it too; it stops at calls, with one exception: an argument of a generic type's method that changes its receiver, given for a parameter declared with the type's parameter, T or T?, is kept by the method, as List[str].push keeps it, so it goes only into a container declared in the handler. Literal messages are safe at runtime, but the compiler cannot know which failures carry one, so the rule is general. Passing the message to a function, formatting it, and raising it again are allowed; strings.copy(failure.message) keeps it.
Where it was raised. at is the position of the statement that raised the error, file:line:column, the text a trap at that statement names (§11.5): sources relative to the project root, or as they were given in a --debug build (on Windows with / between directories, as the compiler spells the paths it composes), and a position directive's source where one covers the statement (§3.3). error(code, message) raises at its own statement, and so does an error the language raises: memory.exhausted from new, invalid_utf8 from str(bytes), a format that does not fit its buffer. try, and a failure leaving a handler, pass the error up with its at unchanged; error(failure) keeps it too, while error(failure.code, failure.message) raises a new error where it stands. at is static text, so unlike the message it may outlive the handler. It is one position, not a trace: an error raised inside a library names the library's statement. called_at is the second position: the statement of the outermost call into another package that the error came out of, a try or a handler's operand calling a function or method of another package by its name, so an error a library raises names the caller's line there. A call through a function value or an interface names no package and records nothing; each call into another package further out replaces the one before, so a library's call into a nested one gives way to its own caller's; try, a failure leaving a handler and error(failure) in the same package keep it. An error that has not come out of such a call, and one raised anew by error(code, message), has called_at equal to at. A failure that escapes a fallible main is printed as error: file:line:column: message (code n), with exit status 1, and then called at file:line:column on a line of its own when called_at differs from at. at and called_at are read-only, and are not places: &failure.at is rejected, as for any temporary. Representation: the position texts sit in the program's read-only data, and the Error keeps their distances from a byte of core's: at's in the 32 bits after the code, which were padding, and called_at's in the upper 32 bits of the message's length, which is 32 bits (a message is at most 4 KiB, or a literal); writing a new message clears it. An Error stays three words and no result grows; reading at, called_at or the message makes the str. Recording at costs a few instructions where an error is raised, called_at a few on the failure branch of a call into another package, and neither costs anything on any other path.
The thread keeps 16 KiB of messages in flight, one message at most 4 KiB: a longer one is cut at a character boundary and ends in …, and when more than 16 KiB of messages are in flight at once the oldest are written over. Raising costs a copy of the message and no allocation. The test runner, a fallible main, and a fallible export (§17.6) are the handlers of the failures that reach them; a worker passes its failure to the thread that waits on it by a copy of its own.
11.4 catch and recover#
import luce_std.files
let text = settings_text(path) catch failure:
if failure.code == files.missing:
recover ""
error(failure)expression catch name: handles failures from evaluating its entire left expression, including nested fallible arguments and receivers, without extra try markers. It does not handle failures from its handler body or from a callback invoked later. A nested catch handles its own operand first; a failure from that nested handler goes to the outer handler or an explicitly fallible function. The handler must recover value, terminate with error or trap, return, or, inside a loop, break or continue. catch binds more loosely than any operator. Where a T? is expected and the handled value is a T, the catch expression is a T? and the handler may recover none: let n: i64? = parse(text) catch: recover none.
11.5 Traps#
trap(message) stops the program with a diagnostic and a source trace. A trap writes trap: file:line:column: message to standard error and exits with status 1: the position is that of the statement the program was running, the innermost one when the trap is inside a called function, and every execution of the language reports the same one; a position directive (§3.3) maps it to the source the statement was compiled from. The compiler inserts traps for: out-of-bounds indexing, checked overflow, division by zero, shift by width, a failed T(x) conversion, else trap, assert, a zero arriving in a bare pointer slot at a C boundary (§17.1), invalid UTF-8 in main's str[] arguments, new or alloc with no allocator set (§12.3), and stack exhaustion. Stack exhaustion is detected by a guard page that the startup shim installs; a --freestanding program that supplies its own _start installs its own or has none. Traps are never recoverable; defer does not run.
11.6 Assertions#
assert(condition) and assert(condition, message) trap when the condition is false, saying where and what: trap: file:line:column: assert failed: condition, with the message appended when given. The condition must be side-effect-free. Assertions are never removed by a build profile. An assert at module level, outside any function, is evaluated at compile time and its condition must be a constant expression; it is C's static_assert:
assert(memory.size_of(Header) == 32, "Header must match the wire format")11.7 Out of memory#
Out of memory is a recoverable memory.exhausted error from new (§12.2), not a fatal termination as in full Luce.
Why. Full Luce makes allocation infallible and out-of-memory fatal, because making every list append fallible would make every API fallible for a condition most hosts cannot recover from anyway. Base programs often run with a fixed memory budget: an allocator over a fixed buffer running out is an ordinary condition there, and the caller wrote the allocation call, so it can handle the failure.