Rust Binary Tree: A Refactor
Rust Binary Tree: A Refactor

Making Our Binary Tree Better

Photo by Matt Lamers on Unsplash

Rust is a beautiful and complex beast. Despite being a truly low-level language, it has amazing features and abstractions available for us as developers. I personally have a (functional) JavaScript and C# background, and am rather new to Rust by comparison. So it amazes me when I see elegantly written Rust, especially code that really takes advantage of the awesome language features. I recently released this article about Binary Trees in Rust. I received a bit of feedback on Reddit:

Coriolinus also sent me a helpful Rust playground link. I’m going to walk through the modifications they’ve made to my code and why I actually really like their implementation! In the end we’ll simplify the code significantly, drop unnecessary wrapper types, make our code more generic, and also reduce the footprint of tree construction. If you’re new to Rust you’ll also get an opportunity to learn about Rust closures and pointer types— all thanks to coriolinus’ refactor.

Previously, I had been thinking of the tree as two structs:

This implementation allows for the possibility of some weird invariants being constructed. That’s not great, because that means a leaf could have been constructed with child nodes and when it comes to the operation those child nodes would be ignored. Or our child nodes could be None and have an operator other than Id, which is also an invariant. What this does is open the possibility for you, the developer, to make a mistake later on — simply because the possibility exists. So let’s take a look at an improvement:

Terser, and removes our invariants.

We will be able to remove our Op enum as well as our Binary Tree wrapper — both have become unnecessary because we have encapsulated the variants of the Node itself in a generic enum. We can remove our Child Node type alias. We can stop wrapping our nodes in Option containers. Also note, we’ve replaced the op field’s definition as being a pointer to a function. Using the dyn modifier indicates that the type provided will be a Trait Object. This is the typical way to indicate you’ll be providing a closure as a parameter. Let’s see how this will change the way that we can implement operators.

Elegantly written rust, provided to us by coriolinus from Reddit.

I learned some things from looking at this piece of code. It heavily utilizes language features and is more semantic Rust than my original implementation. It even keeps things more generic. Let’s walk through each bit of it.

On the few lines, we keep things generic by using impl <T> . Then we add a trait-bound to T using a where clause. We indicate that T implements the Add trait and we set the Add ’s Output type parameter equal to T . Now the meat of it — we declare a function to construct a non-terminal node variant (a Branch ). It takes some type parameters, but because of the creative solution we’ve been provided, these parameters will ultimately be elided. We also have some trait bounds applied to the function. Both L and R have the Into<BTNode<T>> bound. This means that as long as the provided parameters at L and R can be converted into a BTNode<T> , they will pass the type check and be converted into our BTNode wrapper. If you’re lost, this will make a little more sense when we implement From on BTNode later! The body of the function add is much more straightforward. We construct and return a Branch variant of the BTNode . It’s left and right values are Box ed up, because we still need the layer of indirection that prevents us from creating a recursive type. We also call .into which is provided by implementing the Into bound from before. That’s the part that converts our BTNode -convertible value into an actual BTNode . Finally, op is declared as a closure that simply adds its l and r parameters.

This implementation is highly modular and extensible. You can basically copy that code for each operator you’d like to implement, replacing the std::ops::Add to whatever operator you’ll be using along with replacing the closure with an appropriate operation. You could use it to extend BTNode s to be operable on any type of your choosing — without specifically indicating what T will be until construction.

Now let’s go over From :

What this piece of code says is for any T , we can construct a BTNode from that T . The node variant it will become is a Leaf . From is a conversion trait, and Into is its sister trait. Using them together, both in the impl blocks for our operators and in the impl<T> From<T> for BTNode<T> block, we can get implicit conversion (or, I suppose, elided conversion) from our parameter values. Let’s bask in that for a moment — what does this entail? This means:

We can convert virtually any type into a Binary Tree Leaf node.

Sure, it’s just a wrapper. But with the type-restricted implementations for our operators, we can make whatever T we want able to be provided to our constructors as a raw value, and it will turn it Into a BTNode leaf variant of that type T . That’s what makes this implementation so highly extensible and expressive.

Now let’s take a look at how we will collapse our tree. Before, we had a bulky collapse function as a method implemented on BinaryTree . Well, now we don’t even have a BinaryTree struct! So how will this be accomplished? Check out this (recursive) solution:

Yet another amazing piece of code.

Again, learned something from this block of code. Still staying generic, and abstracting away the need to provide a concrete type, we have another impl block for BTNode<T> but with no trait bounds this time. This houses the new constructor (which simply creates a Leaf from any T ), and a special function now part of the BTNode interface, value . This is the easiest way to recursively collapse the tree. In just one match expression, we decide whether to descend the BTNode children in a left-biased depth-first fashion (just as before), except instead of a bulky function, this is accomplished in essentially 3 lines. By having created an enum to house our node’s structure variants, it becomes very easy to extract the data. The recursion now occurs when we call .value() . That forces a new match case that either has found a leaf and can therefore provide a value to it’s outer branch’s op closure, or must descend into another call to .value() . I love it. I already liked Rust before I received some quick mentoring, but this code has really opened my eyes to what Rust can do.

Before we conclude this refactor, let’s look at how we can construct our trees in practice:

Simplified construction for Binary Trees. Its all node now!

We finally provide a concrete type to our nodes when we construct the tree. Because of our conversion implementation, we can provide parameters as raw i32 values without jumping through any hoops. The great part is that this will work for other types now, too. And it’s as simple as calling the instance method .value() to collapse the tree! Very cool and very nice work. A big thanks to Coriolinus for this wonderful refactor!

Here’s the Rust Playground link from the original reply. Hack away!

I learned a lot about Rust just from this refactor, and I hope that my readers have as well. But the best thing that I learned through this experience is how dedicated and responsive the Rust community is. Seriously, if you want to learn Rust, or are stuck on a concept, ask on Reddit’s r/rust community. You will very likely receive feedback from talented developers.

Anyway, I hope you’ve enjoyed another adventure in my Rust journey, and until next time FP on!

(P.S. I did ask for permission to post about this code!)
[]
['Software Engineering', 'Technology', 'Data Science', 'Programming', 'Rust']