Musing About Pythonic Design Patterns In Rust

Teddy Rendahl
9 min readJul 14, 2023

--

Over the last year, I took the leap and started learning Rust after years of programming in Python. While the initial shock of the compiler yelling about types and improper mutability was a shock, I quickly realized how it makes you think in a deeper way about the code your write.

As a career Python programmer, I had a specific style I wanted to use even after switching languages, still having the urge to define “pythonic” APIs. However, after about a year now of writing Rust code I’ve re-examined how I want to use these patterns in a staticly typed and very opinionated language like Rust.

Below I’ll discuss a few Python design patterns with examples from the standard library and discuss how I think about them when writing Rust code. Maybe it’ll help fellow Python programmers making the same transition, or makewe’ll just have a little fun thinking about API design in general.

Do Not Allow Arguments That Are Dead on Arrival

It’s a common pattern in Python to have the first few lines of a function be spent verifying that the parameters passed in are reasonable. Langauges with dynamic typing often need this to defend against inputs that are simply the wrong type. The Rust compiler will protect you from those sorts of typing errors, however, there are still circumstances where you may be tempted to repeat this pattern.

To take an example, let’s say we wanted to port the UUID class from the Python standard library over to Rust. You can look at the source code here. The constructor has four mutally exclusive fields that if we were to naively duplicate in our Rust code would look like a series of Optionarguments. For example:

struct Uuid {}

impl Uuid {
pub fn new(hex: Option<String>, bytes: Option<String>) -> Self {
if hex.is_some() & bytes.is_some() {
panic!("Both hex and bytes are set, these are mutually exclusive")
}
if hex.is_none() & bytes.is_none() {
panic!("Neither hex or bytes are set. One is required.")
}
todo!()
}
}

Now this does the job and succesfully apes the Python implementation. The issue is we may have users naively use both of these keywords in their production code, and nothing but a docstring indicates to a user these things should not both be set. Worse yet, they won’t find out when compiling and building but will see issues arise during runtime. A more canonical Rust design pattern swaps the two arguments for an enum . This gets rid of any ambiguity about how many arguments we expect, and thanks to Rust’s match statement if we add additional options later, the compiler will yell if we don’t update this function.

struct Uuid {}

pub enum RawUuid {
Hex(String),
Bytes(String),
}

impl Uuid {
pub fn new(raw_uuid: RawUuid) -> Self {
match raw_uuid {
RawUuid::Bytes(b) => todo!(),
RawUuid::Hex(h) => todo!(),
}
}
}

In general, it can be nice to keep functions infallible where possible. By moving errors to input creation you make it clear where the issue lies. Code that utilizes your API can be sure that once it properly creates the inputs that it can chain together calls without fear of error. Let’s take a little example where we create some Rust code looking at days of the week.

#[derive(Copy, Clone)]
pub struct DayOfWeek(u8);

impl TryFrom<u8> for DayOfWeek {
type Error = String;

fn try_from(value: u8) -> Result<Self, Self::Error> {
if value < 7 {
Ok(DayOfWeek(value))
} else {
Err(format!("Day of year {value} not between 0 and 6"))
}
}
}

pub fn is_weekday(day: DayOfWeek) -> bool {
day.0 > 0 && day.0 < 6
}

pub fn tomorrow(day: DayOfWeek) -> DayOfWeek {
if day.0 == 6 {
DayOfWeek(0)
} else {
DayOfWeek(day.0 + 1)
}
}

Instead of creating functions that accept u8 , we create a small struct that wraps a u8. The TryFromtrait implementation allows a user to create our DayOfWeek struct from any u8 raising an error if the provided value is out of bounds. This letsis_weekday and tomorrow become infallible because the input has been vetted before being provided to the function. This may seem like simply moving where the error message happens but lets take a minimal example of chaining these calls.

pub fn main() {
let day = DayOfWeek::try_from(2).expect("Invalid day of the week");
// Check if tomorrow's tomorrow is still a weekday
is_weekday(tomorrow(tomorrow(day)));
}

Now we had the choice of making our function signatures look like this is_weekday(day: u8) -> Result<bool, String> , but imagine how messy that line gets where we have three seperate errors to deal with.

In Python, you may not think about this sort of issue. In fact, take a peek at the standard library datetime module. Many of the functions which accept numbers all start which input validation. It’s a little unclear which functions can be chained with zero risk of an Exception. In practice you’d want throw a big try / except around these call sites (though without reading the source code it isn’t even obvious what Exception classes to expect).

Rust requires a little more care, but in doing so makes you think through the error conditions at the time you’re writing the software. Although it can feel pedantic at times, minimizing the risk of bugs reaching production saves everyone time. By naturally dividing logical errors from poor input you help reduce the overall lines of code you need to write to handle errors.

No Keyword Arguments, No Problems

It may come as a bit of a shock to those jumping from Python to Rust that there are no keyword arguments. This means you need to be a little more creative if you want a function to have default values.

In general, as I’ve written more Rust, I’m generally a fan of the choice. It can be quite convenient in Python to add a keyword argument with a default without fear of breaking anything. Unfortunately, this quickly leads to headaches once your software is out in the wild. Users can unknowingly opt into using new behaviors for old functions as defaults change or are added. They also remain unaware of additional options or behaviors they may want to take advantage of.

Rust takes the opposite approach. Arguments are always required. This makes it clear when users need to think about the new behavior of our libraries, and makes it an explicit choice to opt into default parameters. Rust has the added benefit of instantly showing all the calls that require updating when a user builds their software with an updated dependency. This is a larger risk in Python where these need to be caught by additional libraries to lint code or trust that the provided lines are covered by test cases.

