Skip to content

Rust Result and C++ Exceptions: Error Contracts

Rust Result and C++ exceptions can both report a failed operation, but they expose different interfaces and control flow. Start with the failure contract, not a mechanical replacement of throw with panic. This lesson compares a strict integer parser and the cleanup surrounding each attempt.

Rust and C++ parsing requirements

Read Option and Result and scope cleanup first. The independent fixtures require Rust edition 2024 and a C++20 compiler, with no external packages or sensors.

Accept an optional ASCII minus followed by at least one ASCII digit. Leading zeros and negative zero are allowed. Reject plus signs, spaces, trailing characters and non-ASCII digits as Format. A syntactically valid integer outside signed 32-bit range is Range. Syntax validation takes precedence: even a very long number followed by a letter is Format.

For each attempted input, create a small guard and close it exactly once when returning normally or reporting a parsing failure. A counter models cleanup; it does not acquire a real device. Continue after bad inputs and preserve zero as a valid reading. Both implementations must produce:

1
2
3
4
5
OK=0
OK=-500
ERR=Format
ERR=Range
closes=4

C++: typed exceptions and unwinding

Save this complete program as main.cpp. from_chars itself reports an error code; this adapter deliberately turns it into a typed exception. The catch is inside the loop, so one malformed input does not stop the report.

#include <array>
#include <cassert>
#include <charconv>
#include <cstdint>
#include <exception>
#include <iostream>
#include <limits>
#include <string_view>
#include <system_error>

enum class Kind { Format, Range };

class ParseError : public std::exception {
public:
    Kind kind;
    explicit ParseError(Kind value) : kind(value) {}
    const char* what() const noexcept override {
        return kind == Kind::Format ? "Format" : "Range";
    }
};

std::int32_t parse(std::string_view text) {
    auto digits = text;
    if (digits.starts_with('-')) digits.remove_prefix(1);
    if (digits.empty()) throw ParseError(Kind::Format);
    for (char digit : digits) {
        if (digit < '0' || digit > '9') throw ParseError(Kind::Format);
    }
    std::int32_t value = 0;
    auto result = std::from_chars(text.data(), text.data() + text.size(), value);
    if (result.ec == std::errc::result_out_of_range) throw ParseError(Kind::Range);
    if (result.ec != std::errc{} || result.ptr != text.data() + text.size()) {
        throw ParseError(Kind::Format);
    }
    return value;
}

struct Guard {
    int& closes;
    explicit Guard(int& counter) : closes(counter) {}
    Guard(const Guard&) = delete;
    Guard& operator=(const Guard&) = delete;
    ~Guard() noexcept { ++closes; }
};

std::int32_t run(std::string_view text, int& closes) {
    Guard guard(closes);
    return parse(text);
}

void expect_error(std::string_view text, Kind expected) {
    try {
        (void)parse(text);
        assert(false && "input should have failed");
    } catch (const ParseError& error) {
        assert(error.kind == expected);
    }
}

int main() {
    int closes = 0;
    for (std::string_view text : std::array{"0", "-500", "bad", "2147483648"}) {
        try {
            // Parse before emitting a prefix: a failed call prints no partial OK line.
            auto value = run(text, closes);
            std::cout << "OK=" << value << '\n';
        } catch (const ParseError& error) {
            std::cout << "ERR=" << error.what() << '\n';
        }
    }
    assert(closes == 4);
    std::cout << "closes=" << closes << '\n';
    assert(parse("2147483647") == std::numeric_limits<std::int32_t>::max());
    assert(parse("-2147483648") == std::numeric_limits<std::int32_t>::min());
    assert(parse("-0") == 0);
    for (std::string_view text : std::array{"", "-", "+1", " 1", "1 ", "1x", "12", "999999999999x"}) {
        expect_error(text, Kind::Format);
    }
    expect_error("2147483648", Kind::Range);
    expect_error("-2147483649", Kind::Range);
}

Run with assertions enabled:

1
2
3
4
g++ -std=c++20 -Wall -Wextra -Wpedantic -O0 main.cpp -o error_contract
./error_contract
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 main.cpp -o error_contract_release
./error_contract_release

