Jump to content

Rust’s Seven Cardinal Sins: Difference between revisions

From JOHNWICK
PC (talk | contribs)
Created page with "500px The typical advice about Rust focuses on fighting the borrow checker or avoiding unwrap. After building distributed systems in Rust for years, I’ve found the real sins are more subtle. These are mistakes that experienced developers make after they’ve learned the basics, patterns that seem reasonable until they surface under production load as mysterious performance degradation or unexpected memory pressure. Sin #1: Fighting the Opt..."
 
(No difference)

Latest revision as of 04:47, 18 November 2025

The typical advice about Rust focuses on fighting the borrow checker or avoiding unwrap. After building distributed systems in Rust for years, I’ve found the real sins are more subtle. These are mistakes that experienced developers make after they’ve learned the basics, patterns that seem reasonable until they surface under production load as mysterious performance degradation or unexpected memory pressure.

Sin #1: Fighting the Optimizer with Explicit Drop

There’s a peculiar obsession with controlling drop order that surfaces in code reviews. Developers coming from C++ where destructor ordering is critical start sprinkling drop() calls throughout their Rust code, convinced they're being explicit and careful.

fn process_batch(data: Vec<LargeItem>) {
    let processed = transform(&data);
    drop(data); // "Free memory early!"
    store_results(processed);
}

The thinking seems sensible. Release that memory before the next operation. But Rust’s drop semantics are fundamentally different from C++ destructors. The compiler already inserts drops at the optimal points, and LLVM’s optimization passes understand the lifetime semantics. That explicit drop doesn’t free memory any earlier because the compiler would have dropped it at exactly that point anyway based on last use analysis.

What explicit drops actually do is prevent optimizations. The compiler can no longer reorder operations across that drop boundary. In a hot loop processing streaming data, this can prevent vectorization or other LLVM transformations that depend on proving no observable side effects occur between operations.

The exception is when you’re actually trying to control lock guard lifetimes or need to prove to the compiler that a mutable borrow has ended. But even then, a nested scope is clearer than an explicit drop because it signals intent rather than implementation detail.

fn process_batch(data: Vec<LargeItem>) {
    let processed = transform(&data);
    // Compiler drops data here at last use
    store_results(processed);
}

Trust the compiler. It understands the lifetime analysis better than you do. When profiling actual production code, I’ve never found a case where manual drop placement improved performance, but I’ve found several where it prevented optimizations.

Sin #2: Lifetime Annotation Cargo Cult

Developers learn about lifetime parameters and suddenly every function signature sprouts lifetime annotations like mushrooms after rain. The thinking is that explicit is better, that annotating lifetimes makes the code clearer and gives the compiler more information.

fn find_longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

This seems perfectly reasonable until you realize Rust has lifetime elision rules specifically designed to eliminate this noise. The compiler doesn’t need these annotations because the pattern is unambiguous. The actual signature can be:

fn find_longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}

The elision rules are deterministic. One input reference means the output borrows from it. Multiple input references of the same type means they must have compatible lifetimes. The compiler infers this automatically, and the explicit annotations add nothing but visual noise.

The real problem emerges in complex codebases where developers start adding lifetime parameters prophylactically. Structs that don’t need them get lifetime parameters. Methods that could use elision get explicit annotations. This creates a cascade where changing one lifetime annotation requires changing dozens of others, making refactoring painful.

Worse, over-specified lifetimes can actually prevent the compiler from making valid inferences. By being too explicit, you can force the compiler into a more constrained solution than necessary. I’ve debugged borrow checker errors that disappeared when removing explicit lifetime annotations because the elided version gave the compiler more flexibility to infer the actual relationships.

The principle is simple: only add lifetime annotations when the compiler asks for them, and when you do, think carefully about whether you’re actually clarifying relationships or just annotating the obvious.

Sin #3: Allocation Blindness

The most expensive operation in a tight loop isn’t usually the algorithm. It’s the allocations you don’t see. Rust makes allocation explicit with Box and Vec, but there are subtle cases where allocations hide in plain sight. Consider a function that processes strings in a message broker consuming millions of events per second:

fn sanitize(input: &str) -> String {
    input.chars()
        .filter(|c| c.is_alphanumeric())
        .collect()
}

Every call allocates a new String. When this runs in a hot path, the allocator becomes the bottleneck. The heap pressure triggers garbage collection in any allocations that use jemalloc or the system allocator, and suddenly your throughput drops by 40%. The subtle sin is not seeing that you could avoid most of these allocations with a buffer pool or by accepting a mutable buffer:

