5
\$\begingroup\$

Lately I've been using a lot of enums in my project and the lack of generic Enum constraint seems to be quite problematic. This combined with few other headaches caused by the enums, made look for alternative solutions.

I came up with the following abstract Enumeration class:

Enumeration

public abstract partial class Enumeration : IConvertible, IComparable, IFormattable
{
    public string Name { get; }
    public int Value { get; }

    protected Enumeration(int id, string name)
    {
        Value = id;
        Name = name;
    }

    #region Equality members

    public override bool Equals(object obj)
    {
        var otherValue = obj as Enumeration;
        if (otherValue == null)
        {
            return false;
        }
        var typeMatches = GetType() == obj.GetType();
        var valueMatches = Value.Equals(otherValue.Value);
        return typeMatches && valueMatches;
    }

    protected bool Equals(Enumeration other)
    {
        return string.Equals(Name, other.Name) && Value == other.Value;
    }

    public override int GetHashCode()
    {
        unchecked
        {
            return ((Name != null ? Name.GetHashCode() : 0) * 397) ^ Value;
        }
    }

    #endregion

    #region Implementation of IComparable

    public int CompareTo(object other)
    {
        return Value.CompareTo(((Enumeration)other).Value);
    }

    #endregion

    #region ToString methods

    public string ToString(string format)
    {
        if (string.IsNullOrEmpty(format))
        {
            format = "G";
        }
        if (string.Compare(format, "G", StringComparison.OrdinalIgnoreCase) == 0)
        {
            return Name;
        }
        if (string.Compare(format, "D", StringComparison.OrdinalIgnoreCase) == 0)
        {
            return Value.ToString();
        }
        if (string.Compare(format, "X", StringComparison.OrdinalIgnoreCase) == 0)
        {
            return Value.ToString("X8");
        }
        throw new FormatException("Invalid format");
    }

    public override string ToString() => ToString("G");
    public string ToString(string format, IFormatProvider formatProvider) => ToString(format);

    #endregion

    #region Implementation of IConvertible

    TypeCode IConvertible.GetTypeCode() => TypeCode.Int32;
    bool IConvertible.ToBoolean(IFormatProvider provider) => Convert.ToBoolean(Value, provider);
    char IConvertible.ToChar(IFormatProvider provider) => Convert.ToChar(Value, provider);
    sbyte IConvertible.ToSByte(IFormatProvider provider) => Convert.ToSByte(Value, provider);
    byte IConvertible.ToByte(IFormatProvider provider) => Convert.ToByte(Value, provider);
    short IConvertible.ToInt16(IFormatProvider provider) => Convert.ToInt16(Value, provider);
    ushort IConvertible.ToUInt16(IFormatProvider provider) => Convert.ToUInt16(Value, provider);
    int IConvertible.ToInt32(IFormatProvider provider) => Value;
    uint IConvertible.ToUInt32(IFormatProvider provider) => Convert.ToUInt32(Value, provider);
    long IConvertible.ToInt64(IFormatProvider provider) => Convert.ToInt64(Value, provider);
    ulong IConvertible.ToUInt64(IFormatProvider provider) => Convert.ToUInt64(Value, provider);
    float IConvertible.ToSingle(IFormatProvider provider) => Convert.ToSingle(Value, provider);
    double IConvertible.ToDouble(IFormatProvider provider) => Convert.ToDouble(Value, provider);
    decimal IConvertible.ToDecimal(IFormatProvider provider) => Convert.ToDecimal(Value, provider);
    DateTime IConvertible.ToDateTime(IFormatProvider provider) => throw new InvalidCastException("Invalid cast.");
    string IConvertible.ToString(IFormatProvider provider) => ToString();
    object IConvertible.ToType(Type conversionType, IFormatProvider provider)
        => Convert.ChangeType(this, conversionType, provider);

    #endregion
}

public abstract partial class Enumeration
{
    private static readonly Dictionary<Type, IEnumerable<Enumeration>> _allValuesCache =
        new Dictionary<Type, IEnumerable<Enumeration>>();

    #region Parse overloads

    public static TEnumeration Parse<TEnumeration>(string name)
        where TEnumeration : Enumeration
    {
        return Parse<TEnumeration>(name, false);
    }

    public static TEnumeration Parse<TEnumeration>(string name, bool ignoreCase)
        where TEnumeration : Enumeration
    {
        return ParseImpl<TEnumeration>(name, ignoreCase, true);
    }

    private static TEnumeration ParseImpl<TEnumeration>(string name, bool ignoreCase, bool throwEx)
        where TEnumeration : Enumeration
    {
        var value = GetValues<TEnumeration>()
            .FirstOrDefault(entry => StringComparisonPredicate(entry.Name, name, ignoreCase));
        if (value == null && throwEx)
        {
            throw new InvalidOperationException($"Requested value {name} was not found.");
        }
        return value;
    }

    #endregion

    #region TryParse overloads

    public static bool TryParse<TEnumeration>(string name, out TEnumeration value)
        where TEnumeration : Enumeration
    {
        return TryParse(name, false, out value);
    }

    public static bool TryParse<TEnumeration>(string name, bool ignoreCase, out TEnumeration value)
        where TEnumeration : Enumeration
    {
        value = ParseImpl<TEnumeration>(name, ignoreCase, false);
        return value != null;
    }

    #endregion

    #region Format overloads

    public static string Format<TEnumeration>(TEnumeration value, string format)
        where TEnumeration : Enumeration
    {
        return value.ToString(format);
    }

    #endregion

    #region GetNames

    public static IEnumerable<string> GetNames<TEnumeration>()
        where TEnumeration : Enumeration
    {
        return GetValues<TEnumeration>().Select(e => e.Name);
    }

    #endregion

    #region GetValues

    public static IEnumerable<TEnumeration> GetValues<TEnumeration>()
        where TEnumeration : Enumeration
    {
        var enumerationType = typeof(TEnumeration);
        if (_allValuesCache.TryGetValue(enumerationType, out var value))
        {
            return value.Cast<TEnumeration>();
        }
        return AddValueToCache(enumerationType, enumerationType
            .GetFields(BindingFlags.Public | BindingFlags.Static)
            .Select(p => p.GetValue(enumerationType)).Cast<TEnumeration>());
    }

    private static IEnumerable<TEnumeration> AddValueToCache<TEnumeration>(Type key,
        IEnumerable<TEnumeration> value)
        where TEnumeration : Enumeration
    {
        _allValuesCache.Add(key, value);
        return value;
    }

    #endregion

    #region IsDefined overloads

    public static bool IsDefined<TEnumeration>(string name)
        where TEnumeration : Enumeration
    {
        return IsDefined<TEnumeration>(name, false);
    }

    public static bool IsDefined<TEnumeration>(string name, bool ignoreCase)
        where TEnumeration : Enumeration
    {
        return GetValues<TEnumeration>().Any(e => StringComparisonPredicate(e.Name, name, ignoreCase));
    }

    #endregion

    #region Helpers

    private static bool StringComparisonPredicate(string item1, string item2, bool ignoreCase)
    {
        var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
        return string.Compare(item1, item2, comparison) == 0;
    }

    #endregion
}

It's separated into 2 files -

1 responsible for the core logic, interface implementations, protected members, etc. and 1 responsible for the static helper methods such as Parse, TryParse, etc.

I have implemented all of the interfaces that the Enum class usually has. There are 2 properties to access the Name and the Value of the entry. Currently there is no support for the Flags attribute, but I might work this in if I find it necessary.

For the GetValues method I decided to use private cache to speed further lookups, as the method uses reflection which tends to be slow and I've tried to limit it's usage to only the first time for each enumeration type.

Some methods that are present in the Enum class have been omitted, since they don't make much sense, e.g Enum.GetName() my enumeration entries already have such property, unless the idea was to pass some value equal to the underlying type and retrieve the name, but still I find it rather pointless and easily achievable through the other available methods.

Speaking of underlying type, there currently isn't any besides Int32. This could be easily changed by adding a generic type argument to the class itself, but I don't feel like it's necessary, because I rarely find the need to use different underlying type for an enum, perhaps a Int64 would serve me better, but for now it's just Int32.

Example implementation

public class TestEnumeration : Enumeration
{
    public static TestEnumeration A = new TestEnumeration(0, nameof(A));
    public static TestEnumeration B = new TestEnumeration(1, nameof(B));
    //...

    private static readonly IEnumerable<TestEnumeration> _test;

    protected TestEnumeration(int id, string name)
        : base(id, name)
    {
    }

    static TestEnumeration()
    {
        _test = GetValues<TestEnumeration>();
    }

    public static IEnumerable<TestEnumeration> Values()
    {
        foreach (var entry in _test)
        {
            yield return entry;
        }
    }
}

Notice the usage of the static constructor. It allows the Enumeration class to cache the values as soon as possible, resulting in slightly better performance.

A method template would be extremely useful in this situation, as we can get rid of most of the reflection if we can guide the derived classes' usage, but since some of the logic is in static context this doesn't seem to be possible to me.

ANY feedback is welcome! :)

\$\endgroup\$
  • 2
    \$\begingroup\$ How do you intend to use this in practice? What problems does this solve exactly? Is that worth all the extra boilerplate code, the need for null-checks and the lack of switch statement and flags/bitmask support? \$\endgroup\$ – Pieter Witvoet Mar 31 '18 at 12:05
  • \$\begingroup\$ @PieterWitvoet when I get home I will try to elaborate further. But where would a null check be required exactly? Also I don't use switch/ifs I have a custom dictionary that handles any enum/enumeration type. Flags can be added as well I just currently don't require enumeration with flags. Enums can still be used when appropriate. \$\endgroup\$ – Denis Mar 31 '18 at 13:51
  • \$\begingroup\$ So this is part of a specific system rather than a replacement for enums? Then it would indeed be useful to know more about that system. As for null checks, I mentioned that because enums are value types, while Enumeration is a reference type, so code that uses it may need to take null-references into account. \$\endgroup\$ – Pieter Witvoet Mar 31 '18 at 20:44
  • \$\begingroup\$ @PieterWitvoet You would retrieve the value by the property Value which is an integer type, null check wont be required, there is no point of checking if the whole object itself is null or not as if it is null, it's simply a mistake on the supplier's end. That's not the intended usage of the class. It is indeed possible to screw with it, but avoiding that from the class itself would add way too much redundant code for the intended, average user. \$\endgroup\$ – Denis Mar 31 '18 at 21:02
  • \$\begingroup\$ Well, there's a reason that Tony Hoare called null references his billion-dollar mistake. Passing null instead of an Enumeration instance may not be intended usage, but it can - and in practice, likely will - happen. It introduces a potential source of problems that you don't have with enums. I'm not saying that such a trade-off isn't worth it - that depends on how and where you intend to use this - but it's certainly something I would take into account. \$\endgroup\$ – Pieter Witvoet Mar 31 '18 at 21:43
3
\$\begingroup\$

Review


IEquatable<T>

protected bool Equals(Enumeration other)
{
  return string.Equals(Name, other.Name) && Value == other.Value;
}

Two things about this method...

  • If you implemented it, don't downgrade it to a simple protected helper method. It belongs to the IEquatable<T> interface so implement this one too.

  • I find it's easier to implement the Equals pair by forwarting the call from Equals(object other) to Equals(T other) becasue the latter is strongly typed.


Bang! NullReferenceException!

This other.Name will blow if other is null so make sure to check all parts of the expression.


bool throwEx

You shouldn't use parameters that switch exeception throwing on/off. Instead remove the ParseImpl and implement the parsing by one of the TryParse methods then reuse it elsewhere and throw exception by Parse methods if necessasry.


readonly

Remember to make your public fields of the derived class readonly.

Readability

  • partial class

Partial classes are a great way for organizing code and I find there should be more of them. In fact each interface should be implemented by its own partial class to avoid using so many regions.

  • Parameter lists
private static IEnumerable<TEnumeration> AddValueToCache<TEnumeration>(Type key,
  IEnumerable<TEnumeration> value)
  where TEnumeration : Enumeration

I've never liked the style where some parameters are left in one line somehere far to the right and others are put in the new line far to the left... they should either all be on new lines or none of them.