The return type alone does not list possible exceptions. Document ParseError and any other failure sources. During propagation to this handler, the constructed automatic guard is destroyed; that is exception unwinding, not a normal return. The C++ destruction rules govern that cleanup. The current working draft contains later additions too; this fixture uses C++20 features.

The from_chars contract includes partial consumption and range errors. Checking both syntax and the returned pointer prevents silently accepting a prefix. Other parsers may accept whitespace or a plus sign: substituting one requires retesting the same contract.

Rust: a typed failure value, not a panic

Create a project with cargo new error_contract --edition 2024. Replace src/main.rs with the entire program below. Error is a local domain type; standard-library parse errors are translated at the boundary.

use std::cell::Cell;
use std::num::IntErrorKind;

#[derive(Debug, PartialEq, Eq)]
enum Error {
    Format,
    Range,
}

fn parse(text: &str) -> Result<i32, Error> {
    let digits = text.strip_prefix('-').unwrap_or(text);
    if digits.is_empty() || !digits.bytes().all(|digit| digit.is_ascii_digit()) {
        return Err(Error::Format);
    }
    text.parse::<i32>().map_err(|error| match error.kind() {
        IntErrorKind::PosOverflow | IntErrorKind::NegOverflow => Error::Range,
        _ => Error::Format,
    })
}

struct Guard<'a> {
    closes: &'a Cell<usize>,
}

impl Drop for Guard<'_> {
    fn drop(&mut self) {
        self.closes.set(self.closes.get() + 1);
    }
}

fn run(text: &str, closes: &Cell<usize>) -> Result<i32, Error> {
    let _guard = Guard { closes };
    let value = parse(text)?;
    Ok(value)
}

fn main() {
    let closes = Cell::new(0);
    for text in ["0", "-500", "bad", "2147483648"] {
        match run(text, &closes) {
            Ok(value) => println!("OK={value}"),
            Err(error) => println!("ERR={error:?}"),
        }
    }
    assert_eq!(closes.get(), 4);
    println!("closes={}", closes.get());
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn signed_boundaries_are_valid() {
        assert_eq!(parse("2147483647"), Ok(i32::MAX));
        assert_eq!(parse("-2147483648"), Ok(i32::MIN));
    }

    #[test]
    fn zero_and_negative_values_are_not_errors() {
        assert_eq!(parse("0"), Ok(0));
        assert_eq!(parse("-0"), Ok(0));
        assert_eq!(parse("-500"), Ok(-500));
        assert_eq!(parse("0001"), Ok(1));
    }

    #[test]
    fn syntax_has_priority_over_range() {
        for text in ["", "-", "+1", " 1", "1 ", "1x", "12", "999999999999x"] {
            assert_eq!(parse(text), Err(Error::Format), "{text:?}");
        }
    }

    #[test]
    fn valid_syntax_can_exceed_the_range() {
        assert_eq!(parse("2147483648"), Err(Error::Range));
        assert_eq!(parse("-2147483649"), Err(Error::Range));
    }

    #[test]
    fn cleanup_runs_on_success_and_error_returns() {
        let closes = Cell::new(0);
        assert_eq!(run("0", &closes), Ok(0));
        assert_eq!(closes.get(), 1);
        assert_eq!(run("bad", &closes), Err(Error::Format));
        assert_eq!(closes.get(), 2);
        assert_eq!(run("2147483648", &closes), Err(Error::Range));
        assert_eq!(closes.get(), 3);
    }
}

Inside the project, run:

1
2
3
4
5
6
cargo fmt --check
cargo check --offline
cargo test --offline
cargo test --offline --release
cargo run --offline --quiet
cargo run --offline --release --quiet

The Result API represents success or failure as a value. The ? operator here returns an Err early; it does not initiate panic unwinding. On either return path, the local guard is dropped. A caller may match, propagate or deliberately transform the failure, without losing the distinction between zero and invalid input.

A changed requirement: stop at the first failure

Our current requirement is best-effort reporting. If a configuration loader instead requires all inputs to succeed, put the C++ catch outside the loop and return an error from the overall operation. In Rust, make that operation return Result and propagate with ?. The fixture sequence will attempt three inputs, not four: the first Format stops processing before Range. Its guard count must be three.

