C# 10 特性一览
Part 1 结构记录类型
C# 9 有一个新的数据类型,叫做记录(Record)。这个类型是一种特殊的引用类型,我们只需要给出一个东西的具体属性,就可以自动为这个类型生成指定的比较器(Equals
方法、比较运算符 operator ==
和 operator !=
、GetHashCode
方法,甚至是 ToString
方法等等)。
举个例子,我们可以这么写:
这就等价于一个类 Point
,然后生成 X
和 Y
属性,以及相关的方法:
这样的东西。你看看,就写一句话就能生成一系列的内容,是不是很方便。
不过,Point
这样就好比把前文的 sealed class
改写成 struct
record
或者 record class
来表示一个类的记录类型。
前文用到的
init
属性是 C# 9 里诞生的、用来表示属性只在初始化器和构造器里才可赋值的一种属性赋值模型。它比set
的使用范围要少,set
随时随地都可以赋值(最多加一个访问修饰符,但并不能阻止内部的任何时候的赋值)。init
仅允许初始化器和构造器里使用赋值,其它任何地方都不能赋值。这样做就避免了很多地方的安全问题,同时也提供了一种语法上的约束。
Part 2 全局 using
指令
using
指令。
你只需要在单独的文件里写上这么一句话:
global using IntegerList = System.Collections.Generic<int>;
那么,整个项目就都可以使用这个 IntegerList
了。
Part 3 namespace
指令
在 C# 10 里,你可以使用一句类似 using
指令的形式,对文件整体进行声明命名空间,这样的话,后续的内容就不用再用大括号了:
这样就等价于经典写法:
namespace
指令后,就不可在文件里多次声明嵌套或并行的命名空间了。换句话说,这种写法仅仅是一个语法糖。
Part 4 内插字符串扩展
该特性实际上有两个小的子特性。下面我们来说下。
Part 4-1 推广到集合类型的 params
参数
在早期的 C#,params
参数后的参数类型必须是数组。由于数组本身比较笨重的缘故(是引用类型),所以就很不利于扩展性能。因此,C# 10 开始允许用户使用 params 集合
的模式来声明参数。比如说
如例子所示,我们允许参数使用 params Span<string>
目前,C# 团队打算允许如下一些集合类型作为 params
的依托:
params T[]
(经典写法)params IEnumerable<T>
params Span<T>
params ReadOnlySpan<T>
另外,按照次序,如果有这样四个方法的重载的话,C# 会优先采用效率高的 ReadOnlySpan<T>
类型作为调用方,然后是 Span<T>
、T[]
,最后才是 IEnumerable<T>
。
其实也很好记。
ReadOnlySpan<T>
和Span<T>
一定是效率最高的,因为它们仅存储在栈内存里;但是,ReadOnlySpan<T>
不可变,所以相较于后者来说,它更为安全;接着,T[]
和IEnumerable<T>
里,显然是数组更好用,然后才是迭代集合IEnumerable<T>
,因为迭代集合要使用很复杂的迭代器模式来迭代,是一种耗费性能较高的类型;但为了可读性,C# 依旧允许了这一个类型作为params
参数。
Part 4-2 更高效、不装箱拆箱的内插字符串
在允许了前文给的特性后,内插字符串就可以使用更为高级的语法,来避免装箱和拆箱了。在 C# 6 里,内插字符串会被自动翻译为 string.Format
的调用。而很遗憾的是,这个方法的参数都是 object
类型的可变参数序列,因而值类型在传入的时候必然导致装箱,损失了性能。在允许了 4-1 特性后,string.Format
就有了新的实现方式:
这里的 Variant
类型在 C# 团队实现后才会给出。这里你可以理解成一个“任何类型都可以兼容的值类型”。换句话说,任何类型都可以转这个类型来存储,类似 object
,只是这里用 Variant
表达出来可以避免装箱(因为是值类型);至于引用类型,传过来就会被改成地址什么的,总之不会装箱就对了。
其它的问题和解答,你可以查看(英文原文档)。
Part 5 无参值类型构造器
C# 一直以来就存在一个备受争议的语法点:值类型的无参构造器到底是否有必要禁止用户自己声明。C# 团队是这么想的:由于值类型是作为基本数据类型的实现体现(比如 int
这些数据类型),那么它们必然就有赋值和构造的默认权力。如果给用户用的话,用户就有可能会误用无参构造器,导致初始化的问题。因此,C# 的引用类型和值类型的无参构造器表现的行为不同:值类型的是永久都存在的,且你无法自己定义;而引用类型的无参构造器在没有构造器的类里会自动生成,有构造器的话,就不会自动生成,需要你自己定义。
那么,C# 团队考虑了很久,终于想通了。用户开始可以自己定义无参构造器了。
Part 5-1 真·无参构造器
如代码所示。
这个例子展示了无参构造器的使用方式。显然,跟引用类型是基本一样的,只是多了一点:如果你不定义的话,默认会生成一个无参构造器(S0
结构,y
会被自动赋值默认数值 default(object)
,也就是 null
);但一经定义,就必须给所有没有赋值的字段和属性赋值。
当然,此时的构造器既然允许自定义了,这样就使得构造器可以定义和修改访问修饰符了。如果访问修饰符设置为 private
的话,那么外部就无法使用 new
来实例化该类型的对象了。这一点和引用类型还是一样的。
Part 5-2 结构的字段初始化器
可以看到,这个特性一旦出现,就相当于诞生了另外一个特性:结构的字段初始化器。C# 早期同样是不允许你给字段设置默认数值的;相反,你必须在构造器里赋值,还不能是默认的无参构造器里。
Part 5-3 default(T)
表达式
另外,由于值类型和引用类型的默认数值不同的关系,定义了无参构造器必然会影响到它的默认数值 default(T)
表达式。实际上真的是这样吗?并不是。还是拿 Point
类型举例。即使你给出了默认构造器的调用,default(Point)
依旧还是原始数据的原始数据类型的默认数值构造成的实例的结果。
那么,default(Point)
还是 Point { X = 0, Y = 0 }
,而不是 Point { X = -2147483647, Y = -2147483647 }
我们把“原始数据的原始数据类型的默认数值构造成的实例”叫做零初始化实例(Zeroed Instance),那么,default(T)
的定义就可以缩减为“该类型的零初始化实例”;换句话说,该类型的零初始化实例就是这个值类型的默认数值。那么,使用 default
表达式的时候,就算你定义了无参构造器,编译器也会始终忽略它。
Part 6 参数的 nameof
表达式
我们经常会考虑到这样的使用场合:
由于特性在方法外部,因此无法直接使用 obj
这个名称。传入字符串有时候依然不方便,因为你修改了 obj
的名字之后,上方特性传入的 "obj"
这样就很方便了。
Part 7 增强对是否为 null
的对象的代码分析
C# 8 诞生了可空引用类型的概念,并提供了基本的分析模型。但很遗憾的是,很多时候,编译器依旧无法识别对象已经不可能是 null
的情况,进而产生语义分析上的 bug。C# 10 提出了增强分析的概念,这样的话,很多原本是 bug 的情况就得到了解决。举个例子。
比如这个例子下,c
不可空后,调用 M
方法后,如果返回值为 true
的话,参数 obj1
就不能为空了。而编译器暂时无法识别这种变量的传递(最开始是从 c != null
开始的),因此分析这个地方的时候,obj1
仍然不知道是不是为 null
。
C# 10 会对这样类似的场景的分析进行修复。
顺带凡尔赛一波,我提了一个属于这个特性主题的 issue,它们也放在了这个提案里作为解决对象。
Part 8 ref
和 partial
关键字的顺序
在诞生了 ref struct
这种类型之后,C# 由于没有考虑到语法的灵活性,因此如果 ref struct
是分部类型的话,就必须写成 ref partial struct
,而调转顺序 partial ref struct
则是错误的写法。
C# 10 将对这个问题进行修复。
Part 9 参数可空性验证
!!
如果一个参数为 null
,我们期望使用 throw
语句来产生异常信息。于是,代码大概就长这样:
str
参数声明上,这样的话,if
Part 9-2 对于值类型和可空引用类型的可空校验
当然,这个特性用在如果你没有对项目启用 nullable enable
的地方。因为我们能把代码写成 string? arg!!
就没有必要为类型追加这个冗余的可空标记符号 ?
。既然对象一旦为空就抛异常,那么我们何必写成 string?
呢?写成 string
不好吗?你说是吧。
另外,值类型本身就不可能为 null
了,我们何从谈起对值类型使用 !!
呢?当然了,Nullable<T>
除外。不过,对可空值类型使用 !!
的话,还不如不用可空值类型,直接不让它可空不就行了。
所以,总的来说,只要是对可空类型(包含值类型、引用类型这两种)使用 !!
的话,编译器都会产生警告,然后提示你“没必要这么用”。
Part 10 调用方表达式特性
在 C# 5 的时候,诞生了调用方参数特性。我们可以使用可选参数配合 CallerMemberNameAttribute
等特性标记到参数上面,这样的话,运行时就会自动把该数据传入到这个参数上去(比如 CallerMemberNameAttribute
会把调用方方法名传过来)。
现在,C# 10 里新增一个调用方表达式特性,这样的话,就可以把参数上的表达式传过来了。
Part 11 泛型特性
我们目前用到了很多需要传入一个或多个 Type
C# 10 里允许泛型特性的支持,我们就可以使用这样的语法传入一个泛型,带一个类型的方式,就不必再考虑这种性能问题了。
这样就比起经典写法 [DebuggerProxy(typeof(ComplexValueDebuggerProxy))]
要好看不少。而且,特性里的属性就可以直接使用泛型参数替换 object
来表达了,确实很方便。
Part 12 解构 表达式
如果我们有这么一句话在 C# 里是允许的话:
你觉得,arg1
和 arg2
会是多少呢?C# 10 里将允许这个写法,这样的话,arg1
和 arg2
就会默认赋值 0。
Part 13 内插字符串常量
C# 6 诞生的内插字符串并不能直接当成常量使用。举个例子:
B
C# 10 开始允许这一点,内插字符串也可以认为是常量了;当然前提是,内插的部分也都得是常量才行;而且内插的对象必须也得是 string
才行。
Part 14 元组表达式里内联变量定义
这句话不好理解。举个例子。
假如,name
是本身就有的东西(它可能是属性,或者是字段,或者是临时变量),而 age
student
变量解构了的话,由于 age
的定义变量语句无法写到赋值语句里面去,所以只能分开成两行书写。
C# 10 将允许你内嵌定义语句到值元组赋值的语句里去。
这样就合二为一了。name
照旧赋值,而 age
Part 15 模式匹配 IV:集合模式
从 C# 7 开始,模式匹配就是一种特别高大上的语法,搞得别的编程语言纷纷效仿。C# 7 里允许了在 switch
语句里使用 when
从句,并同时允许了 is
表达式里内联变量定义;C# 8 里允许了递归模式匹配;C# 9 里则又多了 and
、not
和 or
的逻辑模式匹配。C# 10 这次带来的是集合模式匹配。
集合模式匹配可以对集合的元素进行解构、处理和判别。
Part 15-1 长度模式
长度模式听起来好像是在判断集合的长度,但是这一点不是可以用 Length
属性的递归模式作为判断对象吗?是的,长度模式并不是这个意思。
长度模式用中括号来获取数据元素,然后通过冒号和模式来表达一个对象的指定索引位置上的数据是不是满足这个模式。举个例子:
当 arr
必须是带 Length
或 Count
属性的类型,且拥有一个以 int
arr
数据的第 3 个元素是一个整数,且不是 -1 的时候,满足条件。
如果 arr
在这里不能确定和断定是不是包含 Length
或 Count
属性,或者是 int
类型作参数的索引器的时候,那么编译器就会告诉你,arr
由于无法断定类型,所以无法使用该模式来对数据进行校验。
上方语法等价于 arr[2] is int val && val != -1
。你可能不一定能理解,为什么 arr[2] is int val
这句话要写出来。因为 arr
的元素类型尚不清楚,换句话说就是,arr
可能是一个 object?[]
,所以无法直接取数据;而使用 ==
会引起数据比较的错误:因为左侧是 object
类型,右侧是 int
类型,等号就会认为是俩 object
的引用比较,所以是错误的用法。
总结一下。只要类型:
有一个
this[int]
的索引器,且必须有get
语句;有一个叫做
Length
或者是Count
的属性。
那么类型就可以使用长度模式对具体某个元素作匹配。
Part 15-2 集合模式
长度模式我们说完了,接下来说一下集合模式。当长度模式要连续拼接多个元素判断的时候,长度模式就显得很麻烦了。那么,集合模式就出现了。
obj
的类型就开始判断对象的数据了,显然是很麻烦的。C# 10 提供了一种轻快的语法:
我们来说一下,这个写法是啥意思。首先,{ 1, 2, 3, .. }
..
在 C# 8 里就有,它表示取序列的一部分。在模式匹配里(就是这里),这个记号表达的是“后面还有别的数据,不过我们不作验证了”。换句话说,{ 1, 2, 3 }
和 { 1, 2, 3, .. }
是两个不同的意思:前者表示必须序列只有三个元素,且必须依次是 1、2、3;而后者则可表示元素至少有三个,只要前三个顺次是 1、2、3 就可以了;后面不管是啥都行。
啰嗦一下。由于
int[]
和{ 1, 2, 3, .. }
这两个模式是分开的两个匹配逻辑(一个是类型判断,一个是数据判断),且它们是且的关系,因此按道理来说,是可以加and
在中间的:is int[] and { 1, 2, 3, .. }
。实际上可以吗?可以的。但是没有必要,因为 C# 知道这里是两个判断的关系。所以这里的int[] { 1, 2, 3, .. }
虽然看起来有点像数组的初始化器,但编译器是知道这里不是在初始化数组,而是一个判断的两个条件(一个判断类型的条件和一个判断数据的条件)。
不过,啥样的数据类型可以使用和校验呢?既然对象可以解析数据,那么前面要满足长度模式的要求必须都得满足。因此:
有一个
this[int]
的索引器,且必须有get
语句;有一个叫做
Length
或者是Count
的属性。
依然是这样的条件。
Part 15-3 切片模式
那么,集合貌似有了判断模式了,好像差不多可以结束了。C# 10 还提供了一种新鲜的语法,除了以数据为单位判断,还可以以数据序列作为切片。
arr[0..10]
,这表示把 arr
的前 10 个元素取出来,而这个语法叫切片(Slice)。只要有一个方法 Slice(int, int)
定义切片的逻辑,那么对象就可以切片了。C# 10 里允许我们使用切片到模式匹配里。举个例子,expr is { 1, .. var s, 3 }
就表示我们对中间的序列作切片,然后切片结果用 s
变量表示,因此,这个写法等价于 expr.Length >= 2 && expr[0] == 1 && expr[^1] == 3 && expr[1..^1] is var s
。特别要注意的是,这里第一个条件并不是直接数值判断,而是长度判断:expr.Length >= 2
。这是因为后续的条件无法保证对象的长度是多少,贸然取值会导致 IndexOutOfRangeException
的异常。
那么,给一些例子给你看看。
弃元(Discard)语法。我们不在意这里的数据是多少,但必须要占位来表示这里是集合的第几个元素,就使用弃元来表达(后两种情况就用到了占位这个概念)。
另请注意,这里
expr
是不知道啥类型的,所以可能集合内的元素都是object
。因此在注释里用的是is
而不是==
当然了,如果只有一个切片的范围记号 ..
,而不判断数据的话(即 { .. }
),就等价于 obj.Length >= 0
这个条件,或者 obj is { Length: >= 0 }
。另外,后者这个写法还比前者多判断一下 null
:obj is not null and { Length: >= 0 }
;而前者只判断 Length
属性是不是至少为 0。
我们来总结一下 C# 7 开始允许的所有模式匹配:
C# 7
expr is T value
(增强is
模式)expr is var variable
(var
模式)expr is var (value1, value2, value3)
(解构模式)expr is var (_, _, value3, _)
(弃元模式)C# 8
expr is { Property1: value1, Property2: { InnerProperty: value2 } }
(递归模式)expr is { } notNullResult
(空递归模式/不可空校验模式)C# 9
expr is var value and not (value1 or value2)
(逻辑模式)C# 10
expr is { [index]: value }
(长度模式)expr is { value1, value2, _, .., value3, .., value4, _ }
(集合模式)expr is { _, _, .. var slice, _, _ }
(切片模式)
当我们需要混用的时候,需要注意一下要求。由于很多的模式匹配上都是用大括号来表达和标记模式匹配的范围和界限,因此如果混用可能就导致语法不清晰。因此,C# 10 只能让我们把集合和切片模式写在整体的最后面;而前面则是 C# 7 到 9 里的基本模式匹配。
这样的写法。