private static IEnumerable<TEnumeration> AddValueToCache<TEnumeration>(
    Type key,
    IEnumerable<TEnumeration> value
) where TEnumeration : Enumeration

I find this is much easier to read.

  • LINQ chains
var value = GetValues<TEnumeration>()
  .FirstOrDefault(entry => StringComparisonPredicate(entry.Name, name, ignoreCase));

A similar rule applies to LINQ chanins. Either no line breaks or all line breaks. Mixed styles are difficult to read.

    var value = 
        GetValues<TEnumeration>()
            .FirstOrDefault(entry => 
                StringComparisonPredicate(
                    entry.Name, 
                    name, 
                    ignoreCase
                )
            );

This is (an extreme example of) how I think long expressions should be formatted if a single line too long.

    var value = 
        GetValues<TEnumeration>()
            .FirstOrDefault(entry => StringComparisonPredicate(entry.Name, name, ignoreCase));

Or just like that so that you can read it easily and naturally from

left to 
    right 

and not from

     right to 
left to 
     right

Naming

You name the properties Name and Value but the parameters inconsistently name and id. They should be name and value too.


Improvements


You can greatly simplify your code by implementing generics. If you make your base class Enumeration<T> you can remove all boilerplate code from the derived class to the new generic Enumeration<T>. The compiler will generate static fields and properties for each T separately so you don't have to write them yourself anymore. Here's a short example.

(I removed all interfaces or other helpers like Parse and TryParse or operators for simplicity but they should be included in the actual implementaion later.)

public abstract partial class Enumeration<T> 
{
    private static readonly IDictionary<string, int> _valueCache = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
    private static readonly IDictionary<int, string> _nameCache = new Dictionary<int, string>();

    public string Name { get; }

    public int Value { get; }

    protected Enumeration(string name, int value)
    {       
        Value = value;
        Name = name;
        _valueCache.Add(name, value);
        _nameCache.Add(value, name);
    }

    public static IEnumerable<string> Names => _nameCache.Values;

    public static IEnumerable<int> Values => _valueCache.Values;

    // Examples only! TODO: add null checks or redicrect `IComparable<T>`
    public static bool operator >(Enumeration<T> left, Enumeration<T> right) => left.Value > right.Value;
    public static bool operator <(Enumeration<T> left, Enumeration<T> right) => left.Value < right.Value;
}

This is how your new class could be implemented. You have now two dictionaries to cache both the names and values for faster lookups.

It's all static! No need to write this code again and again and agian...

You also no longer need reflection because when you create the properties on your derived class, the names and values will automatically by added to both dictionaries.

public class TestEnumeration : Enumeration<TestEnumeration>
{
    public readonly static TestEnumeration A = new TestEnumeration(nameof(A), 0);
    public readonly  static TestEnumeration B = new TestEnumeration(nameof(B), 1);

    protected TestEnumeration(string name, int value)
        : base(name, value)
    {
    }   
}

This is what remains from your original class. I swithched the order of parameters form value and name to name and value because I find this order more natural.

I also suggest implementing the IEquatable<Enumerable<T>> interface by redirecting it to a custom implementation of IEqualityComparer<Enumerable<T>> becuase it not only better encapsulates the logic but also has a nicer Equals method taking two parameters for left and right rather then working with the invisible this and other.

Usage examples:

TestEnumeration.A.Dump(); // (A, 0)

TestEnumeration.Names.Dump(); // A, B

(TestEnumeration.A > TestEnumeration.B).Dump(); // False
(TestEnumeration.B > TestEnumeration.A).Dump(); // True

I like the idea of the Enumeration class and I use similar solutions myself a lot (now improved) because they are often better then ordinary enums:

  • they can have a lot more custom features
  • it's easier to write extensions
  • they're faster and more efficient in scenarios where otherwise boxing would be involved e.g. Dictionar<string, object>
