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

Idiomatic Rust

Core Interview Questions

1. What does "idiomatic Rust" mean?

Idiomatic Rust means writing code that embraces Rust's core features rather than working around them. It's about leveraging ownership, borrowing, the type system, and Rust's standard library to write safe, efficient, and expressive code. Instead of fighting the borrow checker or reaching for unsafe, you design your code to work naturally with Rust's constraints.

2. How do you identify non-idiomatic Rust code?

Non-idiomatic Rust often looks like code written in another language (C++, Java, Python) that was transliterated into Rust. Common signs: overuse of Rc<RefCell<T>> or unsafe to work around ownership, ignoring Result and Option with .unwrap(), using index-based loops instead of iterators, or reinventing utilities that exist in the standard library.

3. Why is idiomatic Rust important?

Idiomatic Rust is safer, more maintainable, and often more efficient. When you work with the language instead of against it, the compiler helps catch bugs at compile time. Other Rust developers can read and understand your code more easily. And Rust's zero-cost abstractions mean idiomatic code is usually just as fast as hand-rolled alternatives.

4. What's the relationship between idiomatic Rust and performance?

Idiomatic Rust is typically faster than naive implementations. Rust's abstractions (iterators, Option, Result) compile down to the same assembly you'd write by hand, but with safety guarantees. The difference is that idiomatic code expresses intent clearly while letting the optimizer do its job.

Code Snippets

Error Handling: unwrap() vs ? Operator

Non-idiomatic:

use std::fs;

fn read_file_bad(path: &str) -> String {
    let content = fs::read_to_string(path).unwrap(); // Crashes on error
    content
}

fn main() {
    let data = read_file_bad("example.txt");
    println!("{}", data);
}

Idiomatic:

use std::fs;
use std::io;

fn read_file(path: &str) -> Result<String, io::Error> {
    let content = fs::read_to_string(path)?; // Propagates error
    Ok(content)
}

fn main() {
    match read_file("example.txt") {
        Ok(content) => println!("{}", content),
        Err(e) => eprintln!("Error reading file: {}", e),
    }
}

Loops: Index-based vs Iterators

Non-idiomatic:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];

    // C-style loop with indices
    let mut sum = 0;
    for i in 0..numbers.len() {
        sum += numbers[i];
    }
    println!("Sum: {}", sum);
}

Idiomatic:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];

    // Iterator with functional style
    let sum: i32 = numbers.iter().sum();
    println!("Sum: {}", sum);

    // Or with more complex transformations
    let doubled: Vec<i32> = numbers.iter().map(|x| x * 2).collect();
    println!("Doubled: {:?}", doubled);
}

Option Handling: unwrap() vs Pattern Matching

Non-idiomatic:

fn main() {
    let maybe_number = Some(42);

    // Crash if None
    let number = maybe_number.unwrap();
    println!("Number: {}", number);
}

Idiomatic:

fn main() {
    let maybe_number = Some(42);

    // Handle both cases explicitly
    match maybe_number {
        Some(n) => println!("Number: {}", n),
        None => println!("No number"),
    }

    // Or use if-let for one case
    if let Some(n) = maybe_number {
        println!("Got number: {}", n);
    }

    // Or use combinators
    let doubled = maybe_number.map(|n| n * 2);
    println!("Doubled: {:?}", doubled);
}

String Handling: &str vs String for Function Arguments

Non-idiomatic:

// Forces caller to allocate, even for static strings
fn greet_bad(name: String) {
    println!("Hello, {}!", name);
}

fn main() {
    let name = "Alice".to_string(); // Unnecessary allocation
    greet_bad(name);
}

Idiomatic:

// Accepts both &str and String, no allocation needed
fn greet(name: &str) {
    println!("Hello, {}!", name);
}

fn main() {
    let static_str = "Alice";
    let owned_string = String::from("Bob");

    greet(static_str);      // Works with &str
    greet(&owned_string);   // Works with &String
}

Ownership: Rc<RefCell<T>> vs Structured Ownership

Non-idiomatic:

use std::rc::Rc;
use std::cell::RefCell;

struct Node {
    value: i32,
    // Using RefCell because ownership wasn't designed properly
    parent: Option<Rc<RefCell<Node>>>,
}

impl Node {
    fn new(value: i32) -> Self {
        Node {
            value,
            parent: None,
        }
    }
}

fn main() {
    let parent = Rc::new(RefCell::new(Node::new(1)));
    let child = Rc::new(RefCell::new(Node::new(2)));

    // Runtime borrow checking, can panic!
    child.borrow_mut().parent = Some(parent.clone());
    println!("Child value: {}", child.borrow().value);
}

Idiomatic:

struct Node {
    value: i32,
    // Clear ownership structure, no runtime checks needed
}

struct Tree {
    root: Node,
    children: Vec<Node>,
}

impl Tree {
    fn new(root_value: i32) -> Self {
        Tree {
            root: Node { value: root_value },
            children: Vec::new(),
        }
    }

    fn add_child(&mut self, value: i32) {
        self.children.push(Node { value });
    }
}

fn main() {
    let mut tree = Tree::new(1);
    tree.add_child(2);
    tree.add_child(3);

    println!("Root: {}", tree.root.value);
    for child in &tree.children {
        println!("Child: {}", child.value);
    }
}

Collection Building: push in Loop vs collect from Iterator

Non-idiomatic:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];

    // Manual loop and mutation
    let mut evens = Vec::new();
    for num in &numbers {
        if num % 2 == 0 {
            evens.push(num * 2);
        }
    }
    println!("Evens doubled: {:?}", evens);
}

Idiomatic:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];

    // Declarative style with iterators
    let evens: Vec<i32> = numbers
        .iter()
        .filter(|&&n| n % 2 == 0)
        .map(|&n| n * 2)
        .collect();

    println!("Evens doubled: {:?}", evens);
}

Constructors: new() vs Builder Pattern

Simple case - new() is idiomatic:

struct Point {
    x: f64,
    y: f64,
}

impl Point {
    fn new(x: f64, y: f64) -> Self {
        Point { x, y }
    }
}

fn main() {
    let p = Point::new(1.0, 2.0);
    println!("Point: ({}, {})", p.x, p.y);
}

Complex case - Builder pattern is idiomatic:

struct Server {
    host: String,
    port: u16,
    max_connections: usize,
    timeout_secs: u64,
}

struct ServerBuilder {
    host: String,
    port: u16,
    max_connections: Option<usize>,
    timeout_secs: Option<u64>,
}

impl ServerBuilder {
    fn new(host: impl Into<String>, port: u16) -> Self {
        ServerBuilder {
            host: host.into(),
            port,
            max_connections: None,
            timeout_secs: None,
        }
    }

    fn max_connections(mut self, max: usize) -> Self {
        self.max_connections = Some(max);
        self
    }

    fn timeout_secs(mut self, secs: u64) -> Self {
        self.timeout_secs = Some(secs);
        self
    }

    fn build(self) -> Server {
        Server {
            host: self.host,
            port: self.port,
            max_connections: self.max_connections.unwrap_or(100),
            timeout_secs: self.timeout_secs.unwrap_or(30),
        }
    }
}

fn main() {
    let server = ServerBuilder::new("localhost", 8080)
        .max_connections(500)
        .timeout_secs(60)
        .build();

    println!("Server: {}:{}", server.host, server.port);
}