一、记录(record)

C# 9.0 引入了记录类型。 可使用 record 关键字定义一个引用类型,以最简的方式创建不可变类型。这种类型是线程安全的,不需要进行线程同步,非常适合并行计算的数据共享。它减少了更新对象会引起各种bug的风险,更为安全。System.DateTime 和 string 也是不可变类型非常经典的代表。

与类不同的是,它是基于值相等而不是唯一的标识符--对象的引用。

通过使用位置参数或标准属性语法,可以创建具有不可变属性的记录类型,整个对象都是不可变的,且行为像一个值。

优点:

  1)在构造不可变的数据结构时,它的语法简单易用

  2)record 为引用类型,不用像值类型在传递时需要内存分配,还可整体拷贝

  3)构造函数和结构函数为一体的、简化的位置记录;

  4)有力的相等性支持 —— 重写了 Equals(object), IEquatable, 和 GetHashCode() 这些基本方法。

record 类型可以定义为可变的,也可以是不可变的。

// 没有 set 访问器,创建后不可更改,叫不可变类型
public record Person
{
    // 要支持用对象初始化器进行初始化,则在属性中使用 init 关键字
    // 或者以构造函数的方式
    public string? FirstName { get; init; }
    public string? LastName { get; init; }
}
// 可变类型的 record
// 因为有 set 访问器,所以它支持用对象初始化器进行初始化
public record Person
{
    public string? FirstName { get; set; }
    public string? LastName { get; set; }
}

记录(record)和类一样,在面向对象方面,支持继承,多态等所有特性。除过前面提到的 record 专有的特性,其他语法写法跟类也是一样。同其他类型一样,record 的基类依然是 object。

  注意:1)记录只能从记录继承,不能从类继承,也不能被任何类继承; 2)record 不能定义为 static 的,但是可以有 static 成员。

从本质上来讲,record 仍然是一个类,但是关键字 record 赋予这个类额外的几个像值的行为:

  1)基于值相等性的比较方法,如 Equals,==,!=,EqualityContract 等; 2)重写 GetHashCode(); 3)拷贝和克隆成员; 4)PrintMembers 和 ToString() 方法。

应用场景:

  1)用于 web api 返回的数据,通常作为一种一次性的传输型数据,不需要是可变的,因此适合使用 record;2)作为不可变数据类型 record 对于并行计算和多线程之间的数据共享非常适合,安全可靠;3)record 本身的不可变性和 ToString 的数据内容的输出,不需要人工编写很多代码,就适合进行日志处理;4)其他涉及到有大量基于值类型比较和复制的场景,也是 record 的常用的使用场景。

with 表达式

  当使用不可变的数据时,一个常见的模式是从现存的值创建新值来呈现一个新状态。

  例如,如果 Person 打算改变他的姓氏(last name),我们就需要通过拷贝原来数据,并赋予一个不同的 last name 值来呈现一个新 Person。这种技术被称为非破坏性改变。作为描绘随时间变化的 person,record 呈现了一个特定时间的 person 的状态。为了帮助进行这种类型的编程,针对 records 就提出了 with 表达式,用于拷贝原有对象,并对特定属性进行修改

// 修改特定属性后复制给新的 record
var person = new Person { FirstName = "Mads", LastName = "Nielsen" };
var otherPerson = person with { LastName = "Torgersen" };
// 只是进行拷贝,不需要修改属性,那么无须指定任何属性修改
Person clone = person with { };

  with 表达式使用初始化语法来说明新对象在哪里与原有对象不同。with 表达式实际上是拷贝原来对象的整个状态值到新对象,然后根据对象初始化器来改变指定值。这意味着属性必须有 init 或者 set 访问器,才能用 with 表达式进行更改

  注意:1)with 表达式左边操作数必须为 record 类型; 2)record 的引用类型成员,在拷贝的时候,只是将所指实例的引用进行了拷贝。

  record 参考:C# 9.0新特性详解系列之五:记录(record)和with表达式

二、仅限 Init 的资源库

