C#9.0新特性详解系列之六:增强的模式匹配

自C#7.0以来,模式匹配就作为C#的一项重要的新特性在不断地演化,这个借鉴于其小弟F#的函数式编程的概念,使得C#的本领越来越多,C#9.0就对模式匹配这一功能做了进一步的增强。

为了更为深入和全面的了解模式匹配,在介绍C#9.0对模式匹配增强部分之前,我对模式匹配整体做一个回顾。

1 模式匹配介绍

1.1 什么是模式匹配?

在特定的上下文中,模式匹配是用于检查所给对象及属性是否满足所需模式(即是否符合一定标准)并从输入中提取信息的行为。它是一种新的代码流程控方式,它能使代码流可读性更强。这里说到的标准有“是不是指定类型的实例”、“是不是为空”、“是否与给定值相等”、“实例的属性的值是否在指定范围内”等。

模式匹配常结合is表达式用在if语句中,也可用在switch语句在switch表达式中,并且可以用when语句来给模式指定附加的过滤条件。它非常善于用来探测复杂对象,例如:外部Api返回的对象在不同情况下返回的类型不一致,如何确定对象类型?

1.2 模式匹配种类

从C#的7.0版本到现在9.0版本,总共有如下十三种模式:

  • 常量模式(C#7.0)
  • Null模式(C#7.0)
  • 类型模式(C#7.0)
  • 属性模式(C#8.0)
  • var模式(C#8.0)
  • 弃元模式 (C#8.0)
  • 元组模式(C#8.0)
  • 位置模式(C#8.0)
  • 关系模式(C#9.0)
  • 逻辑模式(C#9.0)
    • 否定模式(C#9.0)
    • 合取模式(C#9.0)
    • 析取模式(C#9.0)
  • 括号模式(C#9.0)

后面内容,我们就以上这些模式以下面几个类型为基础进行写示例进行说明。

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 各模式介绍与示例

2.1 常量模式

常量模式是用来检查输入表达式的结果是否与指定的常量相等,这就像C#6.0之前switch语句支持的常量模式一样,自C#7.0开始,也支持is语句。

expr is constant

这里expr是输入表达式,constant是字面常量、枚举常量或者const定义常量变量这三者之一。如果expr和constant都是整型类型,那么实质上是用expr == constant来决定两者是否相等;否则,表达式的值通过静态函数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模式

null模式是个特殊的常量模式,它用于检查一个对象是否为空。

expr is null

这里,如果输入表达式expr是引用类型时,expr is null表达式使用(object)expr == null来决定其结果;如果是可空值类型时,使用Nullable .HasValue来决定其结果.

Shape shape = null;

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

2.3 类型模式

类型模式用于检测一个输入表达式能否转换成指定的类型,如果能,把转换好的值存放在指定类型定义的变量里。 在is表达式中形式如下:

expr is type variable

其中expr表示输入表达式,type是类型或类型参数名字,variable是类型type定义的新本地变量。如果expr不为空,通过引用、装箱或者拆箱能转化为type或者满足下面任何一个条件,则整个表达式返回值为true,并且expr的转换结果被赋给变量variable。

  • expr是和type一样类型的实例
  • expr是从type派生的类型的实例
  • expr的编译时类型是type的基类,并且expr有一个运行时类型,这个运行时类型是type或者type的派生类。编译时类型是指声明变量是使用的类型,也叫静态类型;运行时类型是定义的变量中具体实例的类型。
  • expr是实现了type接口的类型的实例

如果expr是true并且is表达式被用在if语句中,那么variable本地变量仅在if语句内被分配空间进行赋值,本地变量的作用域是从is表达式到封闭包含if语句的块的结束位置。

需要注意的是:声明本地变量的时候,type不能是可空值类型。

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);//错误,使用了未赋值的本地变量
    circle = new Circle(6);
    Console.WriteLine($"A new {circle.Name} with radius equal to {circle.Radius} is created now.");
}

//circle变量还处于其作用域内,除非到了封闭if语句的代码块结束的位置。
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}.");
}

上面的包含类型模式的if语句块部分:

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

与下面代码是等效的。

var circle = shape as Circle;

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

从上面可以看出,应用类型模式匹配,使得程序代码更为紧凑简洁。

2.4 属性模式

属性模式使你能访问对象实例的属性或者字段来检查输入表达式是否满足指定标准。与is表达式结合使用的基本形式如下:

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

该模式先检查expr的运行时类型是否能转化成类型type,如果不能,这个模式表达式返回false;如果能,则开始检查其中属性或字段的值匹配,如果有一个不相符,整个匹配结果就为false;如果都匹配,则将expr的对象实例赋给定义的类型为type的本地变量variable。
其中,

  • type可以省略,如果省略,则type使用expr的静态类型;
  • 属性中的value可以为常量、var模式、关系模式或者组合模式。

下面例子用于检查shape是否是为高和宽相等的长方形,如果是,将其值赋给用Rectangle定义的本地变量rect中:

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

属性模式是可以嵌套的,如下检查圆心坐标是否在原点位置,并且半径为100:

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

上面示例与下面代码是等效的,但是采用模式匹配方式写的条件代码量更少,特别是有更多属性需要进行条件检查时,代码量节省更明显;而且上面代码还是原子操作,不像下面代码要对条件进行4次检查:

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模式

将类型模式表达形式的type改为var关键字,就成了var模式的表达形式。var模式不管什么情况下,甚至是expr计算机结果为null,它都是返回true。其最大的作用就是捕获expr表达式的值,就是expr表达式的值会被赋给var后的局部变量名。局部变量的类型就是表达式的静态类型,这个变量可以在匹配的模式外部被访问使用。var模式没有null检查,因此在你使用局部变量之前必须手工对其进行null检查。

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

将var模式和属性模式相结合,捕获属性的值。示例如下所示。

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 弃元模式

弃元模式是任何表达式都可以匹配的模式。弃元不能当作常量或者类型直接用于is表达式,它一般用于元组、switch语句或表达式。例子参见2.7和4.3相关的例子。

var isShape = shape is _; //错误

2.7 元组模式

元组模式将多个值表示为一个元组,用来解决一些算法有多个输入组合这种情况。如下面的例子结合switch表达式,根据命令和参数值来创建指定图形:

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()
};

下面是将元组模式用于is表达式的例子。

(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 位置模式

位置模式是指通过添加解构函数将类型对象的属性解构成以元组方式组织的离散型变量,以便你可以使用这些属性作为一个模式进行检查。

例如我们给Point结构中添加解构函数Deconstruct,代码如下:

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);
}

这样,我就可以将Point结构成不同的变量。

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

解构函数使对象具有了位置模式的功能,使用的时候,看起来像元组模式。例如我用在is语句中例子如下:

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

由于位置型record类型,默认已经带有解构函数Deconstruct,因此可以直接使用位置模式。如果是class和struct类型,则需要自己添加解构函数Deconstruct。我们也可以用扩展方法给一些类型添加解构函数Deconstruct。

2.9 关系模式

关系模式用于检查输入是否满足与常量进行比较的关系约束。形式如: op constant
其中

  • op表示操作符,关系模式支持二元操作符:<,<=,>,>=
  • constant是常量,其类型只要是能支持上述二元关系操作符的内置类型都可以,包括sbyte, byte, short, ushort, int, uint, long, ulong, char, float, double, decimal, nint和 nuint。
  • op的左操作数将做为输入,其类型与常量类型相同,或者能够通过拆箱或者显式可空类型转换为常量类型。如果不存在转换,则编译时会报错;如果存在转换,但是转换失败,则模式不匹配;如果相同或者能转换成功,则其值或转换的值与常量开始进行关系操作运算,该运算结果就是关系模式匹配的结果。由此可见,左操作数可以为dynamic,object,可空值类型,var类型及和constant相同的基本类型等。
  • 常量不能是null;
  • double.NaN或float.NaN虽是常量,但不是数字,是不受支持的。
  • 该模式可用在is,which语句和which表达式中。
int? num1 = null;
const int low = 0;
if (num1 is >low)
{
}

关系模式与逻辑模式进行结合,功能就会更加强大,帮助我们处理更多的问题。

int? num1 = null;
const int low = 0;
double num2 = double.PositiveInfinity;
if (num1 is >low and <int.MaxValue && num2 is <double.PositiveInfinity)
{
}

2.10 逻辑模式

逻辑模式用于处理多个模式间逻辑关系,就像逻辑运算符!、&&和||一样,优先级顺序也是相似的。为了避免与表达式逻辑操作符引起混淆,模式操作符采用单词来表示。他们分别为not、and和or。逻辑模式为多个基本模式进行组合提供了更多可能。

2.10.1 否定模式

否定模式类似于!操作符,用来检查与指定的模式不匹配的情况。它的关键字是not。例如null模式的否定模式就是检查输入表达式不为null.

if (shape is not null)
{
    // 当shape不为null时的代码逻辑
    Console.WriteLine($"shape is {shape}.");
}

上面这段代码我们将否定模式与null模式组合了起来,实现了与下面代码等效的功能,但是易读性更好。

if (!(shape is null))
{
    // 当shape不为null时的代码逻辑
    Console.WriteLine($"shape is {shape}.");
}

我们可以将否定模式与类型模式、属性模式、常量模式等结合使用,用于更多的场景。例如下面例子就将类型模式、属性模式、否定模式和常量模式四种组合起来检查一个图形是否是一个半径不为零的圆。

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

下面示例判断一个shape如果不是Circle时执行一段逻辑。

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

注意:上面这段代码,如果if判断条件为true的话,那么circle的值为null,不能在if语句块中使用,但为false时,circle不为null,即使在if语句块中得到了使用,但也得不到执行,只能在if语句后面使用。

2.10.2 合取模式

类似于逻辑操作符&&,合取模式就是用and关键词连接两个模式,要求他们都同时匹配。
以前,我们检查一个对象是否是边长位于(0,100)之间的正方形时,会有如下代码:

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}");
    }
}

现在,我们可以用模式匹配将上述逻辑描述为:

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

这里,我们将一个类型模式、一个属性模式、一个合取模式、两个关系模式和两个常量模式进行组合。两段同样效果的代码,明显模式匹配代码量更少,没了square.Side的重复出现,更为简洁易懂。

注意事项:

  • and要用于两个类型模式之间,则两个类型必须有一个是接口,或者都是接口
shape is Square and Circle // 编译错误
shape is Square and IName // Ok
shape is IName and ICenter // OK
  • and不能用在一个没有关系模式的属性模式中,
shape is Circle { Radius: 0 and 10 } // 编译错误
  • and不能用在两个属性模式之间,因为这已经隐式实现了
shape is Triangle { Base: 10 and Height: 20 } // 编译错误
shape is Triangle { Base: 10 , Height: 20} // OK,是上一句要实现的效果

2.10.3 析取模式

类似于逻辑操作符||,析取模式就是用or关键词连接两个模式,要求两个模式中有一个能匹配就算匹配成功。

例如下面代码用来检查一个图形是否是边长小于20或者大于60的有效的正方形:

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

这里,我们组合运用了类型模式、属性模式、合取模式、析取模式、关系模式和常量模式这六个模式来完成条件判断。看起来很简洁,这个如果用C#9.0之前的代码实现如下,繁琐很多,并且square.Side有重复出现:

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}");
    }
}

注意事项:

  • or 可以放在两个类型之间,但是不支持捕捉输入表达式的值存到定义的局部变量里;
shape is Square or Circle // OK
shape is Square or Circle smt // 编译错误,不支持捕捉
  • or 可以放在一个没有关系模式的属性模式中,同时支持捕捉输入表达式的值存到定义的局部变量里
shape is Square { Side: 0 or 1 } sq // OK
  • or 不能用于同一对象的两个属性之间
shape is Rectangle { Height: 0 or Length: 0 } // 编译错误
shape is Rectangle { Height: 0 } or Rectangle { Length: 0 } // OK,实现了上一句想实现的目标

2.11 括号模式

有了以上各种模式及其组合后,就牵扯到一个模式执行优先级顺序的问题,括号模式就是用来改变模式优先级顺序的,这与我们表达式中括号的使用是一样的。

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

3 其他

有了模式匹配,对于是否为null的判断检查,就显得丰富多了。下面这些都可以用于判断不为null的代码:

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)...

4 switch语句与表达式中的模式匹配

说到模式匹配,就不得不提与其紧密关联的switch语句、switch表达式和when关键字。

4.1 when关键字

when关键字是在上下文中用来进一步指定过滤条件。只有当过滤条件为真时,后面语句才得以执行。

被用到的上下文环境有:

  • 常用在try-catch或者try-catch-finally语句块的catch语句中
  • 用在switch语句的case标签中
  • 用在switch表达式中

这里,我们重点介绍后面两者情况,有关在catch中的应用,如有不清楚的可以查阅相关资料。

在switch语句的when的使用语法如下:

case (expr) when (condition):

这里,expr是常量或者类型模式,condition是when的过滤条件,可以是任何的布尔表达式。具体示例见后面switch语句中的例子。

在switch表达式中when的应用与switch类似,只不过case和冒号被用=>替代而已。具体示例见switch语句表达式。

4.2 switch语句

自C#7.0之后,switch语句被改造且功能更为强大。变化有:

  • 支持任何类型
  • case可以用表达式,不再局限于常量
  • 支持匹配模式
  • 支持when关键字进一步限定case标签中的表达式
  • case之间不再相互排斥,因而case的顺序很重要,执行匹配了第一个分支,后面分支都会被跳过。

下面方法用于计算指定图形的面积。

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));
    }
}

