Skip to content

Lesson 10.7 — Error handling: Error/Expected vs exceptions vs std::expected

Techniques: llvm::Error and llvm::Expected<T> (must-check semantics, handleErrors/handleAllErrors, cantFail, ExitOnError, joinErrors, custom ErrorInfo), C++ exceptions (zero-cost unwinding tables), std::expected<T, E> (the course's own convention, pebble::Error) · Pebble uses: std::expected<T, pebble::Error> for its own APIs and llvm::Expected wherever LLVM returns one (parsing, ORC) · Lab: Lab 10.2 (R1), Lab 10.3 (R5–R7) · Prerequisites: Lesson 10.4 (the ErrorInfo hierarchy uses the same RTTI idea) · Time: 2–3 hours

parseIRFile can fail. So can LLJITBuilder().create(), lookup("main") and reading a bitcode file. LLVM is compiled with -fno-exceptions (llvm-config --cxxflags ends in -fno-exceptions), so failures come back as values, and a value can be ignored. llvm::Error makes ignoring impossible to miss: in a build with assertions it aborts the program at the exact point where an unhandled error is dropped. This lesson makes that discipline precise and compares it with exceptions and with the standard library's std::expected, which the Pebble code uses.

1. Problem and motivation

Compilers must report recoverable failures (a missing file, bad input, an unsupported target) with enough structure for callers to react. They must do it without slowing the common path and without letting a failure pass unnoticed. Unchecked error codes (C's errno, bool returns) lose information and are easy to ignore. Exceptions cannot be ignored, but they have a runtime and binary-size cost that LLVM refuses [LLVM-CS]. LLVM's answer (2016) was an error value with a checked bit [LLVM-PM].

Error and Expected

llvm::Error is a pointer-sized value that is either success or a (list of) typed error payloads (ErrorInfo subclasses). llvm::Expected<T> is a T or an Error. Both carry a checked flag in builds with LLVM_ENABLE_ABI_BREAKING_CHECKS. Destroying or overwriting a value that was never checked calls fatalUncheckedError(). Handlers dispatch on the payload's dynamic type with LLVM-style RTTI (ErrorInfo::isA) [LLVM-PM, LLVM-Error].

C++ exceptions

throw unwinds the stack to the nearest matching catch, running destructors on the way. With the Itanium ABI's zero-cost model the non-throwing path executes no extra instructions: the unwinder consults tables (.eh_frame, LSDA) only when something is thrown [ItaniumEH, LLVM-EH]. The costs are the tables, the inhibited optimizations around invoke, and very expensive throws. LLVM's coding standards ban exceptions, and a program that links LLVM usually builds with -fno-exceptions too [LLVM-CS].

std::expected

C++23's std::expected<T, E> [P0323] is the standard library's value-or-error type, with monadic helpers (and_then, transform, or_else). It has no checked flag. The only protection against ignoring it is [[nodiscard]] and the call site's discipline. The Pebble course code uses std::expected<T, pebble::Error> (pebble/include/pebble/Support/Error.h) for its own APIs, and converts at the boundary with LLVM.

2. Definitions and algorithms

Definition 10.7.1 (Error value, payload, checked bit)

An Error is either success or a failure holding a payload: one ErrorInfoBase object, or an ErrorList of several. An Expected<T> holds either a T or a failure payload. In builds with LLVM_ENABLE_ABI_BREAKING_CHECKS = 1, each Error and Expected also carries a bit \(\mathit{checked} \in \{0, 1\}\).

Definition 10.7.2 (Must-check discipline)

The operations change the state (payload, checked) as follows (llvm/include/llvm/Support/Error.h):

operation effect on the value \(e\)
construction (from a payload, Error::success(), a T, an Error) checked \(\leftarrow 0\)
(bool)e on an Error checked \(\leftarrow\) (e is success)
(bool)e on an Expected checked \(\leftarrow\) (e holds a value)
e.takeError() on an Expected checked \(\leftarrow 1\); returns an unchecked Error
move-construct from \(e\) \(e\): payload \(\leftarrow\) null, checked \(\leftarrow 1\); the new value is unchecked
move-assign into \(f\) asserts \(f\) is checked; then as move-construct
*e, e.get(), e-> on an Expected asserts \(e\) is checked
destruction asserts \(e\) is checked; for an Error, also that it holds no payload
handleErrors, handleAllErrors, consumeError, toString, logAllUnhandledErrors, cantFail, ExitOnError consume the Error (it arrives moved-in)

A program is must-check safe if no execution reaches an asserting operation in a state that fails the assertion.

Algorithm 10.7.3 (handleErrors)

  • Input: an Error \(E\) and handlers \(h_1, \dots, h_m\), each accepting one ErrorInfo subclass \(C_i\) (by reference or by unique_ptr), and returning void or Error.
  • Output: an Error: success if every payload was handled, otherwise the unhandled payloads (and any errors the handlers returned).
  • Precondition: each handler's parameter type is an ErrorInfo class.
  • Postcondition: each payload was offered to the handlers in order and taken by the first \(h_i\) with \(\mathrm{payload} \sqsubseteq C_i\) (isA). \(E\) has been consumed.
  • Invariant: the result collects exactly the payloads not yet handled plus the handlers' returned errors.
function HandleErrors(E, h₁ … hₘ):
    if E is success: return success
    P ← take the payload(s) of E                    # E becomes checked, empty
    R ← success
    for p in (P is an ErrorList ? P.payloads : [P]):
        handled ← false
        for i in 1 .. m:
            if p.isA(C_i):                          # walks p's class chain (ErrorInfo<ThisT, ParentT>)
                R ← join(R, h_i(p)); handled ← true; break
        if not handled: R ← join(R, p)
    return R
function HandleAllErrors(E, h₁ … hₘ):
    cantFail(HandleErrors(E, h₁ … hₘ))              # leftovers → llvm_unreachable

Error and Expected

A custom ErrorInfo, handleAllErrors, cantFail and ExitOnError

Reproduce (clang 23.1.2, LLVM 23.1.2, Linux x86-64):

cd chapters/10-llvm-cpp-api/examples
clang++-23 $(llvm-config --cxxflags) -std=c++23 err.cpp $(llvm-config --ldflags --libs) \
  -Wl,-rpath,$(llvm-config --libdir) -o err
./err; echo "exit status $?"

err.cpp defines BadDigitError : ErrorInfo<BadDigitError> and Expected<unsigned> parseDecimal(StringRef).

Output (complete; the first line is written to stderr):

err: bad digit 'z' at 1
"123" -> 123
"" -> other error: empty string
"12x4" -> BadDigitError at 2
cantFail -> 42
ExitOnError -> 7
exit status 1

What to notice:

  • handleAllErrors with handlers (const BadDigitError &) and (const ErrorInfoBase &) routed each payload to the first handler whose type it isA (Algorithm 10.7.3). The StringError from createStringError went to the catch-all.
  • cantFail unwrapped a value that cannot fail.
  • ExitOnError printed its banner and the message, then called exit(1), so the line not reached never ran.

The checks exist only in ABI-breaking-checks builds

Reproduce (the LLVM 23.1.2 install used in this course):

grep -n "void assertIsChecked()" -A6 $(llvm-config --includedir)/llvm/Support/Error.h
llvm-config --assertion-mode
grep -n "define LLVM_ENABLE_ABI_BREAKING_CHECKS" $(llvm-config --includedir)/llvm/Config/abi-breaking.h

Output (complete):

268:  void assertIsChecked() {
269-#if LLVM_ENABLE_ABI_BREAKING_CHECKS
270-    if (LLVM_UNLIKELY(!getChecked() || getPtr()))
271-      fatalUncheckedError();
272-#endif
273-  }
274-
--
727:  void assertIsChecked() const {
728-#if LLVM_ENABLE_ABI_BREAKING_CHECKS
729-    if (LLVM_UNLIKELY(Unchecked))
730-      fatalUncheckedExpected();
731-#endif
732-  }
733-
OFF
19:#define LLVM_ENABLE_ABI_BREAKING_CHECKS 0

What to notice: the first match is Error's check (unchecked, or still holding a payload), the second is Expected<T>'s (only the Unchecked flag). Both are compiled only when LLVM_ENABLE_ABI_BREAKING_CHECKS is 1, which is the default for assertion builds. This install is a Release build (assertion-mode OFF), so an unchecked Error here is silently leaked: its payload is deleted but no one is told. Develop against an assertions-enabled LLVM (or at least run your tests against one) to get Theorem 10.7.8's guarantee. The setting must match the library (abi-breaking.h emits a link-time check symbol), which is why you cannot simply flip it in your own code.

C++ exceptions

Definition 10.7.4 (Zero-cost exception handling)

Under the Itanium C++ ABI, a function containing calls that may throw lowers each such call to an invoke with a normal destination and an unwind destination. The unwind destination begins with a landingpad that receives the exception object and a selector, and the function names a personality routine. The compiler emits, per function, unwind information (.eh_frame) and a language-specific data area (LSDA) mapping call-site ranges to landing pads and catch clauses. throw calls __cxa_throw, which runs the two-phase unwinder: phase 1 searches for a handler using the tables, and phase 2 unwinds frame by frame, running cleanups [ItaniumEH, LLVM-EH].

Algorithm 10.7.5 (Two-phase unwinding, outline)

  • Input: a thrown exception object and the current call stack.
  • Output: control transfers to the matching catch, or std::terminate is called.
  • Precondition: every frame on the stack has unwind tables (or is marked nounwind and cannot be unwound through).
  • Postcondition: every frame between the thrower and the catcher has run its cleanups (destructors) exactly once.
  • Invariant: phase 1 does not modify any frame.
function Throw(x):
    # phase 1: search
    for frame f from the thrower outward:
        lp ← LSDA(f).lookup(call site of f)
        if lp has a catch clause matching type(x): target ← f; break
    if no target: std::terminate()
    # phase 2: cleanup
    for frame f from the thrower up to target:
        if LSDA(f) has a cleanup for the call site: run it (a landingpad that resumes)
        pop f
    jump to target's catch landingpad with x

Exceptions become invoke/landingpad; std::expected becomes ordinary data flow

Reproduce (clang 23.1.2 with libstdc++ 14, Linux x86-64):

cd chapters/10-llvm-cpp-api/examples
clang++-23 -std=c++23 -O2 -S -emit-llvm -fno-discard-value-names eh.cpp -o - | sed -n '/^define/,/^}/p'

eh.cpp calls int parseOrThrow(const char *) inside try/catch (...), and std::expected<int, int> parseOrError(const char *) with an explicit test.

Output (complete):

define dso_local noundef range(i32 -2147483647, -2147483648) i32 @_Z13viaExceptionsPKc(ptr noundef %S) local_unnamed_addr #0 personality ptr @__gxx_personality_v0 {
entry:
  %call = invoke noundef i32 @_Z12parseOrThrowPKc(ptr noundef %S)
          to label %invoke.cont unwind label %lpad

invoke.cont:                                      ; preds = %entry
  %add = add nsw i32 %call, 1
  br label %return

lpad:                                             ; preds = %entry
  %0 = landingpad { ptr, i32 }
          catch ptr null
  %1 = extractvalue { ptr, i32 } %0, 0
  %2 = tail call ptr @__cxa_begin_catch(ptr %1) #2
  tail call void @__cxa_end_catch()
  br label %return

return:                                           ; preds = %lpad, %invoke.cont
  %retval.0 = phi i32 [ %add, %invoke.cont ], [ -1, %lpad ]
  ret i32 %retval.0
}
define dso_local noundef range(i32 -2147483647, -2147483648) i32 @_Z11viaExpectedPKc(ptr noundef %S) local_unnamed_addr #0 {
entry:
  %call = tail call i64 @_Z12parseOrErrorPKc(ptr noundef %S)
  %0 = and i64 %call, 4294967296
  %loadedv.i.not = icmp eq i64 %0, 0
  %R.sroa.0.0.extract.trunc = trunc i64 %call to i32
  %add = add nsw i32 %R.sroa.0.0.extract.trunc, 1
  %cond = select i1 %loadedv.i.not, i32 -1, i32 %add
  ret i32 %cond
}

