(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 HashSet
s. 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.