DEV Community

Cover image for Rust Smart Pointers: Safe Memory Management Without Garbage Collection
Aarav Joshi
Aarav Joshi

Posted on

Rust Smart Pointers: Safe Memory Management Without Garbage Collection

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

When I first started programming, memory management felt like a constant battle. I'd allocate memory, forget to free it, and end up with leaks or crashes. Then I discovered Rust, and its smart pointers changed everything. These tools handle memory for you, making it safe and efficient without the headaches. Let me walk you through how they work, with plenty of examples to make it clear.

Memory in computers is like a big storage room. Sometimes, you need to put things on the heap, which is a flexible space, rather than the stack, which is more rigid. Rust's smart pointers help you manage that heap memory. They're not like regular pointers in other languages that can easily lead to mistakes. Instead, they come with rules that the compiler checks, so you catch problems before your code even runs.

The simplest smart pointer is Box. It lets you store data on the heap and gives you one owner for that data. Think of it as putting a value in a box – only one person can hold the box at a time. When the box goes out of scope, Rust automatically cleans up the memory. This is perfect for big data or when you don't know the size at compile time, like in recursive structures.

fn main() {
    let my_number = Box::new(100);
    println!("The number is: {}", *my_number);
}
Enter fullscreen mode Exit fullscreen mode

In this code, we create a Box that holds the number 100. The * operator lets us get the value inside. When my_number goes out of scope at the end of main, the Box is dropped, and the memory is freed. No manual cleanup needed. I use this all the time for storing large structs or when building trees where a node might point to others.

Sometimes, you need multiple parts of your program to share the same data. That's where reference counting comes in. Rust has Rc for single-threaded programs and Arc for multi-threaded ones. These keep track of how many references exist to a piece of data. When the last reference is gone, the data is cleaned up. It's like having a shared book – as long as someone is reading it, it stays on the shelf.

use std::rc::Rc;

fn main() {
    let shared_text = Rc::new("Shared data".to_string());
    let copy_one = Rc::clone(&shared_text);
    let copy_two = Rc::clone(&shared_text);
    println!("Number of references: {}", Rc::strong_count(&shared_text));
}
Enter fullscreen mode Exit fullscreen mode

Here, we create a string inside an Rc. Each call to Rc::clone doesn't copy the data; it just increases the reference count. We print the count, which shows 3 because we have the original and two clones. When all these go out of scope, the string is dropped. I've used this in apps where multiple components need access to the same configuration data without duplicating it.

But what if you need to change data that's shared? Rust's borrowing rules usually prevent that, but interior mutability tools like RefCell and Mutex let you work around it safely. RefCell checks the rules at runtime instead of compile time. It allows you to borrow mutably even when you have immutable references, as long as no two mutable borrows happen at the same time.

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(5);
    {
        let mut borrow = data.borrow_mut();
        *borrow += 1;
    }
    println!("Data is now: {}", data.borrow());
}
Enter fullscreen mode Exit fullscreen mode

In this example, we wrap an integer in a RefCell. We borrow it mutably to change the value, and then borrow immutably to print it. If we tried to borrow mutably twice at the same time, the program would panic at runtime. I find this useful in situations where I need to update a cache without exposing mutable access to the outside world.

For multi-threaded code, Mutex is the way to go. It ensures that only one thread can access the data at a time by using locks. This prevents data races, where multiple threads try to change data simultaneously and cause corruption.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Enter fullscreen mode Exit fullscreen mode

This code uses Arc to share a Mutex-protected counter across threads. Each thread locks the Mutex, increments the counter, and then releases the lock. The Arc makes sure the counter is shared safely. I've built web servers where multiple threads handle requests and update shared statistics using this pattern.

Now, let's talk about real-world uses. Imagine building a graph where nodes point to each other. With raw pointers, you might create cycles that lead to memory leaks. But in Rust, you can use Rc and RefCell together to manage this.

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

#[derive(Debug)]
struct Node {
    value: i32,
    neighbors: Vec<Rc<RefCell<Node>>>,
}

fn main() {
    let node1 = Rc::new(RefCell::new(Node { value: 1, neighbors: Vec::new() }));
    let node2 = Rc::new(RefCell::new(Node { value: 2, neighbors: Vec::new() }));

    node1.borrow_mut().neighbors.push(Rc::clone(&node2));
    node2.borrow_mut().neighbors.push(Rc::clone(&node1));

    println!("Graph created with cycles");
}
Enter fullscreen mode Exit fullscreen mode

Here, each node has a list of neighbors, and we create a cycle between node1 and node2. Because we're using Rc, the reference count never drops to zero, but Rust's ownership model helps us avoid leaks by ensuring we design carefully. In practice, I might use weak references or other patterns to break cycles if needed.

Another common use is in observer patterns, where objects need to notify others of changes. With RefCell, you can have a list of observers that can be updated without making the main object mutable.

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

trait Observer {
    fn update(&self);
}

struct Subject {
    observers: RefCell<Vec<Rc<dyn Observer>>>,
    state: i32,
}

