The Trade-Off of Enums

Some uses of enums defy the Open-Closed Principle. Pervasive use of switches over an enum leaves a code base in a state where code needs to be modified rather than extended to add new behaviour.

Open-Closed Principle

The open-closed principle means that a software artifact should be open to extension and closed to modification. The meaning of this principle is that software artifacts should be easily extended by adding new code and without modifying existing code. By software artifact I mean anything from a function, a class to the entire architecture of a software application. The idea behind the OCP is to guard software against changes in requirements. If a small change in requirements forces large changes on our codebase then we have a problem. The first is that changing a lot of code requires a lot of work. It’s just harder to change existing code than to write new code.

Coin Example

Lets look at some simple Rust code taken from the Rust section on enums and pattern matching.

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

pub fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

In the above example there is a simple enum Coin and it’s used in one function value_in_cents in a match expression, Luckily, Rust forces it’s users to have a case in the match for each of the different enum members or provide a default. Now imageine the above code has some new requirements, our code base should also support Dollar coins.

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
    Dollar,
}

pub fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
        Coin::Dollar => 100,
    }
}

To add support for the Dollar coin the code needed to be modified. Luckily the Rust compiler will complain but in other languages we may be able to add a member to the enum and not not need to modify any switch statements at all. This could lead to unintended behaviour. For this small example this isn’t a big deal as it’s easy to just add a new case in one place. In a much larger code base where the enum is used similarly throughout, this could cause adding a new member to the enum to be a lot of work as a lot of code will need to be changed. Imagine needing to add a new case in 10+ places, in some cases you may not really even know what to do and studying the whole context in which it’s used may be necessary.

The alternative is to use polymorphism but this comes with a trade-off. The trade-off with using polymorphism vs using enums with swtiches/matches is that with polymorphism the code base will end up with more types and become more verbose. The proliferation of types can make the code base a bit more obscure and hard to understand and read. The upside is that it possibily makes the code base easier to extend. Below we will see how we could do this in Rust by turning the value_in_cents function into a behaviour of a trait and implementing that trait for the Penny, Nickel, Dime and Quarter types.


pub trait CoinValue {
    fn value_in_cents(&self) -> u8;
}

pub struct Penny {}

impl CoinValue for Penny {
    fn value_in_cents(&self) -> u8 {
        1
    }
}

pub struct Nickel {}

impl CoinValue for Nickel {
    fn value_in_cents(&self) -> u8 {
        5
    }
}

pub struct Dime {}

impl CoinValue for Dime {
    fn value_in_cents(&self) -> u8 {
        10
    }
}

pub struct Quarter {}

impl CoinValue for Quarter {
    fn value_in_cents(&self) -> u8 {
        25
    }
}

Using polymorphim, to add Dollar as a coin just add the following code.


pub struct Dollar {}

impl CoinValue for Dollar {
    fn value_in_cents(&self) -> u8 {
        100
    }
}

No code was modified in order to add new behaviour. It’s clear in this simple example that the enum method is preferable as it’s much easier to understand and has produced a lot less code. But as the enum is used more and more there will come a point where using polymorphism is preferable. It’s the job of the software engineer to make a judgement on the trade-offs and where the tipping point from one to the other lies.

Best Uses of Enums

Enums are best deployed whenever it’s unlikely they will need to be extended or they will never need to be extended. The Option enum in Rust is a perfect example of this.

pub enum Option<T> {
    None,
    Some(T),
}

The Option enum is intended to be a wrapper type for functionality that may not return any value. So the members of the enum will be Some or None and there will never be any need for anything to be added. Other examples of this are the Result enum which will either contain some result or an error.

Summary

As with everything in software engineering, we are constantly making choices which involve some trade-offs. This is an interesting one as it’s something that is not considered very often in my experience. Enums are great when used well but we should all consider the possible consequences of doing so.

Written on May 25, 2021