Pattern Matching in C# and things you should know about it
Every new version of C# language specification brings us something tasty to try, something that makes the developer’s life easier and the code more clean and understandable.
In the last few versions, the Microsoft team brings a breath of fresh air into the ability to build conditional logic. In this article, we will go through the new syntax in-depth and see on the real examples of how to use pattern matching and how it actually works.
With C# 7.0 we get a new conception of patterns. We can describe them as special language elements that help check values for specific characteristics and extract them. Using C# 7.0 you can use already known language constructions (switch-case and is) in a slightly different way. The patterns themselves can have the form of constants and types. Additionally, when type patterns are used, the checked value can be immediately saved into a variable.
Let’s demonstrate all the new stuff with real examples. In the previous version we could write something similar to the next example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
classAnimal
{
}
classCat:Animal
{
publicvoidMeow()
{
}
}
voidDemoCat(Animal obj)
{
// Check that passed obj is a Cat by casting. Use it if not null.
varcat=obj asCat;
if(cat!=null)
{
cat.Meow();
}
}
Here we check passed obj value against type (type pattern). With the new syntax the same can be done much easier:
1
2
3
4
5
6
7
voidDemoCat(Animal obj)
{
if(obj isCat cat)
{
cat.Meow();
}
}
We can even use a fancy var keyword:
1
2
3
4
5
6
7
voidDemoCat(Animal obj)
{
if(obj isvarcat)
{
cat.Meow();
}
}
Examples above are called Type Pattern and Var Pattern respectively. In addition to casting, you can also compare a variable with a value:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
voidDemoString(stringstr)
{
if(str is"Hello World")
{
// ...
}
}
voidDemoInt(intnum)
{
if(num is100)
{
// ...
}
}
But wait, there’s more! We can use this ability even when we don’t know the exact object type:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
voidDemoObject(objectobj)
{
if(obj is"Hello World")
{
// ...
}
if(obj is100)
{
// ...
}
if(obj isDayOfWeek.Friday)
{
// ...
}
if(obj isnull)
{
// ...
}
}
This type of pattern matching is called Const Pattern.
New possibilities naturally combine together:
1
2
3
4
5
6
7
staticvoidDemoParse(objectobj)
{
if(obj isstringstr&& int.TryParse(str, out int num))
{
Console.WriteLine(num);
}
}
Just look at this neat code and imagine what you would have to write without the new syntax.
Ok. Now it should be clear. But the interesting thing is what happens inside. Let’s compile and decompile it to understand how it actually looks without the syntax sugar:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
voidDemoString(stringstr)
{
if(Equals("Hello World",str))
{
// ...
}
}
voidDemoInt(intnum)
{
if(Equals(100,num))
{
// ...
}
}
voidDemoObject(objectobj)
{
if(Equals("Hello World",obj))
{
// ...
}
if(Equals(100,obj))
{
// ...
}
if(Equals(DayOfWeek.Friday,obj))
{
// ...
}
if(Equals(null,obj))
{
// ...
}
}
voidDemoParse(objectobj)
{
strings=obj asstring;
intresult;
if(s!=null&& int.TryParse(s, out result))
{
Console.WriteLine(result);
}
}
Everything is quite simple, but it’s ugly…
In the example above, in the method DemoCat, we used a reference type. But what happens if we do pattern matching against a value type?
1
2
3
4
5
6
7
voidDemoValueType(objectobj)
{
if(obj isintnum)
{
// ...
}
}
A value type cannot be used with the operator ‘as’ and cannot be translated the same way in the background as it was with the reference Cat object. But anyway, the new syntax somehow allows us to do the check, and it looks the same. And what actually happens is the following:
1
2
3
4
5
6
7
8
9
staticvoidDemoValueType(objectobj)
{
int?nullable=obj asint?;
intvalueOrDefault=nullable.GetValueOrDefault();
if(nullable.HasValue)
{
// ...
}
}
The compiler translates the matching expression through the nullable type.
Let’s move further. We can see more interesting examples when pattern matching is used in the switch-case construction. In the old version, only constants were allowed near the case and the switch itself supported only enums, strings and base types like int, bool, char, etc.
The following example demonstrates different kinds of pattern matching in the switch-case construction and its new features:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
voidDemo(objectobj)
{
switch(obj)
{
// Match different kinds of constants:
case10:
Console.WriteLine("int const");
break;
case"Hello World":
Console.WriteLine("string const");
break;
casenull:
Console.WriteLine("Argument is null");
break;
// Match the type. Note, that variable _ is not used, however it is needed even
// when you want to check only the type.
caseCat_:
Console.WriteLine("Argument is Cat");
break;
// Match reference type and value type with conditions:
casestringstr when str.Length>10&& str != "Hello World":
Console.WriteLine(str);
break;
caselongnum when num>0&& num < 1000:
Console.WriteLine(num);
break;
// Combined cases:
casestringstr when int.TryParse(str,out intnum)&& num != 100:
Console.WriteLine("Combined case");
break;
// The default case can be written as:
casevar_:
Console.WriteLine("Not expected value of obj");
break;
}
}
And traditionally, let’s look at the decompiled version:
if((s=obj1 asstring)!=null&& int.TryParse(s, out result) && result != 100)
{
Console.WriteLine("Combined case");
break;
}
Console.WriteLine("Not expected value of obj");
break;
label_13:
longnum=valueOrDefault2;
if(num>0L&& num < 1000L)
{
Console.WriteLine(num);
break;
}
gotolabel_6;
label_11:
stringstr2=str1;
if(str2.Length>10&& str2 != "Hello World")
{
Console.WriteLine(str2);
break;
}
gotolabel_5;
}
else
{
Console.WriteLine("Argument is Cat");
break;
}
}
}
else
Console.WriteLine("int const");
}
else
Console.WriteLine("Argument is null");
}
Honestly, I don’t even want to try to understand what is happening there…
With C# 8.0 pattern matching for switch-case construction goes even further. It brings three more features to make your code even more elegant: switch expression, property patterns and tuple patterns.
Let’s start with switch expression. Usually, we want to separate responsibilities in our code. So we can rewrite the previous example using next two methods: first, it gets the string by condition and then it prints the string.
1
2
3
4
5
6
7
8
9
10
11
12
13
stringGetString(objectobj)=>objswitch
{
10=>"int const",
"Hello World"=>"string const",
null=>"Argument is null",
Cat_=>"Argument is Cat",
stringstr when str.Length>10&& str != "Hello World" => str,
long num when num > 0 && num < 1000 => num.ToString(),
string str when int.TryParse(str, out int num) && num != 100 => "Combined case",
That’s amazing! Here I don’t use any boilerplate and every line contains only useful code. It allows you to write more declarative code instead of imperative and also omit all type checking and null reference exceptions.
So far so good. Now we look at even more powerful comparisons with a property pattern. Let's say we have a set of people, and we want to call them differently based on their first name and last name:
You can see that we can track every property within an object. You can go deeper and even track inner objects without any worry about null references - if the compiler doesn’t find a correct case, it just goes to default one.
The last thing I want to mention is a tuple pattern. Using this feature you can easily construct and deconstruct tuples right in the switch expression.
As you can see, pattern matching and the new syntax related to it help simplify life very much and make the code much cleaner. There are rumors that all this stuff will be even more extended in the next language versions. And now, try new stuff in your projects and may the force be with you!
Author:
Kirill Miroshnichenko
.NET Developer
Enjoyed this article?
you might want to subscribe for our newsletter to get more content like this: