模式匹配
Pattern Matching
模式匹配是啥?
模式匹配(Pattern Matching),在编程里指的是,把一个不知道具体数据信息的玩意儿,通过一些固定的语法格式来确定模式数据的具体内容的过程。
C# 的模式匹配非常美丽,因为语法格式非常有趣、简单、且更具有可读性。下面我们来看看 C# 的模式匹配。
各种各样的模式
C# 里有很多种类的、用来控制和判断数据的模式(Pattern),它们语法不同,判断的东西、语义也不一样。下面我们列举它们。
声明模式(Declaration Pattern)
1、语法
声明模式用于简单判断一个模糊的数据类型是否是某个具体的数据类型,并尝试将其转换过去。
object greeting = "Hello, World!";
if (greeting is string message)
Console.WriteLine(message.ToLower());注意语法 greeting is string message 的写法。C# 最开始允许 is 的写法是 obj is T,而 T 之后写的变量指的是“如果 obj 确实是 T 类型的实例的话,那么 message 就可以使用了”。换句话说,这段代码等价于下面这样的代码:
object greeting = "Hello, World!";
if (greeting is string)
{
string message = (string)greeting;
Console.WriteLine(message.ToLower());
}即在大括号里等效进行类型转换。
2、声明模式仍可能会进行拆箱
假设我们原始的对象是装箱的操作:
object o = 3;那么,即使你使用这个语法来获取结果:
if (o is int v)
Console.WriteLine(v);它也避免不了拆箱行为:因为它等于 o is int 后直接进行 int v = (int)o 的拆箱赋值操作,所以它会隐式地进行拆箱,它是避免不了的。
var 模式(var Pattern)
var 模式(var Pattern)1、语法
有一些时候,我们可以内联模式匹配和变量声明。
static bool IsAcceptable(int length, int absLimit)
=> SimulateDataFetch(length) is var results
&& results.Min() >= -absLimit
&& results.Max() <= absLimit;
static int[] SimulateDataFetch(int length)
{
var rand = new Random();
int[] result = new int[length];
for (int i = 0; i < length; i++)
result[i] = rand.Next(-100, 100);
return result;
}我们来看下这个例子。SimulateDataFetch 方法获取指定长度的数组,数组的每个元素都是 -100 到 100 之间的随机数。IsAcceptable 方法则是验证序列是不是在某个数字的范围内。可以从例子里看出,我们直接将方法调用的结果直接内联到 return 语句里,我们写的是 is var results。这个语句和下面这段代码相当:
int[] results = SimulateDataFetch(length);
return results.Min() >= -absLimit && results.Max() <= absLimit;2、声明模式和 var 模式的区别
var 模式的区别请稍微注意一下。var 模式和声明模式的书写格式完全一样,唯一的区别是,一个写的是类型的具体名称,一个则是写的固定的关键字 var。声明模式下,写的数据具体类型会作为数据的判断类型进行判断;而 var 仅等价于变量声明,它并不具有任何的数据类型的判断。
虽然我们可以看到,var 模式的例子里,由于 SimulateDataFetch 方法返回的 int[] 类型是固定的,因此我们完全可以写成 SimulateDataFetch(length) is int[] results。而且它们没有运行时和编译期间的差异,写 var 和 int[] 都行。不过我们更建议在模式匹配里使用 var 进行一劳永逸地使用变量定义规则,统一变量定义的语法。如果随时都是显式类型定义的话,每次修改返回值都有可能导致编译失败。
常量模式(Constant Pattern)
1、语法
常量模式针对于一个可能为 null 的数据类型的实例,判断是否等于某个具体的常量。
object o = 3;
if (o is 30)
Console.WriteLine("The condition is true.");
else
Console.WriteLine("The condition is false.");在这段代码下,我们直接在 is 后紧跟一个数值,这表示将 object 进行类型和数值的双重判断。等价的代码是这样的:
object o = 3;
if (o is int i && i == 30)
Console.WriteLine("The condition is true.");那么,为什么非得是 int 呢?因为这里的 3 这个字面量,默认是 int 类型的,因此这里在等价代码里用的是 int 作为类型的判断。
2、常量不是字面量
尤其要强调的一点是,常量和字面量不是等价的概念,也不是包含的概念。
说这个是因为,常量模式的常量可以是表达式。例如 42,你写成 42 或 41 + 1 都是合理的模式:
if (expr is 42)
// ...
if (expr is 41 + 1)
// ...这两种写法都是可行的,编译器也不会因为你使用了加法运算而报错。当然,这里是举例子,你不要去纠结 41 + 1 到底有什么意义。
另外,常量计算并非是简单的数值计算,字符串字面量的加法也属于常量的其中一种情况:
if ("helloworld" is "hello" + "world")
// ...因为字符串字面量属于一种特殊的常量,因此字符串字面量之间的加法会在编译期间计算得到拼接结果,因此这个模式是可行的。只不过这个例子没有啥意义。
另外,常量的强制类型转换之后的结果也是常量。
if (gender is (Gender)1)
// ...
enum Gender { Male = 0, Female = 1 }这里就说一下,哪些属于常量:
基本类型的字面量(如
42、'\n');字符串字面量(如
"Hello");枚举字段(如
Gender.Female);前三者的操作符运算结果(
42 + 18)。
哪些是字面量:
基本类型的字面量;
字符串字面量;
枚举字段;
C# 11 的 UTF-8 字面量(如
"Hello"u8);C# 12 的集合表达式(如
[1, 2, 3, -1])。
C# 11、12 提供的这两种字面量类型不属于常量——它会被系统自动转换为 ReadOnlySpan<byte> 连续内存引用和 List<> 顺序表。从类型上就可以看出,它不是常量。
3、可空值类型的常量模式匹配是不必要的
另外,我们也可以对一个可空值类型使用这样的常量模式判断。
int? i = null, j = 30;
if (i is 3 && j is 3)
Console.WriteLine("The condition is true.");
else
Console.WriteLine("The condition is false.");比如这个格式。这个格式很明显等价于 i is int p && p == 3 && j is int q && q == 3。正是因为写起来很长,所以我们才会使用这样的模式匹配来简化代码。不过,这个模式匹配是不必要的。早在可空值类型出现的时候,它们自身的等号和不等号的逻辑就已经可具有这样的判断了。换句话说,你写成这样,和模式匹配的格式将是一样的等价代码。
int? i = null, j = 30;
if (i == 3 && j == 3)
Console.WriteLine("The condition is true.");
else
Console.WriteLine("The condition is false.");这里,i 就算是 null,也不会影响判断 i == 3 的结果。大不了结果为 false 就是了。
4、double.NaN 的特殊处理
double.NaN 的特殊处理假设我们用 is double.NaN 对一个对象进行数值匹配:
if (d is double.NaN)
// ...这表示什么?d == double.NaN 吗?好像没啥毛病,但是其实毛病挺大的。double.NaN 是一个相当特殊的常量。在 C# 里,该常量有一个特别骚的处理规则:任何数都不等于 double.NaN。因为这保留的常量数值,只用来表示一种特例——表示计算结果它不是一个数。什么时候用得到这个情况呢?除以 0 的时候。一旦有一个数除以了 0,那么这个结果必然是不可能正常的(从数学的角度来说,除以 0 是无意义的运算);而非 0 的数除以 0,结果等于无穷大;0 除以 0 就等于这个 double.NaN 了。不过,假设 == NaN 是可行的,你想要拿你的计算结果和这个数比较就可以采用这样的写法;但是很遗憾,NaN 不适用相等性判断,所以就没办法使用 == NaN 来判断计算结果是否无意义(永远返回 false)。那么怎么做呢?C# 的 API 有一个 double.IsNaN 方法在专门对这个情况进行判断,它可以绕过 == 的判定规则去判断是否结果真的是 NaN;而此时的 d is double.NaN 就刚好是在调用此方法。也就是说,这个 if 条件会被翻译为这样:
if (d is double && double.IsNaN((double)d))
// ...类型判断后,然后直接调用此方法来判断,而并非是 d == double.NaN。请尤其注意这一点。
对位模式(Positional Pattern)
1、语法
对位模式是将一个数据成员通过解构方法来产生解构,来判断属性数据的过程。假设我们拥有这样一个数据类型:
public readonly struct Point
{
public int X;
public int Y;
}显然这里的 X 和 Y 是 Point 里仅存的两个数据成员。如果我们在某个时刻判断数据信息的具体数值的时候,我们可能会使用如下的写法:
if (point.X == 30 && point.Y == 30)
// ...在 C# 里,我们只要写上一个自定义的解构函数,就可以对对象进行解构操作。我们写一个 Deconstruct 方法,然后带有两个参数:x 和 y,它们都是 out int 类型的。
public void Deconstruct(out int x, out int y)
{
x = X;
y = Y;
}通过这样的赋值后,我们就可以通过这个解构来完成数据的解构了:
var point = new Point { X = 10, Y = 60 };
// Deconstruct.
var (x, y) = point;当然,在模式匹配的时候,我们可以使用这样的代码对上面的写法进行简化:
if (x == 30 && y == 30)
// ...不过,这样还不是很好看。因此 C# 还有这样的对位模式匹配:
if (point is (x: 30, y: 30))
// ...通过一对小括号,我们可以实现对这个数据成员具体数值的检测。至于这里为什么是小写字母 x 和 y,是因为解构函数的参数分别是 x 和 y,这是一一对应的。正是因为如此,这个模式才叫做对位模式。其中,因为它借助了解构函数,而解构后的对象分配使用的是一对小括号(var (x, y) = point; 这个语句),因此为了配合这个写法,也用的是小括号。只是这里需要写出解构参数的名字。
稍微注意一点的是,C# 允许在同一个数据类型下定义多个解构函数,但由于语法设计的规则,解构函数的语法绑定只跟参数的个数有关,因此解构模式的参数名写不写一般都无所谓。
if (point is (30, 30))
// ...除非,你需要为了强调参数名故意写出来。
2、主构造器Ⅰ:记录类型主构造器的对位模式
C# 9 和 C# 10 分别诞生了记录类型和记录结构类型,它们必须绑定一个主构造器位于声明的头部:
record Person(string Name, int Age, bool IsBoy);在使用模式匹配的时候,由于编译器会自动生成对应的解构函数,因此我们可以直接对主构造器使用对位模式匹配。
if (person is Person(Name: "Sunnie", Age: 25, _)) ;其中,小括号 (Name: "Sunnie", Age: 25, _) 左侧的数据类型类似于 obj is T t 的 T,而小括号书写出来的对位模式,则是 C# 允许的语法。它们甚至可以写在一起。当然,如果类型一样就不必判断类型了。
3、主构造器Ⅱ:普通类型的主构造器没有对位模式支持
C# 12 提供了一种新机制,允许我们将构造器声明到类型声明的位置上,和记录类型的主构造器一致的语法。这也叫主构造器。但它确实是有很多地方和记录类型的主构造器不一样。
主要的一点就在于,普通类型的主构造器等效于实际的真构造器,因此声明普通类型的主构造器的时候,应和构造器参数的参数名命名规则保持一致;但记录类型的主构造器,背后会生成一个实际的属性,所以为了配合属性名的命名,在记录类型里,主构造器的参数都是帕斯卡命名法,因为属性名的命名广泛采用的是帕斯卡命名法。
而正是因为这个原因,主构造器声明在普通类型里的时候,它没有了记录类型的大量编译器生成成员的依托,所以普通类型即使你声明了主构造器,也无法使用对位模式匹配,如下面的代码就是错误的用法:
var person = new Person("Sunnie", 26);
if (person is ("PersonName", 18)) // Wrong use.
// ...
sealed class Person(string name, int age)
{
public string Name { get; } = name;
public int Age { get; } = age;
}对于普通类型,你不得不手写出解构函数,才可使用此模式匹配。
var person = new Person("Sunnie", 26);
if (person is ("PersonName", 18)) // OK.
// ...
sealed class Person(string name, int age)
{
public string Name { get; } = name;
public int Age { get; } = age;
public void Deconstruct(out string name, out int age)
=> (name, age) = (Name, Age);
}4、调用扩展方法的对位模式
同时,编译器也能够嗅探扩展方法。换句话说,对位模式也允许扩展方法的解构函数。因此,C# 也允许调用扩展方法的解构函数来对位判断元素信息。
static class Extensions
{
public static void Deconstruct<T>(this T[] array, out T firstElement, out T secondElement)
{
firstElement = array[0];
secondElement = array[1];
}
}于是我们可以使用这个方法对一个 T[] 进行解构:
int[] arr = { 1, 3, 10 };
if (arr is (firstElement: 1, secondElement: var secondElement))
{
Console.WriteLine(secondElement);
}解构模式(Deconstruct Pattern)
1、语法
因为前文我们拥有了解构函数,也拥有了 var 模式,因此 C# 灵活的语法提供了 var 模式的解构版本:
if (point is var (x, y) && x == 30 && y == 30)
// ...稍微注意一下这里的语法是写成 var (x, y)。当然,你也可以内联 var 关键字。和值元组的语法一致,你依然可以用 (var x, var y) 的语法。
if (point is (var x, var y) && x == 30 && y == 30)
// ...这样是可以的。严谨一点的话,var (x, y) 是解构模式,而 (var x, var y) 是对位模式。因为前者使用 var (x, y) 语法,小括号里直接定义了变量名,小括号的外侧则是 var 关键字;但 (var x, var y) 在小括号里定义了两个变量,都使用了 var 关键字,这意味着是对应位置上的数据分别定义变量,类似 point.X is var x && point.Y is var y 的效果,因此只能说是对位模式。
使用解构模式可以更清楚、更简明地将对象进行解构,直接赋值到变量上;但它存在一定的弊端,例如解构模式下就不能往里判断数值了。也就是说,你在写成 var (a, b) 的类似语法后,就无法往 a、b 上使用任何模式匹配的判别语法了。该嵌套模式匹配之语法将在稍后说明。
2、可空值类型解构模式的别样意义
在 C# 里,可空值类型一直是一种方便也不方便的数据类型。它的声明和使用都比较方便,但问题就出在它可能是 null 数值。假设前文的 Point 我们用的是可空类型的话:
Point? nullable = new Point { X = 30, Y = 30 };此时,我们在后续的代码里,无从根据代码直接确定 nullable 是否为 null(除非看取了 nullable 的值才行)。因此,一旦我们对这个类型进行解构:
if (nullable is var (x, y))
// ...这就不单纯和 var 模式一样。它牵扯到数据是不是 null 才可解构的问题。如果数据都是 null 了,我们就无法解构。因此,可空值类型的解构模式会先判断对象是不是不为 null,然后才是解构。
if (nullable != null && nullable.Value is (x: var x, y: var y))
// ...
nullable != null和nullable.HasValue是等效的,所以写nullable.HasValue也没问题。
3、主构造器的解构模式
是的,主构造器会自动生成对应的解构函数,因此完全可以直接使用解构模式。还是使用之前的 Person 类型:
record Person(string Name, int Age, bool IsBoy);那么,有这样的语法:
if (person is var (name, _, isBoy)) ;这样是允许的。但你不能写 is Person (name, _, isBoy),因为前面的 var 关键字是这个模式匹配的固定格式,改成了 Person 的话,后面就只能看成对位模式了。
和前面一样,普通类型的主构造器不会直接生成解构函数,因此对于普通类型而言,主构造器不会因此允许你使用解构模式。
4、调用扩展方法的解构模式
解构模式和对位模式类似,编译器也支持嗅探解构模式对应的扩展方法。一般正常的实现我们可能对一些数据类型无法实现解构操作,因此我们需要扩展方法来达到一些行为。比如假设我要去获取数组的前两个元素,我们经常会使用 [0] 和 [1] 来获取,不过现在我们可以使用解构模式来完成:
static class Extensions
{
public static void Deconstruct<T>(this T[] array, out T firstElement)
{
firstElement = array[0];
}
}假设我随便这么写了这个扩展方法,它们用于解构 T[] 数组。于是我们可以对一个一维数组进行解构操作:
int[] arr = { 1, 3, 10 };
if (arr is var (firstElement))
{
Console.WriteLine(firstElement);
}请注意解构函数正常使用的时候是尽量不出现 0 或 1 个元素的解构模式,不过在这个时候也可能会遇到,因此语法没有对此进行限制。
5、解构和对位模式不要求判断元素数量至少两个
这里稍微说一个比较不容易了解到的知识点。编译器限制我们定义一个至少两个元素的值元组 ValueTuple 类型,也就是说,一个或零个的值元组类型是不被允许的:
var emptyTuple = (); // Wrong.
ValueTuple<int> singleElementTuple = (1); // Also wrong.注意,第 2 行的代码我们假设写的 var 而不是 ValueTuple<int> 的话,编译器会自动消去 (1) 两侧的小括号,然后直接认为它是 1;故意显式给出类型名是为了告诉你,这两个情况都是值元组不被允许的。
不过,虽然解构模式和对位模式长得都跟值元组的类型声明模式很像,但对位模式和解构模式允许和支持解构函数可以包含任意多的 out 参数用于解构,这也意味着在解构模式和对位模式里,is () 或 is (1) 是存在的语法。
那么,is () 是什么意思呢?它表示我不判断解构模式对象不解构任何元素出来。那么,唯一可以允许的判断就是可空性了。所以,is () 等于是 is not null 的判断。但是,如果这是一个值类型的话,那么 is () 就没有必要判断了——它永远成立,因为值类型本身就不可空;值类型唯一可以可空的情况是可空值类型 T?。但是这玩意儿是不支持解构模式的,更别说无元素的解构了。
6、单元素的解构模式要手动消除二义性
在 C# 里,小括号如果不需要是会被编译器分析出来的。比如说 var a = (1 + 3),此时的小括号没有必要需要它。在模式匹配里,单元素的解构模式也是一种特殊的处理:它会被视为常量模式,于是,考虑下面的例子,判断就有些奇怪了:
var o = new C();
if (o is (42))
// ...
class C
{
public void Deconstruct(out int variable) => variable = 42;
}请看这样的代码。你认为它是对的吗?答案是不对。编译器会首先认为 (42) 是常量模式,而 o 变量是 C 类型而不是一个整数,因此这个模式会导致编译器直接告知“永远都不会匹配成功”的编译器错误。
那么,怎么让它调用该解构函数来完成判别呢?答案其实很简单:消除编译器认为是常量模式的二义性即可。比如给 (42) 模式添加参数名。
if (o is (variable: 42))
// ...这样编译器就不会简单认为是一个常量了。
7、object 类型的解构模式
object 类型的解构模式对 object 类型而言,它作为特殊类型,是可以直接使用解构模式的。
object o = ...;
if (o is ())
// ...可,这会被视为什么判断规则呢?不知道你知不知道一个类型叫 ITuple?这个数据类型限制了类型具有元组的性质。所以,对任何数据类型来说的解构模式,实际上是被编译器特殊处理和优化过,并认为是在匹配该类型的数据规则。
比如 o is () 会被视为 o is ITuple tuple && tuple.Length == 0。注意此时 ITuple 里的 Length 属性表示的是元组的元素数。当然了,如果你这个类型具有解构函数,就不会走这个路线去判断。但是,如果一个类型既没有实现这个 ITuple 接口,又没有匹配的解构函数,就会产生编译器错误。
弃元模式(Discard Pattern)
1、语法
一旦解构后,我们就有办法只判断其中的一个数据。假设前文的解构函数存在的话,那么我们必然会解构成两个数据(x 和 y)。但是,如果我们仅判断 x 的数据,而不关心 y 是多少的话,我们可以使用一个下划线 _ 来表示“y 我们不用判断”,或者说“y 的模式匹配总是成立的”。
if (nullable is (x: 30, y: _))
// ...或者
if (nullable is var (x, _) && x == 30)
// ...这么写都是可以的。
2、没有 is _ 一说
is _ 一说虽然说它也是一种模式,但你不能直接对表达式使用 is _ 的模式匹配。
if (expr is _) // Wrong.
// ...因为弃元符号的作用是无条件成立,但这么做等价于直接返回 true。因此这么做没有任何意义,编译器也因此禁用你这么使用它。
3、弃元模式的叫法和定义范畴
呃,前面只要带有 _ 记号(弃元符号)的地方,这些模式就都可以叫弃元模式。不论你这个 var (_, _) 也好,还是 { Length: _ } 也好,还是 T _ 也好,甚至包括稍后介绍的一些新语法也好,它们都是在一个大的模式匹配表达式里的其中一个模式匹配单元,它们都称为弃元模式,只要是用到了弃元符号 _ 的地方。
类型模式(Type Pattern)
1、语法
与其单独讲类型模式,还不如让你先明白,声明模式的那个类型,就是类型模式。
if (list is int[] arr)
// ...所以,你暂时可以理解成这样:“声明模式 = 类型模式(就是这个类型)+ 变量定义”。但是,单独提出来说,是有原因的。
2、不支持可空类型的类型模式
C# 8 开始拥有可空引用类型,而在 C# 2 开始则拥有可空值类型。这两种数据类型都带有 ? 标记类型是可空的。不过,这些类型并不能使用到类型模式之中。
if (obj is int? _) ;
if (obj is string? _) ;这两种都是错误的。原因很简单:因为 is 是匹配类型,因此它必须得是这个类型。而既然都是这个类型的数据了,那么为何还去匹配一个可空类型呢?假设 obj 是 int 类型的数据,那么 int is int? 合理吗?显然不合理。int is int 才合理。那 null is int? 呢?null 是没数值的可空类型的默认数值,它不包含任何数值,因此 is 匹配它是没有意义的。
正是因为这些原因,你无法在 is 后使用和声明可空类型作为类型模式——因为是没有意义的。
3、声明模式弃元
在 C# 里,switch 语句可以专门对一个不知道是什么类型的东西作模式匹配:
switch (obj)
{
case int[] arr: // ...
case IEnumerable<int> enumerable: // ...
case List<int> list: // ...
default: // ...
}这里,C# 也是允许的。可问题在于,arr、enumerable 等变量如果不用,我们无法去掉:C# 9 之前,这个变量是不可省去的:即使不用,你也得写弃元符号:_。
switch (obj)
{
case int[] _: // ...
case IEnumerable<int> _: // ...
case List<int> _: // ...
default: // ...
}不过,从 C# 9 开始,弃元符号就可以不写了。于是乎,模式匹配就可以简写成真正的类型模式了:
switch (obj)
{
case int[]: // ...
case IEnumerable<int>: // ...
case List<int>: // ...
default: // ...
}4、is object 模式
is object 模式在 C# 里,object 作为类型的根,提供了一个全局特殊的作用:它可以表示 C# 里可定义的任意数据类型。当然,指针类型除外;但指针可以隐式转换为 IntPtr(C# 9 起叫 nint,而 IntPtr 是结构,仍然可以最终转回 object。
我们也经常想要去判断是否某个变量或表达式不为空,因此我们常常会使用 != null 来判断。但架不住有些用户重写 == 和 != 运算符,然后忘记判断空值的情况,于是 C# 提供了一个轻巧的判断函数:ReferenceEquals。这个函数定义在 object 类型里,因为你的类型全部走 object 类型派生,因此这个类型随时都可以用。更何况此时这个方法是静态的,因此你可以这么写:
if (!object.ReferenceEquals(a, null))
// ...显然,这个写法也过于臃肿。那么我们有什么办法简化一下书写吗?实际上也是有的。
因为我们自定义的类型全部都会走 object 派生,因此我们只需要这么写:
if (obj is object)
// ...那么 obj 通过 if 判断,就等于说它肯定是不可空的了。这是不是很好用?
5、值类型避免使用 is object 模式
is object 模式对于值类型而言,它没有所谓 null 的概念。可空值类型除外哈。既然如此,值类型使用 is object 就显得很奇怪了。如果你在代码里使用值类型判断 is object 的话,编译器将会给你一个警告,提示你这个判断没有意义——它属于永真式。永真式的话题将在后续内容里介绍。
6、ref struct 不支持使用类型模式
ref struct 不支持使用类型模式C# 7 有一个新的数据类型叫引用结构。这种结构在使用的时候不允许以任何形式进入堆内存存储。正是因为有这样的约束,所以在 C# 13 之前,泛型都是不允许使用 ref struct 的。而这种限制带来了一个非常致命的问题:他不能使用大多的模式匹配机制。
泛型在 C# 的设计里都隐式从 object 类型派生,因此你可以在泛型类型的对象上使用诸如 ToString 这种在 object 类型上声明的方法,尽管泛型参数的实际类型没有实现它。但是,很显然,如果这个泛型类型是一个 ref struct 的话,泛型的机制就会出现一个重大的使用上的漏洞。所以,泛型参数早期是不允许是 ref struct 类型的。因此,泛型参数不能使用转型运算符也就理所应当成为了一个约束。所以,你无法看到诸如 obj is ReadOnlySpan<char> 这样的写法格式,因为它也是不被允许的。
不过,这一点在 C# 13 后有所改变。泛型参数从 C# 13 开始允许使用 ref struct,需要泛型参数满足两点内容:
泛型参数约束上有
where T : allows ref struct约束;泛型参数的实例(参数等)在任何内部使用上,都应满足一个
ref struct正常需要满足的所有条件(如不使用装箱机制,不使用转型机制等)。
尽管这一点解决了泛型参数不能使用 ref struct 的致命问题,但 C# 13 仍然不允许使用转型。所以类型模式是不可使用的。让我们静候允许的那一天的到来。
属性模式(Property Pattern)
1、语法
属性模式是用于专门体现对象的属性信息的匹配模式。我们使用一对大括号来表达参数是否必须满足这个数值信息。
假如,我们现在的 Point 类型的 X 和 Y 不再使用字段表达,而是用属性来表达:
public readonly struct Point
{
public int X { get; }
public int Y { get; }
}那么,我们即使不给出解构函数,也可以使用属性的方式来对每一个成员信息进行判断:
if (point is { X: 30, Y: 30 })
// ...属性模式专门给属性提供数据判断的服务,因此这种模式叫属性模式。
2、字段也可参与属性模式匹配
虽然这个模式匹配类型称为属性模式,但本质原因在于它大部分都是在做对属性数值的校验。但是实际上,它可用于字段。
假设我有一个底层的 _mask 字段,使用 private 修饰。此时我如果要校验此字段,从类型的外部访问确实是不可能的,所以外部无法校验它;但是这不代表在这个类型里也不能使用。
class ValueInfo
{
private readonly int _mask;
public ValueInfo(int mask) => _mask = mask;
public static bool CheckValidity(ValueInfo? instance)
=> instance is { _mask: >= 64 and <= 128 };
}如果我在 ValueInfo 类型之中定义了一个 CheckValidity 的方法,并校验参数 instance 的 _mask 数值,这个时候假设我们使用了属性模式,这也不会造成编译器错误。
3、属性模式的弃元
一般来说,属性模式下,由于不需要依赖于解构函数,因此属性是可以写出来判断的;反过来说,如果属性不判断的话,那么写出来就没意义了。不过 C# 的语法允许我们使用弃元来默认通过某个属性的判定:
if (point is { X: 30, Y: _ })
// ...这样的话,Y 属性是永真式,即不用判断了。说白了,这里的 Y: _ 是可以不写的。只是 C# 允许这种语法存在,体现出了语法的灵活性。
4、空属性模式及变量声明内联
如果属性模式里的成员为空,那么它表示什么呢?
if (nullable is { } point)
// ...是的,对于可空类型(不管是值类型也好,还是引用类型也好),都表示“不为 null”。比如 nullable 是一个可空的 Point 类型,那么 is { } 就表示 nullable.HasValue。当满足条件后,我们用 point 表示这个 Point 类型的数据。
从这个例子里,我们可以得到的若干信息是这些:
is { }表示“不为null”,适用于任何可为空的类型,这一点和is object是一样的;大括号后可继续内联一个变量,和
is T variable写法格式(声明模式)一致,但是,注意内联的这个变了和原始变量的类型和可空语义的不同:被匹配的变量(原始变量)是可空的,但是内联的后者这个变量是一定不空的。这一点,is object variable的写法也可以完成。
C# 是允许变量声明的内联作为模式匹配的一部分的。这里仅用空属性模式介绍了内联变量的写法,但你要知道的是,内联变量可用在任何情况下的属性模式。
5、尽量不要让本来就不为 null 的表达式使用属性模式
null 的表达式使用属性模式可以发现,is 的左边其实可以为一个表达式。因此下面的代码是合法的:
if (new Student("Sunnie", 25) is { } student)
// ...不过,这种写法具有副作用。is 的左边一定是一个不为 null 的表达式,那么我们就没有理由使用 is { } 来进行模式匹配。因为这样会导致编译器生成不必要的判空代码。
因此,为了避免这样的写法出现,我们可以改成 var 模式,或者是直接定义一个新的变量来进行赋值。
// Way 1.
var student = new Student("Sunnie", 25);
// ...
// Way 2.
if (new Student("Sunnie", 25) is var student)
// ...请注意。这里所说的属性模式不单单只是空属性模式。在里面带别的属性使用 var 模式的话,也是不必要的写法。
var student = new Student("Sunnie", 25);
if (student is { Name: var name, Age: var age })
// ...这种写法看似是在直接使用大括号语法来同时获取两个属性的数值,但是如果 Student 是引用类型的话,属性模式的大括号本身会让编译器自动生成判空代码,于是这样的代码等价于 student is object && student.Name is var name && student.Age is var age。而 is object 这里会做一次判断 null 的冗余操作。
如果你需要对多个这样的属性一齐取值的话,我建议你使用值元组来进行赋值:
var (name, age) = (student.Name, student.Age);用这样的语法来代替原来的写法。这样的赋值和原始的赋值的期望结果是一致的,但代码里也不会多出冗余的判空。
6、可空值类型模式匹配是匹配的内部数值
判别对象是否为空,我们可以使用 is null 来完成,因此不空就使用 !(obj is null) 就可以了;与此同时,由于空属性模式也可以完成相同的行为,因此这样的代码也可以写成 obj is { };对于可空值类型来说,我们还可以使用 HasValue 属性来完成:obj.HasValue。
但是,可空值类型在模式匹配里是当成值类型来假设的——它可能含有数值,那么数值直接拿出来即可;如果不含有数值,返回 null 就是判断模式的结果。而这里的 HasValue 是对所有可空值类型都具备的一个独特特性。但是在模式匹配里,你无法这么写代码:
if (nullableValueObject is { HasValue: _ })
// ...比如属性模式,我们想要直接使用 HasValue 属性来完成属性模式匹配,这样的语法是错误的。因为编译器会假设 nullableValueObject 在模式匹配里是按数值进行判断的,即使它本身是可空值类型,但在模式匹配里它是被视为一个包含 null 的普通数值类型。比如说 a 是 int? 类型,那么 a is { HasValue: _ } 就是错误写法:因为 a 会被视为包含 null 的普通 int 类型,而不会被当成 int? 类型(即 Nullable<int> 类型)。这个意义在于,由于它进行模式匹配并不会被视为可空值类型,因此你无法使用 { HasValue: _ } 类似的模式来获取其结果。
如果确实要获取可空值类型的内部数据,你应该写 a is { } v 或 a.HasValue && a.Value is var v,而不是 a is { HasValue: _, Value: var v }。
7、用属性模式解构值类型对象
是的,C# 编译器确保了我们的操作完全只包含解构行为的时候,是可以不做判断即可使用这些变量的。举个例子。
_ = stu is { Name: var name, Age: var age };它不依赖于你的解构函数:只要对象具有该属性数值且包含 get 访问器可以用于取值操作,这个属性就可以用来作为属性模式解构操作的一部分。这种解构形式和之前学到的解构函数的解构模式不同,这里用的是属性模式的方式获取,因此称为属性模式解构(Property-pattern-styled Deconstruction)。
另外,上面用到了弃元符号。因为 is 表达式不可单独使用,它必须返回数值给变量调用。如果你确实不使用结果变量(实际上这个解构行为根本就不可能失败,所以上面这样的 is 表达式永远返回 true)赋值给等号左侧的话,只需要写弃元符号即可,它等价于这样:
var name = stu.Name;
var age = stu.Age;又或者是
var (name, age) = (stu.Name, stu.Age);等等写法。
另外,这样的解构风格允许你包含弃元模式嵌套在属性模式之中。但凡右侧 100% 是成功的解构操作的话,你怎么写模式匹配都可以:
_ = obj is { A: _, B: { Nested: var nested, SecondNested: _ } _, C: var propCValue };这些都是编译器允许的写法。这种就是带有递归使用的解构,它也是编译器允许的,因为这样的解构操作肯定是成功的。否则,由于可能失败,所以带有别的模式匹配的话,你可能就得用 if 来判断一下才知道是否模式匹配成功了。
if (obj is { A: 10, B: { Nested: var nested, SecondNested: _ } _, C: var propCValue })
// ...这样的话,由于 A 属性判断了数值,所以可能解构操作不成功,这种场合你只能使用 if,而且不能简化成上面属性模式风格的解构的样式。顺带一说,_ = a is pattern 表达式的 _ 不是模式匹配,它只是表示变量我们不使用了。
8、递归模式Ⅰ:属性模式递归
C# 强大的地方在于,语法很灵活,这样我们写代码可以不用唯一的一条道路去实现。比如前面的解构模式。(x: var x, y: var y) 里又是一个 var 模式的变量声明。所以,正是因为这样,我们学 C# 就不必学得那么痛苦。
C# 的属性模式是 C# 一大秀儿语法。它允许递归使用属性模式进行判断。假设我有这么一个对象:
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Gender Gender { get; set; }
public Person? Father { get; set; }
public Person? Mother { get; set; }
}这个对象是表示一个人的基本数据信息,比如名字啊、年龄啥的,当然也存储了 ta 的父母的实例的引用。
其中,我们假设
Gender类型是个暂时只包含Male和Female俩字段的枚举类型。
Person?语法表示Person这个引用类型具有和值类型类似的语法:这个属性信息可为null。反之,如果没有?标记的类型,这个成员的数值就不能为null。这个语法是 C# 8 里的,这里为了体现出判断用法,故意写上了?来表达为null、更显眼一点;另外,这里故意取可为null的写法,还有一个目的,是为了体现一会儿模式匹配的语义,所以请不要和现实世界进行对比或者对号入座。
假如,我们要判断是否某个人的姓名是“张三”、年龄 24,他爸叫“张二”、而他的妈妈则叫“李四”。如果要判断这个对象的具体信息,我们可以这么写代码:
if (
zhangSan is
{
Name: "Zhang San",
Age: 24,
Father: { Name: "Zhang 'er" },
Mother: { Name: "Li si" }
}
)
{
Console.WriteLine("Zhang san does satisfy that condition.");
}注意这里的模式匹配写法。前面模式匹配就用的是大括号,因此我们可以对对象的内部信息继续作判断。比如 Father 和 Mother 属性又是一个 Person 类型的对象,因此我们还可以接续一个大括号对 Father 和 Mother 的值的具体内容继续进行判断。
一定要注意。Father 和 Mother 属性是可能为 null 的。当 Father 属性的数值本身就是 null 的时候,那么显然就不存在 Name: "Zhang 'er" 的判断行为了:因为 null 值本身就无法继续判断内部数据了。因此,在 Father 为 null 的时候,模式匹配结果一定是 false。当且仅当整个判断的逻辑全都匹配,if 条件才成立。
顺带给大家看下,C# 的模式匹配到底多有魅力:给大家展示一个我之前写过的一段代码,用到了这里的模式匹配。
if (
node is
{
Expression: MemberAccessExpressionSyntax
{
RawKind: (int)SyntaxKind.SimpleMemberAccessExpression,
Expression: MemberAccessExpressionSyntax
{
RawKind: (int)SyntaxKind.SimpleMemberAccessExpression,
Expression: IdentifierNameSyntax
{
Identifier: { ValueText: "TextResources" }
},
Name: IdentifierNameSyntax
{
Identifier: { ValueText: "Current" }
}
},
Name: IdentifierNameSyntax
{
Identifier: { ValueText: var methodName }
} nameNode
},
ArgumentList: var argList
}
)
{
// ...
}这里,这么一大坨都是递归的模式匹配。正好这体现出了模式匹配的魅力。
9、递归模式Ⅱ:对位模式和属性模式是可以放在一起的
C# 的属性模式具有和对位模式完全一致的判断行为,因此 C# 就把对位模式和属性模式在语义分析上放在了一起。假设我有一个 Point 类型,包含 X 和 Y 属性(它们通过解构函数解构为 x 和 y 两个参数),并且包含 Area 属性表示当前点到坐标原点构成的矩形的面积。
这里不是讲数学,我只是告诉你如何并用两个模式。
if (point is (x: 10, y: 30) { Area: _ })
// ...可以看到,我们直接在 (x: 10, y: 30) 这个对位模式后加上了 { Area: _ } 属性模式。在 C# 里,对位模式和属性模式均可以用于递归使用(比如假设一个对位模式的成员是可以继续通过别的模式进行匹配的,那么这个成员就可以继续递归地进行模式的判断),同时属性模式也是如此,前文已经说过了。因此,C# 把对位模式和属性模式统称递归模式(Recursive Pattern)。换句话说,在概念上来讲,你可以同时使用对位模式和属性模式的两种不同模式的判别,并放在一起,这个整体叫做递归模式。
但请注意,必须是先对位模式,后属性模式的顺序。写反了是不行的。
10、递归模式Ⅲ:要省略中间的模式,不要用弃元来省略
递归模式匹配一共具有四个部分:类型(类型模式)、小括号部分(解构模式)、大括号部分(属性模式)和变量(声明模式),我们可以通过拼凑这四个部分来达成我们要组合判别的所有内容。
if (obj is Point(var x, var y) { Area: var area } casted)
// ...这样的判别完全等价于
if (obj is Point casted && casted is var (x, y) && casted.Area is var area)
// ...拼凑在一起就不需要这么多的 && 运算符拼接了,代码也会比较紧凑一些。不过,这里要注意一点。如果你想省略其中四个部分的一个或一些的话,你不写就行了,编译器允许你直接省略掉其中的一部分:
只省略一个模式:
省略类型:
obj is (var x, var y) { Area: var area } casted省略解构模式:
obj is Point { Area: var area } casted省略属性模式:
obj is Point(var x, var y) casted省略变量:
obj is Point(var x, var y) { Area: var area }
省略两个模式:
省略类型和解构模式:
obj is { Area: var area } casted省略类型和属性模式:
obj is (var x, var y) casted省略类型和变量:
obj is (var x, var y) { Area: var area }省略解构模式和属性模式:
obj is Point casted省略解构模式和变量:
obj is Point { Area: var area }省略属性模式和变量:
obj is Point(var x, var y)
省略三个模式:
省略类型、解构模式和属性模式:
obj is var casted(这个要加个var才行,不然编译器还以为你这casted是个常量呢)省略类型、属性模式和变量:
obj is Point(var x, var y)省略类型、解构模式和变量:
obj is Point { Area: var area }省略解构模式、属性模式和变量:
obj is Point
这些写法均是允许的。你可以认为,前面学到的单个的模式匹配的语法都是这里递归模式的子集。不过,这些写法下,省略是 OK 的,但是不能自作主张,用弃元符号占位。例如,obj is (var x, var y) { Area: var area } casted(省略类型模式)是可以的,但是 obj is _ (var x, var y) { Area: var area } casted 不行。
关系模式(Relational Pattern)
1、语法
前面的模式可以解决一大部分的问题了,但是有些时候,数据判断和取值无法对一个范围来判断,因此还不够灵活。C# 里还有关系模式,来对数据的范围来判断。
if (obj is > 30)
// ...即使 obj 不是 int 类型,我们依旧可以这么写。这个代码等价于 obj is int i && i > 30。
C# 允许 >、>=、< 和 <= 四个运算符,写在 is 后,来表达范围判断。稍微注意一下的地方是,is > 30 的 30 必须是常量才行。
给大家看一个例子:
Console.WriteLine(Classify(13)); // output: Too high
Console.WriteLine(Classify(double.NaN)); // output: Unknown
Console.WriteLine(Classify(2.4)); // output: Acceptable
static string Classify(double measurement)
=> measurement switch
{
< -4.0 => "Too low",
> 10.0 => "Too high",
double.NaN => "Unknown",
_ => "Acceptable",
};不过怎么理解,就靠你自己了。
2、不推断类型的时候,不要用模式匹配
正是因为出了这个模式,下面两句话就变成等价的了:
int v = 30;
bool condition1 = v > 30;
bool condition2 = v is > 30;显然,要不要 is,语句都可以理解。但是,有 is 需要模式匹配,因此显然复杂一点。因此,我们建议在数据类型不用判断的时候,不要使用 is。当然,这里说的结论指的是这里这种情况。
逻辑模式(Logical Pattern)
因为模式匹配里的每个模式并不是一个“数据信息”,因此我们无法直接对模式用 &&、|| 等符号来进行拼接组合。C# 为了解决这个问题,多了三个关键字:and、or 和 not 来拼接模式。
1、合取模式
合取模式用 and 拼接模式,来表达这些模式都必须成立。
static bool IsLowerLetter(char c) => c is >= 'a' and <= 'z';比如这里,>= 'a' and <= 'z' 整个表达式用来表达,>= 'a' 和 <= 'z' 两个条件必须都满足。如果要写分开,就必须写成 c is >= 'a' && c is <= 'z'。
2、析取模式
析取模式用 or 拼接。
static bool IsLetter(char c) => c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z');注意,or 拼接了前面 >= 'a' and <= 'z' 和后面 >= 'A' and <= 'Z' 两个模式。or 表示两个模式有一个模式能够匹配成功就可以。
这里我们介绍了一种新的语法:C# 允许模式匹配的内部使用小括号,来断开和分隔一个模式。and 和 or 的模式名称不变,但这个小括号套起来的模式,C# 称之为括号模式。
3、取反模式
取反模式用 not。
if (input is not null)
// ...最常见的就是这里。我们如果判断对象是不是不为 null,那么我们最常用的就是写成 is not null。is null 属于前面的常量模式,判断对象是不是 null。它和 == 运算符的区别是,== 运算符可重载,重载会影响 == 的判断和使用逻辑;而 is 是永远不变的判断模式。
4、字面量在 and 模式下的类型可调整性
and 模式下的类型可调整性之所以放在这里说,是因为字面量是可以进行类型匹配的,这也就是前文提到的常量模式。不过,字面量有时候表现得并不一定非得是字面量本身的数据类型。
举个例子,1 是 int 类型的字面量,但我们可以使用 and 连接常量模式和类型模式,使得这个 1 的类型发生变化:
object o = 1U;
if (o is uint and 1)
// ...请注意这里的 uint and 1 模式。uint 是表示类型必须是 uint 类型,而 1 却又是 int 类型的字面量,这不会冲突吗?答案是并不会,字面量在模式匹配里会按照 and 里联立给出的类型进行隐式转换。如果能够转换过去,那么就是允许的。
举个例子,o is decimal and 1 和 o is decimal and 1M 是一个意思,这个字面量 1 可以写成 1M 但也可以直接写成 1,因为字面量会按照类型匹配的关系自动转换其表达的类型。不过,这仅限于上下文可以暗示类型的情况。如果前面用的是 or 的话,就不行了。
object o = 1U;
if (o is uint or 1)
// ...这表示什么呢?这表示 o is uint || o is 1,也就是 o is uint || o is int && (int)o == 1 的意思。
5、is not var 组合模式到底是否为永假式?
is not var 组合模式到底是否为永假式?所谓的永真和永假式,就是说这个式子的判断结果永远都是 true 或 false。比如 if (true) 条件我们直接写的是 true 字面量,这就是一种典型的永真式;当然你不嫌复杂也可以写 if (!false && true || false || true) 这种超级复杂的写法。
那么,expr is var variable 的话,假设 expr 是一个表达式的话,那么它是否是永真式呢?
这个问题问得好。expr is var variable 的模式匹配规则允许我们将表达式的结果在内联为布尔运算逻辑的其中当成一个永真的表达式在使用,目的是为了合并多个布尔表达式,使之直接称为一个表达式还能跑起来,这样就可以不用使用一大堆的 if 判断语句来影响可读性和代码量了。
不过,is var 真的是永真的吗?如果是的话,那么 is not var 不就是永假式么?永假的逻辑在 if 里还不如就写成 if (false) 么?那这种东西还有何意义呢?
实际上,并非如此。is var 也不一定随时都永真。考虑一种情况:解构模式。解构模式需要我们按照一定的情况对一个对象进行解构。它需要对象有一个解构函数,或者是包含一个可访问到的扩展解构函数。
考虑一种情况,假设这个对象是引用类型呢?那么解构会随时都成功吗?不见得,对吧,因为它自己可能是 null。因此,在引用类型使用该模式组合的时候是有别的含义的,比如:
var nullableExpression = ...;
if (nullableExpression is not var (_, secondVariable, thirdVariable))
return;
// Here can use 'secondVariable' and 'thirdVariable'.
// ...对于这种情况下,nullableExpression 在判断的意思就是,如果它不可解构为后面的三个变量的时候,则退出方法。在这个时候,“不可解构”就对应了它为 null 的时候。
不过,对于纯定义变量的 is var 的话,它肯定是直接赋值过去的,它根本不判空。因此,is not var 不可用于这种情况:因为它永假,导致的情况就是无法完成匹配,因此 var 后面的变量你写啥都没有用:反正也不会成功赋值过去。因此后续的变量自然就不可能用得上它,因此编译器也不允许你这么写代码。
6、再次说明,纯弃元不可单独使用
前文简要介绍过弃元模式的用法和范畴,不过弃元模式是可以直接运用到逻辑模式里的:
if (o is var _ and _ and _)
// ...只要在模式匹配期间出现了一个 var _ 或者别的什么模式,就可以直接使用纯弃元 _。当然,这么写没有意义,因为是冗余的模式匹配(它什么都没有判定),只是说可以这么用——编译器认为不是错误的语法,就没有必要去限制它。
7、not 模式 + 声明模式
not 模式 + 声明模式这里尤其要说明一点。在前面我们介绍了解构模式 (var a, var b) 和属性模式 { Prop: var value },但这里有一个我们连用 not 的特殊用法。C# 的编译器允许我们使用 not 模式上引入声明模式的用法,即下面这样:
if (expr is not { } variable)
// ...虽然我们知道,我们其实经常使用属性模式和声明模式,使得变量内联。但是其实在 not 模式下,这一点仍旧成立。不过,这个理解就比较困难了。下面我们来说一下。
这个模式 not { } variable 一共是三个部分:not 模式(取反模式)、{ } 模式(属性模式)和 变量(声明模式),而因为 C# 允许递归模式匹配,所以属性模式和声明模式放一起是没有问题的——它被看成了整体。因此,not { } variable 应将后者看成一个整体,即 not ({ } variable) 而不是 (not { }) variable。
搞清楚了这一点后,我们来说下怎么看这个复合模式匹配。
既然我们知道 { } variable 是一个整体,那么我们可以将其当作是一个模式,然后需要让它满足后,将这个模式取反理解就行。{ } variable 的意思是,对象非 null。如果确实非 null,就用 variable 变量表示它。那么,not { } variable 的意思自然就一目了然了:对象为 null。
按照数学知识我们知道,{ } variable 要满足需要有两个部件:非 null 和能用后面这个变量表示;那么取反后,应该把这两个部件都取反,然后用“或”连起来表示只要有其中一个不成立就行。但是 variable 变量是一个普通变量。我们通过递归模式匹配知道,大括号后是递归模式匹配的最后一个部件,即变量名。而我们所了解的解构模式会条件性成立,是放在大括号的左边。因为顺序是固定“类型名”、“小括号”、“大括号”和“变量名”依次排列的,所以最后这个部件只可能是一个普通的“变量名”。
而我们既然能这么去看,我们就知道它必然是一个普通变量。要知道一个表达式是一定可以赋值给一个合适类型的变量的,所以这个 variable 不论什么时候它都应成立;而套了 not 之后,这反而成一个永假式了。而两个部件“不空”和“变量赋值”变为了“空”或“变量不能赋值”之后,后者成为了永假式,就可以从整个这个理解的式子里去除掉,成为了冗余项,所以将两个部件取反后,只剩下了“为空”这一个判断部件需要满足了。这里有些绕,但你如果数学逻辑思维较强的话,我相信你不看这句话也能理解我刚才的 not { } variable 为什么只表示前者“为空”这一个原因。
既然 not { } variable 只表示“为空”,那我何必又在模式匹配里加一个 variable 变量呢?这不是没必要吗?其实不然。我们经常会在书写代码的时候将代码的所有不成立的分支优先给判断出来,然后通过 return 或 continue 语句来跳过它们。什么意思呢?
void func()
{
if (!condition1)
return;
if (!condition2)
return;
if (!condition3 || !condition4)
return;
foreach (var element in collection)
{
if (!satisfy(element))
continue;
// ...
}
}通过这样的代码来阐述我想说的意思。显然,我们经常会这么做,优先把若干不成立的部分提取出来,然后挨个过滤掉。过滤的方式就是 return 和 continue。这样的目的是为了减少代码的缩进。如果我们不这样做的话,大量的 if 成立就会反复让代码嵌套,导致层级过多。
而试想一下,它的反转情况是“if 条件成立”。假设我在 if 上表示一个这样的写法:
if (variable is { } expr)
{
// ...
}那么,我们就会造成代码的层级过多。于是我们不得不将此代码进行取反。可是一旦取反后,variable 变量不就不可用了嘛。所以这个 variable 是什么意思呢?也就是在跳过 return 或 continue,到该 if 声明部分的后面,可以用它。
if (expr is not { } variable)
return;
// Here we can use 'variable'.
if (anotherExpr is not { } anotherVariable)
return;
// Here we can use both 'variable' and 'anotherVariable'.
// ...大概是这样的代码段表示的意思。
别小看这个写法。这反倒是经常在代码里使用到。
括号模式(Parenthesized Pattern)
光说逻辑模式,实际上用起来也并不是特别方便。因此,我们有时候需要搭配着一起使用。于是,括号模式也就同时诞生了。
1、混用三种逻辑模式
你可以混用 and 和 or 关键字拼接起来的模式。
if (ch is >= '0' and <= '9' or '.')
// ...这表示 ch 是不是字符 0 到 9,或者是小数点。
如果我们将其取反的话,我们就不得不添加一对额外的小括号来表达出取反整体模式了。
if (ch is not (>= '0' and <= '9' or '.'))
// ...这就表示取反。
如果你对数学的逻辑学比较熟悉,你还可以使用它将 not 套到括号模式里使用:
if (ch is not (>= '0' and <= '9' or '.'))
// ...
if (ch is not ((>= '0' and <= '9') or '.'))
// ...
if (ch is not (>= '0' and <= '9') and not '.')
// ...
if (ch is (not >= '0' or not <= '9') and not '.')
// ...这 4 行 if 条件是等价的判断,只是书写都不同。这可以帮助你理解和拼接模式的具体内容。
2、三种模式的优先级和结合性
稍微注意一下。合取式 and 和数学上是一样的,比 or 更优先推理,因此无需对 and 和 or 模式一起的复杂模式匹配添加括号:
static bool IsLetter(char c) => c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z');比如这样,(>= 'a' and <= 'z') 和 (>= 'A' and <= 'Z') 的小括号就可以不要。
取反式的话,因为它只和一个模式结合使用,不像是 and 和 or 需要两个模式结合,因此 not 的优先级比 and 和 not 都要高。所以,上面的例子里,这个写法你应该是知道哪些地方省略了小括号。
if (ch is (not >= '0' or not <= '9') and not '.')
// ...3、对括号模式和对位模式下小括号的辨识
不知道你注意到了没有。由于我们这里介绍了三种逻辑模式的类型,因此我们也不得不会产生和使用一种新的模式来自定义模式的结合。可是,小括号在模式匹配里本身就有别的用途:它表示一个对位模式。
那么,如果我们出现了像是使用对位模式的语法,但它又可以被当成带括号的模式的话,这不就出现二义性了吗?
if (val is (3))
// ...假设我们这里的 val 是一个对象。这里的模式 (3) 到底是什么呢?是括号模式里套了常量模式吗?还是说,它是一个一元组解构之后的对位模式呢?这我们无法确定,尤其是这个 val 刚好可以解构成这个样子的时候。
所以,考虑到语法的严谨性和兼容性,小括号一直以来都是被视为“冗余后可去掉”的存在,因此,(3) 模式会被认为是一个括号模式。而此时的 (3) 是一个很普通的常量模式,因此编译器会提示你可以直接去掉这个小括号。
不信你可以打开 Visual Studio 试一试。如果你在写一个对位模式匹配的时候,如果你写了一段代码:
switch (a, b)
{
case ()
}当此时光标在 case () 的 ( 和 ) 之间时,你肯定会往里面输入模式:先判断对象 a,然后逗号,然后判断对象 b。可是,你仔细看看,如果你在输入任何一个字符的时候,Intellisense 实际上并不是在给你提供 a 对象的属性成员让你对位匹配,而是比如 a: 啊、b: 啊之类的玩意儿。这是因为,编译器认为你这里的小括号是括号模式,因此括号可以不要。于是,它视为你正在对 (a, b) 这个元组类型 ValueTuple<T1, T2> 在做模式匹配。因此,它会提示给你看的是 a 字段和 b 字段(这两个字段就是 ValueTuple<,> 类型下自带的 Item1 属性和 Item2 属性在编译期间的临时引用名称。所以,Intellisense 给出的比如 a: 的玩意儿,实际上是让你填充进去 a: 的字段名,用于属性模式等模式的判断。
这就解释了前文“解构模式”里的一节内容:解构和对位模式不要求判断元素数量至少两个,以及单元素的解构模式要手动消除二义性,这两点的真实原因。
4、声明和 var 模式不能写进 or 模式的两侧
var 模式不能写进 or 模式的两侧这里就需要稍微提及一下注意事项了。实际上,虽然模式匹配很好用,但有些时候我们难免会因为一些思维不严谨导致错误使用——本来我们觉得可以用,但实际上编译器不认为你这么写是严谨的,于是不允许你这么用。你还纳闷,为什么呢。
举个例子。倘若我们要定义一个变量,并且判断数值是否不为 0,那么我们可以这么做:
int val = int.Parse(Console.ReadLine());
if (val != 0)
// ...我们学了模式匹配后,就可以简化一下:
if (int.Parse(Console.ReadLine()) is var val and not 0)
// ...于是,我们使用 var val and not 0 来判断了数值结果。这是合理的。
但是,我们总有时候,会因为代码的书写逻辑冗长而不得不使用过滤思想:倒装判断逻辑,减少缩进,将所有的不满足条件的情况直接给 return 或 continue 提前退出,这样可以减少缩进。于是,我们就需要对上述表达式进行取反:
if (!(int.Parse(Console.ReadLine()) is var val and not 0))
// ...可是,这样的判断显然是有点丑的,因为我们直接对模式取反还打了括号。我们尝试将括号解开,将取反适用于内部的模式。
按照基本的取反规则,and 要转为 or,not 会变为 not not,也就是双重否定,即可以直接去掉;反之没有 not 的地方补一个 not 即可:
if (int.Parse(Console.ReadLine()) is not var val or 0)
return;
// ...似乎可以这么写。但是,这样的转换真的合理吗?实际上是否定的。我们来详细说明一下,为什么 not var val or 0 是不正确的模式判断。
首先,not var val 模式指的是对 var 模式取反。var 模式唯一可以取反的情况只有元组判断的情况。如果一个引用类型用于解构,那么此时的 var 会优先判断一次 null,毕竟对象不为 null 才可以进行对象解构。因此,这是唯一一种可以用 not var 的情况。但是,很显然这里的 is not var val 并非解构,它的 val 是一个临时变量,因此,not var val or 0 这个模式就永远不可能成立:因为 var val 是必然成立的赋值过程。
其次,就算我们退一万步讲,not var val 我们暂且不考虑赋值成功与否,or 代表的是两者至少有一个成立就可以。那么,如果我们假设 or 右侧的常量模式 0 成立的话,那么左边的 not var val 可能就不一定成立了。注意,这里我们要说“不一定成立”,是因为我们退了一万步在说明这个道理,这里是一个假设说明。那么,一旦这样的 var 模式不成立,我们就无法让编译器断定,这个 val 是否能在代码后面继续使用了。那么这里的 val 的声明就无法确保可用性。且不说编译器无法确定,就是你,在写代码的时候也不能确保能安全使用该变量。因此,对于声明模式和 var 模式下,这两种模式由于都会产生新的临时变量,因此为确保编译器可以正确知道变量的使用范围,我们不能将这两种模式放在 or 的两侧的其中一边。
那么,上面的代码也不是没有办法去做。你只需要将原始的模式加上小括号,然后在小括号左边加 not 表示对整个模式取反即可。
if (int.Parse(Console.ReadLine()) is not (var val and not 0))
return;
// ...这就可以了。这样编译器就知道,你在匹配 var val and not 0 模式。当不满足的时候,if 条件成立,直接 return;反之,var val and not 0 模式成立,于是 val 变量可以安全正确使用。
当然,这里需要你打上小括号。因为 not 优先级最前,它是单模式的运算,不打括号会被视为 not var val 和 not 0 两个模式的 and 连接,导致错误:not var val 是不可能成立的。
5、类型在 and 模式下的特殊做法
and 模式下的特殊做法可能你尝试过这么写代码:
Base b = new Derived();
if (b is Derived and { Property: 42 }) // Here.
Console.WriteLine("Derived!");
class Base;
class Derived : Base
{
public int Property => 42;
}可能没人告诉你,在连用 and 模式的时候,即使 b 变量本身不支持(因为它原本是 Base 类型的),在带有类型之后,就可以使用这种写法了。
可能你觉得,这不是很正常吗?既然已经验证过类型了,自然是可以这么用的;甚至去掉 and 也可以。但是,这种用法是很常见的。使用这种格式的模式匹配一般会有这种用途:
Base b = new Derived();
if (b is Derived and not { Property: 42 }) // Here.
Console.WriteLine("Derived!");这时,not { Property: not 42 } 模式就会显得非常常用。判断一个类型是 Derived 的,并且它的属性 Property 不能是 42。注意,这种用法下,因为它是引用类型,所以 not { } 模式一般情况下会插入一个 is not null 的判断;但因为前面已经指定了 Derived 类型,所以它肯定不可能是 null。只有这种用法下,不会产生额外的 not null 判断。
拓展属性模式(Extended Property Pattern)
1、语法
因为属性模式本身有些地方很鸡肋,因为它可以嵌套,比如下面这样的代码:
if (
zhangSan is
{
Name: "Zhang San",
Age: 24,
Father: { Name: "Zhang 'er" },
Mother: { Name: "Li si" }
}
)
{
Console.WriteLine("Zhang san does satisfy that condition.");
}这是之前的属性递归介绍的代码。这个写法里,Father 里再次包含一层大括号。
在新的模式匹配里,为了解决这种大括号嵌套太多层次导致可读性降低的问题,发明了拓展属性模式。拓展属性模式允许将这个代码简写为 Father.Name:
if (
zhangSan is
{
Name: "Zhang San",
Age: 24,
Father.Name: "Zhang 'er",
Mother.Name: "Li si"
}
)
{
Console.WriteLine("Zhang san does satisfy that condition.");
}即少一个大括号的层级级别:Prop: { NestProp: { } } 改成 Prop.NestedProp { } 的格式。
2、成员访问运算符的隐式判空
请注意,两种写法是等价的。可能你会认为前者的大括号的层次会让判断逻辑多一次 null 的判断:Prop is not null && Prop.NestedProp is not null,而 Prop.NestedProp { } 这个写法因为 Prop 和 NestedProp 是直接引用的(用小数点关联起来),所以不会这么做。如果你这么想的话,那你就错了。C# 的拓展属性模式仅仅是为了简化代码才这么写代码的。这里的小数点(成员访问运算符)和我们以前学习的标准的成员访问运算符是不一致的语义:这里只能表示逻辑上的引用的层次关系,而一旦发现 Prop 是可空类型的话,那么访问 NestedProp 这个嵌套属性的时候就会必然先对外层的 Prop 属性数值做一次隐式判空,避免直接访问 NestedProp 而产生异常。
另一方面,正是因为编译器会自动隐式产生判空的情况,所以 C# 语法上不允许我们在这个小数点前面加 ?,即比如 Prop?.NestedProp: { },这么写是错误的语法。假设你想要实现类似的行为,请必须使用 null or 模式:
Father: null or { Name: "Zhang 'er" }
// Or dual negation.
Father: not { Name: not "Zhang 'er" }来代替错误的语法:
Father?.Name: "Zhang 'er"列表模式(List Pattern)
1、语法
为了将集合的元素提取出来判断,C# 拥有了列表模式。
列表模式是将一个不知道是不是集合的对象,用列表的格式列举出来,对其中的元素挨个进行判断的模式。
我们使用一对大括号进行判断。使用范围记号 .. 来表达“这是一个范围”。举个例子:[1, .., 3] 表示判断一个序列的第一个元素是不是 1,而最后一个元素是不是 3。所以,自然这个写法就等价于下面这个格式了:
if (arr is { Length: 10 } and [1, .., 3])
// ...它等价于
if (arr.Length == 10 && arr[0] == 1 && arr[^1] == 3)
// ...这里的 ^1 是 C# 8 里的表达式,表示倒数第一个元素。^n 就是倒数第 n 个元素。可以从这个写法里看出,.. 是灵活的:它不是固定长度,是随着整个模式匹配的序列来确定 .. 的长度的。这么写是为了简化代码的书写格式。
当然,假设我们判断倒数第二个元素而不是倒数第一个的话,那么我们可以尝试在倒数第一个元素的判断信息上添加弃元记号 _ 来表达占位:
if (arr is { Length: 10 } and [1, .., 3, _])
// ...弃元记号在这里起到了很重要的作用。一个弃元记号占一个位置,这恰好表达和判断了 arr[^2] 的数据,而不是 arr[^1]。
2、预防性长度判断
和前文一致,要用这个模式的话,这个数据类型除了拥有 Length 或 Count 属性外,索引器成员是必不可少的。另外,如果你不写上范围记号 .. 的话,就成了判断恰好这些数据了。
if (arr is { Length: 10 } and [1, 2, 4, ..])
// ...
if (arr is [1, 2, 4])
// ...这两个写法的区别是,第一个因为 [1, 2, 4] 按照顺序,判断的都是前三个数据的数值,因此长度给出后,判断的自然是前三个数据了,而后续的数据不用管。但是第二个则不一样了。第二个因为长度模式不存在的关系,模式匹配的长度模式会依赖于 [1, 2, 4] 这个列表模式。这个模式只给出了三个元素,因此不写长度模式的话,编译器会认为这个写法下,长度模式是 { Length: 3 };相反,如果你加上了 .. 的话,编译器就不再去确认后面的元素信息了。
但是,为了避免抛出异常,C# 会贴心地做一个“预防性判断”。倘若 C# 不判断长度。那么如果 arr 没有这么长呢?假设 arr 就俩元素,那么判断 [1, 2, 4, ..] 就可能产生一个异常。因此,C# 会自动生成一条预判长度语句:arr.Length >= 3。
因此,如下三种写法的等价格式是这样的:
arr is { Length: 10 } and [1, 2, 4, ..]
arr.Length == 10 && arr[0] == 1 && arr[1] == 2 && arr[2] == 4
arr is [1, 2, 4]
arr.Length == 3 && arr[0] == 1 && arr[1] == 2 && arr[2] == 4
arr is [1, 2, 4, ..]
arr.Length >= 3 && arr[0] == 1 && arr[1] == 2 && arr[2] == 4
“默认生成预防性长度判断”这一点比较隐晦,因此你一定要记住。
3、注意使用条件
在列表模式里,我们约定数据类型必须包含索引器和 Count 或 Length 属性才可以使用列表模式,下面有两个最容易忽略也会被当成可以用列表模式,实际上不然的数据类型。
Dictionary<TKey, TValue>类型;ICollection<T>类型。
首先,字典数据类型的索引器并不是 int 数据类型,而是 TKey 这个泛型数据类型的。由于 C# 对列表模式的语法设计规则,所以字典无法表达出合适的写法,所以字典类型不支持列表模式;同时,ICollection<T> 类型也不支持。原因可能让人大跌眼镜:这个接口压根就没有索引器的成员。可能这一点非常容易忽略掉,但是也很好想到为什么:实现了 ICollection<T> 接口的数据类型我们都可以叫它们“集合”。但集合只表示和表达一种合适的存储序列。但序列不一定是连续的,也就是说,你不一定可以使用这个集合类型从前一个元素来找后一个元素。最常见的情况就是“不重复集合”。如果你写了一个不重复集合的话,它可能会使用哈希码来建立列表。那么元素之间就不一定是连续的了,因此你无法定义一种良好的索引机制获取每一个元素。这样的数据类型就不存在索引器一说。正是因为存在这样的集合数据类型不含有索引器,因此你无法对一个这样的数据类型使用列表模式,因为列表模式依赖索引器机制。
4、无条件成立的列表模式
当然,既然可以允许判断列表模式,那么自然就有 [..] 这种写法。这个写法的意思是,集合的元素无条件成立。那么,既然无条件成立了,自然也就无关内部的数据了。因此,[..] 等价于判空:obj is not null。
不过,为了可读性,我建议你写成 obj is { } 或 obj is not null,而不是使用这种花里胡哨的匹配写法 obj is [..]。而且,[..] 使用的是列表模式,而列表模式本身在使用上就有一定的限制条件,所以它还不如 not null 或 { } 的模式匹配通用性那么广。
5、列表模式不是递归模式的一部分
如题,列表模式是一个单独的模式,你必须声明得和别的模式串联起来用 and 或 or 连接,所以如下的语法是错的:
if (o is int[] [1, _, .., 3]) // Wrong.
// ...正确的做法是在 int[] 和 [1, _, .., 3] 之间插入一个 and:
if (o is int[] and [1, _, .., 3])
// ...这一点一定要记住,因为它是对集合作判断,但大多数类型也都不是集合,因此不要想着把它和别的模式放在一起;但是,列表模式允许定义内联变量。
if (expr is [_, _, ..] result)
// ...假设此时的 expr 是一个表达式,我们可以使用此语法定义表达结果,并视 result 为表达式的运算结果。
还有一个原因是,假设我们想要确定是否一个数据类型是 int[] 并且没有元素的话,它的语法是 is int[] and []。如果我们去掉 and 使之允许,这个语法就成了 is int[] [],这会被编译器视为 is int[][],即判断对象是不是一个元素是 int 的锯齿数组。因此,不加 and 还真不行。
6、空列表模式
虽然说我们知道列表模式已经可以进行集合元素的判别,但这里仍然有一个问题,空的列表模式 [] 究竟是个什么玩意儿。
空列表模式指的是一个集合里没有元素的情况。但这优先要求集合对象本身就不可空。如果集合都是 null 了,就没有必要谈及元素有几个了。这就好像厕所的厕纸没有了分为“没有厕纸”(没元素)和“放厕纸的托盘都没了”(空值)这两种情况一样。
但是,这对于引用类型而言,它还需要额外满足不可空的约束。也就是说,对于值类型和引用类型的集合,[] 的意思不同:
值类型集合:
[]直接表示没有元素;引用类型集合:
[]表示集合变量不空,且里面没元素。
于是,如果你看到 is not [] 的用法时,你应该优先判断它可空与否,而不是上来就这么用,这样会造成它为 null 也能通过判断,进而造成一些不必要的问题。
与此同时,由于我们已经充分了解到了错误的缘由,所以 is not [] variable 这个用法就不要随意使用了。初学的时候可能经常会以为 is not [] variable 的组合模式表示集合不空且至少有一个元素,但这表示的是集合为 null 或有至少一个元素,注意这里是“或”。
using System;
using System.Collections.Generic;
var list = (List<int>?)new();
if (list is not [] listVariable)
Console.WriteLine("foo");
else
Console.WriteLine(listVariable);例如这个代码,它的代码转译是这样的:
var list = new List<int>();
if (list is not null && list.Count == 0)
{
List<int> listVariable = list;
Console.WriteLine(listVariable);
}
else
Console.WriteLine("foo");尤其注意 not 模式外带一个变量定义(即声明模式)的复合理解。
7、嵌套级使用
虽然我们说它不属于递归模式的一部分,但这不代表它不能嵌套着使用。尤其会出现的情况是 [{ }] 和 [[]] 两种括号套娃。
第一种是判别集合的元素的属性。外层中括号为列表模式,而内层大括号则是该列表模式,得到的元素的属性模式判别;而第二种,则是集合元素本身也是集合的情况。
有人会问,[[]] 的嵌套形式,在什么时候用。其实不难想到:string[]。string[] 的元素 string 也是集合,它是 char 的序列。假设我要判定 string[] 的元素是否满足指定条件的话,我们可以这么做:
if (commandLine is [_, ['-', ..]])
// ...模式 [_, ['-', ..]] 就是一个嵌套的用法。它在判断 commandLine 变量(一个 string[] 类型的变量)是否由两个 string 元素构成,且第二个元素的第一个字符则必须为横杠 '-'。
分片模式(Slice Pattern)
1、语法
C# 允许对集合类型的分片。举个例子。
if (arr is { Length: 10 } and [_, .. var slice, _])
// ...这段代码表示,我们将中间的 8 个元素提取出来,变成一个列表。它等价于这个写法:
if (arr.Length == 10 && arr[1..^1] is var slice)
// ...一定要注意,1..^1 的语义是“取 [1] 到 [^2] 的元素,而绝对不是取到 ^1。因为 C# 的范围表达式是取前不取后的(前闭后开的半开区间)。
2、分片模式的弃元
分片模式也支持弃元。换句话说,我们可以在 .. 后跟上 _ 来表示这一截内容我们不参与模式匹配,它和不写弃元符号的时候匹配的内容是完全一致的,只是为了确保语法的灵活性:
if (arr is [1, 2, .. _, 3, 4])
// ...这里的 .. _ 的 _ 就是弃元用法。不过它没有意义,可以省略。
3、分片模式解构及嵌套模式匹配
分片模式允许我们对范围记号 .. 的内容进行内联变量定义,但这样的代码仍不够灵活。我还想要内联模式匹配的话,C# 是提供了这个机制的。看看这样的代码是什么意思?
if (arr is [_, _, .. [_, .., 7], _])
// ...是的,在模式匹配 [_, _, .. [_, .., 7], _] 的模式。这个模式要求我们列表集合里至少含有三个以上的元素。接着,.. [_, .., 7] 是分片模式的嵌套模式匹配。我们从第三个元素开始判断,我们必须要求从第三个元素开始,我们包含一个序列,它至少有两个元素,且最后一个元素是 7。换句话说,它等价于这样的代码:
if (arr is { Length: >= 3 } && arr[2..^1] is { Length: >= 2 } sliced && sliced[^1] == 7)
// ...注意这里的判断模式。我们用到了三个逻辑表达式,且使用 && 运算符连起来。下面来说一下挨个的具体判断内容。
第一个表达式
arr is { Length: >= 3 }判断整个数组是不是不为null的同时还至少包含 3 个元素。注意这里arr虽然显然不空,但编译器会自动确保引用类型不空而自动产生该is not null的模式匹配代码;第二个表达式
arr[2..^1] is { Length: >= 2 } sliced较为复杂。它先获取arr从第 2 索引元素(第 3 个元素)到倒数第二个元素(一定注意索引运算符是取前不取后的)。这个操作如果获取成功,则返回正常数组,否则会返回null。因此这个is { Length: >= 2 }验证了arr[2..^1]是否在取分片数组的时候返回结果是否不为null。如果不为空还要判断它的长度是不是至少包含 2 个元素。接着,如果两个判断逻辑都成功的话,那么sliced变量就可以使用了,它就是这个分片后的数组结果;第三个表达式
sliced[^1] == 7就是获取整个数组的最后一个元素,看看是不是 7。
所以整个表达式稍显复杂,但按顺序来看的话,就不会有任何问题了。
4、列表和分片模式的范围记号 .. 最多只能有一个
.. 最多只能有一个虽然有些时候我们很想写这样的代码:
if (arr is [.., 40, ..]) // Ah?
// ...我们的想法是,元素至少有一个,且整个序列里至少有一个元素的结果是 40。
可,C# 编译器并不理解这样的语法,它认为 .. 的长度不定,因此无法叠加使用,否则将无法确保使用的严谨性,比如这样的代码:
if (arr is [.., 10, 20, ..]) // Hmmm...
// ...这个 [.., 10, 20, ..] 就无法理解。而且,这样的代码也是没有意义的:
if (arr is [.., .., _]) // Also wrong.
// ...连续使用两个范围记号 ..。因此,请一定记住,列表和分片模式的范围记号最多只能有一个。不过,这种不影响:
if (arr is [.. [.., _], _]) // Correct.
// ...这种是分片之后判断序列的模式是 [.., _],因此这个是可以的,因为不会冲突。
5、分片模式里的模式嵌套
如果这个数组的每一个元素并不是简单的类型,那么它里面可能包含一些别的元素。这个时候我们可能会在分片后,使用别的模式进行模式匹配:
if (nestedArr is [_, .. [{ Prop: 42 } sliced, ..], _])
// ...比如这里 .. [{ Prop: 42 } sliced, ..] 就是一个典型的嵌套用法。.. 后跟上 [{ Prop: 42 } sliced, ..] 是一个嵌套进去的分片模式。其中,它判断分片后的序列至少包含 1 个元素,且第一个元素必须满足模式 { Prop: 42 } sliced,也就是 Prop 属性必须是 42。如果成功匹配,那么这个元素名称可以使用 sliced 标识符引用。
模模式(Cardinality Pattern)
模模式其实就是我们之前的 { Length: _ } 或者 { Count: _ }。这个其实就是属性模式的一种特例——属性名恰好是 Length 或 Count 而已,只是在 C# 11 里单独为这两个特殊的属性模式匹配的逻辑有所加强:它的代码内容会影响编译器分析代码。
这里稍微说一下命名。模模式的本名叫长度模式(Length Pattern)。在模式匹配里,C# 定义了
Length和Count可以提供和参与编译器分析非负性的属性名称,length 这个单词可以叫长度,但 count 不能。因为 count 记录的是元素总个数,你只能说“集合有多少元素”,而不能说“集合的长度”是多少。从定义和名称叫法上 count 和 length 有区别,所以本文改了一个说法。这个模模式的模就是长度的意思,而它恰好就具有两种不同类型 length 和 count 都可涵盖的含义,所以本文介绍的时候将其称为模模式。英语术语里的 cardinality 是基数的意思,指的是集合里有多少个元素;不过在计算机科学范畴,cardinality 除了翻译成基数以外,还可以被翻译为“势”。比如说两个集合等势就意味着两个集合的元素是一一对应起来的。从这个角度来说,把这个模式类型叫做 cardinality pattern 是最合理的。但是,由于我们从中文术语词汇角度出发,这里也用不着这么复杂的概念,所以我们只需要知道它的基本用法表元素总数就可以了;翻译的话,还是使用广泛使用的“模”来表示长度,就足够理解了。不过,如果你在查资料的时候,请仍然按照“长度模式”这个术语去查阅,本文只是单纯为了避免二义性才换的说法。
早期的长度模式只是一个简单的属性判断,但在 C# 11 里有了列表模式和分片模式,因此在集合里,长度的分析过程变得非常重要。
1、系统类型的长度属性均假设为非负数
C# 11 开始,系统提供的集合类型里,只要它带有 Length 或 Count 属性的话,那么编译器会自动假设它非负。换句话说,虽然我们大家都知道,Length 和 Count 属性返回值是 int,它有负整数的范围,但集合的元素总数是不可能为负数的(哪怕是空集合,长度也是 0,也并非负数)。因此,系统会假设这些数据类型(特别是一维数组类型需要注意)一定是非负长度。如果你使用如下代码,编译器会自动报错:
if (arr is { Length: -1 })
// ...并提示你,数组的长度非负,因此这个属性模式将永远不会匹配成功。
2、自定义类型的长度模式对编译器的表现行为
这里说一下编译器的处理机制。不论是你定义的集合类型,还是系统给定的数据类型里,长度的非负性质一直是一个正常的逻辑实现。虽然你可以随意给 Length 或 Count 属性设置为一个负数值,但它并不是正确的实现,因为长度在使用的时候一定是非负的,虽然它的返回值是 int 类型包含负数的数值范围。
使用 int 类型但不允许非负的原因是因为,uint 类型虽然能够保证非负,但它不符合 CLS(公共语言运行时)的标准,也就是说,这样的代码可能只能在 C# 上跑,而同一个 DLL 文件编译出来之后,VB 对这个 uint 不支持,因此无法使用这样的代码。因此为了兼容性,我们设置了 int 作为理想的返回类型。
而编译器会先查看这个数据类型是否同时包含一个索引器和一个 Length 或 Count 属性。如果同时存在,那么既然你都能索引了,这个类型的长度就一定不可为负数。但是,如果你没有索引器,但只包含一个单纯的 Length 或 Count 属性的话,编译器会认为它是普通的属性,因此不会验证负数情况。
所以总的来说,必须同时包含索引器和长度属性(Length 或 Count 其一)的时候,编译器才会假设长度属性的结果一定非负。
3、同时包含 Length 和 Count 属性的时候
Length 和 Count 属性的时候如果一个集合同时包含 Length 和 Count 属性的话,C# 团队的解决办法是,只验证其中的 Length 属性一定非负,而 Count 属性就不再会假设为非负的情况。举个例子。
_ = list is { Count: -1 };
_ = list is { Length: -1 };如果同时有这两个语句,那么只有 Length 属性会得到非负数的假设。而 Count 属性不论什么时候也不会被假设为非负的,因此这段代码里,如果 list 满足这个模式的判断条件(带索引器,同时带有两种长度属性)的话,那么报错信息只会出在第二行上,第一行是没有任何错误信息的。
字符区间模式(ReadOnlySpan<char> Pattern)
ReadOnlySpan<char> Pattern)从 C# 11 开始,ReadOnlySpan<char> 类型的实例将可以使用模式匹配来判断内部存储的字符串信息。
这个“字符区间模式”也是我个人翻译的说法,字符对应
char,区间对应ReadOnlySpan<>集合。这个ReadOnlySpan<>实际上就是一个区间,它可通过数组初始化new和栈内存数组初始化stackalloc两种方式初始化;而ReadOnlySpan<>更侧重于对这些元素的引用,因此它可以直接完成一些数组不太方便的行为,如ref索引、字符转字符串ToString方法等。
1、语法
要知道,ReadOnlySpan<T> 类型是一个非常轻量级的存储集合,它类似于数组,但数组是引用类型,该类型是值类型。它可以接收很多类型的实例作为存储信息,当然,也包含 char。因此,C# 对这个类型有特殊处理:如果是 ReadOnlySpan<char> 的话,那么可以接收字符串来赋值:
scoped ReadOnlySpan<char> strSpan = "Hello, world!";而 C# 11 开始,我们支持该类型使用字符串的模式匹配规则来匹配该类型的对象。
switch (strSpan)
{
case "Hello":
// ...
break;
case "world":
// ...
break;
case "Hello, world":
// ...
break;
}比如这么使用。
2、null 对于 ReadOnlySpan<char> 的奇怪表现
null 对于 ReadOnlySpan<char> 的奇怪表现请一定要注意,ReadOnlySpan<char> 是值类型,但你仍然可以赋个 null 过去:
scoped ReadOnlySpan<char> s = null;这是被允许的。原因是 null 在这里是 char[]? 类型的对象,而对于 ReadOnlySpan<char>,是有一个隐式转换的,签名长这样:
public static implicit operator ReadOnlySpan<char>(char[]? array);因此你可以这么做。但是,由于赋值之后的 s 对象是值类型,因此你不能对 ReadOnlySpan<char> 类型的实例使用 null 常量模式匹配:
if (s is null)
// ...这样是错误的。因为模式匹配(尤其是关于 null 的常量模式),编译器是不知道你这个隐式转换的。这里的 s 是值类型 ReadOnlySpan<char> 类型的变量,而它既然是值类型,就永远不可能为 null。因此,is null 将永远返回 false。所以,这么使用是不合适的。
综合
下面我们来讨论一些关于前面讲解的模式匹配的综合内容。
1、如何判断集合至少有一个元素
如下的这些模式都可以。
int[] arr = { 2, 3, 5, 7 };
// List pattern (Sorted by code length).
if (arr is not (not { } or [])) Console.WriteLine(arr[0]);
if (arr is { Length: not 0 }) Console.WriteLine(arr[0]);
if (arr is not (null or [])) Console.WriteLine(arr[0]);
if (arr is { Length: > 0 }) Console.WriteLine(arr[0]);
if (arr is { } and not []) Console.WriteLine(arr[0]);
if (arr is [.., _]) Console.WriteLine(arr[0]);
if (arr is [_, ..]) Console.WriteLine(arr[0]);
// Slice.
if (arr is [var f, ..]) Console.WriteLine(f);
// Pure check.
if (arr != null && arr.Length != 0) Console.WriteLine(arr[0]);
if ((arr?.Length ?? -1) != 0) Console.WriteLine(arr[0]);not (not { } or []):先看内层not { } or [],它表示要么not { },要么[]。not { }等于is null,而[]等于Length == 0属性判断,所以对整个模式取反not (not { } or [])就是not (null or { Length: 0 }),即不空且至少包含一个元素;{ Length: not 0 }:最基础的属性模式判断。不过这个用法稍微注意一下,如果集合不含Length属性而是Count属性,你可能需要改掉这里的Length名称;not (null or []):not { }就是null,所以和第一个写法等价;{ Length: > 0 }:和第二个一致,只不过这里是要求Length必须非负。但是 C# 11 开始模模式必须非负,因此语义上> 0和not 0是一致的了;{ } and not []:{ }是not null,not []则是Length == 0的属性判断,因此结合起来就是序列不空,且至少有一个元素;[.., _]和[_, ..]:这两个写法是等价的,不必多说——列表模式包含一个弃元符号和范围记号,因此序列至少包含一个元素才满足该要求,不过先写..还是先写_都无所谓,因为编译器都能识别。
整体来说,看你个人喜好来书写代码。它们最终是一样的代码,都是序列不空且至少有一个元素。
2、三种括号一起用
假设我们有一个类型,它使用了这样的模式匹配:
if (inst is () or [] or {})
// ...根据前面的知识,你可以猜测或者推断出,该类型的最小实现逻辑吗?换言之,你知道它允许这些模式匹配同时使用的时候,至少有多少个必需的成员存在呢?我们来想一想。
首先是 ()。() 代表的是它是一个元组,但不包含任何数值。如果要找到“最优解”,只需要表示出 inst 变量是不是 ITuple 的实现类型就可以了。只要它实现自 ITuple 接口,那么对象就可以支持和兼容 () 模式,不需要定义任何新成员。哪怕它不走 ITuple 派生,只要 inst 是 object 类型,那么这种模式匹配就是成功兼容的,就不会出现语法错误。
其次是 []。列表模式要求对象至少是集合类型,那么对象至少有一个带 int 单参数的索引器,以及一个 Length 或 Count 属性的其中一个即可。那么,这至少就需要对象有两个成员的实现。
最后是 {}。属性模式的唯一要求是,该类型不能是指针。因为指针类型永远都不包含任何判断属性。它只能取出其中的数值(*p)然后才可能有对应的属性。因此为了尽量包容和兼容前面的模式,那么我们假设 inst 此时是 object 类型。那么由于它不是指针,因此属性模式就可以使用(即使我们知道 object 里不包含任何可访问的属性信息)。
所以,要想满足三种括号一起使用的模式匹配的话,那么至少需要对象从语法上实现两个成员(Length 或 Count 其一,然后一个 int 类型的单参数的索引器),然后 inst 是 ITuple 的实现类型即可:
class T : ITuple
{
public int Length => throw new NotImplementedException();
public int this[int index] => throw new NotImplementedException();
object? ITuple.this[int index] => throw new NotImplementedException();
}3、过于复杂的递归模式匹配
考虑一种极端情况:
class S
{
public void Deconstruct(out S s) => s = this;
}这意味着什么?这意味着我在使用解构模式的时候会产生这样的代码:
var s = new S();
if (s is (({})))
// ...这个 (({})) 是一个嵌套模式,不过没什么特殊意义。我们拆开看看就明白了。首先 (({})) 最外层是一个 () 模式,它表示对象可以解构就行,因此它等价于 s is not null;然后里面一层是 ({}) 的 ()。它代表我在使用 Deconstruct 产生解构对象了之后又一次作判断。但 Deconstruct 是我写的一种极端代码:它解构了一个寂寞——返回了它自己。所以内层的 ({}) 的 () 还是跟原来判断信息完全一样。最后,最内层有一个空大括号,它表示空属性模式匹配,它依然和 o is not null 表达式等价,因此,完整的表达式和你写一个 s is {} 或 s is not null 没有区别。
你觉得好玩的话,我这还有一个例子。
class S
{
public int Length { get; }
public S this[int index] => null;
public void Deconstruct() { }
public void Deconstruct(out S s) => s = this;
}然后模式匹配:
var s = new S();
if (s is ([{}]) or [[[[]]]])
// ...蛇皮怪。
我们强烈不建议你这么写代码,我之所以讲这个是为了告诉你有这么一种特殊的情况。不过虽然没有这么多层级的嵌套,但经常会有括号嵌套括号的用法,两层还是蛮常见的。下面我们将模式匹配里使用过的三种括号类型进行组合,排列出 9 种情况,这些都是比较常见的模式匹配的嵌套书写情况,你的代码里可能就会被用到。你可以拿这个对照进行参考。
((var a, var b), var c)
将对象解构为两个值,用对位模式判断。
第一个值可继续解构,并仍然使用对位模式继续对位判断;
第二个值使用的是 var 模式
([var a, var b], var c)
将对象解构为两个值,用对位模式判断。
第一个值使用列表模式判断;
第二个值使用的是 var 模式
({ Property: var a }, _)
将对象解构为两个值,用对位模式判断。 第一个值使用属性模式判断; 第二个值使用的是弃元模式
[(var a, var b), var c]
使用列表模式判断对象是否只有两个值。
第一个值可解构为两个值,并都使用 var 模式;
第二个值使用的是 var 模式
[[var a, ..], [.., var b]]
使用列表模式判断对象是否只有两个值。
第一个值可继续使用列表模式判断,并只判断该列表里的其中第一个值,使用 var 模式;
第二个值也使用列表模式判断,且只判断该列表里的最后一个值,使用 var 模式
[{ P1: 42 }, [var p2], (p3: 0)]
使用列表模式判断对象是否只有三个值。
第一个值使用属性模式判断 P1 属性;
第二个值使用列表模式判断该列表是否只包含一个值,并使用 var 模式将该值取出;
第三个值使用对位模式判断 p3
{ Property: (var a, var b, _) }
使用属性模式判断属性 Property。该属性的结果可继续使用对位模式判断三个值。
前两个值都用 var 模式;
最后一个是弃元模式
{ Property: [var a, .., var b, _] }
使用属性判断属性 Property。该属性的结果可继续使用列表模式。
第一个元素和倒数第二个元素,都用 var 模式
{ Property: { Nested1: 42, Nested2: 0 } }
使用属性模式判断属性 Property。
该属性的返回值继续使用属性模式判断其中的 Nested1 和 Nested2 模式
4、对非类型模式和重名属性的辨识
考虑一种极端情况(虽说是极端情况,但我个人觉得还蛮普遍的)——一个类型里有一个属性,它自己的类型和它本身的名字一样,比如:
public Person Person { get; }实际上这种情况还蛮常见的。当我们将它用于一个模式匹配的时候:
var obj = new object();
if (obj is Person)
// ...
sealed class Person
{
}
static partial class Program
{
public const int Person = 42;
}你知道这里的 Person 指的是什么吗?是 Person 类型,还是当前所在 Program 类型下的 Person 常量呢?这种极端问题一般是不会出现的。事实证明,这个 Person 是实际的类型。
不过,它在用于逻辑模式里之后,情况就不太一样了。
var obj = new object();
if (obj is null or Person)
// ...
sealed class Person
{
}
static partial class Program
{
public const int Person = 42;
}当我们写出这种代码的时候,Person 在这里是常量,而不是 Person 类型了。哪怕你交换位置,写作 is Person or null,也不会改变编译器对这个机制的识别。你不得不追加一个额外的弃元模式来告知编译器 Person 是类型而不是常量:
var obj = new object();
if (obj is null or Person _)
// ...该机制你觉得反人类吗?实际上,Roslyn 团队认为这是反而比较起将 Person 视作类型来说,反而视作常量更加合理。请参考 GitHub 上 Roslyn 仓库的 #66841 的 issue 的回复(这 issue 是我提的,后来团队给我作出了这种设计的解释),以及 #62078 的同类型 issue。按照官方的解释的话,我翻译成中文给你看一下:
C# 7 才开始提供模式匹配机制。这种机制是新的语法机制,所以它需要确保一定的兼容性。
在提供基本的模式匹配的时候,
is只能配合类型使用;而如今,is也可以配合常量进行模式匹配。在一般情况下我们可以不考虑这个,因为is T会考虑为实际的T类型,而不是可以访问到的T常量,这是出于兼容早期语法的考量;但是从常量模式匹配出现之后,模式匹配可以用于匹配常量了,这时候就会需要优先考虑常量的模式匹配,这样的方式来避免编译器总是去将T识别为类型名。考虑一下,如果我们总是把T识别为类型名的话,那么常量模式就算是被 C# 制定出来使用,也相当不方便:你不得不引用它的所属类型(可能还需要写命名空间)。哪怕它就在你当前写代码的所在类型里,也不得不这样做,来避免编译器识别为具体类型。这相当奇怪。所以,出于兼容性和新版语法优先级的考虑,普通的类型模式会被识别为类型名称
T,而只要模式匹配的上下文不是普通的类型模式匹配(比如嵌套的模式匹配,或者是非类型模式的其他模式)时,这个T就会优先去查找常量名,以对抗常量模式引用常量名称的时候的不便。
另外,由于这个原因,如果你实现代码的时候,这里的 T 并非为一个常量,而是普通的属性字段等成员名称的话,由于 Roslyn 对语义分析的考虑,这里也不会因为语法不起作用而重新定位为类型名。也就是说:
sealed class Person
{
}
class Program
{
internal int Person { get; } = 42;
}在这种情况下,你在 Program 类型里实现代码的时候,出于编译器考量,假设你使用了 obj is 非类型模式 的写法,而且这里的非类型模式部分使用到了 Person,Roslyn 编译器就会定位到 Person 属性上去(因为字段和属性类型,都是成员名)。因此,编译器此时照样会报错,告知你这种写法不正确:因为常量模式匹配里的 Person 不是常量。
模式匹配的未来
C# 对模式匹配相当注重,因此所有添加进来的模式都是相当有趣且有意义的。在以后的 C# 版本里,C# 还会继续更进模式匹配语法,使得模式匹配更加好用。
有很多有关模式匹配语法优化的提案,下面列举一些:
is运算符重载(Overloadable operatoris):允许自定义operator is的语法来完成自定义is匹配规则;自定义模式匹配(User-defined Positional Pattern):允许方法等成员放在
is后进行匹配的规则;IEnumerable<T>列表模式(IEnumerable<T>Pattern):对于IEnumerable<T>类型由于目前没有索引器支持所以它不支持列表模式,该提案将它翻译成 .NET API 方法调用来支持列表模式匹配;or和not模式下的变量定义增强(Variables under Disjunctive Pattern):允许在包含or模式的两侧定义变量;索引器模式(Indexer Pattern):允许使用类似索引器的调用方式来判断属性规则的新匹配模式;
UTF-8 字符串字面量(UTF-8 String Literal Pattern):允许给字符串末尾加一个
u8或U8后缀,使得它是 UTF-8 编码的字符串(例如"Hello"u8这样的语法)。虽说这个跟模式匹配没啥大关系,但是以后说不定会创建单独的数据类型,于是可能会有针对于这种类型的模式匹配语法;out赋值模式(outPattern):允许在已经定义好变量之后,使用is out 变量模式来判断类型并在类型匹配时将变量往外侧转换和赋值。
稍微提及一下这些特性吧。
1、is 运算符重载
is 运算符重载允许用户自定义 operator is 的语法来完成自定义的模式匹配规则:
public class T
{
public static bool operator is(int flag)
{
return (this & flag) == flag;
}
}比如这样。然后你可以这么写代码:
if (obj is T(10))
// ...嗯对,长得就跟对位模式匹配一样,不过这里可以传入一定的参数,使得对象可以进行自定义的判断。不过这个规则和现有的对位模式语法冲突了,因此可能不会采用这样的语法,不过这个功能说不定以后会出现,只不过可能会换别的语法来完成。
2、自定义模式匹配
允许用户定义一个方法,使得该方法满足一定的模式匹配要求,这样的方法就可以放在 is 之后了。假设我这样写代码:
public bool Odd(this int i) => (i & 1) != 0;比如我定义了一个 int 类型的扩展方法,于是你可以这样写代码:
if (integer is Odd())
// ...是的,Odd 是方法名,然后参数表列已经被提前到 integer 这里了,因此不需写出参数;然后参数表列是空的,因此要有一对空括号。该提案也允许传参:
public bool HasFlag(this int i, int flag) => (i & flag) == flag;然后:
if (integer is HasFlag(0x1000))
// ...这样的用法。
3、IEnumerable<T> 列表模式
IEnumerable<T> 列表模式现有的语法规则约定导致 IEnumerable<T> 虽然允许 foreach 但不能使用列表模式,因为它没有 Count 或 Length 属性,也没有索引器。
说不定以后会针对于它有特殊的语法处理规则呢?
4、非 and 模式的变量定义
and 模式的变量定义现在的语法要求我们不能定义变量在 or 和 not 模式里,除了 not 模式是纯正的 not:
if (obj is int a or double b)
// ...
if (obj is not null or var a)
// ...
if (obj is not null and not var a)
// ...这些都不行。因为目前的 C# 类型体系不支持我们使用类似 int a or double b 这样的“两者选择其一”的语法规则。该语法在 C# 里是有提案的,叫做可区分联合(Discriminated Union),在别的编程语言里有,比如 F#。但是,C# 并不支持该特性。
如果有了这个玩意儿的话,想必上面的这种定义模式说不定就有可能存在了。
5、索引器模式
索引器模式的用法其实可以不必多说,因为看名字估计就可以猜到了。它和 C# 11 的列表、分片模式不同,索引器模式是一种和属性模式语法类似的匹配模式,但它可以更为灵活地处理和判断指定索引上的数据的模式。
举个例子,我要判断第 12 个位置上的数据的话,如果按照列表模式的语法的话,就会非常不方便:我要定义一大堆的 _ 弃元符号来占位;但是用索引器模式就可以很方便地描述出来:
if (obj is { [12]: { } valueAtIndex12 })
// ...我们使用 [12] 来表示取 12 号索引器的规则。另外,这个提案包含了扩展属性模式的语法,因此索引器模式的提案自然包含所谓的“拓展索引器模式”或者是“索引器模式和属性模式的混用语法”。
if (obj is { [12].NestedProp: not null })
// ...假设我有这样的语法,[12].NestedProp 是啥意思呢?就是 [12] 索引位置上的元素的 NestedProp 属性的数值是多少。所以,它的完整写法是:
if (obj is { [12]: { NestedProp: not null } })
// ...当然,如果连续的索引器模式放在一起,提案没有规定语法,不过我猜测的语法是使用小数点连接。
if (obj is { [12].[3]: { } valueAtNestedArray })
// ...比如这样的语法,[12].[3] 模式表示的是原本对象的第 12 号索引上的对象。假设这个取出的对象还可以继续使用索引器的话,那么再次对其使用 [3] 模式可以得到对应的对象结果,进行模式匹配。所以这样的语法等价于:
if (obj is { [12]: { [3]: { } valueAtNestedArray } })
// ...是这样的。当然,提案包含分片的模式规则,所以还可以这样:
if (obj is { [3..^2]: { Count: >= 10 } slice })
// ...类似这样的逻辑。
6、UTF-8 字符串字面量的模式匹配
C# 11 开始支持 UTF-8 字面量。所谓的 UTF-8 字面量,指的是一个字符串,里面只能包含 UTF-8 格式的字符,构成的字面量形式。它们每一个字符只占据一个字节的内存空间大小,和目前的 char 类型占据两个字节形成对比。
C# 11 开始允许使用 u8 或 U8 后缀,对字符串的字面量进行修饰。现在 C# 11 支持如下这些字符串的 UTF-8 字符串字面量形式:
普通字符串字面量:
"hello"u8;原义字符串字面量:
@"hello"u8;原生字符串字面量(C# 11 起可用):
"""hello, world!"""u8。
这些字面量形式均可使用 u8 或 U8 后缀。将其改造为只占据单字节的字符的字符串信息序列。
现已知的是,C# 正在考虑这个特性,但是目前因为没有此类型可以对应和兼容该字符串,所以目前这种字面量是直接赋值给 ReadOnlySpan<byte> 或 byte[]? 类型的。毕竟,UTF-8 格式的话,只有 256 种情况,这刚好兼容的是 byte 类型的范围。你想想,C 语言里 byte 没有这种类型,不也用的是 unsigned char 来代替的吗?它们是可转换的。
和前文介绍的 ReadOnlySpan<char> 模式匹配语法类似,C# 有望支持对这种字符串的模式匹配规则。不过目前来说,由于特性还在考虑之中,因此还需要进一步的研究和提案,因此暂时并不支持对此类型的字符串支持模式匹配的规则。
对于以后,模式匹配可能会有令人惊艳的用法和规则,让我们一起期待这一天的到来。
7、out 变量赋值模式
out 变量赋值模式这个提案的赋值模式特别有意思。考虑如下的代码:
object obj = 123;
string name;
// Does not match as the value is not compatible with string.
if (obj is out name)
// ...这种模式约束和规定,在判断 obj 的具体类型,必须和 out 后跟的变量的类型一致。如果一致的时候,才会往里面赋值。它等于这样的语句:
if (obj is string tempName)
name = tempName;这样的定义有一个弊端是,它会多出来一个变量的定义,而该变量是没有意义的:它不必定义出来。但是,如果你去掉的话,你又必须使用强制转换:
if (obj is string)
name = (string)obj;两种用法都是复杂的。所以,该提案制定了一种新的规则:is out 模式,在 out 后跟一个变量,而该变量的类型就决定了 is 左侧的判断表达式的匹配类型。如果类型是一致或兼容的的话,那么赋值过程就是成功的。
该提案有一个问题在于,name 在如果没有初始化的状态下,进行 is out 匹配,那么成功或失败就会导致 name 是否被初始化会更加难以预测。编译器对于这样的分析是痛苦的,这是需要克服的问题。可能该提案尚未完善,对于这样的规则可能使用一些其它的规则去补充漏洞,例如匹配失败时会自动往该变量赋值 default(T),其中的 T 是这个变量的类型。那么这样的话:
var temp = obj is T variable ? variable : default;类似这样的语句,就可以简化一下了:
T temp;
_ = obj is out temp;通过弃元来放弃对 is out 模式匹配成功和失败的最终布尔数值结果,然后对 is out temp 的方式来进行 obj 的转型和赋值。
总结
下面列举前文所有模式匹配,每一种模式在 C# 里出现和允许使用的版本号。
声明模式
C# 7
expr is T v
var 模式
C# 7
expr is var v
常量模式
C# 7
expr is 0
对位模式
C# 7
expr is (_, 1, "")
解构模式
C# 7
expr is var (_, second, _, last)
弃元模式
C# 7
expr is (_, _) or [_] or { Prop: _ }
类型模式
C# 7
expr is T
属性模式
C# 8
expr is { Prop: _ }
关系模式
C# 9
expr is < 100
逻辑模式
C# 9
expr is > 0 and < 100
括号模式
C# 9
expr is not (var value and not 0)
扩展属性模式
C# 10
expr is { Parent.Nested: _ }
列表模式
C# 11
expr is [1, _, .., _, 3]
分片模式
C# 11
expr is [1, _, .. var slice, _]
模模式
C# 11
expr is { Length: not 0 }
仅为编译器分析,不是新语法。
字符区间模式
C# 11
readOnlySpanExpr is "Hello"
最后更新于