That change is not transactional rollback. Earlier successful effects remain unless the application stages changes or defines compensation. To promise an unchanged configuration on failure, parse and validate into temporary owned data first, then commit. Destructor-based cleanup does not undo external writes or report failed commits.

For a C++ non-throwing domain interface, a std::variant can express both alternatives in C++20. std::expected is a C++23 option, not a C++20 prerequisite. Returning a variant does not by itself guarantee that every operation inside the function is non-throwing. Choose the interface according to the project's convention and caller obligations, not the language's reputation.

Compile-time rejection is a different kind of failure

Save this Rust program separately. It intentionally fails because ? cannot propagate a parse error through this i32 return contract:

1
2
3
4
5
6
7
8
fn parse(text: &str) -> i32 {
    let value = text.parse::<i32>()?;
    value
}

fn main() {
    let _ = parse("0");
}

Change the return type to Result and wrap the success in Ok(value). That repairs propagation, but does not automatically implement our stricter grammar/domain mapping. Do not repair it with unwrap or an arbitrary zero fallback.

A C++ result wrapper likewise is not an integer. Compile this independently; it intentionally fails:

1
2
3
4
5
6
7
#include <variant>
enum class Error { Format, Range };
std::variant<int, Error> parse() { return Error::Format; }
int main() {
    int value = parse();
    (void)value;
}

Repair the caller by examining the selected variant and handling both alternatives. A compiler diagnostic is not the runtime error message for a user's malformed input. The successful programs above intentionally accept the program and reject selected input values at runtime.

Panic, invariants and boundary decisions

Reserve a panic for an invariant violation or another deliberately non-recoverable path, not normal bad input. Unwrap changes an Err into a panic; it does not make the failure impossible. The catch_unwind documentation warns that it catches unwinding panics, not aborting ones, and is not a replacement for Result-based error handling.

Do not assume C++ exceptions can cross a Rust FFI boundary or that catch_unwind catches arbitrary foreign exceptions. Define an explicit boundary protocol; see unsafe and FFI. Neither this fixture nor destructor tests establish recovery from process termination, aborts, hardware failure or all allocation failures. Keep destructors simple: required fallible shutdown needs a separate operation with a visible result.

Requirement Interface choice Caller responsibility
Expected invalid input with actionable reasons Rust Result / C++ domain result or typed exception Preserve the reason; decide whether to continue
Missing value, not an error reason Option / optional Distinguish absence from valid zero
All-or-nothing application changes Staging plus explicit commit Define rollback and external effects separately
Broken internal invariant Assertion/panic or the project's failure policy Specify whether recovery is meaningful
External language boundary Explicit documented protocol Translate errors without unintended unwinding

Exercises and verification

  1. Implement stop-on-first-error in both languages. Assert three cleanup events, and that the fourth input is not processed.
  2. Repair each compiler-failure example without replacing an error with zero. Check both success and failure alternatives.
  3. Remove syntax validation and test +1, trailing characters and the long invalid number. Explain why using a built-in parser alone is not the same specification.
  4. Add input provenance to the domain error while retaining Format/Range classification. Do not expose a borrowed label whose owner expires.
  5. Explain which effects a guard cleans up and which effects require an explicit transaction protocol. No speed comparison follows from this fixture.

On October 10, 2026, the extracted programs passed on the authorised Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust/Cargo 1.99.0 and GCC g++ 14.2.0. Rust check, exact-source formatting, five debug/release tests and both outputs passed. A stop-first variant retained the tests and produced three cleanup events; the repaired Result caller asserted success and failure. C++20 normal and stop-first outputs passed at -O0/-O2 with assertions enabled, as did the variant repair. The isolated Rust and C++ failure programs were rejected with E0277 and a variant-to-integer conversion diagnostic. Sources and logs were retained; generated Cargo targets were cleaned. These checks do not exercise panic recovery, transactional rollback or performance.

Previous: traits and dispatch · Next: Rust and C++ moves · Course overview

Donate