What to notice:

  • Exceptions: the happy path is invoke → add → ret, with no test (Definition 10.7.4). The error path lives in lpad, reached only by the unwinder. The function also names a personality routine, which is where the table cost comes from.
  • std::expected: returns an i64 whose bit 32 is the "has value" flag. The caller tests it and selects, a compare and a select on every call, but with no tables and a cheap failure path. llvm::Expected compiles the same way, with the checked bit on top in checking builds.

std::expected

Definition 10.7.6 (std::expected)

std::expected<T, E> holds a T (has_value()) or an E (error(), wrapped on construction as std::unexpected<E>). *x requires has_value() (undefined behavior otherwise). x.value() throws bad_expected_access<E> if there is no value (with -fno-exceptions, libstdc++ aborts). The monadic operations and_then(f), transform(f), or_else(g) and transform_error(g) propagate the error automatically. The class is declared [[nodiscard]] in libstdc++ and libc++, but it has no checked state: an ignored error is not detected at run time [P0323].

Algorithm 10.7.7 (Propagating std::expected through a pipeline)

  • Input: fallible steps \(f_1, \dots, f_k\) with \(f_i : A_{i-1} \to \texttt{expected}\langle A_i, E \rangle\), and an input \(a_0\).
  • Output: \(\texttt{expected}\langle A_k, E \rangle\).
  • Precondition: none.
  • Postcondition: the value \(f_k(\dots f_1(a_0))\) if every step succeeds; otherwise the first error.
  • Invariant: after step \(i\), the accumulator holds \(A_i\) or the first error.
