Skip to content

PatternMatchingUnions

David Arno edited this page Feb 17, 2020 · 9 revisions

Pattern Matching

Succinc<T> pattern matching guide: Unions


Introduction

This pattern matching guide is split into the following sections:

A union can contain precisely one value from a fixed set of different data types. Pattern matching on unions therefore consists of matching values of those various types, via one of two methods: Case1() through Case4() and CaseOf<T>().

Syntax

The generalised syntax for option patterns can be expressed using BNF-like syntax. As with all Succinc<T> pattern matching cases, there are two types of match. Firstly, matching and returning a value:

result = {union}.Match<{result type}>()
    [CaseExpression|CaseOfExpression]...
    [ElseExpression]
    .Result();

CaseExpression ==>
    .CaseN()[OfExpression|WhereExpression].Do({value} => {result type expression}) |
    .CaseN()[OfExpression|WhereExpression].Do({result type value})

CaseOfExpression ==>
    .CaseOf<some type>()[OfExpression|WhereExpression].Do({value} => {result type expression}) |
    .CaseOf<some type>()[OfExpression|WhereExpression].Do({result type value})

OfExpression ==>
    .Of({value})[.Or({value})]...

WhereExpression ==>
    .Where({item} => {boolean expression})

ElseExpression ==>
    .Else({item} => {result type expression}) |
    .Else({result type value})

CaseN ==> Case1|Case2|Case3|Case4

And the alternative is a match that invokes a statement (ie, an Action<{item type}>):

{union}.Match()
        [CaseExpression|CaseOfExpression]...
        [ElseExpression]
        .Exec();

CaseExpression ==>
    .CaseN()[OfExpression|WhereExpression].Do({value} => {action on value})

CaseOfExpression ==>
    .CaseOf<some type>()[OfExpression|WhereExpression].Do({value} => {action on value})

OfExpression ==>
    .Of({value})[.Or({value})]...

WhereExpression ==>
    .Where({item} => {boolean expression})
	
ElseExpression ==>
    .Else({union} => {action on union}) |
    .IgnoreElse()

CaseN ==> Case1|Case2|Case3|Case4

To explain the above syntax:

  • {} denotes a non-literal, eg {void expression} could be the empty expression, {}, or something like Console.WriteLine("hello").
  • Items in [] are optional.
  • | is or, ie [x|y] reads as "an optional x or y".
  • ... after [x] means 0 or more occurrences of x.
  • ==> is a sub-rule, which defines the expression on the left of this symbol.

Important note Case4 is only supported when matching on Union<T1,T2,T3,T4>. Case3 is only supported when matching on Union<T1,T2,T3> and Union<T1,T2,T3,T4>.

CaseN or CaseOf<type>?

Succinc<T> offers two different ways of pattern matching with unions. Both offer different ways of referencing the types in a union and both have their advantages and disadvantages due to the nature of the C# language.

CaseN

The original way of matching, uses Case1 ... Case4 to identify the (up to) four types within the union. So for Union<int, bool, string, DateTime>,

  • Case1 matches the union containing an int value,
  • Case2 matches a bool value,
  • Case3 matches string and
  • Case4 matches a DateTime.

The advantage of this method is that the pattern is type-safe as the whole case expression has to be of one of the types associated with the union. The disadvantage is that a mass of code containing Case2, Case4 etc, doesn't give much clue to the types involved.

CaseOf<type>

The other way of matching with unions is to use CaseOf<some type> in place of Case1 - Case4. So for our example union, Union<int, bool, string, DateTime>, CaseOf<int> will match a union containing an int value etc.

The advantage of this approach is that code containing CaseOf<string> is immediately clearer than Case3. The disadvantage is that - due to limitations in the way C# handles generics - it isn't possible to differentiate method signatures just with generics; nor is it possible to constrain a generic type with an OR construct. Thus a match case of CaseOf<TextReader>() against the example union will compile just fine and throw an exception at runtime.

CaseN Usage

The following sections detail the basic usage, matching individual values and match order for Case1 - Case4. CaseOf<type> usage can be found here.

Basic Usage

The most basic form is matching on which type a union contains:

public static bool ContainsRectangle(Union<Rectangle, Circle> shape)
    => shape.Match<bool>()
            .Case1().Do(x => true)
            .Case2().Do(x => false)
            .Result();

public static void PrintShape(Union<Rectangle, Circle> shape)
    => shape.Match()
            .Case1().Do(Console.WriteLine("Rectangle"))
            .Case2().Do(Console.WriteLine("Circle"))
            .Exec();

In ContainsRectangle, we test against Case1() (rectangle) and Case2() (circle) to return true/false accordingly. In PrintShape, we test against Case1() and Case2() once more, and invoke an action to print the shape type that corresponds to the union's state.

In both cases, we have used both Case1() and Case2(), but we could optionally use Else():

public static bool ContainsRectangle(Union<Rectangle, Circle> shape) 
    => shape.Match<bool>()
            .Case1().Do(x => true)
            .Else(x => false)
            .Result();

public static void PrintShape(Union<Rectangle, Circle> shape) 
    => shape.Match()
            .Case2().Do(Console.WriteLine("Circle"))
            .Else(Console.WriteLine("Rectangle"))
            .Exec();

Else() or IgnoreElse() is invoked if there is no match from any specified Case1() or Case2() expressions respectively.

One further change can be made to the functional example. We are supplying a parameter, x, which isn't then used. In this case, we can dispense with the lambda and just specify the return value:

public static bool ContainsRectangle(Union<Rectangle, Circle> shape) 
    => shape.Match<bool>()
            .Case1().Do(true)
            .Else(false)
            .Result();

Matching Individual Values

The previous examples just matched each case of the union with any value. We might want to match specific values though. We can use this feature as part of a simple calculator:

public static ExpressionNode CreateExpressionNode(Union<string, float> token)
    => token.Match<ExpressionNode>()
            .Case1().Of("+").Do(new ExpressionNode(x, y => x + y))
            .Case1().Of("-").Do(new ExpressionNode(x, y => x - y))
            .Case1().Of("/").Do(new ExpressionNode(x, y => x / y))
            .Case1().Of("*").Do(new ExpressionNode(x, y => x * y))
            .Else(x => new ExpressionNode(x))
            .Result();

CreateExpressionNode will create an instance of some type, ExpressionNode, that takes either a Func<float, float, float> or float parameter. For the former, it constructs a lambda function to perform the appropriate calculation. For the latter, it just stores the number supplied.

It's often the case that more than one value needs to match a particular pattern. We have two choices here: we can use Or() or Where().

Firstly, using Or we could write a more advanced CreateExpressionNode method:

public static ExpressionNode CreateExpressionNode(Union<string, float> token) 
    => token.Match<ExpressionNode>()
            .Case1().Of("+").Or("-".Or("*").Or("/").Do(ArithmaticExpression)
            .Case1().Of("(").Do(new ExpressionNode(SpecialAction.StartGroup))
            .Case1().Of(")").Do(new ExpressionNode(SpecialAction.EndGroup))
            .Else(x => new ExpressionNode(x))
            .Result();

Here we now match +, -, / and * together and invoke a method ArithmaticExpression that returns one of the four previously described lambdas. ExpressionNode now accepts an enum SpecialAction too, which is used to denote the start and end of a grouping (via ()).

If we want to check a range of values, we can use Where:

public static void PositiveOrNegative(Union<string, int> token)
    => data.Match()
           .Case2().Where(i => i < 0).Do(_ => Console.WriteLine("Negative"))
           .Case2().Where(i => i > 0).Do(_ => Console.WriteLine("Positive"))
           .Case2().Do(_ => Console.WriteLine("Zero"))
           .Else("Not a number")
           .Exec();

Match Order

So far, we have only considered distinct match patterns, ie where there is no overlap. In many cases, more than one Case1() or Case2() pattern will be required and the match patterns may overlap. The following function highlights this:

public static string OddOrNegative(Union<string, int> token) 
    => data.Match<string>()
           .Case2().Where(i => i % 2 == 1).Do(_ => Console.WriteLine("Odd"))
           .Case2().Where(i => i < 0).Do(_ => Console.WriteLine("Negative"))
           .Else("Neither")
           .Result();

Clearly in this situation, all negative odd integers will match both Where clauses. The matching mechanism tries each match in the order specified and stops on the first match. So OddOrPositive(new Union<string, int>(-1)) will return Odd, rather than Negative.


CaseOf<type> Usage

The following sections detail the basic usage, matching individual values, match order and handling invalid types for CaseOf<type>. CaseN usage can be found here.

Basic Usage

The most basic form is matching on which type a union contains:

public static bool ContainsRectangle(Union<Rectangle, Circle> shape) 
    => shape.Match<bool>()
            .CaseOf<Rectange>().Do(x => true)
            .CaseOf<Circle>().Do(x => false)
            .Result();

public static void PrintShape(Union<Rectangle, Circle> shape) 
    => shape.Match()
            .CaseOf<Rectangle>().Do(Console.WriteLine("Rectangle"))
            .CaseOf<Circle>().Do(Console.WriteLine("Circle"))
            .Exec();

In ContainsRectangle, we test against CaseOf<Rectangle>() and CaseOf<Circle>() to return true/false accordingly. In PrintShape, we test against CaseOf<Rectangle>() and CaseOf<Circle>() once more, and invoke an action to print the shape type that corresponds to the union's state.

In both cases, we have used both CaseOf<Rectangle>() and CaseOf<Circle>(), but we could optionally use Else():

public static bool ContainsRectangle(Union<Rectangle, Circle> shape) 
    => shape.Match<bool>()
            .CaseOf<Rectangle>().Do(x => true)
            .Else(x => false)
            .Result();

public static void PrintShape(Union<Rectangle, Circle> shape) 
    => shape.Match()
            .CaseOf<Circle>().Do(Console.WriteLine("Circle"))
            .Else(Console.WriteLine("Rectangle"))
            .Exec();

Else() or IgnoreElse() is invoked if there is no match from any specified CaseOf<type>() expressions.

One further change can be made to the functional example. We are supplying a parameter, x, which isn't then used. In this case, we can dispense with the lambda and just specify the return value:

public static bool ContainsRectangle(Union<Rectangle, Circle> shape)
    => shape.Match<bool>()
            .CaseOf<Rectangle>().Do(true)
            .Else(false)
            .Result();

Matching Individual Values

The previous examples just matched each case of the union with any value. We might want to match specific values though. We can use this feature as part of a simple calculator:

public static ExpressionNode CreateExpressionNode(Union<string, float> token) 
    => token.Match<ExpressionNode>()
            .CaseOf<string>().Of("+").Do(new ExpressionNode(x, y => x + y))
            .CaseOf<string>().Of("-").Do(new ExpressionNode(x, y => x - y))
            .CaseOf<string>().Of("/").Do(new ExpressionNode(x, y => x / y))
            .CaseOf<string>().Of("*").Do(new ExpressionNode(x, y => x * y))
            .Else(x => new ExpressionNode(x))
            .Result();

CreateExpressionNode will create an instance of some type, ExpressionNode, that takes either a Func<float, float, float> or float parameter. For the former, it constructs a lambda function to perform the appropriate calculation. For the latter, it just stores the number supplied.

It's often the case that more than one value needs to match a particular pattern. We have two choices here: we can use Or() or Where().

Firstly, using Or we could write a more advanced CreateExpressionNode method:

public static ExpressionNode CreateExpressionNode(Union<string, float> token)
    => token.Match<ExpressionNode>()
            .CaseOf<string>().Of("+").Or("-".Or("*").Or("/").Do(ArithmaticExpression)
            .CaseOf<string>().Of("(").Do(new ExpressionNode(SpecialAction.StartGroup))
            .CaseOf<string>().Of(")").Do(new ExpressionNode(SpecialAction.EndGroup))
            .Else(x => new ExpressionNode(x))
            .Result();

Here we now match +, -, / and * together and invoke a method ArithmaticExpression that returns one of the four previously described lambdas. ExpressionNode now accepts an enum SpecialAction too, which is used to denote the start and end of a grouping (via ()).

If we want to check a range of values, we can use Where:

public static void PositiveOrNegative(Union<string, int> token)
    => data.Match()
           .CaseOf<int>().Where(i => i < 0).Do(_ => Console.WriteLine("Negative"))
           .CaseOf<int>().Where(i => i > 0).Do(_ => Console.WriteLine("Positive"))
           .CaseOf<int>().Do(_ => Console.WriteLine("Zero"))
           .Else("Not a number")
           .Exec();

Match Order

So far, we have only considered distinct match patterns, ie where there is no overlap. In many cases, more than one CaseOf<SomeType>() pattern will be required and the match patterns may overlap. The following function highlights this:

public static string OddOrNegative(Union<string, int> token)
    => data.Match<string>()
           .CaseOf<int>().Where(i => i % 2 == 1).Do(_ => Console.WriteLine("Odd"))
           .CaseOf<int>().Where(i => i < 0).Do(_ => Console.WriteLine("Negative"))
           .Else("Neither")
           .Result();

Clearly in this situation, all negative odd integers will match both Where clauses. The matching mechanism tries each match in the order specified and stops on the first match. So OddOrPositive(new Union<string, int>(-1)) will return Odd, rather than Negative.

Handling unsupported types

CaseOf<type>() patterns can only be tested for type validity at runtime. This means the following code will compile, but will throw an InvalidCaseOfTypeException at runtime, when trying to handle CaseOf<DateTime>():

public static string OddOrNegative(Union<string, int> token)
    => data.Match<string>()
           .CaseOf<int>().Where(i => i % 2 == 1).Do(_ => Console.WriteLine("Odd"))
           .CaseOf<DateTime>().Do("Will produce an exception here")
           .Else("Neither")
           .Result();
Clone this wiki locally