Back to top

Coding Safely and Swiftly: A Comprehensive Guide to Rust Principles

Published: Feb 10, 2024


Rust is quickly becoming a preferred language for system development. Its principles enable writing low-level code while eliminating entire categories of bugs. This post covers key Rust principles programmers should understand.


Understanding the Stack and Heap


In Rust, the stack and heap are the two pivotal foundations upon which your program’s memory is built. Mastering them is key to building high-performance systems.


When a Rust program starts, the OS sets aside a blank canvas of raw memory. As the Rust compiler explores your code, it decides how to carefully craft this memory into the custom data structures your program needs to come alive - painting some into the tidy, rigid rows of the stack, and sculpting the rest into the more freeform landscape of the heap.


The Stack: Fast and Structured


The stack is designed for speed and simplicity. It holds temporary scoped values in an orderly fashion:


fn factorial(n: u32) -> u32 {

    if n == 0 { 
        return 1; 
    }

    let mut f = 1; // f allocated on the stack  
    for i in 1..n+1 {
        f *= i;  // f updated on stack
    }

    f // f automatically popped off stack
       // when factorial returns  
}

Each thread gets its own dedicated stack space. Whenever Rust calls a function, it paints a fresh stack frame to hold that function’s local data. The values sit neatly stacked in last-in, first-out order, awaiting use.


Accessing stack data is lightning fast - just popping entries straight off the top. And since stack allocations happen in a rigidly structured order, cleaning up also becomes effortless and automated.


But with simplicity comes limitations. The stack is quite spartan in its restrictions:


  • Stack space per thread is small (commonly just a few MB)
  • Values must have fixed sizes known at compile time

For the stack’s constraints, the heap fills the gaps…


The Heap: Flexible with Some Chaos


If the stack mimics tidy rows of vegetable gardens, then the heap is more like a wild greenhouse - dynamic, flexible, but also requiring careful cultivation to prevent unchecked overgrowth.


The heap supports features impossible for the stack:


let s = String::from("Hello world!"); // stored on heap, can resize

let mut v = Vec::new(); 
v.push(1); v.push(2); // vector grows dynamically

The heap allows data structures like strings and vectors to start small but organically expand over time, unlike the stack’s fixed capacities.


But with flexibility comes responsibility. While the stack tidies itself as functions return, the heap has no such innate organization. Values persist independently until actively freed:


drop(s); // explicitly free no-longer-needed heap data

Without drop here, the string data would permanently occupy space until the entire program exits.


Rust’s ownership model cultivates program responsibility to drop unneeded heap allocations properly. Languages lacking Rust’s cultivation tools often encourage wild, unchecked heap overgrowth - vulnerable to all manner of chaotic memory safety issues over time.


By combining the stack’s brisk efficiency for temporary values with the heap’s flexible capacity for dynamic data, Rust empowers you to get the best out both worlds safely.


Moves Versus Copies in Rust


When passing variables between functions in Rust, you’ll hear about “moves” and “copies”. What do these concepts mean?


Moves


A move transfers ownership of a value to another binding:


let v = vec![1, 2, 3]; // v owns vector 

let v2 = v; // v2 takes ownership, v becomes invalid

After a move, the original binding can no longer be used since ownership transferred. Only types implementing Copy trait avoid this.


Moves are efficient - just pointer/integer copies in memory. But we lose access to the original owner.


Many Rust types move by default:


  • Vectors
  • Strings
  • Boxes
  • File handles

These types have a single owner, so moves transfer that ownership.


Copies


Type that implement the Copy trait instead copy themselves when passed to functions:


let x = 5;
let y = x; // x gets copied rather than moved 

  • The original binding remains valid
  • But we pay the cost of copying bits stored on stack

Built-in types like integers, floats, booleans, etc implement Copy by default since they are cheap to duplicate.


But custom types opt out of Copy by default since they usually manage heap data (expensive to copy). We can derive Copy manually in some cases.


Clones


For non-Copy types, we can explicitly request a copy using clone():


let v = vec![1, 2, 3];

let w = v.clone(); // explicity copy vector  

Clones make a deep copy duplicating the entire value, a potentially costly operation.


Sharing Data with References in Rust


References allow multiple parts of code to access the same data without transferring ownership. They come in two flavors:


Shared References


Shared references provide read-only access to data via the &T syntax:


let v = vec![1, 2, 3];  

let a = &v; // shared borrow
let b = &v; // multiple shared borrows allowed

Rules:


  • Multiple shared references are allowed
  • Cannot mutate data via shared reference
  • Underlying data must live at least as long as the references

Shared references prevent accidental modification, allowing safe access.


Mutable References


Mutable references enable read/write access via &mut T syntax:


let mut v = vec![];

let a = &mut v; // mutable borrow  
v.push(1);      // mutation allowed

Rules:


  • Only one active mutable ref at a time
  • No shared references during mutable ref

Rust only allows one path to mutation at a time via mutable references. This prevents data races at compile time.


Ownership and Borrowing - References


Without references, we could only pass data between functions via moves:


fn fill_vec(v: Vec<i32>) {
   // we must return v to retain access 
   // as passing it moved ownership  
   v.push(1);
   v
}

References allow shared access without transfers, minimizing expensive copy/clone costs while preventing accidental modification or ownership transfer when unnecessary.


Ownership and Borrowing


Ownership is one of the features that makes Rust unique. The ownership system allows Rust to guarantee memory safety without needing a garbage collector.


Review of Ownership


In Rust, each value has an owner. The owner is responsible for cleaning up the value once it goes out of scope or is no longer needed. When the owner goes out of scope, the owned value will be dropped automatically.


For example:


{
    let v = Vec::new(); // v is created, v owns the vector data  
                        // allocated on the heap

    // do stuff with v  

} // v goes out of scope, vector data is automatically freed

Ownership can also be transferred. For example, when passing a value to a function, ownership transfers to the function:


fn process(v: Vec<i32>) {
     // v is now owned by the function
}

let v = vec![1, 2, 3];  

process(v); // ownership moves to process  

In addition to ownership transfer, Rust allows immutable borrows via references. References allow access to data without taking ownership. For example:


fn print(v: &Vec<i32>) {
    // borrow v for read-only access
    println!("{:?}", v);  
}

let v = vec![1, 2, 3];
print(&v); // v is immutably borrowed  
           // original owner (v binding) still valid

This single ownership model, along with borrowing, allows Rust to ensure memory safety efficiently.


Borrow Checker


The borrow checker enforces Rust’s ownership and borrowing rules at compile time. It ensures references:


  • Remain valid for their expected lifetimes
  • Do not outlive the data they refer to
  • Do not conflict with mutable accesses or mutation

This prevents dangling pointers, use-after-free errors, and data races.


The borrow checker is why Rust can guarantee memory safety without a garbage collector. The key rules it enforces are:


At any given time, you can have either:

  • One or more references (&T) to a resource
  • One and only one mutable reference (&mut T)

References must always be valid:

  • Can’t return references to local data from a function
  • Can’t store references in structs without proper lifetimes

No aliasing violations:

  • No mutable references alongside any shared references
  • No mutable and shared references in the same scope

Let’s look at some examples to illustrate the borrow checker in action:


let mut v = vec![1, 2, 3];

let a = &v; // immutable borrow
let b = &v; // ok, multiple shared borrows

*a = vec![]; // err: cannot assign through shared reference

v.push(4); // err: cannot mutate v while shared reference exists

The compiler prevents mutation through shared references.


let mut v = vec![1, 2, 3]; 

let a = &mut v; // mutable borrow
v.push(4); // ok: we mutable borrowed v
println!("{:?}", v); // error: borrow 'a' does not live long enough

let b = &v; // err: cannot borrow v again after mutable borrow

The mutable borrow of v prevents sharing v until the mutable reference goes out of scope.


Understanding these rules thoroughly takes practice, but prevents entire categories of unsafe code. Rust allows system programming without compromising reliability.


Debugging Ownership and Borrowing Errors


Common errors involve:


  • Invalidating existing references/borrows
  • Trying to mutate through a shared reference

Useful debugging techniques include:


  • Carefully inspecting borrow checker error messages
  • Visualizing sequence of operations leading to errors
  • Using compiler suggestions about where borrows start/end
  • Reordering code to avoid simultaneous borrows
  • Adding/removing references to fix invalidations

Internalizing borrow checker rules takes practice but prevents entire classes of bugs. Mastering Rust ownership with some perseverance pays dividends through safer system code.


Memory Safety


Memory safety is a critical concern when writing low-level system code. Use after free, double frees, dangling pointers, and leaks can introduce dangerous vulnerabilities.


Rust guarantees complete memory safety through its ownership and borrowing system, without needing garbage collection.


Ownership System


Rust’s ownership model prevents the most common sources of memory unsafety:


  • Use after free - Compile-time borrowing rules prevent accessing data after it goes out of scope or gets dropped.
  • Double free - Owned values are dropped exactly once when their owner goes out of scope.
  • Dangling pointers - References must always point to valid data, enforced by lifetimes.

Let’s examine how Rust prevents each issue:


Use after free


{
  let v = vec![1, 2, 3];

  println!("{:?}", v); // use v  

} // v gets dropped here

println!("{:?}", v); // error: use after free

The compiler stops us from using v after it gets dropped.


Double free


let v = Vec::new(); // v owns the vector allocation

drop(v); // explicitly drop v 

// vector was already dropped, 
// double free is impossible

Owned values like vectors can only be dropped once.


Double free


In languages like C/C++, manually managing memory allocation and deallocation makes it possible to free the same memory multiple times:


int* p = malloc(sizeof(int)); // allocate memory
free(p); // deallocate memory

// but program logic has issues, 
// and p gets freed again:
free(p); // double free!

This is undefined behavior and can lead to crashes or corruption.


Rust’s ownership system makes double frees impossible by enforcing that owned values like Box or Vec get dropped exactly once when their owner goes out of scope:


{
     let v = vec![1, 2, 3]; // allocate vector on heap

     println!("{:?}", v); // use allocation

     // vector gets dropped fully once here    
} 

If we try to explicitly drop v again, it would fail:


let v = Vec::new(); // v owns the vector allocation

drop(v); // explicitly drop v        

// error: use of moved value
drop(v);  
// ^ v was already dropped before

The Rust compiler tracks movings and usages of owned values like v. It prevents all accidental double frees in safe Rust.


This helps eliminate an entire class of memory unsafety issues.


Dangling pointers


fn dangling() -> &Vec<i32> {
    let v = vec![1, 2, 3];

    &v // error: returns reference to local data 
}

Returning references to stack data is disallowed, preventing dangling pointer issues.


The ownership system makes these classes of errors impossible at compile time.


Zero-Cost Abstractions


One of Rust’s key principles is providing low-level control without sacrificing high-level ergonomics. Rust does this through zero-cost abstractions.


What are Zero-Cost Abstractions?


Abstractions in Rust don’t have a run-time cost - they compile away to lower level code. The abstractions make code easier to write and maintain without impacting performance.


For example, Vec<T> provides a resizable array abstraction on top of Rust’s raw pointers. But it compiles to simple heap allocations and pointer manipulation - no overhead over hand written code.


The same applies to constructs like iterators. This iterator pipeline:


let squared = v.iter().map(|n| n * n).filter(|&n| n > 4); 

Compiles into optimal low-level code with no per-iteration costs.


So Rust allows safe, ergonomic code while retaining the performance of unsafe systems languages.


How Rust Achieves Zero-Cost Abstractions


Rust is able to optimize away abstractions through:


  • Value semantics - Copy types have no allocation overhead
  • Trait generic code - Monomorphization optimizes per use case
  • Static dispatch - No dynamic dispatch costs
  • Inlining - Functions often inline to minimal instructions

Combined they enable code that’s as fast as unsafe C/C++ while preventing entire classes of bugs.


Real World Examples


As an example, Servo’s Rust-based web browser engine achieves performance on par with C++ engines through leveraging these zero-cost abstractions.


This combination of safe, ergonomic but low-level access allows Rust to serve as an ideal systems programming language.


Concurrency Principles


Concurrency is increasingly important for taking advantage of multi-core hardware. Rust provides strong support for concurrency along with memory safety guarantees.


Concurrency vs Parallelism


Concurrency enables independent tasks to execute conceptually in parallel while actually interleaving on top of shared hardware resources. Parallelism is utilizing multiple cores simultaneously to literally run code in parallel.


Rust supports both concurrency and parallelism - lightweight threads make concurrency ergonomic while rayon provides parallel iterators.


Ownership and Concurrency


Sharing mutable state between threads leads to data races which cause undefined behavior. Rust ownership principles guarantee thread safety while minimizing overhead:


  • Immutable data can safely be accessed within threads
  • Mutable data cannot be aliased across threads

This compile time enforcement prevents entire classes of issues.


Concurrency Patterns


Common concurrency patterns like thread pools, message passing, and async I/O are ergonomic and efficient in Rust while avoiding dangers like deadlocks at compile time.


Ownership integration makes patterns like async/await trivial compared to other systems languages. Concurrency in Rust delivers power without compromised reliability.


The ecosystem continues to rapidly adopt asynchronous Rust for faster and safer systems.


Safety Without Garbage Collection


Memory safety without relying on garbage collection is one of Rust’s standout features. Rust shows collecting garbage is not required for safe systems programming.


Memory Management in Rust


Most managed languages like Java or Go use automatic garbage collection to reclaim unused memory. However, naive garbage collection has downsides for systems code:


  • Runtime overhead
  • Pause times interrupting execution
  • Requiring excess memory to reduce pauses

In contrast, Rust uses deterministic RAII based cleanup:


  • Values automatically dropped when owners go out of scope
  • Reference counting in some cases ensures prompt cleanup

This provides same safety guarantees as collecting garbage but with minimal overhead.


Preventing Memory Leaks


Garbage collection languages preclude entire classes of errors like use after free. But issues like leaks are still possible through accumulating unnecessary allocations over time.


Rust sidesteps classes of issues like leaks through scope based cleanup. But leaks are still possible by losing the last owned handle to allocated memory. Thankfully tooling like leak checking in tests can catch such issues. By verifying leaks aren’t introduced, Rust enables leak freedom.


The ownership model enables complete memory safety without runtime downsides. Rust showcases an explicit safe systems language is achievable.


Practical Examples


Rust’s principles like ownership and borrowing directly translate to writing better systems code. Let’s look at some hands-on examples.


File I/O


Interacting with files is a common task. Rust makes this safe and easy:


use std::fs;

fn read_file(path: &str) -> std::io::Result<String> {
    let mut f = fs::File::open(path)?;

    let mut buffer = String::new();
    f.read_to_string(&mut buffer)?;

    Ok(buffer)
}

  • fs::File closes itself automatically when it drops, preventing resource leaks
  • Immutable borrows via &str prevent accidentally mutating path data
  • Idiomatic error handling with Result makes control flow clear

Ownership enables an expressive and leak-free API for file handling.


Async Networking


Async I/O concurrency is efficient and easy with ownership:


async fn fetch_url(url: &str) -> Result<String> {
    let response = reqwest::get(url).await?;
    let text = response.text().await?;

    Ok(text)  
}

  • Asynchronous code reads similarly to synchronous style
  • Ownership automatically closes network connections
  • Error handling ensures failures handled properly

Rust allows clear and safe async code without overhead.


So Rust’s principles translate directly into superior systems code - safety and speed without compromise.


Best Practices in Rust


Writing idiomatic Rust not only improves safety but enhances developer productivity. Here are some best practices to follow.


Coding Guidelines


  • Leverage ownership to minimize explicit memory management
  • Use enums to encode complex state spaces cleanly
  • Break down code into small, composable functions
  • Use references to pass data between functions safely
  • Handle errors systematically via Result or panic when appropriate

Following Rust’s idioms leads to code that’s both robust and readable.


Testing


Rust’s tooling enables excellent testing ergonomics:


  • Unit test individual modules in isolation
  • Property test functionality via quickcheck
  • Fuzz test with arbitrary input data

Taking advantage of Rust’s ecosystem allows thorough testing without substantial overhead.


Adopting Rust best practices leads to maintainable codebases developers enjoy working in.


Conclusion


Rust provides a unique set of principles enabling safe systems programming without runtime downsides. Ownership and borrowing in Rust empower developers to write robust systems code with minimal overhead.


With support for low-level control, zero-cost abstractions, ergonomic concurrency, and guaranteed memory safety, Rust establishes itself as an emerging leader for next generation systems programming. Companies from startups to large tech giants are increasingly adopting Rust to improve the reliability of foundational infrastructure.


By providing both high programmer productivity and low-level efficiency without compromising reliability, Rust represents an exciting step forward for our industry. Any aspiring systems developer should strongly consider adding Rust skills to their repertoire.