The is operator and switch statement remained relatively static until C# 7.0 where declaration and significant pattern matching capabilities were added. Pattern matching provides a syntax through which criteria can be evaluated and the code execution branched accordingly. The C# team continued pattern matching enhancements all the way to C# 11.
In this section we will review all the pattern matching functionality: type patterns constant patterns (C# 7.0), relational patterns (C# 9.0), logical patterns (C# 9.0), tuple patterns (C# 8.0), positional patterns (C# 9.0), extended (C# 10) property patterns (C# 7.0), and list patterns (C# 11.). All of which leverage the recursive pattern capability that allows for multiple checks on a type to be made.
In Chapter 4, we demonstrated constant patterns when using the is operator to check for null (i.e., data is null) and not null and in explaining the basic switch statement ( https://essentialcsharp.com/the-basic-switch-statement ). The principle is that you can compare the input operand against any constant. You could, for example, compare data.Day against with the value 21. Listing 7.22 provides both is and switch expression examples.
Constants can be any literal including strings, defined constants, and of course, null. Note that the comparison of a string to the literal empty string "" will work, but comparing it to string.Empty will fail because string.Empty is not declared as a constant.
With relational patterns you can compare to a constant relatively using the operators <, >, <=, or >=. Listing 7.23 provides both is operator and switch expression versions of relational patterns. (Note that == is not one of the operators since this functionality is already available in the constant pattern matching.)
It is important to note that starting in C# 7.0, match expressions are no longer necessarily mutually exclusive. For example, we could have a match expression of > 0 and another with < 24 and both would be true regardless of the hour. Order will make the final determination of which path the code execution follows. For this reason, use caution when ordering the match expressions in a switch statements and expressions (in the same way you would be careful about ordering a series of if statements).
With logical patterns we can combine relational match expressions together with a single variable and the operators not, and, and or, providing negated, conjunctive, and disjunctive patterns respectively. Therefore, rather than using the logical operators like && or || (see the IsDeveloperWorkHours() method in Listing 7.24 for example) you can specify the input operand once and then have multiple match expressions.
The order of precedence is not, and, or, thus the first two examples don’t need parenthesis. Parenthesis, however, are allowed to change the default order of precedence as demonstrated (arbitrarily) by match expressions in the switch expression of Listing 7.23.
Be aware that when using the or and not operators, you cannot also declare a variable. For instance:
if (input is "data" or string text) { }
will result in an error: “CS8780: A variable may not be declared within a ‘not’ or ‘or’ pattern.” Doing so would result in ambiguity about whether initialization of text occurred.
To override the order of precedence of logical pattern operators you can group them together using parenthesis. Listing 7.25.
Parenthesizing is available with both is operators and switch syntax.
With tuple pattern matching, you can check for constant values within the tuple or assign tuple items to a variable (see Listing 7.26).
As expected, the same is possible with the switch expression or statement syntax (see Listing 7.27)
In both Listing 7.26 and Listing 7.27, we pattern match against a tuple that is populated with the length and the elements of args. In the first match expression, we check for one argument and the action "cat". In the second match expression, we evaluate whether the first item in the array is equal to "encrypt". In addition, Listing 7.26 assigns the third element in the tuple to the variable fileName if the initial match expression evaluates to true. The switch statement in Listing 7.27 doesn’t make the variable assignment since the input operand of the switch statement is the same for all match expressions.
Each element match can be a constant or a variable. Since the tuple is instantiated before the is operator executes, we can’t use the "encrypt" scenario first because args[FileName] would not be a valid index if the "cat" action was requested.
Building on the deconstructor construct introduced in C# 7.0 (see Chapter 6), C# 8.0 enables positional pattern matching with a syntax that closely matches tuple pattern matching (see Listing 7.28).
The System.Drawing.Point type doesn’t have a deconstrutor. However, we are able to add one as an extension method and this suffices the criteria for converting the Point to a tuple of X and Y. The deconstructor is then used to match the order of each of the comma separated match expressions in the pattern. In the example of IsVisibleOnVGAScreen(), X is matched with >=0 and <=1920 and Y with >=0 and <=1080. Similarly with the switch expression in GetQuadrant().
With property patterns, the match expression is based on property names and values of the data type identified in the switch expression, as shown in Listing 7.29.
The first important thing to note about all property match expressions is that they will only match when the input operand is not null. This is why, for example, a match expression of { }, an empty property match expression, matches whenever the input operand is not null. (In Listing 7.29, the { } match expression is redundant as the default match expression at the end of the switch will catch both null and remaining non-null input operands that fall through anyway.) While { } works as a not null check, simply specifying not null (C# 9.0) is preferred as the syntax is clearer.
In listing in Listing 7.29, the first match expressions within the switch expression starts with ItemName.Length: > 0, requiring that ItemName.Length be greater than 0. Implied in the expression is that ItemName is also not null. In fact, since the initial null check is implicit, the compiler disallows using the null conditional in the property expression (ItemName?.Length).
Listing 7.29, the first match expression also requires that the Employee.Role is set to “Admin”. And, if these first two match expressions match, the switch expression returns true – indicating the ExpenseItem is valid. The complete match expression uses an expanded property pattern match syntax introduced in C# 10 and identified by the dot notation that accesses sub-properties (Length and Role).
The second match expression for Listing 7.29, { ItemName: { Length: > 0 }, Employee: {Role: "Manager" }, ExpenseDate: DateTime date }, is also a property match expression but with a slightly different syntax – a property match expression (rather than an expanded property match expression). The property match expression was introduced in C# 7.0 and is still supported, albeit with a warning, because the expanded syntax is simpler and preferred in all cases.
This second match expression includes a property match for ExpenseDate for which it declares a variable, date. While date is still used as part of the switch filter, it appears in a when clause rather than the match expression.
For all forms of constant and relational match expressions, it is necessary to use a constant. This, however, can be restrictive given that frequently comparative operand is not known at compiler time. To avoid the restriction, C# 7.0 includes a when clause into which you can add any conditional expression (an expression that returns a Boolean).
The code in listing Listing 7.29 declares a DateTime date variable to check that the ExpenseItem.ExpenseDate is no older than 30 days. However, because age is dependent on the current date and time, the value isn’t constant and we can’t use a match expression. Instead, the code uses a when clause following the match expression where the date value is compared to DateTime.Now.AddDays(-30). The when clause provides a catch all location for (optionally) more complex conditionals without the restriction of only using constants.
The addition of when clauses is the core reason why C# 7.0 allowed match expressions to no longer be mutually exclusive. With a when clause it is no longer possible to determine at compile time to evaluate the full case logic and check for exclusivity. As a reminder, be careful of the order that each case appears within a switch expression to avoid having earlier cases catch conditions meant for later cases.
An interesting capability of pattern matching is that it becomes possible to extract data from unrelated types and morph the data into a common format. Listing 7.30 provides an example.
The first match expression of the switch expression in Listing 7.31 uses type pattern matching (C# 7.0) to check whether the input is of type DateTime. If the result is true, it passes the result to the property pattern matching to declare and assign the values year, month, and day; it then uses those variables in a tuple expression that returns the tuple (year, month, day). The DateTimeOffset and DateOnly match expression works the same way.
Given a match on the string match expression, if TryParse() is unsuccessful, we return a default((int Year, int Month, int Day)?),2 which evaluates to null. It is not possible to simply return null because there is no implicit conversion from (int Year, int Month, int Day) (the ValueTuple type returned by the other match expressions) and null. Rather, a nullable tuple data type needs to be specified to accurately determine the switch expression’s type. (The alternative to using the default operator would be to cast: ((int Year, int Month, int Day)?) null.) Additionally, nullability is important so that input switch {} is { } date doesn’t return true when parsing is unsuccessful.
In Listing 7.31, we have four data types that, while conceptually similar, there is no inheritance relationship between them other than their derivation from object. As such, the only data type available for the input operand is object. And yet, from each we can extract properties for year, month, and day (even if the names don’t exist or vary between the data types). In this case, we extract a (int Year, int Month, int Day)?, a nullable tuple. And, as long as the tuple is not null, we are able to use the extracted value for year, month, and day into the compositFormatString value.
As mentioned earlier, much of the power of pattern matching doesn’t really emerge until it is leveraged within a switch statement or expression. The one exception, however, might be when pattern matching is used recursively. Listing 7.31 provides an (admittedly contrived) example of the potential complexity when applying patterns recursively.
In this example, couple is of the following type:
(Person, (string FirstName, string LastName))
As such, the first match occurs on the outer tuple, (inigo, buttercup). Next, positional pattern matching is used against inigo, leveraging the Person deconstructor. This selects a (FirstName, LastName) tuple, from which property pattern matching is used to extract the Length of the inigo.FirstName value. The LastName portion of the positional pattern matching is discarded with an underscore. Finally, property pattern matching is used to select buttercup.FirstName.
While pattern matching is a powerful means to select data, is not without a caution: Be mindful of readability. Even with the comments of Listing 7.31, it is likely challenging to understand the code. Without them, it would be even harder:
if (couple is ( ( { Length: int inigoFirstNameLength }, _ ),
{ FirstName: string buttercupFirstName })) { ...}
Even so, where pattern matching really proves useful is in switch statements and expressions.
One important new feature in C# 11 was the introduction of list patterns. These enable pattern matching over a list of items such as an array. As such, you can use any type of pattern matching on the elements within the list. Listing 7.32 demonstrates the feature to parse the args parameter of the Main method.
The first case in Listing 7.32 looks for the text “--help” as the first and only element of the args array. If there is no match then the remainder of the list match expression matches on a first element that starts with ‘/’ or ‘-’ followed by ‘?’ or ‘h’. The latter part of the list pattern looks for exactly two characters (because a string is an array of characters) as identified by the ‘,’ within the expression, separating the prefix from the single character expression:
['/' or '-', 'h' or '?']
The next two cases evaluate the first element of args as well, determining whether the first character is a ‘/’ or a ‘-’ or, in the third case, whether the first element of args starts with “--”. In both cases, if there is a match args[0] is assigned to the command variable. Unfortunately, there is no way to capture the remaining elements of the list into a variable. List pattern matching always looks for an exact number of elements or allows for “..” to mean no further evaluation of the remaining elements is required. For this reason, when invoking ExecuteCommand() we use the range args[1..] to pass all the elements in args except the first one (see Chapter 3 - https://essentialcsharp.com/arrays - for examples on ranges).
The fourth case uses [ ] to identify a list with 0 elements. In this example it is redundant because the default case would also capture the case when args has 0 elements.
Within the ExecuteComand() method of Listing 7.32 we use a tuple to first evaluate the command portion. The second element in the tuple evaluates the remaining arguments. For the “cat” match expression it looks for a list with one item, whereas, for the “copy” match expression it looks for a list of exactly two elements. In both cases it captures the array elements into variables and uses them to invoke the corresponding command method.
There is a lot of power in the list pattern matching capability and the fact that it works with the characters of strings enables a host of simple scenarios for string parsing without resorting to regular expressions.
________________________________________