Evaluating C, C++, Rust, and Zig for Modern Low-Level Development (Error Handling)

Published by

on

C, CPP, Rust, and Zig Languages Error Handling

Balancing Speed, Safety, and Complexity in Low-Level Development and Systems Programming

Explore the complete series through the links below:

The focus of this part is on error handling for each programming language.


Introduction

In low-level programming, error handling is not just about managing failures; it directly impacts system stability, performance, and debugging complexity. Unlike high-level languages, where exceptions or garbage collection mitigate risks, low-level development requires explicit control over errors to avoid memory corruption, undefined behavior, and unpredictable execution flow.

This section explores how different languages structure error reporting, propagation, and recovery, shaping their reliability, efficiency, and maintainability in system programming.


Error Handling

C

C does not have built-in exceptions or structured error handling like C++ or Rust. Instead, it relies on manual error handling mechanisms, primarily through return codes, errno, assertions, and signal handling.

In C, functions typically return a status code to indicate success or failure:

#include 
#define SUCCESS 0
#define ERROR_FILE_NOT_FOUND -1
int readFile(const char *filename) {
    FILE *file = fopen(filename, "r");
    if (!file) {
        return ERROR_FILE_NOT_FOUND; // Return error code
    }
    fclose(file);
    return SUCCESS;
}
int main() {
    int status = readFile("nonexistent.txt");
    if (status != SUCCESS) {
        printf("Error: File not found!\n");
    }
    return 0;
}

The global variable errno (defined in ) is set by system calls and standard library functions to indicate error conditions:

#include 
#include 
#include 
int main() {
    FILE *file = fopen("nonexistent.txt", "r");
    if (!file) {
        printf("Error: %s\n", strerror(errno)); // Convert errno to human-readable string
        return 1;
    }
    fclose(file);
    return 0;
}

C provides a way to jump out of deep call stacks using setjmp and longjmp when an error occurs, similar to exception handling:

#include 
#include 
jmp_buf buf;
void secondFunction() {
    printf("Error occurred! Jumping back.\n");
    longjmp(buf, 1); // Jump back to the point where setjmp was called
}
void firstFunction() {
    secondFunction();
    printf("This will never execute.\n");
}
int main() {
    if (setjmp(buf) == 0) {
        firstFunction();
    } else {
        printf("Recovered from error.\n");
    }
    return 0;
}

Signals allow handling asynchronous errors, like division by zero or segmentation faults:

#include 
#include 
void handle_signal(int sig) {
    printf("Caught signal %d\n", sig);
}
int main() {
    signal(SIGINT, handle_signal); // Handle Ctrl+C
    while (1); // Infinite loop
    return 0;
}

C handles errors in a manual, explicit way, requiring programmers to check return values and handle failures explicitly. This approach offers fine-grained control but can lead to verbose and error-prone code.

C++

C++ builds upon C’s manual error handling methods but introduces structured exception handling, making error management more maintainable and reducing the need for constant return value checking. The primary mechanisms for handling errors in C++ include:

  • Exceptions (try-catch): The preferred method in modern C++.
  • Error Codes (std::error_code, std::optional): Still used, particularly in performance-sensitive applications.
  • RAII (Resource Acquisition Is Initialization): Ensures cleanup in case of failures.

Exceptions (try-catch)

Unlike C, where error handling relies on return codes, C++ provides exception handling via throw, try, and catch. This enables propagation of errors up the call stack without manually checking error codes.

#include 
#include  // Required for standard exceptions
void process(int value) {
    if (value < 0) {
        throw std::invalid_argument("Negative value is not allowed");
    }
    std::cout << "Processing: " << value << std::endl;
}
int main() {
    try {
        process(-5); // This will throw an exception
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << std::endl;
    }
    return 0;
}

Custom Exception Handling

C++ allows defining custom exceptions by extending std::exception:

#include 
#include 
class MyException : public std::exception {
public:
    const char* what() const noexcept override {
        return "Custom exception occurred!";
    }
};
int main() {
    try {
        throw MyException();
    } catch (const std::exception& e) {
        std::cerr << "Caught: " << e.what() << std::endl;
    }
    return 0;
}

Exception Safety with RAII

In C++, destructors automatically clean up resources when an object goes out of scope. This prevents memory leaks when an exception is thrown:

#include 
#include 
class Resource {
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource released\n"; } // Always called
};
void riskyOperation() {
    std::unique_ptr res = std::make_unique(); // RAII
    throw std::runtime_error("Something went wrong!"); // Exception thrown
}
int main() {
    try {
        riskyOperation();
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << std::endl;
    }
    return 0;
}

RAII (Resource Acquisition Is Initialization) ensures resource cleanup even when exceptions occur. Smart pointers (std::unique_ptr, std::shared_ptr) automatically free memory.

Error Codes (std::error_code and std::optional)

