Alternatives to Enums – Part 2: Advanced Enums Implementing Polymorphic Behavior with Custom Types

Posted by : on

Category : C#

Introduction

In the previous post, I wrote about Strongly typed strings, exploring how they can serve as user-extendable enums, along with their advantages and disadvantages compared to traditional enums.

From that discussion, we saw that enumerations are user-defined types with a finite set of values that conceptually belong together. These values are often represented through a one-to-one or many-to-one relationship with some underlying entity. In C#, the default representation of an enum is an integer, with the relationship resolved at compile time, hence the common saying that “enums are integers.”

However, as we demonstrated, enums don’t have to be integers. As long as we can establish a one-to-one or many-to-one relationship with a specific type, we can create our own enumerations that are represented by types better suited to our use case. While this approach may sacrifice performance and compile-time resolution, it can offer other benefits depending on our implementation.

In the previous post, the primary benefit we focused on was extensibility. In this post, we’ll take that concept further by replacing the underlying “something” with our own types that inherit from a common parent type. This will allow us to introduce polymorphic behavior in our enumerations.

Enumerations That Support Behavior

While we can use extension methods to add behavior to enums, extension methods are ultimately static methods that operate on an instance of the enum. This approach can be limiting when dealing with more complex behaviors that require maintaining state.

There are multiple ways to enhance C# enums with behavior, as discussed in my posts:

Additionally, we can attempt to add state to extension methods using ConditionalWeakTable as I describe in How to Extend a Type’s State by Adding Fields with ConditionalWeakTable. However, in some cases, it may be more practical to move away from integer-based enums entirely and create a closed set of values represented by our own types, which can support polymorphism.

These custom enumerations can be particularly useful for defining “types” within our classes using composition. For example, let’s say we have an Enemy class. Each derived class of Enemy needs a movement type. We want this movement type to support behavior in a polymorphic way. If we have ground and air movement types, we may want to define methods like Move and Retreat, each with different implementations for ground and air movement.

We can achieve this by representing each enum value with a distinct type. Each value of the enum will be unique and immutable and represented by an instance of that type, ensuring a one-to-one or many-to-one relationship. To enforce this immutability of the values and restrict the creation of these instances, we must ensure that they can only be instantiated internally.

An Example With Nested Classes

One way to achieve this is by using private nested classes that inherit from an abstract parent class. These nested classes will only be accessible within their enclosing class, which itself will have a private constructor. This setup ensures that values can only be created internally within the enclosing class.

For example, if our enumeration is MovementType with the values Ground and Air, we can implement it as follows:

public abstract class MovementType
{
   public static readonly MovementType Ground = new GroundMovement();
   public static readonly MovementType Air = new AirMovement();
   
   private MovementType() { }

   public abstract void Move();
   public abstract void Retreat();

   private class GroundMovement : MovementType
   {
      public override void Move() => Console.WriteLine("moving on Ground");
      public override void Retreat() => Console.WriteLine("retreating on Ground");
   }
   
   private class AirMovement : MovementType
   {
      public override void Move() => Console.WriteLine("moving on Air");
      public override void Retreat() => Console.WriteLine("retreating on Air");
   }
}

This creates a MovementType enum with the values MovementType.Ground and MovementType.Air. As with strongly typed strings, we lose compile-time safety and the extensibility that strongly typed strings provide. However, in this case, we gain polymorphic behavior.

Each enum value has its own implementation of the desired behaviors, which can be used as needed. If we have Goblin and Dragon types derived from an Enemy class, each can have its own MovementType. This allows MovementType to function like a regular enum, modifiable at runtime, while also supporting polymorphic behavior. For example:

public abstract class Enemy
{
   public abstract MovementType MovementType { get; set; }
   public abstract void Move();
   public abstract void Retreat();
}

public class Goblin : Enemy
{
   public override MovementType MovementType { get; set; } = MovementType.Ground;

   public override void Move()
   {
      Console.Write("The Goblin is ");
      MovementType.Move();
   }

   public override void Retreat()
   {
      Console.Write("The Goblin is ");
      MovementType.Retreat();
   }
}

public class Dragon : Enemy
{
   public override MovementType MovementType { get; set; } = MovementType.Air;

   public override void Move()
   {
      Console.Write("The Dragon is ");
      MovementType.Move();
   }

   public override void Retreat()
   {
      Console.Write("The Dragon is ");
      MovementType.Retreat();
   }
}

Now, not only do we have polymorphic behavior based on our Enemy type, but we also have it based on the MovementType of each instance, which functions like an enum. Unlike traditional composition, the values of MovementType are predefined and immutable, MovementType.Ground, for example, remains the same for every Enemy instance that uses it. Here’s an example of how it can be used:

Enemy goblin = new Goblin();
Enemy dragon = new Dragon();

goblin.Move();
dragon.Move();
dragon.MovementType = MovementType.Ground;
dragon.Move();

and the result will be:

The Goblin is moving on Ground
The Dragon is moving on Air
The Dragon is moving on Ground

If we need to use a switch statement with this enum, we must handle it the same way as we would with strongly typed strings:

public class MovementUtils
{
   public void LogMovementType(Enemy enemy)
   {
      switch (enemy.MovementType)
      {
         case var _ when enemy.MovementType == MovementType.Ground:
            Console.WriteLine("The enemy is Moving on Ground");
            break;
         case var _ when enemy.MovementType == MovementType.Air:
            Console.WriteLine("The enemy is Moving on Air");
            break;
      }
   }
}

But in this case, we don’t usually need to use switch statements, as our enum supports polymorphic behavior. Instead of relying on switch cases, we can define these behaviors directly as methods within our values.

One Nested Class - Many States

Strongly typed strings and private nested classes are not the only ways to create custom types with a predefined, finite set of values represented through a one-to-one or one-to-many relationship. As long as we ensure the uniqueness of these representations, we can create our own enumerations.

Another approach is to use a private nested class while representing each value as a unique instance of that class. This is similar to strongly typed strings, but instead of wrapping a string, we wrap a class that contains its own methods. However, we must carefully implement equality to ensure functional equivalence between enum values. As long as we guarantee unique representations, this method remains reliable.

Enums in Enums

Of course, we can take this approach too far. Representing enums by types means we could have enums within enums within enums. Even the example with private nested classes could confuse new developers, and the ability to do something like Enemy.Undead.Dragon.Flying can will lead to debugging nightmares.

Just because we can represent enum values with unique instances of a type, or even with distinct types, doesn’t mean we should treat them like normal classes. In addition to enforcing uniqueness and restricting external instance creation, we must ensure their internal implementations remain simple. These types should not depend on internal state, meaning their methods should function independently of instance-specific data, as changing state in one instance that uses the enum value, would change the value for all instances that use it.

If not managed carefully, we could still end up with overly complex structures like Enemy.Undead.Dragon.Flying, which is undesirable.

Ideally, a single enum value should exist without having other enums nested within its state. It is preferable to design a type that, through composition, contains multiple enum values rather than a single value with nested ones.

Conclusion

Choosing to use enums represented by something other than integers is a decision that comes with responsibility. As the saying goes, “With great power comes great responsibility”.

Each approach offers benefits, such as the extensibility of strongly typed strings or the polymorphic behavior of nested types, but we must also consider the trade-offs. Factors like compile-time safety, performance, extensibility, debugging complexity, and overall code maintainability should all be weighed when deciding on an implementation.

Thank you for reading, and if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also, if you don’t want to miss any of the new blog posts, you can subscribe to my newsletter or the RSS feed.


Follow me: