C#7 - Pattern Matching Is One More Step Forward in Making C# More Concise and Readable
06 August 2017

Why do we need it?

Remember boring polymorphism examples where Circle and Square are derived from Shape and each one override Draw method? This works well if you are dealing with classes. What if you just have simple values and no inheritance is possible?

New C#7 extended existing is and switch language constructs to check if item is of the certain "shape". Now you can execute different code according to "shape" of the tested item (similar to polymorphism). In the past is was testing only against a type and case was matching only constant values. Today both can test against pattern containing value types as well as reference types.

is example

If pattern is matched value can be extracted to a variable. This is the part I love. Example below tests each list item with is keyword against int or IEnumerable. If match is confirmed item is extracted to a new variable (value or list). As a result we will add up all numbers from complex list containing not only integers but lists too.

[Fact]
public void IsPatternTest()
{
var valuesToSum = new List<object> { 1, 2, new List<object> { 3, 4 } };

int LocalSumFunction(IEnumerable<object> numbers)
{
var sum = 0;

foreach (var item in numbers)
{
if (item is int value)
sum += value;
else if (item is IEnumerable<object> list)
sum += LocalSumFunction(list);
}

return sum;
}

var totalSum = LocalSumFunction(valuesToSum);

Assert.Equal(1 + 2 + 3 + 4, totalSum);
}

We can write something similar without pattern matching but code above is more concise and more readable.

Prior to the version 7 is expression was testing only against type. Now we can test against constant or even structure. On the right side of is now we can have a pattern (will introduce more complex patterns later). Same goes for case inside of switch.

switch example

For the next example lets switch to switch (pun intended). Same as is, switch now supports pattern matching. Let's improve our code and test against constant 0, null value and also introduce default case. Note that default case will be executed last regardless of where is located in the switch statement. To avoid confusion, put it at the end. All other cases are executed in textual order.

[Fact]
public void SwitchPatternTest()
{
var valuesToSum = new List<object> {1, 2, null, new List<object> {3, 4}};

int LocalSumFunction(IEnumerable<object> numbers)
{
var sum = 0;
foreach(var item in numbers)
{
switch(item)
{
case 0:
break;
case int value:
sum += value;
break;
case IEnumerable<object> list:
sum += LocalSumFunction(list);
break;
case null:
break;
default: // Always evaluated last so put it at the end of switch to avoid confusion
throw new InvalidOperationException("Can't work with that!");
}
}

return sum;
}

var totalSum = LocalSumFunction(valuesToSum);

Assert.Equal(1 + 2 + 3 + 4, totalSum);
}

We are switching on any type and not only primitive types as before.

Breaking changes

  • You can't fall thru cases! Each case must end with break, return or goto.
  • Order is important! case 0 must be before case int or it will never be matched (zero case is subset of int case).

Good news is that compiler will generate error for any of above.

when condition

Pattern can be enriched with conditions. Let's introduce matching to a structure (value type) and also apply when clause. Our funny list now contains structure Person. Person age will be included in sum only if person is 18 or older. Also we will introduce nullList to show how case IEnumerable<object> list will not match this null.

struct Person
{
public string Name;
public int Age;
}

[Fact]
public void WhenPatternTest()
{
IEnumerable<object> nullList = null;

var valuesToSum = new List<object> { 1,
new Person { Name = "Daniel", Age = 22 }, new Person { Name = "Sofia", Age = 17 },
null, nullList,
new List<object> {3, 4}};

int LocalSumFunction(IEnumerable<object> numbers)
{
var sum = 0;
foreach(var item in numbers)
{
switch(item)
{
case 0: // Must be before case int value
case Person p when p.Age < 18: // Must be before "case Person p"
// Don't use p here ! It can be unassigned in case of item == 0
break;
case int value:
sum += value;
break;
case IEnumerable<object> list: // nullList will not match this
sum += LocalSumFunction(list);
break;
case Person p:
sum += p.Age;
break;
case null:
break;
default: // Always evaluated last so put it at the end of switch to avoid confusion
throw new InvalidOperationException("Can't work with that!");
}
}

return sum;
}

var totalSum = LocalSumFunction(valuesToSum);

Assert.Equal(1 + 22 + 3 + 4, totalSum);
}

Again, order is important! case Person p when p.Age < 18 must be before case Person p since it is more restrictive! In example above we merged two cases (zero and person < 18) but we are not using any variable inside that block. We are just doing break. Keep in mind that using p inside first block would be illegal since when case 0 is executed p is unassigned! No worries, compiler will detect that.

case null

Matching pattern guarantees a non-null value. In example above, compiler didn't complain that case null is unreachable. All cases are checked against null and they will not match if variable is null. Therefore we need to handle null separately.

var pattern

var pattern will match everything including null ! Proof that it will match everything is a compiler error if you uncomment case int or case null in example below. Since matching with var is before those two cases they are unreachable.

public class Number
{
public int Value { get; }
public string Text { get; }

public Number(int value, string text)
{
Value = value;
Text = text;
}
}

[Fact]
public void VarPatternTest()
{
Number number1 = new Number(1, "1");
Number number2 = null;

var valuesToSum = new List<object> { number1, number2, 3 };

int LocalSumFunction(IEnumerable<object> numbers)
{
var sum = 0;
foreach(var item in numbers)
{
switch(item)
{
case var intOrNumber:
if(intOrNumber is int intValue)
{
sum += intValue;
}
else if(intOrNumber is Number number)
{
sum += number.Value;
}
break;
// Uncommenting next two cases would generate error:
// "The switch case has already been handled by a previous case."
// var is already handling both int and null case !
// case int value:
// case null:
default:
throw new InvalidOperationException("var didn't match!");
}
}

return sum;
}

var totalSum = LocalSumFunction(valuesToSum);

Assert.Equal(1 + 3, totalSum);
}

A million dollar question is why compiler didn't complain about default case?

Inconsistent scope

Defining variables in if-else has a bit of inconsistency.

[Fact]
public void InconsistentScopeTest()
{
object variable = "1";

if(variable is string text)
{
Assert.Equal("1", text);
}
else if (variable is int number)
{
Assert.Equal(1, number);
}

// Uncommenting below will generate error:
// "Use of unassigned local variable 'text'"
//Assert.Equal("1", text);

// Uncommenting below will generate error:
// "The name 'number' does not exist in the current context"
//Assert.Equal("1", number);
}

Variable text is defined in if statement and has scope same as if statement itself - entire surrounding function. If we try to use it after if-else statement compiler will complain that variable is unassigned. On the other side if we try to use number variable from else statement in the same way error will be different. Variable is out of scope. If we understand that else-if is actually another if inside else block all makes sense.

Conclusion

Pattern matching is one more step forward in making C# more concise and readable. At first having four items in if expression was strange. Now looks so normal.