C# 二十年语法变迁之 C# 10参考

自从 2000 年引入 C# 以来,该语言的规模已经大大增加,我不确定任何人是否有可能随时对每个语言特性都有深入的了解。因此,我想写一系列快速参考文章,总结自 C# 2.0 以来所有主要的新语言特性。我不会详细介绍它们中的任何一个,但我希望这个系列可以作为我自己的参考(希望你也是!),我可以不时回过头来记住我使用的工具工具箱里有。:).

开始之前的一个小提示:我将跳过一些更基本的东西(例如 C# 2.0 引入了泛型,但它们的使用范围如此广泛,以至于它们不值得包括在内);我还可以将一些功能“粘合”在一起,以使其更简洁。本系列并不打算成为该语言的权威或历史记录。相反,它更像是可能派上用场的重要语言功能的“备忘单”。您可能会发现浏览左侧的目录以搜索您不认识或需要快速提醒的任何功能很有用。

C# 10.0

类文件命名空间声明作用域范围

我把它放在第一位,因为它多年来 一直在我的个人愿望清单上。这可能是我在 Java 中一直最怀念的事情,而且让我感到沮丧的是,我曾与之交谈过的其他 C# 工程师似乎都不在乎!

无论如何,功能如下:使用 C# 10,我们现在可以声明文件中的每个成员都在某个命名空间下作用域;而不是使用特定范围(使用)。例如:

// Beforenamespace BenBowen.Blog.FileScopedNamespaceDemo {    public class User {
    }}
// Afternamespace BenBowen.Blog.FileScopedNamespaceDemo;
public class User {
}

“之前和之后的文件范围命名空间声明” 文件范围的命名空间以分号结尾;而不是开始一个范围,并且必须在文件中的任何其他成员声明之前(在使用声明之前或之后,但按照惯例最好在之后)。

这意味着您的所有代码都可以少一个不必要的缩进级别!

扩展属性模式匹配

属性模式已经改进,使用点更自然地链接嵌套成员。操作员。以我之前的帖子为例:

// Taken from https://benbowen.blog/post/two_decades_of_csharp_iv/property_patterns.htmlvar salary = user switch {    Manager { ManagerialLevel: ManagerialLevel.CLevel } => 100_000, // C-Level managers get 100,000    Manager { ManagerialLevel: ManagerialLevel.UpperManagement } => 70_000, // Upper managers get 70,000    Manager _ => 50_000, // All other managers get 50,000    { LastAppraisal: { Rating: 10 } } => 40_000, // Users whose last appraisal gave them a 10/10 rating get 40,000    _ => 30_000 // Everyone else gets 30,000};

“属性模式” 我们现在可以将第 6 行重写为:

{ LastAppraisal.Rating: 10 } => 40_000, // Users whose last appraisal gave them a 10/10 rating get 40,000

“扩展属性模式” 不要忘记可以在is表达式中使用属性模式:

if (user is { LastAppraisal.Rating: 100 } or Manager { KeyMetrics.FinancialTargetStatus: TargetStatus.AboveTarget }) {    GiveBonus();}

“Is-Expression 中的扩展属性模式”

全局使用指令

在using指令(即用于导入命名空间的指令,通常位于文件顶部)前面 添加单词global将意味着命名空间被导入到项目中的每个文件中。我发现非常有趣的是使用 alias es 也可以工作,这对于两个依赖库之间的名称冲突可能非常方便。这是一个例子:

global using System.Linq;global using StringList = System.Collections.Generic.List<string>;

“GlobalImports.cs”

// No using statements here
var list = new StringList { "Hello", "I", "Am", "A", "List" }; // StringList instead of List<string>!Console.WriteLine(list.Sum(str => str.Length)); // And we can use Linq!

“Main.cs”

自定义无参数结构体构造函数

到目前为止,还不可能在任何结构中指定自定义无参数构造函数。但是,在 C# 10 中添加了此功能:

