Error Handling: codes, exceptions & error unions
One task, seven philosophies of failure. Each language parses a string into an integer and computes 100 / n, and each must handle two distinct failures: the string isn't a number, and the number is zero (divide-by-zero). Watch how the answer differs: C threads a status through return values and errno; C++ throws exceptions that unwind the stack; Zig uses error unions (!T) with try; Hare uses tagged-union error types matched with match; Odin returns multiple values and chains them with or_return; and HolyC and Forth fall back to sentinel returns and abort/CATCH. The memory angle matters here too - an error path must still free anything it allocated, so notice which schemes make that automatic and which leave it to you.
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <limits.h>
/* C has no exceptions: report failure through a return code and let
* the OUT param carry the result. errno is the standard side channel
* for library failures like strtol's range/format errors. */
enum parse_result { PARSE_OK = 0, PARSE_NOT_A_NUMBER, PARSE_DIV_BY_ZERO };
enum parse_result divide_100_by(const char *s, long *out) {
char *end = NULL;
errno = 0; /* clear errno before strtol */
long n = strtol(s, &end, 10);
/* end == s means no digits consumed; *end != 0 means trailing junk. */
if (end == s || *end != '\0')
return PARSE_NOT_A_NUMBER;
if (errno == ERANGE || n < INT_MIN || n > INT_MAX)
return PARSE_NOT_A_NUMBER; /* overflow: treat as invalid */
if (n == 0)
return PARSE_DIV_BY_ZERO; /* can't divide by zero */
*out = 100 / n; /* success: write the OUT param */
return PARSE_OK;
}
int main(void) {
/* Heap-allocate the input to show that the error path must still
* free it -- C gives you no automatic cleanup on early return. */
char *input = malloc(8);
if (input == NULL) return 1;
snprintf(input, 8, "%s", "5");
long result;
switch (divide_100_by(input, &result)) {
case PARSE_OK: printf("100 / n = %ld\n", result); break;
case PARSE_NOT_A_NUMBER: fprintf(stderr, "not a number\n"); break;
case PARSE_DIV_BY_ZERO: fprintf(stderr, "divide by zero\n"); break;
}
free(input); /* every path reaches this free */
return 0;
}Idiomatic C reports failure with a return code (here an enum) and passes the result back through an OUT parameter; library routines like strtol use errno plus the end pointer to distinguish "not a number" from "trailing junk." There is no unwinding, so the caller must remember to free(input) on every branch - the error path is just another code path you write by hand.
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>
// Modern C++ signals failure by throwing typed exceptions; the stack
// unwinds and destructors run automatically, so RAII-owned memory is
// freed on the error path with no explicit cleanup.
int divide_100_by(const std::string &s) {
std::size_t pos = 0;
int n = std::stoi(s, &pos); // throws std::invalid_argument / out_of_range
if (pos != s.size())
throw std::invalid_argument("trailing characters");
if (n == 0)
throw std::domain_error("divide by zero");
return 100 / n;
}
int main() {
// unique_ptr owns the input string; its destructor frees it even
// if divide_100_by throws and main's try-block unwinds.
auto input = std::make_unique<std::string>("5");
try {
std::cout << "100 / n = " << divide_100_by(*input) << '\n';
} catch (const std::invalid_argument &) {
std::cerr << "not a number\n"; // also catches stoi's parse failure
} catch (const std::domain_error &e) {
std::cerr << e.what() << '\n'; // divide by zero
} catch (const std::out_of_range &) {
std::cerr << "out of range\n"; // stoi overflow
}
return 0;
} // input's destructor frees the string here, on normal OR exceptional exit
C++ throws typed exceptions (std::invalid_argument, std::domain_error) and selects a handler by type in the catch chain; std::stoi itself throws on a bad parse. Because the input is owned by a unique_ptr, stack unwinding runs its destructor automatically, so the heap string is freed whether the function returns normally or an exception propagates - RAII makes the error path leak-free for free.
// HolyC has no exceptions and no error unions. The idiom is a sentinel
// return value plus a status OUT param (via a pointer), exactly like C.
// Str2I64 parses; we flag the two failures through *ok.
I64 Divide100By(U8 *s, I64 *ok)
{
U8 *end;
I64 n = Str2I64(s, 10, &end); // base-10 parse; 'end' points past digits
if (end == s || *end) { // no digits, or trailing junk
*ok = FALSE;
return 0; // sentinel; caller must check *ok
}
if (n == 0) {
*ok = FALSE; // can't divide by zero
return 0;
}
*ok = TRUE;
return 100 / n;
}
// Top-level code runs in TempleOS -- no main needed.
U8 *input = MAlloc(8); // per-task heap; MAlloc never returns NULL
StrCpy(input, "5");
I64 ok;
I64 r = Divide100By(input, &ok);
if (ok)
Print("100 / n = %d\n", r);
else
Print("not a number or divide by zero\n");
Free(input); // manual cleanup on the single exit path
// For an unrecoverable error HolyC can throw with: throw('Err'); and a
// caller can wrap a body in try { ... } catch { ... } -- but the C-style
// sentinel above is the everyday idiom.
HolyC mirrors C: no exceptions for routine errors, so failure rides on a sentinel return plus a status OUT param, and Str2I64's end pointer separates "not a number" from trailing junk. TempleOS does have throw/try/catch for unrecoverable faults, but with no memory protection in ring 0 you still hand-pair every MAlloc with a Free on each exit path.
const std = @import("std");
// Zig encodes failure in the type system as an error union, '!T'.
// We define a named error set, return '!i64', and let 'try' propagate.
const DivError = error{ NotANumber, DivByZero };
fn divide100By(s: []const u8) DivError!i64 {
// parseInt returns its own error union; 'catch' maps it into ours.
const n = std.fmt.parseInt(i64, s, 10) catch return DivError.NotANumber;
if (n == 0) return DivError.DivByZero;
return @divTrunc(100, n);
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Explicit allocation; 'defer' frees it on EVERY exit, error included.
const input = try allocator.dupe(u8, "5");
defer allocator.free(input);
// 'switch' on the caught error handles each variant exhaustively.
const r = divide100By(input) catch |err| switch (err) {
error.NotANumber => {
std.debug.print("not a number\n", .{});
return;
},
error.DivByZero => {
std.debug.print("divide by zero\n", .{});
return;
},
};
std.debug.print("100 / n = {d}\n", .{r});
}Zig makes errors values in the type: divide100By returns DivError!i64, try propagates them, and catch maps another function's error set (parseInt) into ours or switches over each variant exhaustively. Allocation is explicit through the Allocator, and defer allocator.free(input) runs on the error path too - so propagating an error never leaks.
use fmt;
use strconv;
use strings;
// Hare models errors as values in a tagged union return type. We list
// the failure cases as their own types and 'match' on the result.
type divbyzero = !void;
fn divide_100_by(s: str) (i64 | strconv::invalid | strconv::overflow | divbyzero) = {
// strconv::stoi64 returns (i64 | invalid | overflow); '?' would
// propagate, but here we forward those error types directly.
const n = strconv::stoi64(s)?;
if (n == 0) {
return divbyzero;
};
return 100 / n;
};
export fn main() void = {
// Manual heap: alloc a copy of the input, defer the free so every
// path (including an error return) releases it.
let input: []u8 = alloc([0u8...], 1);
defer free(input);
input[0] = '5';
const s = strings::fromutf8(input)!;
match (divide_100_by(s)) {
case let r: i64 =>
fmt::printfln("100 / n = {}", r)!;
case strconv::invalid =>
fmt::println("not a number")!;
case strconv::overflow =>
fmt::println("out of range")!;
case divbyzero =>
fmt::println("divide by zero")!;
};
};Hare returns a tagged union of the success type and each error type (i64 | strconv::invalid | strconv::overflow | divbyzero), and the caller matches every case exhaustively; the ? operator propagates an error up the chain when you don't want to handle it locally. Memory is manual, so defer free(input) is what guarantees the buffer is released even when an error variant is returned.
package main
import "core:fmt"
import "core:strconv"
// Odin returns multiple values; the last is conventionally an error or
// ok flag. 'or_return' bails out early, forwarding the error to the
// caller -- the lightweight cousin of try/?.
Div_Error :: enum { None, Not_A_Number, Div_By_Zero }
divide_100_by :: proc(s: string) -> (result: int, err: Div_Error) {
n, ok := strconv.parse_int(s)
if !ok {
return 0, .Not_A_Number // multi-value error return
}
if n == 0 {
return 0, .Div_By_Zero
}
return 100 / n, .None
}
// helper showing or_return: if divide_100_by errs, this returns that err.
run :: proc(s: string) -> (out: int, err: Div_Error) {
out = divide_100_by(s) or_return // propagate err automatically
return out, .None
}
main :: proc() {
// make/delete go through the implicit context.allocator; defer frees
// the dynamic buffer on every return path, error included.
input := make([]u8, 1)
defer delete(input)
input[0] = '5'
r, err := divide_100_by(string(input))
switch err {
case .None: fmt.println("100 / n =", r)
case .Not_A_Number: fmt.println("not a number")
case .Div_By_Zero: fmt.println("divide by zero")
}
}Odin returns multiple values with the error last, and or_return propagates that error to the caller automatically when you choose not to handle it inline (as run shows). Allocation routes through the implicit context.allocator, so make([]u8, 1) plus defer delete(input) frees the buffer on every path; swap the context allocator for an arena and you could drop the per-buffer delete entirely.
\ Forth has no exceptions for routine flow; the idiom is to return a
\ flag (and the result) on the data stack, much like a C status code.
\ >NUMBER does the parsing; we report failure with a TRUE/FALSE flag.
\ ( c-addr u -- result ok? ) parse, then 100/n, leaving a success flag.
: DIVIDE-100-BY ( c-addr u -- n flag )
0 0 2SWAP ( ud=0 c-addr u ) \ accumulator for >NUMBER
>NUMBER ( ud c-addr2 u2 ) \ convert digits
NIP 0<> IF ( ud ) \ leftover chars? not a number
2DROP 0 FALSE EXIT \ drop the double, push result=0, flag=FALSE
THEN
DROP ( n ) \ keep low cell of the double
DUP 0= IF ( n ) \ divide by zero?
DROP 0 FALSE EXIT
THEN
100 SWAP / TRUE ; ( 100/n TRUE ) \ success
S" 5" DIVIDE-100-BY ( n flag )
IF ." 100 / n = " . CR \ success: print the quotient
ELSE DROP ." not a number or divide by zero" CR \ failure: drop the 0
THEN
\ For true exceptions Forth offers CATCH/THROW: ' SOME-WORD CATCH
\ runs a word and returns 0 or the thrown code, so a handler can FREE
\ any ALLOCATEd memory -- the manual equivalent of try/finally.Forth carries both the result and a success flag on the data stack, and the caller branches with IF ... ELSE ... THEN - the RPN form of a C status code rather than an exception. For non-local error handling Forth has the CATCH/THROW word set (' word CATCH returns 0 or the thrown code), which is also where you'd FREE any ALLOCATEd buffer, hand-rolling try/finally since there is no automatic unwind.