10
\$\begingroup\$

Enums with the FlagsAttribute have the disadvantage that you need to be careful when assigning their values. They are also inconvenient when you would like to allow the user of the library to add their own options. The enum is closed/final.

My alternative Option class should solve these two issues. It can be used either on its own or be inherited from. The two static Create factories take care of the Flag value for the specified Category. The Category groups options together. HasFlag is called here Contains. Other than this it also implements the usual set of operators and parsing.

[PublicAPI]
[DebuggerDisplay(DebuggerDisplayString.DefaultNoQuotes)]
public class Option : IEquatable<Option>, IComparable<Option>, IComparable
{
    private static readonly OptionComparer Comparer = new OptionComparer();

    private static readonly ConcurrentDictionary<SoftString, int> Flags = new ConcurrentDictionary<SoftString, int>();

    public Option(SoftString category, SoftString name, int flag)
    {
        Category = category;
        Name = name;
        Flag = flag;
    }

    private string DebuggerDisplay => ToString();

    [AutoEqualityProperty]
    public SoftString Category { [DebuggerStepThrough] get; }

    public SoftString Name { [DebuggerStepThrough] get; }

    [AutoEqualityProperty]
    public int Flag { [DebuggerStepThrough] get; }

    public static Option Create(string category, string name)
    {
        return new Option(category, name, NextFlag(category));
    }

    [NotNull]
    public static T Create<T>(string name) where T : Option
    {
        return (T)Activator.CreateInstance(typeof(T), name, NextFlag(typeof(T).Name));
    }

    private static int NextFlag(string category)
    {
        return Flags.AddOrUpdate(category, t => 0, (k, flag) => flag == 0 ? 1 : flag << 1);
    }
    public static Option Parse([NotNull] string value, params Option[] options)
    {
        if (value == null) throw new ArgumentNullException(nameof(value));
        if (options.Select(o => o.Category).Distinct().Count() > 1) throw new ArgumentException("All options must have the same category.");

        return options.FirstOrDefault(o => o.Name == value) ?? throw DynamicException.Create("OptionOutOfRange", $"There is no such option as '{value}'.");
    }

    public static Option FromValue(int value, params Option[] options)
    {
        if (options.Select(o => o.Category).Distinct().Count() > 1) throw new ArgumentException("All options must have the same category.");

        return
            options
                .Where(o => (o.Flag & value) == o.Flag)
                .Aggregate((current, next) => new Option(options.First().Category, "Custom", current.Flag | next.Flag));
    }

    public bool Contains(params Option[] options) => Contains(options.Aggregate((current, next) => current.Flag | next.Flag).Flag);

    public bool Contains(int flags) => (Flag & flags) == flags;

    [DebuggerStepThrough]
    public override string ToString() => $"{Category.ToString()}.{Name.ToString()}";

    #region IEquatable

    public bool Equals(Option other) => AutoEquality<Option>.Comparer.Equals(this, other);

    public override bool Equals(object obj) => Equals(obj as Option);

    public override int GetHashCode() => AutoEquality<Option>.Comparer.GetHashCode(this);

    #endregion

    public int CompareTo(Option other) => Comparer.Compare(this, other);

    public int CompareTo(object other) => Comparer.Compare(this, other);

    public static implicit operator string(Option option) => option?.ToString() ?? throw new ArgumentNullException(nameof(option));

    public static implicit operator int(Option option) => option?.Flag ?? throw new ArgumentNullException(nameof(option));

    public static implicit operator Option(string value) => Parse(value);

    public static implicit operator Option(int value) => FromValue(value);

    #region Operators

    public static bool operator ==(Option left, Option right) => Comparer.Compare(left, right) == 0;
    public static bool operator !=(Option left, Option right) => !(left == right);

    public static bool operator <(Option left, Option right) => Comparer.Compare(left, right) < 0;
    public static bool operator <=(Option left, Option right) => Comparer.Compare(left, right) <= 0;

    public static bool operator >(Option left, Option right) => Comparer.Compare(left, right) > 0;
    public static bool operator >=(Option left, Option right) => Comparer.Compare(left, right) >= 0;

    public static Option operator |(Option left, Option right) => new Option(left.Category, "Custom", left.Flag | right.Flag);

    #endregion

