Doubly Linked List
is the next fundamental collection that is useful to understand. It goes further from Singly Linked List
by allowing appending elements from both sides, but this comes at the cost of more complex implementation. This article will cover all the details you need to implement this data structure in Rust.
This article is a continuation of a previous one related to Singly Linked List
. Have a look at it if you are unfamiliar with Singly Linked List
yet: Rust Singly Linked List Implementation.
General Idea
Doubly Linked List
(or Linked List
) is a dynamically sized data collection that allows adding and popping elements in constant time from the start and end. These properties must implement other data structures like Stack
, Queue
, and Deque
.
Representation
Linked List
consists of several Nodes
. Each Node
contains a value itself and pointers to the Node
s after and before it. There are also two pointers called HEAD
and TAIL
, pointing to the first Node
and the last one, respectively. They are used to push or pop elements from both sides.
ListNode Implementation
Let’s start by implementing a ListNode
struct.
use std::{cell::RefCell, rc::Rc};
struct ListNode<T> {
item: T,
next: Link<T>,
prev: Link<T>,
}
impl<T> ListNode<T> {
fn new(item: T) -> Self {
Self {
item,
next: None,
prev: None,
}
}
}
type Link<T> = Option<Rc<RefCell<ListNode<T>>>>;
Firstly, you can see that we added a second link here, compared to Singly Linked List
. It’s called prev
and is pointing to the previous Node. Note that we are using shorthand type Link<T>
, an optional link to the Node
. It’s handy to add such types in Rust for convenience.
Doubly Linked List Implementation
Structure and basic methods
Secondly, let’s add the main structure together with the basic methods that are easy to implement:
#[derive(Default)]
pub struct DoublyLinkedList<T> {
head: Link<T>,
tail: Link<T>,
size: usize,
}
impl<T> DoublyLinkedList<T> {
pub fn new() -> Self {
Self {
head: None,
tail: None,
size: 0,
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn len(&self) -> usize {
self.size
}
}
We define head
and tail
, as discussed earlier, to operate on the nodes. Also, we added size
variable to implement methods len()
and is_empty()
, that are useful for collections.
Push methods
Let’s add the push_back
method next.
pub fn push_back(&mut self, item: T) {
let node = Rc::new(RefCell::new(ListNode::new(item)));
if let Some(prev_tail) = self.tail.take() {
prev_tail.borrow_mut().next = Some(Rc::clone(&node));
node.borrow_mut().prev = Some(prev_tail);
self.tail = Some(node);
self.size += 1;
} else {
self.head = Some(Rc::clone(&node));
self.tail = Some(node);
self.size = 1;
}
}
First of all, we build the new node that should be added. Then we check a case if our list is empty.
- If the list is empty, we should point
head
andtail
to the new node and setsize
to 1. - Otherwise, we assign
prev
andnext
pointers appropriately for thetail
and newly addednode
and then pointtail
to the new location. Size is increased by 1.
The implementation for push_front
is similar. We use head
instead of tail
here.
pub fn push_front(&mut self, item: T) {
let node = Rc::new(RefCell::new(ListNode::new(item)));
if let Some(prev_head) = self.head.take() {
prev_head.borrow_mut().prev = Some(Rc::clone(&node));
node.borrow_mut().next = Some(prev_head);
self.head = Some(node);
self.size += 1;
} else {
self.head = Some(Rc::clone(&node));
self.tail = Some(node);
self.size = 1;
}
}
Pop methods
Pop methods are a little more complicated, but I will go line by line to explain them.
pub fn pop_back(&mut self) -> Option<T> {
self.tail.take().map(|prev_tail| {
self.size -= 1;
match prev_tail.borrow_mut().prev.take() {
Some(node) => {
node.borrow_mut().next = None;
self.tail = Some(node);
}
None => {
self.head.take();
}
}
Rc::try_unwrap(prev_tail).ok().unwrap().into_inner().item
})
}
Remember that tail
is Link<T>
which is Option<...>
? Option
has a map
method that will apply a function to the Some(...)
value or return None
otherwise.
That means, if the List
is empty, we will return None
, and the function will not be called. Otherwise, it will be called, and we will return an item
from the tail
.
The function implementation is straightforward: if the tail
has the previous Node
, we will point the tail
to it and remove the next
pointer. Otherwise, we should empty the head
and tail
, as the size
is 0
, and they should be None
.
The last line unwraps the Node
, moves value out of RefCell
by calling into_inner()
, and returns .item
property. The result of this function is then wrapped in Some(...)
.
The implementation of pop_front
is similar:
pub fn pop_front(&mut self) -> Option<T> {
self.head.take().map(|prev_head| {
self.size -= 1;
match prev_head.borrow_mut().next.take() {
Some(node) => {
node.borrow_mut().prev = None;
self.head = Some(node);
}
None => {
self.tail.take();
}
}
Rc::try_unwrap(prev_head).ok().unwrap().into_inner().item
})
}
Drop Implementation
As you may have noticed, we are using Rc<RefCell<...>>
to refer to the node in our code. Rc
is an intelligent pointer in Rust that allows several references to the exact location. The value is stored in a heap and will be removed only when the references count equals 0
. The advantage of this pointer is that the borrow checker will not complain about using it, and it’s entirely safe to do so. However, there is one caveat here.
As we have cycle references in our Linked List
, the count will never become 0
, and the value will be stuck in memory forever! To handle that case, we should implement the Drop
trait ourselves and remove references from all the Nodes. Hopefully, the implementation is simple enough:
impl<T> Drop for DoublyLinkedList<T> {
fn drop(&mut self) {
while let Some(node) = self.head.take() {
let _ = node.borrow_mut().prev.take();
self.head = node.borrow_mut().next.take();
}
self.tail.take();
}
}
This code replaces self.head
and self.tail
with None
and drops their initial values. Then it traverses all the Node
s from head
to the tail
, replacing their prev
and next
links.
This removes all the references in the memory, and Rc
implementation will drop the values behind the scenes.
To check that this will work, we can create a dumb structure called Token
and add it to the ListNode
like this:
struct Token {}
impl Token {
fn new() -> Self {
println!("CREATED");
Self {}
}
}
impl Drop for Token {
fn drop(&mut self) {
println!("DESTROYED");
}
}
struct ListNode<T> {
item: T,
next: Link<T>,
prev: Link<T>,
token: Token,
}
impl<T> ListNode<T> {
fn new(item: T) -> Self {
Self {
item,
next: None,
prev: None,
token: Token::new(),
}
}
}
Then we can write a test like this to verify it:
#[test]
fn it_drops_correctly() {
let mut list = DoublyLinkedList::new();
for i in 0..3 {
list.push_back(i);
}
drop(list);
}
And run it:
➜ linkedlist git:(double-linked-list) cargo t it_drops_correctly -- --nocapture
Compiling linkedlist v0.1.0 (/home/rostyslav/Programming/linkedlist)
Finished test [unoptimized + debuginfo] target(s) in 0.71s
Running unittests (target/debug/deps/linkedlist-32e634d8d6bef48f)
running 1 test
CREATED
CREATED
CREATED
DESTROYED
DESTROYED
DESTROYED
test doubly_linked_list::tests::it_drops_correctly ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 12 filtered out; finished in 0.00s
As you can see, there were 3
items added to the list, and the program removed them all. It works!
Iterator Implementation
To be more user-friendly, we can add the Iterator
trait. As our Linked List
is double-ended, we can also implement DoubleEndedIterator
. Here are implementations:
impl<T> IntoIterator for DoublyLinkedList<T> {
type Item = <ListIterator<T> as Iterator>::Item;
type IntoIter = ListIterator<T>;
fn into_iter(self) -> Self::IntoIter {
Self::IntoIter::new(self)
}
}
pub struct ListIterator<T> {
list: DoublyLinkedList<T>,
}
impl<T> ListIterator<T> {
fn new(list: DoublyLinkedList<T>) -> Self {
Self { list }
}
}
impl<T> Iterator for ListIterator<T> {
type Item = T;
fn next(&mut self) -> Option<Self::Item> {
self.list.pop_front()
}
}
impl<T> DoubleEndedIterator for ListIterator<T> {
fn next_back(&mut self) -> Option<Self::Item> {
self.list.pop_back()
}
}
I created a separate struct called ListIterator
. It can be constructed by implementing the IntoIterator
trait for DoublyLinkedList
. Then, we can use pop_front
and pop_back
methods to implement the iterator. Simple enough!
Testing
To verify everything works fine, we should add tests. Here are the tests created by me. Fill free to add more tests and verify more sophisticated cases.
#[cfg(test)]
mod tests {
use super::DoublyLinkedList;
#[test]
fn it_works() {
let mut list = DoublyLinkedList::new();
list.push_back(3);
list.push_back(4);
assert_eq!(list.pop_front(), Some(3));
assert_eq!(list.len(), 1);
list.push_front(5);
assert_eq!(list.pop_back(), Some(4));
assert_eq!(list.pop_back(), Some(5));
assert_eq!(list.pop_back(), None);
assert_eq!(list.pop_front(), None);
assert_eq!(list.len(), 0);
}
#[test]
fn can_push_back() {
let mut list = DoublyLinkedList::new();
assert_eq!(list.pop_back(), None);
list.push_back(3);
list.push_back(4);
list.push_back(5);
assert_eq!(list.pop_back(), Some(5));
list.push_back(6);
list.push_back(7);
assert_eq!(list.pop_back(), Some(7));
assert_eq!(list.pop_back(), Some(6));
assert_eq!(list.pop_back(), Some(4));
assert_eq!(list.pop_back(), Some(3));
list.push_back(2);
assert_eq!(list.pop_back(), Some(2));
assert_eq!(list.pop_back(), None);
assert_eq!(list.len(), 0);
}
#[test]
fn it_drops_correctly() {
let mut list = DoublyLinkedList::new();
for i in 0..3 {
list.push_back(i);
}
drop(list);
}
#[test]
fn can_iterate_forward() {
let mut list = DoublyLinkedList::new();
for i in 0..10 {
list.push_front(i);
}
assert!(Iterator::eq(list.into_iter(), (0..10).rev()));
}
#[test]
fn can_iterate_back() {
let mut list = DoublyLinkedList::new();
for i in 0..10 {
list.push_front(i);
}
assert!(Iterator::eq(list.into_iter().rev(), 0..10));
}
}
Summary
This article discussed what Doubly Linked List
is, when it’s used, and how it’s implemented. We also tested it properly and implemented comment traits such as Iterator
and Drop
.
You can view the final code in my public repo.