public readonly struct User {    public readonly string Name;    public readonly int Age;
    public User() { // Struct constructor defined with no parameters!        Name = "<no name>";        Age = -1;    }}
// ...
var u = new User();Console.WriteLine(u.Name + " " + u.Age); // Prints "<no name> -1"

“无参数结构构造函数” 正如您在上面的示例中看到的,现在可以为结构指定无参数构造函数。如果你明确想要一个结构的默认值,你仍然可以使用default关键字(即var user = default(User)),它不会使用用户定义的构造函数。无参数构造函数必须是public。

此功能以前不受支持,因为它为毫无戒心的库作者创造了潜在的陷阱。尽管添加此功能是一个受欢迎的改进,但我现在将演示使用无参数结构构造函数(和相关功能)可以通过多种方式出错:

结构体字段内联初始化器

在 C# 10 之前,不允许将值内联分配给结构定义中的字段/属性。这是有道理的,因为这种语法本质上是在类型中每个构造函数的开头分配给定值的简写,这将包括默认构造函数。但是,C# 10 现在允许这样做:

public readonly struct User {    public readonly string Name = "<no name>";    public readonly int Age = -1;
    public User(string name, int age) {        Name = name;        Age = age;    }}
// ...
var u = new User();Console.WriteLine(u.Name + " " + u.Age);

“内联结构字段初始化” 但是,您认为在此示例中控制台上会打印什么?如果您认为 -1,很抱歉告诉您这是不正确的(但这也是我的第一个猜测)!事实上,我们什么都看不到,而0是Name和Age的默认值。但是如果我们去掉另一个构造函数会发生什么?

public readonly struct User {    public readonly string Name = "<no name>";    public readonly int Age = -1;}
// ...
var u = new User();Console.WriteLine(u.Name + " " + u.Age);

“无构造函数的内联结构字段初始化” 现在我们确实在控制台上看到了 -1 。我承认一开始这真的让我感到惊讶,但经过多一点思考之后,它是有道理的:

在上面的示例中,还提供了内联字段初始化和构造函数,如果User是一个类,则值得考虑这段代码会是什么样子。实际上,在这种情况下,我们将无法调用无参数构造函数,因为在类类型中提供任何构造函数都会删除默认构造函数。

但是,结构仍然必须始终可以使用无参数构造函数进行实例化,因此User的 struct 版本中的无参数构造函数不会被编译器删除,而是恢复到像过去一样的行为,为我们提供默认值 (并忽略我们的字段初始值设定项)。

那么你什么时候想使用字段初始值设定项呢?好吧,当某些构造函数不提供这些值时,它们对于设置默认值仍然很有用:

public readonly struct User {    public readonly string Name;    public readonly int Age = -1;
    public User(string name) => Name = name;
    public User(string name, int age) {        Name = name;        Age = age;    }}
// ...
var u = new User("Ben");Console.WriteLine(u.Name + " " + u.Age);

“使用多个构造函数进行内联结构字段初始化” 在上面的示例中,我们将看到Ben -1打印到控制台。

如果您忽略在任何构造函数中初始化字段,编译器仍然会显示错误;通过直接赋值、字段初始值设定项或显式链接您的构造函数来调用另一个分配它的构造函数。

总而言之,我不确定让我们以这种方式在脚上开枪是否明智。感觉有点像枪[12]- 将非无参数构造函数添加到以前仅使用字段初始化的结构将更改整个代码库中其无参数构造函数的每次调用的行为,从使用字段初始化程序改为像默认值( ) . 我愿意就像添加自定义无参数结构构造函数一样,但如果没有字段初始值设定项,我也可以愉快地生活。我不认为它们增加了太多,但它们有可能导致不断和令人困惑的错误,而且在我的代码库中,我可能不会尽可能多地使用它们。我也觉得没有意识到这些微妙之处的程序员在使用它们或阅读使用它们编写的任何代码时可能会感到困惑。

数组和未初始化的字段

那么当我们有一个带有自定义无参数构造函数的结构类型,我们想将其用作另一个类中的字段或数组类型时会发生什么?

public readonly struct User {    public readonly string Name;    public readonly int Age;
    public User() {        Name = "<no name>";        Age = -1;    }}
public sealed class UserWrapper {    public User WrappedUser { get; }}
// ...
var uw = new UserWrapper();Console.WriteLine(uw.WrappedUser.Name + " " + uw.WrappedUser.Age); // What do you think this will print?

“使用自定义无参数构造函数作为字段的结构” 这将再次打印任何内容和0,即用户类型的默认值。这意味着没有调用无参数构造函数。这是我所期望的,你也应该如此。正如我们在上面已经看到的,C# 现在区分了结构的默认值和由无参数构造函数初始化的值。在 C# 10 之前,我们可以将这两个值视为一个且相同的值,但现在我们需要更加小心地考虑在任何给定情况下我们将获得哪个值。因为我们从未显式调用WrappedUser的构造函数,所以我们得到了它的默认值。

请注意,这种区分对于将new MyStructType()视为常量的任何地方也有影响。例如,虽然public static void MyExampleMethod(MyStructType input = new MyStructType()) { ... }将始终编译,但如果MyStructType指定自定义无参数构造函数,现在可能无法编译。那么数组呢?

var userArray = new User[3];Console.WriteLine(userArray[0].Name + " " + userArray[0].Age);

“使用自定义无参数构造函数作为数组类型的结构” 希望你会期望这不会打印任何内容并再次打印0,因为这就是我们得到的。

泛型

那么作为泛型类型参数呢?

public readonly struct User {    public readonly string Name;    public readonly int Age;
    public User() {        Name = "<no name>";        Age = -1;    }}
// ...
static void PrintStructDetails<T>() where T : struct {    void EnumerateFieldsOnToConsole(T instance, [CallerArgumentExpression("instance")] string? instanceName = null) { // If you're confused about this line, see the "Caller Argument Expressions" section below        foreach (var field in typeof(T).GetFields()) {            Console.WriteLine($"Field '{field.Name}' in {instanceName}: {field.GetValue(instance)}");        }    }
    var newedInstance = new T();    var defaultInstance = default(T);
    EnumerateFieldsOnToConsole(newedInstance);    EnumerateFieldsOnToConsole(defaultInstance);}

“具有自定义无参数构造函数的通用结构” 调用PrintStructDetails()会在控制台上打印以下内容:

Field 'Name' in newedInstance: <no name>Field 'Age' in newedInstance: -1Field 'Name' in defaultInstance:Field 'Age' in defaultInstance: 0

“PrintStructDetails 输出”

结构记录

我在本系列的上一篇文章中描述了记录:记录类型[17]。这一新功能由结构的自定义无参数构造函数启用,仅允许您将记录声明为结构(值类型)而不是默认值(类/引用类型)。您还可以将结构记录声明为只读(就像常规结构一样):

public readonly record struct User(string Name, int Age);
// ...
var user = new User { Name = "Ben", Age = 32 };user.Name = "Seb"; // Won't compile, 'Name' is init-only

“简单结构记录类型定义”

与“标准”(即类/引用类型)记录不同,默认情况下,结构记录的自动生成属性是可变的(即它们具有为它们生成的设置器)。您必须将结构记录指定为只读以使其自动生成的属性仅初始化。因此,简单地将标准记录更改为结构记录实际上会使您的自动生成的属性比以前更加可变,这可能不是您想要的!

调用者参数表达式

您可能还记得以前版本的 C# 中的调用方信息属性[19]。C# 10 现在添加了另一个CallerArgumentExpressionAttribute[20]。此属性将自动填充传递给另一个参数的代码中的值。举个例子可能最容易理解:

static void Evaluate(int value, [CallerArgumentExpression("value")] string? expression = null) {    Console.WriteLine($"{expression} = {value}");}
Evaluate(1512 - 19 * 7); // Prints "1512 - 19 * 7 = 1379" on the console

“CallerArgumentExpressionAttribute 示例”

结构上的“With”表达式

'With' 表达式是在 C# 9 中引入的,作为一种通过稍微修改现有实例的副本来创建新记录类型实例的方法[22]。在 C# 10 中,它们现在可以自动与可变结构一起使用:

public struct User {    public string Name;    public int Age;
    public User(string name, int age) {        Name = name;        Age = age;    }}
// ...
var user = new User("Ben", 31);var birthdayBoy = user with { Age = user.Age + 1 };Console.WriteLine(birthdayBoy.Name + " is now " + birthdayBoy.Age); // Prints "Ben is now 32"

“结构上的'With'表达式” 可变结构通常是不明智的[24],但幸运的是,此功能也适用于仅 init 属性:

public readonly struct User {    public string Name { get; init; }    public int Age { get; init; }}
// ...
var user = new User { Name = "Ben", Age = 31 };var birthdayBoy = user with { Age = user.Age + 1 };Console.WriteLine(birthdayBoy.Name + " is now " + birthdayBoy.Age); // Prints "Ben is now 32"

“不可变结构的‘With’表达式支持” 感谢 Reddit 上的 /u/meancoot向我指出这一点[26]

常量内插字符串

当字符串的每个组件本身都是常量字符串时,您现在可以在常量声明中使用内插字符串:

const string AppVersion = "1.2.3";const string WelcomeMessage = $"Thanks for installing SuperDuperApp. You are running version {AppVersion}.";

“常量内插字符串”

自定义内插字符串处理程序

此功能允许您手动处理API中插值字符串的插值逻辑。[28]此功能的主要用例是面向性能的场景;例如,当您知道它不会被使用时,允许您避免构建结果字符串。

MS 的文档中已经有这种情况的示例[29],所以我将把它混合一下。假设我们正在处理将数据发送到嵌入式硬件设备,并且由于该设备的内存限制,我们知道我们的字符串永远不会超过 100 个 UTF-8 字节。

然后,假设我们有一个最终大于 100 字节的内插字符串,我们希望优先显示动态数据(即{}大括号)而不是静态/常量字符串文字部分。我们别无选择,只能以某种方式截断字符串(因为最大值为 100 字节),所以最好尽可能多地保留动态数据!

首先,我们必须定义将数据发送到我们的嵌入式设备的 API:

public class EmbeddedClientStream {    public const int MaxMessageLength = 100;
    public void SendMessage(string message) {        // TODO truncate to 100 bytes max after encoding and send message
        Console.WriteLine($"Sent message: \"{message}\" ({Encoding.UTF8.GetBytes(message).Length} bytes UTF8)");    }
    public void SendMessage(EmbeddedClientStreamInterpolatedStringHandler interpolatedStringHandler) {        SendMessage(interpolatedStringHandler.GetFormattedText());    }}

“嵌入式客户端流 API” 这只是我们的 API,这里的实际实现无关紧要。我们提供了一个方法SendMessage,它接受一个字符串参数发送到我们的嵌入式设备;以及一个采用EmbeddedClientStreamInterpolatedStringHandler的额外重载。我们稍后将定义这种类型,我们可以将特殊的插值逻辑注入到我们的 API 中。我们在它上面调用GetFormattedText()来获得我们最终的结果字符串后插值。

在创建使用自定义插值字符串处理程序的 API 时,提供与纯字符串输入一起使用的重载总是一个好主意。这意味着您的 API 可以像使用插值字符串一样轻松地与常规非插值字符串一起使用。这是EmbeddedClientStreamInterpolatedStringHandler的实现:

[InterpolatedStringHandler]public readonly ref struct EmbeddedClientStreamInterpolatedStringHandler {    readonly List<(bool WasDynamicElement, Memory<byte> EncodedData)> _data;
    public EmbeddedClientStreamInterpolatedStringHandler(int literalLength, int formattedCount) {        // literalLength is the sum total length of all the literal 'sections' of the interpolated string        // formattedCount is the number of non-literal components to the string (i.e. the number of elements demarcated with {} braces)        // I'm not going to use either of them here
        _data = new();    }
    public void AppendLiteral(string s) { // This method is called to append a section of the literal (non-interpolated) part of the string        _data.Add((false, Encoding.UTF8.GetBytes(s)));    }
    public void AppendFormatted<T>(T obj) { // This method is called to append a dynamic object (i.e. an element enclosed in {} braces)        _data.Add((true, Encoding.UTF8.GetBytes(obj?.ToString() ?? "")));    }
    public void AppendFormatted<T>(T obj, string format) where T : IFormattable { // This method is called to append a dynamic object with a format string        _data.Add((true, Encoding.UTF8.GetBytes(obj?.ToString(format, null) ?? "")));    }
    public void AppendFormatted(byte[] obj) { // You can even supply specific methods for handling specific types         AppendFormatted(String.Join(null, obj.Select(b => b.ToString("x2"))));    }
    public string GetFormattedText() {        var totalLength = _data.Sum(tuple => tuple.EncodedData.Length);
        // Note: There's a more efficient way to do this; we could pre-calculate this as the data comes in.        // But it's a blog post about InterpolatedStringHandlers, not efficient algorithms, and I'm tired ;).        // And this will be at the bottom of the post where no one gets to anyway. Prove me wrong by leaving a comment!        while (totalLength > EmbeddedClientStream.MaxMessageLength && _data.Count > 0) {            var lastStaticElementIndex = -1;            totalLength = 0;
            for (var i = _data.Count - 1; i >= 0; --i) {                if (lastStaticElementIndex > 0 || _data[i].WasDynamicElement) totalLength += _data[i].EncodedData.Length;                else lastStaticElementIndex = i;            }
            _data.RemoveAt(lastStaticElementIndex > -1 ? lastStaticElementIndex : _data.Count - 1);        }
        return String.Join(null, _data.Select(tuple => Encoding.UTF8.GetString(tuple.EncodedData.Span)));    }}

“嵌入式客户端流自定义插值字符串处理程序” 现在,当我使用此 API 发送消息时会发生以下情况:

var clientStream = new EmbeddedClientStream();var messageAData = new byte[] { 0x3F, 0x7B, 0x14, 0x00 };var messageBData = new byte[] { 0x47, 0x21, 0xAE, 0x10, 0x3F, 0x7B, 0x14, 0x00 };var messageCData = new byte[] { 0x4B, 0x6A, 0x77, 0xFF, 0x47, 0x21, 0xAE, 0x10, 0x3F, 0x7B, 0x14, 0x00 };
clientStream.SendMessage($"Discovered missing packet (data {messageAData}). Please ensure shielding is applied (on {LineVoltage.FiveVolt:G} line)!");clientStream.SendMessage($"Discovered missing packet (data {messageBData}). Please ensure shielding is applied (on {LineVoltage.FiveVolt:G} line)!");clientStream.SendMessage($"Discovered missing packet (data {messageCData}). Please ensure shielding is applied (on {LineVoltage.FiveVolt:G} line)!");
// Result on console:// Sent message: "Discovered missing packet (data 3f7b1400). Please ensure shielding is applied (on FiveVolt line)!" (97 bytes UTF8)// Sent message: "Discovered missing packet (data 4721ae103f7b1400). Please ensure shielding is applied (on FiveVolt" (98 bytes UTF8)// Sent message: "Discovered missing packet (data 4b6a77ff4721ae103f7b1400FiveVolt" (64 bytes UTF8)

“嵌入式客户端流 API 使用” 注意我们的第一条只包含 4 个字节数据的消息是如何短到足以包含整个字符串的。但是,消息“B”有 8 个字节的数据要显示,这足以使我们的编码数据长度超过 100 个字节,因此我们的内插字符串处理程序选择在行尾(“ line)!”)。最后,消息“C”包含太多数据,以至于我们的内插字符串处理程序不得不删除更多数据;但它不只是截断消息的结尾,而是选择删除下一个字符串文字组件并将重要的LineVoltage参数保留在消息内容中。

实际上,您可能希望在这样的场景中实现一些更智能的东西,以明确字符串的各个部分被砸在一起的位置,但这只是一个示例!那么,这是如何工作的呢?

首先,请注意这是一个只读的 ref struct。这个例子实际上可以在没有ref修饰符的情况下正常工作,但我想证明这些处理程序可以是 ref 结构,这意味着您可以将 span 作为字段存储在其中。readonly修饰符也是可选的。

其次,请注意我们已经用InterpolatedStringHandlerAttribute[33]注释了这个类型。省略这个属性意味着编译器不知道我们的结构是一个内插的字符串处理程序,而是编译器回退到绑定到我们 API 中的SendMessage(string message)重载。构造函数必须采用两个int参数。如果没有,编译器在尝试编译对SendMessage()的调用时会出错。

当我们实际调用SendMessage()时,而不是像往常一样使用String.Format()插入字符串并将其传递给SendMessage(string),编译器会注意到我们有一个SendMessage()的重载,它采用标记为插值字符串处理程序。它将构造我们的处理程序的一个实例,然后开始使用AppendLiteral()和AppendFormatted()方法来构造结果字符串。我已将一次调用的 IL 输出附加到下面的clientStream.SendMessage()中:

    IL_003d: ldloc.0      // clientStream    IL_003e: ldloca.s     V_4    IL_0040: ldc.i4.s     81 // 0x51    IL_0042: ldc.i4.2    IL_0043: call         instance void TestingStuff.EmbeddedClientStreamInterpolatedStringHandler::.ctor(int32, int32)    IL_0048: ldloca.s     V_4    IL_004a: ldstr        "Discovered missing packet (data "    IL_004f: call         instance void TestingStuff.EmbeddedClientStreamInterpolatedStringHandler::AppendLiteral(string)    IL_0054: nop    IL_0055: ldloca.s     V_4    IL_0057: ldloc.1      // messageAData    IL_0058: call         instance void TestingStuff.EmbeddedClientStreamInterpolatedStringHandler::AppendFormatted(unsigned int8[])    IL_005d: nop    IL_005e: ldloca.s     V_4    IL_0060: ldstr        "). Please ensure shielding is applied (on "    IL_0065: call         instance void TestingStuff.EmbeddedClientStreamInterpolatedStringHandler::AppendLiteral(string)    IL_006a: nop    IL_006b: ldloca.s     V_4    IL_006d: ldc.i4.1    IL_006e: ldstr        "G"    IL_0073: call         instance void TestingStuff.EmbeddedClientStreamInterpolatedStringHandler::AppendFormatted<valuetype TestingStuff.LineVoltage>(!!0/*valuetype TestingStuff.LineVoltage*/, string)    IL_0078: nop    IL_0079: ldloca.s     V_4    IL_007b: ldstr        " line)!"    IL_0080: call         instance void TestingStuff.EmbeddedClientStreamInterpolatedStringHandler::AppendLiteral(string)    IL_0085: nop    IL_0086: ldloc.s      V_4    IL_0088: callvirt     instance void TestingStuff.EmbeddedClientStream::SendMessage(valuetype TestingStuff.EmbeddedClientStreamInterpolatedStringHandler)    IL_008d: nop

“一次调用 ClientStream.SendMessage() 的 IL”

编辑 22 年 1 月 22 日:我最初说过,结构上的 'with' 语句对于不可变/只读结构是不可能的。这实际上是错误的,它们仅使用 init 属性。感谢 reddit 上的 /u/meancoot 指出这一点。