C#9.0中的新特性

2020-09-11 21:05:44

C#9.0引入了记录类型,这是一种引用类型,它提供合成方法来提供相等的值语义。默认情况下,记录是不可变的。

记录类型使得在.NET中创建不可变的引用类型变得容易。过去,.NET类型主要分为引用类型(包括类和匿名类型)和值类型(包括结构和元组)。虽然建议使用不可变值类型,但可变值类型通常不会引入错误。值类型变量保存这些值,因此在将值类型传递给方法时,会对原始数据的副本进行更改。

不可变引用类型也有很多优点。这些优势在具有共享数据的并发程序中更为明显。不幸的是,C#迫使您编写相当多的额外代码来创建不可变的引用类型。记录为使用相等的值语义的不可变引用类型提供类型声明。如果两条记录的属性都相等,则相等和散列码的综合方法认为这两条记录相等。请考虑以下定义:

公共记录人员{public string LastName{get;}公共字符串FirstName{get;}公共人员(String First,String LastName)=>;(FirstName,LastName)=(First,LastName);}。

记录定义创建包含两个只读属性的Person类型:FirstName和LastName。Person类型是引用类型。如果你看一下IL,就会发现这是一门课。它是不可变的,因为一旦创建,所有属性都不能修改。定义记录类型时,编译器会为您合成几个其他方法:

公共记录教师:Person{public String Subject{get;}公共教师(String First,String Last,String sub):base(First,Last)=>;Subject=sub;}。

公有密封记录学员:Person{public int level{get;}public学员(String First,String Last,int Level):base(first,last)=>;level=level;}。

编译器综合上述方法的不同版本。方法签名取决于记录类型是否密封以及直接基类是否为Object。记录应具有以下功能:

相等是基于值的,并且包括类型匹配的检查。例如,一个学生不能等于一个人,即使这两个记录的名字相同。

记录支持复制构造。正确的副本构造必须包括继承层次结构和开发人员添加的属性。

除了熟悉的等于重载、运算符==和运算符!=之外,编译器还合成了一个新的EqualityContract属性。该属性返回与记录类型匹配的Type对象。如果基类型为Object,则属性为Virtual。如果基类型是另一个记录类型,则该属性为重写。如果记录类型是密封的,则属性是密封的。合成的GetHashCode使用基类型和记录类型中声明的所有属性和字段中的GetHashCode。这些合成方法在整个继承层次结构中强制基于值的相等。这意味着一个学生永远不会被认为与同名的人是平等的。这两条记录的类型必须匹配,并且记录类型之间共享的所有属性必须相等。

记录还具有合成构造函数和用于创建副本的克隆方法。合成构造函数有一个记录类型的参数。它为记录的所有属性生成一个具有相同值的新记录。如果记录是密封的,则此构造函数是私有的,否则它是受保护的。合成克隆方法支持记录层次结构的副本构造。术语";克隆";用引号引起来,因为实际名称是编译器生成的。您不能在记录类型中创建名为Clone的方法。合成的";clone";方法返回使用虚拟分派复制的记录的类型。编译器根据记录上的访问修饰符为";clone";方法添加不同的修饰符:

如果记录类型是抽象的,那么";clone";方法也是抽象的。如果基类型不是Object,则该方法也是Override。

对于基类型为Object时不是抽象的记录类型:如果记录是密封的,则不会向";clone";方法添加任何额外的修饰符(意味着它不是虚拟的)。

所有这些规则的结果是在记录类型的任何层次结构中一致地实现相等。如果两条记录的属性相等且类型相同,则两条记录相等,如下例所示:

Var Person=新人员(";Bill";,";Wagner&34;);var Student=新学生(";Bill";,";Wagner";,11);Console.WriteLine(Student=Person);//false

编译器合成两个支持打印输出的方法:ToString()重写和PrintMembers。PrintMembers将System.Text.StringBuilder作为其参数。它为记录类型中的所有属性追加一个逗号分隔的属性名称和值列表。PrintMembers调用从其他记录派生的任何记录的基实现。ToString()重写返回PrintMembers生成的字符串,该字符串由{和}括起来。例如,学生的ToString()方法返回如下代码所示的字符串:

到目前为止显示的示例使用传统语法来声明属性。有一种更简明的形式叫做位置记录。以下是前面定义为位置记录的三种记录类型:

公有记录Person(String FirstName,String LastName);公有记录教师(String FirstName,String LastName,String Subject):Person(FirstName,LastName);公有封存记录学生(String FirstName,String LastName,int level):Person(FirstName,LastName);

这些声明创建了与早期版本相同的功能(下一节将介绍几个额外的功能)。这些声明以分号而不是方括号结尾,因为这些记录不会添加其他方法。您可以添加主体,也可以包括任何其他方法:

公共记录宠物(字符串名称){public void ShredTheFurinet()=>;Console.WriteLine(";粉碎家具";);}公共记录狗(字符串名称):宠物(名称){public void wagail()=>;Console.WriteLine(";它';的尾部摆动时间";);公共覆盖字符串ToString(){StringBuilder s=new();base.Printe.Pet(";它';它=>;Console.WriteLine);公共覆盖字符串ToString(){StringBuilder s=new();base.Printe.Printe.Print.。

编译器为位置记录生成解构方法。Deconstruct方法的参数与记录类型中所有公共属性的名称相匹配。可以使用Deconstruct方法将记录解构为其组件属性:

最后,记录支持使用-表达式。With-expression指示编译器创建记录的副本,但修改了指定的属性:

以上行创建了一个新的Person记录,其中LastName属性是Person的副本,FirstName是";Paul";。您可以在with-expression中设置任意数量的属性。除克隆方法之外的任何合成成员都可以由您编写。如果记录类型具有与任何合成方法的签名匹配的方法,则编译器不会合成该方法。前面的Dog记录示例包含一个手工编码的ToString()方法作为示例。

仅初始化设置器提供一致的语法来初始化对象的成员。属性初始值设定项清楚地表明哪个值正在设置哪个属性。缺点是这些属性必须是可设置的。从C#9.0开始,您可以创建初始化访问器,而不是为属性和索引器设置访问器。调用方可以使用属性初始值设定项语法在创建表达式中设置这些值,但这些属性在构造完成后是只读的。仅初始化设置器提供了更改状态的窗口。该窗口在构建阶段结束时关闭。在所有初始化(包括属性初始化器和with表达式)完成之后,构造阶段实际上就结束了。

前面的位置记录示例演示了如何使用仅限初始化的setter来设置使用WITH表达式的属性。您可以在您编写的任何类型中仅声明init setter。例如,下面的结构定义了一个天气观测结构:

Public struct WeatherObservation{public datetime RecordedAt{get;init;}public decimal TemperatureInCelsius{get;init;}public decimal PressureInMillibar{get;init;}public decimal PressureInMillibar{get;init;}public Override String ToString()=>;$";at{RecordedAt:h:mm TT}on{RecordedAt:m/d/yyyy}:";+$&#。

调用方可以使用属性初始值设定项语法来设置值,同时仍然保持不可变性:

Var now=新天气观测{RecordedAt=DateTime.Now,TemperatureInCelsius=20,PressureInMillibar=998.0m};

但是,通过在初始化之外为仅初始化属性赋值,在初始化后更改观测是错误的:

从派生类设置基类属性时,仅初始化设置器非常有用。它们还可以通过基类中的帮助器设置派生属性。位置记录仅使用初始化设置器声明属性。这些设置器在with-expression中使用。您只能为您定义的任何类或结构声明init setter。

使用系统;命名空间HelloWorld{class Program{static void main(string[]args){Console.WriteLine(";Hello World!";);}

只有一行代码可以做任何事情。对于顶级语句,您可以将所有样板替换为Using语句和执行此工作的单行:

如果需要一行程序,可以删除Using指令并使用完全限定的类型名:

应用程序中只有一个文件可以使用顶级语句。如果编译器在多个源文件中找到顶级语句,则是错误的。如果将顶级语句与声明的程序入口点方法(通常是main方法)组合在一起,也是错误的。在某种意义上,您可以认为一个文件包含通常位于Program类的Main方法中的语句。

此功能最常见的用途之一是创建教材。初学者C#开发人员可以编写规范的“Hello World!”在一两行代码中。不需要任何额外的仪式。但是,经验丰富的开发人员也会发现此功能有很多用处。顶级语句提供了类似于Jupyter笔记本提供的类似脚本的实验体验。顶级语句非常适合小型控制台程序和实用程序。Azure函数是顶级语句的理想用例。

最重要的是,顶级语句不会限制应用程序的范围或复杂性。这些语句可以访问或使用任何.NET类。它们也不限制您使用命令行参数或返回值。顶级语句可以访问名为args的字符串数组。如果顶级语句返回整数值,则该值将成为合成的Main方法的整数返回代码。顶级语句可以包含异步表达式。在这种情况下,合成入口点返回一个Task或Task<;int>;。

关系模式要求输入小于、大于、小于或等于给定常量。

公共静态bool IsLetter(此字符c)=>;c is>;=';a';and<;=';z';or>;=';a';and<;=';z';;

或者,也可以使用可选的圆括号,以表明和的优先级高于或:

公共静态bool IsLetterOrSeparator(此字符c)=>;c是(>;=';a';and<;=';z';)或(>;=';a';and<;=';Z';)或';.';或';或';,';

这些模式中的任何一种都可以在任何允许使用模式的上下文中使用:IS模式表达式、Switch表达式、嵌套模式和Switch语句的case标签的模式。

三个新特性改进了对需要高性能的本机互操作和低级库的支持:本机大小的整数、函数指针和省略localsinit标志。

本机大小的整数nint和nuint都是整数类型。它们由基础类型System.IntPtr和System.UIntPtr表示。编译器将这些类型的附加转换和操作设置为本机整数。本机大小的整数没有MaxValue或MinValue的常量,除了nuint.MinValue,它的MinValue为0。其他值不能表示为常量,因为它取决于目标计算机上整数的本机大小。您可以使用范围[int.MinValue]中的nint常量值。Int.MaxValue]。您可以对范围[uint.MinValue]中的nuint使用常量值。Uint.MaxValue][Uint.MaxValue]。编译器使用System.Int32和System.UInt32类型对所有一元运算符和二元运算符执行常量折叠。如果结果不适合32位,则在运行时执行该操作,并且不将其视为常量。在广泛使用整数数学并且需要尽可能具有最快性能的情况下,本机大小的整数可以提高性能。

函数指针提供了访问IL操作码ldftn和calli的简单语法。您可以使用新的委托*语法声明函数指针。委托*类型是指针类型。与在Invoke()方法上使用callvirt的委托不同,调用委托*类型使用calli。在语法上,调用是相同的。函数指针调用使用托管调用约定。您可以在Delegate*语法之后添加非托管关键字,以声明您需要非托管调用约定。可以使用Delegate*声明上的属性指定其他调用约定。

最后,您可以添加System.Runtime.CompilerServices.SkipLocalsInitAttribute来指示编译器不发出localsinit标志。此标志指示CLR将所有局部变量初始化为零。从1.0开始,localsinit标志一直是C#的默认行为。但是,在某些情况下,额外的零初始化可能会对性能产生可衡量的影响。特别是在使用stackalloc时。在这些情况下,您可以添加SkipLocalsInitAttribute。您可以将其添加到单个方法或属性中,也可以添加到类、结构、接口甚至模块中。此属性不影响抽象方法;它影响为实现生成的代码。

在某些情况下,这些功能可以提高性能。它们应该在采用之前和之后都经过仔细的基准测试后才能使用。涉及本机整数的代码必须在具有不同整数大小的多个目标平台上进行测试。其他功能需要不安全的代码。

许多其他功能可以帮助您更高效地编写代码。在C#9.0中,当创建的对象的类型已知时,您可以在新表达式中省略该类型。最常见的用法是在字段声明中:

当您需要创建要作为参数传递给方法的新对象时,也可以使用目标类型new。考虑使用以下签名的ForecastFor()方法:

此功能的另一个很好的用途是将其与仅初始化属性相结合,以初始化新对象:

您可以使用return new();表达式返回由默认构造函数创建的实例。

类似的功能提高了条件表达式的目标类型解析。通过此更改,这两个表达式不需要从一个表达式隐式转换为另一个表达式,但都可以都隐式转换为公共类型。您可能不会注意到这一变化。您会注意到,一些以前需要强制转换或不会编译的条件表达式现在可以正常工作。

从C#9.0开始,您可以向lambda表达式或匿名方法添加static修饰符。静态lambda表达式类似于静态局部函数:静态lambda或匿名函数不能捕获局部变量或实例状态。静态修饰符防止意外捕获其他变量。

协变返回类型为重写函数的返回类型提供了灵活性。重写的虚函数可以返回从基类方法中声明的返回类型派生的类型。这对于记录以及支持虚拟克隆或工厂方法的其他类型都很有用。

此外,foreach循环将识别并使用以其他方式满足foreach模式的扩展方法GetEnumerator。这一变化意味着foreach与其他基于模式的构造(如异步模式和基于模式的解构)是一致的。实际上,此更改意味着您可以将foreach支持添加到任何类型。您应该将其使用限制在枚举对象在设计中有意义时使用。

接下来,您可以将丢弃用作lambda表达式的参数。这种方便性使您可以避免命名参数,并且编译器可能会避免使用它。您可以将_用于任何参数。

最后,您现在可以将属性应用于本地函数。例如,您可以将可为空的属性批注应用于本地函数。

最后两个特性支持C#代码生成器。C#代码生成器是一个组件,您可以编写类似于Roslyn分析器或代码修复的组件。不同之处在于,作为编译过程的一部分,代码生成器分析代码并编写新的源代码文件。典型的代码生成器在代码中搜索属性或其他约定。

代码生成器使用Roslyn分析API读取属性或其他代码元素。根据该信息,它将新代码添加到编译中。源代码生成器只能添加代码,不允许他们修改编译中的任何现有代码。

为代码生成器添加的两个功能是对分部方法语法的扩展和模块初始化器。首先,对部分方法的更改。在C#9.0之前,分部方法是私有的,但是不能指定访问修饰符,不能有void返回,并且不能有输出参数。这些限制意味着,如果没有提供方法实现,编译器将移除对分部方法的所有调用。C#9.0消除了这些限制,但要求分部方法声明必须有实现。代码生成器可以提供该实现。为了避免引入破坏性更改,编译器认为任何没有访问修饰符的分部方法都遵循旧规则。如果分部方法包括私有访问修饰符,则新规则控制该分部方法。

代码生成器的第二个新特性是模块初始化器。模块初始值设定项是附加了ModuleInitializerAttribute属性的方法。这些方法将在加载程序集时由运行库调用。一种模块初始化式方法:

最后一个要点实际上意味着该方法及其包含的类必须是内部的或公共的。该方法不能是局部函数。