function Pipeline(a₀, f₁ … f_k):
    x ← expected(a₀)
    for i in 1 .. k:
        x ← x.and_then(f_i)       # if x has an error, f_i is skipped and the error is kept
    return x

The same function with llvm::Expected is written with early returns: auto A1 = f1(a0); if (!A1) return A1.takeError(); ….

The course's own convention: std::expected

Reproduce (this repository):

grep -n "std::expected" pebble/include/pebble/Support/Error.h pebble/include/pebble/CodeGen/PIRToLLVM.h

Output (complete):

pebble/include/pebble/Support/Error.h:9:/// failures as values: `std::expected<T, pebble::Error>`. An Error is just a
pebble/include/pebble/Support/Error.h:15:///   std::expected<int, Error> parseCount(std::string_view S) {
pebble/include/pebble/Support/Error.h:47:/// Builds the `std::unexpected` for a failing `std::expected<T, Error>`,
pebble/include/pebble/CodeGen/PIRToLLVM.h:70:std::expected<std::unique_ptr<llvm::Module>, Error>

What to notice: Pebble uses std::expected with a plain message type for its own APIs, such as lowering PIR to an LLVM module (Ch 11). It meets LLVM's Expected at the boundary: when Pebble calls parseIRFile or LLJIT::lookup, it converts with toString(E.takeError()), which consumes the LLVM error (Definition 10.7.2).

3. Worked example

parseDecimal("12x4") returns an Expected<unsigned> holding a BadDigitError{Digit='x', Pos=2}. We follow it through the three styles, including the states of Definition 10.7.2.

Error and Expected

step code value checked note
1 Expected<unsigned> R = parseDecimal("12x4") failure 0 constructed unchecked
2 if (R) failure 0 the bool test leaves a failure unchecked
3 R.takeError() \(R\): empty 1 \(R\) is now safe to destroy
3′ (the returned Error) failure 0 the obligation moved into the new Error
4 handleAllErrors(std::move(E), h₁, h₂) \(E\): empty 1 moved into the parameter
5 payload isA(BadDigitError)? yes: \(h_1\) runs, prints BadDigitError at 2
6 handleErrors result success 0 → 1 cantFail tests it: checked

If step 3 were missing, \(R\) would be destroyed at the end of the scope with checked = 0, and a checking build would abort with "Expected must be checked before access or destruction".

C++ exceptions

With parseOrThrow("12x4") throwing BadDigit{'x', 2} from inside viaExceptions:

  1. __cxa_throw allocates the exception object and starts phase 1 (Algorithm 10.7.5).
  2. The frame of parseOrThrow has no handler. The frame of viaExceptions has call site %call covered by lpad with catch ptr null (catch (...)), so phase 1 stops there.
  3. Phase 2 pops parseOrThrow (running its destructors) and enters lpad, which calls __cxa_begin_catch/__cxa_end_catch and returns \(-1\).

std::expected

viaExpected("12x4"): parseOrError returns std::unexpected(2), encoded in the i64 return value with bit 32 clear. The test %loadedv.i.not is true, so the select returns \(-1\). There are no tables and no unwinding, only one compare and one select.

Try it

./course drill must-check --seed 3 --difficulty hard gives snippets to classify as checked or unchecked, with the rule for each.

4. Invariants and correctness

Error and Expected

Theorem 10.7.8 (Unhandled errors cannot go unnoticed in checking builds)

In a build with LLVM_ENABLE_ABI_BREAKING_CHECKS = 1, every execution that destroys or overwrites a failure Error/Expected that no consuming operation received, or that dereferences an Expected never tested, aborts at that operation. Conversely, a must-check-safe program (Definition 10.7.2) never aborts because of these checks.

Proof

Consider a failure value \(e\). By Definition 10.7.2, the only transitions that set checked = 1 while \(e\) keeps its payload are none. (bool)e sets checked to "is success", which is 0 for a failure, and every transition that sets checked = 1 also removes the payload (takeError, moves, takePayload inside the consuming functions). So if \(e\) reaches destruction or move-assignment still holding its payload, assertIsChecked sees a payload (getPtr() != null) and calls fatalUncheckedError(), the "or holds a payload" disjunct in the real-world box. A failing Expected is checked by its Unchecked flag alone, but the only transitions that clear that flag on a failure are takeError and moves, and both take the error out, so destroying it with the error still inside reaches fatalUncheckedExpected(). For a success value, destruction requires checked = 1, which only a test or a move sets, so an untested success also aborts (the drill's "discard" case). For Expected, get()/* assert checked = 1. The converse is the definition of must-check safety: if no asserting operation ever sees a failing state, none of them aborts.

The check is dynamic: it catches the path your tests execute, not every possible path. A static analysis (for example, clang-tidy's bugprone-unused-return-value together with [[nodiscard]] on Error) covers some of the rest.

Proposition 10.7.9 (Handler dispatch follows the class chain)

p.isA(C) is true iff \(\mathrm{dyn}(p) \sqsubseteq C\) in the ErrorInfo hierarchy, and it costs \(O(\mathrm{depth})\) comparisons of class-ID pointers.

Proof

ErrorInfo<ThisT, ParentT>::isA(ID) returns ID == classID() || ParentT::isA(ID), and ErrorInfoBase::isA compares with its own ID (llvm/include/llvm/Support/Error.h). Each class's ID is the address of its own static char ID, which is unique per class. By induction on the chain from \(\mathrm{dyn}(p)\) to ErrorInfoBase, the disjunction is true exactly when \(C\)'s ID occurs on the chain, which means \(C\) is an ancestor-or-self. Each step is one pointer comparison.

C++ exceptions

Proposition 10.7.10 (No work on the non-throwing path)

With table-based (zero-cost) EH, a call that does not throw executes the same instructions whether or not it is inside a try: invoke lowers to an ordinary call instruction, plus table entries.

Proof sketch (full description: [ItaniumEH], [LLVM-EH])

invoke differs from call only in having an unwind edge. Code generation emits a normal call and records the call site's address range and landing pad in the LSDA. The personality routine reads the LSDA only during unwinding. On a normal return, control continues at the normal successor with no test. The real-world box shows the IR, whose invoke.cont path has no test; its machine code is the same call. The costs are indirect: landing pads constrain block placement, and values live across an invoke must be available on both edges.

std::expected

Proposition 10.7.11 (and_then propagates the first error)

Algorithm 10.7.7 returns \(f_k \circ \dots \circ f_1(a_0)\) if all steps succeed and otherwise the error of the first failing step, and it calls no \(f_j\) after the first failure.

Proof

By induction on \(i\), with the loop invariant. x.and_then(f) is defined as x.has_value() ? f(*x) : expected(unexpect, x.error()) ([P0323], [expected.object.monadic]). If \(x\) holds a value, the step computes \(f_i\) of it. If it holds an error, \(f_i\) is not called and the same error is carried forward. So the first error, once present, is returned unchanged.

5. Complexity

Technique Success path Failure path Space Notes
Error/Expected one test per call (branch or select) heap-allocated payload; handler dispatch \(O(m \cdot \mathrm{depth})\) (Proposition 10.7.9) one pointer (Error); max(sizeof(T), pointer) + flags (Expected) checked bit only in checking builds
C++ exceptions 0 extra instructions (Proposition 10.7.10) allocation + two-phase unwind: \(O(\text{frames} \times \text{LSDA lookup})\), typically microseconds unwind tables and LSDA per function (a few percent of code size) a throw costs ~\(10^3\)–\(10^4\) times a test
std::expected one test per call none beyond constructing \(E\) max(sizeof(T), sizeof(E)) + a flag no run-time checking

Variables: \(m\) = number of handlers, \(\mathrm{depth}\) = depth of the ErrorInfo class chain.

Pathological input. An error-heavy workload, such as a fuzzer feeding malformed files so that most parses fail, is where exceptions lose worst: every failure pays the unwinder. A parser that throws for every syntax error in a million-token input would spend almost all its time unwinding. With value errors, a failure costs the same as a success plus a small allocation.

At scale: LLVM's policy follows from these numbers. The code base is huge (so table size matters), failures are common in tools (so throw cost matters), and it is compiled into applications that disable exceptions (so it cannot require them) [LLVM-CS].

6. Variants and refinements

Error and Expected

  • joinErrors (Algorithm 10.7.3 handles ErrorList payloads), ErrorAsOutParameter for constructors that report through an Error &, and FileError / createFileError to attach a file name.
  • Interop: errorCodeToError/errorToErrorCode, ErrorOr<T> (the older std::error_code-based type), and expectedToOptional (drops the error explicitly).
  • Fallible iterators (fallible_iterator), where iteration reports an error through an Error & checked after the loop.

C++ exceptions

  • SJLJ exceptions (setjmp/longjmp): a cost on the non-throwing path in exchange for no tables, used on a few older targets.
  • Windows SEH/C++ EH funclets (catchswitch, catchpad, cleanuppad in LLVM IR) [LLVM-EH].
  • Herbceptions (P0709, "static exceptions"): a proposal for value-based throws in C++, not adopted.

std::expected

  • Rust's Result<T, E> and the ? operator: the same idea with language support and a #[must_use] lint.
  • Swift's throws: syntax like exceptions, but implemented as an extra error return register, so an error return costs about as much as a normal one.
  • Pebble's pebble::Error is a plain message. The course's DiagnosticEngine handles many located errors, and std::expected is used for single failures.

7. In real compilers

Error and Expected

LLVM

llvm/include/llvm/Support/Error.h (Error, Expected, ErrorInfo, handleErrors, handleAllErrors, cantFail, ExitOnError, joinErrors, consumeError, assertIsChecked) and llvm/lib/Support/Error.cpp (fatalUncheckedError, logAllUnhandledErrors). Users: llvm/lib/ExecutionEngine/Orc/* (every ORC API returns Error/Expected), llvm/lib/Object/*, llvm/lib/Bitcode/Reader/* [LLVM-Error].

  • Clang uses llvm::Expected in its tooling and driver APIs (e.g. clang/lib/Tooling/*), while its diagnostics go through DiagnosticsEngine.

Find where LLVM does it. In Error.h, read Error::operator bool. Question: after if (E) on a failing Error, is E checked? (Quiz error-bool-failure.)

C++ exceptions

Itanium ABI / LLVM

The Itanium C++ ABI's exception-handling chapter (__cxa_throw, _Unwind_RaiseException, the two phases) [ItaniumEH]; llvm/docs/ExceptionHandling.md (invoke, landingpad, personality, resume) [LLVM-EH]; llvm/lib/CodeGen/DwarfEHPrepare.cpp (the IR-level EH preparation).

  • GCC emits the same tables (gcc/except.cc). Most C++ code bases outside LLVM use exceptions. LLVM itself, and code linking it, build with -fno-exceptions (llvm-config --cxxflags).

std::expected

C++ standard library

<expected> in libstdc++ 14 and in libc++ (libcxx/include/__expected/expected.h in llvm-project at llvmorg-23.1.2); specified by P0323R12 [P0323].

  • Pebble pebble/include/pebble/Support/Error.h (fail, failMessage). Rust core::result::Result.

8. Comparison

Technique Power / precision Speed Output / error quality Implementation effort Typical use
Error/Expected typed, composable payloads; handler dispatch by type one test per call; cheap failures unhandled errors abort in checking builds (Theorem 10.7.8) verbose (takeError, handlers) LLVM libraries, tools, ORC, object/bitcode readers
C++ exceptions non-local, cannot be ignored, automatic cleanup free success path, very expensive throws uncaught → std::terminate with a message least code at call sites general C++ outside LLVM
std::expected value-or-error, monadic composition one test per call ignoring an error is not detected (only [[nodiscard]]) standard, concise Pebble's APIs, modern C++ libraries

Choose Error/Expected in any code that uses or extends LLVM, and test in an assertions build so the checks run. Choose exceptions only in code that never touches LLVM's -fno-exceptions world. Choose std::expected for your own APIs when you want the standard type and are content with [[nodiscard]], and convert at the LLVM boundary with toString(E.takeError()) or std::unexpected(…).

9. Assessment

Technique Quiz ids (solutions/quizzes/ch10.yaml) Drill Flashcard tag Exercises
Error/Expected error-bool-failure, must-check-trace, handle-dispatch ./course drill must-check llvm-error Lab 10.3 R5–R7
C++ exceptions eh-zero-cost, eh-invoke-count ./course drill must-check (contrast cases); justification below exceptions —
std::expected std-expected-and-then, std-expected-unchecked ./course drill must-check (the [[nodiscard]]-only contrast is in the quiz) std-expected Pebble APIs

Exceptions and std::expected have no randomizable state to trace beyond the must-check rules that the drill already covers. The quiz tests their cost model and propagation rule.

Pitfall

if (auto Err = f()) return Err; is correct, but if (!ExpectedV) return nullptr; is a bug: the failed Expected is destroyed unchecked. You must take its error and handle it (return ExpectedV.takeError(); or consumeError(ExpectedV.takeError())). In a Release build of LLVM nothing tells you, which is why Lab 10.3's tests call consumeError on every failing result they expect.

References

See the chapter references.