从 C# 9.0 开始,可为属性和索引器创建 init 访问器,而不是 set 访问器。 调用方可使用属性初始化表达式语法在创建表达式中设置这些值,但构造完成后,这些属性将变为只读。

仅限 init 的资源库提供了一个窗口用来更改状态。 构造阶段结束时,该窗口关闭。 在完成所有初始化(包括属性初始化表达式和 with 表达式)之后,构造阶段实际上就结束了。

属性初始值设定项可明确哪个值正在设置哪个属性。 缺点是这些属性必须是可设置的。

可在编写的任何类型中声明仅限 init 的资源库。 例如,以下结构定义了天气观察结构:

// 以下结构定义了天气观察结构
public struct WeatherObservation
{
    public DateTime RecordedAt { get; init; }
    public decimal TemperatureInCelsius { get; init; }
    public decimal PressureInMillibars { get; init; }
    public override string ToString() =>
        $"At {RecordedAt:h:mm tt} on {RecordedAt:M/d/yyyy}: " +
        $"Temp = {TemperatureInCelsius}, with {PressureInMillibars} pressure";
}
// 调用方可使用属性初始化表达式语法来设置值,同时仍保留不变性
var now = new WeatherObservation 
{ 
    RecordedAt = DateTime.Now, 
    TemperatureInCelsius = 20, 
    PressureInMillibars = 998.0m 
};
//初始化后尝试更改观察值会导致编译器错误
// Error! CS8852.
now.TemperatureInCelsius = 18;

对于从派生类设置基类属性,仅限 init 的资源库很有用。这些设置器可在 with 表达式中使用。 可为定义的任何 classstruct 或 record 声明仅限 init 的资源库。

三、顶级语句

顶级语句,就是从应用程序中删除了不必要的流程。例如最基本的“HelloWorld!”:

using System;
namespace HelloWorld
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");
        }
    }
}
// 只有一行代码执行所有操作,借助顶级语句
// 可使用 using 指令和执行操作的一行替换所有样本
using System;
Console.WriteLine("Hello World!");
// 如果需要单行程序,可删除 using 指令,并使用完全限定的类型名称
System.Console.WriteLine("Hello World!");

应用程序中只有一个文件可使用顶级语句。

  如果编译器在多个源文件中找到顶级语句,则是错误的。

  如果将顶级语句与声明的程序入口点方法(通常为 Main 方法)结合使用,也会出现错误。

从某种意义上讲,可认为一个文件包含通常位于 Program 类的 Main 方法中的语句。

顶级语句可提供类似脚本的试验体验,这与 Jupyter 笔记本提供的很类似。 顶级语句非常适合小型控制台程序和实用程序。Azure Functions 是顶级语句的理想用例。

(Jupyter Notebook 的本质是一个 Web 应用程序,便于创建和共享程序文档,支持实时代码,数学方程,可视化和 markdown。 用途包括:数据清理和转换,数值模拟,统计建模,机器学习等等)

(Azure Functions 是一种无服务器解决方案,可以使用户减少代码编写、减少需要维护的基础结构并节省成本。 无需担心部署和维护服务器,云基础结构提供保持应用程序运行所需的所有最新资源。你只需专注于对你最重要的代码,Azure Functions 处理其余代码。)

四、模式匹配增强功能

C# 9.0 版本进行模式匹配方面的改进如下:

  1)类型模式,匹配一个与特定类型匹配的对象;
  2)带圆括号的模式强制或强调模式组合的优先级;(圆括号模式允许编程人员在任何模式两边加上括号)
  3)联合 and 模式要求两个模式都匹配;
  4)析取 or 模式要求任一模式匹配;
  5)否定 not 模式要求模式不匹配;
  6)关系模式要求输入小于、大于、小于等于或大于等于给定常数。

// 类型模式,一个类型模式需要声明一个标识符
void M(object o1, object o2)
{
    var t = (o1, o2);
    if (t is (int, string)) {} // 判断 o1、o2 是 int、string 类型
    switch (o1) {
        case int: break; // 判断 o1 是 int
        case System.String: break; // 判断 o1 是 string
    }
}
// 关系模式,关系运算符<,<=等对应的模式
DeliveryTruck t when t.GrossWeightClass switch
{
    > 5000 => 10.00m + 5.00m,
    < 3000 => 10.00m - 2.00m,
    _ => 10.00m,
},
// 逻辑模式,用逻辑操作符and,or 和not将模式进行组合
DeliveryTruck t when t.GrossWeightClass switch
{
    < 3000 => 10.00m - 2.00m,
    >= 3000 and <= 5000 => 10.00m,
    > 5000 => 10.00m + 5.00m,
},

后面模式中的任何一种都可在允许使用模式的任何上下文中使用:is 模式表达式、switch 表达式、嵌套模式以及 switch 语句的 case 标签的模式。

模式组合器

  模式 组合器 允许匹配两个不同模式 and(还可以通过重复使用)来扩展到任意数量的模式,方法是通过 and、or,或者使用的是模式的 求反 not 。

bool IsLetter(char c) => c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z');

五、模块初始值设定(ModuleInitializer)

为什么要支持 模块或者程序集初始化工作:

  1)在库加载的时候,能以最小的开销、无需用户显式调用任何接口,使客户做一些期望的和一次性的初始化;

  2)当前静态构造函数方法的一个最大的问题是,运行时会对带有静态构造函数的类型做一些额外的检查,这是因为要决定静态构造函数是否需要被运行所必须的一步,但是这个又有着显著的开销影响;

  3)使源代码生成器在不需要用户显式调用一些东西的情况下能运行一些全局的初始化逻辑。

详细内容

C# 9.0 将模块初始化器设计为一个 Attribute,用这个 Attribute 来修饰进行模块初始化逻辑的方法,就实现了模块初始化功能。这个 Attribute 被命名为 ModuleInitializerAttribute,具体定义如下:

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()
    {  ...  }
}

实践

模块初始化器与静态构造函数之间有着一定的关联影响。因为模块初始化器是一个静态方法,因而其被调用执行前,必然会引起其所处类型的静态构造函数的执行。请参考下列示例:

static class ModuleInit
{
    static ModuleInit()
    {
        //先执行
        Console.WriteLine("ModuleInit静态构造函数 cctor");
    }
    [ModuleInitializer]
    internal static void Initializer()
    {
        //在静态构造函数执行后才执行
        Console.WriteLine("模块初始化器");
    }
}

在一个模块中指定多个模块初始化器的时候,他们之间的顺序也是一个值得注意的问题。以上这些问题的存在,就要求我们注意以下几点:
  1)在指定了模块初始化器的类型中,不要在静态构造函数中,写与模块初始化器中代码有着顺序依赖代码,最好的就是不要使用静态构造函数。
  2)多个模块初始化器之间的代码,也不要有任何依赖关系,保持各个初始化器代码的独立性。

日常开发中,我们通常需要在模块初始化的时候,做一些前置性的准备工作,以前常采用静态构造函数这种不具有全局性方法,局限性很大,现在,这些都得到了完美解决。

  参考:C# 9.0新特性详解系列之三:模块初始化器

六、可以为 null 的引用类型规范

此功能添加了,两种新类型的可为 null 的类型 (可以为 null 的引用类型和可以为 null 的现有值类型) 为 null 的泛型类型,并引入了静态流分析以实现 null 安全。

可以为 null 的引用类型和可以为 null 的类型参数的语法 T? 与可以为 null 的值类型的短格式相同,但没有相应的长格式。(DateTime 格式、TimeSpan 格式)

出于规范的目的,当前 nullable_type 被重命名为 nullable_value_type ,并新增了可空引用类型命名 nullable_reference_type nullable_type_parameter 可空类型参数。

non_nullable_reference_type nullable_reference_type 必须是不可 null 引用类型 (类、接口、委托或数组) 。

non_nullable_non_value_type_parameterIn nullable_type_parameter 必须是不被约束为值类型的类型参数。

