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:
class Animal
{
}
class Cat : Animal
{
public void Meow()
{
}
}
void DemoCat(Animal obj)
{
// Check that passed obj is a Cat by casting. Use it if not null.
var cat = obj as Cat;
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:
void DemoCat(Animal obj)
{
if (obj is Cat cat)
{
cat.Meow();
}
}
We can even use a fancy var keyword:
void DemoCat(Animal obj)
{
if (obj is var cat)
{
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:
void DemoString(string str)
{
if (str is "Hello World")
{
// ...
}
}
void DemoInt(int num)
{
if (num is 100)
{
// ...
}
}
But wait, there’s more! We can use this ability even when we don’t know the exact object type:
void DemoObject(object obj)
{
if (obj is "Hello World")
{
// ...
}
if (obj is 100)
{
// ...
}
if (obj is DayOfWeek.Friday)
{
// ...
}
if (obj is null)
{
// ...
}
}
This type of pattern matching is called Const Pattern.
New possibilities naturally combine together:
static void DemoParse(object obj)
{
if (obj is string str && 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:
void DemoString(string str)
{
if (Equals("Hello World", str))
{
// ...
}
}
void DemoInt(int num)
{
if (Equals(100, num))
{
// ...
}
}
void DemoObject(object obj)
{
if (Equals("Hello World", obj))
{
// ...
}
if (Equals(100, obj))
{
// ...
}
if (Equals(DayOfWeek.Friday, obj))
{
// ...
}
if (Equals(null, obj))
{
// ...
}
}
void DemoParse(object obj)
{
string s = obj as string;
int result;
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?
void DemoValueType(object obj)
{
if (obj is int num)
{
// ...
}
}
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:
static void DemoValueType(object obj)
{
int? nullable = obj as int?;
int valueOrDefault = 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:
void Demo(object obj)
{
switch (obj)
{
// Match different kinds of constants:
case 10:
Console.WriteLine("int const");
break;
case "Hello World":
Console.WriteLine("string const");
break;
case null:
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.
case Cat _:
Console.WriteLine("Argument is Cat");
break;
// Match reference type and value type with conditions:
case string str when str.Length > 10 && str != "Hello World":
Console.WriteLine(str);
break;
case long num when num > 0 && num < 1000:
Console.WriteLine(num);
break;
// Combined cases:
case string str when int.TryParse(str, out int num) && num != 100:
Console.WriteLine("Combined case");
break;
// The default case can be written as:
case var _:
Console.WriteLine("Not expected value of obj");
break;
}
}
And traditionally, let’s look at the decompiled version:
void Demo(object obj)
{
object obj1 = obj;
if (obj1 != null)
{
int? nullable1 = obj1 as int?;
int valueOrDefault1 = nullable1.GetValueOrDefault();
if (!nullable1.HasValue || valueOrDefault1 != 10)
{
switch (obj1 as string)
{
case "Hello World":
Console.WriteLine("string const");
break;
default:
if (!(obj1 is Cat))
{
string str1;
if ((str1 = obj1 as string) != null)
goto label_11;
label_5:
long? nullable2 = obj1 as long?;
long valueOrDefault2 = nullable2.GetValueOrDefault();
if (nullable2.HasValue)
goto label_13;
label_6:
string s;
int result;
if ((s = obj1 as string) != null && int.TryParse(s, out result) && result != 100)
{
Console.WriteLine("Combined case");
break;
}
Console.WriteLine("Not expected value of obj");
break;
label_13:
long num = valueOrDefault2;
if (num > 0L && num < 1000L)
{
Console.WriteLine(num);
break;
}
goto label_6;
label_11:
string str2 = str1;
if (str2.Length > 10 && str2 != "Hello World")
{
Console.WriteLine(str2);
break;
}
goto label_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.
string GetString(object obj) => obj switch
{
10 => "int const",
"Hello World" => "string const",
null => "Argument is null",
Cat _ => "Argument is Cat",
string str 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",
_ => "Not expected value of obj",
};
void PrintString(string str) => Console.WriteLine(str);
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:
string HowToCall(Person person) => person switch
{
{ FirstName: "Jack" } => "I'm Jack",
{ FirstName: "Peter" } => "I'm Superman",
{ FirstName: "Bruce", LastName: "Wayne" } => "I'm Batman",
{ FirstName: "Bruce" } => "I'm just Bruce",
_ => "I'm incognito"
};
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.
string HowToCall(Person person) => (person.FirstName, person.LastName) switch
{
("Jack", _) => "I'm Jack",
("Peter", _) => "I'm superman",
("Bruce", "Wayne") => "I'm batman",
("Bruce", _) => "I'm just Bruce",
_ => "I'm incognito"
};
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!