impl Subject {
    fn new() -> Self {
        Subject { observers: RefCell::new(Vec::new()), state: 0 }
    }

    fn attach(&self, observer: Rc<dyn Observer>) {
        self.observers.borrow_mut().push(observer);
    }

    fn set_state(&mut self, state: i32) {
        self.state = state;
        self.notify();
    }

    fn notify(&self) {
        for observer in self.observers.borrow().iter() {
            observer.update();
        }
    }
}

struct ConcreteObserver;

impl Observer for ConcreteObserver {
    fn update(&self) {
        println!("Observer notified!");
    }
}

fn main() {
    let subject = Rc::new(RefCell::new(Subject::new()));
    let observer = Rc::new(ConcreteObserver);
    subject.borrow().attach(Rc::clone(&observer));
    subject.borrow_mut().set_state(10);
}
Enter fullscreen mode Exit fullscreen mode

In this setup, the Subject has a list of observers stored in a RefCell. When the state changes, it notifies all observers. The RefCell allows us to mutate the observer list even though we're borrowing the subject immutably in some places. I've implemented this in GUI frameworks where UI components need to react to data changes.

Comparing Rust to other languages, like C++, shows why Rust's approach is safer. In C++, smart pointers can still have issues like circular references that cause memory leaks. Rust's compile-time checks prevent many of these problems. For example, the ownership model ensures that data has a clear owner, and the borrow checker makes sure references are valid.

// Rust code that prevents double-free errors
fn main() {
    let data = Box::new(42);
    // If I try to use data after moving it, the compiler stops me
    // let another = data; // This would move ownership
    // println!("{}", data); // Error: borrow of moved value
}
Enter fullscreen mode Exit fullscreen mode

If I uncommented those lines, Rust would give an error because data was moved. In C++, similar code might compile and crash at runtime. I remember spending hours debugging such issues in other languages, but Rust catches them early.

For advanced users, you can create your own smart pointers. This is handy if you have special memory needs, like integrating with a custom allocator or managing resources beyond memory, like files or network connections.

use std::ops::Deref;
use std::ops::DerefMut;

struct MyPointer<T> {
    value: T,
}

impl<T> MyPointer<T> {
    fn new(value: T) -> Self {
        MyPointer { value }
    }
}

impl<T> Deref for MyPointer<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.value
    }
}

impl<T> DerefMut for MyPointer<T> {
    fn deref_mut(&mut self) -> &mut T {
        &mut self.value
    }
}

impl<T> Drop for MyPointer<T> {
    fn drop(&mut self) {
        println!("Cleaning up MyPointer with value");
    }
}

fn main() {
    let my_ptr = MyPointer::new("Custom data");
    println!("Value: {}", *my_ptr);
}
Enter fullscreen mode Exit fullscreen mode

This custom pointer implements Deref and DerefMut so it can be used like a Box, and Drop to clean up. When it goes out of scope, it prints a message. I've used similar patterns in projects where I needed to log when resources are freed, like closing database connections.

Speaking of resources, smart pointers aren't just for memory. They can manage anything that needs cleanup, like files or network sockets. By implementing Drop, you can ensure resources are released properly.

use std::fs::File;
use std::io::{Write, Result};

struct FileHandler {
    file: File,
}

impl FileHandler {
    fn new(path: &str) -> Result<Self> {
        let file = File::create(path)?;
        Ok(FileHandler { file })
    }

    fn write_data(&mut self, data: &str) -> Result<()> {
        self.file.write_all(data.as_bytes())
    }
}

impl Drop for FileHandler {
    fn drop(&mut self) {
        println!("File closed automatically");
    }
}

fn main() -> Result<()> {
    {
        let mut handler = FileHandler::new("example.txt")?;
        handler.write_data("Hello, world!")?;
    } // handler goes out of scope here, file is closed
    println!("File handled safely");
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

In this code, FileHandler wraps a file. When it's dropped, we print a message, but in real use, the file is closed automatically by the OS. This pattern ensures I never leave files open accidentally. I've applied this to network sockets in servers, where connections must be closed to free resources.

Performance is a big deal in programming, and smart pointers have different costs. Box is very fast – it's almost as quick as using raw pointers because it has little overhead. Rc and Arc add a bit of cost for reference counting, but they avoid the pauses you get with garbage collectors in languages like Java. Choosing the right one depends on your needs. For most cases, start with Box, and only use Rc or Arc when you need sharing.

In my projects, I profile the code to see if the overhead matters. For example, in a high-performance game engine, I might avoid Rc in hot loops, but for a configuration manager, it's fine. Arc is essential in web servers where data is shared across threads.

To sum up, Rust's smart pointers make memory management straightforward and safe. They prevent common errors, work efficiently, and can be extended for custom needs. By using them, I've built more reliable software with less debugging. If you're new to Rust, start with Box and gradually explore others as you need them. The compiler is your friend here, guiding you to correct code. With practice, you'll find smart pointers are a powerful tool in your programming toolkit.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)