Could enums be considered an anti-pattern?

This is an argument me and a friend have gotten into several times now with no real resolution, and I'm wanting to get some more opinions on it.

Basically he's arguing that rust enums aren't really that useful because they could always be represented using traits, plus they're effectively a wrapper around a union with a switch statement (and switch statements are evil because they're hard to maintain and could be done much more nicely with good polymorphism).

On the other hand, I'm a massive fan of using enums to represent things like the different error types (à la Error handling with a custom type from The Book), or using it to represent the different node types in an AST.

Obviously there's a time and a place where using enums might be a good idea, and others where it's a horrible idea that'll just create pain for you down the track. But this is more along the lines of long term development and making sure a single change isn't going to force you to rewrite hundreds of call sites or match statements.

So I'm wondering:

  • What situations would you recommend using an enum in?
  • Where would it be a better idea to use a different tool (trait objects, etc)?
  • Can/should enums be used as a limited form of polymorphism?
  • Can you give examples of some really elegant/horrible uses of enums?
  • How are rust-style enums different to/better/worse than C-stype enums?
8 Likes

Trait objects are great when you have an arbitrary number of distinct variants and don't care about which specific one you're using. If you know that there are e.g. specifically 4 variants and need to do arbitrary specific processing for each, you can model that with a visitor pattern, but it's a huge PITA.

You use an enum when you want to be forced to handle the addition in every use site.

15 Likes

+1 to all of this.

The trade-off between enums and trait objects for OO programming is really pretty simple: traits make it easy to add new implementations, whereas enums make it easy to add new interface methods. The choice between them depends on which of these things you expect to have highest variability in the final software.

If you go with an enum and a new variant has to be added later on, then you'll need to modify every pattern matching in your code. If you go with a trait and a new method has to be added later on, then you'll need to review and possibly modify every single implementation of that trait in your code AND other people's code if the trait is public. In both cases, you will suffer.

If you go with an enum and a new method needs to be added later on, well, you just write the method. Same if you go with a trait, then figure out that you need a new implementation of it. In both cases, it will be easy.

7 Likes

Enums are for closed sets, and trait objects are for open sets. That is, if you know every variant, you go with an enum. If you don't, then you go with a trait object.

Sure. More on this below.

Rust's enums are closer to a union than they are to an enum in C. They're sometimes called "tagged unions" Tagged union - Wikipedia

This is where the "effectively a wrapper around a union with a switch statement" comes in.

switch statements are evil because they're hard to maintain

Rust has match, not switch, and they're very different. Furthermore, you don't write the switch statement he refers to, the compiler does. So I don't find this line of argument very compelling.

could be done much more nicely with good polymorphism

"Polymorphism" is a far too generic term to know what this means. Many people use it to mean "inheritance", but they're not synonyms.

Oh, and you might be interested in reading Virtual Structs Part 1: Where Rust's enum shines · baby steps Virtual Structs Part 2: Classes strike back · baby steps and Virtual Structs Part 3: Bringing Enums and Structs Together · baby steps ...

11 Likes

Already some great responses here, but I thought I'd chip in a small example of where I've found Rust's enums particularly compelling in some work I've done recently.

In a project I worked on recently, I had to implement a client for a REST API, and wanted to be able to represent the endpoint URLs nicely. Normally, I'd consider defining a set of string constants to contain each one, but with an enum, coupled with a useful application of traits, I was able to do much better. Instead of writing my client code to accept a String or &str or even something that implements ToString, I created an enum that contains one variant for each endpoint my client supports, like so:

enum Endpoint {
  Authenticate,
  TerminateSession(String),
  // etc...
}

I then implemented ToString for Endpoint with a body like so:

match *self {
  Endpoint::Authenticate                => "/users/authenticate".to_owned(),
  Endpoint::TerminateSession(ref token) => format!("/sessions/{}/terminate", token)
}

With this construction, I know that I can now write functions that accept an Endpoint and that I'll always be able to call .to_string() on whatever value I get and have a valid (provided the data contained in the variants is valid) URL for the API I'm calling into.

If I'd used a trait object, I wouldn't have this guarantee because I'd potentially be dealing with any ToString, which could be something I don't want. If I were to define and implement a new trait to use in place of ToString, I'd have a lot more work to do to define separate types for what would replace each variant of my enum, and implement my new trait on each of those types. Even if I'd done all that work, I'd be incurring an even bigger loss because I'd be sacrificing some of the ergonomics of using my functions, as every caller would have to write something like &AuthenticateEndpoint as &ClientEndpoint.

I hope this makes a degree of sense. I don't consider myself a Rust expert.

4 Likes

Here's a brilliant talk by @withoutboats touching this topics: RustConf 2016 - Using Generics Effectively by Without Boats - YouTube

2 Likes

I agree that this is the biggest difference between enums and traits, but I'd add that there are some cases where (performance questions aside), it may make more sense to represent a closed set as a trait.

The other main difference between an enum and a trait is that an enum can be destructured into its variants, by anyone who has access to the enum. This means an enum never encapsulates its variance; anyone who gets one can always open it up to find out which specific variant you gave to it.

In my code (and I think for many people this is true), my types are usually divided into two broad categories:

  • "Plain old data" types - usually these implement Copy, usually they are shortlived, they tend to get passed around as messages between types in the second category.
  • "Object" types - usually these own some heap-allocated data, usually they live for a long time, they tend to be the things receiving messages from one another.

I try to do a few things differently between these two categories of types. Data types I try to keep immutable, and write methods on them as functions which produce new values, rather than changing them. Object types I let keep mutable state, and therefore I try to encapsulate them as much as possible.

So if I have a thing that wants to be an object type and an enum, the lack of encapsulation is sort of a problem.

There are two solutions to this. The first is to use a trait, and trait objects. This has some downsides: its almost always going to be at least a little slower, and you have to deal with 'object safety' issues. The second is to wrap your enum in a struct:

pub struct Foo {
    inner: InnerFoo,
}

impl Foo {
     pub fn bar(&self) {
        self.inner.bar();
    }
}

enum InnerFoo {
    //...
}

impl InnerFoo {
    fn bar(&self) {
        match *self {
            //...
        }
    }
}

The downside of this approach is that you have a lot of boilerplate / rightward drift compared to just defining a bunch of types and implementing the trait for them.

We've thought about a 'third way' approach of introducing closed or sealed traits, which would have a more enum-like representation rather than trait objects, but still be implemented as traits to make it easier to create them.

1 Like

It sounds like my friend's main disagreement with Rust enums is:

It's a matter of which type system is more flexible and robust. enums or traits [...] and it appears that they (trait objects and enums) can be used interchangeably.

I'm personally in agreement with @withoutboats in that there's usually a distinction between "plain old data" types and "object" types, and enums would fall into the former category, while traits fall into the latter. It all depends on how you're using something and whether it's a smaller data container or a larger thing used for its behaviour.

Another really valid point he raised is that if your enum has two different types of thing inside it with different signatures.... Isn't that violating the single responsibility principle because it's now capable of doing two things?

I very much disagree that they can be used interchangeably, but complete feature orthogonality is not a target of any language I'm aware of in any case. Would your friend advocate for never using "for" loops because they can be emulated by "while" loops?

It's possible to write bad code using all kinds of features of all kinds of languages. I'm not really sure what kind of situation you're looking for though - does Result<T, E> somehow violate the single responsibility principle since it contains different types in its Ok and Err variants?

2 Likes

How is this different from an object with two fields being able to do two things? These kinds of very vague applications of 'OO principles' are not convincing to me at all.

4 Likes

I think this is a misunderstanding of the single responsibility principle. The goal of the single responsibility principle is to ensure each module (or class, but we are talking about Rust) has high cohesion, and enums do not necessarily reduce cohesion.

