Skip to content

Instantly share code, notes, and snippets.

@nattomi
Last active September 26, 2021 13:43
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nattomi/7c10c89343ed3670c00a55d19f2d9901 to your computer and use it in GitHub Desktop.
Save nattomi/7c10c89343ed3670c00a55d19f2d9901 to your computer and use it in GitHub Desktop.
FP snippets: Reducing 2 (or more) options into 1

FP snippets: Reducing two (or more) options into one

The main goal of this snippet is to demonstrate a possible scenario of reducing an IEnumerable<Option<T>> into an Option<T> in C#. For better readability I give a basic introduction to Option<T> and briefly cover how does it compare to the built-in Nullable<T>.

What is an Option<T>?

Option<T> is a type defined in the language-ext library for representing a value of type T which might not be present. It's similar to the built-in Nullable<T>, but Nullable<T> doesn't force the developer to check for null references which might lead to unexpected runtime exceptions -- unless nullable context starting from C# 8 is enabled in the .csproj file:

  <PropertyGroup>
	<Nullable>enable</Nullable>
	<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>

Therefore, one simple way to think about Option<T> is that it's a safer replacement for Nullable<T>. In reality there is more to it than that: Option<T> is a monad and consequently also a functor -- but we are not going into the details here. The only thing worthy of note is how can you "extract" the "contained" value given a variable option holding an Option<T>, let's say an Option<int>. You can either match upon the option or use the convienence method IfNone. The following two are equivalent, i.e. a == b should be true.

int a = option.Match(
    Some: x => x,
    None: () => -1
);
int b = option.IfNone(-1);

The example above uses the identity function in the Some argument of Match to extract the contained value as it is, but since Some expects a Func<T, R> (in this case a Func<int, R>), it is also possible to transform the contained value.

Run

dotnet test --filter "FullyQualifiedName~BasicOptionTests"

and have a look into BasicOptionTests.cs for reproducible usage examples.

Example: "merging" two options into one

Given two Option<T>, can we define a sort of "merge" operation with function signature (Option<T>, Option<T>) -> Option<T>? Let's say we want the merged result

  • to be None if and only if both options are None and
  • be equal to one of the Some values otherwise,

then we can come up with the following implementation:

static Option<T> Merge<T>(this Option<T> a, Option<T> b) => a.IsSome ? a : b;

Run

dotnet test --filter "FullyQualifiedName~OptionCombiningTests.CanMergeTwoOptions"

and have a look into OptionCombiningTests.cs for reproducible usage examples. Obviously, the usefulness of defining the "merge" operation this way largely depends on the context and it can easily happen that a different implementation with the same function signature is favourable. Please also note that this implementation is not commutative, as shown by

dotnet test --filter "FullyQualifiedName~OptionCombiningTests.MergeIsNotCommutative"

Bonus example: "merging" more options into one

Now that we can merge two options, we can merge arbitrary many by repeatedly applying the binary merge:

static Option<T> Merge<T>(this IEnumerable<Option<T>> options) =>
	options.Reduce((x, y) => x.Merge(y));

This can be seen in action by running

dotnet test --filter "FullyQualifiedName~OptionCombiningTests.CanMergeMoreOptions"

Discussion

So how is it better than using Nullable<T> as the container, where we could define our merge function just as easily:

public static Nullable<T> Merge<T>(this Nullable<T> a, Nullable<T> b) 
	where T: struct => a.HasValue ? a : b;

As mentioned earlier starting from C# 8 there is not much difference in terms of null-safety, but we'd loose all the nice monadic properties an Option<T> has. We refer to this excellent blog post for a more detailed explanation.

using FluentAssertions;
using Xunit;
using static LanguageExt.Prelude;
public class BasicOptionTests
{
[Theory]
[InlineData(0, 0)]
[InlineData(1, 1)]
[InlineData(2, 4)]
[InlineData(null, -1)]
public void CanExtractContainedValueWithMatch(int? containedValue, int expectedValue)
=> Optional(containedValue).Match(
Some: x => x * x,
None: () => -1
).Should().Be(expectedValue);
[Theory]
[InlineData(0, 0)]
[InlineData(1, 1)]
[InlineData(2, 2)]
[InlineData(null, -1)]
public void CanExtractContainedValueWithIfNone(int? containedValue, int expectedValue)
=> Optional(containedValue).IfNone(-1).Should().Be(expectedValue);
}
using System;
using System.Collections.Generic;
using LanguageExt;
static class Extensions
{
public static Option<T> Merge<T>(this Option<T> a, Option<T> b) => a.IsSome ? a : b;
public static Option<T> Merge<T>(this IEnumerable<Option<T>> options) =>
options.Reduce((x, y) => x.Merge(y));
public static Nullable<T> Merge<T>(this Nullable<T> a, Nullable<T> b)
where T: struct => a.HasValue ? a : b;
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.1.0" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>
using System.Collections.Generic;
using FluentAssertions;
using LanguageExt;
using Xunit;
using static LanguageExt.Prelude;
public class OptionCombiningTests
{
public static IEnumerable<object[]> Data =>
new List<object[]>
{
new object[] { new List<Option<int>>() { None }, None },
new object[] { new List<Option<int>>() { Some(1) }, Some(1) },
new object[] { new List<Option<int>>() { None, None, None }, None },
new object[] { new List<Option<int>>() { None, None, None, Some(1) }, Some(1) },
new object[] { new List<Option<int>>() { None, None, None, Some(1), Some(2) }, Some(1) }
};
[Theory]
[InlineData(1, 2, 1)]
[InlineData(1, null, 1)]
[InlineData(null, 2, 2)]
[InlineData(null, null, null)]
public void CanMergeTwoOptions(int? valueA, int? valueB, int? expectedValue) =>
Optional(valueA).Merge(Optional(valueB))
.Equals(Optional(expectedValue))
.Should().BeTrue();
[Fact]
public void MergeIsNotCommutative() =>
Some(1).Merge(Some(2))
.Equals(Some(2).Merge(Some(1)))
.Should().NotBe(true);
[Theory]
[MemberData(nameof(Data))]
public void CanMergeMoreOptions(IEnumerable<Option<int>> options, Option<int> expected)
{
options.Merge().Equals(expected).Should().BeTrue();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment