Click here to Skip to main content
15,886,806 members
Articles / Programming Languages / XML

Utilities for Enumeration Field Attribute

Rate me:
Please Sign up or sign in to vote.
1.92/5 (4 votes)
11 Jul 2018CPOL4 min read 4.1M   30   12   11
Enumeration fields typically require a mapping to keys and human-friendly names when being displayed on UI or output to some persistence

Introduction

This is an idea to define enumeration along with associated data in a simple form in C#. Mapping enumeration values with those related information requires a certain amount of code, and it makes a bit of mess around the code of enumeration definitions. This is a solution to improve such code to be simpler, more intuitive, and more readable by borrowing the power of attribute.

Problem

When defining a property that has a limited set of valid values, we use enumerations. By nature, it makes such values readable in code, but it is neither enough to be key name nor enough to literally display to users. For example, when there are following key values and their captions for display:

Key Caption
PS-4 Sony PlayStation 4
XBOX-ONE Microsoft Xbox One
SWITCH Nintendo Switch

The keys are not numeric values so that they cannot be enumeration values, and some of the key names cannot be enumeration value name because they have hyphen. Therefore, we need to have methods to translate enum value to the key and to translate the key back to enum value.

When it comes to display them to users as options for the property, it is not good UX to display enum value names. We also need to provide a caption for each enum value.

The following test code simulates that users firstly receive a list of options and choose one of them, and system aquires an enumeration value from the chosen option's key.

C#
[TestFixture]
public class EnumUtilityTest
{
    [Test]
    public void Test()
    {
        var options = GameConsoleUtility.GetAll();

        var optionDisplay = options
            .ToDictionary(o => o.ToKey(), o => $"{o.ToCaption()} [{o.ToKey()}]");

        var selectedKey = "XBOX-ONE";
        var enumValue = selectedKey.ToGameConsole();

        Assert.That(enumValue, Is.EqualTo(GameConsole.XboxOne));
    }
}

Background

When tackling the problem, I wanted to have the following points in the solution for code-readability and accordingly for better maintainability.

  • Name each associated data

It not only clarifies the purpose of each associated data in enumeration declaration code, but also makes it easy to find where a specific associated data is used/referred out of your hundreds of thousands lines of code.

  • Commonize the logic as much as possible

As I want to name associated data, custom code is required for each enumeration declaration. In order to simplify each code, sharing common code is vital. 

Before we get into my solution, let's see two other attempts firstly. Those also solve the problem, but I don't like them, which is the reason why I'm writing this tip. Those might be too nonsensical, but please regard them only as a contrast to the solution that comes later.

One Attempt

The following implementation can cover the requirements by using 3 sets of one-to-one mappings. But I don't like it.

In such implementation, if we need to add a new enumeration value, we need to make sure to add a line in other two parts, which would be error-prone if we're working in a team with many developers. In addition, it is not much readable, where the mapping definition for each enumeration value is separated.

C#
public enum GameConsole
{
    PS4,
    XboxOne,
    Switch
}

public static class GameConsoleUtility
{
    static Dictionary<gameconsole, string="">
    _keyMapping = new Dictionary<gameconsole, string="">
    {
        { GameConsole.PS4, "PS-4" },
        { GameConsole.XboxOne, "XBOX-ONE" },
        { GameConsole.Switch, "SWITCH" },
    };

    static Dictionary<string, gameconsole=""> _antiKeyMapping;

    static Dictionary<gameconsole, string="">
    _captionMapping = new Dictionary<gameconsole, string="">
    {
        { GameConsole.PS4, "Sony PlayStation 4" },
        { GameConsole.XboxOne, "Microsoft Xbox One" },
        { GameConsole.Switch, "Nintendo Switch" },
    };

    public static string ToKey(this GameConsole enumValue)
    {
        string key;
        if (!_keyMapping.TryGetValue(enumValue, out key))
            throw new Exception($"No mapping is specified for {enumValue.ToString()}");
        return key;
    }

    public static GameConsole ToGameConsole(this string key)
    {
        if (_antiKeyMapping == null)
        {
            _antiKeyMapping = new Dictionary<string, gameconsole="">();
            foreach (var pair in _keyMapping)
                _antiKeyMapping.Add(pair.Value, pair.Key);
        }

        GameConsole enumValue;
        if (!_antiKeyMapping.TryGetValue(key, out enumValue))
            throw new Exception($"Invalid key for GameConsole: {key}");
        return enumValue;
    }

    public static string ToCaption(this GameConsole enumValue)
    {
        string caption;
        if (!_captionMapping.TryGetValue(enumValue, out caption))
            throw new Exception($"No mapping is specified for {enumValue.ToString()}");
        return _captionMapping[enumValue];
    }

