Enhancing C# Enums across project boundaries
Keeping functionality that depends on enums in one place per assembly using extension methods and private classes. No more scattered switch statements.
C#'s enums are literally just enumerations, named constants with no functionality of their own. Sometimes that's all you need, but it often that means there are several big switch statements elsewhere in your code where you take a decision based on which enum instance you're passed. This means the behaviours that your enum controls are spread around your code, and decoupling is difficult. If you add an enum instance, you might have to update a lot of other classes to update all the switch statements. How can we avoid this mess, particularly when an enum in one assembly is used in another?
I've written a lot of Java in my time, and my immediate reaction is to think "if only C#'s enums were like Java's". Enums in Java have fields, methods, and constructors, though you can't create new instances at runtime. They're effectively classes with predefined, named instances.
Java's famous Planets
example enum. They actually went to the trouble of updating it to remove Pluto when it got demoted. Poor Pluto :(
Is there any way to get something in C# that acts like a Java enum? In most cases this is a solved problem. Way back in 2008 Jimmy Bogard gave us the Enumeration
class, which is available as a NuGet package. I'll let his blog post explain in detail, but the core of it is that you create classes that extend his Enumeration
class rather than creating enums, and you create a public static readonly
instance of the class for each enum entry. You get to keep most of the functionality of an enum like iterating over all the instances (because Enumeration
handles that for you), but because you're really dealing with class instances, the "enums" can have fields and methods, so you don't need big switch statements in your code any more, and all the functionality is in one place.
However, with Hexahedra, there's a bit of a snag. As as example, let me show you the DeviceFunction
enum, which at first glance seems like an obvious use-case for the Enumeration
class. The enum lists all the things that devices in the game can do. Here it is as a basic C# enum:
namespace HexSim.Factories.Devices {
public enum DeviceFunction {
SOURCE, // Adds a new cube from a specific source
SHIP, // Dispatch cube to specific target. Must match or level failed!
BUFFER_ENDPOINT, // Push/pop to buffer stack.
SPRAY_COLOUR, // Applies colour to a side
DROP_COLOUR, // Drips colour from above.
CLEAN, // Removes colour from a side.
DECORATE, // Adds a decal or similar
HIT, // Damages or destroys a side
REPAIR,
REMOVE_PANEL,
ADD_PANEL, // Adds a panel of a configured spec
ADD_REMOVE_PANEL, // Removes panels from cubes into a stack that can be added back later.
ADD_PAYLOAD, // Puts something into the cube - requires a missing side for access
HEAT,
COOL,
DETECT_PANEL, // Branch based on whether cube has a panel facing the device.
WEIGH, // Configurable threshold. Branch on response.
LASER, // Looks for a return signal, based on colour. Branch on response
PANEL, // Moves into place when triggered, has a colour, may interact with LASER
}
}
The DeviceFunction
enum, listing all 19 devices
Each device has its own 3D model, so somewhat inevitably there's a big switch statement in the code that gets a Unity prefab for a device. The mapping isn't totally trivial. For one thing, because the Panel Adder and the Panel Remover are the reverse of each other, they use the same model. There are also some special cases for a few devices - the Buffer, which teleports cubes around, usually has two halves, but when it's mounted at the rear of a workstation, there's only room for one, so we need to load a different prefab. It would be handy if I could encapsulate all that.
private static DeviceModelSet MapDeviceFunction(DeviceFunction df) {
switch (df) {
case DeviceFunction.SOURCE: return DeviceModelSet.Sourcer;
case DeviceFunction.SHIP: return DeviceModelSet.Shipper;
case DeviceFunction.BUFFER_ENDPOINT: return DeviceModelSet.Buffer;
case DeviceFunction.SPRAY_COLOUR: return DeviceModelSet.SprayPainter;
case DeviceFunction.DROP_COLOUR: return DeviceModelSet.SquirtPainter;
case DeviceFunction.CLEAN: return DeviceModelSet.Cleaner;
case DeviceFunction.DECORATE: return DeviceModelSet.Decorator;
case DeviceFunction.HIT: return DeviceModelSet.Hitter;
case DeviceFunction.REPAIR: return DeviceModelSet.Repairer;
// Panel Remover uses the same model as the Panel Adder
case DeviceFunction.REMOVE_PANEL: return DeviceModelSet.PanelAdder;
case DeviceFunction.ADD_PANEL: return DeviceModelSet.PanelAdder;
case DeviceFunction.ADD_REMOVE_PANEL: return DeviceModelSet.PanelAdderRemover;
case DeviceFunction.ADD_PAYLOAD: return DeviceModelSet.PayloadAdder;
case DeviceFunction.HEAT: return DeviceModelSet.Heater;
case DeviceFunction.COOL: return DeviceModelSet.Cooler;
case DeviceFunction.DETECT_PANEL: return DeviceModelSet.PanelSensor;
case DeviceFunction.WEIGH: return DeviceModelSet.Weigher;
case DeviceFunction.LASER: return DeviceModelSet.Laser;
case DeviceFunction.PANEL: return DeviceModelSet.ColouredPanel;
default: throw new ArgumentException("No mapping for device function " + df);
}
}
A switch
block in a totally different class. Bonus points because DeviceModelSet
is yet another big enum.
// Special cases
if (dc.type == DeviceFunction.BUFFER_ENDPOINT && Position == Facing.FAR && bufferSingleIndustrialOne != null) {
GameObject go = GameObject.Instantiate(bufferSingleIndustrialOne);
return go;
}
if (dc.type == DeviceFunction.SOURCE) {
if (Position == Facing.TOP && sourcerTopIndustrialOne != null) {
return GameObject.Instantiate(sourcerTopIndustrialOne);
}
else if (Position == Facing.BOTTOM && sourcerTopIndustrialOne != null) {
return GameObject.Instantiate(sourcerBottomIndustrialOne);
}
}
if (dc.type == DeviceFunction.SHIP) {
if (Position == Facing.TOP && shipperTopIndustrialOne != null) {
return GameObject.Instantiate(shipperTopIndustrialOne);
}
else if (Position == Facing.BOTTOM && shipperTopIndustrialOne != null) {
return GameObject.Instantiate(shipperBottomIndustrialOne);
}
}
Special cases for loading models — another case of functionality depending on the enum but not being defined by it.
A few devices also share behaviours, like allowing the player to branch the logic of a workstation based on the state of its cube.
UDevice device = deviceContainer.GetDevice(deviceIndex);
if (device != null && (device.Type == DeviceFunction.DETECT_PANEL ||
device.Type == DeviceFunction.LASER ||
device.Type == DeviceFunction.WEIGH)) {
// We have a branching command.
// Work out whether we're potentially going up or down.
int newTrack = ((BranchMode)command.mode).HasFlag(BranchMode.BRANCH_UP) ? track - 1 : track + 1;
AddCandidate(reachable, candidates, newTrack, index);
}
break;
Part of the code that detects unreachable commands. It would be handy to be able to ask each enum instance if it's a logic-branching device.
Now for the snag: as I discussed in an earlier post Hexahedra is split into two projects. HexSim, the core logic of the puzzle, is provided to the Unity project as a DLL. The DeviceFunction
enum is part of HexSim, but all the prefab loading stuff is, naturally, in the Unity side. If I was bundling DeviceFunction
up into one big Enumeration
class, the prefab naming stuff would end up in HexSim, which is really the wrong side of the divide.
So, how can we sort out the problem of all these switch statements all over the place while keeping each piece of code in the appropriate project? We're going to borrow some of the ideas from the Enumeration
class and use them in conjunction with extension methods to attach functions to enums.
Using extension methods means that on the HexSim side I can keep the plain enum, plus some extension methods for things like "is this a branching device?", and I can have an additional set of extension methods for prefabs on the Unity side.
To start with, I'll show you code that just uses extension methods to put all the functionality into one place per project, and then we'll refine it.
So, let's go and tidy this code up...
Method 1: Extension methods alone
If we want "is this a branching device?" and "what prefab should I use?" functionality on the enum, we can do this:
public static class HexSimExtensions {
public static bool IsBranchingDevice(this DeviceFunction df) {
switch (df) {
case DeviceFunction.DETECT_PANEL:
case DeviceFunction.LASER:
case DeviceFunction.WEIGH: {
return true;
}
default: {
return false;
}
}
}
}
// This would be in the other project
public static class UnityExtensions {
public static String GetPrefabName(this DeviceFunction df, int facing) {
switch (df) {
case DeviceFunction.SOURCE: {
if (facing == Facing.TOP) {
return "SourcerTop";
}
if (facing == Facing.BOTTOM) {
return "SourceBottom";
}
return "Sourcer";
}
case DeviceFunction.SHIP: {
if (facing == Facing.TOP) {
return "ShipperTop";
}
if (facing == Facing.BOTTOM) {
return "ShipperBottom";
}
return "Shipper";
}
case DeviceFunction.LASER: {
return "Laser";
}
// etc
}
}
}
We've now gathered all those big switch statements from across the codebase into two static classes, one in each project. The rest of the code is much cleaner and easier to read:
// I can replace things like this
if (device.Type == DeviceFunction.DETECT_PANEL ||
device.Type == DeviceFunction.LASER ||
device.Type == DeviceFunction.WEIGH))
// with something much simpler:
if (device.Type.IsBranchingDevice())
There is one problem, though, which is that the switch statements haven't got any smaller. They are encapsulated, but they still make for a set of lengthy functions. So here's where we're going to borrow from the Enumerations
class.
Method 2: Extensions and a private class
We'll leave DeviceFunction
as an enum, but within each static extension class we'll create a private class with lots of static readonly
instances, and use those to simplify the extension methods. I've just shown the Unity side here:
public static class UnityExtensions {
// We still have one switch statement
private static DeviceFunctionInstance GetInstance(DeviceFunction df) {
switch (df) {
case DeviceFunction.SOURCE: return DeviceFunctionInstance.SOURCE;
case DeviceFunction.SHIP: return DeviceFunctionInstance.SHIP;
case DeviceFunction.DETECT_PANEL: return DeviceFunctionInstance.DETECT_PANEL;
case DeviceFunction.LASER: return DeviceFunctionInstance.LASER;
// etc
}
}
// But everything else is much simpler!
// This used to be a massive switch statement!
public static String GetPrefabName(this DeviceFunction df, int facing) {
return GetInstance(df).GetPrefabName(facing);
}
// The class that actually does all the work
private class DeviceFunctionInstance {
private readonly String folderName;
private readonly String prefabName;
private readonly Dictionary<int, String> prefabOverrides;
// Devices with overrides use the full constructor
public static readonly DeviceFunctionInstance SOURCE = new DeviceFunctionInstance(
"Sourcer", "Sourcer", new Dictionary<int, String>() { { Facing.TOP, "SourcerTop" }, { Facing.BOTTOM, "SourcerBottom" } });
public static readonly DeviceFunctionInstance SHIP = new DeviceFunctionInstance(
"Shipper", "Shipper", new Dictionary<int, String>() { { Facing.TOP, "ShipperTop" }, { Facing.BOTTOM, "ShipperBottom" } });
// Simpler ones can use a cut-down version.
public static readonly DeviceFunctionInstance DETECT_PANEL = new DeviceFunctionInstance("PanelSensor");
public static readonly DeviceFunctionInstance LASER = new DeviceFunctionInstance("Laser");
public String GetFolderName() {
return folderName;
}
public String GetPrefabName(int facing) {
if (prefabOverrides != null && prefabOverrides.TryGetValue(facing, out String name)) {
return name;
}
return prefabName;
}
private DeviceFunctionInstance(String name) {
folderName = name;
prefabName = name;
}
private DeviceFunctionInstance(String folderName, String prefabName, Dictionary<int, String> prefabOverrides) {
this.folderName = folderName;
this.prefabName = prefabName;
this.prefabOverrides = prefabOverrides;
}
}
}
So here we have the best of both worlds. Using the class-masquerading-as-enum concept from Enumeration
we can drop all but one of the switch statements and replace them with method calls. Probably the biggest win in this example is turning lots of if (Position == Facing.TOP) return "prefabName"
code into a simple dictionary lookup. And by leaving DeviceFunction
itself as a true enum and adding extension methods we can split up the extra functionality between HexSim and the Unity project.
Arguably I could use Enumeration
on the HexSim side and the extension method system on the Unity side, but at this point I don't think it gains me much - and in the case of DeviceFunction
almost all the complicated functionality is on the Unity side anyway.
So, while I'd definitely recommend using Enumeration
when appropriate, if you're crossing assembly boundaries there's still a way to create relatively clean enum-based code.
Hexahedra has a Steam demo — head to the store page to try it out.