C#9.0在11月10日已经正式发布。在这里我将C#9.0的一共16个新特性进行了汇总。新特性虽多,但本次这个版本主要落脚点还是放在了数据的简洁性和不可变性表达上。
1. init关键字
1.1 只初始化属性设置器 — init关键字
对象初始化方式对于创建对象来说是一种非常灵活和可读的方式,特别对一口气创建含有嵌套结构的树型对象来说更有用。一个简单的初始化例子如下:
var person = new Person { FirstName = "Mads", LastName = "Torgersen" };
原来要进行对象初始化,我们不得不写一些含有set访问器的属性,并且在初始化器中,通过给属性赋值来实现。
public class Person
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
}
这种方式最大的局限就是,对于初始化来说,属性必须是可变的,也就是说,set访问器对于初始化来说是必须的。而其他情况下又不需要set,因此这个setter就不合适了。为了解决这个问题,仅仅只用来初始化的init访问器出现了.。例如:
public class Person
{
public string? FirstName { get; init; }
public string? LastName { get; init; }
}
init访问器是一个只在对象初始化时用来赋值的set访问器的变体,并且除过初始化进行赋值外,后续其他的赋值操作是不允许的。上面定义的Person对象,在下面代码中第一行初始化可以,第二行再次赋值就不被允许了。
var person = new Person { FirstName = "Mads", LastName = "Nielsen" }; // OK person.LastName = "Torgersen"; // ERROR!
因此,一旦初始化完成之后,仅初始化属性就保护着对象免于改变
1.2 init属性访问器和只读字段
因为init访问器只能在初始化时被调用,所以在init属性访问器中可以改变封闭类的只读字段。
public class Person
{
private readonly string firstName = "<unknown>";
private readonly string lastName = "<unknown>";
public string FirstName
{
get => firstName;
init => firstName = (value ?? throw new ArgumentNullException(nameof(FirstName)));
}
public string LastName
{
get => lastName;
init => lastName = (value ?? throw new ArgumentNullException(nameof(LastName)));
}
}
2 记录 / Records
传统面向对象的编程的核心思想是一个对象有着唯一标识,封装着随时可变的状态。C#也是一直这样设计和工作的。但是一些时候,你就非常需要刚好对立的方式。原来那种默认的方式往往会成为阻力,使得事情变得费时费力。如果你发现你需要整个对象都是不可变的,且行为像一个值,那么你应当考虑将其声明为一个record类型。
public record Person { public string? FirstName { get; init; } public string? LastName { get; init; } }
一个record仍然是一个类,但是关键字record赋予这个类额外的几个像值的行为。通常说,records由他们的内容来界定,不是他们的标识。从这一点上讲,records更接近于结构,但是他们依然是引用类型。
2.1 with表达式
当使用不可变的数据时,一个常见的模式是从现存的值创建新值来呈现一个新状态。例如,如果Person打算改变他的姓氏(last name),我们就需要通过拷贝原来数据,并赋予一个不同的last name值来呈现一个新Person。这种技术被称为非破坏性改变。作为描绘随时间变化的person,record呈现了一个特定时间的person的状态。为了帮助进行这种类型的编程,records就提出了一个新的表达式——with表达式:
var person = new Person { FirstName = "Mads", LastName = "Nielsen" }; var otherPerson = person with { LastName = "Torgersen" };
with表达式使用初始化语法来说明新对象在哪里与原来对象不同。with表达式实际上是拷贝原来对象的整个状态值到新对象,然后根据对象初始化器来改变指定值。这意味着属性必须有init或者set访问器,才能用with表达式进行更改。
一个record隐式定义了一个带有保护访问级别的“拷贝构造函数”,用来将现有record对象的字段值拷贝到新对象对应字段中:
protected Person(Person original) { /* 拷贝所有字段 */ } // generated
with表达式就会引起拷贝构造函数被调用,然后应用对象初始化器来有限更改属性相应值。如果你不喜欢默认的产生的拷贝构造函数,你可以自定以,with表达式也会进行调用。
2.2 基于值的相等
所有对象都从object类型继承了 Equals(object),这是静态方法
Object.Equals(object, object)
用来比较两个非空参数的基础。
结构重写了这个方法,通过递归调用每个结构字段的Equals方法,从而有了“基于值的相等”,Recrods也是这样。这意味着只要他们的值保持一致,两个record对象可以不是同一个对象就会相等。例如我们将修改的Last name又修改回去了:
var originalPerson = otherPerson with { LastName = "Nielsen" };
现在我们会得到 ReferenceEquals(person, originalPerson)
= false (他们不是同一对象),但是 Equals(person, originalPerson)
= true (他们有同样的值).。与基于值的Equals一起的,还伴有基于值的GetHashCode()的重写。另外,records实现了IEquatable<T>并重载了==和 !=这两个操作符,以便于基于值的行为在所有的不同的相等机制方面显得一致。
基于值的相等和可变性不总是契合的很好。一个问题是改变值可能引起GetHashCode的结果随时变化,如果这个对象被存放在哈希表中,就会出问题。我们没有不允许使用可变的record,但是我们不鼓励那样做,除非你已经想到了后果。
如果你不喜欢默认Equals重写的字段与字段比较行为,你可以进行重写。你只需要认真理解基于值的相等时如何在records中工作原理,特别是涉及到继承的时候,后面我们会提到。
2.3 继承 / Inheritance
记录(record)可以从其他记录(record)继承:
public record Student : Person { public int ID; }
with表达式和值相等性与记录的继承结合的很好,因为他们考虑到了整个运行时对象,不只是静态的已知类型。比如,我创建一个Student对象,将其存在Person变量里。
Person student = new Student { FirstName = "Mads", LastName = "Nielsen", ID = 129 };
with表达式仍然拷贝整个对象并保持着运行时的类型:
var otherStudent = student with { LastName = "Torgersen" }; WriteLine(otherStudent is Student); // true
同样地,值相等性确保两个对象有着同样的运行时类型,然后比较他们的所有状态:
Person similarStudent = new Student { FirstName = "Mads", LastName = "Nielsen", ID = 130 }; WriteLine(student != similarStudent); //true, 由于ID值不同
2.4 位置记录 / Positional records
有时,有更多的位置定位方式对一个记录是很有用的,在那里,记录的内容是通过构造函数的参数传入,并且通过位置解构函数提取出来。你完全可能会在记录中定义你自己的构造和解构函数(注意不是析构函数)。如下所示:
public record Person { public string FirstName { get; init; } public string LastName { get; init; } public Person(string firstName, string lastName) => (FirstName, LastName) = (firstName, lastName); public void Deconstruct(out string firstName, out string lastName) => (firstName, lastName) = (FirstName, LastName); }
也可以用更精简的语法表达上面同样的内容。
public record Person(string FirstName, string LastName);
该方式声明了公开的、仅仅初始化的自动属性、构造函数和解构函数,和2.1种第一行代码带有大括号的声明方式不同。现在你就可以写如下代码:
var person = new Person("Mads", "Torgersen"); // 位置构造函数 / positional construction var (f, l) = person; // 位置解构函数 / deconstruction
当然,如果你不喜欢产生的自动属性,你可以自定义的同名属性代替,产生的构造函数和解构函数将会只使用你自定义的那个。在这种情况下,该参数处于你用于初始化的作用域内。例如,你想让FirstName是个保护属性:
public record Person(string FirstName, string LastName) { protected string FirstName { get; init; } = FirstName; }
一个位置记录可以像下面这样调用父类构造函数。
public record Student(string FirstName, string LastName, int ID) : Person(FirstName, LastName);
3 顶层程序(Top-Level Programs)
通常,我们写一个简单的C#程序,都必然会有大量的代码:
using System; class Program { static void Main() { Console.WriteLine("Hello World!"); } }
这个不仅对于初学者来说麻烦,而且使得代码凌乱,并且增加了缩进层级。在C#9.0中,你可以选择在顶层用如下代码代替写你的主程序:
using System; Console.WriteLine("Hello World!");
当然,任何语句都是允许的。但是这段代码必须放在using后,和任何类型或者命名空间声明的前面。并且你只能在一个文件里面这样做,像如今只能写一个main方法一样。
如果你想返回状态,你可以那样做;你想用await,也可以那样做。并且,如果你想访问命令行参数,神奇的是,args像魔法一样也是可用的。
using static System.Console; using System.Threading.Tasks; WriteLine(args[0]); await Task.Delay(1000); return 0;
本地函数作为语句的另一种形式,也是允许在顶层程序代码中使用的。在顶层代码段外部的任何地方调用他们都会产生错误。
4 增强的模式匹配
C#9.0添加了几种新的模式。如果要了解下面代码段的上下文,请参阅模式匹配教程:
public static decimal CalculateToll(object vehicle) => vehicle switch { ... DeliveryTruck t when t.GrossWeightClass > 5000 => 10.00m + 5.00m, DeliveryTruck t when t.GrossWeightClass < 3000 => 10.00m - 2.00m, DeliveryTruck _ => 10.00m, _ => throw new ArgumentException("Not a known vehicle type", nameof(vehicle)) };
(1)简单类型模式
当前,进行类型匹配的时候,一个类型模式需要声明一个标识符——即使这标识符是一个弃元_,像上面代码中的DeliveryTruck _
。但是在C#9.0中,你可以只写类型,如下所示:
DeliveryTruck => 10.00m,
(2)关系模式
C#9.0 提出了关系运算符<,<=等对应的模式。所以你现在可以将上面模式中的DeliveryTruck部分写成一个嵌套的
switch表达式:
DeliveryTruck t when t.GrossWeightClass switch { > 5000 => 10.00m + 5.00m, < 3000 => 10.00m - 2.00m, _ => 10.00m, },
这里 > 5000
和 < 3000就是关系模式。
(3)逻辑模式
最后,你可以用逻辑操作符and,or 和not将模式进行组合,这里的操作符用单词来表示,是为了避免与表达式操作符引起混淆。例如,上面嵌套的的switch可以按照升序排序,如下:
DeliveryTruck t when t.GrossWeightClass switch { < 3000 => 10.00m - 2.00m, >= 3000 and <= 5000 => 10.00m, > 5000 => 10.00m + 5.00m, },
中间的分支使用了and 来组合两个关系模式来形成了一个表达区间的模式。
not模式的常见的使用是将它用在null常量模式上,如not null。例如我们要根据是否为空来把一个未知分支的处理进行拆分:
not null => throw new ArgumentException($"Not a known vehicle type: {vehicle}", nameof(vehicle)), null => throw new ArgumentNullException(nameof(vehicle))
在包含了is表达式的if条件语句中,用于取代笨拙的双括号,使用not也会很方便:
if (!(e is Customer)) { ... }
你可以这样写:
if (e is not Customer) { ... }
实际上,在is not表达式里,允许你给Customer指定名称,以便后续使用。
if (e is not Customer c) { throw ... } // 如果这个分支抛出异常或者返回... var n = c.FirstName; // ... 这里,c肯定已经被赋值了,不会为空
5 类型推导new表达式
类型推导是从一个表达式所在的位置根据上下文获得它的类型时使用的一个术语。例如null和lambda表达式总是涉及到类型推导的。
在C#中,new表达式总是要求一个具体指定的类型(除了隐式类型数组表达式)。现在,如果表达式被指派给一个明确的类型时,你可以忽略new关键字后面的类型。
Point p = new (3, 5);
当有大量重复,这个特别有用。例如下面数组初始化:
Point[] ps = { new (1, 2), new (5, 2), new (5, -3), new (1, -3) };
6 返回值类型支持协变
有时候,在子类的一个重写方法中返回一个更具体的、且不同于父类方法的返回类型更为有用,C# 9.0对这种情况提供了支持。如下列子中,子类Tiger的在重写父类Animal的GetFood方法时,返回值使用了Meat而不是Food,就更为形象具体。
abstract class Animal { public abstract Food GetFood(); ... } class Tiger : Animal { public override Meat GetFood() => ...; }
7. 本地大小的整型——nint和nuint
为了互操作和实现底层库的需要,C# 9.0 引入了两个新的上下文相关的关键字——有符号和无符号的两个整型类型,即nint和nuint。这两种类型的大小是32还是64的取决于所用平台。在被编译之后,这两个关键字会被转换为System.IntPtr
和System.UIntPtr。
nint常量值介于范围 [
int.MinValue
, int.MaxValue
],nuint的常量值介于范围[ uint.MinValue
, uint.MaxValue
],但是他们没有像int和uint那样MaxValue和MinValue这两个静态属性。同时,他们支持所有的一元{
+
, -
, ~
} 和二元运算符 { +
, -
, *
, /
, %
, ==
, !=
, <
, <=
, >
, >=
, &
, |
, ^
, <<
, >>
}.
nint x = 3; _ = nint.Equals(x, 3);
8. 静态匿名方法
允许在Lambda表达式和匿名方法使用static关键字,用来防止访问所处范围的对象实例的状态或者本地变量。
Action<int> nc = static delegate (int x) {Console.WriteLine("Anonymous Method: {0}", x); Action<int> la = static (int a) => a++;
9. 模块初始化器
为了使在库加载的时候,能以最小的开销做一些期望的一次性的初始化工作,并且可以使源代码生成器运行一些全局的初始化逻辑。C# 9.0引入了模块初始化器。模块初始化器被设计为一个Attribute:
using System;
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class ModuleInitializerAttribute : Attribute { }
}
在你的代码里,你可将这个ModuleInitializerAttribute用在符合下面要求的方法上就可以了。
1)方法必须使静态的、无参的、返回值为void的函数。
2) 该方法不能是泛型或者包含在泛型类型里
3)该方法必须是可从其包含模块里访问的。也就是说,方法的有效访问符必须是internal或者public,不能是本地方法。
using System.Runtime.CompilerServices;
class MyClass
{
[ModuleInitializer]
internal static void Initializer()
{
// ...
}
}
10 本地函数支持Attribute
本地函数现在允许添加Attribute,并且参数和类型参数都是允许的。extern关键字也可以用于本地方法。这里,因为一个用的extern关键字,一个用的是ConditionalAttribute,所有都是static静态方法。
private static void PrintEnds(Func<bool> condition, string str, string separator ="+")
{
if (condition())
{
Console.Write(str);
}
else
{
Console.Write(@$"{str} {separator} ");
}
Log();
Print();
[DllImport("log")]
extern static void Log();
[Conditional("DEBUG")]
static void Print()
{
Console.WriteLine("print is running");
}
}
11 扩展的分部方法
为了使partial方法变成为更为通用的C#方法声明形式,C#9.0移除了partial方法签名的所有限制。分部方法将方法划分声明和定义两部分,例如:
partial class MyClass
{
// MyClass.Print的声明
partial void Print(string message);
}
partial class MyClass
{
// MyClass.Print的定义或实现
partial void Print(string message) => Console.WriteLine(message);
}
分部方法的一个行为是当定义/实现缺少时,编译器会删除方法声明以及对该方法的调用。这类似于调用一个含有[Conditional]的方法,当条件为false时的情况。因为存在删除这种情况,防止外部调用或引用失效引起的各种问题,所以存在以下几个限制:
-
返回值必须是void类型
-
不能有out参数
-
不能有任何访问修饰符,隐式为private
C# 9.0 就是移除了这些限制,从而可以用out,可以是非void返回类型,也可显式添加任何访问级别的修饰符,也可以用extern等等所有的C#语言的表达形式。但是这样修改的结果就是要求一个声明必须有一个定义或实现。这样,语言就不必考虑删除调用产生的影响。看下面列子:
partial class MyClass
{
// 正确,因为没有定义或者实现要求
partial void Method1();
// 正确,因为M2已经有了一个定义和实现了
private partial void Method2();
// 错误,含有显式访问修饰符的分部方法声明必须有一个实现
private partial void Method3();
}
partial class MyClass
{
private partial void Method2() { }
}
此外,显式访问修饰符例子如下:
partial class MyClass2
{
// 正确
internal partial bool TryParse(string s, out int i);
}
partial class MyClass2
{
internal partial bool TryParse(string s, out int i) { }
}
C#9.0现在对partial方法的限制如下:
-
带有显式访问修饰符的partial声明必须有一个定义/实现。
-
partial的声明和定义的签名在方法和参数修饰上必须匹配,唯一不同的是参数名和attribute列表了。
12 Lambda弃元参数
允许弃元被用作lambda和匿名表达式的的参数,从而没有使用的参数可以不必被命名。例如:
Func<int,int,nint> f= (_, _) => 0;
Func<int, int, nint> f2 = (int _,int _) => 0;
Func<int, int, nint> f3 = delegate (int _, int _) { return 0; };
13 类型推导的条件表达式
对于一个条件表达式 c ? e1 : e2,当存在下面情况的时候,我们定义了一个新的隐式条件表达式转换,它允许从条件表达式到任一类型T有一个隐式的转换。
-
对于e1和e2如果没有共同的类型。
-
或者有共同的类型存在,但是e1或者e2的表达式没有到共同类型的隐式转换。
对来自于表达式转换为这个类型T的情况来说,有一个来自于从e1到T的转换,也有一个从e2到T的转换。如果条件表达式在e1和e2之间既没有共同类型,也没有相关适合的条件表达式转换,那么就会出现错误。
14 方法指针
在今天的C#中,当前的中间语言IL的指令集没能有效地被访问,如:ldftn(将指向实现特定方法的本机代码的非托管指针(native int 类型)推送到计算堆栈上)和calli(通过调用约定描述的参数调用在计算堆栈上指示的方法(作为指向入口点的指针))。方法指针就是提供了有效的方式来访问IL指令集接口,以便开发人员能编写高性能代码。
C#允许你用delegate*来声明函数指针。
unsafeclass Example {
void Example(Action<int> a, delegate*<int, void> f) { a(42); f(42); } }
15 禁止发出localsinit标记
编译器对所有带有局部变量的方法发出localsinit标记,告诉他们对所有局部变量进行初始化,以防止代码运行时产生不可预知的错误。允许通过SkipLocalsInitAttribute 属性来禁止发出localsinit标记。对于自动初始化局部变量,特别是使用stackalloc的数据的时候,他们所花的成本是非常明显的,对于性能要求特别高的程序,该特性是有重要意义的。
[SkipLocalsInit] unsafe static void Print() { Span<int> members = stackalloc int[100]; for (var i = 0; i < length; i++) { numbers[i] = i; } }
SkipLocalsInitAttribute可以用于方法,属性,模块,类,结构、接口和构造函数。应用于在容器(类、模块,包含嵌入函数的函数等)上时,其里面所有的方法都会受到影响。对于抽象函数和和没有传播到的覆盖/实现的方法,时无效的。
16 扩展GetEnumerator支持foreach循环
允许foreach循环能识别以扩展方法定义的GetEnumberator()方法,该方法的定义必须满足foreach模式的其他要求:
- GetEnumerator()的返回值类型必须是类,结构或者接口,并且返回值类型有成员符合要求的方法MoveNext()和属性Current。
- GetEnumerator()不能有类型参数。
上述16个新特性为C#9.0这次的所有新添加功能。限于篇幅原因,针对每个新特性详细解释,不在此罗列,后面,我将做一个C#9.0新特性详解系列专题。