Result<T, E> is one good counterexample. Result<T, E> obviously contains two different types, possibly unrelated, but you are normally working with the data structure and not with its contents when you use Result. This also explains why we don't have separate types like IoResult<T> or UnitOrError<E>---the commonality is the data structure and a generic type can capture enough aspects of it.

Probably you and/or your friend may have thought of enums like enum T { A(A), B(B) } and T implements several methods that should really belong to A and B. This is actually not very common in Rust, because it tends to be cumbersome as methods grow (which agrees to the Single Responsibility Principle by the way) and more easily modelled as a trait even when the values form a closed set. In that form, they are more likely used as an intermediate container (e.g. a function may return one of two values [1]), or values are highly cohensive enough that this is actually easier to model (type systems tend to be a good example). This is what @withoutboats have mentioned above.

Personally I think the SOLID is too overrated, mainly because it fails to catch other commonalities not common in OOP (in the other words the principles are tailored to OOP). Functional programming languages have used the data-oriented abstractions which are vastly different from OO principles. It can be argued that the driving motivations behind SOLID are solid (pun intended) and I mostly agree, but you still need to be careful when applying SOLID to other paradigms :slight_smile:

[1] either crates are actually a good abstraction for this, but I personally want an isomorphic version of Either with custom labels because otherwise it is very hard to discern what is "right" and "left".

6 Likes

I definitely agree with you here, I've found that I almost never treat an enum as two separate things, instead you'd usually combine them with combinators or just generally treat the enum variants as a single thing.

My personal thoughts are that SOLID and all those well known OO principles are nice guidelines to keep in mind but they don't necessarily fit with Rust's more functional nature and, like most things in life, you shouldn't take them as gospel.

I'm really impressed with all the good quality answers to this thread so far. I've used Rust for a good 2/3 of a year so far and haven't really encountered any of the issues with enums my friend mentioned. That said I come from a more functional background (Python, javaScript), so Rust's idioms weren't overly dissimilar to what I'm used to, while my friend is coming from very OO languages (C++, etc) where inheritance and SOLID is a massive thing.

Has anyone seen any particularly ugly uses of traits or enums, or situations where you discovered later on that you used the wrong language construct and that made maintaining things harder? I imagine some of you who have worked on a big project like the compiler would have encountered this at some point.

2 Likes

I've personally always found working with traits forces me into a comparatively more object-oriented mindset. Working with enums on the other hand has usually made it easier for me to express myself more directly, without having to think about object-oriented design ideas as much. Of course, each has different (if occasionally overlapping) applications, and I have personally not had much difficulty deciding which is the right one to reach for in a given situation.

Thinking in a sort of hybrid manner combining considerations relevant to both OOP and FP, I tend to follow these rules when deciding whether I want an enum or a trait.

  1. If I want to model data, I use either an enum or a struct.
  2. If I want to model behavior, I'll define a trait.

There's certainly a lot of nuance here which I think becomes clearer as one gains experience, and these rules don't capture the whole story. Fortunately, as long as I've thought about whether I care about data or behavior up front, Rust gives me the tools I need to compose enums, structs, and traits together effectively either way.

If I'm modelling data with an enum, I can very well implement traits on that enum that allow me to define behavior that instances of the enum (or struct) in question ought to be capable of.

If I'm modelling behavior with a trait, I can very well define an enum or a struct to implement that trait and contain whatever data happens to be relevant to that implementation.

Rust's trait system and the way it ties into (bounded) generics is probably one of the language's greatest strengths, because traits as a feature compose so effectively with other parts of the language (like enums and structs). In general, I find I'm most effective with Rust when I work with the understanding that Rust is neither strictly object-oriented nor strictly functional. Understanding the relevance of data and behavior in a program, and when to work with a concrete construction (a type such as an enum or struct) or an abstract one (a trait) is really the key, if I may say so, to writing idiomatic Rust programs.

9 Likes