\$\endgroup\$
  • \$\begingroup\$ Thank you for your input, I'd like to hear more if possible. \$\endgroup\$ – Denis Mar 31 '18 at 16:28
  • \$\begingroup\$ @Denis updated, I think you'll like it ;-) One of my older and similar implementaions is the LogLevel that I'll have to revise after writing this answer ;-] \$\endgroup\$ – t3chb0t Apr 1 '18 at 7:31
  • \$\begingroup\$ It looks very good, one thing that kinda breaks it tho is how would you retrieve an instance of Enumeration derived class by it's name and value? For example the Parse method. Instantiating a new object doesn't seem right to me, what are your thoughts? Perhaps another cache? \$\endgroup\$ – Denis Apr 1 '18 at 17:17
  • \$\begingroup\$ @Denis oh, crap. Sure, you're right, the instance isn't in the cache! :-o I'd make the Value of the dictionary a tuple with the value it has now and the instance. It should cover all needs. As far as Parse is concerned you call it via Enumeration<YourEnum>.Parse(...) then it has access to both caches and can retrieve everthing. \$\endgroup\$ – t3chb0t Apr 1 '18 at 17:23
  • \$\begingroup\$ Let me play with it for a bit, I just woke up, I feel like there might be some problems with overlapping names, values between different enumerations. I will get back to you possibly with a github repository or something. \$\endgroup\$ – Denis Apr 1 '18 at 17:29
1
\$\begingroup\$

After incorporating the suggestions by t3chb0t, the class now looks like this:

[DebuggerDisplay("{Name} = {Value}")]
public abstract partial class Enumeration<T>
    where T : Enumeration<T>
{
    private static readonly IDictionary<int, Enumeration<T>> _valueCache =
        new Dictionary<int, Enumeration<T>>();
    public static IDictionary<int, Enumeration<T>> ValueCache
    {
        get
        {
            RuntimeHelpers.RunClassConstructor(typeof(T).TypeHandle);
            return _valueCache;
        }
    }

    private static readonly IDictionary<string, Enumeration<T>> _nameCache =
        new Dictionary<string, Enumeration<T>>(StringComparer.OrdinalIgnoreCase);
    public static IDictionary<string, Enumeration<T>> NameCache
    {
        get
        {
            RuntimeHelpers.RunClassConstructor(typeof(T).TypeHandle);
            return _nameCache;
        }
    }

    public string Name { get; }
    public int Value { get; }

    public static IEnumerable<string> Names => NameCache.Keys;
    public static IEnumerable<int> Values => ValueCache.Keys;

    protected Enumeration(string name, int value)
    {
        Name = name;
        Value = value;
        ValueCache.Add(value, this);
        NameCache.Add(name, this);
    }

    public static T Parse(string name)
    {
        if (TryParse(name, out var value))
        {
            return value;
        }
        throw new InvalidOperationException($"Requested value {name} was not found.");
    }

    public static bool TryParse(string name, out T value)
    {
        if (NameCache.TryGetValue(name, out var containedValue))
        {
            value = (T)containedValue;
            return true;
        }
        value = default(T);
        return false;
    }

    public static string Format(T value, string format)
    {
        return value.ToString(format);
    }

    public static bool IsDefined(string name)
    {
        return NameCache.ContainsKey(name);
    }
}

public abstract partial class Enumeration<T> : IConvertible
{
    TypeCode IConvertible.GetTypeCode() => TypeCode.Int32;
    bool IConvertible.ToBoolean(IFormatProvider provider) => Convert.ToBoolean(Value, provider);
    char IConvertible.ToChar(IFormatProvider provider) => Convert.ToChar(Value, provider);
    sbyte IConvertible.ToSByte(IFormatProvider provider) => Convert.ToSByte(Value, provider);
    byte IConvertible.ToByte(IFormatProvider provider) => Convert.ToByte(Value, provider);
    short IConvertible.ToInt16(IFormatProvider provider) => Convert.ToInt16(Value, provider);
    ushort IConvertible.ToUInt16(IFormatProvider provider) => Convert.ToUInt16(Value, provider);
    int IConvertible.ToInt32(IFormatProvider provider) => Value;
    uint IConvertible.ToUInt32(IFormatProvider provider) => Convert.ToUInt32(Value, provider);
    long IConvertible.ToInt64(IFormatProvider provider) => Convert.ToInt64(Value, provider);
    ulong IConvertible.ToUInt64(IFormatProvider provider) => Convert.ToUInt64(Value, provider);
    float IConvertible.ToSingle(IFormatProvider provider) => Convert.ToSingle(Value, provider);
    double IConvertible.ToDouble(IFormatProvider provider) => Convert.ToDouble(Value, provider);
    decimal IConvertible.ToDecimal(IFormatProvider provider) => Convert.ToDecimal(Value, provider);
    DateTime IConvertible.ToDateTime(IFormatProvider provider) => throw new InvalidCastException("Invalid cast.");
    string IConvertible.ToString(IFormatProvider provider) => ToString();