    private class OptionComparer : IComparer<Option>, IComparer
    {
        public int Compare(Option left, Option right)
        {
            if (ReferenceEquals(left, right)) return 0;
            if (ReferenceEquals(left, null)) return 1;
            if (ReferenceEquals(right, null)) return -1;
            return left.Flag - right.Flag;
        }

        public int Compare(object left, object right) => Compare(left as Option, right as Option);
    }
}

This should replace the previous enum

[Flags]
public enum FeatureOptions
{
    None = 0,

    /// <summary>
    /// When set a feature is enabled.
    /// </summary>
    Enabled = 1 << 0,

    /// <summary>
    /// When set a warning is logged when a feature is toggled.
    /// </summary>
    Warn = 1 << 1,

    /// <summary>
    /// When set feature usage statistics are logged.
    /// </summary>
    Telemetry = 1 << 2, // For future use
}

with

[PublicAPI]
public static class FeatureOptionsNew
{
    public static readonly FeatureOption None = Option.Create<FeatureOption>(nameof(None));

    /// <summary>
    /// When set a feature is enabled.
    /// </summary>
    public static readonly FeatureOption Enable = Option.Create<FeatureOption>(nameof(Enable));

    /// <summary>
    /// When set a warning is logged when a feature is toggled.
    /// </summary>
    public static readonly FeatureOption Warn = Option.Create<FeatureOption>(nameof(Warn));

    /// <summary>
    /// When set feature usage statistics are logged.
    /// </summary>
    public static readonly FeatureOption Telemetry = Option.Create<FeatureOption>(nameof(Warn));
}

that is based on a new FeatureOption type

public class FeatureOption : Option
{
    public FeatureOption(string name, int value) : base(nameof(FeatureOption), name, value) { }
}

They can be used exactly like classic enums:

public class OptionTest
{
    [Fact]
    public void Examples()
    {
        Assert.Equal(new[] { 0, 1, 2, 4 }, new[]
        {
            FeatureOptionsNew.None,
            FeatureOptionsNew.Enable,
            FeatureOptionsNew.Warn,
            FeatureOptionsNew.Telemetry
        }.Select(o => o.Flag));

        Assert.Equal(FeatureOptionsNew.Enable, FeatureOptionsNew.Enable);
        Assert.NotEqual(FeatureOptionsNew.Enable, FeatureOptionsNew.Telemetry);

        var oParsed = Option.Parse("Warn", FeatureOptionsNew.Enable, FeatureOptionsNew.Warn, FeatureOptionsNew.Telemetry);
        Assert.Equal(FeatureOptionsNew.Warn, oParsed);

        var oFromValue = Option.FromValue(3, FeatureOptionsNew.Enable, FeatureOptionsNew.Warn, FeatureOptionsNew.Telemetry);
        Assert.Equal(FeatureOptionsNew.Enable | FeatureOptionsNew.Warn, oFromValue);

        Assert.True(FeatureOptionsNew.None < FeatureOptionsNew.Enable);
        Assert.True(FeatureOptionsNew.Enable < FeatureOptionsNew.Telemetry);
    }
}

Questions

  • Is this as extendable as I think it is?
  • Are there any APIs missing that I didn't think of or would be convenient?
  • What do you think about the automatic Flag maintenance and options creation?
\$\endgroup\$
  • \$\begingroup\$ The current commit. \$\endgroup\$ – t3chb0t May 26 at 12:06
  • \$\begingroup\$ Comments are not for extended discussion; this conversation has been moved to chat. \$\endgroup\$ – rolfl May 27 at 13:15
  • \$\begingroup\$ wow, I got another downvote... how so? \$\endgroup\$ – t3chb0t Jul 12 at 11:50
10
\$\begingroup\$

int NextFlag(string category)

I'd expect this to throw when it runs out of flags.

I really really don't like that the first flag just happens to be 0: that depends on the order in which they are defined, and isn't written down anywhere.

Option FromValue(int value, params Option[] options)

I don't understand what this method is really meant to achieve... I'd expect it to throw a nicer exception when options is null or empty (no category, so it has to fail), and it seems to do a lot of work to produce a new option with the given flag, implicitly filtering out options which are not given... I just don't get it. Shouldn't it throw if you are trying to stuff 42879 into something which only expects the last 4 bits to be set?

