欢迎光临散文网 会员登陆 & 注册

C# 10 特性一览

2021-03-02 17:59 作者:SunnieShine  | 我要投稿

C# 官方团队最近正在研究下一个版本(C# 10)的新特性。下面我们来看一下。以后做 C# 教程视频的时候,看情况,如果录制到这个地方的时候,C# 10 特性发布正式版本的时候,说不定我还会单独录一个视频。这里先做一个参考。

Part 1 结构记录类型

C# 9 有一个新的数据类型,叫做记录(Record)。这个类型是一种特殊的引用类型,我们只需要给出一个东西的具体属性,就可以自动为这个类型生成指定的比较器(Equals 方法、比较运算符 operator ==operator !=GetHashCode 方法,甚至是 ToString 方法等等)。

举个例子,我们可以这么写:

这就等价于一个类 Point,然后生成 XY 属性,以及相关的方法:

这样的东西。你看看,就写一句话就能生成一系列的内容,是不是很方便。

不过,Point 类型就俩属性,显然没有必要定义成类,因为它太轻量级了。因此,C# 10 开始允许结构记录类型。

这样就好比把前文的 sealed class 改写成 struct。因此 C# 10 开始允许结构的记录类型,所以更轻量级,灵活度更高了。当然,C# 10 依然允许引用类型(类)的记录类型,你可以使用 record 或者 record class 来表示一个类的记录类型。

前文用到的 init 属性是 C# 9 里诞生的、用来表示属性只在初始化器和构造器里才可赋值的一种属性赋值模型。它比 set 的使用范围要少,set 随时随地都可以赋值(最多加一个访问修饰符,但并不能阻止内部的任何时候的赋值)。init 仅允许初始化器和构造器里使用赋值,其它任何地方都不能赋值。这样做就避免了很多地方的安全问题,同时也提供了一种语法上的约束。

Part 2 全局 using 指令

C# 很早(C# 2)就允许类型取别名。但由于这个东西实在是不好用(只允许同文件取别名),因此不是很常用。C# 10 开始允许全局的 using 指令。

你只需要在单独的文件里写上这么一句话:

global using IntegerList = System.Collections.Generic<int>;

那么,整个项目就都可以使用这个 IntegerList 了。

Part 3 namespace 指令

C# 团队是真的有趣,这种没有啥特殊意义的特性也开始允许了。但是说实话,如果允许了的话,其实还是挺不错的一个东西,毕竟缩进也变少了,这样代码的层次就分得比较清楚了。

在 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" 并未进行修改,就会产生隐藏的 bug。因此,C# 10 开始允许我们使用参数,来避免这一点。

这样就很方便了。

Part 7 增强对是否为 null 的对象的代码分析

C# 8 诞生了可空引用类型的概念,并提供了基本的分析模型。但很遗憾的是,很多时候,编译器依旧无法识别对象已经不可能是 null 的情况,进而产生语义分析上的 bug。C# 10 提出了增强分析的概念,这样的话,很多原本是 bug 的情况就得到了解决。举个例子。

比如这个例子下,c 不可空后,调用 M 方法后,如果返回值为 true 的话,参数 obj1 就不能为空了。而编译器暂时无法识别这种变量的传递(最开始是从 c != null 开始的),因此分析这个地方的时候,obj1 仍然不知道是不是为 null

C# 10 会对这样类似的场景的分析进行修复。

顺带凡尔赛一波,我提了一个属于这个特性主题的 issue,它们也放在了这个提案里作为解决对象。

Part 8 refpartial 关键字的顺序

在诞生了 ref struct 这种类型之后,C# 由于没有考虑到语法的灵活性,因此如果 ref struct 是分部类型的话,就必须写成 ref partial struct,而调转顺序 partial ref struct 则是错误的写法。

C# 10 将对这个问题进行修复。

Part 9 参数可空性验证

Part 9-1 可空校验标记符 !!

如果一个参数为 null,我们期望使用 throw 语句来产生异常信息。于是,代码大概就长这样:

C# 10 将允许我们直接添加双叹号到 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 解构 default 表达式

如果我们有这么一句话在 C# 里是允许的话:

你觉得,arg1arg2 会是多少呢?C# 10 里将允许这个写法,这样的话,arg1arg2 就会默认赋值 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 里则又多了 andnotor 的逻辑模式匹配。C# 10 这次带来的是集合模式匹配。

集合模式匹配可以对集合的元素进行解构、处理和判别。

Part 15-1 长度模式

长度模式听起来好像是在判断集合的长度,但是这一点不是可以用 Length 属性的递归模式作为判断对象吗?是的,长度模式并不是这个意思。

长度模式用中括号来获取数据元素,然后通过冒号和模式来表达一个对象的指定索引位置上的数据是不是满足这个模式。举个例子:

arr 必须是带 LengthCount 属性的类型,且拥有一个以 int 类型作为参数的索引器时候,就可以用这个模式写法。这句话表示,当 arr 数据的第 3 个元素是一个整数,且不是 -1 的时候,满足条件。

如果 arr 在这里不能确定和断定是不是包含 LengthCount 属性,或者是 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, .. } 是集合模式。它表示判别的序列,要满足前三个元素和这里的顺序一一匹配的 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 还提供了一种新鲜的语法,除了以数据为单位判断,还可以以数据序列作为切片。

C# 8 里有这么一种语法: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 }。另外,后者这个写法还比前者多判断一下 nullobj is not null and { Length: >= 0 };而前者只判断 Length 属性是不是至少为 0。

Part 15-4 混用模式匹配

我们来总结一下 C# 7 开始允许的所有模式匹配:

  • C# 7

    • expr is T value(增强 is 模式)

    • expr is var variablevar 模式)

    • 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 里的基本模式匹配。

这样的写法。

C# 10 特性一览的评论 (共 条)

分享到微博请遵守国家法律