fn sanitize_into(input: &str, output: &mut String) {
    output.clear();
    output.extend(input.chars().filter(|c| c.is_alphanumeric()));
}

This pattern shows up everywhere. Format strings that allocate when you could write to a buffer. Error types that allocate strings when a static message would suffice. Intermediate vectors in iterator chains when a single pass would work.

The principle that makes this subtle is that Rust’s ownership model makes you think about moves and borrows, but it doesn’t make allocations visible the way C’s malloc does. You have to develop a sense for where allocations happen, and that comes from profiling real code under load. In a distributed malware detection system, the difference between allocating on every message versus reusing buffers can be the difference between saturating your network or saturating your CPU with allocator overhead.

Sin #4: Copy-on-Write Cargo Cult

The Cow type seems like a clever optimization. Why allocate when you might not need to? Just use Cow and let it allocate only when you actually modify something. This reasoning leads to codebases full of Cow types that never optimize anything.

fn process(data: Cow<str>) -> Cow<str> {
    if data.contains("bad") {
        Cow::Owned(data.replace("bad", "good"))
    } else {
        data
    }
}

The problem is that Cow is an enum, which means every access checks a discriminant. When the common case is that you always modify the data, you’re paying the cost of the enum checks plus the allocation, versus just allocating upfront. Cow is only a win when the no-modification path is common enough to amortize the checking overhead.

More subtly, Cow in APIs forces complexity on callers. They have to understand the borrowing semantics. They have to know when to pass Borrowed versus Owned. The cognitive overhead often exceeds any performance benefit, especially in code that isn’t actually allocation-sensitive.

The real use case for Cow is when you have static data that occasionally needs modification, like configuration values with templating. The static path is overwhelmingly common, and the modification path is rare. But as a general API pattern, it usually just adds complexity without meaningful optimization.

I’ve seen this pattern especially in parsing code where developers think Cow will help avoid string copies. But if you’re parsing untrusted input, you’re validating every field anyway, which often means you’re effectively copying the data. The Cow wrapper just adds indirection without benefit.

Sin #5: Monomorphization Explosion

Generics in Rust are compiled through monomorphization, which means every concrete type combination generates a separate copy of the function in the binary. This is why Rust generics have zero runtime cost, but it has a compile-time and binary size cost that can become severe.

pub fn process<T: Message>(msg: T) -> Result<(), Error> {
    validate(&msg)?;
    transform(&msg)?;
    store(&msg)
}

If this function is called with 50 different message types, you get 50 copies of the function in your binary. For small functions this is fine, but for large functions with complex logic, the binary bloat becomes significant. More importantly, the instruction cache pressure from having 50 nearly-identical large functions can hurt performance more than the zero-cost abstraction helps. The alternative is trait objects with dynamic dispatch, which has runtime cost but prevents code duplication:

pub fn process(msg: &dyn Message) -> Result<(), Error> {
    validate(msg)?;
    transform(msg)?;
    store(msg)
}

The conventional wisdom is that generics are always faster because they avoid virtual dispatch. But in practice, when you have many concrete types and large functions, the instruction cache effects can dominate. The virtual dispatch cost is predictable and small, while instruction cache misses from code bloat are unpredictable and large.

This becomes especially pronounced in plugin systems or anywhere you have open type sets. A message broker that handles arbitrary message types will have better performance characteristics with trait objects than with monomorphized generics, despite the theoretical cost of dynamic dispatch.

The subtlety is knowing when you’re in the regime where monomorphization helps versus hurts. Small functions with few concrete types benefit from monomorphization. Large functions with many concrete types often benefit from trait objects. But the default instinct is to always reach for generics because they feel more “zero-cost.”

Sin #6: Send and Sync Boundary Confusion

The Send and Sync marker traits are automatic for most types, which leads to a dangerous assumption that they’re always correct. The compiler infers them based on the types you contain, but it can’t verify that your unsafe code actually maintains the invariants.

Consider a lock-free data structure using crossbeam:

pub struct Queue<T> {
    inner: crossbeam::queue::SegQueue<T>,
    stats: Cell<Stats>,
}