For performance-sensitive applications where exception overhead is a concern, C++ provides alternatives like std::error_code, std::optional, and std::expected (C++23).

#include 
#include 
std::optional safeDivide(int a, int b) {
    if (b == 0) return std::nullopt; // Indicate error without throwing
    return a / b;
}
int main() {
    auto result = safeDivide(10, 0);
    if (result) {
        std::cout << "Result: " << *result << std::endl;
    } else {
        std::cerr << "Division by zero error\n";
    }
    return 0;
}

Instead of exceptions, functions can return std::optional to indicate success or failure.

Compared to C, C++ reduces boilerplate and improves maintainability through exceptions and RAII. However, exceptions can be costly, so alternatives like std::optional are preferred in performance-critical scenarios.

Rust

Rust provides a structured and type-safe approach to error handling, designed to eliminate undefined behavior and runtime crashes without relying on exceptions or global error codes. Instead, Rust enforces explicit error management through:

  • Result: Used for recoverable errors.
  • Option: Used when a value may be absent.
  • Panic Handling (panic!): For unrecoverable errors.
  • Error Propagation (? operator): Simplifies error handling.
  • Custom Error Types (impl std::error::Error): For application-specific errors.

Unlike C++, Rust does not have exceptions. Instead, errors are handled at the type level, ensuring that all error cases must be explicitly considered at compile time.

Result: Recoverable Errors

Rust’s Result enum represents an operation that can succeed (Ok(T)) or fail (Err(E)). This forces developers to explicitly handle errors:

use std::fs::File;
use std::io::Error;
fn open_file() -> Result {
    File::open("data.txt") // Returns Result
}
fn main() {
    match open_file() {
        Ok(file) => println!("File opened successfully: {:?}", file),
        Err(e) => eprintln!("Error opening file: {}", e),
    }
}

Error Propagation with ? Operator

The ? operator simplifies error propagation by automatically returning errors to the caller:

use std::fs::File;
use std::io::{self, Read};
fn read_file() -> io::Result {
    let mut file = File::open("data.txt")?; // If File::open fails, it returns Err
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}
fn main() {
    match read_file() {
        Ok(content) => println!("File content: {}", content),
        Err(e) => eprintln!("Error reading file: {}", e),
    }
}

Option: Handling Absence of Values

Rust uses Option when a value may or may not exist, replacing null pointers in other languages:

fn divide(a: f64, b: f64) -> Option {
    if b == 0.0 {
        None // No division by zero
    } else {
        Some(a / b)
    }
}
fn main() {
    match divide(10.0, 0.0) {
        Some(result) => println!("Result: {}", result),
        None => println!("Cannot divide by zero"),
    }
}

Unrecoverable Errors: panic!

Rust uses panic for unrecoverable errors (e.g., out-of-bounds access):

fn main() {
    panic!("Something went terribly wrong!");
}

By default, panic! terminates the program and provides a stack trace.

However, Rust allows catching panics using std::panic::catch_unwind in scenarios where graceful recovery is needed:

use std::panic;

fn main() {
    let result = panic::catch_unwind(|| {
        panic!("This panic is caught!");
    });

    match result {
        Ok(_) => println!("No panic occurred"),
        Err(_) => println!("Panic caught, recovering..."),
    }
}

Defining Custom Errors

Rust allows custom error types by implementing the std::error::Error trait:

use std::fmt;
#[derive(Debug)]
struct MyError(String);
impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}
impl std::error::Error for MyError {}
fn process_data(value: i32) -> Result {
    if value < 0 {
        Err(MyError("Negative values not allowed".to_string()))
    } else {
        Ok(value * 2)
    }
}
fn main() {
    match process_data(-5) {
        Ok(result) => println!("Processed: {}", result),
        Err(e) => eprintln!("Error: {}", e),
    }
}

Rust’s error handling system eliminates entire classes of bugs present in C and C++ by:

  • Replacing exceptions with Result and Option, making errors explicit.
  • Forcing compile-time handling of failures, preventing silent failures.
  • Using the ? operator for clean error propagation.
  • Ensuring memory safety through ownership, preventing resource leaks.

Compared to C++’s exceptions, Rust’s approach is more predictable and forces explicit error handling at every level. Rust makes us write robust code by design. However, it requires a shift in thinking, as developers must manage errors manually rather than relying on implicit exception propagation.

Zig

Unlike Rust, which enforces strict compile-time safety with Result, and C++, which uses exceptions, Zig takes a different path, favoring simplicity, predictability, and explicit control. Zig’s error handling is akin to C but with safety improvements and without the overhead of exceptions or garbage collection.

No Exceptions, Just Explicit Error Returns

Zig does not have exceptions. Instead, functions return errors explicitly using an error union (!T), meaning a function either returns a valid value (T) or an error. This eliminates hidden control flow and makes it clear which functions can fail:

const std = @import("std");