To showcase how to make this convenient for both you and users. Here is a little example by making our own Rust version of math.is_close. The function compares two numbers against two different types of tolerances, an absolute value and a relative one. Each of these tolerances is optional with a default value provided.

A clean pattern to have a similar behavior in Rust is to tuck our two tolerances into a struct that implements the Default trait.

struct Tolerances {
absolute: f32,
relative: f32,
}

impl Default for Tolerances {
fn default() -> Self {
Tolerances {
absolute: 0.,
relative: 1e-9,
}
}
}
fn is_close(a: f32, b: f32, tolerances: Tolerances) -> bool {
(a - b).abs() <= (tolerances.relative * a.abs().max(b.abs())).max(tolerances.absolute)
}

This has a few benefits:
1. All the users of our API are fully aware that is_close has configurable tolerances.

2. A user can opt-in to using the default tolerances by simply calling with is_close(a, b, Tolerances::default()) . This is explicit (very clear they are using default values which may change) and simple. Users who may want to future-proof themselves can build their own Tolerances with values that make sense for their application.

3. If we wanted, we could take this opportunity to follow the advice in the section above and validate that Tolerances has sensible values. Negative absolute tolerances e.t.c are perfectly viable in the type system, but will create unexpected results in our is_close implementation.

Default also has some convenient shorthand API options. For instance, if you wanted to set a specific absolute tolerance, but wanted to use the default values for all the other parameters Tolerances {absolute: 4.0, ..Tolerances::default()} does the job.

List Comprehension

If you’ve spent some time reading Python code you’ve probably seen List comprehension. Code like values = [x**2 for x in range(3)] makes the

creation of populated data structures simple. In Rust, theIterator trait offers an incredibly powerful API for iterating through values. The concept maps closely to a Python generator. An Iterator is sequence of values whose next value can be generated by calling next . An Iterator is lazy, this means that creating an Iterator by itself does nothing, it isn’t until next is called the Iterator starts doing it’s magic.

Rust takes this pattern to the next level, allowing adapters to be added to the end of the Iterator Here a few different examples of the pattern in action.

// Creates a Vec of the square of the values 0..10
let _: Vec<i32> = (0..10)
.into_iter()
.map(|x: i32| x.pow(2))
.collect();


// Creates a Vec of the square of the values 0..10 omitting odd numbers
let _: Vec<i32> = (0..10)
.into_iter()
// filter_map modifies the value if Some, and omits the value if None
.filter_map(|x: i32| if x % 2 == 0 { Some(x.pow(2)) } else { None })
.collect();

// Partitions a single iterator into two different Vec with even and odd numbers
let (even, odd): (Vec<_>, Vec<_>) = [1, 2, 3].into_iter().partition(|n| n % 2 == 0);

There are a ton of helpful functions to utilize, I recommend getting familiar by taking a look in the docs. One added benefit of using this pattern in Rust is to limit where you need to use mutability. The following code does exactly the same thing as our first Iterator example. In fact, it literally compiles to the same thing as a for loop de-sugars to into_iter

fn foo() -> {
// Generate values
let mut values = Vec::new();
for x in (0_i32..10) {
values.push(x.pow(2));
}
// Do thing with values
}

However, you’ll notice we had to make our values variable mutable in order to push new values. This means below our forloop in our foo function we may further modify the created values. Maybe this is what we want, but it creates ambiguity whether the variable needed to be mutable just for the initial creation or whether or not we actually want to allow the modification of the values.

One last note glossed over above is the collect function. This does the final act of taking our iterator, exhausting all the values from it, and putting them into a container. When you first start playing with collect you may notice it seems overly sensitive about types. Code like this does not compile:

let values = (0..10).into_iter().map(|x: i32| x.pow(2)).collect();

error[E0282]: type annotations needed
--> src/main.rs:8:9
|
8 | let values = (0..10).into_iter().map(|x: i32| x.pow(2)).collect();
| ^^^^^^
|
help: consider giving `values` an explicit type
|
8 | let values: Vec<_> = (0..10).into_iter().map(|x: i32| x.pow(2)).collect();
| ++++++++

This is because collect is incredibly powerful. It is capable of collecting our values into a variety of different types of data structures. In addition to Vec , structures like String , Hashmap, VecDeque are all fair game as long as your iterator is of the correct type. One of my favorite use cases is handling iterators that have fallible Result objects inside of them. Usually your code wants to do one of two things:

  1. Go through all the values in the Iterator , store all the results into our collection, even if they error.
  2. Go through the Iterator adding Ok values. If we hit an Error stop and give that to me.

The beautiful thing about our friend collect is it can do either! You just need to tell it which you want with the typing system.

use std::collections::HashMap;

/// Function which returns the index of the char 'a'
///
/// Errors if 'a' is not present
fn index_of_a(value: &str) -> Result<usize, String> {
value
.find("a")
.ok_or_else(|| format!("No 'a' found in {value}"))
}

pub fn main() {
let values = ["and", "has", "been"];

// Try to build a HashMap, but return on first error
let map_or_error: Result<HashMap<&str, usize>, String> = values
.into_iter()
.map(|word| {
let idx = index_of_a(word)?;
Ok((word, idx))
})
.collect();

// Build a HashMap where each value is the Result of index_of_a
let map_of_maybe_errors: HashMap<&str, Result<usize, String>> = values
.into_iter()
.map(|word| (word, index_of_a(word)))
.collect();
}

The difference is subtle but the explicit type definition works doubly, helping those looking at the code see what type we are creating, and indicating tocollect what type to create. The code inside the mapbarely needs to be modified. We just specify the types we want and collect is clever enough to create the proper flow control for us.

--

--