Algebraic Data Types for Python. A Lesson From Rust.
The most useful concept when writing code. Algebraic data types make it easy to write complex scenarios in a simple way.
When learning a new programming language, the most important thing you get is to expose yourself to a new way of thinking. Different programming languages, not only have different syntaxes (in fact this is the least important distinction) but also have different rules and implementations of similar concepts.
In this post, I’ll compare Python enums (meh) vs Rust enums (good… Really good!). I’ll give you a short, understandable, and, most importantly, powerful explanation of why Rust enums are so amazing and how to implement them in Python.
In Python, enums are implemented as “a set of symbolic names bound to unique values”. In Rust, enums give you a way of saying a value is one of a possible set of values… wait, I just wrote the same thing. Well, in both cases enums basically represent a collection of variants of a single type. The key difference is that while in Python enums are only a symbolic collection of variants, in Rust each of these variants can have its own signature and behavior.
Let’s start simple. If you were to represent weekdays, enums, in both languages, are a perfectly good way to represent each day of the week. In Python, you could write something like…
from enum import Enum
class Weekday(Enum):
MONDAY = 1
TUESDAY = 2
WEDNESDAY = 3
THURSDAY = 4
FRIDAY = 5
SATURDAY = 6
SUNDAY = 7
While in Rust you could write…
enum Weekday {
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday,
}
Besides the ugly = 1..7
in the Python code, both examples are basically the same. In this scenario, both Python and Rust enums are perfectly capable of modeling the problem. But what happens if these enum variants not only represent plain data, such as the name of a weekday, but meaningful actions that required data, such as actions a human might perform?
Let’s say we want to model 4 human actions: Eat, Talk, Walk, and Sleep
. A perfectly reasonable way to group these is using an enum called HumanAction
. In Python that might look something like…
from enum import Enum
class HumanAction(Enum):
TALK = 1
WALK = 2
SLEEP = 3
EAT = 4
While in Rust, something like…
enum HumanAction {
Talk,
Walk,
Sleep,
Eat,
}
But then, you might start thinking that each of these actions requires a unique set of parameters to be executed by a human. The Talk
action requires the text
that will be said, the Walk
action requires the number of steps
to be walked, the Sleep
action requires the number of seconds
the person will sleep for, and so on. The idea is that each enum variant might have a distinct signature while still representing one variant of a similar type. In Rust, this use case can be implemented the right way…
enum HumanAction {
Talk { text: String },
Walk { steps: u8 },
Sleep { seconds: u16 },
Eat { food: String },
}
fn human_do_action(action: HumanAction) {
match action {
HumanAction::Talk { text } => println!("{:?}", text),
HumanAction::Eat { food } => println!("I'm eating {:?}", food),
HumanAction::Sleep { seconds } => println!("I'll sleep for {:?} seconds", seconds),
HumanAction::Walk { steps } => println!("I'll walk {:?} steps", steps),
}
While, in Python, this use case can not be implemented using enums. Enums in Python are not thought to store complex data structures, they are only symbolic names. To implement this, in Python, you are required to use typing
functionalities and forget about enums. The same pattern we just wrote in Rust can be achieved in Python as follows…
from __future__ import annotations
from dataclasses import dataclass
from typing import TypeAlias
from typing_extensions import assert_never
@dataclass
class Talk:
word: str
@dataclass
class Walk:
steps: int
@dataclass
class Sleep:
seconds: int
@dataclass
class Eat:
food: str
HumanAction: TypeAlias = Talk | Walk | Sleep | Eat
def human_do_action(action: HumanAction) -> None:
if isinstance(action, Talk):
print(f"{action.word}")
elif isinstance(action, Eat):
print(f"I'm eating {action.food}")
elif isinstance(action, Sleep):
print(f"I'll sleep for {action.seconds} seconds")
elif isinstance(action, Walk):
print(f"I'll walk {action.steps} steps")
else:
assert_never(action)
The power of this pattern is that enums now both represent a variant of a single type while containing the values that are meaningful for each of those variants. This helps to write less code that is easier to understand, maintain, and safer because you can leverage assert_never
to ensure you are handling all variants of the implemented enum.