Error Handling: errno to Result
Every syscall can fail. Every allocation can return NULL. How a language handles
errors defines how reliable the software built with it can be. C gives you
conventions. Rust gives you a type system. This chapter covers both, from the
humble return code to the ? operator.
C: The Return Code Convention
The simplest C error-handling pattern: return 0 for success, non-zero for failure.
/* retcode.c */
#include <stdio.h>
int parse_positive_int(const char *s, int *out)
{
int val = 0;
if (!s || !out)
return -1; /* invalid argument */
for (const char *p = s; *p; p++) {
if (*p < '0' || *p > '9')
return -2; /* not a digit */
val = val * 10 + (*p - '0');
}
*out = val;
return 0; /* success */
}
int main(void)
{
int val;
int rc;
rc = parse_positive_int("42", &val);
if (rc == 0)
printf("Parsed: %d\n", val);
else
printf("Error: %d\n", rc);
rc = parse_positive_int("12ab", &val);
if (rc == 0)
printf("Parsed: %d\n", val);
else
printf("Error: %d\n", rc);
return 0;
}
Compile and run:
gcc -Wall -o retcode retcode.c && ./retcode
Output:
Parsed: 42
Error: -2
This works for small programs. But notice: the caller must remember to check the return value. The compiler will not warn if they forget.
C: errno -- The Global Error Variable
POSIX functions return -1 (or NULL) on failure and set errno to indicate
what went wrong.
/* errno_demo.c */
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
int main(void)
{
int fd = open("/nonexistent/file.txt", O_RDONLY);
if (fd == -1) {
printf("errno = %d\n", errno);
printf("strerror: %s\n", strerror(errno));
perror("open"); /* prints: open: No such file or directory */
}
errno = 0; /* manual reset */
FILE *f = fopen("/etc/shadow", "r");
if (!f) {
perror("fopen /etc/shadow");
}
return 0;
}
Compile and run:
gcc -Wall -o errno_demo errno_demo.c && ./errno_demo
The flow:
open("/nonexistent/file.txt", O_RDONLY)
|
+-- kernel returns error
+-- glibc sets errno = ENOENT (2)
+-- returns -1 to caller
+-- caller checks return value, reads errno for detail
Caution:
errnois thread-local in modern C (C11 / POSIX), but it is still fragile. Any function call between the failing call and readingerrnocan overwrite it. Always checkerrnoimmediately after the call that failed.
C: The -1 / NULL / errno Pattern
Most POSIX and standard library functions follow one of these patterns:
+---------------------------+------------------+------------------+
| Function returns | On success | On failure |
+---------------------------+------------------+------------------+
| int (file descriptor) | >= 0 | -1, sets errno |
| pointer | valid pointer | NULL, sets errno |
| ssize_t (byte count) | >= 0 | -1, sets errno |
| int (status) | 0 | -1, sets errno |
+---------------------------+------------------+------------------+
Not all functions follow this. getchar() returns EOF on failure.
pthread_create returns the error number directly (not through errno). You
must read the man page for every function you call.
Caution: Some functions (like
strtol) have ambiguous return values. A return of 0 might mean "parsed zero" or "parse failed." You must seterrno = 0before calling and check it afterward.
C: The goto cleanup Pattern
When a function acquires multiple resources, you need to release them all on
any error path. The goto pattern is standard practice in C -- and is used
extensively in the Linux kernel.
/* goto_cleanup.c */
#include <stdio.h>
#include <stdlib.h>
int process_file(const char *path)
{
int ret = -1;
FILE *f = NULL;
char *buf = NULL;
f = fopen(path, "r");
if (!f) {
perror("fopen");
goto cleanup;
}
buf = malloc(4096);
if (!buf) {
perror("malloc");
goto cleanup;
}
if (!fgets(buf, 4096, f)) {
if (ferror(f)) {
perror("fgets");
goto cleanup;
}
buf[0] = '\0';
}
printf("First line: %s", buf);
ret = 0; /* success */
cleanup:
free(buf); /* free(NULL) is safe */
if (f)
fclose(f);
return ret;
}
int main(int argc, char *argv[])
{
if (argc != 2) {
fprintf(stderr, "Usage: %s <file>\n", argv[0]);
return 1;
}
return process_file(argv[1]) == 0 ? 0 : 1;
}
Compile and run:
gcc -Wall -o goto_cleanup goto_cleanup.c && echo "hello world" > /tmp/test.txt && ./goto_cleanup /tmp/test.txt
The goto-cleanup pattern:
function entry
|
allocate resource A ---fail---> goto cleanup
|
allocate resource B ---fail---> goto cleanup
|
do work -------------fail---> goto cleanup
|
success: ret = 0
|
cleanup:
free B (if allocated)
free A (if allocated)
return ret
Driver Prep: The Linux kernel style guide explicitly endorses
gotofor error handling. You will see this pattern in virtually every kernel function that acquires resources.
Try It: Modify
process_fileto alsomalloca second buffer for a processed output. Add the proper cleanup. What happens if you forget to free the second buffer on the error path?
Rust: Result<T, E>
Rust replaces all of the above with a single type:
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
You cannot ignore it. If a function returns Result, the compiler warns you if
you do not handle it.
// result_basic.rs use std::fs; use std::io; fn read_first_line(path: &str) -> Result<String, io::Error> { let contents = fs::read_to_string(path)?; let first = contents.lines().next().unwrap_or("").to_string(); Ok(first) } fn main() { match read_first_line("/tmp/test.txt") { Ok(line) => println!("First line: {}", line), Err(e) => eprintln!("Error: {}", e), } match read_first_line("/nonexistent/file.txt") { Ok(line) => println!("First line: {}", line), Err(e) => eprintln!("Error: {}", e), } }
Compile and run:
rustc result_basic.rs && ./result_basic
The ? Operator
The ? operator is Rust's answer to the goto-cleanup pattern. It does three
things:
- If the
ResultisOk(val), unwrapvaland continue. - If the
ResultisErr(e), converteinto the function's error type and return early. - All cleanup happens automatically via
Drop.
// question_mark.rs use std::fs::File; use std::io::{self, BufRead, BufReader}; fn first_line_length(path: &str) -> Result<usize, io::Error> { let file = File::open(path)?; // returns Err if open fails let reader = BufReader::new(file); let mut line = String::new(); reader.take(4096).read_line(&mut line)?; // returns Err if read fails Ok(line.trim_end().len()) } fn main() { match first_line_length("/tmp/test.txt") { Ok(len) => println!("Length: {}", len), Err(e) => eprintln!("Error: {}", e), } }
Compare the flow to C's goto:
C (goto cleanup) Rust (? operator)
---------------- ------------------
f = fopen(path) let file = File::open(path)?;
if (!f) goto cleanup; // auto-returns Err on failure
buf = malloc(4096); let mut line = String::new();
if (!buf) goto cleanup; // String manages its own memory
fgets(buf, 4096, f); reader.read_line(&mut line)?;
if error goto cleanup; // auto-returns, auto-cleans up
cleanup: // no cleanup block needed --
free(buf); // Drop runs automatically
fclose(f);
Option -- Nullable Without NULL
C uses NULL for "no value." Rust uses Option<T>:
// option_demo.rs fn find_first_negative(nums: &[i32]) -> Option<usize> { for (i, &n) in nums.iter().enumerate() { if n < 0 { return Some(i); } } None } fn main() { let data = [3, 7, -2, 5, -8]; match find_first_negative(&data) { Some(idx) => println!("First negative at index {}", idx), None => println!("No negatives found"), } if let Some(idx) = find_first_negative(&data) { println!("Value: {}", data[idx]); } let empty: &[i32] = &[]; println!("In empty: {:?}", find_first_negative(empty)); }
Compile and run:
rustc option_demo.rs && ./option_demo
Rust Note:
Option<T>has zero overhead for pointer types. The compiler uses the null representation internally, soOption<&T>is the same size as a raw pointer. This is called the "null pointer optimization."
Custom Error Types
Real programs have multiple error sources. In Rust, you define an enum that
implements std::error::Error:
// custom_error.rs use std::fmt; use std::io; use std::num::ParseIntError; #[derive(Debug)] enum AppError { Io(io::Error), Parse(ParseIntError), InvalidArg(String), } impl fmt::Display for AppError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { AppError::Io(e) => write!(f, "I/O error: {}", e), AppError::Parse(e) => write!(f, "Parse error: {}", e), AppError::InvalidArg(msg) => write!(f, "Invalid argument: {}", msg), } } } impl From<io::Error> for AppError { fn from(e: io::Error) -> Self { AppError::Io(e) } } impl From<ParseIntError> for AppError { fn from(e: ParseIntError) -> Self { AppError::Parse(e) } } fn read_number_from_file(path: &str) -> Result<i64, AppError> { if path.is_empty() { return Err(AppError::InvalidArg("empty path".to_string())); } let contents = std::fs::read_to_string(path)?; // io::Error -> AppError let num: i64 = contents.trim().parse()?; // ParseIntError -> AppError Ok(num) } fn main() { match read_number_from_file("/tmp/number.txt") { Ok(n) => println!("Number: {}", n), Err(e) => eprintln!("Error: {}", e), } match read_number_from_file("") { Ok(n) => println!("Number: {}", n), Err(e) => eprintln!("Error: {}", e), } }
Compile and run:
echo "42" > /tmp/number.txt
rustc custom_error.rs && ./custom_error
The From implementations let the ? operator automatically convert different
error types into your unified AppError.
Why C Error Handling Leads to Bugs
Consider this real-world pattern:
/* bug_demo.c -- spot the bugs */
#include <stdio.h>
#include <stdlib.h>
char *load_config(const char *path)
{
FILE *f = fopen(path, "r");
if (!f)
return NULL;
char *buf = malloc(1024);
if (!buf)
return NULL; /* BUG: f is leaked! */
if (!fgets(buf, 1024, f)) {
free(buf);
return NULL; /* BUG: f is leaked again! */
}
fclose(f);
return buf;
}
int main(void)
{
char *cfg = load_config("/tmp/test.txt");
if (cfg) {
printf("Config: %s", cfg);
free(cfg);
}
return 0;
}
Two resource leaks in a 15-line function. This is everywhere in C codebases. The language simply does not help.
Try It: Fix the bugs in
load_configusing the goto-cleanup pattern. Then write the equivalent in Rust and observe that the resource leaks are impossible.
Using thiserror and anyhow (Cargo ecosystem)
For real Rust projects, two crates simplify error handling enormously.
thiserror -- for library code, auto-generates Display and From:
#![allow(unused)] fn main() { // In Cargo.toml: thiserror = "1" use thiserror::Error; #[derive(Error, Debug)] enum DataError { #[error("I/O error: {0}")] Io(#[from] std::io::Error), #[error("parse error: {0}")] Parse(#[from] std::num::ParseIntError), #[error("value {0} out of range {1}..{2}")] OutOfRange(i64, i64, i64), } }
anyhow -- for application code, wraps any error into a single type:
// In Cargo.toml: anyhow = "1" use anyhow::{Context, Result}; fn load_config(path: &str) -> Result<String> { let contents = std::fs::read_to_string(path) .with_context(|| format!("failed to read config from {}", path))?; Ok(contents) } fn main() -> Result<()> { let cfg = load_config("/tmp/test.txt")?; println!("Config: {}", cfg.trim()); Ok(()) }
Error Handling Decision Tree
Are you writing a library?
|
YES --> Use thiserror (or manual impl)
| - Callers need to match on specific errors
|
NO --> Are you writing an application?
|
YES --> Use anyhow
| - Just print errors and bail
| - Use .context() for readable messages
|
(learning / small scripts) --> Use Box<dyn Error>
Mapping C errno to Rust
When calling C functions from Rust (via FFI), convert errno to a Rust error
using std::io::Error:
// errno_to_rust.rs fn main() { let result = std::fs::File::open("/nonexistent/path"); match result { Ok(_) => println!("opened"), Err(e) => { println!("Error: {}", e); println!("OS error code: {:?}", e.raw_os_error()); println!("Error kind: {:?}", e.kind()); } } }
Output:
Error: No such file or directory (os error 2)
OS error code: Some(2)
Error kind: NotFound
io::Error wraps errno values and maps them to the ErrorKind enum. This
is how Rust bridges the C world.
Knowledge Check
-
What happens in C if you call two POSIX functions in a row and only check
errnoafter the second one? -
In Rust, what does the
?operator do when it encounters anErrvalue? -
Why does Rust not need a goto-cleanup pattern for resource management?
Common Pitfalls
- Forgetting to check return values in C. The compiler will happily let you
ignore the return value of
read(),write(), orclose(). Bugs hide here for years. - Checking
errnoafter an intervening call. Evenprintfcan overwriteerrno. Read it immediately after the failing call. - Using
unwrap()in Rust production code. It panics onErr. Use?ormatchinstead. Reserveunwrap()for cases where failure is truly impossible. - Ignoring
#[must_use]warnings. Rust marksResultas#[must_use]. If you see a warning about an unusedResult, you are ignoring an error. - Confusing Option with Result. Use
Optionfor "might not exist" andResultfor "might fail." Do not useResult<T, ()>when you meanOption<T>.