fn divide(a: i32, b: i32) !i32 {
    if (b == 0) return error.DivideByZero;
    return a / b;
}

pub fn main() !void {
    const result = divide(10, 0) catch |err| {
        std.debug.print("Error: {}\n", .{err});
        return;
    };
    std.debug.print("Result: {}\n", .{result});
}

This forces explicit error handling, avoiding silent failures.

The try Keyword for Simpler Error Propagation

For functions that bubble up errors instead of handling them immediately, Zig provides try, which automatically returns an error if the function fails:

fn riskyOperation() !void {
    return error.SomethingWentWrong;
}

pub fn main() !void {
    try riskyOperation(); // If an error occurs, it propagates automatically.
    std.debug.print("Success!\n", .{});
}

try is equivalent to Rust’s ? operator, automatically propagating errors without needing explicit if checks.

Error Sets for More Predictable Error Handling

Zig allows defining error sets to make error handling more predictable and structured:

const MyErrors = error{ FileNotFound, PermissionDenied };

fn openFile(path: []const u8) MyErrors!void {
    return error.FileNotFound;
}

pub fn main() void {
    if (openFile("config.txt")) |success| {
        std.debug.print("File opened!\n", .{});
    } else |err| {
        std.debug.print("Failed: {}\n", .{err});
    }
}

Error sets explicitly declare which errors a function can return, improving readability. This is safer than C, where functions can return any error code arbitrarily.

No Uncaught Panics: @panic() for Explicit Crashes

Unlike Rust (which distinguishes between recoverable and unrecoverable errors), Zig treats panics as a last resort. Instead of exceptions, Zig uses @panic() to abort the program explicitly:

const std = @import("std");

fn dangerousAction() void {
    @panic("Something went terribly wrong!");
}

pub fn main() void {
    dangerousAction(); // The program crashes with a clear message.
}

No stack unwinding: Unlike C++ exceptions, Zig’s panics immediately abort execution, reducing runtime complexity.

Zig’s approach is a mix of C’s simplicity and Rust’s safety, but without runtime overhead:

  1. No surprises: Errors are returned explicitly instead of being thrown unexpectedly.
  2. Predictable control flow: No hidden exception stack unwinding like C++.
  3. Zero-cost abstractions: No runtime overhead, making it perfect for embedded systems, OS development, and performance-critical applications.
  4. Safer than C: No need for global errno or ambiguous return codes.

However, unlike Rust, Zig does not guarantee memory safety, it’s still up to the programmer to be disciplined.

Comparison Summary

Error handling in systems programming varies significantly depending on the language philosophy. C relies on manual error codes, C++ uses exceptions, Rust enforces strict compile-time safety, and Zig takes a middle-ground approach with explicit error unions:

FeatureCC++RustZig
Error RepresentationReturn codes (errno, special values)Exceptions (try / catch)Result enumExplicit error unions (!T)
PropagationManual (must check return values)Automatic via exception unwinding? operator for automatic bubblingtry for explicit propagation
PerformanceZero-cost but error-proneExpensive due to stack unwindingZero-cost at runtimeZero-cost, no stack unwinding
PredictabilityUnclear (errors mixed with valid returns)Exceptions can be thrown anywhereFully explicit and enforced at compile-timeNo hidden control flow, always explicit
Error CheckingMust check manuallyCan be caught but often ignoredForced by type systemForced by explicit handling

For absolute safety: Rust wins. It forces correct error handling at compile-time, eliminating entire classes of bugs. However, it is verbose and has a learning curve.

For performance and simplicity: Zig is a great middle ground. No exceptions, no GC, explicit error handling, and zero-cost abstractions make it an excellent choice for low-level development.

It’s amazing how each language makes a trade-off. But it’s not yet time for the final decision.


Conclusion

Error handling in low-level programming varies widely, reflecting each language’s philosophy on control, safety, and performance:

  • C relies on manual error codes (errno) and return values, offering full control but requiring careful handling to prevent silent failures.
  • C++ introduces exceptions and RAII, improving error propagation and resource management but adding runtime overhead.
  • Rust enforces compile-time safety with Result and Option, eliminating many runtime errors at the cost of verbosity.
  • Zig takes a middle ground, using explicit error unions (!T) to maintain clarity, predictability, and zero-cost handling.

Each approach presents trade-offs between simplicity, performance, and robustness, shaping how errors impact debugging complexity, system reliability, and runtime behavior.

In the next section, we will explore developer experience, ecosystem maturity, tooling, learning curve, and real-world performance benchmarks to assess how C, C++, Rust, and Zig perform in modern software development.


Discover more from Code, Craft & Community

Subscribe to get the latest posts sent to your email.

2 responses to “Evaluating C, C++, Rust, and Zig for Modern Low-Level Development (Error Handling)”

  1. […] Error Handling […]

Leave a Reply

Discover more from Code, Craft & Community

Subscribe now to keep reading and get access to the full archive.

Continue reading