    public static List<gameconsole> GetAll()
    {
        return _keyMapping.Keys.ToList();
    }
}

Another Attempt

The implementation below combines the separate mappings to one set of mappings. I think this is better because code is simpler and data-naming is clearer. However, I cannot like it either.

Still, the mapping to associated data is a bit apart from enumeration declaration. In addition, having this much code for each enumeration type sounds not ideal for me.

C#
public enum GameConsole
{
    PS4,
    XboxOne,
    Switch
}

public static class GameConsoleUtility
{
    internal class GameConsoleMapping
    {
        public GameConsole EnumValue { get; set; }
        public string Key { get; set; }
        public string Caption { get; set; }
        public GameConsoleMapping(GameConsole gameConsole, string key, string caption)
        {
            EnumValue = gameConsole;
            Key = key;
            Caption = caption;
        }
    }

    static List<gameconsolemapping> _mappings = new List<gameconsolemapping>
    {
        new GameConsoleMapping(GameConsole.PS4, key: "PS-4", caption: "Sony PlayStation 4"),
        new GameConsoleMapping(GameConsole.XboxOne, key: "XBOX-ONE", caption: "Microsoft Xbox One"),
        new GameConsoleMapping(GameConsole.Switch, key: "SWITCH", caption: "Nintendo Switch"),
    };

    public static string ToKey(this GameConsole enumValue)
    {
        var key = _mappings.FirstOrDefault(m => m.EnumValue == enumValue)?.Key;
        if (key == null)
            throw new Exception($"No mapping is specified for {enumValue.ToString()}");
        return key;
    }

    public static GameConsole ToGameConsole(this string key)
    {
        var enumValue = _mappings.FirstOrDefault(m => m.Key == key)?.EnumValue;
        if (!enumValue.HasValue)
            throw new Exception($"Invalid key for GameConsole: {key}");
        return enumValue.Value;
    }

    public static string ToCaption(this GameConsole enumValue)
    {
        var caption = _mappings.FirstOrDefault(m => m.EnumValue == enumValue)?.Caption;
        if (caption == null)
            throw new Exception($"No mapping is specified for {enumValue.ToString()}");
        return caption;
    }

    public static List<gameconsole> GetAll()
    {
        return _mappings.Select(m => m.EnumValue).ToList();
    }
}

So, I need a solution that is:

  • more readable and intuitive
  • simpler code for each enumeration type

Solution

So, for the solution, I need:

  • Such data mapping code is placed close to the enumeration type declaration
  • Code for each enumeration is simpler

For my first desire, I thought usage of attribute would be ideal. I tried to commonize the code as much as possible, and came up with the solution below.

Let's see the code I extracted for the common use first.

C#
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
public class AliasAttribute : Attribute
{
    public string[] Aliases { get; }
    public AliasAttribute(params string[] aliases)
    {
        Aliases = aliases;
    }
    public string this[int i]
    {
        get
        {
            if (Aliases.Length > i)
                return Aliases[i];
            return null;
        }
    }
}

public static class AttributeUtility
{
    class AliasMapping
    {
        public IConvertible EnumValue { get; }
        public AliasAttribute Aliases { get; }
        public AliasMapping(IConvertible enumValue, AliasAttribute aliases)
        {
            EnumValue = enumValue;
            Aliases = aliases;
        }
    }

    static Dictionary<Type, IEnumerable<AliasMapping>>
    _mappingsMap = new Dictionary<Type, IEnumerable<AliasMapping>>();

    static IEnumerable<T> getCustomAttributes<T, U>(this U enumValue)
        where T : Attribute
        where U : struct, IConvertible
    {
        var fieldName = enumValue.ToString();
        return typeof(U).GetField(fieldName).GetCustomAttributes<T>(true);
    }

    static IEnumerable<AliasMapping> getMappings<T>() where T : struct, IConvertible
    {
        IEnumerable<AliasMapping> mappings;
        if (!_mappingsMap.TryGetValue(typeof(T), out mappings))
        {
            mappings = Enum.GetValues(typeof(T)).Cast<T>().Select
            (e => e.getCustomAttributes<AliasAttribute, T>().Select
            (a => new AliasMapping(e, a)).FirstOrDefault());
            _mappingsMap.Add(typeof(T), mappings);
        }
        return mappings;
    }

    public static string ToAlias<T>(this T enumValue, int index) where T : struct, IConvertible
    {
        var mapping = enumValue.getCustomAttributes<AliasAttribute, T>().FirstOrDefault();
        if (mapping == null)
            throw new Exception($"No mapping is defined for {enumValue.ToString()}");
        return mapping[index];
    }

    public static T ToEnum<T>(this string alias, int index) where T: struct, IConvertible
    {
        var mapping = getMappings<T>().FirstOrDefault(m => m.Aliases[index] == alias);
        if (mapping == null)
            throw new Exception($"Invalid alias for {nameof(T)}: {alias}");
        return (T)mapping.EnumValue;
    }

    public static List<T> GetAll<T>() where T : struct, IConvertible
    {
        return Enum.GetValues(typeof(T)).Cast<T>().ToList();
    }
}

Having such code as common, here's the code around an enumeration definition.

C#
public enum GameConsole
{
    [KeyCaption(key: "PS-4", caption: "Sony PlayStation 4")]
    PS4,
    [KeyCaption(key: "XBOX-ONE", caption: "Microsoft Xbox One")]
    XboxOne,
    [KeyCaption(key: "SWITCH", caption: "Nintendo Switch")]
    Switch
}

[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
public class KeyCaptionAttribute : AliasAttribute
{
    public KeyCaptionAttribute(string key, string caption) : base(key, caption)
    {
    }
}

public static class GameConsoleUtility
{
    public static string ToKey(this GameConsole enumValue)
    {
        return enumValue.ToAlias<GameConsole>(0);
    }

    public static GameConsole ToGameConsole(this string key)
    {
        return key.ToEnum<GameConsole>(0);
    }

    public static string ToCaption(this GameConsole enumValue)
    {
        return enumValue.ToAlias<GameConsole>(1);
    }

    public static List<GameConsole> GetAll()
    {
        return AttributeUtility.GetAll<GameConsole>();
    }
}

There's much less code and is thus tidy. Not only is it more readable, but also easier to maintain code, in contrast with the former two attempts.

I hope it could help you to make your code tidy and to improve quality in readability and maintainability.

Remarks

For caption or something to display on UI, there would be another concern, which is about globalization. I regard it is another problem. This article focuses on associated data with enumeration. For example, even in globalized program or system, you could have one and more associated keys, such as caption key, tool-tip message key, sub-option references.

If you need globalization with this utility class here, Sacha Barber's article can give you an idea.

How to use Attached File

The attached file EnumFieldAttributeUtilities.zip contains a VS solution. The solution has build configurations named Attempt1, Attempt2, and Solution, so that please switch build configuration on VS before you run test code.

The VS solution is created on VS Community 2017 for Mac. Please leave a comment if you have problem to use it in Windows.

History

  • 2018/06/26: Published first edition.
  • 2018/06/29: Attached a file which include a VS Solution with the code in this article. Also added a section for how to use it.
  • 2018/06/29: Added a link to Sacha Barber's article to Remarks section
  • 2018/07/12: Clarify the background of this solution more, reflecting the conversations in the comments.

 

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
New Zealand New Zealand
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
QuestionI've been using this a while: see what you think Pin
BillWoodruff14-Jul-18 4:36
professionalBillWoodruff14-Jul-18 4:36 
QuestionUsing Description attribute Pin
Clifford Nelson26-Jun-18 7:31
Clifford Nelson26-Jun-18 7:31 
AnswerRe: Using Description attribute Pin
YasIkeda27-Jun-18 17:47
YasIkeda27-Jun-18 17:47 
QuestionI did one of these some time back, it also supported localization Pin
Sacha Barber26-Jun-18 2:55
Sacha Barber26-Jun-18 2:55 
AnswerRe: I did one of these some time back, it also supported localization Pin
YasIkeda28-Jun-18 9:43
YasIkeda28-Jun-18 9:43 
QuestionDid You Forget to Add the Sample? Pin
David A. Gray25-Jun-18 10:39
David A. Gray25-Jun-18 10:39 
AnswerRe: Did You Forget to Add the Sample? Pin
YasIkeda27-Jun-18 13:16
YasIkeda27-Jun-18 13:16 
AnswerRe: Did You Forget to Add the Sample? Pin
YasIkeda28-Jun-18 9:22
YasIkeda28-Jun-18 9:22 
GeneralRe: Did You Forget to Add the Sample? Pin
David A. Gray29-Jun-18 7:53
David A. Gray29-Jun-18 7:53 
GeneralRe: Did You Forget to Add the Sample? Pin
YasIkeda29-Jun-18 9:51
YasIkeda29-Jun-18 9:51 
GeneralRe: Did You Forget to Add the Sample? Pin
David A. Gray29-Jun-18 12:01
David A. Gray29-Jun-18 12:01 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.