Match Expressions
The second central idea is the match expression itself. It is represented by two classes: Match<TInput, TOutput> and
Match<TInput>. The difference between them is that the former represents a match expression which yields a result, and
the latter represents a match expression which doesn't yield a result (also known as a match statement).
Using Match Expressions
Creating Match Expressions
A match expression can be created using the Create methods of the static class Match.
Adding Cases
The Match classes include Case methods which are used to add a pattern and a function which is executed if the match
is successful. Match expressions are immutable – Case methods return new match expressions; they do not affect the
ones on which they are called.
Case methods are generic – they also contain information about the pattern's transformation type. Match expressions
can contain patterns of arbitrary transformation types without knowing about these types.
Executing Match Expressions
To execute a match expression, the ExecuteOn method is used. It takes the input value to match. There are two modes of
execution in match expressions: strict and non-strict. The strict mode throws an exception if no matches were found, and
the non-strict doesn't.
In the Match<TInput, TOutput> class, the ExecuteOn method returns the result of the match or throws a
MatchException if no successful match was found. Match<TInput, TOutput> also contains the ExecuteNonStrict method
which executes the match expression in the non-strict mode. It returns MatchResult<TOutput> because the result might
not be present.
In the Match<TInput> class, the ExecuteOn method doesn't return anything, and also throws a MatchException if the
match wasn't successful. This class also contains the ExecuteNonStrict method – it returns a boolean value which
indicates whether the match was successful and doesn't throw an exception if it wasn't.
The ToFunction method and its variations are also available. They return a function which, when called, will execute
the match expression.
Matching with Fall-through
C, C++ and, Java support fall-through in switch statements. So does this library, although it works differently here.
Fall-through must be explicitly enabled for cases and then explicitly enabled during execution. Both Match classes
contain the ExecuteWithFallthrough method which takes fall-through behavior into account. ExecuteOn and
ExecuteStrict ignore the fall-through behavior.
If a case has fall-through enabled, then the expression falls to the next successful match, unlike switch, which
falls to the next case whether it's successful or not.
The Case methods are overloaded to accept a boolean value which indicates the fall-through behavior. If fall-through
is enabled for a pattern, then the expression will continue searching for the next successful pattern. If it isn't, then
the expression will stop at this pattern and not go any further.
Match.Create is also overloaded to take the default fall-through behavior.
Matching with fall-through is lazy, i.e., it returns an IEnumerable and is only executed when this enumerable is
enumerated. Because matching will fall-through is lazy, it doesn't have any modes of execution – the user must decide
whether to throw an exception or not if there were no successful matches.
In the Match<TInput, TOutput> class, the ExecuteWithFallthrough method returns an IEnumerable<TOutput> which can
be used to get all successful match results.
In the Match<TInput> class there are no results, so the ExecuteWithFallthrough method returns an
IEnumerable<object> which should be used simply to enumerate the match process itself. This is implemented so that
matching with fall-through is also lazy in this class. The values of the resulting enumerable don't matter - in fact,
they are always null, because match statements don't produce any results. What matters is the process of enumeration.
You can use the LINQ's Take method to limit the number of executed matches, or the Count method to execute it and
get the number of successful matches.
The Matchmaker.Linq namespace contains the Enumerate extension method for IEnumerable<T> which enumerates it and
ignores the result. You can use it if you just want to execute the match statement with fall-through.
Important
Matching with fall-through is lazy and is actually executed when the result is enumerated.
Here's a (somewhat convoluted) implementation of the famous fizz-buzz program which uses matching with fall-through:
using System.Linq;
using Matchmaker;
using Matchmaker.Linq;
using static Matchmaker.Patterns.Pattern;
// ...
IPattern<int, int> DivisibleBy(int n) =>
CreatePattern<int>(input => input % n == 0);
var result = Enumerable.Range(0, 15)
.Select(Match.Create<int, string>(fallthroughByDefault: true)
.Case(DivisibleBy(3), _ => "Fizz")
.Case(DivisibleBy(5), _ => "Buzz")
.Case(Not(DivisibleBy(3).Or(DivisibleBy(5))), n => n.ToString())
.ToFunctionWithFallthrough())
.Select(items => items.Aggregate(String.Concat))
.ToList();
// The result is:
// "FizzBuzz", "1", "2", "Fizz", "4", "Buzz", "Fizz", "7", "8", "Fizz", "Buzz", "11", "Fizz", "13", "14", "FizzBuzz"
Static Match Expressions
The Initialization Problem
One pain point of match expressions is that whenever a method which contains a match expression is executed, the match expression is initialized from scratch. Take a look at this example:
void DoStuff(int i) =>
Match.Create<int, string>()
.Case(...)
.Case(...)
.Case(...)
.Case(...)
.ExecuteOn(i);
The problem here is that if we call DoStuff 10,000 times, we will initialize the match expression 10,000 times as
well, even though it's actually the same expression. Having just 4 cases may not seem like much, but the lag and
allocations do accumulate if we execute it thousands of times.
We can save the expression in a field and then call the ExecuteOn method on this field. But this makes the code much
less readable because the case definitions are in a different place from the actual execution point.
The Solution
There is a way to create static match expressions – expressions which will be initialized only once.
The Match class contains the CreateStatic methods which allow the creation of static match expressions. Take a look
at the modified example:
void DoStuff(int i) =>
Match.CreateStatic<int, string>(builder => builder
.Case(...)
.Case(...)
.Case(...)
.Case(...))
.ExecuteOn(i);
It looks almost the same, except for one difference: the calls to Case methods are inside the lambda expression,
called the build action, which is passed to the CreateStatic method. Now this match expression will be initialized
only once, and its initialization code is in the same place as its execution point.
The parameter of the build action has the type MatchBuilder<TInput, TOutput or MatchBuilder<TInput>, depending on
which type of match expressions you are building. This type has the same methods for adding cases as the Match classes
and is mutable – the methods return the same builder instance.
MatchBuilder also has the Fallthrough method which specifies the default fall-through behavior. But this method
specifies fall-through behavior only for cases that are defined after it. For example:
builder
.Fallthrough(true)
.Case(...) // this case has fall-through behavior if not specified otherwise
.Case(...) // this case also has fall-through behavior if not specified otherwise
.Fallthrough(false)
.Case(...) // this case doesn't have fall-through behavior if not specified otherwise
.Case(...) // this case also doesn't have fall-through behavior if not specified otherwise
Every case can configure its fall-through behavior individually as well.
Caching Match Expressions
The build action will be called only once, and its result will be cached. The cache is a static hash-table. The caching process is not thread-safe.
The key of the cache is the place where the CreateStatic method is called. Apart from the build action, this method
also accepts two caller info arguments: the path to the source file and the line in the source file. Users don't need to
pass these arguments to the method as they have the CallerFilePath and CallerLineNumber attributes.
The Match class also contains the ClearCache methods which clear a global cache. Match expressions have a cache per
type (so Match<int, int> uses a different cache than Match<int, string>) so ClearCache only clears one cache.
Clearing the cache will force all static match expressions of that type to be reinitialized. This process is not
thread-safe as well.