-
Notifications
You must be signed in to change notification settings - Fork 2
Option User Guide
This guide describes the programming interface of Option<T>
and Maybe<T>
. An executable version of the code presented below can be found in OptionDemo
and MaybeDemo
, respectively.
Option<T>
and Maybe<T>
provide essentially the same programming interface as Nullable<T>
(T?
for short), but allow T
to be either any type or a class
type, respectively.
Option<T>
can be initialized with null
, default
, or any valid value of type T
just like T?
:
double? nullDouble = null;
Option<string> nullString = null;
double? someDouble = 3.14;
Option<string> someString = "foo";
Alternatively, one may use the special values Option.None
/ Maybe.None
and the corresponding constructor functions Option.Some<T>(T)
/ Maybe.Some<T>(T)
.
Before describing the API for Option<T>
and Maybe<T>
, we introduce a couple of helper function so as to keep the code examples short:
static void Print(object value = default) => Console.WriteLine(value);
static void Print<T>(Option<T> opt)
{
var typename = typeof(T).Name;
Print(opt.HasValue ? $"Some<{typename}>({opt})" : $"None<{typename}>");
}
static void Print<T>(T? opt) where T : struct
{
var typename = typeof(T).Name;
Print(opt.HasValue ? $"Some<{typename}>({opt})" : $"None<{typename}>");
}
static object TryCatch<T>(Func<T> func)
{
try { return func(); } catch (Exception ex) { return $"{ex.GetType()}: {ex.Message}"; }
}
Property HasValue
indicates the presence of a valid value in T?
and Option<T>
:
Print(nullDouble.HasValue); // False
Print(nullString.HasValue); // False
Print(someDouble.HasValue); // True
Print(someString.HasValue); // True
Property Value
provides direct access to the value stored in T?
and Option<T>
:
Print(someDouble.Value); // 3.14
Print(someString.Value); // foo
Print(TryCatch(() => nullDouble.Value)); // System.InvalidOperationException: Nullable object must have a value.
Print(TryCatch(() => nullString.Value)); // System.InvalidOperationException: Nullable object must have a value.
Alternatively, one can access the value with an explicit cast:
Print((double)someDouble); // 3.14
Print((string)someString); // foo
Print(TryCatch(() => (double)nullDouble)); // System.InvalidOperationException: Nullable object must have a value.
Print(TryCatch(() => (string)nullString)); // System.InvalidOperationException: Nullable object must have a value.
Access the value or return default(T)
using method GetValueOrDefault()
:
Print(nullDouble.GetValueOrDefault()); // 0
Print(nullString.GetValueOrDefault()); //
Print(someDouble.GetValueOrDefault()); // 3.14
Print(someString.GetValueOrDefault()); // foo
Or provide a custom default value:
Print(nullDouble.GetValueOrDefault(42)); // 42
Print(nullString.GetValueOrDefault("bar")); // bar
Print(someDouble.GetValueOrDefault(42)); // 3.14
Print(someString.GetValueOrDefault("bar")); // foo
The null coalescing operator (??) is not directly supported on Option<T>
. Instead, we call the method GetValueOr(Func<T>)
. That is, we provide a function that can supply a default value similar to this one:
static T Computation<T>(T value)
{
Print("running computation ...");
return value;
}
We can use it in GetValueOr(Func<T>)
like so:
Print(nullDouble ?? Computation(42.0)); // running computation ... 42
Print(nullString.GetValueOr(() => Computation("bar"))); // running computation ... bar
Print(someDouble ?? Computation(42.0)); // 3.14
Print(someString.GetValueOr(() => Computation("bar"))); // foo
We can use the same construct to throw custom exceptions when there is no valid value:
Print(TryCatch(() => nullDouble ?? throw new Exception("missing double"))); // System.Exception: missing double
Print(TryCatch(() => nullString.GetValueOr(() => throw new Exception("missing string")))); // System.Exception: missing string
Print(TryCatch(() => someDouble ?? throw new Exception("missing double"))); // 3.14
Print(TryCatch(() => someString.GetValueOr(() => throw new Exception("missing string")))) // foo
The null conditional operator (?.) is emulated by method Select<TOut>(Func<T, TOut>)
:
Print(nullDouble?.CompareTo(0)); // None<Int32>
Print(nullString.Select(x => x.CompareTo("bar"))); // None<Int32>
Print(someDouble?.CompareTo(0)); // Some<Int32>(1)
Print(someString.Select(x => x.CompareTo("bar"))); // Some<Int32>(1)
Similarly, we can emulate lifted conversion operators:
Print(2 * 6371 * nullDouble); // None<Double>
Print(nullString.Select(x => x + "bar")); // None<String>
Print(2 * 6371 * someDouble); // Some<Double>(40009.88)
Print(someString.Select(x => x + "bar")); // Some<String>(foobar)
Any option type can be regarded as a set containing either zero or one element. Hence, we should be able to apply LINQ type operations to it like Where
, Contains
, Any
, All
, SelectMany
, etc. This is also true for Nullable<T>
, for which we provide corresponding extension methods in Nullables
. These allow us to write code of the following sort:
Print(nullDouble.Where(x => x >= 0).Select(Math.Sqrt)); // None<Double>
Print(nullString.Where(x => x.Length > 0).Select(x => x[0])); // None<Char>
Print(someDouble.Where(x => x >= 0).Select(Math.Sqrt)); // Some<Double>(1.77200451466693)
Print(someString.Where(x => x.Length > 0).Select(x => x[0])); // Some<Char>(f)
Or equivalently in LINQ syntax:
Print(from x in nullDouble where x >= 0 select Math.Sqrt(x)); // None<Double>
Print(from x in nullString where x.Length > 0 select x[0]); // None<Char>
Print(from x in someDouble where x >= 0 select Math.Sqrt(x)); // Some<Double>(1.77200451466693)
Print(from x in someString where x.Length > 0 select x[0]); // Some<Char>(f)
The idiomatic way of handling option types in functional programming languages is through pattern matching. The library supports a similar notation using the Match
method:
Print(nullDouble.Match(some: x => x + x, none: () => Computation(42))); // running computation ... 42
Print(nullString.Match(some: x => x + x, none: () => Computation("bar"))); // running computation ... bar
Print(someDouble.Match(some: x => x + x, none: () => Computation(42))); // 6.28
Print(someString.Match(some: x => x + x, none: () => Computation("bar"))); // foofoo
Similarly, there exists a ForEach
method to consume the value within the option type.
Typical use cases for Option<T>
and Maybe<T>
are optional function parameters and return values.
Optional function parameters can be conveniently expressed with Option<T>
like so:
void FuncWithOpt(Option<string> opt = default)
{
opt.ForEach(
none: () => Print("executing without option"),
some: x => Print($"executing with option {x}"));
}
FuncWithOpt(); // executing without option
FuncWithOpt("foo"); // executing with option foo
The following signature shows a typical design pattern in the C# library (taken from IReadOnlyDictionary<TKey, TValue>
):
/// <summary>Gets the value that is associated with the specified key.</summary>
/// <param name="key">The key to locate.</param>
/// <param name="value">When this method returns, the value associated with the specified key, if the key is found; otherwise, the default value for the type of the value parameter. This parameter is passed uninitialized.</param>
/// <returns>true if the object that implements the <see cref="T:System.Collections.Generic.IReadOnlyDictionary`2"></see> interface contains an element that has the specified key; otherwise, false.</returns>
/// <exception cref="T:System.ArgumentNullException"><paramref name="key">key</paramref> is null.</exception>
bool TryGetValue(TKey key, out TValue value);
The function return value is split into two parts: a bool
indicating the success of the operation and the actual dictionary value passed via the out
variable. Even though the usage of such functions has become less cumbersome with the introduction of the out var
syntax, this signature seems rather inelegant. Moreover, there is no guarantee that the client will actually check the Boolean flag before accessing value
. By using Option<T>
, we can recombine the function output into a single return value as implemented in Dictionaries
:
static Option<TValue> GetOption<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> dict, TKey key) =>
dict.TryGetValue(key, out var value) ? Option.Some(value) : Option.None;
This allows for concise code as in the following example:
var asciiTable = Enumerable.Range(0, 128).ToDictionary(i => (char)i, i => i);
Print(asciiTable.TryGetValue('A', out var x) ? x : default(int?)); // Some<Int32>(65)
Print(asciiTable.GetOption('A')); // Some<Int32>(65)
Print(asciiTable.TryGetValue('é', out var y) ? y : default(int?)); // None<Int32>
Print(asciiTable.GetOption('é')); // None<Int32>
A similar design can be applied to the various parsing functions in the C# library (typically called TryParse
). In these cases, on may also consider returning a Try<T>
to the caller. This is an extension of Option<T>
which will capture an Exception
in the case of failure, thereby providing more information about what went wrong.
Similarly to Nullable<T>
, Option<T>
is essentially implemented as a struct
type that holds a member of type T
and an additional flag of type bool
. Clearly, the Boolean flag consumes additional space. When T
is known to be a class
type, we can remove the bool
from the struct and rely on null
representing the undefined value as in conventional code. However, the API design ensures that null
values are never dereferenced. Here is a very basic implementation of this idea:
readonly struct Maybe<T> where T : class
{
private readonly T value;
private Maybe(T value)
{
this.value = value;
}
public static readonly Maybe<T> None = default(Maybe<T>);
public static implicit operator Maybe<T>(T value) => new Maybe<T>(value);
public static explicit operator T(Maybe<T> maybe) => maybe.Value;
public bool HasValue => !ReferenceEquals(this.value, null);
public T Value => this.value ?? throw new InvalidOperationException("Nullable object must have a value");
public T GetValueOrDefault() => this.value;
public T GetValueOrDefault(T @default) => this.value ?? @default;
public T GetValueOr(Func<T> @default) => this.value ?? @default.Invoke();
// ...
}
We can measure the size of the compiled data types Nullable<T>
, Option<T>
, and Maybe<T>
:
static int SizeOf<T>()
{
var dm = new DynamicMethod("SizeOfType", typeof(int), Array.Empty<Type>());
var il = dm.GetILGenerator();
il.Emit(OpCodes.Sizeof, typeof(T));
il.Emit(OpCodes.Ret);
return (int)dm.Invoke(null, null);
}
Print(SizeOf<int>()); // 4
Print(SizeOf<int?>()); // 8
Print(SizeOf<Option<int>>()); // 8
Print(SizeOf<double>()); // 8
Print(SizeOf<double?>()); // 16
Print(SizeOf<Option<double>>()); // 16
Print(SizeOf<string>()); // 8
Print(SizeOf<Option<string>>()); // 16
Print(SizeOf<Maybe<string>>()); // 8
We observe that for int
and double
, both Nullable<T>
and Option<T>
require twice the space as the underlying type T
. For a reference type like string
, we observe the same increase in space for Option<T>
, but no additional space for Maybe<T>
. This confirms that the implementation of Maybe<T>
results in a zero memory wrapper around pointers. Since the implementation defines Maybe<T>
as a readonly struct
, there is also no pointer indirection or additional heap memory involved. Hence, we may expect practically no performance overhead when wrapping reference types into Maybe<T>
, supposing the JIT compiler in-lines most method calls.
We have given a rudimentary implementation of Maybe<T>
above. Our implementation of Option<T>
is based on Nullable<T>
. In order to use any unconstrainted type T
with Nullable<T>
, T
needs to be wrapped into a struct
type like so:
readonly struct Any<T>
{
public Any(T value)
{
Value = value;
}
public T Value { get; }
public static implicit operator Any<T>(T value) => new Any<T>(value);
public static implicit operator T(Any<T> any) => any.Value;
}
Then Option<T>
can be implemented based on Any<T>?
:
readonly struct Option<T>
{
private readonly Any<T>? value;
private Option(T value)
{
this.value = value;
}
public static readonly Option<T> None = default(Option<T>);
public static implicit operator Option<T>(T value) =>
typeof(T).IsValueType || !ReferenceEquals(value, null) ? new Option<T>(value) : None;
public static explicit operator T(Option<T> option) => option.Value;
public bool HasValue => value.HasValue;
public T Value => value.Value;
public T GetValueOrDefault() => value.GetValueOrDefault();
public T GetValueOrDefault(T @default) => value.GetValueOrDefault(@default);
public T GetValueOr(Func<T> @default) => value ?? @default.Invoke();
// ...
}