可以为 null 的引用类型和可以为 null 的类型参数不能出现在以下位置:

null 合并运算符

  E1 ?? E2     // 若 E1 为 null 则取 E2 的值

七、目标类型的 new 表达式

当类型已知时,则构造函数的类型标注不必须。

// 允许字段初始化,而不显示类型
Dictionary<string, List<int>> field = new() { { "item1", new() { 1, 2, 3 } } };
// 如果可从用法推断,则允许省略类型
XmlReader.Create(reader, new() { IgnoreWhitespace = true });
// 实例化对象,而不会对类型进行拼写检查
private readonly static object s_syncObj = new();
(int a, int b) t = new(1, 2); // "new" 是不必要的
Action a = new(() => {}); // "new" 是不必要的
(int a, int b) t = new(); // 可以,类似 (0, 0)
Action a = new(); // 没有发现构造函数

八、扩展分部方法

什么是分布类、分部方法?

  拆分一个类、一个结构、一个接口或一个方法的定义到两个或更多的文件中是可能的。 每个源文件包含类型或方法定义的一部分,编译应用程序时将把所有部分组合起来

  分部方法要求,所在的类型有 partial 标识,同时分部方法也有 partial 进行标识。CLR其实是不知道所谓的分部方法的,都是编译器在做。通过使用分部方法,可以将一个类型中的操作分散在多个文件中,方便开发

分部类的作用:

  1)处理大型项目时,使一个类分布于多个独立文件中可以让多位程序员同时对该类进行处理;

  2)当使用自动生成的源文件时,你可以添加代码而不需要重新创建源文件。 Visual Studio 在创建 Windows 窗体、Web 服务包装器代码等时会使用这种方法。 你可以创建使用这些类的代码,这样就不需要修改由 Visual Studio 生成的文件。

  3)若要拆分类定义,必须使用 partial 关键字修饰符。

partial 关键字指示可在命名空间中定义该类、结构或接口的其他部分。 所有部分都必须使用 partial 关键字。 在编译时,各个部分都必须可用来形成最终的类型。 各个部分必须具有相同的可访问性,如 publicprivate 等。

如果将任意部分声明为抽象的,则整个类型都被视为抽象的;如果将任意部分声明为密封的,则整个类型都被视为密封的;如果任意部分声明基类型,则整个类型都将继承该类。

指定基类的所有部分必须一致,但忽略基类的部分仍继承该基类型。 各个部分可以指定不同的基接口,最终类型将实现所有分部声明所列出的全部接口。 在某一分部定义中声明的任何类、结构或接口成员可供所有其他部分使用。 最终类型是所有部分在编译时的组合

注: partial 修饰符不可用于委托或枚举声明中。

[SerializableAttribute]
partial class Moon { }
[ObsoleteAttribute]
partial class Moon { }
// 上边部分的两次声明,等同于以下声明
[SerializableAttribute]
[ObsoleteAttribute]
class Moon { }

可以合并的内容包括:(XML 注释)(接口)(泛型类型参数属性)(class 特性)(成员)。

partial class Earth : Planet, IRotate { }
partial class Earth : IRevolve { }
// 与下面声明等效
class Earth : Planet, IRotate, IRevolve { }

处理分部类定义时需遵循下面的几个规则:

  1. 要作为同一类型的各个部分的所有分部类型定义都必须使用 partial 进行修饰。
  2. partial 修饰符只能出现在紧靠关键字 classstruct 或 interface 前面的位置。
  3. 分部类型定义中允许使用嵌套的分部类型。
partial class ClassWithNestedClass 
{ 
    partial class NestedClass { } 
} 
partial class ClassWithNestedClass 
{
    partial class NestedClass { } 
}

  4. 要成为同一类型的各个部分的所有分部类型定义都必须在同一程序集和同一模块(.exe 或 .dll 文件)中进行定义。 分部定义不能跨越多个模块。

  5. 类名和泛型类型参数在所有的分部类型定义中都必须匹配。 泛型类型可以是分部的。 每个分部声明都必须以相同的顺序使用相同的参数名。

  6. 下面用于分部类型定义中的关键字是可选的,但是如果某关键字出现在一个分部类型定义中,则该关键字不能与在同一类型的其他分部定义中指定的关键字冲突:(public、private、protect、internal、abstract、sealed、基类、new修饰符(嵌套部分)、泛型约束)。