The SegQueue is Send and Sync because it’s designed for concurrent use. Cell is not Sync because it provides interior mutability without synchronization. But the compiler marks this whole struct as not Sync, which prevents you from sharing it across threads, even though your actual usage pattern is safe because you only access stats from a single thread.

The temptation is to wrap it in unsafe impl and assert that it’s Sync:

unsafe impl<T> Sync for Queue<T> where T: Send {}

This compiles, but now you’ve made a promise to the compiler that you might not keep. If anyone adds code that accesses stats from multiple threads, there’s no compile-time check anymore. You’ve created a time bomb.

The correct solution is to recognize that your abstraction leaks. If stats is truly single-threaded, it shouldn’t be part of the shared structure. Factor it out into a separate type that lives in the thread that uses it. Or use proper atomic operations if you actually need cross-thread access. The sin is assuming that Send and Sync are just annotations you can fix with unsafe impl when the compiler complains. They’re actually invariants that your entire API must maintain, and unsafe impl is a promise that you’ve verified the unsafe code maintains those invariants even though the compiler can’t check them.

In distributed systems where you’re building lock-free algorithms, this becomes critical. A wrong Send or Sync impl doesn’t cause immediate crashes. It causes race conditions that only appear under production load when timing is just wrong, and they’re nearly impossible to reproduce in development.

Sin #7: Destructor Ordering Assumptions

Rust guarantees that destructors run, but it doesn’t guarantee they run at any particular time or in any particular order relative to other threads. Code that assumes destructor ordering for correctness breaks in subtle ways.

struct LogFile {
    file: BufWriter<File>,
}
impl Drop for LogFile {
    fn drop(&mut self) {
        self.file.flush().ok(); // Flush before closing
    }
}
static LOGGER: Mutex<Option<LogFile>> = Mutex::new(None);
fn shutdown() {
    *LOGGER.lock().unwrap() = None; // Destructor flushes the log
}

This looks reasonable. The Drop implementation flushes the buffer, so closing the logger should flush all pending writes. But this assumption breaks when the program terminates. If another thread panics or the process exits while the destructor is running, the flush might not complete. Worse, if the destructor itself returns an error, it gets swallowed by the Ok() call.

The subtle trap is thinking destructors are like finally blocks in other languages. They’re not. They’re best-effort cleanup that runs when memory is reclaimed, but they’re not guaranteed to complete. Any operation in a destructor that must complete for correctness is a bug waiting to happen.

For logging, you need explicit shutdown that handles errors:

fn shutdown() -> Result<(), io::Error> {
    let mut logger = LOGGER.lock().unwrap();
    if let Some(ref mut log) = *logger {
        log.file.flush()?;
    }
    *logger = None;
    Ok(())
}

Now flush errors propagate, and you can handle them appropriately. The destructor can still try to flush as a fallback, but critical operations happen explicitly where you can handle errors. This pattern appears in resource management everywhere. Network connections that need graceful shutdown. Database transactions that need commit. File handles that need fsync. Any time you put critical operations in a destructor, you’re assuming the destructor will run to completion, and that assumption is wrong.

In an actor system like Pekko, this becomes especially important. If an actor panic causes unwinding, your destructors might run concurrently with other actors still processing messages. Any assumptions about ordering or atomicity break down. Critical cleanup must be explicit, not implicit in destructors.

The Pattern Behind the Sins

These seven sins share a common thread. They all stem from treating Rust like a language you already know rather than learning its actual semantics. Fighting the optimizer is treating Rust like C++ where you control everything. Lifetime cargo cult is treating explicit like Python where type annotations are documentation. Allocation blindness is treating Rust like Java where allocation is invisible. Cow cargo cult is premature optimization without measurement. Monomorphization explosion is ignoring the actual costs of zero-cost abstractions. Send/Sync confusion is treating unsafe as an escape hatch rather than a contract. Destructor assumptions are treating Drop like a finally block.

The way out is the same in every case. Learn what the compiler actually does. Profile the real behavior. Understand the guarantees Rust provides and the ones it doesn’t. The type system is sophisticated, but it’s not magic. Zero-cost abstractions have costs, just not runtime costs. Unsafe code must maintain invariants manually. Destructors are cleanup, not error handling.

Rust rewards understanding its model deeply rather than pattern matching against languages you already know. These subtle sins persist because they seem reasonable until they fail under load, and then they’re hard to debug because the failure modes are non-obvious. The best way to avoid them is to internalize Rust’s actual semantics rather than approximating them with mental models from other languages.