    object IConvertible.ToType(Type conversionType, IFormatProvider provider)
        => Convert.ChangeType(this, conversionType, provider);
}

public abstract partial class Enumeration<T> : IComparable<Enumeration<T>>
{
    public int CompareTo(Enumeration<T> other)
    {
        if (ReferenceEquals(this, other)) return 0;
        if (ReferenceEquals(null, other)) return 1;
        return Value.CompareTo(other.Value);
    }
}

public abstract partial class Enumeration<T> : IFormattable
{
    public string ToString(string format)
    {
        if (string.IsNullOrEmpty(format))
        {
            format = "G";
        }
        if (string.Compare(format, "G", StringComparison.OrdinalIgnoreCase) == 0)
        {
            return Name;
        }
        if (string.Compare(format, "D", StringComparison.OrdinalIgnoreCase) == 0)
        {
            return Value.ToString();
        }
        if (string.Compare(format, "X", StringComparison.OrdinalIgnoreCase) == 0)
        {
            return Value.ToString("X8");
        }
        throw new FormatException("Invalid format");
    }

    public override string ToString() => ToString("G");
    public string ToString(string format, IFormatProvider formatProvider) => ToString(format);
}

public abstract partial class Enumeration<T> : IEquatable<Enumeration<T>>
{
    public bool Equals(Enumeration<T> other)
    {
        if (ReferenceEquals(null, other)) return false;
        if (ReferenceEquals(this, other)) return true;
        return Value == other.Value;
    }

    public override bool Equals(object obj)
    {
        if (ReferenceEquals(null, obj)) return false;
        if (ReferenceEquals(this, obj)) return true;
        if (obj.GetType() != GetType()) return false;
        return Equals((Enumeration<T>)obj);
    }

    public override int GetHashCode()
    {
        return Value.GetHashCode();
    }

    public static bool operator >(Enumeration<T> item1, Enumeration<T> item2) => item1.CompareTo(item2) > 0;
    public static bool operator <(Enumeration<T> item1, Enumeration<T> item2) => item1.CompareTo(item2) < 0;

    public static explicit operator Enumeration<T>(int value)
    {
        return (Enumeration<T>)Activator.CreateInstance(
            typeof(T),
            BindingFlags.NonPublic | BindingFlags.Instance, null,
            new object[] { value.ToString(), value }, null);
    }
}

The base class is now generic, which allows the derived class to have an extremely simplified creation.

All of the helper methods no longer need to declare a generic type argument as they can use the class' one. Also most of the overloads have been removed, due to now having 2 private dictionary caches, which allows for a StringComparer to be passed during initialization, which also means that all Names will be treated using the StringComparer.OrdinalIgnoreCase.

All of the interfaces' implementations have been separated into partial files. IEquatable<Enumeration<T>> has been inherited and implemented along with some changes to the equality members as a whole. Now they take only the Value of an object in to consideration. As multiple repetitive values are NOT allowed. It's intended to work this way because from my experience with enums it's quite a headache to work with duplicate values inside the same enum.

2 operators have been overridden < && > and an explicit cast from int to Enumeration<T> has be added.

That's pretty much all the changes, the class looks a lot cleaner and definitely faster, because reflection is no longer needed, and we can make use of the constant time complexity of the dictionary's look up.

\$\endgroup\$
  • \$\begingroup\$ @t3chb0t, click for future reference. \$\endgroup\$ – Denis Apr 1 '18 at 20:20

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.