啥是模式匹配?模式匹配是一种高端的使用机制,它允许程序员在开发的时候以对象的类型作为条件筛选和分情况处理的一种手段。
虽说模式匹配(Pattern Matching)这个说法有些高端,不过我们依旧不必害怕,术语词的高端导致我们无法理解它。
C# 7 开始支持模式匹配。如果需要使用如下的功能,请自行将项目调整到可以使用 C# 7 的框架版本上去。
假设,我们拥有一个 Shape
的抽象数据类型,这个数据类型有 Circle
、Rectangle
、Square
、Triangle
等类类型将其抽象方法实现或继承下来。如果我们拿到的一个数据类型只是 Shape
类型的,那么我们如何去判断它具体是什么形状呢?一般的代码是这么写的:
Shape s = ...;
if (s is Circle)
{
var c = (Circle)s;
...; // Code using `c`.
}
else if (s is Rectangle)
{
var r = (Rectangle)s;
...; // Code using `r`.
}
else...
可以看出,这种书写格式没啥问题,但会让程序特别臃肿。后来,C# 允许用户直接把转换后的对象写在 is
类型判定的类型后,于是少了一个语句:
Shape s = ...;
if (s is Circle c)
{
...; // Code using `c`.
}
else if (s is Rectangle r)
{
...; // Code using `r`.
}
else...
这明显就少了很多个定义语句。不过,这个写法依旧臃肿,因为不同分支会添加大括号让代码变得很长。所以我们可以考虑用 switch
语句来代替。switch
语句最初也不允许直接类型判别,因为 switch
最开始是按数值作分类讨论的。自从 C# 7 诞生以来,switch
也支持了类型判定。于是写法变成了这样:
Shape s = ...;
switch (s)
{
case Circle c:
...;
break;
case Rectangle r:
...;
break;
case ...
}
看起来好像差不多,不过更加清晰明了。
如上,这些都是模式匹配的基本操作,用到的是 is
来判别数据类型。接下来我们就来看看 is
的各种模式匹配的骚操作吧。
在 C# 7 里引入了一个概念,叫做弃元(Discard)。弃元表示一个我们完全用不上的数值,并用关键字 _
表示和占位。
目前 _
可以用在 out
参数、switch
的模式匹配标签、switch
表达式、is
模式匹配的转换参数名和返回值上。这里侧重要讲到的是这里的模式匹配标签(switch
语句)。
当我们在判断标签上用不上这个对象的时候,你可以使用弃元来占位:
switch (obj)
{
case int _:
return 1;
case double _:
return 2;
...;
}
另外,is
的模式匹配上,你可以使用弃元,也可以不写这个变量:
if (o is int _)
{
...;
}
和
if (o is int)
{
...;
}
完全等价。
这个弃元是出现在模式匹配之后的,所以最初就允许不写变量。之后出了弃元,才为了配合语义完整性,添加了弃元依旧成立的语法格式。
但是,这一点目前在switch
语句里还不行:switch
标签上暂时还不允许省略弃元。
null
模式最初,我们一直用的是 object.ReferenceEquals
方法来判断是否一个对象为 null
:
if (ReferenceEquals(a, null))
{
...;
}
当然,因为我们说,静态方法不可被重写,但依旧可以继承下来,所以我们在任何地方都可以使用这个方法,且能省略 object.ReferenceEquals
的 object.
这部分。不过,这样写太长了,于是我们知道,object
里有一个自带的运算符 ==
可以直接用,它直接调用的是这个方法。所以我们完全可以直接用 ==
。
if (a == null)
{
...;
}
那么问题来了。C# 允许用户自定义运算符的重载,可是 a
这个类型如果重新实现了 ==
的操作的话,忘了判定 null
或者预期没有考虑到 null
传入的情况,那直接使用 ==
就会直接定位到这个重载后的运算符 ==
上,而不是 object
的 ==
。所以我们就得给 a
或者 null
前增加强制转换:
if ((object)a == null)
{
...;
}
// Or
if (a == (object)null)
{
...;
}
这样依旧是可行的。不过,这么写太长了。所以 is
在 C# 7 允许用户直接书写来判断 null
情况。
if (a is null)
{
...;
}
语句就会少很多。
不要以为null
是不能转换的。你错了,null
虽然在一般使用场景下都会触发NullReferenceException
,但强制转换并不会报错。null
是可以允许被转换为任何引用类型或可空的值类型的。
不过,null
模式不支持再添加变量或弃元在后面。因为判断结果是 null
数值,根本没有必要单独再建一个变量来表示 null
(你直接用原本的变量不就行了)。
C# 2 引入了 var
类型。这是一个强类型,只是我们不想或不记得原本类型名称(可能很长也可能很短)的写法,于是我们索性在类型名位置放上 var
让 C# 编译器自己进行类型推断。随后,它也被拿来作为模式匹配了。
我们使用 is
习惯后,就经常会这么书写代码:
if (a is Rectangle r)
{
int width = r.Width;
if (width > 20)
{
...;
}
}
由于 width
变量必须被定义在大括号里,所以我们没办法放在 if
条件判断的那个小括号里面。后来,为了方便,C# 允许了用户把变量放在 if
里,不过怎么写呢?
假定这个 Width
属性是 int
类型的,那么我们可以把上面的写法(俩 if
)合并成一个 if
:
if (a is Rectangle r && r.Width is var width && width > 20)
{
...;
}
虽然 if
长了,但是更清晰明了了。如果 a
是 Rectangle
类型的,且这个类型的 Width
属性大于 20。这里的 var
会被自动认为是一个 int
,而这个写法(r.Width is var width
)永远是为 true
的,因为这是一句看起来像是废话的语句。
虽然是废话,但是我们能写到 if
里,这样就更优雅了。
当然,我们刚说了,var
替代的类型是 int
,所以我们也可以写 int
:
if (a is Rectangle r && r.Width is int width && width > 20)
{
...;
}
我喜欢称之为废话模式,因为没有类型转换,单纯是考虑可以把变量放到 if
条件里的一种格式。不过为了简化代码,废话模式也不是随时随地都是废话。
如果我们把上面的条件改一下,把 > 20
改为 == 20
。这个写法就还能继续简写:
if (a is Rectangle { Width: 20 } r)
{
...;
}
这个写法等价于
if (a is Rectangle r && r.Width is int width && width == 20)
{
...;
}
仔细观察写法 Rectangle { Width: 20 } r
。在类型的后面加上一个大括号,把需要判断的数据信息写在大括号里。注意这里暂时只能接受常量数值。另外,如果判断结果成功的话,r
就会被赋值,后续就可以使用了。
当然,我们可以写多个条件:
if (a is Triangle { A: 2, B: 3, C: 4 } t)
{
...;
}
如果我们这里的大括号里写的是数据类型而不是一个直接的常量,这样是允许的吗?
答案是,C# 编译器考虑到了你所说的这个情况。
if (a is Triangle { A: int i } t)
{
...;
}
这里 a
除了判断它是不是 Triangle
类型外,还要看这个类型的 A
属性是不是 int
的。如果是的话,那么 a
会成功转换为 t
对象,且 i
也可以在大括号里用。
另外,如果判断这个对象不是 null
,我们还有一个骚操作写法:
if (a is { } o)
{
...; // `o` is not null.
}
// Reverse case
if (!(a is { } o))
{
...; // `o` is null.
}
我们不声明其数据类型的时候,会被认为这个对象就是原本的数据类型。比如这里,假设 a
是 Shape
的类型,那么属性模式的大括号里啥都不写,就会直接判断成功。当且仅当对象不是 null
的时候,这样的情况就一定会成立;反之,如果对象是 null
,那么 is
本身的判断就不成立,更别说后面的大括号了。所以这样写就可以直接表达 o
是 null
和不是 null
。
大括号里是可以嵌套大括号判断的。如果我们判断的一个类型是 A
类型的数据,它有一个 B
类型的属性,且这个 B
类型里有一个 int
类型的整数字段,我们甚至可以嵌套大括号:
if (obj is A { Item: B { Value: 6 } } a)
{
...; // Code using `a`.
}
之所以叫递归模式,因为这个大括号可以无限往下。这个写法等价于
if (obj is A a && a.Item is B b && b.Value is 6)
{
...; // Code using `a`.
}
所以,你可以记住一点,这个大括号里写的 prop: value
的格式里,冒号就好比是外部的 is
关键字。不信你可以试试把这里的所有冒号替换为 is
再来读一下这个语句,看看是不是完全看不出问题。
有些时候我们会写元组类型,如果我们同时判断多个数据各是什么类型的话,可以考虑使用元组模式来简化书写:
(object, object) o = ...;
if (o is (int, double) pair)
{
int z = (int)pair.Item1;
}
这里 (object, object)
和 (int, double)
都是一个二元组,不要被表象迷惑了,以为它是其他的东西。
当然,你依旧可以对每一个数据进行 null
的判别。
(object, object) o = (1, 2);
if (o is (null, null) pair)
{
...;
}
这种叫对位模式,上面的二元组类型有两个元素构成,所以下面作判定的时候,也是对位判断是否是 null
。如果两个都是 null
,那么这个二元组结果就可以用 pair
变量表示,并且在 if
里可以直接用 pair
了。
还有一些时候,我们在不知道对象是什么类型的时候就想看它具体的数值,也为了避免为 null
的时候直接判断数据会报错。所以,is
后还可以加入常量判断来看对象是不是这个类型,且是不是这个数值。
object o = 3;
if (o is 14)
{
...;
}
这样等价于
object o = 3;
if (o is int i && i == 14)
{
...;
}
这样有一个好处。假如对象是 null
数值,if
条件就不成立,但不会抛异常。
这个写法经常用来判断三值布尔类型(Nullable
或 bool?
类型)。假如有一个方法返回了可能是 true
、false
或 null
的三种情况的其一,我们为了避免 null
引用异常,我们可以写 is
看这个对象是否是具体的哪个数值。
if (a is true)
{
...;
}
// Or
if (a is false)
{
...;
}
不过请注意,三值布尔类型的相反情况和普通布尔类型有区别。!true
在普通的条件下,只能是 false
;但在 bool?
下就可以是 false
或 null
了。
在 C# 9 里,模式匹配再度优化。C# 9 为开发人员提供了三个新的关键字 and
、or
和 not
,这三个关键字用处就大了,它们可以用在一些完全你想不到的地方上。
and
:is
判别多条件情况我们可以使用 and
对一个不知道是什么类型的数据做数据大小的判断:
object o = 16;
if (o is int i and > 60) // Here `i` is not used in the condition clause. You can remove it.
{
...;
}
这个写法有一点意思的地方在于,我们把 > 60
这个看似不知道是什么鬼的残缺语句直接放在 and
之后,前面是类型判别。有意思吧。当前,这个写法 C# 9 可以直接继续化简。
object o = 16;
if (o is > 60)
{
...;
}
呃,厉害了。
不要急。主要是这个 o
传入的目前是一个整数。如果它可能是小数呢?
if (o is float and > 50F)
当然能这么写了。
我们前面写了一种写法:
if (a is Rectangle r && r.Width is int width && width > 20)
{
...;
}
后来,有了 and
关键字,我们就可以这么写了:
object o = new Rectangle { Width = 4, Height = 10 };
if (o is Rectangle and { Width: > 20 })
{
...;
}
惊喜不……现在连属性模式也可以用大小判断了!
仔细看: >
还挺萌。
哦对,这里的and
是可以不需要的。答案很简单,最初我们的判定写法就是type { prop: value }
的格式,如果这个and
没有了,就只是看作一个很普通的属性模式。只是没有到 C# 9 时,这个大括号里只能是一个常量,并不能判断大小关系。
or
:由 ||
变过来的写法当如果我们要判断一个数据类型可以是多个的其一时,可以考虑使用 or
关键字。
if (o is byte or short or int or long)
{
...;
}
这样,o
如果是 byte
、short
、int
或 long
的其一时,判断都是成功的。
当然,这个依然可以用数据判断。如果不同的数据类型,我们甚至可以配合 and
一起用:
if (o is > 10 and < 20 or > 10F and < 20F or > 10D and < 20D)
{
...;
}
当 o
是整数且范围在 10 到 20 之间,或是 float
类型的 10 到 20 之间,或是 double
类型的 10 到 20 之间,条件都成立。
not
:判断取反当我们要取反 is
判断的时候,我们不得不为其添加一个小括号,然后写一个取反运算符 !
。这样太丑了,所以 C# 9 带来了一种简化写法:
if (o is not int i)
{
...;
}
...; // Here `i` is available which can be used.
这个写法稍微有点诡异,当然 not
我们能理解语义模型定义的位置,因为一般英语就习惯把 not
放中间。可是这个 i
……
是的,这个地方有点麻烦。你不妨把它按照传统思维去理解它:
if (!(o is int i))
{
...;
}
...;
这样一写你就明白了,i
变量在是 int
的时候会有转换。但不是 int
的时候,这个大括号里是不允许使用 i
的;反之,出来之后,i
变为了可用状态。
not
不仅用于取反一个条件,还能判断数据范围。前面不是有一个类似 is >= 100 and <= 200
的写法吗?我们要想取反这个条件,按道理说是可以直接取反的(大于改小于等于,小于改大于等于,and
改成 or
)。不过,有一个更好看的写法,就是把它们括起来然后加个 not
。
if (o is not (> 100 and < 200))
{
...;
}
这个写法看起来很诡异吧。但是前面的内容就已经知道了,这个 > 100
和 < 200
本身看起来语句残缺,但它是模式匹配里常用的语句形式。
而且这样写有一个好处,数据校验。当数据不在范围的时候,直接抛异常。
if (age is not (>= 0 and <= 150))
{
throw new ArgumentOutOfRangeException(
paramName: nameof(age),
message: "The age cannot be below 0 or greater than 150.");
}
当然你要不喜欢 not
也可以不要这个小括号和 not
:
if (age is < 0 or > 150)
{
throw new ArgumentOutOfRangeException(
paramName: nameof(age),
message: "The age cannot be below 0 or greater than 150.");
}
它和传统的数据校验是一致的,不过原本的写法需要写两次 age
变量:
↓here ↓here
if (age < 0 || age > 150)
{
...;
}