Skip to content

Option User Guide

Martin Marciniszyn Mehringer edited this page Apr 5, 2019 · 19 revisions

User Guide for Option<T> and Maybe<T>

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.

Comparing Option<T> to Nullable<T>

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.

Initialization

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).

Helper Functions

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}"; }
}

Basic Methods

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

Null Coalescing Operator

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

Null Conditional Operator and Lifted Conversion Operators

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)

LINQ Type Operations

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)

Match Expressions

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.

Use Cases for Option<T> and Maybe<T>

Typical use cases for Option<T> and Maybe<T> are optional function parameters and return values.

Optional Function Parameters

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

Optional Return Values

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.

Reducing the Memory Footprint of Option<T> with Maybe<T>

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.

Implementation Notes for Option<T>

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();

    // ...
}