1. 实例化类型推断(Target-typed new)
我们会使用 new 关键字来实例化,但在部分字段和属性声明的时候,这些类型已经是在旁边给出,且不能使用 var 代替的。因此,我们必须这么写:
public Person XiaoMing { get; private set; } = new Person();
例如上面的类似写法。可以看到,实例化里必须要写上类型,但这个类型名称在上下文里已经给出,所以不必去写应该也知道,但 C# 一直没考虑省略的问题。直到 C# 9,这个特性才提上日程:
public Person XiaoMing { get; private set; } = new();
比如这样。
2. 基于平台的整型(Native integers)
C# 提供了两个新的关键字 nint 和 nuint 用来表达底层平台的 int 不确定(随机器位数大小变化的 int 类型)。
因为 C# 的 int 和 uint 都是固定的 4 字节数据,所以是不可变的。当我们不得不转换到底层(调用底层函数)的时候。
3. 模式匹配 3.0(Pattern matching improvements)
从 C# 7 开始,C# 开始支持模式匹配。模式匹配说白了就是基于数据的具体类型进行分情况讨论的一种用法。当一个类型需要从模糊转具体的时候,就需要模式匹配来判别具体的类型。C# 早期有 is 可以判断数据类型,有 as 可以进行尝试转换。但 C# 一直觉得这些写法还不够精简,所以发明了模式匹配的格式。
来看看 C# 每一个版本的模式匹配吧:
- 模式匹配 1.0(C# 7):类型判别和转换一条蛇服务:o is T t、null 校验:o is null、switch 匹配类型;
- 模式匹配 2.0(C# 8):类型属性和字段匹配模型(递归模式):sunnie is { Age: 24, Gender: 'm' }、switch 表达式风格的类型匹配。
C# 9 开始添加两种新的模式匹配模型:and/or/not 匹配模型和数据范围校验。
(1)and/or/not 模型
C# 新增了三个关键字:and、or 和 not。它们专门用来检测和校验数据的具体类型,以及数值是否是正常结果。与此同时,也可以校验递归模式。
因为用法比较多,所以这里就阐述其中一部分的用法:
bool b1 = o is Person and { Age: 24 } sunnie;
if (b1)
{
bool b2 = sunnie is { Age: 24 } or { Gender: 'm' };
if (b2)
{
bool b3 = sunnie is { Age: not 24 };
bool b4 = sunnie is not null;
bool b5 = sunnie is not ({ Age: 80 } or { Gender: 'f'});
}
}
(耸肩)看不懂对吧。
其实也不难。b1 是判断 o 是不是 Person 类型的。如果是,再看 Age 是不是 24。如果是,sunnie 才被正常转换过来。换句话说,b1 为 true 的时候,sunnie 变量必定有值(不是未赋值状态),且 Age 属性肯定是 24 这个结果。
b2 是看 sunnie 的 Age 是 24 或者 Gender 是 'm'。只要满足至少其中一个,就可以了。
b3 是看 sunnie 的 Age 不是 24 才满足要求,bool 才是 true,b4 是看 sunnie 不是 null 才满足要求,而 b5 是看 sunnie 的 Age 既不是 80,Gender 也不能是 'f',b5 才为 true。其中 b5 最复杂。不过你可以用数学上的取反来看:“非 A 且 非 B”的等价写法是“非 (A 或 B)”,所以 b5 也可以这么写:
bool b5 = sunnie is not { Age: 80 } and not { Gender: 'f' };
或
bool b5 = sunnie is { Age: not 80 } and { Gender: not 'f' };
但是需要你注意的是,模式匹配所比较的数值都必须是常量,即这里的 24、80、'f' 等。
稍微复杂一点的就是看 not 的推断了。我们可以认为,用大括号或者小括号一起判断的内容是且的关系,但对这样的条件取反就比较难理解了:
bool b = a is not { Prop1: < 20, Prop2: >= 40 };
这样的条件意味着什么呢?我们拆解语句,先把 not 改成可以看的办法:
bool b = !(a is { Prop1: < 20, Prop2: >= 40 });
然后取反。
bool b = a.Prop1 >= 20 || a.Prop2 < 40;
这里稍微注意一点的是,大括号连接表示 and,在取反时将改为 or,所以需要分开写。
(2)数值范围校验
除了这些判断方式外,C# 9 还允许在不知道数据类型的情况下对数据大小进行校验:
这个就表示,当 o 是 int 且范围是 10 到 20 之间、或是 float 且范围是 (float)10 到 (float)20 之间、或是 decimal 且范围是 (decimal)10 到 (decimal)20 之间,这三种情况之一,b 就为 true。
bool b = o is
> 10 and < 20 or
> 10F and < 20F or
> 10M and < 20M;
(3)or 模式不能把类型校验变量内联到 is 校验里
我们刚才的 and 内联就可以将条件成立后的结果直接写到校验表达式 is 的最后。实际上 not 也可以:
if (o is not int i) { // ... } else { // Code can using 'i'. }
因为它完全等价于
if (!(o is int i)) { // ... } else { // Code can using 'i'. }
4. 静态 Lambda(Static lambdas)
当 Lambda 表达式不捕获数据的时候,我们允许使用 static 修饰 Lambda,来确保以后修改 API 和代码的时候,该 Lambda 不能捕获任何外部的数据。
Func<Person, int> selector = static (person) => person.Age;
另外,它也可以应用于静态的匿名函数上。
Func<Person, int> selector = static delegate(Person person)
{
return person.Age;
}
这一点就是从 C# 8 出现的静态本地函数那边学来的。
5. 记录类型(Records)
(1)基本用法
记录类型是一种特殊的引用类型,它用来简化我们书写代码的代码量,同时达到了灵活程度。假如我们要定义一个轻量级的类型,这个类型就用来记录数据的结果信息的时候,我们就可以使用记录。如果我们这个类型假设带有 A、B、C 和 D 四个属性,我们直接写 A、B、C 和 D 要写四遍属性,然后初始化的时候要给这个类型传四个参数的构造器,对应赋值到这四个属性上;而且如果我们要重写 ToString 和一些比较函数的操作的时候,还得自己写。
这太麻烦了吧!所以 C# 9 里就简化了这些步骤,我们直接用一个 record 关键字就可以了:
public sealed record R(int A, int B, int C, int D);
你看,这么写是不是很简单?在编译后,R 会展开,把小括号里的四个参数直接输出变为属性,R 也会直接改成 public sealed class R,里面包含了很多已经帮你写好了的方法:ToString、Deconstruct 和 Clone 等等。
如果你不想要这些已经自动实现好的方法,你可以自己手写,不过请注意,Clone 方法不能重写,它在记录类型里有特殊用途,这一点我们在之后的特性 with 表达式里讲。
就像上面这样,把自动属性写在小括号里,这种我们叫做对位记录(Positional record)。
更广泛地,对位记录可以没有自动属性,所以小括号里没东西,所以干脆就不让你写小括号了,所以直接这么写:
public sealed record R;
写了后面的空小括号反而会报错,告诉你:一个对位记录必须带有至少一个自动属性在小括号里。
(2)记录的继承关系
就现在来说,记录类型只能继承自一个记录。但最终被翻译为 class 的缘故,也有很多声音想要让团队去支持 record 继承自普通类的,或者普通类继承自 record 的,不过这一点我们只能耐心等待了。
(3)它能做哪些用?
如果你把它当成一个普通的类的话,我想你应该就懂了:只要类能做的,它基本上都能做,而且还能简化书写的代码量,唯独就是这里的 Clone 你不能重写。记录类型甚至都把 operator == 和 operator != 都给你重写好了。
因为是简化类类型的书写才有的记录类型,所以它支持 sealed、abstract 等绝大多数修饰符,但 static 不行。因为 static class 用来提供静态操作的,但记录类型不是,它专门记录一个执行操作的效果,封装一个结果类型才出现的,所以没有 static record。
6. with 表达式(with expression)
(1)基本用法
为了解释记录的原理,C# 专门提供了两个语法用来表示它们,一个叫 with 表达式,一个是 init 赋值器。我们先来说一下这个语法。
with 用来拓展一个记录,使得新的记录可以独立于原类型,并且在此之上更改和增加数据成员的数值:
Person p = new() { Name = "XiaoMing", Age = 24 };
Person q = p with { Name = "XiaoWang", IsGeBi = true };
我相信你可以看到这段代码不用解释就能理解意思。
对!就是直接给 q 赋值 p 的所有数据后,在此之上更新 Name 和 IsGeBi 的数值信息。
(2)with 的原理
我很高兴看到了这个语法机制,但我不得不说一下它的具体执行行为。with 关键字在后面跟上一个对象初始化器,以完成对新的数据的更新。当然,没有更新的数据就保持原本的数值,比如这里的 q 的 Age 还是 24。那么它是怎么实现的呢?
还记得前面点到了一个内容吗?记录会默认实现一个不可修改的方法 Clone。这个方法为啥不可修改呢?因为这个方法是用来给 with 用的。换句话说,记录类型也是一个鸭子类型,只要你实现了 Clone 方法就能用 with,因为内部会调用 Clone 方法后,才会修改掉那些具体的数值。如果 C# 让你修改 Clone,整个执行操作的意义都变化了,以至于 with 的意义也发生了变化。C# 为了避免你这么做,就定了这么一个语法盐,不让你去动 Clone。
7. init 赋值器(init setter)
(1)基本用法
第二个用来理解记录类型不可变的语法就是这个 init 关键字了。init 和 get 还有 set 这两个关键字被定义成完全平级的关键字,你可以当成一个特殊的 set 块,只是这个块在初始化就用得上一次以外就不可再修改了。比如说
public class Person
{
public char Gender { get; init; }
public int Age { get; init; }
}
这个和 set 的使用语法相当类似,初始化器也能用:
var p = new Person { Gender = 'm', Age = 24 };
但你再次修改就不行了:
p.Age = 25; // Wrong.
这一点和普通类的 get 加 set 有区别。
另外,既然 set 和 init 都是用作赋值行为(作为赋值器),所以不能同时出现,一次就只能有一个。
(2)那么它和 get-only 的属性有啥区别呢?
前文说了 get; init; 组合和 get; set; 组合的区别,下面来说一下 get; init; 和 get; 的区别。
get; init; 可以让属性赋初始值,而 get; 也可以。举个例子:
public class A
{
public A(int age) => Age = age; // Correct.
public int Age { get; }
}
public class B
{
public B(int age) => Age = age; // Correct.
public int Age { get; init; }
}
这么来看,它们是没有区别的。唯一的区别就是初始化赋值的时候。前者只有 get 所以不让用对象初始化器;但后者就不一样了,后者保证了对象可以在初始化的时候赋值一次,而这个“初始化”可以通过构造器本身,也可以对象初始化器。这一点来说,只有 get 的属性是不能用对象初始化器的。
(3)init 是咋实现的?
嗯哼,这个底层原理可能会让你很失望,因为……确实很简单。
一个 get; set; 组合的属性,在底层被翻译成了一个字段、一个取值方法和一个赋值方法。它的代码是这样的:
private int _age;
public int Age
{
get => _age;
set => _age = value;
}
而 init 的呢?
private readonly int _age;
public int Age
{
get => _age;
set => _age = value;
}
发现区别了吗?对了,底层字段多了一个 readonly 而已。
这就很奇怪了朋友们。这样的话 set 依然是随意赋值的,那它是怎么保证你在其他地方随意赋值的时候报警告信息的?
答案其实很简单。带 readonly 的内部字段,那么 set 就可以改成 init,因为 readonly 字段保证了字段只在初始化的时候提供修改和赋值,之后就一直保持不变。这不是就是完全类似于 init 的行为吗?
是的,要说区别呢,就只有一个,readonly 是只能在构造器里赋值;而 init 可以在初始化器里,也可以在构造器里。不知道你想过一个问题没有,有没有 public readonly 组合?有对吧,那么你可不可能从外界来为这个对象赋值呢?显然就不可能了。因为它们都是在类内部初始化的,要么在它旁边直接赋值,要么就是构造器了。
那么,还是没解释怎么防止赋值的啊。实际上,这一点是从编译器层面搞定的。它检测你的赋值行为,是在初始化器里还是其他别的什么地方。只要是别的地方,肯定就是不允许的,自然就会报错了。
(4)啥时候用 init 呢?
你去思考一下,init 是为了配合记录类型才出现的一种语法,这也就意味着它在记录类型里就可以体现出非常神奇的语义。
记录是用来存储一些结果数据信息的,那么这些数据肯定是单纯用于显示和输出的,所以在初始化后它们肯定不能修改,即不可变。所以,在我们平时的编码过程中,只要对象专门用来表示初始化后不再变更的行为,就可以用 init 来替代 set。