// 分部结构和接口示例
partial interface ITest
{
    void Interface_Test();
}
partial interface ITest
{
    void Interface_Test2();
}
partial struct S1
{
    void Struct_Test() { }
}
partial struct S1
{
    void Struct_Test2() { }
}

分部类或结构可以包含分部方法。 类的一个部分包含方法的签名。 可以在同一部分或另一个部分中定义可选实现。 如果未提供该实现,则会在编译时删除方法以及对方法的所有调用。

分部方法使类的某个部分的实施者能够定义方法(类似于事件)。 分部类中的任何代码都可以随意地使用分部方法,即使未提供实现也是如此。 调用但不实现该方法不会导致编译时错误或运行时错误

在自定义生成的代码时,分部方法特别有用。 这些方法允许保留方法名称和签名,因此生成的代码可以调用方法,而开发人员可以决定是否实现方法。 与分部类非常类似,分部方法使代码生成器创建的代码和开发人员创建的代码能够协同工作,而不会产生运行时开销

 分部方法声明由两个部分组成:定义和实现。 它们可以位于分部类的不同部分中,也可以位于同一部分中。 如果不存在实现声明,则编译器会优化定义声明和对方法的所有调用。

// 定义在 file1.cs
partial void OnNameChanged();

// 实现在 file2.cs
partial void OnNameChanged()
{
  // method body
}

九、静态匿名函数

为了避免不必要的内存分配, C# 9.0 中引入 static 匿名函数。

如果想在 lambda 表达式里捕获封闭方法的局部变量或者参数,那么就会存在两种堆分配,一种是委托上的分配,另一种是闭包上的分配,如果 lambda 表达式仅仅捕获一个封闭方法的实例状态,那么仅会有委托分配,如果 lambda 表达式什么都不捕获或者仅捕获一个静态状态,那么就没有任何分配。示例如下:

//  lambda 中需要获取 y,所以就有了意想不到的堆分配
int y = 1;
MyMethod(x => x + y);
// 为了避免这种不必要和浪费内存的分配,可以在 lambda 上使用 static 关键词或变量上标注 const
const int y = 1;
MyMethod(static x => x + y);
// 注:static 匿名函数不能访问封闭方法的局部变量和参数和 this 指针,但可以引用它的 静态方法 和 常量

如何使用静态匿名方法:

// 通过两步标记,来避免多余的内存分配
public class Demo
{
    // 1/2 formattedText 上标记 const
    private const string formattedText = "{0} It was developed by Microsoft's Anders Hejlsberg in the year 2000.";
    void DisplayText(Func<string, string> func)
    {
        Console.WriteLine(func("C# is a popular programming language."));
    }
    public void Display()
    {
        // 2/2 lambda 上标记 static
        DisplayText(static text => string.Format(formattedText, text));
        Console.Read();
    }
}
class Program
{
    static void Main(string[] args)
    {
        new Demo().Display();
        Console.Read();
    }
}
// 若没有今天加静态标识,则:
// formattedText 变量会被 DisplayText 方法中的 func 所捕获,这也就意味着它会产生你意料之外的内存分配

现在就可以使用 static + const 组合来提升应用程序性能了,同时也可以有效的阻止在 lambda 中误用封闭方法中的局部变量和参数引发的不必要开销。

 参考:如何在 C#9 中使用 static 匿名函数

十、目标类型(Target-Typed)的条件表达式

对于条件表达式: c ? e1 : e2

  当 e1 和 e2 没有通用类型,或它们的通用类型为 e1 或者 e2,但另一个表达式没有到该类型的隐式转换