The Aggregate seems like it incurs some unnecessary allocations, and I think the alternative of accumulating the flag before creating any options would be clearer. I'd also consider breaking it down a little so that each stage in the LINQ is clearer, and I'd kind of expect the name to be more useful (what I've done below will of course look awful when combined with your ToString()).

var observedOptions = options.Where(o => (o.Flag & value) == o.Flag);
var flags = observedOptions.Aggregate(0, (current, o) => current | o.Flag));
var name = string.Join(" | ", observedOptions.OrderBy(o => o.Flag).Select(o => o.Name));
return new Option(options.First().Category, name, flags));

The flag accumulator could be its own method, shared with the Contains method, since it seems like a meaningful task in its own right.

Misc

  • It should check for name reuse: this bug should throw in your example:

    Telemetry = Option.Create<FeatureOption>(nameof(Warn));
    
  • Does it make sense to provide inequality comparators? Again, this depends on the order in which the flags are created to have meaning, though I'll grant this is consistent with enum.

  • Option(string value) => Parse(value); looks broken, as does operator Option(int value) => FromValue(value), because they don't provide any options from which to select.

  • There are a few [NotNull]s strewn about the place, and some that appear to be missing (e.g. both Create methods presumably don't return null, nor should Parse; the parameters to the implicit operators).

  • The comparer will happily compare Options from different categories, which doesn't sound particularly meaningful. You might consider putting the check for uni-categoriyness into a new method taking params Option[], and feed it in this instance also.

  • You could make use of [System.Runtime.CompilerServices.CallerMemberName] in Option.Create<T>, which could mitigate bugs like the misnaming of Telemetry.

\$\endgroup\$
  • 1
    \$\begingroup\$ That's a lot to improve! I'd better get to work... \$\endgroup\$ – t3chb0t May 26 at 14:33
  • \$\begingroup\$ Shouldn't it throw if you are trying to stuff 42879 - this one might be problematic as the language allows you to cast any number into an enum without throwing an exception... but I think it might be a good idea to change this behavior and make the new API more robust... \$\endgroup\$ – t3chb0t May 27 at 9:24
  • \$\begingroup\$ @t3chb0t Would it need to throw an exception or normalize to the subset that is known? For instance, if only 0 and 1 are defined flags, and the user requests 3, you might return the option corresponding to 1. If both 1 and 2 were defined, you might return the combined option 1 | 2. I am not sure what would be the best behavior for these partially known superset values. \$\endgroup\$ – dfhwze May 28 at 10:08
  • \$\begingroup\$ @dfhwze I currently throw when the user requests option > Max, I think this is better than returning something close to it becuase this shouldn't happen and would indicate an error. I like the second option and it looks like I, at least, partialy support it with FromValue where I look for known options, otherwise I return Unknown, which is still valid because each bit is defined but that particular combination not. \$\endgroup\$ – t3chb0t May 28 at 17:27
  • \$\begingroup\$ @t3chb0t The only possible downside I see is if your options struct is shared in a public API as int/uint and the other party might have more flags specified as you. Not sure this is a valid use case for you, but if so, think about how to be compatible with the other party. \$\endgroup\$ – dfhwze May 28 at 17:30
9
\$\begingroup\$

Is this as extendable as I think it is?

Does it work for multi-bit flags? For instance

[Flags]
enum Modifiers : uint {
    None = 0,
    Private = 1,
    Protected = 2,
    Public = 4,
    NonPublic = Private | Protected,  // <- multi-bit
    All = ~None
}

Are there any APIs missing that I didn't think of or would be convinient?

BitVector32 has support for bit flags, sections, masks. Perhaps this serves your purpose better, since it is dynamic and flexible. There are no design time constraints like in an enum.

What do you think about the automatic Flag maintenance and options creation?

It's cool, but I would have a look at existing API's how to augment it for masks and multi-bit flags.

\$\endgroup\$
  • \$\begingroup\$ It is very likely that I don't know what multibit flags are. Could give me an example? \$\endgroup\$ – t3chb0t May 26 at 12:47
  • 1
    \$\begingroup\$ @t3chb0t Here is an example for Value 'SuperUser': stackoverflow.com/questions/19582477/… \$\endgroup\$ – dfhwze May 26 at 12:49
4
\$\begingroup\$

(self-answer)


v3

I wanted to use v2 of this code (below) to upgrade my old MimeType that was very similar but it turned out I cannot because I need string values (like application/json) and not numerical ones (like 1) (which are rarely useful anyway) so I've changed the whole thing to work with my SoftString and replaced binary operations with HashSets. Alternatively this could use a generic value but currently I don't see any use for them.

[PublicAPI]
public abstract class Option
{
    protected const string Unknown = nameof(Unknown);

    public static readonly IImmutableList<SoftString> ReservedNames =
        ImmutableList<SoftString>
            .Empty
            .Add(nameof(Option<Option>.None))
            .Add(nameof(Option<Option>.Known));

    // Disallow anyone else to use this class.
    // This way we can guarantee that it is used only by the Option<T>.
    private protected Option() { }

    [NotNull]
    public abstract SoftString Name { get; }

    public abstract IImmutableSet<SoftString> Values { get; }

    public abstract bool IsFlag { get; }
}

[PublicAPI]
[DebuggerDisplay(DebuggerDisplayString.DefaultNoQuotes)]
public abstract class Option<T> : Option, IEquatable<Option<T>>, IFormattable where T : Option
{
    // Values are what matters for equality.
    private static readonly IEqualityComparer<Option<T>> Comparer = EqualityComparerFactory<Option<T>>.Create
    (
        equals: (left, right) => left.Values.SetEquals(right.Values),
        getHashCode: (obj) => obj.Values.GetHashCode()
    );

    // ReSharper disable once StaticMemberInGenericType - this is correct
    private static readonly ConstructorInfo Constructor;

    static Option()
    {
        Constructor =
            typeof(T).GetConstructor(new[] { typeof(SoftString), typeof(IImmutableSet<SoftString>) })
            ?? throw DynamicException.Create
            (
                "ConstructorNotFound",
                $"{typeof(T).ToPrettyString()} must provide a constructor with the following signature: " +
                $"ctor({typeof(SoftString).ToPrettyString()}, {typeof(int).ToPrettyString()})"
            );

        // Always initialize "None".
        var none = New(nameof(None), ImmutableHashSet<SoftString>.Empty.Add(nameof(None)));
        Known = ImmutableHashSet<T>.Empty.Add(none);
    }

    protected Option(SoftString name, IImmutableSet<SoftString> values)
    {
        Name = name;
        Values = values;
    }

    [NotNull]
    public static T None => Known.Single(o => o.Name == nameof(None));

    /// <summary>
    /// Gets all known options ever created for this type.
    /// </summary>
    [NotNull]
    public static IImmutableSet<T> Known { get; private set; }

    /// <summary>
    /// Gets options that have only a single value.
    /// </summary>
    [NotNull, ItemNotNull]
    public static IEnumerable<T> Bits => Known.Where(o => o.IsFlag);

    #region Option

    public override SoftString Name { [DebuggerStepThrough] get; }

    public override IImmutableSet<SoftString> Values { get; }

    /// <summary>
    /// Gets value indicating whether this option has only a single value.
    /// </summary>
    public override bool IsFlag => Values.Count == 1;

    #endregion

    #region Factories

    public static T Create(SoftString name, params SoftString[] values)
    {
        return Create(name, values.ToImmutableHashSet());
    }

    [NotNull]
    public static T Create(SoftString name, IImmutableSet<SoftString> values)
    {
        if (name.In(ReservedNames))
        {
            throw DynamicException.Create("ReservedOption", $"The option '{name}' is reserved and must not be created by the user.");
        }

        if (name.In(Known.Select(o => o.Name)))
        {
            throw DynamicException.Create("DuplicateOption", $"The option '{name}' is already defined.");
        }

        var newOption = New(name, values);

        if (name == Unknown)
        {
            return newOption;
        }

        Known = Known.Add(newOption);
        return newOption;
    }

    private static T New(SoftString name, IImmutableSet<SoftString> values)
    {
        return (T)Constructor.Invoke(new object[]
        {
            name,
            values.Any()
                ? values
                : ImmutableHashSet<SoftString>.Empty.Add(name)
        });
    }

    [NotNull]
    public static T CreateWithCallerName([CanBeNull] string value = default, [CallerMemberName] string name = default)
    {
        return Create(name, value ?? name);
    }

    [NotNull]
    public static T FromName([NotNull] string name)
    {
        if (name == null) throw new ArgumentNullException(nameof(name));

        return
            Known.FirstOrDefault(o => o.Name == name)
            ?? throw DynamicException.Create("OptionOutOfRange", $"There is no such option as '{name}'.");
    }

    private static bool TryGetKnownOption(IEnumerable<SoftString> values, out T option)
    {
        if (Known.SingleOrDefault(o => o.Values.SetEquals(values)) is var knownOption && !(knownOption is null))
        {
            option = knownOption;
            return true;
        }
        else
        {
            option = default;
            return false;
        }
    }

    #endregion

    public T Set(Option<T> option) => this | option;

    public T Reset(Option<T> option) => this ^ option;

    [DebuggerStepThrough]
    public string ToString(string format, IFormatProvider formatProvider)
    {
        if (format.In(new[] { "asc", null }, SoftString.Comparer))
        {
            return Values.OrderBy(x => x).Select(x => $"{x.ToString()}").Join(", ");
        }

        if (format.In(new[] { "desc" }, SoftString.Comparer))
        {
            return Values.OrderByDescending(x => x).Select(x => $"{x.ToString()}").Join(", ");
        }

        return ToString();
    }

    public override string ToString() => $"{this:asc}";

    public bool Contains(T option) => Values.Overlaps(option.Values);

    #region IEquatable

    public bool Equals(Option<T> other) => Comparer.Equals(this, other);

    public override bool Equals(object obj) => Equals(obj as Option<T>);

    public override int GetHashCode() => Comparer.GetHashCode(this);

    #endregion

    #region Operators

    public static implicit operator string(Option<T> option) => option?.ToString() ?? throw new ArgumentNullException(nameof(option));

    public static bool operator ==(Option<T> left, Option<T> right) => Comparer.Equals(left, right);

    public static bool operator !=(Option<T> left, Option<T> right) => !(left == right);

    [NotNull]
    public static T operator |(Option<T> left, Option<T> right)
    {
        var values = left.Values.Concat(right.Values).ToImmutableHashSet();
        return GetKnownOrCreate(values);
    }

    [NotNull]
    public static T operator ^(Option<T> left, Option<T> right)
    {
        var values = left.Values.Except(right.Values).ToImmutableHashSet();
        return GetKnownOrCreate(values);
    }

    private static T GetKnownOrCreate(IImmutableSet<SoftString> values)
    {
        return
            TryGetKnownOption(values, out var knownOption)
                ? knownOption
                : Create(Unknown, values);
    }

    #endregion
}

v2

I have made a couple of changes so here's the summary and the improved code:

  • Using CallerMemberName for automatic option names, however, it's still possible to create custom options with anyname.
  • Using generic Option<T> to remove the Dictionary and provide a few default properties such as None, All or Max, Bits.
  • Cleaned-up naming; now parsing APIs are called FromName and FromValue
  • Added internal set of options so that I can check whether an option is already defined and use it for other properties like All, Max and Bits.
  • Added multi-bit support.
  • Not using BitVector32 yet... maybe later.
  • Added IFormattable interface and three formats: names, flags and names+flags.
  • Encapsulated operators | and ^ respectively as Set and Reset.
  • Added Flags property that enumerates all bits of an option.
[PublicAPI]
public abstract class Option
{
    public static readonly IImmutableList<SoftString> ReservedNames =
        ImmutableList<SoftString>
            .Empty
            .Add(nameof(Option<Option>.None))
            .Add(nameof(Option<Option>.All))
            .Add(nameof(Option<Option>.Max));

    // Disallow anyone else to use this class.
    // This way we can guarantee that it is used only by the Option<T>.
    private protected Option() { }

    [NotNull]
    public abstract SoftString Name { get; }

    public abstract int Flag { get; }

    /// <summary>
    /// Returns True if Option is power of two.
    /// </summary>
    public abstract bool IsBit { get; }
}

[PublicAPI]
[DebuggerDisplay(DebuggerDisplayString.DefaultNoQuotes)]
public abstract class Option<T> : Option, IEquatable<Option<T>>, IComparable<Option<T>>, IComparable, IFormattable where T : Option
{
    protected const string Unknown = nameof(Unknown);

    private static readonly OptionComparer Comparer = new OptionComparer();

    private static IImmutableSet<T> Options;

    static Option()
    {
        // Always initialize "None".
        Options = ImmutableSortedSet<T>.Empty.Add(Create(nameof(None), 0));
    }

    protected Option(SoftString name, int flag)
    {
        if (GetType() != typeof(T)) throw DynamicException.Create("OptionTypeMismatch", "Option must be a type of itself.");

        Name = name;
        Flag = flag;
    }

    #region Default options

    [NotNull]
    public static T None => Options.First();

    [NotNull]
    public static T Max => Options.Last();

    [NotNull]
    public static IEnumerable<T> All => Options;

    #endregion

    [NotNull, ItemNotNull]
    public static IEnumerable<T> Bits => Options.Where(o => o.IsBit);

    #region Option

    public override SoftString Name { [DebuggerStepThrough] get; }

    [AutoEqualityProperty]
    public override int Flag { [DebuggerStepThrough] get; }

    public override bool IsBit => (Flag & (Flag - 1)) == 0;

    #endregion

    [NotNull, ItemNotNull]
    public IEnumerable<T> Flags => Bits.Where(f => (Flag & f.Flag) > 0);

    #region Factories

    [NotNull]
    public static T Create(SoftString name, T option = default)
    {
        if (name.In(Options.Select(o => o.Name).Concat(ReservedNames)))
        {
            throw DynamicException.Create("DuplicateOption", $"The option '{name}' is defined more the once.");
        }

        var bitCount = Options.Count(o => o.IsBit);
        var newOption = Create(name, bitCount == 1 ? 1 : (bitCount - 1) << 1);
        Options = Options.Add(newOption);

        return newOption;
    }

    [NotNull]
    public static T CreateWithCallerName(T option = default, [CallerMemberName] string name = default)
    {
        return Create(name, option);
    }

    private static T Create(SoftString name, IEnumerable<int> flags)
    {
        var flag = flags.Aggregate(0, (current, next) => current | next);
        return (T)Activator.CreateInstance(typeof(T), name, flag);
    }

    public static T Create(SoftString name, params int[] flags)
    {
        return Create(name, flags.AsEnumerable());
    }

    [NotNull]
    public static T FromName([NotNull] string value)
    {
        if (value == null) throw new ArgumentNullException(nameof(value));

        return
            Options.FirstOrDefault(o => o.Name == value)
            ?? throw DynamicException.Create("OptionOutOfRange", $"There is no such option as '{value}'.");
    }

    [NotNull]
    public static T FromValue(int value)
    {
        if (value > Max.Flag)
        {
            throw new ArgumentOutOfRangeException(paramName: nameof(value), $"Value {value} is greater than the highest option.");
        }

        // Is this a known value?
        if (TryGetKnownOption(value, out var knownOption))
        {
            return knownOption;
        }

        var newFlags = Bits.Where(o => (o.Flag & value) == o.Flag).Select(o => o.Flag);
        return Create(Unknown, newFlags);
    }

    private static bool TryGetKnownOption(int flag, out T option)
    {
        if (Options.SingleOrDefault(o => o.Flag == flag) is var knownOption && !(knownOption is null))
        {
            option = knownOption;
            return true;
        }
        else
        {
            option = default;
            return false;
        }
    }

    #endregion

    public T Set(Option<T> option)
    {
        return this | option;
    }

    public T Reset(Option<T> option)
    {
        return this ^ option;
    }

    [DebuggerStepThrough]
    public string ToString(string format, IFormatProvider formatProvider)
    {
        if (SoftString.Comparer.Equals(format, "names"))
        {
            return Flags.Select(o => $"{o.Name.ToString()}").Join(", ");
        }

        if (SoftString.Comparer.Equals(format, "flags"))
        {
            return Flags.Select(o => $"{o.Flag}").Join(", ");
        }

        if (SoftString.Comparer.Equals(format, "names+flags"))
        {
            return Flags.Select(o => $"{o.Name.ToString()}[{o.Flag}]").Join(", ");
        }

        return ToString();
    }

    public override string ToString() => $"{this:names}";

    public bool Contains(T option) => Contains(option.Flag);

    public bool Contains(int flags) => (Flag & flags) == flags;

    public int CompareTo(Option<T> other) => Comparer.Compare(this, other);

    public int CompareTo(object other) => Comparer.Compare(this, other);

    #region IEquatable

    public bool Equals(Option<T> other) => AutoEquality<Option<T>>.Comparer.Equals(this, other);

    public override bool Equals(object obj) => Equals(obj as Option<T>);

    public override int GetHashCode() => AutoEquality<Option<T>>.Comparer.GetHashCode(this);

    #endregion

    #region Operators

    public static implicit operator string(Option<T> option) => option?.ToString() ?? throw new ArgumentNullException(nameof(option));

    public static implicit operator int(Option<T> option) => option?.Flag ?? throw new ArgumentNullException(nameof(option));

    public static bool operator ==(Option<T> left, Option<T> right) => Comparer.Compare(left, right) == 0;

    public static bool operator !=(Option<T> left, Option<T> right) => !(left == right);

    public static bool operator <(Option<T> left, Option<T> right) => Comparer.Compare(left, right) < 0;

    public static bool operator <=(Option<T> left, Option<T> right) => Comparer.Compare(left, right) <= 0;

    public static bool operator >(Option<T> left, Option<T> right) => Comparer.Compare(left, right) > 0;

    public static bool operator >=(Option<T> left, Option<T> right) => Comparer.Compare(left, right) >= 0;

    [NotNull]
    public static T operator |(Option<T> left, Option<T> right) => GetKnownOrCreate(left.Flag | right.Flag);

    [NotNull]
    public static T operator ^(Option<T> left, Option<T> right) => GetKnownOrCreate(left.Flag ^ right.Flag);

    private static T GetKnownOrCreate(int flag)
    {
        return
            TryGetKnownOption(flag, out var knownOption)
                ? knownOption
                : Create(Unknown, flag);
    }

    #endregion

    private class OptionComparer : IComparer<Option<T>>, IComparer
    {
        public int Compare(Option<T> left, Option<T> right)
        {
            if (ReferenceEquals(left, right)) return 0;
            if (ReferenceEquals(left, null)) return 1;
            if (ReferenceEquals(right, null)) return -1;
            return left.Flag - right.Flag;
        }

        public int Compare(object left, object right)
        {
            return Compare(left as Option<T>, right as Option<T>);
        }
    }
}

A new option-set can now be defined by deriving it from Option<T> and adding static properties for the desired flags:

public class FeatureOption : Option<FeatureOption>
{
    public FeatureOption(SoftString name, int value) : base(name, value) { }

    /// <summary>
    /// When set a feature is enabled.
    /// </summary>
    public static readonly FeatureOption Enable = CreateWithCallerName();

    /// <summary>
    /// When set a warning is logged when a feature is toggled.
    /// </summary>
    public static readonly FeatureOption Warn = CreateWithCallerName();

    /// <summary>
    /// When set feature usage statistics are logged.
    /// </summary>
    public static readonly FeatureOption Telemetry = CreateWithCallerName();

    public static readonly FeatureOption Default = CreateWithCallerName(Enable | Warn);
}

Since there is only one option-class now, tests have also become simpler.

public class OptionTest
{
    [Fact]
    public void Examples()
    {
        Assert.Equal(new[] { 0, 1, 2, 4 }, new[]
        {
            FeatureOption.None,
            FeatureOption.Enable,
            FeatureOption.Warn,
            FeatureOption.Telemetry
        }.Select(o => o.Flag));

        Assert.Equal(FeatureOption.Enable, FeatureOption.Enable);
        Assert.NotEqual(FeatureOption.Enable, FeatureOption.Telemetry);

        var fromName = FeatureOption.FromName("Warn");
        Assert.Equal(FeatureOption.Warn, fromName);

        var fromValue = FeatureOption.FromValue(3);
        var enableWarn = FeatureOption.Enable | FeatureOption.Warn;
        Assert.Equal(enableWarn, fromValue);

        var names = $"{enableWarn:names}";
        var flags = $"{enableWarn:flags}";
        var namesAndFlags = $"{enableWarn:names+flags}";
        var @default = $"{enableWarn}";

        Assert.True(FeatureOption.None < FeatureOption.Enable);
        Assert.True(FeatureOption.Enable < FeatureOption.Telemetry);

        Assert.Throws<ArgumentOutOfRangeException>(() => FeatureOption.FromValue(1000));
        //Assert.ThrowsAny<DynamicException>(() => FeatureOption.Create("All", 111111));
    }
}

The intended usage is:

  • Logger layer where the user can define their custom log-levels
  • FeatureService where the user can define their custom behaviours
  • Other services that work with some default options and let the user customize it with their domain-specific flags.
\$\endgroup\$
  • \$\begingroup\$ Does a private protected constructor work? I have never seen this before. \$\endgroup\$ – dfhwze Jun 16 at 16:03
  • 1
    \$\begingroup\$ @dfhwze this is new in 7.2 and the first time here where it was actually useful, see C# 7 Series, Part 5: Private Protected This way I can make sure only Option<T> can access it and no other type and guarantee that nobody will break the constraint class Option<T> : Option where T : Option \$\endgroup\$ – t3chb0t Jun 16 at 16:07

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.