Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rust FAQ: Debugging and Error Handling

PART09 -- Debugging and Error Handling

Q39: How can I handle errors in a constructor-like function?

In Rust, a constructor-like function is a regular function that creates a new instance of a struct or enum (often named new). To handle errors in such functions, you typically return a Result<T, E> type, where T is the constructed type and E is an error type. This lets you signal when something goes wrong during initialization (e.g., invalid input or resource failure) and forces the caller to handle the error.

Why use Result?

  • Rust doesn’t use exceptions like other languages. Instead, Result ensures errors are handled explicitly, preventing silent failures.
  • It’s idiomatic and integrates with Rust’s ? operator for concise error handling.

Example: Suppose you’re creating a User struct, but the username must not be empty:

#[derive(Debug)]
struct User {
    username: String,
    age: u32,
}

#[derive(Debug)]
enum UserError {
    EmptyUsername,
    InvalidAge,
}

impl User {
    fn new(username: &str, age: u32) -> Result<User, UserError> {
        if username.is_empty() {
            return Err(UserError::EmptyUsername);
        }
        if age > 150 {
            return Err(UserError::InvalidAge);
        }
        Ok(User {
            username: username.to_string(),
            age,
        })
    }
}

fn main() -> Result<(), UserError> {
    let user = User::new("Alice", 30)?; // Success
    println!("User: {:?}", user);
    
    let invalid_user = User::new("", 20); // Fails
    match invalid_user {
        Ok(user) => println!("User: {:?}", user),
        Err(e) => println!("Error: {:?}", e), // Prints: Error: EmptyUsername
    }
    Ok(())
}

How it works:

  • The new function returns Result<User, UserError>.
  • If the username is empty or the age is invalid, it returns Err with a custom error.
  • The caller uses ? or match to handle the Result, ensuring errors aren’t ignored.

Tips:

  • Define a custom error type (like UserError) or use existing ones (e.g., std::io::Error for I/O).
  • Use the thiserror crate for easier error type creation:
    #![allow(unused)]
    fn main() {
    use thiserror::Error;
    
    #[derive(Error, Debug)]
    enum UserError {
        #[error("username cannot be empty")]
        EmptyUsername,
        #[error("age {0} is invalid")]
        InvalidAge(u32),
    }
    }
  • For simple cases, you can return Option<User> if “no value” is the only failure case, but Result is more flexible for errors.

This approach keeps your constructor safe and explicit, aligning with Rust’s philosophy.

Q40: How can I compile out debugging print statements?

Debugging print statements (like println!) are useful during development but can clutter output or hurt performance in production. Rust provides ways to compile out these statements so they don’t appear in the final binary. The most common approaches are using the debug! macro from the log crate or conditional compilation with #[cfg(debug_assertions)].

Option 1: Using the log crate with debug! The log crate provides logging macros (debug!, info!, warn!, etc.) that can be filtered by log level at runtime or compiled out entirely in release builds.

Steps:

  1. Add the log crate to your Cargo.toml:
    [dependencies]
    log = "0.4"
    
  2. Use a logging backend like env_logger for configuration:
    [dependencies]
    env_logger = "0.11"
    
  3. Initialize the logger and use debug! for debugging statements:
    use log::{debug, info};
    
    fn main() {
        env_logger::init(); // Initialize logger
        debug!("This is a debug message"); // Only shown if log level allows
        info!("This is an info message");
    }
  4. Control output with an environment variable:
    • Run with RUST_LOG=debug cargo run to see debug messages.
    • Run with RUST_LOG=info cargo run to exclude debug! messages.
    • In release builds (cargo build --release), debug! statements are typically optimized out if the log level is set to info or higher.

Why use log?

  • Flexible: Control which messages appear without changing code.
  • Efficient: Debug logs are compiled out in release builds if the logger is configured appropriately.
  • Standard: Widely used in Rust’s ecosystem.

Option 2: Using #[cfg(debug_assertions)] Rust’s conditional compilation lets you include code only in debug builds using #[cfg(debug_assertions)].

Example:

fn main() {
    #[cfg(debug_assertions)]
    println!("This is a debug-only message!");

    println!("This always appears.");
}
  • In debug builds (cargo build or cargo run), the println! is included.
  • In release builds (cargo build --release), the println! is compiled out entirely.

Why use #[cfg(debug_assertions)]?

  • Simple: No external dependencies needed.
  • Zero cost: Debug statements are completely removed in release builds, with no runtime overhead.
  • Precise: You control exactly which statements are debug-only.

Best Practice:

  • Use log::debug! for most projects, especially libraries, because it’s flexible and integrates with Rust’s ecosystem.
  • Use #[cfg(debug_assertions)] for quick, one-off debug statements in small projects or when you don’t need a logging framework.
  • Avoid println! for debugging in production code, as it’s always included unless conditionally compiled out.

Example combining both:

use log::debug;

fn main() {
    env_logger::init();
    debug!("This is filtered by log level");

    #[cfg(debug_assertions)]
    println!("This is only in debug builds");
}

This gives you maximum control: debug! for runtime filtering and #[cfg(debug_assertions)] for compile-time removal.