我们定义了一个新的隐式条件表达式转换,该转换允许从条件表达式到任何类型(T)的隐式转换(从 e1 到 T 的转换,以及从 e2 到 T 的转换)。如果条件表达式在 e1 和 e2 之间,既没有通用类型,也不符合条件表达式转换,则是错误的。

_ = (A)(b ? c : d);

其中 c 的类型为 C,d 的类型为 D ,并且存在从 C 到 D 的隐式用户定义的转换, 以及从 D 到 A 的隐式用户定义的转换,以及从 C 到 A 的隐式用户定义的转换。

  • 如果在 C# 9.0 之前编译此代码,则在 b 为 true 时,我们会将 c 从 D 转换 A 为。
  • 如果使用条件表达式转换,则当 b 为 true 时,将直接从转换 c 为 A ,从而减少了一次操作

因此,我们将 条件表达式转换 视为转换中的最后一个手段,以保留现有行为。

十一、协变返回类型(Covariant returns type)

我们经常会遇到实现基类型的抽象方法时,返回值是固定的一个抽象类类型,示例:

public abstract class A
{
    public abstract A? GetNewOne(A? val); // 固定类型 A?
}
public sealed class B : A
{
    public override A? GetNewOne(A? val) => val as B; // 实现抽象方法时,仍要返回固定类型 A?
}

可以看到,这里返回的结果要么是 B 这个派生类类型的,也可以是 null,但总之跟 A 除了一个继承关系也就没啥别的关系了。

当我们想调用 B 类中的 GetNewOne 方法的时候能够立即得到 B 类的实例或 null 的话,C# 9 就直接允许我们把返回值类型改成 B?,以后就不必每次调用的时候还强制转换一下了。

这个协变返回类型就是这里重写方法的返回类型 B? 了。

public override B? GetNewOne(A? val) => val as B;

十二、迭代器扩展(扩展 GetEnumerator 方法来支持 foreach 循环)

允许 foreach 循环,识别扩展了方法 GetEnumerator 的类型。

也就是说,对于不支持 foreach 的类型,只要我们为这个类型实现 GetEnumerator 的扩展方法,那么这个类型就可以用 foreach 循环了。

如果我要实现一个功能,来获取这个 int 类型数据的所有比特位为 1 的这个偏移量的话,就只能写一个比较丑的方法,然后去调用它了,示例:

public static class Utils
{
    public static IEnumerator<int> GetEnumerator(this int @this)
    {
        for (int i = 0, v = @this; i < 32; i++, v >>= 1)
        {
            if ((v & 1) != 0)
            {
                yield return i;
            }
        }
    }
}
// 于是我们就可以对 int 类型的值应用 foreach
foreach (int offset in 17)
{
    // ...
}

十三、lambda 弃元参数

允许弃元( _ )用作 lambda 表达式和匿名方法的参数。写法:

(_, _) => 0 , (int _, int _) => 0 // lambda 表达式
delegate(int _, int _) { return 0; } // 匿名方法

当且仅当参数同时有两个及以上的都不用的话,弃元才生效。如果 Lambda 只需要一个参数的时候,即使你写 _,它也是一个正常的变量。

textBoxHello.TextChanged += (_, _) =>
{
    // ...
};

在上面这个情景下的时候,Lambda 弃元会比较有用:再给【一个控件赋值一个事件处理方法,且该方法直接用 Lambda 表达式赋值】的时候。

十四、本地函数的属性(Attributes on local functions)

本地函数是 C# 7 新增的一个概念,在当前 C# 9.0 允许本地函数声明属性。 包括本地函数上的参数和类型参数。

在 C# 7 里,本地函数是一个高级版的委托变量,它允许捕获变量,也允许传入 Lambda 的时候正常传递,这就是一个委托变量嘛!所以,既然是一个普通的变量,当然就不能标注特性了。

如果代码确实比较短,想让代码使用类似 C/C++ 的内联关键字 inline ,加个特性就行了,C# 9.0 允许我们加特性到本地函数上(如下代码示例):

(调用函数需要 CPU 执行参数压栈、寄存器保存与恢复、跳转指令等操作,开销比较大,高频繁的调用函数对性能有影响,在 C/C++ 语言里产生了 Macro 宏,由于宏不是函数不会产生上述开销,是一种比较好的优化,但宏不是强类型编程,于是 VC++ 产生了 inline 内联函数,inline 优化就是将内联函数展开,就没有了函数调用的 CPU 开销,性能上等同于宏,而且是强类型编程)

public void Method()
{
    var rng = new Random();
    Console.WriteLine(g());
    Console.WriteLine(g());
    Console.WriteLine(g());
    // Local function.
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    int g() => rng.Next(1, 100);
}

现在允许对本地函数使用修饰符 extern 这使本地函数成为外部方法的外部函数。

与外部方法类似,外部本地函数的本地函数体必须是分号。 只允许对外部本地函数的局部函数体使用分号。外部本地函数也必须是 static 。

另:本地函数和 Lambda 表达式的语法非常相似,但本地函数更能节省时间和空间上的开销。参见:C# 中的本地函数

十五、本机大小的整数

本机大小的整数包含(有符号) nint 和(无符号,大于 0) nuint 两种类型,都是整数类型。它们由基础类型 System.IntPtr 和 System.UIntPtr 表示。编译器将这些类型的其他转换和操作作为本机整数公开。

在运行时获取本机大小的整数大小,可以使用 sizeof()。 但是,必须在不安全的上下文中编译代码。例如:

Console.WriteLine($"size of nint = {sizeof(nint)}");
Console.WriteLine($"size of nuint = {sizeof(nuint)}");
// output when run in a 64-bit process
//size of nint = 8
//size of nuint = 8
// output when run in a 32-bit process
//size of nint = 4
//size of nuint = 4

// 也可以通过静态 IntPtr.Size 和 UIntPtr.Size 属性获得等效的值。

 本机大小的整数定义 MaxValue 或 MinValue 的属性。 这些值不能表示为编译时编译时,因为它们取决于目标计算机上整数的本机大小

若要在运行时获取本机大小的整数的最小值和最大值,请将 MinValue 和 MaxValue 用作 nint 和 nuint 关键字的静态属性,如以下示例中所示:

Console.WriteLine($"nint.MinValue = {nint.MinValue}");
Console.WriteLine($"nint.MaxValue = {nint.MaxValue}");
Console.WriteLine($"nuint.MinValue = {nuint.MinValue}");
Console.WriteLine($"nuint.MaxValue = {nuint.MaxValue}");
//  output when run in a 64-bit process----
//nint.MinValue = -9223372036854775808
//nint.MaxValue = 9223372036854775807
//nuint.MinValue = 0
//nuint.MaxValue = 18446744073709551615
//  output when run in a 32-bit process----
//nint.MinValue = -2147483648
//nint.MaxValue = 2147483647
//nuint.MinValue = 0
//nuint.MaxValue = 4294967295

这些值在运行时是只读的。编译器可将这些类型隐式和显式转换为其他数值类型。 

可在以下范围内对 nint 使用常量值:[int.MinValue .. int.MaxValue]。

可在以下范围内对 nuint 使用常量值:[uint.MinValue .. uint.MaxValue]。

没有适用于本机大小整数文本的直接语法。 没有后缀可表示文本是本机大小整数,例如 L 表示 long。 可以改为使用其他整数值的隐式或显式强制转换。 例如:

nint a = 42
nint a = (nint)42;

十六、函数指针(Function pointers)

C# 的函数指针由于会兼容 C 语言和 C++ 的函数,因此会有托管函数(托管方法,Managed Function)和非托管函数(非托管方法,Unmanaged Function)的概念。

  托管函数:函数由 C# 语法实现,底层也是用的 C# 提供的 CLR 来完成的。
  非托管函数:函数并不由 C# 实现,它不受 C# 语法控制,而是通过 DLL 文件交互使用。

本文只简单介绍下托管函数的函数指针,详情可参考大牛文章:探索 C# 9 的函数指针

托管函数的函数指针

先来说一下 C# 函数内部的函数指针(托管函数的函数指针)

unsafe
{
    int arr[] = { 3, 8, 1, 6, 5, 4, 7, 2, 9 };
    delegate* managed<int, int, int> funcPtr = &compareTwoValue; // Here.
    bubbleSort(arr, funcPtr); // Pass the function pointer.
}
static int compareTwoValue(int a, int b) => a - b;
static unsafe void bubbleSort(int* arr, delegate* managed<int, int, int> comparison)
{
    for (int i = 0; i < arr.Length; i++)
    {
        for (int j = 0; j < arr.Length - 1 - i; j++)
        {
            if (comparison(arr[j], arr[j + 1]) >= 0)
            {
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
}

就是把 C 里的 int (*ptr)(int, int) 改成 delegate* managed<int, int, int>

先写函数记号 delegate 关键字,然后带一个星号。这两个东西是函数指针声明的头部,是固定不变的语法规则。接着,指针符号后面写上 managed 关键字,这也是 C# 9 里提供的一个新关键字,它在这里的语义表示一个“托管函数”。然后使用委托的类似语法:尖括号里写类型参数表列,最后一个类型参数是整个函数的返回值类型。如果一个函数没有参数,返回值为 void 就写成 delegate* managed<void>,如果有多个参数,就把参数挨个写上去,然后返回值上追加一个类型参数在末尾就可以了。

另外,managed 默认可以不写,因为 C# 的函数指针默认是指向托管函数的,于是,记号就简化成了 delegate*<int, int, int>。当然,你得注意一下,函数指针是不安全的,所以需要先写 unsafe 才能用

十七、跳过临时变量初始化(Skip locals initialization)

这个特性不属于 C# 语法的特性,它是为 .NET 的 API 里添加了一个新的特性(attribute):SkipLocalsInitAttribute,它用来告诉当前代码块里,所有的变量都在定义的时候能不初始化的地方都不初始化,以便优化代码执行的效率。

十八、

在 c # 8 中, ? 批注仅适用于显式约束为值类型或引用类型的类型参数。 在 c # 9 中, ? 批注可应用于任何类型参数,而不考虑约束。

除非将类型形参显式约束为值类型,否则只能在上下文中应用注释 #nullable enable 。

如果类型参数 T 替换为引用类型,则 T? 表示该引用类型的可以为 null 的实例。

// 如果类型参数 T 替换为引用类型,则 T? 表示该引用类型的可以为 null 的实例
var s1 = new string[0].FirstOrDefault();  // string? s1
var s2 = new string?[0].FirstOrDefault(); // string? s2
// 如果 T 用值类型替换,则 T? 表示的实例 T 
var i1 = new int[0].FirstOrDefault();  // int i1
var i2 = new int?[0].FirstOrDefault(); // int? i2
// 如果 T 使用批注类型替换 U? ,则 T? 表示批注的类型 U? 而不是 U?? 
var u1 = new U[0].FirstOrDefault();  // U? u1
var u2 = new U?[0].FirstOrDefault(); // U? u2
// 如果 T 将替换为类型 U ,则 T? 表示 U? ,即使在上下文中也是如此 #nullable disable 
#nullable disable
var u3 = new U[0].FirstOrDefault();  // U? u3
// 对于返回值, T? 等效于 [MaybeNull]T ; 对于参数值, T? 等效于 [AllowNull]T 
// 在使用 c # 8 编译的程序集中重写或实现接口时,等效性非常重要
public abstract class A
{
    [return: MaybeNull] public abstract T F1<T>();
    public abstract void F2<T>([AllowNull] T t);
}
public class B : A
{
    public override T? F1<T>() where T : default { ... }       // matches A.F1<T>()
    public override void F2<T>(T? t) where T : default { ... } // matches A.F2<T>()
}

终极参考:C# 9.0 功能-官网

部分参考:C# 9 特性一览及评价

注:暂时整理到这里,欢迎指正和补充。

11-03 18:04