上面该方法仅用于展示模式匹配多种不同可能的用法,其中计算面积为0的那一部分其实是没有必要的。

4.3 switch表达式

switch表达式是为在一个表达式的上下文中可以支持像switch语句那样的功能而添加的表达式。

我们将4.1中的switch语句改为表达式,如下所示:

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))
};

由上例子可以看出,switch表达式与switch语句有以下不同:

  • 输入参数位于switch关键字前面
  • case和:被用=>替换,显得更加简练和直观
  • default被弃元符号_替代
  • 语句体是表达式不是语句

switch表达式的每个分支=>标记后面的表达式们的最佳公共类型如果存在,并且每个分支的表达式都可以隐式转换为这个类型,那么这个类型就是switch表达式的类型。

在运行情况下,switch表达式的结果是输入参数第一个匹配到的模式的分支中表达式的值。如果没有匹配到的情况,就会抛出SwitchExpressionException异常。

switch表达式的各个分支情况要全面覆盖输入参数的各种值的情况,否则会报错。这也是弃元在switch表达式中用于代表不可知情况的原因。

如果switch表达式中一些前面分支总是得到匹配,不能到达后面的分支话,就会出错。这就是弃元模式要放在最后分支的原因。

5 为什么用模式匹配?

从前面很多例子可以看出,模式匹配的很多功能都可以用传统方法实现,那么为什么还要用模式匹配呢?

首先,就是我们前面提到的模式匹配代码量少,简洁易懂,减少代码重复。

再者,就是模式常量表达式在运算时是原子的,只有匹配或者不匹配两种相斥的情况。而多个连接起来的条件比较运算,要多次进行不同的比较检查。这样,模式匹配就避免了在多线程场景中的一些问题。

总的来说,如果可能的话,请使用模式匹配,这才是最佳实践。

6 总结

这里我们回顾了所有的模式匹配,也介绍了模式匹配在switch语句和switch表达式中的使用情况,最后介绍了为什么使用模式匹配的原因。

如对您有价值,请推荐,您的鼓励是我继续的动力,在此万分感谢。关注本人公众号“码客风云”,享第一时间阅读最新文章。

C#9.0新特性详解系列之六:增强的模式匹配

 

发表评论

相关文章