Skip to Content
Course content

222: Implementing a Doubly Linked List with Unsafe Rust

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've always found that the best way to wrap your head around raw pointers is to build a doubly linked list. It's the classic "final boss" of memory management. In safe Rust, a doubly linked list is a nightmare because the borrow checker sees the circular references—where a node points to the next and the next points back to the previous—as a violation of ownership rules. You could use Rc<RefCell<T>>, but that adds runtime overhead and feels like you're fighting the language.

Today, we're going to go "off-road" with unsafe. We'll build a simple Playlist manager. The goal is to have a list of song titles that we can traverse in both directions. To do this, we'll use NonNull, which is essentially a wrapper around a raw pointer that tells Rust the pointer will never be null, allowing the compiler to optimize Option<NonNull<T>> to be the same size as a single pointer.

Defining the Node and the List

First, we need a Node. Since we're managing memory manually, we can't rely on standard references. We need raw pointers for both the next and prev links.

use std::ptr::NonNull;
use std::marker::PhantomData;

struct Node<T> {
    val: T,
    next: Option<NonNull<Node<T>>>,
    prev: Option<NonNull<Node<T>>>,
}

pub struct Playlist<T> {
    head: Option<NonNull<Node<T>>>,
    tail: Option<NonNull<Node<T>>>,
    len: usize,
    _marker: PhantomData<T>,
}

I added PhantomData here. Because the Playlist struct only holds raw pointers, Rust doesn't actually "know" that Playlist owns T. PhantomData tells the compiler to treat the struct as if it owns T, which ensures the correct variance and drop-check behavior.

Adding the first song

Now, let's implement push_front. This is where we enter the unsafe block. We have to allocate memory on the heap using Box::new, then immediately turn that box into a raw pointer using Box::into_raw. This tells Rust: "Stop managing this memory; I've got it from here."

impl<T> Playlist<T> {
    pub fn new() -> Self {
        Self { head: None, tail: None, len: 0, _marker: PhantomData }
    }

    pub fn push_front(&mut self, val: T) {
        let new_node = Box::new(Node {
            val,
            next: self.head,
            prev: None,
        });
        
        let node_ptr = NonNull::new(Box::into_raw(new_node)).unwrap();

        unsafe {
            if let Some(mut old_head) = self.head {
                old_head.as_mut().prev = Some(node_ptr);
            } else {
                self.tail = Some(node_ptr);
            }
            self.head = Some(node_ptr);
        }
        self.len += 1;
    }
}

The "missing link" bug

When I first wrote this logic a few years ago, I made a classic mistake. I updated the head of the list, but I forgot to update the prev pointer of the node that used to be the head. I'd push three items, and then try to traverse backward from the tail, only to realize I could only go back one step before hitting a None.

If you look at the code above, the if let Some(mut old_head) = self.head block is the fix. It ensures that the existing list is properly linked back to the new node. Without that line, you don't have a doubly linked list; you just have a singly linked list with some useless prev fields.

Cleaning up the heap

Here is the most dangerous part. Since we used Box::into_raw, Rust's automatic memory management is disabled for these nodes. If our Playlist goes out of scope, we have a massive memory leak. We must implement the Drop trait to manually walk the list and reclaim the memory.

impl<T> Drop for Playlist<T> {
    fn drop(&mut self) {
        while let Some(node_ptr) = self.head {
            unsafe {
                let node = Box::from_raw(node_ptr.as_ptr());
                self.head = node.next;
                // node is dropped here as it goes out of scope
            }
        }
    }
}

Notice how I use Box::from_raw. This effectively "re-claims" the memory from the raw pointer and puts it back into a Box. Once that Box goes out of scope at the end of the while loop iteration, Rust's normal cleanup kicks in and the memory is freed. It's a delicate dance, but it's the only way to ensure our Playlist doesn't eat all the system RAM.




📋 Practical Task

Implement the push_back Method for Playlist

Now that you've seen how push_front works, your task is to implement the push_back method for the Playlist<T> struct. This method should add a new node to the end of the list and correctly update the tail pointer and the next pointer of the previous tail node.

Requirements:

  • Use Box::into_raw to allocate the new node.
  • Correctly handle the case where the list is currently empty (both head and tail should point to the new node).
  • Ensure the previous tail's next pointer is updated to point to the new node.
  • Increment the len of the playlist.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.