Detailed explanation of c#9.0 new features Series 6: enhanced pattern matching

Time:2021-9-14

Since C #7.0, pattern matching has been evolving as an important new feature of C # continuously. Drawing lessons from the concept of functional programming of his younger brother f #, C # has more and more abilities. C #9.0 has further enhanced the function of pattern matching.

In order to have a more in-depth and comprehensive understanding of pattern matching, before introducing the enhancement of pattern matching in c#9.0, I will review the whole pattern matching.

1. Introduction to pattern matching

1.1 what is pattern matching?

In a specific context, pattern matching is used to check whether a given object and attribute meet the required pattern (i.e. whether they meet certain criteria) and extract information from the input. It is a new way of code flow control, which can make the code flow more readable. The criteria mentioned here include “whether the instance of the specified type”, “whether it is empty”, “whether it is equal to the given value”, “whether the value of the attribute of the instance is within the specified range”, etc.

Pattern matching is often used in combination with is expression in if statement or switch statement. In switch expression, when statement can be used to specify additional filter conditions for patterns. It is very good at detecting complex objects. For example, the objects returned by external APIs have different types in different situations. How to determine the object type?

1.2 type of pattern matching

From version 7.0 of c# to version 9.0 now, there are 13 modes in total:

  • Constant mode (c#7.0)
  • Null mode (c#7.0)
  • Type mode (c#7.0)
  • Attribute mode (c#8.0)
  • Var mode (c#8.0)
  • Discard mode (c#8.0)
  • Tuple mode (c#8.0)
  • Position mode (c#8.0)
  • Relationship mode (c#9.0)
  • Logical mode (c#9.0)
    • Negative mode (c#9.0)
    • Join mode (c#9.0)
    • Disjunctive mode (c#9.0)
  • Bracket mode (c#9.0)

Later, we will write examples based on the following types to illustrate these patterns.

public readonly struct Point
{
    public Point(int x, int y) => (X, Y) = (x, y);
    public int X { get; }
    public int Y { get; }
    public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
}

public abstract record Shape():IName
{
    public string Name =>this.GetType().Name;
}

public record Circle(int Radius) : Shape,ICenter
{
    public Point Center { get; init; }
}

public record Square(int Side) : Shape;

public record Rectangle(int Length, int Height) : Shape;

public record Triangle(int Base, int Height) : Shape
{
    public void Deconstruct(out int @base, out int height) => (@base, height) = (Base, Height);
}

interface IName
{
    string Name { get; }
}

interface ICenter
{
    Point Center { get; init; }
}

2 introduction and examples of each mode

2.1 constant mode

The constant mode is used to check whether the result of the input expression is equal to the specified constant. Just like the constant mode supported by the switch statement before c#6.0, the is statement is also supported since c#7.0.

expr is constant

Here, expr is the input expression, and constant is one of literal constant, enumeration constant or const defined constant variable. If both expr and constant are integer types, in essence, expr = = constant is used to determine whether they are equal; Otherwise, the value of the expression is determined by the static function object. Equals (expr, constant).

var circle = new Circle(4);

if (circle.Radius is 0)
{
    Console.WriteLine("This is a dot not a circle.");
}
else
{
    Console.WriteLine($"This is a circle which radius is {circle.Radius}.");
}

2.2 null mode

Null mode is a special constant mode, which is used to check whether an object is empty.

expr is null

Here, if the input expression expr is a reference type, the expr is null expression uses (object) expr = = null to determine its result; Nullable is used if it is a nullable value type. hasvalue to determine the result

Shape shape = null;

if (shape is null)
{
    Console.WriteLine("shape does not have a value");
}
else
{
    Console.WriteLine($"shape is {shape}");
}

2.3 type mode

Type mode is used to check whether an input expression can be converted to a specified type. If so, the converted value is stored in the variable defined by the specified type. In is expression, the form is as follows:

expr is type variable

Where expr represents the input expression, type is the name of the type or type parameter, and variable is the new local variable defined by type. If expr is not empty and can be converted to type by reference, boxing or unpacking, or if any of the following conditions are met, the return value of the whole expression is true, and the conversion result of expr is assigned to variable.

  • Expr is an instance of the same type as type
  • Expr is an instance of a type derived from type
  • The compile time type of expr is the base class of type, and expr has a runtime type, which is type or a derived class of type. Compile time type refers to the type used to declare variables, also known as static type; A runtime type is the type of a specific instance in a defined variable.
  • Expr is an instance of a type that implements the type interface

If expr is true and the is expression is used in the if statement, the variable local variable is only assigned space in the if statement. The scope of the local variable is from the is expression to the end of the block containing the if statement.

It should be noted that when declaring local variables, type cannot be nullable.

Shape shape = new Square(5);
if (shape is Circle circle)
{
    Console.WriteLine($"This shape is a {circle.Name} with radius equal to {circle.Radius}");
}
else
{
    Console.WriteLine(circle.Radius);// Error, unassigned local variable used
    circle = new Circle(6);
    Console.WriteLine($"A new {circle.Name} with radius equal to {circle.Radius} is created now.");
}

//The circle variable is still in its scope unless it reaches the end of the code block enclosing the if statement.
if (circle is not null && circle.Radius is 0)
{
    Console.WriteLine("This is a dot not a circle.");
}
else
{
    Console.WriteLine($"This is a circle which radius is {circle.Radius}.");
}

The above if statement block containing the type pattern:

if (shape is Circle circle)
{
    Console.WriteLine($"This shape is a {circle.Name} with radius equal to {circle.Radius}");
}

It is equivalent to the following code.

var circle = shape as Circle;

if (circle != null)
{
    Console.WriteLine($"This shape is a {circle.Name} with radius equal to {circle.Radius}");
}

As can be seen from the above, the application type pattern matching makes the program code more compact and concise.

2.4 attribute mode

The property mode enables you to access the properties or fields of the object instance to check whether the input expression meets the specified criteria. The basic form used in combination with is expression is as follows:

expr is type {prop1:value1,prop2:value2,...} variable

This mode first checks whether the runtime type of expr can be converted to type type. If not, the mode expression returns false; If yes, start to check whether the values of attributes or fields match. If one does not match, the whole matching result will be false; If both match, assign the object instance of expr to the local variable variable of type defined.
Among them,

  • Type can be omitted. If omitted, type uses the static type of expr;
  • The value in the attribute can be constant, VAR mode, relational mode or combined mode.

The following example is used to check whether the shape is a rectangle with equal height and width. If so, assign its value to the local variable rect defined with rectangle:

if (shape is Rectangle { Length: var l,Height:var w } rect && l == w)
{
    Console.WriteLine($"This is a square");
}

The attribute mode can be nested. Check whether the center coordinate is at the origin position and the radius is 100 as follows:

if (shape is Circle {Radius:100, Center: {X:0,Y:0} c })
{
    Console.WriteLine("This is a circle which center is at (0,0)");
}

The above example is equivalent to the following code, but the amount of conditional code written by pattern matching is less, especially when more attributes need to be checked for conditions, the amount of code is saved more obviously; Moreover, the above code is still atomic operation. Unlike the following code, the condition should be checked four times:

if (shape is Circle circle &&
    circle.Radius == 100
    && circle.Center.X == 0
    && circle.Center.Y == 0)
{
    Console.WriteLine("This is a circle which center is at (0,0)");
}

2.5 var mode

Changing the type of the expression form of the type pattern to the VaR keyword becomes the expression form of the VaR pattern. Var mode returns true in any case, even if the expr computer result is null. Its biggest function is to capture the value of expr expression, that is, the value of expr expression will be assigned to the local variable name after var. The type of a local variable is the static type of an expression. This variable can be accessed and used outside the matching pattern. Var mode has no null check, so you must manually check local variables before you use them.

if (shape is var sh && sh is not null)
{
    Console.WriteLine($"This shape's name is {sh.Name}.");
}

Combine var mode and attribute mode to capture the value of attribute. An example is shown below.

if (shape is Square { Side: var side } && side > 0 && side < 100)
{
    Console.WriteLine($"This is a square which side is {side} and between 0 and 100.");
}

2.6 abandonment mode

The discard pattern is a pattern that any expression can match. Discarding elements cannot be used as constants or types directly in is expressions. It is generally used in tuples, switch statements or expressions. See 2.7 and 4.3 for examples.

var isShape = shape is _; // error

2.7 tuple mode

Tuple mode represents multiple values as a tuple, which is used to solve the situation that some algorithms have multiple input combinations. As shown in the following example, the specified graph is created according to the command and parameter values in combination with the switch expression:

Shape Create(int cmd, int value1, int value2) => (cmd,value1,value2) switch {
    (0,var v,_)=>new Circle(v),
    (1,var v,_)=>new Square(v),
    (2,var l,var h)=>new Rectangle(l,h),
    (3,var b,var h)=>new Triangle(b,h),
    (_,_,_)=>throw new NotSupportedException()
};

The following is an example of using tuple patterns for is expressions.

(Shape shape1, Shape shape2) shapeTuple = (new Circle(100),new Square(50));
if (shapeTuple is (Circle circle, _))
{
    Console.WriteLine($"This shape is a {circle.Name} with radius equal to {circle.Radius}");
}

2.8 position mode

Positional pattern means that the attributes of type objects are decomposed into discrete variables organized in tuples by adding deconstruction functions, so that you can use these attributes as a pattern for inspection.

For example, we add deconstruct function deconstruct to the point structure. The code is as follows:

public readonly struct Point
{
    public Point(int x, int y) => (X, Y) = (x, y);
    public int X { get; }
    public int Y { get; }
    public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
}

In this way, I can structure point into different variables.

var point = new Point(10,20);
var (x, y) = point;
Console.WriteLine($"x = {x}, y = {y}");

The deconstruction function enables the object to have the function of location mode. When used, it looks like tuple mode. For example, the example I use in the is statement is as follows:

if (point is (10,_))
{
    Console.WriteLine($"This point is (10,{point.Y})");
}

Because of the positional record type, it has the deconstruction function deconstruct by default, so you can directly use the positional mode. If it is a class or struct type, you need to add the deconstruction function deconstruct yourself. We can also use the extension method to add the deconstruction function deconstruct to some types.

2.9 relationship model

The relational schema is used to check whether the input satisfies the relational constraints compared with constants. Form: OP constant
among

  • OP represents an operator, and the relational mode supports binary operators: >=
  • Constant is a constant. As long as it is a built-in type that can support the above binary relational operators, it can include sbyte, byte, short, USHORT, int, uint, long, ulong, char, float, double, decimal, Nint and nuint.
  • OP’s left operand will be used as input, and its type is the same as the constant type, or it can be converted to the constant type by unpacking or explicitly nullable type. If there is no conversion, an error will be reported during compilation; If there is a conversion, but the conversion fails, the pattern does not match; If it is the same or can be converted successfully, its value or converted value and constant start relational operation, and the operation result is the result of relational pattern matching. Thus, the left operand can be dynamic, object, nullable type, VAR type and the same basic type as constant.
  • Constant cannot be null;
  • Although double.nan or float.nan are constants, they are not numbers and are not supported.
  • This pattern can be used in is, which statements and which expressions.
int? num1 = null;
const int low = 0;
if (num1 is >low)
{
}

The combination of relational mode and logical mode will be more powerful and help us deal with more problems.

int? num1 = null;
const int low = 0;
double num2 = double.PositiveInfinity;
if (num1 is >low and

2.10 logic mode

Logical patterns are used to handle logical relationships among multiple patterns, just like logical operators&& Like 𞓜, the priority order is similar. In order to avoid confusion with expression logical operators, pattern operators are represented by words. They are not, and and or. Logical patterns provide more possibilities for the combination of multiple basic patterns.

2.10.1 negative mode

The negative pattern is similar to! Operator to check for mismatches with the specified pattern. Its keyword is not. For example, the negative mode of null mode is to check that the input expression is not null

if (shape is not null)
{
    //Code logic when shape is not null
    Console.WriteLine($"shape is {shape}.");
}

In the above code, we combine the negative mode with the null mode to achieve the function equivalent to the following code, but it is easier to read.

if (!(shape is null))
{
    //Code logic when shape is not null
    Console.WriteLine($"shape is {shape}.");
}

We can use negative mode in combination with type mode, attribute mode and constant mode for more scenarios. For example, the following example combines type mode, attribute mode, negative mode and constant mode to check whether a graph is a circle with a radius of non-zero.

if (shape is Circle { Radius: not 0 })
{
    Console.WriteLine("shape is not a dot but a Circle");
}

The following example executes a piece of logic when judging that a shape is not a circle.

if (shape is not Circle circle)
{
    Console.WriteLine("shape is not a Circle");
}

Note: in the above code, if the if judgment condition is true, the value of the circle is null and cannot be used in the if statement block. However, when it is false, the circle is not null. Even if it is used in the if statement block, it cannot be executed and can only be used after the if statement.

2.10.2 conjunction mode

Similar to the logical operator & &, the conjunctive pattern is to connect two patterns with the and keyword, requiring them to match at the same time.
Previously, when we checked whether an object is a square with a side length between (0100), we would have the following code:

if (shape is Square)
{
    var square = shape as Square;

    if (square.Side > 0 && square.Side < 100)
    {
        Console.WriteLine($"This shape is a square with a side {square.Side}");
    }
}

Now, we can use pattern matching to describe the above logic as:

if (shape is Square { Side: > 0 and < 100 } square)
{
    Console.WriteLine($"This shape is a square with a side {square.Side}");
}

Here, we combine a type pattern, an attribute pattern, a conjunction pattern, two relational patterns, and two constant patterns. For two pieces of code with the same effect, it is obvious that the amount of pattern matching code is less. Without the repetition of square.side, it is more concise and easy to understand.

matters needing attention:

  • And to be used between two type patterns, one of the two types must be an interface, or both must be interfaces
Shape is square and circle // compilation error
shape is Square and IName // Ok
shape is IName and ICenter // OK
  • And cannot be used in an attribute schema without a relational schema,
Shape is circle {radius: 0 and 10} // compilation error
  • And cannot be used between two attribute patterns because it is already implicitly implemented
Shape is triangle {base: 10 and height: 20} // compilation error
Shape is triangle {base: 10, height: 20} // OK, which is the effect of the previous sentence

2.10.3 disjunctive mode

Similar to the logical operator 𞓜, disjunctive pattern is to connect two patterns with or keyword. If one of the two patterns can match, the matching is successful.

For example, the following code is used to check whether a figure is a valid square with side length less than 20 or greater than 60:

if (shape is Square { Side: >0 and < 20 or > 60 } square)
{
    Console.WriteLine($"This shape is a square with a side {square.Side}");
}

Here, we combine six modes: type mode, attribute mode, conjunction mode, disjunction mode, relationship mode and constant mode to complete condition judgment. It looks very concise. If the code before c#9.0 is used to implement this, it is much cumbersome, and square.side appears repeatedly:

if (shape is Square)
{
    var square = shape as Square;

    if (square.Side > 0 && square.Side < 20 || square.Side>60)
    {
        Console.WriteLine($"This shape is a square with a side {square.Side}");
    }
}

matters needing attention:

  • Or can be placed between two types, but it does not support capturing the value of the input expression and saving it in the defined local variable;
shape is Square or Circle // OK
Shape is square or circle SMT // compilation error, capture not supported
  • Or can be placed in an attribute mode without relational mode. At the same time, it supports capturing the value of the input expression and saving it in the defined local variable
shape is Square { Side: 0 or 1 } sq // OK
  • Or cannot be used between two properties of the same object
Shape is rectangle {height: 0 or length: 0} // compilation error
Shape is rectangle {height: 0} or rectangle {length: 0} // OK, achieving the goal in the previous sentence

2.11 bracket mode

With the above modes and their combinations, there is a problem of mode execution priority order. The bracket mode is used to change the mode priority order, which is the same as the use of brackets in our expression.

if (shape is Square { Side: >0 and (< 20 or > 60) } square)
{
    Console.WriteLine($"This shape is a square with a side {square.Side}");
}

3 others

With pattern matching, it is much richer to check whether it is null or not. The following can be used to judge non null Code:

if (shape != null)...
if (!(shape is null))...
if (shape is not null)...
if (shape is {})...
if (shape is {} s)...
if (shape is object)...
if (shape is object s)...
if (shape is Shape s)...

4The switch statement matches the pattern in the expression

When it comes to pattern matching, we have to mention its closely related switch statements, switch expressions and when keywords.

4.1 when keyword

The when keyword is used in the context to further specify the filter conditions. Only when the filter condition is true can the following statements be executed.

The contexts used are:

  • It is often used in catch statements of try catch or try catch finally statement blocks
  • Used in the case tag of a switch statement
  • Used in switch expressions

Here, we focus on the latter two. If you are unclear about the application of catch, you can refer to relevant materials.

The syntax of when in the switch statement is as follows:

case (expr) when (condition):

Here, expr is a constant or type pattern, condition is the filter condition of when, and can be any Boolean expression. See the examples in the following switch statements for specific examples.

In the switch expression, the application of when is similar to that of switch, except that case and colon are replaced by = >. See switch statement expression for specific examples.

4.2 switch statement

Since c#7.0, the switch statement has been modified and more powerful. Changes include:

  • Any type is supported
  • Case can be expressed and is no longer limited to constants
  • Support matching mode
  • The when keyword is supported to further qualify the expression in the case tag
  • Cases are no longer mutually exclusive, so the order of cases is very important. If the execution matches the first branch, the subsequent branches will be skipped.

The following method is used to calculate the area of a specified drawing.

static int ComputeArea(Shape shape)
{
    switch (shape)
    {
        case null:
            throw new ArgumentNullException(nameof(shape));

        case Square { Side: 0 }:
        case Circle { Radius: 0 }:
        case Rectangle rect when rect is { Length: 0 } or { Height: 0 }:
        case Triangle { Base: 0 } or Triangle { Height: 0 }:
            return 0;

        case Square { Side:var side}:
            return side * side;
        case Circle c:
            return (int)(c.Radius * c.Radius * Math.PI);
        case Rectangle { Length:var l,Height:var h}:
            return l * h;
        case Triangle (var b,var h):
            return b * h / 2;

        default:
            throw new ArgumentException("shape is not a recognized shape",nameof(shape));
    }
}

The above method is only used to show a variety of possible uses of pattern matching, in which the part with calculation area of 0 is actually unnecessary.

4.3 switch expression

Switch expressions are expressions added to support functions like switch statements in the context of an expression.

We change the switch statement in 4.1 to an expression, as follows:

static int ComputeArea(Shape shape) => shape switch 
{
    null=> throw new ArgumentNullException(nameof(shape)),
    Square { Side: 0 } => 0,
    Rectangle rect when rect is { Length: 0 } or { Height: 0 } => 0,
    Triangle { Base: 0 } or Triangle { Height: 0 } => 0,
    Square { Side: var side } => side*side,
    Circle c => (int)(c.Radius * c.Radius * Math.PI),
    Rectangle { Length: var l, Height: var h } => l * h,
    Triangle (var b, var h) => b * h / 2,
    _=> throw new ArgumentException("shape is not a recognized shape",nameof(shape))
};

As can be seen from the above example, switch expressions are different from switch statements in the following ways:

  • The input parameter precedes the switch keyword
  • Case and: replaced by = > to make it more concise and intuitive
  • Default discarded meta symbol_ replace
  • The statement body is an expression, not a statement

Each branch of the switch expression = > the best common type of the expressions behind the tag. If it exists and the expression of each branch can be implicitly converted to this type, then this type is the type of the switch expression.

At run time, the result of the switch expression is the value of the expression in the branch of the pattern to which the input parameter first matches. If there is no match, a switchexpressionexception exception will be thrown.

Each branch of the switch expression should fully cover the various values of the input parameters, otherwise an error will be reported. This is also the reason why discards are used to represent unknowable situations in switch expressions.

If some previous branches in the switch expression are always matched and cannot reach the later branches, an error will occur. This is why the discard mode should be placed in the last branch.

5 why use pattern matching?

As can be seen from many previous examples, many functions of pattern matching can be realized by traditional methods, so why use pattern matching?

First, the pattern matching code we mentioned earlier is small, concise and easy to understand, and reduces code duplication.

Moreover, the pattern constant expression is atomic in operation, and there are only two exclusive cases of matching or mismatching. For multiple connected conditional comparison operations, different comparison checks should be carried out many times. In this way, pattern matching avoids some problems in multi-threaded scenarios.

In general, if possible, use pattern matching, which is a best practice.

6summary

Here, we review all pattern matching, introduce the use of pattern matching in switch statements and switch expressions, and finally introduce the reasons why pattern matching is used.

If it is valuable to you, please recommend it. Your encouragement is the driving force for me to continue. Thank you very much. Pay close attention to my official account “code customer” and enjoy the first time to read the latest articles.

码客风云