深入-C--第四版-全-

深入 C# 第四版(全)

原文:C# in Depth 4e

译者:飞龙

协议:CC BY-NC-SA 4.0

第一部分. C#的背景

当我在大学学习计算机科学时,一位同学纠正了讲师在黑板上写的细节。讲师显得有些不耐烦,回答说:“是的,我知道。我在简化。我在这里掩盖真相是为了展示更大的真相。”虽然我希望我在第一部分中并没有太多地掩盖真相,但它确实关乎更大的真相。

本书的大部分内容都近距离地审视 C#,偶尔将其置于显微镜下以观察最细微的细节。在我们开始这样做之前,第一章将镜头拉远,以查看 C#的历史和 C#在更广泛的计算背景中的位置。

在我提供本书其余部分的主要内容之前,您将看到一些代码作为开胃菜,但在这个阶段细节并不重要。这部分更多地关于 C#的发展理念和主题,以使您处于最佳心态来欣赏这些想法是如何实现的。

让我们出发吧!

第一章. 最敏锐者的生存

本章涵盖

  • C#的快速进化如何使开发者更加高效

  • 选择 C#的次要版本以使用最新功能

  • 能够在更多环境中运行 C#

  • 从一个开放和积极参与的社区中受益

  • 本书关注旧版和新版 C#版本

选择在这里介绍 C#最有趣的部分是有难度的。有些部分非常迷人但很少使用。其他部分极其重要,但对 C#开发者来说现在已经司空见惯。例如,async/await 这样的功能在很多方面都很出色,但很难简要描述。无需多言,让我们看看 C#随着时间的推移走了多远。

1.1. 一个不断发展的语言

在本书的前几版中,我提供了一个示例,展示了该版所涵盖的语言版本的演变。但这种方式已经不再可行,因为它的阅读体验不再有趣。尽管大型应用程序可能使用几乎所有的新功能,但任何适合印刷页面的单个代码片段可能只会使用其中的一小部分。

相反,在这一节中,我选择了我认为 C#进化中最重要的一些主题,并给出了改进的简要示例。这远非一个详尽无遗的功能列表。这也不是为了教你这些功能;相反,它是对你已知的功能如何改进语言的一个提醒,以及对你可能尚未见过的功能的诱惑。

如果您认为其中一些功能模仿了您熟悉的其他语言,您几乎肯定是对的。C#团队毫不犹豫地从其他语言中汲取伟大的想法,并将它们重塑以在 C#中感到舒适。这是一件好事!F#特别值得提及,它是许多 C#功能的灵感来源。

注意

有可能 F#最大的影响不是它为 F#开发者带来的能力,而是它对 C#的影响。这并不是要低估 F#作为一门语言的价值,或者暗示它不应该直接使用。但当前,C#社区比 F#社区大得多,C#社区对 F#团队表示感激之情,因为 F#启发了 C#团队。

让我们从 C#最重要的一个方面开始:其类型系统。

1.1.1. 大规模和小规模的有用类型系统

C#从一开始就是一种静态类型语言:你的代码指定了变量的类型、参数、方法返回的值等等。你越能精确地指定代码接受和返回的数据形状,编译器就越能帮助你避免错误。

这尤其适用于你构建的应用程序增长时。如果你能在单屏上看到整个程序的所有代码(或者至少一次能将它们全部记在脑海中),静态类型语言的好处就不大了。随着规模的增加,你的代码简洁有效地传达其功能变得越来越重要。你可以通过文档来实现这一点,但静态类型让你能够以机器可读的方式传达信息。

随着 C#的发展,其类型系统允许更精细的描述。最明显的例子是泛型。在 C# 1 中,你可能会有这样的代码:

public class Bookshelf
{
    public IEnumerable Books { get { ... } }
}

Books序列中的每个项目是什么类型?类型系统不会告诉你。在 C# 2 中,通过泛型你可以更有效地沟通:

public class Bookshelf
{
    public IEnumerable<Book> Books { get { ... } }
}

C# 2 也引入了可空值类型,从而允许有效地表达信息的缺失,而无需依赖于像集合索引的-1 或日期的DateTime.MinValue这样的魔法值。

C# 7 为我们提供了使用readonly struct声明来告诉编译器用户定义的结构应该是不可变的能力。这个特性的主要目标可能是提高编译器生成的代码的效率,但它对传达意图也有额外的益处。

C# 8 的计划包括可空引用类型,这将允许更多的沟通。到目前为止,语言中没有任何东西让你表达一个引用(无论是作为返回值、参数还是局部变量)可能为 null。如果你不小心,这会导致错误代码;如果你小心,则会导致样板验证代码,这两种情况都不理想。C# 8 将期望任何未明确指定为可空的项都不是可空的。例如,考虑以下方法声明:

string Method(string x, string? y)

参数类型表明与x对应的参数不应该为 null,而与y对应的参数可能为 null。返回类型表明该方法不会返回 null。

C# 类型系统的其他更改旨在更小的规模上,并关注一个方法可能如何实现,而不是大型系统中的不同组件如何相互关联。C# 3 引入了 匿名类型隐式类型局部变量 (var)。这些有助于解决某些静态类型语言的缺点:冗长。如果你需要在单个方法内使用特定的数据形状,但其他地方不需要,只为该方法创建一个全新的类型就过于冗余了。匿名类型允许以简洁的方式表达这种数据形状,同时不失去静态类型的好处:

var book = new { Title = "Lost in the Snow", Author = "Holly Webb" };
string title = book.Title;                                             *1*
string author = book.Author;                                           *1*
  • 1 名称和类型仍然由编译器检查

匿名类型主要用于 LINQ 查询,但创建仅用于单个方法的类型的原理并不依赖于 LINQ。

类似地,似乎没有必要显式指定通过调用该类型的构造函数初始化的变量的类型。我知道以下哪种声明我觉得更简洁:

Dictionary<string, string> map1 = new Dictionary<string, string>();   *1*

var map2 = new Dictionary<string, string>();                          *2*
  • 1 显式类型

  • 2 隐式类型

虽然在处理匿名类型时需要隐式类型,但我发现它在处理常规类型时也越来越有用。区分 隐式 类型化和 动态 类型化很重要。前面的 map2 变量仍然是静态类型化的,但你不必显式编写类型。

匿名类型仅在单个代码块内有所帮助;例如,你不能将它们用作方法参数或返回类型。C# 7 引入了 元组:一种有效收集变量的值类型。这些元组的框架支持相对简单,但额外的语言支持允许为元组的元素命名。例如,你可以在前面的匿名类型之外使用以下方式:

var book = (title: "Lost in the Snow", author: "Holly Webb");
Console.WriteLine(book.title);

在某些情况下,元组可以替代匿名类型,但并非所有情况。它们的其中一个好处是,可以用作方法参数和返回类型。目前,我建议将这些保留在程序的内部 API 中,而不是公开暴露,因为元组代表的是值的简单组合,而不是封装它们。这就是为什么我仍然认为它们在实现层面上有助于编写更简单的代码,而不是改进整体程序设计。

我应该提到一个可能在 C# 8 中出现的特性:记录类型。我认为这在某种程度上可以被视为命名的匿名类型,至少在其最简单的形式中。它们将提供匿名类型在移除样板代码方面的好处,但允许这些类型获得与常规类相同的行为。请关注这个领域!

1.1.2. 代码更加简洁

在 C# 的新特性中,一个反复出现的主题一直是让你能够以越来越简洁的方式表达你的想法。类型系统是其中的一部分,正如你通过匿名类型所看到的,但许多其他特性也对此做出了贡献。你可能会听到很多关于这个话题的词汇,尤其是在使用新特性可以移除的内容方面。C# 的特性允许你减少 仪式感,移除 样板代码,并避免 冗余。这只是对同一效果的不同的描述方式。并不是说现在多余的代码有什么错误;它只是分散注意力且不必要的。让我们看看 C# 在这方面的一些演变方式。

构造和初始化

首先,我们将考虑如何创建和初始化对象。委托可能经历了最大的演变,并且是多阶段的。在 C# 1 中,你必须为委托编写一个单独的方法来引用,然后以冗长的方式创建委托本身。例如,在 C# 1 中,你会这样编写代码来订阅一个新的事件处理器到按钮的 Click 事件:

button.Click += new EventHandler(HandleButtonClick);     *1*
  • 1 C# 1

C# 2 引入了 方法组转换匿名方法。如果你想保留 HandleButtonClick 方法,方法组转换将允许你将前面的代码更改为以下内容:

button.Click += HandleButtonClick;      *1*
  • 1 C# 2

如果你的点击处理器很简单,你可能根本不想使用单独的方法,而是使用匿名方法:

button.Click += delegate { MessageBox.Show("Clicked!"); };     *1*
  • 1 C# 2

匿名方法还有一个额外的优点,可以作为 闭包 使用:它们可以使用创建它们的上下文中的局部变量。然而,在现代 C# 代码中,它们并不常用,因为 C# 3 提供了 lambda 表达式,它几乎具有匿名方法的所有优点,但语法更简洁:

button.Click += (sender, args) => MessageBox.Show("Clicked!");      *1*
  • 1 C# 3
注意

在这种情况下,lambda 表达式比匿名方法更长,因为匿名方法使用了 lambda 表达式没有的一个特性:通过不提供参数列表来忽略参数的能力。

我使用事件处理器作为委托的例子,因为在 C# 1 中,委托的主要用途就是作为事件处理器。在 C# 的后续版本中,委托被用于更多样化的场景,尤其是在 LINQ 中。

LINQ 还通过 对象初始化器集合初始化器 的形式带来了初始化的其他好处。这些允许你在单个表达式中指定要设置在新对象上的属性集或要添加到新集合中的项目。这比描述起来更简单,我将借用 第三章 中的一个示例。考虑你可能之前这样编写的代码:

var customer = new Customer();
customer.Name = "Jon";
customer.Address = "UK";
var item1 = new OrderItem();
item1.ItemId = "abcd123";
item1.Quantity = 1;
var item2 = new OrderItem();
item2.ItemId = "fghi456";
item2.Quantity = 2;
var order = new Order();
order.OrderId = "xyz";
order.Customer = customer;
order.Items.Add(item1);
order.Items.Add(item2);

C# 3 引入的对象和集合初始化器使这一点更加清晰:

var order = new Order
{
    OrderId = "xyz",
    Customer = new Customer { Name = "Jon", Address = "UK" },
    Items =
    {
        new OrderItem { ItemId = "abcd123", Quantity = 1 },
        new OrderItem { ItemId = "fghi456", Quantity = 2 }
    }
};

我不建议详细阅读这两个示例;重要的是第二种形式比第一种形式的简单性。

方法和属性声明

简化的最明显例子之一是通过自动实现属性。这些属性最初在 C# 3 中引入,但在后续版本中得到了进一步改进。考虑一个在 C# 1 中可能这样实现的属性:

private string name;
public string Name
{
    get { return name; }
    set { name = value; }
}

自动实现属性允许将其写为单行:

public string Name { get; set; }

此外,C# 6 引入了表达式主体成员,这减少了更多的仪式。假设您正在编写一个包装现有字符串集合的类,并且您希望有效地将您类的 CountGetEnumerator() 成员委托给该集合。在 C# 6 之前,您可能需要编写如下内容:

public int Count { get { return list.Count; } }

public IEnumerator<string> GetEnumerator()
{
    return list.GetEnumerator();
}

这是一个仪式的强烈例子:语言曾经要求的大量语法,而好处却很少。在 C# 6 中,这变得更加简洁。使用 => 语法(已经被 lambda 表达式使用)来指示表达式主体成员:

public int Count => list.Count;

public IEnumerator<string> GetEnumerator() => list.GetEnumerator();

虽然使用表达式主体成员的价值是个人和主观的,但我对它们对我的代码可读性带来的影响感到惊讶。我爱它们!另一个我没有预料到会像现在这样频繁使用的功能是字符串插值,这是 C# 中与字符串相关的改进之一。

字符串处理

C# 中的字符串处理经历了三项重大改进:

  • C# 5 引入了调用者信息属性,包括编译器自动将方法和文件名作为参数值填充的能力。这对于诊断目的非常有用,无论是永久性日志记录还是更临时的测试。

  • C# 6 引入了 nameof 操作符,它允许变量、类型、方法和其他成员以重构友好的形式表示。

  • C# 6 也引入了插值字符串字面量。这不是一个新概念,但它使得使用动态值构造字符串变得更加简单。

为了简洁起见,我将仅演示最后一点。构造包含变量、属性、方法调用结果等字符串的情况相当常见。这可能是用于日志记录目的、面向用户的错误消息(如果不需要本地化),异常消息等。

这里有一个来自我的 Noda Time 项目的例子。用户可以尝试通过其 ID 查找一个日历系统,如果该 ID 不存在,代码将抛出 KeyNotFoundException。在 C# 6 之前,代码可能看起来像这样:

throw new KeyNotFoundException(
    "No calendar system for ID "  + id + " exists");

使用显式字符串格式化,它看起来像这样:

throw new KeyNotFoundException(
    string.Format("No calendar system for ID {0} exists", id));
注意

有关 Noda Time 的信息,请参阅第 1.4.2 节。您不需要了解它就能理解这个示例。

在 C# 6 中,通过插值字符串字面量直接包含 id 的值,代码变得更加简洁:

throw new KeyNotFoundException($"No calendar system for ID {id} exists");

这看起来可能不是什么大问题,但我现在真的很讨厌没有字符串插值工作。

这些只是帮助提高代码信号与噪声比的最显著特性。我本可以展示 C# 6 中的 using static 指令和空条件运算符,以及 C# 7 中的模式匹配、解构和 out 变量。而不是将本章扩展到提及每个版本中的每个特性,让我们继续到一个比进化更革命性的特性:LINQ。

1.1.3. 使用 LINQ 进行简单数据访问

如果你询问 C# 开发者他们喜欢 C# 的哪些地方,他们很可能会提到 LINQ。你已经看到了一些构建 LINQ 的特性,但最激进的特性是查询表达式。考虑以下代码:

var offers =
    from product in db.Products
    where product.SalePrice <= product.Price / 2
    orderby product.SalePrice
    select new {
        product.Id, product.Description,
        product.SalePrice, product.Price
    };

这看起来与传统的 C# 完全不同。想象一下回到 2007 年,向一个使用 C# 2 的开发者展示这段代码,并解释说这具有编译时检查和 IntelliSense 支持,并且可以高效地执行数据库查询。哦,你还可以使用相同的语法来处理常规集合。

通过 表达式树 提供了对进程外数据的查询支持。这些表示代码为数据,LINQ 提供商可以分析代码将其转换为 SQL 或其他查询语言。尽管这非常酷,但我自己很少使用它,因为我很少与 SQL 数据库打交道。不过,我确实处理内存中的集合,并且我经常使用 LINQ,无论是通过查询表达式还是使用 lambda 表达式的方法调用。

LINQ 不仅为 C# 开发者提供了新的工具;它还鼓励我们以函数式编程为基础,以新的方式思考数据转换。这不仅仅影响数据访问。LINQ 提供了初步的动力去接受更多的函数式思想,但许多 C# 开发者已经接受了这些思想并将它们进一步发展。

C# 4 在动态类型方面进行了激进的变化,但我认为这并没有影响到像 LINQ 那样多的开发者。然后 C# 5 出现了,再次改变了游戏规则,这次是关于异步的。

1.1.4. 异步

异步编程在主流语言中一直是个难题。一些从一开始就考虑异步的语言已经创建出来,而一些函数式语言则使它相对容易,因为它们可以很好地处理它。但 C# 5 通过通常称为 async/await 的特性,为主流语言中的异步编程带来了新的清晰度。这个特性包括围绕异步方法的两个互补部分:

  • 异步方法无需开发者做任何努力就能产生表示异步操作的结果。这个结果类型通常是 TaskTask<T>

  • 异步方法使用 await 表达式来消费异步操作。如果方法尝试等待尚未完成的操作,方法将异步暂停,直到操作完成然后继续。

注意

更确切地说,我可以把这些称为异步 函数,因为匿名方法和 lambda 表达式也可以是异步的。

异步操作异步暂停的确切含义是事情变得复杂的地方,我现在不会尝试解释这一点。但结果是,你可以编写看起来主要像你更熟悉的同步代码的异步代码。它甚至以自然的方式允许并发。作为一个例子,考虑这个可能从 Windows Forms 事件处理器调用的异步方法:

private async Task UpdateStatus()
{
    Task<Weather> weatherTask = GetWeatherAsync();        *1*
    Task<EmailStatus> emailTask = GetEmailStatusAsync();  *1*
    Weather weather = await weatherTask;                  *2*
    EmailStatus email = await emailTask;                  *2*

    weatherLabel.Text = weather.Description;              *3*
    inboxLabel.Text = email.InboxCount.ToString();        *3*
}
  • 1 同时启动两个操作

  • 2 异步等待它们完成

  • 3 更新用户界面

除了同时启动两个操作并等待它们的结果之外,这还展示了 async/await 如何了解同步上下文。你正在更新用户界面,这只能在 UI 线程中完成,尽管也在启动和等待长时间运行的操作。在 async/await 之前,这将会很复杂且容易出错。

我不声称 async/await 是异步问题的银弹。它并不能神奇地消除随之而来的所有复杂性。相反,它通过移除之前所需的大量样板代码,让你能够专注于异步固有的困难方面。

你迄今为止看到的所有功能都是为了使代码更简单。我想提到的最后一个方面略有不同。

1.1.5. 平衡效率和复杂性

我记得我第一次接触 Java 的经历;它完全是解释执行的,速度慢得令人痛苦。过了一段时间,可选的即时编译器(JIT)变得可用,最终几乎可以想当然地认为任何 Java 实现都会进行 JIT 编译。

让 Java 表现良好需要付出很多努力。如果这种语言失败了,这种努力就不会发生。但开发者看到了潜力,并且已经感觉比以前更有效率。开发速度和交付速度往往比应用程序速度更重要。

C#处于一个稍微不同的位置。公共语言运行时(CLR)从一开始就相当高效。语言对与本地代码轻松互操作以及性能敏感的不安全代码的支持也有帮助。C#的性能随着时间的推移持续改进。(我带着一丝苦笑指出,微软现在正在广泛引入分层 JIT 编译,就像 Java HotSpot JIT 编译器一样。)

但不同的工作负载有不同的性能需求。正如你将在第 1.2 节中看到的那样,C#现在被用于各种令人惊讶的平台,包括游戏和微服务,这两者都可能对性能有很高的要求。

异步有助于在某些情况下提高性能,但 C# 7 是最明显关注性能的发布。只读结构和更大的ref特性表面面积有助于避免冗余复制。现代框架中存在的Span<T>特性,以及由类似 ref 的结构类型支持的特性,有助于减少不必要的分配和垃圾回收。显然,希望当这些技术被谨慎使用时,它们将满足特定开发者的需求。

我对这些特性有一丝不安的感觉,因为它们对我来说仍然感觉复杂。我无法像对常规值参数那样清晰地推理使用in参数的方法,并且我相信在适应我可以和不能使用 ref 局部变量和 ref 返回值之前,还需要一段时间。

我希望这些特性能够适度使用。它们会在有利的情境中简化代码,无疑会受到维护这些代码的开发者的欢迎。我期待在个人项目中尝试这些特性,并更加适应性能提升和代码复杂度之间的平衡。

我不想过于强调这个警告。我怀疑 C#团队在包含新特性时做出了正确的选择,无论我在工作中使用它们的频率高低。我只是想指出,你不必仅仅因为某个特性存在就使用它。做出选择加入复杂性的决定应该是经过深思熟虑的。说到选择加入,C# 7 带来了一个新的元特性:自 C# 1 以来首次使用次要版本号。

1.1.6. 快速演变:使用次要版本

C#的版本号集合是奇怪的,而且由于许多开发者可能会在框架和语言之间产生混淆,这使得问题更加复杂。(例如,没有 C# 3.5。.NET Framework 3.0 与 C# 2 一起发布,.NET 3.5 与 C# 3 一起发布。)C# 1 有两个发布版本:C# 1.0 和 C# 1.2。在 C# 2 和 C# 6(包括)之间,只有通常由 Visual Studio 新版本支持的次要版本。

C# 7 打破了这一趋势:有 C# 7.0、C# 7.1、C# 7.2 和 C# 7.3 的发布,这些版本都在 Visual Studio 2017 中可用。我认为这种模式在 C# 8 中很可能继续。目标是允许新特性快速地根据用户反馈进行演变。C# 7.1–7.3 的大多数特性都是对 C# 7.0 中引入特性的调整或扩展。

语言特性的波动可能会让人感到不安,尤其是在大型组织中。许多基础设施可能需要改变或升级,以确保新语言版本得到全面支持。许多开发者可能以不同的速度学习和采用新特性。如果其他什么都没有,那么语言比你习惯的更频繁地改变可能会让人感到有些不舒服。

因此,C# 编译器默认使用它支持的最新主要版本的最早次版本。如果你使用 C# 7 编译器并且没有指定任何语言版本,它将默认限制你使用 C# 7.0。如果你想使用更晚的次版本,你需要在项目文件中指定它并选择新功能。你可以通过两种方式来做这件事,尽管它们的效果相同。你可以直接编辑你的项目文件,在 <PropertyGroup> 中添加一个 <LangVersion> 元素,如下所示:

<PropertyGroup>
  ...                                  *1*
  <LangVersion>latest</LangVersion>    *2*
</PropertyGroup>
  • 1 其他属性

  • 2 指定项目的语言版本

如果你不喜欢直接编辑项目文件,你可以转到 Visual Studio 中的项目属性,选择“生成”选项卡,然后单击右下角的“高级”按钮。将打开高级生成设置对话框,如图 1.1 所示,允许你选择希望使用的语言版本和其他选项。

图 1.1. Visual Studio 中的语言版本设置

对话框中的此选项并非新功能,但现在你更有可能想要使用它,比之前的版本更频繁。你可以选择以下值:

  • 默认—最新主要版本的第一个发布版本

  • 最新版—最新版本

  • 特定版本号—例如,7.0 或 7.3

这不会改变你运行的编译器的版本;它改变的是你可以使用的语言功能集。如果你尝试使用在你目标版本中不可用的功能,编译器错误信息通常会解释需要哪个版本才能使用该功能。如果你尝试使用编译器完全不了解的语言功能(例如,使用 C# 7 功能与 C# 6 编译器),错误信息通常不太明确。

C# 作为一种语言,自从其首次发布以来已经走得很远了。那么它所运行的平台呢?

1.2. 一个不断发展的平台

近年来,.NET 开发者感到非常兴奋。同时,也存在一定程度的挫折感,因为微软和 .NET 社区都在逐步接受更加开放的开发模式的影响。但这么多人的辛勤工作所带来的整体成果是显著的。

多年来,运行 C# 代码几乎总是意味着在 Windows 上运行。这通常意味着一个用 Windows Forms 或 Windows Presentation Foundation (WPF) 编写的客户端应用程序,或者一个用 ASP.NET 编写的服务器端应用程序,可能运行在 Internet Information Server (IIS) 后面。其他选项已经存在很长时间了,特别是 Mono 项目有着丰富的历史,但 .NET 开发的主流仍然是在 Windows 上。

当我在 2018 年 6 月写下这些内容时,.NET 世界已经非常不同。最显著的发展是 .NET Core,这是一个可移植且开源的运行时和框架,由微软在多个操作系统上全面支持,并具有简化的开发工具。仅在几年前,这还是不可想象的。再加上一个可移植且开源的 IDE,即 Visual Studio Code,你将得到一个繁荣的 .NET 生态系统,开发者们在各种本地平台上工作,然后将它们部署到各种服务器平台上。

过分关注 .NET Core 并忽视 C# 当今运行的其他许多方式将会是一个错误。Xamarin 提供了丰富的多平台移动体验。其 GUI 框架(Xamarin Forms)允许开发者创建在不同设备上相当统一但也能利用底层平台的用户界面。

Unity 是世界上最受欢迎的游戏开发平台之一。它拥有定制的 Mono 运行时和即时编译,可以为习惯于更传统运行时环境的 C# 开发者提供挑战。但对于许多开发者来说,这可能是他们第一次,也许也是他们唯一一次使用这种语言的经验。

这些广泛采用的平台远非唯一使用 C# 的平台。我最近一直在使用 Try .NET 和 Blazor 进行不同形式的浏览器/C# 交互。

尝试 .NET 允许用户在浏览器中编写代码,具有自动完成功能,然后构建并运行该代码。这对于用尽可能低的门槛进行 C# 实验来说非常棒。

Blazor 是一个可以在浏览器中直接运行 Razor 页面的平台。这些页面不是由服务器渲染并在浏览器中显示的;用户界面代码是在浏览器中运行的,使用的是将 Mono 运行时转换为 Web-Assembly 的版本。几年前,整个运行时通过浏览器中的 JavaScript 引擎执行中间语言 (IL),不仅在全功能计算机上,而且在手机上,这个想法在我看来是荒谬的。我很高兴其他开发者有更多的想象力。在这个领域的大部分创新都得益于比以往任何时候都更加协作和开放的社区。

1.3. 一个不断发展的社区

我从 C# 1.0 时代就参与了 C# 社区,我从未见过它像今天这样充满活力。当我开始使用 C# 时,它被视为一种“企业”编程语言,而且相对较少有乐趣和探索的感觉。¹ 在这种背景下,与 Java(也被视为一种企业语言)相比,开源的 C# 生态系统增长相当缓慢。在 C# 3 时代,alt.NET 社区正在超越 .NET 开发的主流,这在某些意义上被视为与微软对抗。

¹

请不要误解;这是一个令人愉快的社区,而且一直有人为了乐趣而尝试 C#。

2010 年,NuGet(最初为 NuPack)包管理器被推出,这使得生产和使用类库(无论是商业的还是开源的)变得更加容易。尽管下载 zip 文件、将 DLL 复制到适当的位置,然后添加引用似乎并不特别重要,但每一个摩擦点都可能让开发者望而却步。

注意

除了 NuGet 之外,其他包管理器早在之前就已经开发出来,由 Sebastien Lambla 开发的 OpenWrap 项目尤其有影响力。

快进到 2014 年,微软宣布其 Roslyn 编译器平台将成为新的.NET 基金会的开源项目。随后宣布了.NET Core,最初代号为 Project K;DXN 随后出现,然后是现在发布和稳定的.NET Core 工具集。接着是 ASP.NET Core。还有 Entity Framework Core。还有 Visual Studio Code。真正在 GitHub 上生存和发展的产品名单还在继续。

技术很重要,但微软对开源的新拥抱对于健康社区同样至关重要。第三方开源包蓬勃发展,包括对 Roslyn 的创新使用和.NET Core 工具集内的集成,这些都感觉恰到好处。

所有这些都不是在真空中发生的。云计算的兴起使得.NET Core 比其他情况下对.NET 生态系统更加重要;对 Linux 的支持不是可选项。但由于.NET Core 的可用性,现在将 ASP.NET Core 服务打包到 Docker 镜像中、使用 Kubernetes 部署,并将其用作一个可能涉及多种语言的大型应用程序的一部分,已经不再特殊。许多社区之间优秀思想的交叉融合一直存在,但现在比以往任何时候都要强烈。

你可以在浏览器中学习 C#。你可以在任何地方运行 C#。你可以在 Stack Overflow 和其他众多网站上询问有关 C#的问题。你可以在 C#团队的 GitHub 仓库中参与关于语言未来的讨论。它并不完美;我们仍然需要共同努力,以便让 C#社区尽可能对每个人开放,但我们已经处于一个非常好的位置。

我希望认为C#深入理解在 C#社区中也有自己的一席之地。这本书是如何演变的?

1.4. 一本不断演变的书

你正在阅读C#深入理解的第四版。尽管这本书的演变速度没有语言、平台或社区快,但它也发生了变化。本节将帮助你了解这本书涵盖了哪些内容。

1.4.1. 混合级别覆盖

《C# 深入》的第一版于 2008 年 4 月出版,这恰好是我加入谷歌的时间。当时,我知道很多开发者对 C# 1 比较熟悉,但他们边走边学 C# 2 和 C# 3,并没有牢固地掌握所有部分是如何结合在一起的。我旨在通过深入语言来填补这一空白,帮助读者不仅理解每个特性做了什么,还理解为什么是这样设计的。

随着时间的推移,开发者的需求会发生变化。在我看来,社区似乎通过渗透作用几乎自然而然地吸收了对语言的更深入理解,至少对于早期版本来说是这样。对语言的深入理解不会是每个人的普遍体验,但对于第四版来说,我希望重点放在新版本上。我仍然认为理解语言版本的演变是有用的,但不需要查看 C# 2–4 中每个特性的每一个细节。

注意

逐个版本地查看语言并不是从头学习语言的最佳方式,但如果你想深入理解它,这很有用。我不会用同样的结构来为 C# 初学者写一本书。

我也不喜欢厚重的书籍。我不想让《C# 深入》显得令人畏惧、难以把握或难以书写。仅仅为了涵盖 C# 2–4 的 400 页内容似乎并不合适。因此,我对这些版本的内容进行了压缩。每个特性都被提及,我在认为合适的地方进行了详细说明,但深度不如第三版。你可以使用第四版的内容来回顾你已经了解的主题,并帮助你确定在第三版中想要了解更多信息的话题。你可以在 www.manning.com/books/c-sharp-in-depth-fourth-edition 找到访问第三版电子版链接。本版详细介绍了语言 5–7 版本。异步仍然是一个难以理解的话题,第三版显然完全没有涵盖 C# 6 或 7。

写作,就像软件工程一样,通常是一种平衡的艺术。我希望我在细节和简洁之间找到的平衡能对你有所帮助。

小贴士

如果你拥有这本书的实体副本,我强烈建议你在上面做笔记。记录下你不同意的地方或特别有用的部分。这样做将加强你在记忆中的内容,而笔记将在以后作为提醒。

1.4.2. 使用 Noda Time 的示例

我在书中提供的多数示例都是独立的。但为了更有效地说明某些特性,能够指出我在生产代码中使用它们的地方是有用的。大多数情况下,我会使用 Noda Time 来做这件事。

Noda Time 是一个我在 2009 年开始的开源项目,旨在为.NET 提供更好的日期和时间库。尽管如此,它还有一个次要目的:它是我一个很好的沙盒项目。它帮助我磨练 API 设计技能,了解更多关于性能和基准测试的知识,并测试新的 C#功能。当然,这一切都不会破坏用户的使用。

每一个 C#的新版本都引入了我在 Noda Time 中能够使用的功能,因此我认为在本书中使用这些功能作为具体示例是有意义的。所有代码都可在 GitHub 上找到,这意味着你可以克隆它并亲自实验。在示例中使用 Noda Time 的目的并不是为了说服你使用这个库,但如果这成为了一个副作用,我也不会抱怨。

在本书的其余部分,我将假设当提到 Noda Time 时,你知道我在说什么。为了使其适合示例,其重要方面如下:

  • 代码需要尽可能易于阅读。如果语言特性允许我为了可读性而重构,我会抓住这个机会。

  • Noda Time 遵循语义版本控制,并且新的大版本发布很少。我关注新语言特性的向后兼容性方面。

  • 我没有具体的性能目标,因为 Noda Time 可以在许多具有不同要求的上下文中使用。我确实关注性能,并且会接受那些提高效率的功能,只要它们不会使代码变得过于复杂。

要了解更多关于该项目及其源代码的信息,请访问nodatime.orggithub.com/nodatime/nodatime

1.4.3. 术语选择

我尽量在书中尽可能接近官方 C#术语,但有时我会让清晰度优先于精确度。例如,在撰写关于异步性的内容时,我经常提到异步方法,尽管相同的信息也适用于异步匿名函数。同样,对象初始化器适用于可访问字段以及属性,但只需提及一次并在此后的解释中仅提及属性会更简单。

有时规范中的术语在更广泛的社区中很少使用。例如,规范中有“函数成员”的概念。这是一个方法、属性、事件、索引器、用户定义的操作符、实例构造函数、静态构造函数或终结器。这是一个可以包含可执行代码的类型成员的术语,当描述语言特性时很有用。当你查看自己的代码时,它几乎没什么用,这就是为什么你可能从未听说过它。我尽量少用这样的术语,但我的观点是,为了更接近语言,了解它们是有价值的。

最后,一些概念没有官方的术语,但仍然可以用简短的形式来引用。我可能最常使用的是不可言说的名称。这个术语是由埃里克·利珀特提出的,指的是编译器生成的标识符,用于实现迭代块或 lambda 表达式等功能。[2]) 这个标识符在 CLR 中是有效的,但在 C#中不是,它是一个在语言内部不能“说出”的名字,因此可以保证不会与你的代码冲突。

²

我们认为这应该是埃里克。埃里克不确定,但认为 Anders Hejlsberg 可能是第一个提出这个术语的人。不过,我总是将这个术语与埃里克联系在一起,以及他对异常的分类:致命的、愚蠢的、令人烦恼的或外生的。

摘要

我喜欢 C#。它既舒适又令人兴奋,我也喜欢看到它未来的发展方向。我希望这一章已经将其中的一些兴奋传递给了你。但这仅仅是一个尝试。让我们不再拖延,直接进入这本书的真正内容。

第二部分。C# 2–5

本书这部分内容涵盖了从 C# 2(随 Visual Studio 2005 发布)到 C# 5(随 Visual Studio 2012 发布)之间引入的所有功能。这正是本书第三版全部内容所涵盖的功能集。其中许多现在感觉就像古老的历史;例如,我们简单地认为 C#包含了泛型。

这是对 C#来说一个非常富有成效的时期。我在这部分内容中将要介绍的一些功能包括泛型、可空值类型、匿名方法、方法组转换、迭代器、部分类型、静态类、自动实现属性、隐式类型局部变量、隐式类型数组、对象初始化器、集合初始化器、匿名类型、Lambda 表达式、扩展方法、查询表达式、动态类型、可选参数、命名参数、COM 改进、泛型协变和逆变、async/await 以及调用者信息属性。哇!

我预计你们中的大多数人至少对大多数功能有些熟悉,所以我在这部分内容中会快速展开。同样,为了合理地简短,我没有像第三版那样详细地介绍。目的是覆盖各种读者的需求:

  • 介绍您在过程中可能错过的功能

  • 提醒您曾经了解但已遗忘的功能

  • 解释功能背后的原因:为什么它们被引入以及为什么它们被设计成这样的方式

  • 如果您知道您想做什么但忘记了某些语法,这是一个快速参考

如果您需要更多细节,请参阅第三版。作为提醒,购买第四版您将有权获得第三版的电子书副本。

这里有一个例外:我完全重写了关于 async/await 的介绍,这是 C# 5 中最大的功能。第五章涵盖了您使用 async/await 所需了解的内容,而第六章则讨论了它在幕后是如何实现的。如果您对 async/await 是新手,您几乎肯定会在阅读第六章之前先使用一下它,即使那样,您也不应该期望它是一个简单的阅读。我已经尽力以尽可能易于理解的方式解释事物,但这个主题本质上很复杂。我确实鼓励您尝试;在深度理解 async/await 之后,即使您永远不需要深入研究编译器为您自己的代码生成的 IL,这也能帮助您在使用该功能时增强信心。好消息是,在第六章之后,您会发现第七章带来了一丝轻松。这是书中最短的一章,也是探索 C# 6 之前恢复的机会。

在所有介绍都完成之后,准备好迎接功能的大潮。

第二章。C# 2

本章涵盖

  • 使用泛型类型和方法编写灵活、安全的代码

  • 使用可空值类型表达信息缺失

  • 相对容易地构建代表

  • 无需编写样板代码即可实现迭代器

如果你的 C# 经验足够久远,这一章将提醒我们我们已经走了多远,并促使我们感激一个专注且聪明的语言设计团队。如果你从未使用泛型编写过 C#,你可能会想知道 C# 没有这些功能是如何起飞的。¹] 无论哪种情况,你仍然可能会在这里找到你之前不知道的功能或从未考虑过的细节。

¹

对于我来说,这个问题的答案很简单:C# 1 对于许多开发者来说比当时的 Java 更有效率。

自从 C# 2(与 Visual Studio 2005 一起发布)发布以来已经超过 10 年了,因此很难对后视镜中的功能感到兴奋。你不应该低估它在当时的重要性。它也是一个痛苦的过程:从 C# 1 和 .NET 1.x 升级到 C# 2 和 .NET 2.0 花了很长时间才在业界推广。随后的演变要快得多。C# 2 的第一个特性是几乎所有开发者都认为最重要的特性:泛型。

2.1. 泛型

泛型允许你编写通用代码,在编译时使用相同的类型在多个地方进行类型安全,而不必事先知道该类型是什么。当泛型最初引入时,它们的主要用途是集合,但在现代 C# 代码中,它们无处不在。它们可能最常用于以下方面:

  • 集合(它们在集合中的用途和以前一样有用)

  • 代表们,尤其是在 LINQ 中

  • 异步代码,其中 Task<T> 是类型为 T 的未来值的承诺

  • 可空值类型,我将在第 2.2 节中详细介绍

这并不是它们有用性的极限,但即使这四个要点也意味着 C# 程序员每天都在使用泛型。集合提供了解释泛型优势的最简单方式,因为你可以查看 .NET 1 中的集合,并将它们与 .NET 2 中的泛型集合进行比较。

2.1.1. 以示例为例:泛型之前的集合

.NET 1 有三种主要的集合类型:

  • 数组—这些具有直接的语言和运行时支持。大小在初始化时固定。

  • 基于对象的集合—值(以及相关的键)通过 System.Object 在 API 中描述。它们没有特定的集合语言或运行时支持,尽管可以使用诸如索引器和 foreach 语句之类的语言功能。ArrayListHashtable 是最常用的例子。

  • 专用集合——值在 API 中用特定类型描述,并且集合只能用于该类型。例如,StringCollection 是一个字符串集合;它的 API 看起来像 ArrayList,但使用 String 而不是 Object 来引用值。

数组和专用集合是 静态类型,我的意思是 API 阻止你将错误类型的值放入集合中,当你从集合中获取值时,你不需要将结果转换回你期望的类型。

注意

引用类型数组在存储值时由于数组协变而 大部分是安全的。我认为数组协变是一个早期的设计错误,超出了本书的范围。Eric Lippert 在他的关于协变和逆变的博客帖子系列中讨论了这一点,请参阅 mng.bz/gYPv

让我们具体分析一下:假设你想要在一个方法(GenerateNames)中创建一个字符串集合,并在另一个方法(PrintNames)中打印这些字符串。你将考虑三种保持名字集合的方法——数组、ArrayListStringCollection——并权衡每种方法的优缺点。每种情况下的代码看起来都很相似(尤其是对于 PrintNames),但请耐心等待。我们将从数组开始。

列表 2.1. 使用数组生成和打印名字
static string[] GenerateNames()
{
    string[] names = new string[4];      *1*
    names[0] = "Gamma";
    names[1] = "Vlissides";
    names[2] = "Johnson";
    names[3] = "Helm";
    return names;
}

static void PrintNames(string[] names)
{
    foreach (string name in names)
    {
        Console.WriteLine(name);
    }
}
  • 1 数组的大小需要在创建时已知

我在这里没有使用数组初始化器,因为我想模拟只发现一个名字的情况,比如从文件中读取它们。请注意,你一开始就需要分配正确的数组大小。如果你真的从文件中读取,你可能需要在开始之前找出有多少个名字,或者你可能需要编写更复杂的代码。例如,你可以一开始分配一个数组,如果第一个数组满了,就复制内容到一个更大的数组,依此类推。如果你最终得到的数组比确切的名字数量大,你可能还需要考虑创建一个正好大小的最终数组。

用来跟踪我们集合大小、重新分配数组等的代码是重复的,可以被封装在一种类型中。实际上,这正是 ArrayList 所做的。

列表 2.2. 使用 ArrayList 生成和打印名字
static ArrayList GenerateNames()
{
    ArrayList names = new ArrayList();
    names.Add("Gamma");
    names.Add("Vlissides");
    names.Add("Johnson");
    names.Add("Helm");
    return names;
}
static void PrintNames(ArrayList names)
{
    foreach (string name in names)       *1*
    {
        Console.WriteLine(name);
    }
}
  • 1 如果 ArrayList 包含非字符串会发生什么?

在我们的 GenerateNames 方法中,这样做更简洁:在开始向集合添加内容之前,你不需要知道你有多少个名字。但同样,也没有什么可以阻止你向集合中添加非字符串;ArrayList.Add 参数的类型只是 Object

此外,尽管 PrintNames 方法在类型方面看起来很安全,但实际上并不安全。集合可以包含任何类型的对象引用。如果你向集合中添加了一个完全不同的类型(例如一个 WebRequest,作为一个奇怪的例子),然后尝试打印它,你会期望发生什么?由于 name 变量的类型,foreach 循环隐藏了一个隐式转换,从 object 转换为 string。这种转换可能会以正常的方式失败,抛出 InvalidCastException。因此,你解决了一个问题,但引发了另一个。有什么东西可以解决这两个问题?

列表 2.3. 使用 StringCollection 生成和打印名称
static StringCollection GenerateNames()
{
    StringCollection names = new StringCollection();
    names.Add("Gamma");
    names.Add("Vlissides");
    names.Add("Johnson");
    names.Add("Helm");
    return names;
}

static void PrintNames(StringCollection names)
{
    foreach (string name in names)
    {
        Console.WriteLine(name);
    }
}

列表 2.3 与 列表 2.2 完全相同,只是将所有 ArrayList 替换为 StringCollection。这就是 StringCollection 的全部意义:它应该感觉像是一个令人愉快的通用集合,但仅限于处理字符串。StringCollection.Add 的参数类型是 String,因此你不能通过我们代码中的某个奇怪错误将其添加一个 WebRequest。结果是,当你打印名称时,你可以确信 foreach 循环不会遇到任何非字符串引用。(当然,你仍然可能看到空引用。)

如果你总是只需要字符串,那当然很好。但如果你需要其他类型的集合,你必须要么希望框架中已经存在一个合适的集合类型,要么自己编写一个。这是一个如此常见的任务,以至于有一个 System.Collections.CollectionBase 抽象类来使工作不那么重复。还有代码生成器来避免手动编写所有内容。

这解决了之前解决方案中的两个问题,但所有这些额外类型的成本实在太高了。在代码生成器更改时,保持它们更新需要维护成本。在编译时间、程序集大小、JIT 编译时间和保持代码在内存中等方面存在效率成本。最重要的是,跟踪所有可用的集合类需要人力成本。

即使这些成本并不太高,你也会错过编写一种可以在静态类型方式下适用于任何集合类型的方法的能力,可能还会使用集合的元素类型作为另一个参数或返回类型。例如,假设你想编写一个方法来将集合的前 N 个元素复制到一个新的集合中,然后返回。你可以编写一个返回 ArrayList 的方法,但这会失去静态类型的好处。如果你传入一个 StringCollection,你希望返回一个 StringCollection。字符串方面是方法输入的一部分,然后需要将其传播到输出。在使用 C# 时,你无法表达这一点。1. 进入泛型时代。

2.1.2. 泛型拯救了世界

让我们直接解决我们的 GenerateNames/PrintNames 代码的解决方案,并使用 List<T> 泛型类型。List<T> 是一个集合,其中 T 是集合的元素类型——在我们的例子中是 string。您可以在任何地方用 List<string> 替换 StringCollection。^([2])

²

我故意不讨论使用接口作为返回类型和参数的可能性。这是一个有趣的话题,但我不想让您从泛型中分心。

列表 2.4. 使用 List<T> 生成和打印名称
static List<string> GenerateNames()
{
    List<string> names = new List<string>();
    names.Add("Gamma");
    names.Add("Vlissides");
    names.Add("Johnson");
    names.Add("Helm");
    return names;
}

static void PrintNames(List<string> names)
{
    foreach (string name in names)
    {
        Console.WriteLine(name);
    }
}

List<T> 解决了我们之前讨论的所有问题:

  • 与数组不同,您不需要事先知道集合的大小。

  • 暴露的 API 在需要引用元素类型的地方都使用 T,因此您知道 List<string> 将只包含字符串引用。如果您尝试添加其他任何东西,将会得到编译时错误,这与 ArrayList 不同。

  • 您可以使用任何元素类型,而无需担心生成代码和管理结果,这与 StringCollection 和类似类型不同。

泛型还解决了将元素类型作为方法输入表达的问题。要更深入地探讨这个方面,您需要更多的术语。

类型参数和类型参数

术语 参数参数 在 C# 中的泛型出现之前就已经存在,并且在其他语言中使用了几十年。一个方法声明其输入为参数,它们以参数的形式由调用代码提供。图 2.1 展示了这两个术语是如何相互关联的。

图 2.1. 方法参数和参数之间的关系

图片

参数的值用作方法内部参数的初始值。在泛型中,您有 类型参数类型参数,这是相同的概念,但应用于类型。泛型类型或方法的声明包括在名称之后使用尖括号声明的类型参数。在声明的主体内部,代码可以使用类型参数作为正常类型(只是它对它了解不多)。

使用泛型类型或方法的代码随后在名称之后也指定了类型参数。图 2.2 展示了在 List<T> 上下文中这种关系。

图 2.2. 类型参数和类型参数之间的关系

图片

现在想象一下 List<T> 的完整 API:所有的方法签名、属性等等。如果您使用图中的 list 变量,API 中的任何 T 都将变为 string。例如,List<T> 中的 Add 方法具有以下签名:

public void Add(T item)

但如果您在 Visual Studio 中输入 list.Add(,IntelliSense 将会提示您,好像 item 参数已经被声明为 string 类型。如果您尝试传递其他类型的参数,将会导致编译时错误。

虽然 图 2.2 指的是泛型类,但方法也可以是泛型的。方法声明类型参数,并且这些类型参数可以在方法签名的其他部分中使用。方法类型参数通常用作签名中其他类型的类型参数。以下列表显示了之前无法实现的方法的解决方案:创建一个包含现有集合中前 N 个元素的新集合,但以静态类型的方式。

列表 2.5. 将元素从一个集合复制到另一个集合
public static List<T> CopyAtMost<T>(                         *1*
 List<T> input, int maxElements)                          *1*
{
    int actualCount = Math.Min(input.Count, maxElements);
 List<T> ret = new List<T>(actualCount);                  *2*
    for (int i = 0; i < actualCount; i++)
    {
        ret.Add(input[i]);
    }
    return ret;
}

static void Main()
{
    List<int> numbers = new List<int>();
    numbers.Add(5);
    numbers.Add(10);
    numbers.Add(20);

    List<int> firstTwo = CopyAtMost<int>(numbers, 2);        *3*
    Console.WriteLine(firstTwo.Count);
}
  • 1 方法声明一个类型参数 T 并在参数和返回类型中使用它。

  • 2 在方法体中使用类型参数

  • 3 使用 int 作为类型参数的方法调用

许多泛型方法在签名中只使用一次类型参数^([3]),并且它不是任何泛型类型的类型参数。但使用类型参数来表达常规参数类型与返回类型之间关系的能力是泛型功能的重要组成部分。

³

虽然编写一个在签名中其他地方没有使用类型参数的泛型方法是有效的,但这通常没有太大用处。

同样,泛型类型可以在声明基类或实现接口时使用它们的类型参数作为类型参数。例如,List<T> 类型实现了 IEnumerable<T> 接口,因此类声明可以写成这样:

public class List<T> : IEnumerable<T>
注意

事实上,List<T> 实现了多个接口;这是一个简化的形式。

泛型类型和方法的度数

泛型类型或方法可以通过在尖括号内用逗号分隔来声明多个类型参数。例如,.NET 1 Hashtable 类的泛型等效声明如下:

public class Dictionary<TKey, TValue>

声明的泛型 度数 是它拥有的类型参数的数量。说实话,这是一个在日常编写代码时比作者更有用的术语,但我认为它仍然值得了解。你可以将非泛型声明视为具有泛型度数 0 的声明。

声明的泛型度数实际上是使其独特的一部分。例如,我已经提到了在 .NET 2.0 中引入的 IEnumerable<T> 接口,但它与 .NET 1.0 中已经存在的非泛型 IEnumerable 接口是不同的类型。同样,即使它们的签名在其他方面相同,你也可以编写具有相同名称但不同泛型度数的方法:

public void Method() {}            *1*
public void Method<T>() {}         *2*
public void Method<T1, T2>() {}    *3*
  • 1 非泛型方法(泛型度数 0)

  • 2 泛型度数为 1 的方法

  • 3 泛型度数为 2 的方法

当声明具有不同泛型度数的类型时,这些类型不必属于同一类型,尽管它们通常是这样的。作为一个极端的例子,考虑以下可以在一个高度混乱的程序集中共存的类型声明:

public enum IAmConfusing {}
public class IAmConfusing<T> {}
public struct IAmConfusing<T1, T2> {}
public delegate void IAmConfusing<T1, T2, T3> {}
public interface IAmConfusing<T1, T2, T3, T4> {}

虽然我强烈反对上述代码,但一个相对常见的模式是有一个非泛型静态类提供辅助方法,这些方法引用具有相同名称的其他泛型类型(有关静态类的更多信息,请参阅第 2.5.2 节)。例如,你会在第 2.1.4 节中看到 Tuple 类,它用于创建各种泛型 Tuple 类的实例。

就像多个类型可以有相同的名字但不同的泛型参数数量一样,泛型方法也是如此。这就像基于参数创建重载,但这是基于类型参数数量的重载。请注意,尽管泛型参数数量保持了声明之间的分离,但类型参数名称并没有。例如,你不能声明如下两个方法:

public void Method<TFirst>()  {}
public void Method<TSecond>() {}      *1*
  • 1 编译时错误;不能仅通过类型参数名称进行重载

这些被认为具有等效的签名,因此它们在方法重载的正常规则下是不被允许的。只要方法在其他方面有所不同(例如常规参数的数量),你就可以编写使用不同类型参数名称的方法重载,尽管我不记得曾经想要这样做。

当我们谈论多个类型参数时,你不可能在同一个声明中给两个类型参数相同的名字,就像你不能声明两个常规参数具有相同的名字一样。例如,你不能声明如下方法:

public void Method<T, T>() {}         *1*
  • 1 编译时错误;重复的类型参数 T

虽然两个类型参数相同是可以的,而且这通常是你的需求。例如,为了创建字符串到字符串的映射,你可能使用 Dictionary<string, string>

之前 IAmConfusing 的例子使用了枚举作为非泛型类型。这不是巧合,因为我想要用它来展示我的下一个观点。

2.1.3. 什么是泛型?

并非所有类型或类型成员都可以是泛型的。对于类型来说,这相对简单,部分原因是因为可以声明的类型种类相对较少。枚举不能是泛型,但类、结构体、接口和委托都可以。

对于类型成员,这可能会稍微有些令人困惑;一些成员可能看起来像是泛型,因为它们使用了其他泛型类型。记住,只有当声明引入了新的类型参数时,声明才是泛型的。

方法和嵌套类型可以是泛型的,但以下所有都必须是非泛型的:

  • 字段

  • 属性

  • 索引器

  • 构造函数

  • 事件

  • 析构函数

作为你可能倾向于将字段视为泛型,尽管它不是泛型的例子,考虑以下泛型类:

public class ValidatingList<TItem>
{
    private readonly List<TItem> items = new List<TItem>();     *1*
}
  • 1 许多其他成员

我将类型参数命名为 TItem,只是为了将其与 List<T>T 类型参数区分开来。在这里,items 字段是 List<TItem> 类型。它使用类型参数 TItem 作为 List<T> 的类型参数,但这是由类声明引入的类型参数,而不是由字段声明引入的。

对于这些中的大多数,很难想象成员可以是泛型的。偶尔,我想要编写一个泛型构造函数或索引器,但答案几乎总是编写一个泛型方法。

说到泛型方法,我在描述泛型方法的调用方式时,只给出了类型参数的简化描述。在某些情况下,编译器可以确定调用中的类型参数,而无需你在源代码中提供它们。

2.1.4. 方法的类型参数类型推断

让我们回顾一下列表 2.5 的关键部分。你有一个这样声明的泛型方法:

public static List<T> CopyAtMost<T>(List<T> input, int maxElements)

然后,在Main方法中,你声明一个类型为List<int>的变量,稍后将其用作方法的参数:

List<int> numbers = new List<int>();
...
List<int> firstTwo = CopyAtMost<int>(numbers, 2);

我在这里突出了方法调用。你需要为CopyAtMost调用提供一个类型参数,因为它有一个类型参数。但你不必在源代码中指定该类型参数。你可以将那段代码重写如下:

List<int> numbers = new List<int>();
...
List<int> firstTwo = CopyAtMost(numbers, 2);

这正是编译器在 IL 中生成的完全相同的调用方法。但你不必指定int的类型参数;编译器为你推断出了这个类型。它是根据你在方法中的第一个参数的参数推断出来的。你使用类型为List<int>的参数作为类型为List<T>的参数的值,因此T必须是int

类型推断只能使用你传递给方法的参数,而不能使用你对结果所做的操作。它还必须是完整的;你必须显式指定所有类型参数或根本不指定。

虽然类型推断仅适用于方法,但它可以用来更轻松地构造泛型类型的实例。例如,考虑.NET 4.0 中引入的Tuple类型家族。它由一个非泛型的静态Tuple类和多个泛型类组成:Tuple<T1>Tuple<T1, T2>Tuple<T1, T2, T3>等等。静态类有一组重载的Create工厂方法,如下所示:

public static Tuple<T1> Create<T1>(T1 item1)
{
    return new Tuple<T1>(item1);
}

public static Tuple<T1, T2> Create<T1, T2>(T1 item1, T2 item2)
{
    return new Tuple<T1, T2>(item1, item2);
}

这些看起来毫无意义,但它们允许在创建元组时使用类型推断,否则在创建元组时必须显式指定类型参数。而不是这样做

new Tuple<int, string, int>(10, "x", 20)

你可以写出这个:

Tuple.Create(10, "x", 20)

这是一个需要了解的强大技术;它通常很容易实现,可以使处理泛型代码变得更加愉快。

我不会深入探讨泛型类型推断的工作原理。随着时间的推移,语言设计者找到了使其在更多情况下工作的方式,因此它发生了很大的变化。重载解析和类型推断紧密相连,并且与所有各种其他特性(如继承、转换和 C# 4 中的可选参数)相交。这是我认为最复杂的规范领域,^([4]),在这里我无法做到公正。

我并不孤单。在撰写本文时,重载解析的规范已经破裂。为 C# 5 ECMA 标准修复它的努力失败了;我们将为下一版再次尝试。

幸运的是,这是理解细节在日常编码中不会帮助很大的一个领域。在任何特定情况下,存在三种可能性:

  • 类型推断成功并给出了你想要的结果。太好了。

  • 类型推断成功但给出了你不想得到的结果。只需显式指定类型参数或对一些参数进行类型转换。例如,如果你从前面的Tuple.Create调用中想要一个Tuple<int, object, int>,你可以显式指定Tuple.Create的类型参数,或者只是调用new Tuple<int, object, int>(...),或者调用Tuple.Create(10, (object) "x", 20)

  • 类型推断在编译时失败。有时可以通过对一些参数进行类型转换来修复。例如,null字面量没有类型,所以对于Tuple.Create(null, 50)类型推断会失败,但对于Tuple.Create((string) null, 50)则会成功。有时你只需要显式指定类型参数。

在我个人的经验中,对于最后两种情况,你选择的选项很少对可读性有很大影响。理解类型推断的细节可以使预测哪些会工作以及哪些不会变得更容易,但不太可能回报你在研究规范上投入的时间。如果你好奇,我永远不会积极阻止任何人阅读规范。只是当你发现它交替地感觉像是一个迷宫,所有的通道都一样,然后是一个迷宫,所有的通道都不同时,不要感到惊讶。

这种关于复杂语言细节的夸张谈话不应该影响类型推断的便利性。由于它的存在,C#的使用要容易得多。

到目前为止,我们讨论的所有类型参数都是无约束的。它们可以代表任何类型。但这并不总是你想要的;有时,你只想让某些类型作为特定类型参数的类型参数。这就是类型约束发挥作用的地方。

2.1.5. 类型约束

当一个泛型类型或方法声明一个类型参数时,它也可以指定类型约束,以限制哪些类型可以作为类型参数提供。假设你想编写一个方法,格式化一个项目列表,并确保你使用特定的文化格式化而不是线程的默认文化。IFormattable接口提供了一个合适的ToString(string, IFormatProvider)方法,但你如何确保你有一个合适的列表?你可能期望一个这样的签名:

static void PrintItems(List<IFormattable> items)

但这几乎永远不会很有用。例如,你不能传递一个List<decimal>给它,即使decimal实现了IFormattableList<decimal>不能转换为List<IFormattable>

注意

我们将在第四章中更深入地探讨这个原因,当我们考虑泛型方差时。现在,只需将其视为一个约束的简单示例。

你需要表达的是参数是一个某些元素类型的列表,其中元素类型实现了 IFormattable 接口。"某些元素类型" 部分暗示你可能想使该方法泛型,而 "where the element type implements the IFormattable interface" 正是类型约束给我们的能力。你可以在方法声明的末尾添加一个 where 子句,如下所示:

static void PrintItems<T>(List<T> items) where T : IFormattable

你在这里对 T 的约束不仅改变了可以传递给方法的价值;它还改变了在方法内部可以使用 T 类型的值做什么。编译器知道 T 实现了 IFormattable,因此它允许在任意的 T 值上调用 IFormattable.ToString(string, IFormatProvider) 方法。

列表 2.6. 使用类型约束在不变文化中打印项目
static void PrintItems<T>(List<T> items) where T : IFormattable
{
    CultureInfo culture = CultureInfo.InvariantCulture;
    foreach (T item in items)
    {
        Console.WriteLine(item.ToString(null, culture));
    }
}

没有类型约束,那个 ToString 调用就不会编译;编译器对 T 所知的唯一 ToString 方法是在 System.Object 中声明的。

类型约束不仅限于接口。以下类型约束可用:

  • 引用类型约束where T : class。类型参数必须是一个引用类型。(不要被 class 关键字的用法所迷惑;它可以是任何引用类型,包括接口和委托。)

  • 值类型约束where T : struct。类型参数必须是非可空值类型(要么是结构体,要么是枚举)。可空值类型(在第 2.2 节中描述)不满足此约束。

  • 构造函数约束where T : new()。类型参数必须有一个公共的无参数构造函数。这允许在代码体中使用 new T() 来构造 T 的新实例。

  • 转换约束where T : SomeType。在这里,SomeType 可以是一个类、一个接口或另一个类型参数,如下所示:

    • where T : Control

    • where T : IFormattable

    • where T1 : T2

中等复杂的规则说明了如何组合约束。一般来说,当你违反这些规则时,编译器错误信息会清楚地表明问题所在。

一种有趣且相对常见的约束形式是使用约束本身中的类型参数:

public void Sort(List<T> items) where T : IComparable<T>

该约束使用 T 作为泛型 IComparable<T> 接口类型参数。这允许我们的排序方法通过 IComparable<T>CompareTo 方法成对比较 items 参数中的元素:

T first = ...;
T second = ...;
int comparison = first.CompareTo(second);

我比其他任何类型都更多地使用了基于接口的类型约束,尽管我怀疑你使用什么很大程度上取决于你编写的代码类型。

当在泛型声明中存在多个类型参数时,每个类型参数都可以有完全不同的约束集,如下例所示:

TResult Method<TArg, TResult>(TArg input)    *1*
    where TArg : IComparable<TArg>           *2*
    where TResult : class, new()             *3*
  • 1 具有两个类型参数的通用方法,TArg 和 TResult

  • 2 TArg 必须实现 IComparable

  • 3 TResult 必须是一个具有无参数构造函数的引用类型。

我们几乎完成了对泛型的快速浏览,但我还有一些主题要描述。我将从 C# 2 中可用的两个类型相关操作符开始。

2.1.6. 默认和 typeof 操作符

C# 1 已经有了 typeof() 操作符,它只接受类型名称作为其唯一的操作数。C# 2 添加了 default() 操作符并稍微扩展了 typeof 的使用。

default 操作符很容易描述。操作数是类型或类型参数的名称,结果是该类型的默认值——如果你声明了一个字段但没有立即为其赋值,你会得到相同的值。对于引用类型,这是一个空引用;对于不可为 null 的值类型,它是“全零”值(0、0.0、0.0m、false、数值为 0 的 UTF-16 代码单元等);对于可为 null 的值类型,它是该类型的 null 值。

default 操作符可以与类型参数以及提供适当类型参数的泛型类型一起使用(这些参数也可以是类型参数)。例如,在声明类型参数为 T 的泛型方法中,所有这些都是有效的:

  • default(T)

  • default(int)

  • default(string)

  • default(List<T>)

  • default(List<List<string>>)

default 操作符的类型是其中命名的类型。它最常与泛型类型参数一起使用,因为否则你通常可以用不同的方式指定默认值。例如,你可能想使用默认值作为可能或可能不会在以后分配不同值的局部变量的初始值。为了使这一点具体化,这里有一个可能熟悉你的方法的简单实现:

public T LastOrDefault<T>(IEnumerable<T> source)
{
    T ret = default(T);             *1*
    foreach (T item in source)
    {
        ret = item;                 *2*
    }
    return ret;                     *3*
}
  • 1 声明一个局部变量并将其赋值为 T 的默认值。

  • 2 用序列中的当前值替换局部变量的值。

  • 3 返回最后分配的值。

typeof 操作符稍微复杂一些。有四种主要情况需要考虑:

  • 完全不涉及泛型;例如,typeof(string)

  • 涉及泛型但没有类型参数;例如,typeof(List<int>)

  • 只是一个类型参数;例如,typeof(T)

  • 在操作数中使用类型参数涉及泛型;例如,在声明类型参数为 TItem 的泛型方法中,typeof(List<TItem>)

  • 涉及泛型但在操作数中没有指定类型参数;例如,typeof(List<>)

这第一个很简单,没有任何变化。其他所有情况都需要更多的注意,最后一种引入了一种新的语法。typeof 操作符仍然被定义为返回一个 Type 值,那么在这些情况下它应该返回什么?Type 类被扩展以了解泛型。有多个情况需要考虑;以下是一些示例:

  • 如果你列出包含List<T>的程序集中的类型,例如,你预计会得到没有为T指定任何特定类型参数的List<T>。这是一个泛型类型定义

  • 如果你在一个List<int>对象上调用GetType(),你将希望得到一个包含类型参数信息的类型。

  • 如果你询问一个声明为

    class StringDictionary<T> : Dictionary<string, T>
    

    的类的泛型类型定义的基本类型,你将得到一个包含一个“具体”类型参数(对于Dictionary<TKey, TValue>TKey类型参数是string)和一个仍然是类型参数的类型参数(对于TValue类型参数是T)的类型。

坦白说,这非常令人困惑,但这在问题域中是固有的。Type中的许多方法和属性允许你从一个泛型类型定义转换到具有所有类型参数的类型,或者反之亦然,例如。

让我们回到typeof运算符。理解的最简单例子是typeof(List<int>)。它返回代表具有int类型参数的List<T>Type,就像你调用new List<int>().GetType()一样。

下一个案例,typeof(T),返回在代码中该点T的类型参数是什么。这始终是一个封闭的、构造的类型,这是规范中指明它是没有涉及任何类型参数的真正类型的方式。尽管我在大多数地方都试图彻底解释术语,但泛型(开放、封闭、构造、绑定、未绑定)的术语令人困惑,并且在现实生活中几乎从未有用过。我们稍后会讨论封闭的、构造的类型,但不会涉及其他内容。

最容易演示的是关于typeof(T)的含义,你可以在同一个例子中查看typeof(List<T>)。下面的列表声明了一个泛型方法,该方法将typeof(T)typeof(List<T>)的结果打印到控制台,然后使用两个不同的类型参数调用该方法。

列表 2.7. 打印 typeof 运算符的结果
static void PrintType<T>()
{
    Console.WriteLine("typeof(T) = {0}", typeof(T));               *1*
    Console.WriteLine("typeof(List<T>) = {0}", typeof(List<T>));
}

static void Main()
{
    PrintType<string>();                                           *2*
    PrintType<int>();                                              *3*
}
  • 1 打印 typeof(T)和 typeof(List)

  • 2 使用字符串类型参数调用方法

  • 3 使用整数类型参数调用方法

列表 2.7 的结果如下所示:

typeof(T) = System.String
typeof(List<T>) = System.Collections.Generic.List`1[System.String]
typeof(T) = System.Int32
typeof(List<T>) = System.Collections.Generic.List`1[System.Int32]

重要的是,当你在一个类型参数为string(在第一次调用期间)的环境中运行时,typeof(T)的结果与typeof(string)相同。同样,typeof(List<T>)的结果与typeof(List<string>)的结果相同。当你再次使用int作为类型参数调用该方法时,你得到与typeof(int)typeof(List<int>)相同的结果。当代码在泛型类型或方法中执行时,类型参数始终指代一个封闭的、构造的类型。

从这个输出中,另一个可以吸取的经验是使用反射时泛型类型的名称格式。List1表示这是一个名为List`的泛型类型,具有 1 个泛型秩(一个类型参数),类型参数随后显示在方括号中。

我们之前列表中的最后一项是typeof(List<>)。这似乎完全缺少了一个类型参数。这种语法仅在typeof运算符中有效,并指代泛型类型定义。对于具有 1 个泛型秩的类型,语法是TypeName<>;对于每个额外的类型参数,你需要在尖括号内添加一个逗号。要获取Dictionary<TKey, TValue>的泛型类型定义,你会使用typeof(Dictionary<,>)。要获取Tuple<T1, T2, T3>的定义,你会使用typeof(Tuple<,,>)

理解泛型类型定义与关闭的、已构造类型之间的区别对于我们最终话题至关重要:类型是如何初始化的,以及类型范围内的(静态)状态是如何处理的。

2.1.7. 泛型类型初始化和状态

正如你在使用typeof运算符时看到的,List<int>List<string>实际上是不同的类型,它们是由相同的泛型类型定义构造的。这不仅适用于你如何使用这些类型,也适用于类型是如何初始化的,以及静态字段是如何处理的。每个关闭的、已构造的类型都是单独初始化的,并且有自己的独立静态字段集合。以下列表通过一个简单的(非线程安全)泛型计数器演示了这一点。

列表 2.8. 探索泛型类型中的静态字段
class GenericCounter<T>
{
    private static int value;                    *1*

    static GenericCounter()
    {
        Console.WriteLine("Initializing counter for {0}", typeof(T));
    }

    public static void Increment()
    {
        value++;
    }

    public static void Display()
    {
        Console.WriteLine("Counter for {0}: {1}", typeof(T), value);
    }
}

class GenericCounterDemo
{
    static void Main()
    {
        GenericCounter<string>.Increment();      *2*
        GenericCounter<string>.Increment();
        GenericCounter<string>.Display();
        GenericCounter<int>.Display();           *3*
        GenericCounter<int>.Increment();
        GenericCounter<int>.Display();
    }
}
  • 1 每个关闭的、已构造的类型有一个字段

  • 2 触发 GenericCounter的初始化

  • 3 触发 GenericCounter的初始化

列表 2.8 的输出如下:

Initializing counter for System.String
Counter for System.String: 2
Initializing counter for System.Int32
Counter for System.Int32: 0
Counter for System.Int32: 1

在那个输出中,有两个结果需要关注。首先,GenericCounter<string>的值与GenericCounter<int>是独立的。其次,静态构造函数运行了两次:一次为每个关闭的、已构造的类型。如果你没有静态构造函数,那么对于每个类型确切初始化的时间保证会更少,但本质上你可以将GenericCounter<string>GenericCounter<int>视为独立的类型。

要进一步复杂化问题,泛型类型可以嵌套在其他泛型类型中。当这种情况发生时,对于每个类型参数的组合都有一个单独的类型。例如,考虑以下类:

class Outer<TOuter>
{
    class Inner<TInner>
    {
        static int value;
    }    
}

使用intstring作为类型参数,以下类型是独立的,并且每个都有自己的value字段:

  • Outer<string>.Inner<string>

  • Outer<string>.Inner<int>

  • Outer<int>.Inner<string>

  • Outer<int>.Inner<int>

在大多数代码中,这种情况相对较少见,当你意识到重要的是完全指定的类型,包括叶类型和任何封装类型的任何类型参数时,处理起来就足够简单了。

这就是泛型,它是 C# 2 中最大的单一特性,并且比 C# 1 有了巨大的改进。我们接下来要讨论的是可空值类型,它们牢牢地基于泛型。

2.2. 可空值类型

托尼·霍尔(Tony Hoare)在 1965 年将空引用引入到 ALGOL 中,并随后称其为他的“十亿美元的错误”。无数的开发者在他们的代码抛出NullReferenceException (.NET)、NullPointerException (Java)或其他等效异常时感到沮丧。Stack Overflow 上有许多标准问题,有数百个其他问题指向它们,因为这是一个非常普遍的问题。如果空值如此糟糕,为什么在 C# 2 和.NET 2.0 中引入了更多的空值类型?在我们查看该功能的实现之前,让我们考虑它试图解决的问题和之前的工作方案。

2.2.1. 目标:表达信息缺失

有时候有一个变量来表示某些信息是有用的,但并不是在每种情况下都会存在这种信息。这里有一些简单的例子:

  • 你正在模拟一个客户订单,包括公司的详细信息,但客户可能不是代表公司订购。

  • 你正在模拟一个人,包括他们的出生日期和死亡日期,但这个人可能仍然活着。

  • 你正在模拟一个产品的过滤器,包括价格范围,但客户可能没有指定最大价格。

这些都是想要表示值缺失的一种特定形式;你可能拥有完整的信息,但仍需要模拟缺失。在其他情况下,你可能拥有不完整的信息。在第二个例子中,你可能不知道某人的出生日期,并不是因为他们没有出生,而是因为你的系统没有这方面的信息。有时你需要在数据中表达“已知缺失”和“未知”之间的差异,但通常只是信息的缺失就足够了。

对于引用类型,你已经有一种表示信息缺失的方法:空引用。如果你有一个Company类,并且你的Order类有一个与订单关联的公司引用,你可以将其设置为 null,如果客户没有指定公司。

对于 C# 1 中的值类型,没有等效的方法。有两种常见的方法来表示这一点:

  • 使用保留值来表示缺失的数据。例如,你可能会在价格过滤器中使用decimal.MaxValue来表示“未指定最大价格”。

  • 维护一个单独的布尔标志来指示另一个字段是否有实际值或应该被忽略的值。只要你在使用其他字段之前检查标志,其值在缺失的情况下就是无关紧要的。

这两种方法都不是理想的。第一种方法减少了有效值的集合(对于decimal来说问题不大,但对于byte来说则是一个问题,因为可能需要完整的范围)。第二种方法会导致大量的繁琐和重复的逻辑。

更重要的是,两者都容易出错。两者都需要你在使用可能有效或无效的值之前进行检查。如果你不执行这个检查,你的代码将继续使用不适当的数据。它将默默地做错事情,并且很可能会将错误传播到系统的其他部分。静默失败是最糟糕的,因为它很难追踪,也很难撤销。我更喜欢那种大声的异常,它能够立即停止错误的代码。

可空值类型封装了之前展示的第二种方法:它们保留了一个额外的标志和值,以表示是否应该使用它。封装在这里是关键;使用值的简单方式也是安全的,因为如果你尝试在不适当的情况下使用它,它将抛出异常。使用单一类型一致地表示可能缺失的值使得语言能够使我们的生活更轻松,并且库作者也有一种在他们的 API 表面表示它的习惯方式。

在完成这个概念介绍之后,让我们看看框架和 CLR 在可空值类型方面提供了什么。在你建立了这个基础之后,我会向你展示 C# 采纳的额外特性,使它们易于使用。

2.2.2. CLR 和框架支持:Nullable<T> 结构

可空值类型支持的核心是 Nullable<T> 结构。Nullable<T> 的原始版本看起来像这样:

public struct Nullable<T> where T : struct               *1*
{
    private readonly T value;
    private readonly bool hasValue;

    public Nullable(T value)                             *2*
    {
        this.value = value;
        this.hasValue = true;
    }

    public bool HasValue { get { return hasValue; } }    *3*

    public T Value                                       *4*
    {                                                    *4*
        get                                              *4*
        {                                                *4*
            if (!hasValue)                               *4*
            {                                            *4*
                throw new InvalidOperationException();   *4*
            }                                            *4*
            return value;                                *4*
        }                                                *4*
    }                                                    *4*
}
  • 1 具有约束 T 为非可空值类型的泛型结构体

  • 2 构造函数以提供值

  • 3 属性以检查是否存在实际值

  • 4 访问值,如果值缺失则抛出异常

正如你所见,唯一声明的构造函数将 hasValue 设置为 true,但像所有结构体一样,存在一个隐式的无参构造函数,它将 hasValue 设置为 false 并将 value 设置为 T 的默认值:

Nullable<int> nullable = new Nullable<int>();
Console.WriteLine(nullable.HasValue);           *1*
  • 1 打印 False

Nullable<T> 上对 where T : struct 的约束允许 T 是任何值类型,除了另一个 Nullable<T>。它与原始类型、枚举、系统提供的结构体和用户定义的结构体一起工作。以下都是有效的:

  • Nullable<int>

  • Nullable<FileMode>

  • Nullable<Guid>

  • Nullable<LocalDate> (来自 Noda Time)

但以下是不合法的:

  • Nullable<string> (string 是引用类型)

  • Nullable<int[]> (数组是引用类型,即使元素类型是值类型)

  • Nullable<ValueType> (ValueType 本身不是值类型)

  • Nullable<Enum> (Enum 本身不是值类型)

  • Nullable<Nullable<int>> (Nullable<int> 是可空的)

  • Nullable<Nullable<Nullable<int>>> (尝试进一步嵌套可空性并不会有所帮助)

类型 T 也被称为 Nullable<T>基础类型。例如,Nullable<int> 的基础类型是 int

只需这部分就位,没有额外的 CLR、框架或语言支持,你就可以安全地使用类型来显示最大价格过滤器:

public void DisplayMaxPrice(Nullable<decimal> maxPriceFilter)
{
    if (maxPriceFilter.HasValue)
    {
        Console.WriteLine("Maximum price: {0}", maxPriceFilter.Value);
    }
    else
    {
        Console.WriteLine("No maximum price set.");
    }
}

这是一段行为良好的代码,在使用值之前进行了检查,但关于那些忘记先检查或检查错误的内容的糟糕代码怎么办?你不能意外地使用不适当的价值;如果你在maxPriceFilter.ValueHasValue属性为false时尝试访问它,将会抛出异常。

注意

我知道我之前已经提到过这一点,但我认为这很重要,值得再次强调:进步不仅仅来自于使编写正确代码更容易;它也来自于使编写错误代码更困难或使后果更轻微。

Nullable<T>结构体也有可用的方法和运算符:

  • 无参数的GetValueOrDefault()方法将在struct中返回值或如果HasValuefalse,则返回该类型的默认值。

  • 参数化的GetValueOrDefault(T defaultValue)方法将在struct中返回值或如果HasValuefalse,则返回指定的默认值。

  • object中声明的Equals(object)GetHashCode()方法以一种合理明显的方式被重写,首先比较HasValue属性,然后在两个值的HasValue都是true的情况下比较Value属性以检查相等性。

  • TNullable<T>存在隐式转换,这总是成功并返回一个HasValuetrue的值。这相当于调用参数化构造函数。

  • Nullable<T>T存在显式转换,它要么返回封装的值(如果HasValuetrue),要么如果HasValuefalse,则抛出InvalidOperationException。这相当于使用Value属性。

我将在讨论语言支持时回到转换这个话题。到目前为止,你唯一看到 CLR 需要理解Nullable<T>的地方是强制struct类型约束。CLR 行为的另一个方面是特定于可空的:装箱。

装箱行为

可空值类型在装箱方面与非可空值类型的行为不同。当一个非可空值类型的值被装箱时,结果是原始类型的装箱形式的类型对象的引用。比如说,如果你写下这个:

int x = 5;
object o = x;

o的值是一个指向类型为“装箱int”的对象的引用。装箱intint之间的区别通常在 C#中不可见。如果你调用o.GetType(),返回的Type将等于typeof(int),例如。一些其他语言(如 C++/CLI)允许开发者区分原始值类型及其装箱等效类型。

然而,可空值类型没有装箱等效类型。装箱类型Nullable<T>的值的结果取决于HasValue属性:

  • 如果HasValuefalse,结果是空引用。

  • 如果HasValuetrue,结果是类型为“装箱T”的对象的引用。

以下列表演示了这两个点。

列表 2.9. 装箱可空值类型值的影响
Nullable<int> noValue = new Nullable<int>();
object noValueBoxed = noValue;                    *1*
Console.WriteLine(noValueBoxed == null);          *2*

Nullable<int> someValue = new Nullable<int>(5);
object someValueBoxed = someValue;                *3*
Console.WriteLine(someValueBoxed.GetType());      *4*
  • 1 当 HasValue 为 false 时装箱一个值

  • 2 打印 True:装箱的结果是空引用。

  • 3 当 HasValue 为 true 时装箱一个值

  • 4 打印 System.Int32:结果是装箱的 int。

当你意识到这种行为时,几乎总是你想要的。然而,这有一个奇怪的副作用。在System.Object上声明的GetType()方法是不可变的,并且关于装箱何时发生的复杂规则意味着,如果你对一个值类型值调用GetType(),它总是需要先装箱。通常,这有点低效,但不会引起任何混淆。对于可空值类型,它将导致NullReferenceException或返回基础的非可空值类型。下面的列表显示了这些示例。

列表 2.10. 在可空值上调用 GetType 导致令人惊讶的结果
Nullable<int> noValue = new Nullable<int>();
// Console.WriteLine(noValue.GetType());           *1*

Nullable<int> someValue = new Nullable<int>(5);
Console.WriteLine(someValue.GetType());            *2*
  • 1 会抛出 NullReferenceException

  • 2 打印 System.Int32,与使用 typeof(int) 相同

你已经看到了框架支持和 CLR 支持,但 C# 语言更进一步,使可空值类型更容易使用。

2.2.3. 语言支持

C# 2 本来可以在强制struct类型约束时只让编译器知道可空值类型。那会很糟糕,但考虑为了使可空值类型更符合语言习惯而添加的所有功能,了解所需的最小支持是很有用的。让我们从最简单的一部分开始:简化可空值类型名称。

? 类型后缀

如果你将?添加到非可空值类型的名称末尾,这正好等同于为同一类型使用Nullable<T>。它适用于简单类型的关键字简写(如intdouble等)以及完整的类型名称。例如,这四个声明是精确等价的:

  • Nullable<int> x;

  • Nullable<Int32> x;

  • int? x;

  • Int32? x;

你可以随意混合使用它们。生成的 IL 不会改变。在实践中,我最终在所有地方都使用?后缀,但其他团队可能有不同的约定。为了清晰起见,我在本节的其余部分使用了Nullable<T>,因为当在散文中使用时,?可能会造成混淆,但在代码中这很少是问题。

这是最简单的语言增强,但允许你编写简洁代码的主题贯穿本节的其余部分。?后缀是关于轻松表达类型;下一个特性专注于轻松表达值。

空字面量

在 C# 1 中,表达式 null 总是指向一个空引用。在 C# 2 中,这个含义扩展到了空值:要么是一个空引用,要么是一个 HasValuefalse 的可空值类型的值。这可以用于赋值、方法参数、比较——任何方式。重要的是要理解,当它用于可空值类型时,它实际上代表的是该类型在 HasValuefalse 时的值,而不是一个空引用;如果你试图将空引用融入到你对可空值类型的心理模型中,它很快就会变得混乱。以下两行是等价的:

int? x = new int?();

int? x = null;

我通常更喜欢使用空字面量而不是显式调用无参数构造函数(我宁愿写前面几行中的第二行而不是第一行),但在比较方面,我对这两种选择持中立态度。例如,这两行是等价的:

if (x != null)

if (x.HasValue)

我怀疑我甚至没有关于使用哪种方法的连贯性。我并不提倡不一致性,但这是一个不会造成很大伤害的领域。你总是可以在以后改变主意,而不用担心兼容性问题。

转换

你已经看到 Nullable<T> 提供了从 TNullable<T> 的隐式转换和从 Nullable<T>T 的显式转换。语言通过允许某些转换链式连接来进一步扩展这一组转换。当有两个非可空值类型 ST 并且存在从 ST 的转换(例如,从 intdecimal 的转换)时,以下转换也是可用的:

  • Nullable<S>Nullable<T>(隐式或显式,取决于原始转换)

  • SNullable<T>(隐式或显式,取决于原始转换)

  • Nullable<S>T(总是显式)

这些操作通过传播空值并按需使用 ST 的转换以合理明显的方式工作。将操作扩展到适当地传播空值的过程称为提升

需要注意的一点是:可以显式提供到可空和非可空类型的转换。LINQ to XML 就利用了这一点。例如,存在从 XElementintNullable<int> 的显式转换。如果你要求 LINQ to XML 查找一个不存在的元素,许多操作会返回一个空引用,并且将空引用转换为空值并传播空性而不抛出异常。然而,如果你尝试将空 XElement 引用转换为非可空的 int 类型,则会抛出异常。存在这两种转换使得安全地处理可选和必需元素变得容易。

转换是一种可以构建到 C# 或用户定义的运算符形式。在非可空类型上定义的其他运算符在其可空对应物中也会得到类似的处理。

提升运算符

C# 允许以下运算符被重载:

  • 一元运算符:+ ++ - -- ! ~ true false

  • 二进制操作符:+ - * / % & | ^ << >>

    等价性和关系运算符也是二元运算符,但它们的行为与其他运算符略有不同,因此它们被单独列出。

  • 等价性:== !=

  • 关系运算:< > <= >=

当这些运算符用于非可空值类型 T 时,Nullable<T> 类型具有相同的运算符,但操作数和结果类型略有不同。这些被称为 提升运算符,无论它们是预定义运算符,如数值类型上的加法,还是用户定义运算符,如将 TimeSpan 添加到 DateTime。以下是一些限制条件:

  • truefalse 运算符永远不会提升。它们最初就非常罕见,所以这不是什么大损失。

  • 只有操作数类型为非可空值类型的运算符才会提升。

  • 对于一元和二元运算符(除了等价性和关系运算符),原始运算符的返回类型必须是非可空值类型。

  • 对于等价性和关系运算符,原始运算符的返回类型必须是 bool

  • Nullable<bool> 上的 &| 运算符有分别定义的行为,我们将在下面讨论。

对于所有运算符,操作数类型变为它们的 nullable 等价类型。对于一元和二元运算符,返回类型也变为 nullable,如果任何操作数是 null 值,则返回 null 值。等价性和关系运算符保持它们的非 nullable 布尔返回类型。对于等价性,两个 null 值被认为是相等的,null 值和任何非 null 值被认为是不同的。关系运算符在任一操作数是 null 值时总是返回 false。当两个操作数都不是 null 值时,调用非 nullable 类型的运算符以明显的方式。

所有这些规则听起来比实际复杂;大部分情况下,一切都会按照你预期的进行。通过几个例子最容易看出会发生什么,而由于 int 有许多预定义的运算符(并且整数可以很容易地表示),它是一个自然的演示类型。表 2.1 显示了多个表达式、提升运算符签名和结果。假设存在变量 fourfivenullInt,每个变量类型为 Nullable<int>,并且具有明显的值。

表 2.1. 可 nullable 整数应用提升运算符的示例
表达式 提升运算符 结果
-nullInt int? -(int? x) null
-five int? -(int? x) -5
five + nullInt int? +(int? x, int? y) null
five + five int? +(int? x, int? y) 10
four & nullInt int? &(int? x, int? y) null
four & five int? &(int? x, int? y) 4
nullInt == nullInt bool ==(int? x, int? y) true
five == five bool ==(int? x, int? y) true
five == nullInt bool ==(int? x, int? y) false
five == four bool ==(int? x, int? y) false
four < five bool <(int? x, int? y) true
nullInt < five bool <(int? x, int? y) false
five < nullInt bool <(int? x, int? y) false
nullInt < nullInt bool <(int? x, int? y) false
nullInt <= nullInt bool <=(int? x, int? y) false

表格中最令人惊讶的一行可能是最后一行:即使两个空值被认为彼此相等(如第七行所示),它们也不被认为小于或等于彼此!这非常奇怪,但根据我的经验,这不太可能在现实生活中引起问题。在关于运算符提升的限制列表中,我提到Nullable<bool>与其他类型的工作略有不同。

可空逻辑

真值表通常用于演示所有可能的输入组合和结果的布尔逻辑。尽管可以使用相同的方法来处理Nullable<Boolean>逻辑,但我们对于每个输入需要考虑三个值(truefalsenull),而不是仅仅truefalse。没有为Nullable<bool>定义条件逻辑运算符(短路运算符&&||),这使得生活更加简单。

只有逻辑与和包含或运算符(分别用&|表示)具有特殊行为。其他运算符——一元逻辑否定(!)和异或(^)——遵循其他提升运算符的相同规则。为了完整性,表 2.2 给出了所有四个有效Nullable<bool>逻辑运算符的真值表。我突出显示了如果不存在针对Nullable<bool>的特殊规则,结果将会不同的结果。

表 2.2. 可空布尔运算符的真值表
x y x & y x | y x ^ y !x
true true true false false false null null null true false null true false null true false null true false null false false false null false null true true true true false null true null null false true null true false null null null null false false false true true true null null null

如果你发现理解规则比在表中查找值更容易,那么一个空bool?值在某种程度上是一个可能值。如果你想象表输入侧的每个空值都是一个变量,那么如果结果取决于该变量的值,你将在表的输出侧始终得到一个空值。例如,查看表的第三行,表达式true & y只有在ytrue时才为真,但表达式true | y无论y的值如何都始终为真,因此可空结果分别是nulltrue

在考虑提升运算符以及特别是可空逻辑如何工作时,语言设计者面临两组略微矛盾的行为:C# 1 的 null 引用和 SQL NULL 值。在许多情况下,它们根本不冲突;C# 1 没有将逻辑运算符应用于 null 引用的概念,因此使用前面给出的类似 SQL 的结果没有问题。然而,当涉及到比较时,你看到的定义可能会让一些 SQL 开发者感到惊讶。在标准 SQL 中,如果两个值(就相等或大于/小于而言)中的任何一个值为 NULL,比较的结果总是未知。在 C# 2 中,结果永远不会是 null,并且两个 null 值被认为是彼此相等。

提升运算符的结果是 C#特有的

提升运算符和转换,以及本节中描述的Nullable<bool>逻辑,都是由 C#编译器提供的,而不是由 CLR 或框架本身提供的。如果你在评估这些可空运算符的代码上使用 ildasm,你会发现编译器已经创建了所有适当的 IL 来测试 null 值并相应地处理它们。

不同的语言在这些方面可能会有不同的行为,如果你需要在不同的.NET 语言之间移植代码,这绝对是一件需要注意的事情。例如,VB 将提升运算符处理得远比 SQL 更像,所以如果xyNothing,则x < y的结果是Nothing

现在另一个熟悉的运算符也适用于可空值类型,并且如果考虑你对 null 引用的现有知识并进行适当的调整,它的行为可能正如你所预期的那样。

as运算符和可空值类型

在 C# 2 之前,as运算符仅适用于引用类型。从 C# 2 开始,它现在也可以应用于可空值类型。结果是该可空类型的值:如果原始引用是错误类型或 null,则为 null 值;否则为有意义值。以下是一个简短的例子:

static void PrintValueAsInt32(object o)
{
    int? nullable = o as int?;
    Console.WriteLine(nullable.HasValue ?
                      nullable.Value.ToString() : "null");
}
...
PrintValueAsInt32(5);                   *1*
PrintValueAsInt32("some string");       *2*
  • 1 打印 5

  • 2 打印 null

这允许你安全地将任意引用转换为值,尽管你通常会在之后检查结果是否为 null。在 C# 1 中,你将不得不使用is运算符然后进行转换,这并不优雅;这本质上是在要求 CLR 执行相同的类型检查两次。

注意

使用as运算符与可空类型相比非常慢。在大多数代码中,这不太可能成为问题(与任何 I/O 相比都不会慢),但它比is运算符然后进行转换要慢,我在尝试的所有框架和编译器组合中都是这样。

C# 7 为大多数我使用 as 运算符与可空值类型结合模式匹配(在第十二章中描述)的情况提供了一个更好的解决方案。如果你的预期结果类型确实是 Nullable<T>,那么 as 运算符很方便。最后,C# 2 引入了一个全新的运算符,专门用于优雅地处理空值。

空合并 ?? 运算符

想要使用可空值类型——或者实际上,引用类型——并在特定表达式评估为空时提供一个默认值的情况相当常见。C# 2 引入了 ?? 运算符,也称为 空合并运算符,正是为了这个目的。

?? 是一个二元运算符,通过以下步骤(大致来说)评估 first ?? second 的表达式:

  1. 评估 first

  2. 如果结果是非空的,那么这就是整个表达式的结果。

  3. 否则,评估 second,并使用它作为整个表达式的结果。

我说大致来说,因为规范中的正式规则必须处理涉及 firstsecond 类型之间转换的情况。在大多数使用情况下,这些并不重要,我也不打算详细说明。如果你需要,它们在规范中很容易找到。

这些规则中的一个方面值得强调。如果第一个操作数的类型是可空值类型,而第二个操作数的类型是第一个操作数的底层类型,则整个表达式的类型就是那个(非可空)底层类型。例如,以下代码是完全有效的:

int? a = 5;
int b = 10;
int c = a ?? b;

注意,尽管 c 的类型是非可空的 int 类型,你仍然可以直接将其赋值。你之所以能这样做,仅仅是因为 b 是非可空的,因此你知道整体结果不可能是空的。?? 运算符可以很好地与自身组合;例如,表达式 x ?? y ?? z 只有在 x 评估为空时才会评估 y,只有在 xy 都评估为空时才会评估 z

在 C# 6 中,使用 ?. 空条件运算符使处理空值变得更加容易——并且作为表达式结果的可能性更大,正如你将在 第 10.3 节 中看到的。结合 ?.?? 可以是处理执行过程中各个点的可能空值的一种强大方式。像所有技术一样,最好适度使用。如果你发现代码的可读性在下降,你可能想要考虑使用多个语句来避免一次尝试做太多。

C# 2 中关于可空值类型的讨论就到这里。我们已经涵盖了 C# 2 的两个最重要的特性,但还有一些相当大的特性以及一系列较小的特性尚未讨论。接下来是委托。

2.3. 简化的委托创建

委托的基本目的自它们首次引入以来没有改变:封装一段代码,以便可以在类型安全的方式下传递并执行,这涉及到返回类型和参数。在 C# 1 的时代,这几乎总是用于事件处理或启动线程。当 C# 2 在 2005 年引入时,情况仍然大致如此。直到 2008 年,LINQ 帮助 C# 开发者对传递函数的各种原因感到舒适。

C# 2 引入了创建委托实例的三种新方法,以及声明泛型委托的能力,例如 EventHandler<TEventArgs>Action<T>。我们将从方法组转换开始。

2.3.1. 方法组转换

方法组 指的是具有相同名称的一个或多个方法。每个 C# 开发者一直在使用它们,而无需刻意思考,因为每次方法调用都会使用到它们。例如,考虑以下简单的代码:

Console.WriteLine("hello");

表达式 Console.WriteLine 是一个方法组。编译器随后查看参数以确定应该调用该方法组中的哪个重载。除了方法调用之外,C# 1 使用方法组在 委托创建表达式 中作为创建委托实例的唯一方式。例如,假设你有一个如下所示的方法:

private void HandleButtonClick(object sender, EventArgs e)

然后,你可以创建一个 EventHandler^([6]) 实例,如下所示:

仅供参考,EventHandler 的签名是 public delegate void EventHandler(object sender, EventArgs e)

EventHandler handler = new EventHandler(HandleButtonClick);

C# 2 引入了 方法组转换 作为一种简写方式:方法组可以隐式转换为任何与其中某个重载兼容的委托类型。你将在 2.3.3 节 中进一步探索兼容性的概念,但此时你将查看与你要转换到的委托签名完全匹配的方法。

在我们前面提到的 EventHandler 代码的情况下,C# 2 允许你将委托的创建简化为如下形式:

EventHandler handler = HandleButtonClick;

这同样适用于事件订阅和移除:

button.Click += HandleButtonClick;

生成的代码与用于创建委托表达式的代码相同,但更加简洁。如今,我在惯用代码中很少看到委托创建表达式。方法组转换在创建委托实例时节省了一些字符,但匿名方法却能实现更多。

2.3.2. 匿名方法

你可能会合理地期待在这里看到很多关于匿名方法的细节。我将把大部分信息留到匿名方法的继承者:lambda 表达式。它们是在 C# 3 中引入的,我预计如果它们在匿名方法之前就存在,那么后者可能根本就不会被引入。

即使如此,C# 2 中的引入让我对委托有了全新的认识。匿名方法允许你通过在需要创建实例的任何地方编写一些代码来创建委托实例,而不需要一个真正的要引用的方法^([7])。你只需使用delegate关键字,可选地包含一些参数,然后在花括号中编写一些代码。例如,如果你想要一个在触发时仅将日志记录到控制台的事件处理程序,你可以非常简单地做到这一点:

在你的源代码中。该方法仍然存在于 IL 中。

EventHandler handler = delegate
{
    Console.WriteLine("Event raised");
};

这不会立即调用Console.WriteLine;相反,它创建了一个在调用时将调用Console.WriteLine的委托。要查看发送者和事件参数的类型,你需要适当的参数:

EventHandler handler = delegate(object sender, EventArgs args)
{
    Console.WriteLine("Event raised. sender={0}; args={1}",
        sender.GetType(), args.GetType());
};

当你使用匿名方法作为闭包时,真正的力量才显现出来。闭包能够访问其声明点处所有作用域内的变量,即使这些变量在委托执行时通常不再可用。当你查看 lambda 表达式时,你将更详细地了解闭包(包括编译器如何处理它们)。现在,这里有一个简单的例子;这是一个AddClickLogger方法,它将一个带有自定义消息的Click处理程序添加到任何控件中:

void AddClickLogger(Control control, string message)
{
    control.Click += delegate
    {
        Console.WriteLine("Control clicked: {0}", message);
    }
}

在这里,message变量是方法的参数,但它被匿名方法捕获。AddClickLogger方法本身并不执行事件处理程序;它只是将其添加为Click事件的处理器。当匿名方法中的代码执行时,AddClickLogger已经返回。参数是如何仍然存在的呢?简而言之,编译器为你处理所有这些,以避免你不得不编写无聊的代码。第 3.5.2 节提供了在查看 lambda 表达式中的变量捕获时的更多详细信息。这里关于EventHandler没有什么特别之处;它只是一个框架中一直存在的知名委托类型。对于 C# 2 委托改进的最后一部分,让我们回到兼容性的想法,我在讨论方法组转换时提到了它。

2.3.3. 委托兼容性

在 C# 1 中,你需要一个具有完全相同返回类型和参数类型(以及ref/out修饰符)的方法签名来创建委托实例。例如,假设你有一个这样的委托声明和方法:

public delegate void Printer(string message);

public void PrintAnything(object obj)
{
    Console.WriteLine(obj);
}

现在想象一下,你想要创建一个Printer实例来有效地包装PrintAnything方法。这似乎应该是可以的;Printer将始终被赋予一个string引用,并且可以通过身份转换转换为object引用。C# 1 不允许这样做,因为参数类型不匹配。C# 2 允许在委托创建表达式和方法组转换中这样做:

Printer p1 = new Printer(PrintAnything);
Printer p2 = PrintAnything;

此外,你可以创建一个委托来包装另一个具有兼容签名的委托。假设你有一个第二个委托类型,它偶然与PrintAnything方法匹配:

public delegate void GeneralPrinter(object obj);

如果你已经有了一个GeneralPrinter,你可以从它创建一个Printer

GeneralPrinter generalPrinter = ...;               *1*
Printer printer = new Printer(generalPrinter);     *2*
  • 1 你可能创建 GeneralPrinter 委托的任何方式

  • 2 构建一个 Printer 来包装 GeneralPrinter

编译器允许你这样做,因为它很安全;任何可以传递给Printer的参数都可以安全地传递给GeneralPrinter。编译器也愿意在返回类型上以相同的方式做相同的事情,如下面的示例所示:

public delegate object ObjectProvider();    *1*
public delegate string StringProvider();    *1*

StringProvider stringProvider = ...;        *2*
ObjectProvider objectProvider =             *3*
    new ObjectProvider(stringProvider);     *3*
  • 1 无参数委托返回值

  • 2 你可能创建 StringProvider 的任何方式

  • 3 创建一个 ObjectProvider 来包装 StringProvider

再次强调,这是安全的,因为StringProvider可以返回的任何值肯定适合从ObjectProvider返回。

然而,它并不总是按你希望的方式工作。不同参数或返回类型之间的兼容性必须是在执行时不会改变值表示的恒等转换。例如,以下代码无法编译:

public delegate void Int32Printer(int x);  *1*
public delegate void Int64Printer(long x); *1*

Int64Printer int64Printer = ...;           *2*
Int32Printer int32Printer =                *3*
    new Int32Printer(int64Printer);        *3*
  • 1 接受 32 位和 64 位整数的委托

  • 2 你可能创建 Int64Printer 的任何方式

  • 3 错误!无法将 Int64Printer 包装在 Int32Printer 中

这里的两个委托签名不兼容;尽管存在从intlong的隐式转换,但它不是一个恒等转换。你可能会争辩说编译器可以默默地创建一个为你执行转换的方法,但它并没有这样做。从某种意义上说,这是有帮助的,因为这种行为与你在第四章中将要看到的泛型方差功能相吻合。

重要的是要理解,尽管这个功能看起来有点像泛型方差,但它们是不同的功能。除此之外,这种包装实际上确实创建了一个新的委托实例,而不是仅仅将现有的委托视为不同类型的一个实例。当你全面查看这个功能时,我会详细介绍,但我希望尽可能早地强调,它们并不相同。

关于 C#中的委托就到这里。方法组转换仍然被广泛使用,并且通常兼容性方面会被使用,甚至没有人会去想它。如今匿名方法很少见,因为 lambda 表达式几乎可以做匿名方法能做的任何事情,但我仍然怀念它们,因为它们是我对闭包力量的第一次体验。说到一个功能导致另一个功能,让我们看看 C# 5 异步的前身:迭代器块。

2.4. 迭代器

在 C# 2 中,相对较少的接口具有特定的语言支持。IDisposable通过using语句提供支持,语言对数组实现的接口做出保证,但除此之外,只有可枚举接口有直接支持。IEnumerable始终以foreach语句的形式提供消费支持,C# 2 以一种相当明显的方式将其扩展到.NET 2 的新泛型对应物IEnumerable<T>

可枚举接口表示一系列项目,尽管消费它们非常常见,但想要生成一个序列也是完全合理的。手动实现泛型或非泛型接口可能会很繁琐且容易出错,因此 C# 2 引入了一个名为迭代器的新功能,以使其更简单。

2.4.1. 迭代器简介

迭代器是通过迭代器块实现的,而迭代器块只是使用yield returnyield break语句的代码块。迭代器块只能用于实现具有以下返回类型的方法或属性:

  • IEnumerable

  • IEnumerable<T>(其中T可以是类型参数或常规类型)

  • IEnumerator

  • IEnumerator<T>(其中T可以是类型参数或常规类型)

每个迭代器都有一个基于其返回类型的yield 类型。如果返回类型是非泛型接口之一,则 yield 类型为object。否则,它是提供给接口的类型参数。例如,返回IEnumerator<string>的方法的 yield 类型是string

yield return语句为返回的序列提供值,而yield break语句将终止序列。在某些其他语言中,如 Python,存在类似的构造,有时被称为生成器

以下列表显示了一个简单的迭代器方法,您可以进一步分析。我在方法中突出显示了yield return语句。

列表 2.11. 一个简单的生成整数迭代器
static IEnumerable<int> CreateSimpleIterator()
{
    yield return 10;
    for (int i = 0; i < 3; i++)
    {
        yield return i;
    }
    yield return 20;
}

使用该方法后,您可以调用该方法,并使用常规的foreach循环遍历结果:

foreach (int value in CreateSimpleIterator())
{
    Console.WriteLine(value);
}

该循环将打印以下输出:

10
0
1
2
20

到目前为止,这并不特别令人兴奋。你可以将方法更改为创建List<int>,将每个yield return语句替换为对Add()的调用,然后在方法末尾返回列表。循环输出将完全相同,但执行方式却完全不同。巨大的区别在于迭代器是懒执行的。

2.4.2. 懒执行

懒执行,或称为懒评估,是在 20 世纪 30 年代作为 lambda 演算的一部分被发明的。其基本思想很简单:只有在需要计算出的值时才执行代码。它在迭代器之外也有很好的应用,但就目前而言,我们只需要用它来处理迭代器。

为了解释代码是如何执行的,以下列表将 foreach 循环扩展为大部分等效的代码,该代码使用 while 循环代替。为了简单起见,我仍然使用了 using 语句的语法糖,它会自动调用 Dispose

列表 2.12. foreach 循环的展开
IEnumerable<int> enumerable = CreateSimpleIterator();   *1*
using (IEnumerator<int> enumerator =                    *2*
    enumerable.GetEnumerator())                         *2*
{
    while (enumerator.MoveNext())                       *3*
    {
        int value = enumerator.Current;                 *4*
        Console.WriteLine(value);
    }
}
  • 1 调用迭代器方法

  • 2IEnumerable<T> 获取 `IEnumerator

  • 3 如果有,移动到下一个值

  • 4 获取当前值

如果你以前从未看过 IEnumerable/IEnumerator 这对接口(以及它们的泛型等效物),现在是确保你理解它们之间差异的好时机。IEnumerable 是一个可以迭代的序列,而 IEnumerator 则是序列中的一个游标。多个 IEnumerator 实例可以迭代同一个 IEnumerable,而不会改变其状态。将此与 IEnumerator 进行比较,它自然地 确实 有可变状态:每次你调用 MoveNext(),你都是在要求它将其游标移动到正在迭代的序列的下一个元素。

如果这没有太多意义,你可能想要将 IEnumerable 视为一本书,将 IEnumerator 视为一个书签。在任何时候,一本书中可以有多个书签。将书签移动到下一页不会改变书或任何其他书签,但它确实会改变该书签的状态:它在书中的位置。IEnumerable.GetEnumerator() 方法是一种启动机制:它要求序列创建一个 IEnumerator,该 IEnumerator 被设置为迭代该序列,就像在书的开始处放置一个新的书签一样。

在你有了 IEnumerator 之后,你反复调用 MoveNext();如果它返回 true,这意味着你已经移动到了另一个可以访问的值,你可以使用 Current 属性来访问它。如果 MoveNext() 返回 false,你已到达序列的末尾。

这与延迟评估有什么关系?好吧,既然你现在确切地知道使用迭代器的代码会调用什么,你可以看看方法体何时开始执行。提醒一下,这是来自 列表 2.11 的方法:

static IEnumerable<int> CreateSimpleIterator()
{
    yield return 10;
    for (int i = 0; i < 3; i++)
    {
        yield return i;
    }
    yield return 20;
}

当调用 CreateSimpleIterator() 时,方法体没有任何执行。

如果你将断点放在第一行 (yield return 10) 上并逐步执行代码,当你调用方法时,你不会遇到断点。当你调用 GetEnumerator() 时,你也不会遇到断点。方法体只有在调用 MoveNext() 时才会开始执行。但那时会发生什么?

2.4.3. yield 语句的评估

即使方法开始执行,它也只会执行到需要的地方。当以下任何一种情况发生时,它就会停止执行:

  • 抛出异常。

  • 它到达了方法的末尾。

  • 它到达了 yield break 语句。

  • 它已经评估了操作数到 yield return 语句,因此它准备好产生该值。

如果抛出异常,该异常会像正常一样传播。如果达到方法末尾或遇到yield break语句,MoveNext()方法返回false以指示您已达到序列的末尾。如果您遇到yield return语句,Current属性设置为要产生的值,并且MoveNext()返回true

注意

为了阐明前面的段落,异常会像正常一样传播,假设您已经在执行迭代器代码。别忘了,直到调用代码迭代返回的序列,您才不会开始执行迭代器代码。是MoveNext()调用会抛出异常,而不是迭代器方法的初始调用。

在我们的简单示例中,一旦MoveNext()开始迭代,它就会到达yield return 10;语句,将Current设置为 10,然后返回true

对于MoveNext()的第一个调用来说,这一切听起来都很简单,但随后的调用呢?您不能从头开始;否则,序列将是无限次重复的 10。相反,当MoveNext()返回时,就像方法被暂停了一样。生成的代码会跟踪您在方法中达到的点以及任何其他状态,例如循环中的局部变量i。当再次调用MoveNext()时,执行将从您达到的点继续进行。这就是它懒惰的原因,这也是您自己编写代码时难以做对的部分。

2.4.4. 懒惰的重要性

为了让您了解为什么这很重要,让我们编写一些代码来打印出斐波那契数列,直到您遇到第一个大于 1,000 的值。下面的列表显示了返回无限序列的Fibonacci()方法和一个迭代该序列直到达到限制的方法。

列表 2.13. 迭代斐波那契数列
static IEnumerable<int> Fibonacci()
{
    int current = 0;
    int next = 1;
    while (true)                         *1*
    {
        yield return current;            *2*
        int oldCurrent = current;
        current = next;
        next = next + oldCurrent;
    }
}

static void Main()
{
    foreach (var value in Fibonacci())   *3*
    {
        Console.WriteLine(value);        *4*
        if (value > 1000)                *5*
        {
            break;
        }
    }
}
  • 1 无限循环?只有当你不断请求更多时

  • 2 返回当前的斐波那契值

  • 3 调用方法以获取序列

  • 4 打印当前值

  • 5 跳出条件

如果没有迭代器,您会如何做类似的事情?您可以将方法更改为创建一个List<int>并填充它,直到达到限制。但如果限制很大,这个列表可能会很大,而且为什么知道斐波那契数列细节的方法也应该知道您想要如何停止?假设您有时想根据打印值的时长停止,有时根据打印的值的数量停止,有时根据当前值停止。您不希望实现这个方法三次。

你可以通过在循环中打印值来避免创建列表,但这会使你的Fibonacci()方法与当前想要使用值的那个东西耦合得更紧密。如果你想要将值相加而不是打印它们呢?你会写第二个方法吗?这完全是违反关注点分离的可怕行为。

迭代器解决方案正是你想要的:一个无限序列的表示,仅此而已。调用代码可以迭代它,直到它想要的程度,并按需使用这些值。

至少直到它超出int的范围。在那个点上,它可能会抛出一个异常,或者根据代码是否处于检查上下文中,可能会下溢到一个很大的负数。

手动实现斐波那契序列并不难。调用之间几乎没有状态需要维护,流程控制也很简单。(只有一个yield return语句的事实对此有所帮助。)但是,一旦代码变得更加复杂,你就不想自己编写这段代码。编译器不仅生成跟踪代码到达位置的代码,而且它还非常聪明地处理finally块,而这些块并不像你可能想象的那样简单。

2.4.5. finally 块的评估

在所有用于管理执行流程的 C#语法中,我专注于finally块似乎有些奇怪,但在迭代器中处理它们的方式既有趣又对特性的实用性很重要。实际上,你更有可能使用using语句而不是原始的finally块,但你可以将using语句视为实际上是使用finally块构建的,因此行为相同。

为了展示执行流程是如何工作的,以下列表显示了一个简单的迭代器块,在try块中产生两个项目,并将它的进度写入控制台。然后你将以几种方式使用这个方法。

列表 2.14. 记录其进度的迭代器
static IEnumerable<string> Iterator()
{
    try
    {
        Console.WriteLine("Before first yield");
        yield return "first";
        Console.WriteLine("Between yields");
        yield return "second";
        Console.WriteLine("After second yield");
    }
    finally
    {
        Console.WriteLine("In finally block");
    }
}

在运行之前,想想如果你只是迭代方法返回的序列,你会期望它打印什么。特别是,你会期望在控制台看到在 finally 块中吗?有两种思考方式:

  • 如果你认为执行被yield return语句暂停,那么逻辑上它仍然在try块内部,就没有必要执行finally块。

  • 如果你认为代码在遇到yield return语句时必须实际返回到MoveNext()调用者,那么感觉你是在退出try块,并且应该像平常一样执行finally块。

不想剧透惊喜,暂停模型获胜。它更有用,并避免了其他看似反直觉的方面。例如,只执行 try 块中的每个语句一次,但执行其 finally 块三次,这会显得很奇怪——每次你产生一个值,然后当你执行方法的其余部分时。

让我们证明它是这样工作的。下面的列表调用该方法,遍历序列中的值,并在过程中打印它们。

列表 2.15. 一个简单的 foreach 循环用于迭代和记录
static void Main()
{
    foreach (string value in Iterator())
    {
        Console.WriteLine("Received value: {0}", value);
    }
}

列表 2.15 的输出显示 finally 块只在结束时执行一次:

Before first yield
Received value: first
Between yields
Received value: second
After second yield
In finally block

这也证明了延迟求值正在工作:Main() 方法的输出与 Iterator() 方法的输出交织在一起,因为迭代器被反复暂停和恢复。

到目前为止,很简单,但这依赖于你遍历整个序列。如果你想在中间停止怎么办?如果从迭代器获取项的代码只调用一次 MoveNext()(例如,如果它只需要序列的第一个值),那么这会留下迭代器在 try 块中暂停,永远不执行 finally 块吗?

答案是是和否。如果你手动编写所有对 IEnumerator<T> 的调用,并且只调用一次 MoveNext(),那么 finally 块确实永远不会被执行。但是如果你编写了一个 foreach 循环,并且在不遍历整个序列的情况下退出它,那么 finally将会被执行。下面的列表通过在看到非空值时立即退出循环来演示这一点。它与 列表 2.15 相同,但增加了粗体部分。

列表 2.16. 通过使用迭代器退出 foreach 循环
static void Main()
{
    foreach (string value in Iterator())
    {
        Console.WriteLine("Received value: {0}", value);
 if (value != null)
 {
 break;
 }
    }
}

列表 2.16 的输出如下:

Before first yield
Received value: first
In finally block

最后一行是重要的一行:你仍然在执行 finally 块。当你退出 foreach 循环时,这会自动发生,因为 foreach 循环有一个隐藏的 using 语句。列表 2.17 展示了如果你不能使用 foreach 循环而必须手动编写等效代码时,列表 2.16 会是什么样子。如果这看起来很熟悉,那是因为你在 列表 2.12 中做了同样的事情,但这次你更加关注 using 语句。

列表 2.17. 列表 2.16 的扩展,以不使用 foreach 循环
static void Main()
{
    IEnumerable<string> enumerable = Iterator();
    using (IEnumerator<string> enumerator = enumerable.GetEnumerator())
    {
        while (enumerator.MoveNext())
        {
            string value = enumerator.Current;
            Console.WriteLine("Received value: {0}", value);
            if (value != null)
            {
                break;
            }
        }
    }
}

重要的部分是 using 语句。这确保了无论你如何离开它,你都会在 IEnumerator<string> 上调用 Dispose。如果迭代器方法在那个点被 try 块“暂停”,Dispose 方法最终会执行 finally 块。这不是很聪明吗?

2.4.6. 最后处理的重要性

这可能听起来像是一个小细节,但它对迭代器的适用性有很大影响。这意味着它们可以用于需要释放的资源的方法,例如文件句柄。这也意味着它们可以用于链接到具有相同要求的其他迭代器。你将在 第三章 中看到 LINQ to Objects 大量使用序列,可靠的释放是能够处理文件和其他资源的关键。

所有这些都要求调用者释放迭代器

如果你没有在迭代器上调用 Dispose(并且你没有迭代到序列的末尾),你可能会泄露资源或至少延迟清理。这应该被避免。

非泛型的 IEnumerator 接口不扩展 IDisposable,但 foreach 循环会检查运行时实现是否也实现了 IDisposable,并在必要时调用 Dispose。泛型的 IEnumerator<T> 接口确实扩展了 IDisposable,这使得事情变得简单。

如果你通过手动调用 MoveNext() 来迭代(这当然有其适用场景),你应该做同样的事情。如果你正在迭代一个泛型的 IEnumerable<T>,你可以像我在扩展的 foreach 循环列表中那样使用一个 using 语句。如果你不幸地正在迭代一个非泛型序列,你应该执行与编译器在 foreach 中执行相同的接口检查。

作为在迭代器块中获取资源的有用性的一个例子,考虑以下从文件中读取行序列的方法列表。

列表 2.18. 从文件中读取行
static IEnumerable<string> ReadLines(string path)
{
    using (TextReader reader = File.OpenText(path))
    {
        string line;
        while ((line = reader.ReadLine()) != null)
        {
            yield return line;
        }
    }
}

这种方法是在 .NET 4.0 中引入的(File.ReadLines),但如果一次调用方法但多次迭代结果,框架方法就不太适用;它只打开文件一次。列表 2.18 中的方法每次迭代都会打开文件,这使得推理变得简单。然而,这也有一个缺点,即延迟由于文件不存在或不可读而引发的任何异常。在 API 设计中总是存在棘手的权衡。

展示这个方法的目的是为了说明正确处理迭代器释放是多么重要。如果一个抛出异常或提前返回的 foreach 循环导致悬挂的打开文件句柄,该函数几乎毫无用处。在我们离开迭代器之前,让我们简要地揭开面纱,看看它们是如何实现的。

2.4.7. 实现草图

我总是发现看到编译器对代码做了什么很有用,尤其是对于像迭代器、async/await 和匿名函数这样的复杂情况。本节仅提供一种味道;csharpindepth.com 上的文章提供了更多的细节。请注意,确切细节是特定实现的;你可能发现不同的编译器采取了略有不同的方法。虽然如此,我预计大多数都会有一个相同的基本策略。

首先要理解的是,尽管你已经编写了一个方法,^([9)),编译器为你生成一个全新的类型来实现相关接口。你的方法主体被移动到这个生成的类型中的 MoveNext() 方法中,并调整以适应迭代器的执行语义。为了演示生成的代码,我们将查看编译器为以下列表生成的代码。

你也可以使用迭代器来编写属性访问器,但为了简洁起见,我将在本节的其余部分只讨论迭代器方法。实现方式对于属性访问器是相同的。

列表 2.19. 样本迭代器方法用于反编译
public static IEnumerable<int> GenerateIntegers(int count)
{
    try
    {
        for (int i = 0; i < count; i++)
        {
            Console.WriteLine("Yielding {0}", i);
            yield return i;
            int doubled = i * 2;
            Console.WriteLine("Yielding {0}", doubled);
            yield return doubled;
        }
    }
    finally
    {
        Console.WriteLine("In finally block");
    }
}

列表 2.19 展示了其原始形式中相对简单的方法,但我故意包括了五个可能不那么明显方面:

  • 一个参数

  • 需要在 yield return 语句之间保留的局部变量

  • 不需要在 yield return 语句之间保留的局部变量

  • 两个 yield return 语句

  • 一个 finally

该方法遍历其循环 count 次,并在每次迭代中产生两个整数:迭代次数和相同值的两倍。例如,如果你传入 5,它将产生 0, 0, 1, 2, 2, 4, 3, 6, 4, 8。

可下载的源代码包含生成的代码的完整、手动调整的反编译形式。它相当长,所以我没有在这里全部包含。相反,我想给你一个生成的感觉。以下列表显示了大部分基础设施,但没有实现细节。我将解释这一点,然后你将查看 MoveNext() 方法,它做了大部分实际工作。

列表 2.20. 迭代器生成的代码的基础设施
public static IEnumerable<int> GenerateIntegers(             *1*
    int count)                                               *1*
{
    GeneratedClass ret = new GeneratedClass(-2);
    ret.count = count;
    return ret;
}

private class GeneratedClass                                 *2*
    : IEnumerable<int>, IEnumerator<int>                     *2*
{
    public int count;                                        *3*
    private int state;                                       *3*
    private int current;                                     *3*
    private int initialThreadId;                             *3*
    private int i;                                           *3*

    public GeneratedClass(int state)                         *4*
    {
        this.state = state;
        initialThreadId = Environment.CurrentManagedThreadId;
    }

    public bool MoveNext() { ... }                           *5*

    public IEnumerator<int> GetEnumerator() { ... }          *6*

    public void Reset()
    {
        throw new NotSupportedException();                   *7*
    }
    public void Dispose() { ... }                            *8*

    public int Current { get { return current; } }           *9*

    private void Finally1() { ... }                          *10*

    IEnumerator Enumerable().GetEnumerator()                 *11*
    {                                                        *11*
        return GetEnumerator();                              *11*
    }                                                        *11*
                                                             *10*
    object IEnumerator.Current { get { return current; } }   *11*
}
  • 1 带有原始声明签名的存根方法

  • 2 生成的类用于表示状态机

  • 3 状态机中具有不同目的的所有字段

  • 4 由存根方法和 GetEnumerator 调用的构造函数

  • 5 状态机代码的主体

  • 6 如果需要,创建一个新的状态机

  • 7 生成的迭代器从不支持重置

  • 8 如果需要,执行任何 finally 块

  • 9 当前属性用于返回最后一个产生的值

  • 10 用于 MoveNext 和 Dispose 的 finally 块的主体

  • 11 显式实现非泛型接口成员

是的,这是简化版本。需要理解的重要点是,编译器为你生成一个状态机,作为一个私有嵌套类。编译器生成的许多名称都不是有效的 C#标识符,但我为了简单起见提供了有效的名称。编译器仍然会发出在原始源代码中声明的签名的方法,这就是任何调用者将使用的。这一切只是创建状态机的一个实例,将任何参数复制到它,并将状态机返回给调用者。没有调用原始源代码,这与你已经看到的懒加载行为相对应。

状态机包含实现迭代器所需的一切:

  • 一个指示你在方法中位置的指示器。这类似于 CPU 中的指令计数器,但更简单,因为你只需要区分几个状态

  • 所有参数的副本,以便你在需要时可以获取它们的值

  • 方法内的局部变量

  • 最后一次返回的值,以便调用者可以通过Current属性获取它

你会期望调用者执行以下操作序列:

  1. 调用GetEnumerator()以获取IEnumerator<int>

  2. 重复调用MoveNext()然后在IEnumerator<int>上调用Current,直到MoveNext()返回false

  3. 调用Dispose进行任何必要的清理,无论是否抛出异常。

在几乎所有情况下,状态机仅使用一次,并且仅在创建它的同一个线程上使用。编译器生成代码以优化这种情况;GetEnumerator()方法会检查它,如果状态机仍然处于原始状态并且位于同一线程上,则返回this。这就是为什么状态机实现了IEnumerable<int>IEnumerator<int>,这在正常代码中是不常见的.^([10]) 如果从不同的线程或多次调用GetEnumerator(),这些调用将创建一个新的状态机实例,并将初始参数值复制进去。

¹⁰

如果原始方法只返回IEnumerator<T>,状态机只实现那个。

MoveNext()方法是复杂的部分。第一次调用时,它只需要像正常一样执行方法中编写的代码;但在随后的调用中,它需要有效地跳转到方法中的正确点。局部变量需要在调用之间保持不变,因此它们被存储在状态机的字段中。

在优化构建中,一些局部变量不需要复制到字段中。使用字段的目的在于,你可以跟踪在MoveNext()调用中设置的值,当你再次调用MoveNext()时。如果你查看列表 2.19 中的doubled局部变量,它从未像那样使用过:

for (int i = 0; i < count; i++)
{
    Console.WriteLine("Yielding {0}", i);
    yield return i;
 int doubled = i * 2;
 Console.WriteLine("Yielding {0}", doubled);
 yield return doubled;
}

你所做的一切就是初始化变量,打印它,然后产生它。当你返回到该方法时,该值是无关紧要的,因此编译器可以将其优化为发布构建中的实际局部变量。在调试构建中,它可能仍然存在,以改善调试体验。注意,如果你交换了前面代码中的最后两行粗体内容——先产生值然后打印它——优化将不可行。

MoveNext()方法看起来是什么样子?在不陷入过多细节的情况下给出真实代码是困难的,所以下面的列表提供了一个结构的草图。

列表 2.21. 简化的MoveNext()方法
public bool MoveNext()
{
    try
    {
        switch (state)
        {
                          *1*
        }
                          *2*
    }
    fault                 *3*
    {
        Dispose();        *4*
    }
}
  • 1 跳转表以到达方法的其余部分的正确部分

  • 2 在每个yield return返回时返回方法代码

  • 3 故障块仅在异常发生时执行

  • 4 异常清理

状态机包含一个变量(在我们的例子中,称为state),它记住它到达的位置。确切使用的值取决于实现,但在我所使用的 Roslyn 版本中,状态实际上如下所示:

  • –3MoveNext()当前正在执行

  • –2GetEnumerator()尚未调用

  • –1—完成(无论成功与否)

  • 0GetEnumerator()已调用但MoveNext()尚未调用(方法开始)

  • 1—在第一个yield return语句

  • 2—在第二个yield return语句

当调用MoveNext()时,它使用此状态跳转到方法中的正确位置,以开始第一次执行或从之前的yield return语句恢复。注意,没有关于代码中位置的状态,例如“刚刚给doubled变量赋值”,因为你从不需要从那里恢复;你需要从之前暂停的地方恢复。

列表 2.21 末尾的fault块是一个没有直接 C#等价的 IL 构造。它就像一个在抛出异常时执行的finally块,但没有捕获异常。这用于执行所需的任何清理操作;在我们的例子中,那就是finally块。finally块中的代码被移动到一个单独的方法中,该方法从Dispose()(如果已抛出异常)和MoveNext()(如果你在没有异常的情况下到达那里)调用。Dispose()方法检查状态以查看需要什么清理。如果有更多的finally块,这会变得更加复杂。

查看实现并不能在教你更多 C#编码技术方面提供帮助,但它对于建立对编译器能够为你做什么的欣赏是极好的。在 C# 5 中,同样的想法再次在 async/await 中发挥作用,在这种情况下,异步方法实际上暂停,直到异步操作完成,而不是暂停直到再次调用MoveNext()

我们现在已经涵盖了 C# 2 的最大功能,但当时还引入了一些较小的功能。这些功能描述起来相对简单,这就是为什么我把它们都放在一起。它们在其他方面没有关联,但有时语言设计就是这样发生的。

2.5. 小功能

在我的经验中,本节中描述的一些功能很少使用,但其他功能在任何现代 C#代码库中都很常见。描述一个功能所需的时间并不总是与其实用性相关。在本节中,你将查看以下内容:

  • 部分类型允许将单个类型的代码拆分到多个源文件中

  • 静态实用类型类

  • 属性中的getset访问器具有单独的可访问性(公共、私有等)

  • 对命名空间别名进行改进,以便更容易处理在多个命名空间或程序集中使用相同名称的代码

  • 允许使用额外的编译器特定功能,如暂时禁用警告的指令

  • 在不安全代码中用于内联数据的固定大小缓冲区

  • [InternalsVisibleTo] 属性,它使测试更简单

每个功能都是独立的,我描述它们的顺序并不重要。如果你对其中任何一个部分了解得足够多,知道它对你来说无关紧要,你可以安全地跳过它,而不会在以后成为问题。

2.5.1. 部分类型

部分类型允许单个类、结构体或接口在多个部分中声明,通常跨越多个源文件。这通常与代码生成器一起使用。多个代码生成器可以为同一类型贡献不同的部分,并且这些部分可以通过手动编写的代码进一步扩展。不同的部分由编译器组合,并像它们一起声明一样行动。

部分类型通过在类型声明中添加partial修饰符来声明。这必须存在于每个部分中。以下列表显示了一个包含两个部分的示例,并演示了如何在不同的部分中使用在一个部分中声明的方。

列表 2.22. 一个简单的部分类
partial class PartialDemo
{
    public static void MethodInPart1()
    {
        MethodInPart2();                      *1*
    }
}

partial class PartialDemo
{
    private static void MethodInPart2()       *2*
    {
        Console.WriteLine("In MethodInPart2");
    }
}
  • 1 使用第二部分中声明的方

  • 2 第一部分使用的方法

如果类型是泛型的,每个部分都必须声明具有相同名称的相同类型参数集,尽管如果多个声明约束了相同的类型参数,则这些约束必须相同。不同的部分可以贡献类型实现的不同接口,而实现不需要在指定接口的部分中。

部分方法(C# 3)

C# 3 引入了一个名为局部方法的额外特性,用于部分类型。这些方法在一个部分中声明而没有主体,然后可选地在另一个部分中实现。局部方法是隐式私有的,必须是void且没有out参数。(可以使用ref参数。)在编译时,只有具有实现的局部方法被保留;如果一个局部方法没有被实现,那么对其的所有调用都会被移除。这听起来很奇怪,但它允许生成代码为手动编写的代码提供可选的钩子,以便添加额外的行为。实际上,这确实很有用。以下列表提供了一个具有两个局部方法的示例,其中一个已实现,另一个未实现。

列表 2.23. 两个局部方法——一个已实现,一个未实现
partial class PartialMethodsDemo
{
    public PartialMethodsDemo()
    {
        OnConstruction();                            *1*
    }

    public override string ToString()
    {
        string ret = "Original return value";
        CustomizeToString(ref ret);                  *2*
        return ret;
    }

    partial void OnConstruction();                   *3*
    partial void CustomizeToString(ref string text); *3*
}

partial class PartialMethodsDemo
{
    partial void CustomizeToString(ref string text)  *4*
    {
        text += " - customized!";
    }
}
  • 1 调用未实现的局部方法

  • 2 调用已实现的局部方法

  • 3 局部方法声明

  • 4 局部方法实现

在列表 2.23 中,第一部分很可能是生成代码,从而允许在构造时和获取对象字符串表示时添加额外的行为。第二部分对应于手动编写的代码,它不需要自定义构造,但希望更改ToString()返回的字符串表示。尽管CustomizeToString方法不能直接返回值,但它可以通过ref参数有效地将信息传递回其调用者。

因为OnConstruction从未被实现,所以它会被编译器完全移除。如果调用了一个带有参数的局部方法,在没有实现的情况下,这些参数甚至不会被评估。

如果你曾经编写过代码生成器,我强烈建议你让它生成局部类。你也许还会发现,在纯手工编写的代码中创建局部类很有用;例如,我使用这种方法将大型类的测试拆分为多个源文件,以便于组织。

2.5.2. 静态类

静态类是使用static修饰符声明的类。如果你曾经发现自己编写了完全由静态方法组成的实用工具类,那么这些类是成为静态类的理想候选。静态类不能声明实例方法、属性、事件或构造函数,但可以包含常规嵌套类型。

虽然声明仅包含静态成员的常规类是完全有效的,但添加static修饰符会表明你的意图,即你期望如何使用该类。编译器知道静态类永远不会被实例化,因此它阻止它们被用作变量类型或类型参数。以下列表提供了一个简要示例,说明了允许和不允许的内容。

列表 2.24. 静态类的演示
static class StaticClassDemo
{
    public static void StaticMethod() { }   *1*

    public void InstanceMethod() { }        *2*

    public class RegularNestedClass         *3*
    {
        public void InstanceMethod() { }    *4*
    }
}
...
StaticClassDemo.StaticMethod();             *5*

StaticClassDemo localVariable = null;       *6*
List<StaticClassDemo> list =                *7*
    new List<StaticClassDemo>();            *7*
  • 1 正确:静态类可以声明静态方法。

  • 2 无效:静态类不能声明实例方法。

  • 3 合法:静态类可以声明常规嵌套类型。

  • 4 合法:静态类中嵌套的常规类型可以声明实例方法。

  • 5 合法:从静态类中调用静态方法

  • 6 无效:不能声明静态类的变量

  • 7 无效:不能将静态类用作类型参数

静态类具有额外的特殊行为,即扩展方法(在 C# 3 中引入)只能声明在非嵌套、非泛型、静态类中。

2.5.3. 属性的单独获取器/设置器访问

很难相信,但在 C# 1 中,属性只有一个访问修饰符,用于获取器和设置器,假设两者都存在。C# 2 引入了通过向更私有的访问器添加修饰符来使一个访问器比另一个更私有的能力。这几乎总是用来使设置器比获取器更私有,最常见的情况是具有公共获取器和私有设置器,如下所示:

private string text;

public string Text
{
    get { return text; }
    private set { text = value; }
}

在此示例中,任何可以访问属性设置器的代码都可以直接设置字段值,但在更复杂的情况下,您可能希望添加验证或更改通知。使用属性允许这种行为被很好地封装。尽管这可以放在方法中,但在 C#中使用属性感觉更符合惯例。

2.5.4. 命名空间别名

命名空间用于允许在多个命名空间中声明具有相同名称的类型。这避免了仅仅为了唯一性而使用长而复杂的类型名称。C# 1 已经支持命名空间,甚至命名空间别名,因此如果您有一段需要使用来自不同命名空间的同名类型的代码,可以清楚地指明您指的是哪个类型。以下列表显示了如何一个方法可以引用 Windows Forms 和 ASP.NET Web Forms 中的Button类。

列表 2.25. C# 1 中的命名空间别名
using System;
using WinForms = System.Windows.Forms;              *1*
using WebForms = System.Web.UI.WebControls;         *1*

class Test
{
    static void Main()
    {
        Console.WriteLine(typeof(WinForms.Button)); *2*
        Console.WriteLine(typeof(WebForms.Button)); *2*
    }
}
  • 1 介绍命名空间别名

  • 2 使用别名限定名称

C# 2 在三个重要方面扩展了对命名空间别名的支持。

命名空间别名限定符语法

在列表 2.25 中的WinForms.Button语法在没有任何名为WinForms的类型存在的情况下是有效的。在这种情况下,编译器会将WinForms.Button视为在类型WinForms中使用名为Button的成员的尝试,而不是使用命名空间别名。C# 2 通过引入一个名为命名空间别名限定符的新语法来解决此问题,它只是一个由两个冒号组成的对。这仅用于命名空间别名,从而消除了任何歧义。使用命名空间别名限定符,列表 2.25 中的Main方法将变为以下内容:

static void Main()
{
    Console.WriteLine(typeof(WinForms::Button));
    Console.WriteLine(typeof(WebForms::Button));
}

解决歧义不仅对帮助编译器有用。更重要的是,它帮助任何阅读你代码的人理解在 :: 前的标识符预期是一个命名空间别名,而不是类型名。我建议在任何使用命名空间别名的地方都使用 ::

全局命名空间别名

虽然在生产代码中声明全局命名空间中的类型是不常见的,但它确实可能发生。在 C# 2 之前,没有完全限定对命名空间中类型的引用的方法。C# 2 引入了 global 作为命名空间别名,它始终指向全局命名空间。除了引用全局命名空间中的类型外,全局命名空间别名还可以用作完全限定名称的“根”,这就是我最常使用它的方式。

例如,最近我处理了一些代码,其中有很多方法使用 DateTime 参数。当另一个名为 DateTime 的类型被引入到同一命名空间中时,这给这些方法声明带来了问题。尽管我可以为 System 命名空间引入一个命名空间别名,但用 global::System.DateTime 替换每个方法参数类型更简单。我发现,在编写代码生成器或处理可能发生冲突的生成代码时,命名空间别名,尤其是全局命名空间别名,特别有用。

外部别名

到目前为止,我一直在谈论具有相同名称但位于不同命名空间的多类型之间的命名冲突。那么,更令人担忧的冲突是什么:同一命名空间中具有相同名称但由不同汇编提供的两个类型?

这确实是一个边缘情况,但它可能会发生,C# 2 引入了 外部别名 来处理这种情况。外部别名在源代码中声明,没有任何指定的关联,如下所示:

extern alias FirstAlias;
extern alias SecondAlias;

在相同的源代码中,然后可以在 using 指令或编写完全限定类型名称时使用该别名。例如,如果你正在使用 Json.NET 但有一个额外的汇编声明了 Newtonsoft.Json.Linq.JObject,你可以编写如下代码:

extern alias JsonNet;
extern alias JsonNetAlternative;

using JsonNet::Newtonsoft.Json.Linq;
using AltJObject = JsonNetAlternative::Newtonsoft.Json.Linq.JObject;
...
JObject obj = new JObject();           *1*
AltJObject alt = new AltJObject();     *2*
  • 1 使用常规 Json.NET JObject 类型*

  • 2 在替代汇编中使用 JObject 类型*

这留下了一个问题:将每个外部别名与一个汇编关联起来。执行此操作的具体机制是实现的特定。例如,它可以在项目选项或编译器命令行中指定。

我不记得自己曾经需要使用外部别名,我通常期望它们被用作临时解决方案,在找到避免命名冲突的其他方法时使用。但我很高兴它们存在,以便允许这些临时解决方案。

2.5.5. 预处理指令

预处理指令是特定于实现的指令,它向编译器提供额外信息。预处理指令不能改变程序的行为以违反 C#语言规范中的任何内容,但它可以在规范范围之外做任何事情。如果编译器不理解特定的预处理指令,它可以发出警告但不能报错。预处理指令的语法很简单:它只是作为行中第一个非空白部分的#pragma,后面跟着预处理指令的文本。

Microsoft C#编译器支持用于警告和校验和的预处理指令。我总是只在生成的代码中看到校验和预处理指令,但警告预处理指令对于禁用和重新启用特定警告很有用。例如,要禁用针对特定代码段的警告 CS0219(“变量已分配但其值从未使用”),你可能写出如下代码:

#pragma warning disable CS0219
int variable = CallSomeMethod();
#pragma warning restore CS0219

在 C# 6 之前,警告只能使用数字指定。Roslyn 使编译器管道更加可扩展,从而允许其他包在构建过程中贡献警告。为了适应这一点,语言被修改为允许在警告标识符中指定前缀(例如,CS代表 C#编译器)。我建议始终包含前缀(例如,在前面的示例中,使用 CS0219 而不是仅使用 0219)以提高清晰度。

如果你省略特定的警告标识符,所有警告都将被禁用或恢复。我从未使用过这个功能,并且通常不推荐使用它。通常,你希望修复警告而不是禁用它们,而全面禁用警告会隐藏可能潜藏在代码中的问题信息。

2.5.6. 固定大小的缓冲区

固定大小的缓冲区是我从未在生产代码中使用过的另一个功能。这并不意味着你不会发现它们有用,尤其是如果你大量使用与本地代码的互操作。

固定大小的缓冲区只能在非安全代码和结构体内部使用。它们通过使用fixed修饰符在结构体内部有效地分配一块内存。以下列表展示了表示 16 字节任意数据和两个 32 位整数(用于表示数据的版本号)的结构的简单示例。

列表 2.26. 使用固定大小的缓冲区处理版本化的二进制数据块
unsafe struct VersionedData
{
    public int Major;
    public int Minor;
 public fixed byte Data[16];
}

unsafe static void Main()
{
    VersionedData versioned = new VersionedData();
    versioned.Major = 2;
    versioned.Minor = 1;
    versioned.Data[10] = 20;
}

我预计这种结构类型值的尺寸将是 24 字节,或者如果运行时将字段对齐到 8 字节边界,可能是 32 字节。重要的是所有的数据都直接在值内;没有引用到单独的字节数组。这个结构可以用于与本地代码的互操作性,或者仅用于常规托管代码中。

警告

尽管我在本书中提供了关于使用示例代码的一般警告,但我仍觉得有必要为这个例子给出一个更具体的警告。为了使代码更简洁,我没有尝试在这个结构体中提供任何封装。它应该仅用于获取固定大小缓冲区的语法印象。

C# 7.3 中字段中固定大小缓冲区的访问得到改进

列表 2.26 展示了通过局部变量访问固定大小的缓冲区。如果 versioned 变量是一个字段而不是变量,那么在 C# 7.3 之前访问 versioned.Data 的元素将需要使用 fixed 语句创建指针。从 C# 7.3 开始,你可以在字段中直接访问固定大小的缓冲区,尽管代码仍然需要在非安全上下文中。

2.5.7. InternalsVisibleTo

C# 2 的最后一个特性既是一个框架特性,也是一个运行时特性。它甚至没有在语言规范中提及,尽管我预计任何现代的 C#编译器都应该知道它。该框架公开了一个名为 [InternalsVisibleToAttribute] 的属性,这是一个具有单个参数的模块级属性,该参数指定了另一个模块。这允许包含该属性的模块的内部成员可以被属性中指定的模块使用,如下面的示例所示:

[assembly:InternalsVisibleTo("MyProduct.Test")]

当程序集被签名时,需要在程序集名称中包含公钥。例如,在 Noda Time 中,我有以下内容:

[assembly: InternalsVisibleTo("NodaTime.Test,PublicKey=0024...4669"]

实际的公钥当然要长得多。使用此属性与签名程序集一起从不漂亮,但你不需要经常查看代码。我已在这三种情况下使用该属性,其中之一我后来后悔了:

  • 允许测试程序集访问内部成员以简化测试

  • 允许工具(这些工具从未发布)访问内部成员以避免代码重复

  • 允许一个库访问另一个紧密相关的库中的内部成员

这些中的最后一个是一个错误。我们习惯于期望我们可以更改内部代码而不必担心版本控制,但当内部代码暴露给另一个独立版本化的库时,它就具有与公共代码相同的版本特性。我不打算再次这样做。

然而,对于测试和工具,我非常支持使内部成员可见。我知道关于只测试公共 API 表面的测试教条,但通常如果你试图保持公共表面较小,允许测试访问内部代码可以使你编写更简单的测试,这意味着你可能会编写更多的测试。

摘要

  • C# 2 的变化对惯用 C#的外观和感觉产生了巨大的影响。在没有泛型或可空类型的情况下工作实际上是可怕的。

  • 泛型允许类型和方法在其 API 签名中表达更多关于类型的信息。这促进了编译时类型安全,而无需大量代码重复。

  • 引用类型始终具有使用空值来表示信息缺失的能力。可空值类型将这一理念应用于值类型,并在语言、运行时和框架中提供支持,使它们易于使用。

  • 在 C# 2 中,委托的使用变得更加容易,常规方法和匿名方法的方组转换提供了更多的功能和简洁性。

  • 迭代器允许代码产生延迟评估的序列,这实际上会在请求下一个值之前暂停方法。

  • 并非所有功能都是巨大的。像部分类型和静态类这样的小功能仍然可以产生重大影响。其中一些可能不会影响每位开发者,但对于特定用例却至关重要。

第三章. C# 3:LINQ 及其所有相关内容

本章涵盖

  • 简单实现平凡属性

  • 更简洁地初始化对象和集合

  • 为本地数据创建匿名类型

  • 使用 lambda 表达式构建委托和表达式树

  • 使用查询表达式简单表达复杂查询

C# 2 的新功能大多相互独立。可空值类型依赖于泛型,但它们仍然是独立的功能,并没有朝着共同的目标构建。

C# 3 是不同的。它包含了许多新功能,每个功能本身都有用,但几乎都是朝着 LINQ 的更大目标构建的。本章将分别展示每个功能,然后演示它们是如何结合在一起的。我们将首先查看的唯一一个与 LINQ 没有直接关系的功能。

3.1. 自动实现属性

在 C# 3 之前,每个属性都必须手动实现,包括 get 和/或 set 访问器的主体。编译器乐于为字段类似的事件提供实现,但不为属性提供。这意味着有很多这样的属性:

private string name;
public string Name
{
    get { return name; }
    set { name = value; }
}

格式会根据代码风格而变化,但无论属性是一行很长、11 行很短,还是在中间有 5 行(如前面的示例所示),它始终只是噪音。这是一种非常冗长的表达意图的方式,即有一个字段,并通过属性将其值暴露给调用者。

C# 3 通过使用 自动实现属性(通常称为 自动属性 或甚至 autoprops)使这一切变得更加简单。这些属性没有访问器主体;编译器提供实现。前面的所有代码都可以用一行替换:

public string Name { get; set; }

注意,现在源代码中没有字段声明。仍然有一个字段,但它是由编译器自动创建的,并赋予一个在 C# 代码中无法引用的名字。

在 C# 3 中,你不能声明只读的自动实现属性,也不能在声明点提供初始值。这两个特性最终在 C# 6 中被引入,并在第 8.2 节中描述。在 C# 6 之前,通过给它们一个私有的 set 访问器来伪造只读属性是一种相当常见的做法:

public string Name { get; private set; }

C# 3 中自动实现属性的引入对减少样板代码产生了巨大影响。它们只有在属性仅仅获取和设置字段值时才有用,但根据我的经验,这占了属性的大多数比例。

正如我提到的,自动实现属性并不直接贡献于 LINQ。让我们继续到第一个确实有贡献的特性:数组和局部变量的隐式类型。

3.2. 隐式类型

为了尽可能清晰地定义 C# 3 中引入的特性,我需要首先定义一些术语。

3.2.1. 类型术语

用来描述编程语言与其类型系统交互的术语有很多。有些人使用 弱类型强类型 这些术语,但我尽量避免使用它们,因为它们没有明确的定义,对不同开发者意味着不同的事情。另外两个方面有更多的共识:静态/动态类型和显式/隐式类型。让我们依次看看这些。

静态和动态类型

通常,静态类型的语言是编译型语言;编译器能够确定每个表达式的类型并检查其是否被正确使用。例如,如果你对一个对象调用方法,编译器可以使用类型信息来检查是否存在一个适合调用该方法的方法,基于方法调用的表达式类型、方法名称以及参数的数量和类型。确定像方法调用或字段访问这样的含义的过程称为绑定动态类型的语言将所有或大部分的绑定留给执行时。

注意

正如你将在各个地方看到的那样,C# 中的一些表达式在源代码中考虑时没有类型,例如空字面量。但编译器始终根据表达式使用的上下文来确定类型,此时该类型可以用于检查表达式的使用方式。

除了在 C# 4 中引入的动态绑定(并在第四章中描述)之外,C# 是一种静态类型语言。尽管选择执行哪个虚拟方法实现取决于被调用对象在执行时的类型,但确定方法签名的绑定过程全部发生在编译时。

显式和隐式类型

在一个显式类型的语言中,源代码指定了所有涉及的类型。这可能包括局部变量、字段、方法参数或方法返回类型等。一个隐式类型的语言允许开发者从源代码中省略类型,以便其他机制(无论是编译器还是执行时的某些机制)可以根据其他上下文推断出期望的类型。

C# 主要为显式类型。即使在 C# 3 之前,也存在一些隐式类型,例如,正如你在第 2.1.4 节中看到的泛型类型参数的类型推断。可以说,存在隐式转换(如 intlong)也使得语言不那么显式地类型化。

将这些不同的类型方面分开,你可以查看 C# 3 的隐式类型功能。我们将从隐式类型局部变量开始。

3.2.2. 隐式类型局部变量(var)

隐式类型局部变量是使用上下文关键字 var 而不是类型名称声明的变量,如下所示:

var language = "C#";

使用 var 而不是类型名称声明局部变量的结果仍然是一个具有已知类型的局部变量;唯一的区别是类型是由编译器从分配给它的值的编译时类型推断出来的。前面的代码将生成与以下代码完全相同的结果:

string language = "C#";
提示

当 C# 3 首次发布时,许多开发者避免使用 var,因为他们认为它会移除很多编译时检查或导致执行时性能问题。它根本不是这样;它只是推断局部变量的类型。在声明之后,变量表现得就像它已经使用显式类型名称声明一样。

类型推断的方式导致隐式类型局部变量有两个重要的规则:

  • 变量必须在声明点进行初始化。

  • 初始化变量的表达式必须有一个类型。

下面是一些无效代码,用于演示这些规则:

var x;           *1*
x = 10;          *1*

var y = null;    *2*
  • 1 未提供初始值

  • 2 初始值没有类型。

通过分析分配给变量的所有赋值并从中推断类型,在某些情况下可以避免这些规则。一些语言这样做,但 C# 语言设计者更喜欢将规则保持得尽可能简单。

另一个限制是 var 只能用于局部变量。很多时候,我渴望有隐式类型的字段,但它们仍然不可用(至少在 C# 7.3 的情况下)。

在前面的示例中,使用 var 的好处很少,如果有的话,显式声明是可行的,并且同样易于阅读。通常有三个原因使用 var

  • 当变量的类型无法命名因为它是不具名的。你将在第 3.4 节中查看匿名类型。这是该功能的 LINQ 相关部分。

  • 当变量的类型有一个长名称,并且可以根据初始化它的表达式很容易地由人类读者推断出来时。

  • 当变量的精确类型不是特别重要时,并且用于初始化它的表达式为阅读代码的人提供了足够的信息。

我会将第一个项目符号的示例保存在第 3.4 节中,但第二个示例很容易展示。假设你想创建一个将名称映射到十进制值列表的字典。你可以通过显式类型化的变量来实现这一点:

Dictionary<string, List<decimal>> mapping =
    new Dictionary<string, List<decimal>>();

这真的很丑陋。我不得不将它分成两行,只是为了让它适合页面,而且有很多重复。这种重复可以通过使用var完全避免:

var mapping = new Dictionary<string, List<decimal>>();

这样可以以更少的文字表达相同的信息量,因此有更少的内容会分散你对其他代码的注意力。当然,这仅在你想变量的类型与初始化表达式的类型完全一致时才有效。如果你希望映射变量的类型是IDictionary<string, List<decimal>>——接口而不是类——那么var就不会有帮助。但对于局部变量来说,这种接口和实现之间的分离通常不太重要。

当我撰写《C# 深入》的第一版时,我对隐式类型化的局部变量持谨慎态度。我很少在 LINQ 之外使用它们,除了直接调用构造函数的情况,就像前面的例子一样。我担心在阅读代码时无法轻松地确定变量的类型。

十年后,这种谨慎态度大部分已经消失。我在测试代码中几乎使用var来声明所有局部变量,在生产代码中也广泛使用。我的担忧并没有成真;在几乎所有情况下,我都能通过检查轻松地推断出应该是什么类型。在那种情况下,我会高兴地使用显式声明。

我不声称自己在这一点上完全一致,我当然也不是教条主义者。因为显式类型化的变量会生成与隐式类型化的变量完全相同的代码,所以你可以随时改变主意,无论是向哪个方向改变。我建议你与将最多与你合作的其他人(无论是同事还是开源合作者)讨论这个问题,了解每个人的舒适度,并尽量遵守这一点。C# 3 中隐式类型化的另一个方面有些不同。它与var没有直接关系,但它有相同的方面,即移除类型名称以让编译器推断它。

3.2.3. 隐式类型化数组

有时你需要创建一个未填充的数组,并保留所有元素及其默认值。自 C# 1 以来,这种语法的语法没有改变;它总是像这样:

int[] array = new int[10];

但你通常希望创建一个具有特定初始内容的数组。在 C# 3 之前,有两种实现方式:

int[] array1 = { 1, 2, 3, 4, 5};
int[] array2 = new int[] { 1, 2, 3, 4, 5};

这种形式仅在它是变量声明的一部分,并指定了数组类型时才有效。例如,这是无效的:

int[] array;
array = { 1, 2, 3, 4, 5 };      *1*
  • 1 无效

第二种形式始终有效,因此前一个示例中的第二行可以如下所示:

array = new int[] { 1, 2, 3, 4, 5 };

C# 3 引入了一种第三种形式,其中数组的类型基于内容隐式指定:

array = new[] { 1, 2, 3, 4, 5 };

这可以在任何地方使用,只要编译器能够从指定的数组元素中推断出数组元素类型。它也适用于多维数组,如下面的示例所示:

var array = new[,] { { 1, 2, 3 }, { 4, 5, 6 } };

下一个明显的问题是编译器是如何推断出类型的。正如经常发生的那样,为了处理各种边缘情况,精确的细节很复杂,但简化的步骤顺序如下:

  1. 通过考虑具有类型的每个数组元素的类型,找到一组 候选类型

  2. 对于每个候选类型,检查每个数组元素是否可以隐式转换为该类型。删除任何不满足此条件的候选类型。

  3. 如果只剩下一个类型,那么这就是推断出的元素类型,编译器将创建一个适当的数组。否则(如果没有类型或类型超过一个),将发生编译时错误。

数组元素类型必须是数组初始化器中某个表达式的类型。没有尝试找到公共基类或公共实现接口。 表 3.1 给出了一些说明规则的示例。

表 3.1. 隐式类型数组的类型推断示例
表达式 结果 备注
new[] int[] 所有元素都是 int 类型。
new[] 错误 没有元素具有类型。
new[] string[] 只有一个候选类型是 string,并且 null 文字面量可以转换为 string。
new[] object[] 候选类型为 string 和 object;从 string 到 object 的隐式转换,但反之则不行。
new[] 错误 候选类型为 int 和 DateTime,但没有从任一类型到另一类型的转换。
new[] 错误 只有一个候选类型是 int,但没有从 null 转换到 int 的转换。

隐式类型的数组主要是为了减少源代码的需要而提供便利,除了匿名类型,即使你想显式地声明数组类型,也无法这样做。即便如此,如果我现在不得不在没有它们的情况下工作,我肯定会怀念这种便利。

下一个特性继续了使创建和初始化对象更简单的主题,但以不同的方式。

3.3. 对象和集合初始化器

对象初始化器集合初始化器使得创建具有初始值的新对象或集合变得简单,就像您可以在单个表达式中创建和填充数组一样。这种功能对于 LINQ 来说很重要,因为查询的翻译方式,但它也证明在其他地方也非常有用。它确实要求类型是可变的,如果您试图以函数式风格编写代码,这可能会很烦人,但您可以在适用的地方使用它,它就很好。在深入细节之前,让我们看看一个简单的例子。

3.3.1. 对象和集合初始化器简介

作为一个非常简化的例子,让我们考虑电子商务系统中的一个订单可能看起来是什么样子。下面的列表显示了三个类来模拟订单、客户以及订单中的单个项目。

列表 3.1. 在电子商务系统中建模订单
public class Order
{
    private readonly List<OrderItem> items = new List<OrderItem>();

    public string OrderId { get; set; }
    public Customer Customer { get; set; }
    public List<OrderItem> Items { get { return items; } }
}

public class Customer
{
    public string Name { get; set; }
    public string Address { get; set; }
}

public class OrderItem
{
    public string ItemId { get; set; }
    public int Quantity { get; set; }
}

您如何创建一个订单?嗯,您需要创建一个Order实例,并将其OrderIdCustomer属性赋值。您不能赋值给Items属性,因为它只读。相反,您可以向它返回的列表中添加项目。下面的列表显示了如果您没有对象和集合初始化器,并且无法更改类以简化事物,您可能会如何做。

列表 3.2. 没有对象和集合初始化器创建和填充订单
var customer = new Customer();  *1*
customer.Name = "Jon";          *1*
customer.Address = "UK";        *1*

var item1 = new OrderItem();    *2*
item1.ItemId = "abcd123";       *2*
item1.Quantity = 1;             *2*

var item2 = new OrderItem();    *3*
item2.ItemId = "fghi456";       *3*
item2.Quantity = 2;             *3*

var order = new Order();        *4*
order.OrderId = "xyz";          *4*
order.Customer = customer;      *4*
order.Items.Add(item1);         *4*
order.Items.Add(item2);         *4*
  • 1 创建客户

  • 2 创建第一个 OrderItem

  • 3 创建第二个 OrderItem

  • 4 创建订单

这段代码可以通过为各种类添加构造函数来简化,以便根据参数初始化属性。即使有对象和集合初始化器可用,我也会这样做。但为了简洁起见,我将要求您相信,出于各种原因,这并不总是可行的。除此之外,您并不总是控制您所使用的类的代码。对象和集合初始化器使得创建和填充我们的订单变得简单得多,如下面的列表所示。

列表 3.3. 使用对象和集合初始化器创建和填充订单
var order = new Order
{
    OrderId = "xyz",
    Customer = new Customer { Name = "Jon", Address = "UK" },
    Items =
    {
        new OrderItem { ItemId = "abcd123", Quantity = 1 },
        new OrderItem { ItemId = "fghi456", Quantity = 2 }
    }
};

我不能代表所有人,但我发现列表 3.3 比列表 3.2 更容易阅读。对象的结构在缩进中变得明显,重复出现的次数也减少了。让我们更仔细地看看代码的每一部分。

3.3.2. 对象初始化器

语法上,对象初始化器是一系列在大括号内的成员初始化器。每个成员初始化器具有property = initializer-value的形式,其中property是要初始化的字段或属性的名称,而initializer-value是一个表达式、一个集合初始化器或另一个对象初始化器。

注意

对象初始化器通常与属性一起使用,这就是我在本章中描述它们的方式。字段没有访问器,但显然的等效操作适用:读取字段而不是调用获取访问器,以及写入字段而不是调用设置访问器。

对象初始化器只能作为构造函数调用或另一个对象初始化器的一部分使用。构造函数调用可以像往常一样指定参数,但如果你不想指定任何参数,你根本不需要参数列表,因此你可以省略 ()。没有参数列表的构造函数调用等同于提供一个空参数列表。例如,这两行是等效的:

Order order = new Order() { OrderId = "xyz" };
Order order = new Order { OrderId = "xyz" };

只有在提供对象或集合初始化器的情况下,你才能省略构造函数参数列表。这是无效的:

Order order = new Order;       *1*
  • 1 无效

对象初始化器只是说明了如何初始化它在成员初始化器中提到的每个属性。如果 initializer-value 部分(等号右侧的部分)是一个正常表达式,则该表达式将被评估,并将值传递给属性设置访问器。这就是 列表 3.3 中的大多数对象初始化器工作的方式。Items 属性使用了一个 集合初始化器,你很快就会看到。

如果 initializer-value 是另一个对象初始化器,则设置访问器永远不会被调用。相反,将调用获取访问器,然后嵌套对象初始化器应用于属性返回的值。例如,列表 3.4 创建了一个 HttpClient 并修改了每个请求发送的默认头信息集合。代码设置了 FromDate 头信息,我之所以选择它们,仅仅是因为它们是最简单的设置。

列表 3.4. 使用嵌套对象初始化器修改新 HttpClient 的默认头信息
HttpClient client = new HttpClient
{
    DefaultRequestHeaders =              *1*
    {
        From = "user@example.com",       *2*
        Date = DateTimeOffset.UtcNow     *3*
    }
};
  • 1 调用 DefaultRequestHeaders 属性获取访问器

  • 2 调用 From 属性设置访问器

  • 3 调用日期属性设置访问器

列表 3.4 中的代码等同于以下代码:

HttpClient client = new HttpClient();
var headers = client.DefaultRequestHeaders;
headers.From = "user@example.com";
headers.Date = DateTimeOffset.UtcNow;

单个对象初始化器可以在成员初始化器的序列中包含嵌套对象初始化器、集合初始化器和正常表达式的混合。说到集合初始化器,让我们现在看看它们。

3.3.3. 集合初始化器

语法上,集合初始化器是花括号内逗号分隔的 元素初始化器 列表。每个元素初始化器要么是一个单独的表达式,要么是花括号内逗号分隔的表达式列表。集合初始化器只能作为构造函数调用或对象初始化器的一部分使用。它们可以使用的类型存在进一步的限制,我们将在稍后讨论。在 列表 3.3 中,你看到了集合初始化器作为对象初始化器的一部分的使用。以下是带有集合初始化器加粗的列表:

var order = new Order
{
    OrderId = "xyz",
    Customer = new Customer { Name = "Jon", Address = "UK" },
    Items =
    {
 new OrderItem { ItemId = "abcd123", Quantity = 1 },
 new OrderItem { ItemId = "fghi456", Quantity = 2 }
 }
};

在创建新集合时,集合初始化器可能更常用。例如,此行声明了一个字符串列表的新变量并填充了列表:

var beatles = new List<string> { "John", "Paul", "Ringo", "George" };

编译器将其编译为一个构造函数调用,后跟一系列对 Add 方法的调用:

var beatles = new List<string>();
beatles.Add("John");
beatles.Add("Paul");
beatles.Add("Ringo");
beatles.Add("George");

但如果你使用的集合类型没有带单个参数的 Add 方法呢?这就是花括号内元素初始化器发挥作用的地方。在 List<T> 之后,第二常见的通用集合可能是带有 Add(key, value) 方法的 Dictionary<TKey, TValue>。可以使用如下方式使用集合初始化器来填充字典:

var releaseYears = new Dictionary<string, int>
{
    { "Please please me", 1963 },
    { "Revolver", 1966 },
    { "Sgt. Pepper's Lonely Hearts Club Band", 1967 },
    { "Abbey Road", 1970 }
};

编译器将每个元素初始化器视为单独的 Add 调用。如果元素初始化器是一个没有花括号的简单表达式,则值作为单个参数传递给 Add。这就是我们 List<string> 集合初始化器中的元素所发生的情况。

如果元素初始化器使用了花括号,它仍然被视为对 Add 的单个调用,但每个花括号内的表达式都有一个参数。前面的字典示例实际上等同于以下内容:

var releaseYears = new Dictionary<string, int>();
releaseYears.Add("Please please me", 1963);
releaseYears.Add("Revolver", 1966);
releaseYears.Add("Sgt. Pepper's Lonely Hearts Club Band", 1967);
releaseYears.Add("Abbey Road", 1970);

然后进行重载解析,以找到最合适的 Add 方法,包括如果有任何泛型 Add 方法,则执行类型推断。

集合初始化器仅适用于实现 IEnumerable 的类型,尽管它们不必实现 IEnumerable<T>。语言设计者检查了具有 Add 方法的框架中的类型,并确定将它们分为集合和非集合的最佳方式是查看它们是否实现了 IEnumerable。作为一个为什么这很重要的例子,考虑 DateTime.Add(TimeSpan) 方法。显然,DateTime 类型不是一个集合,所以能够编写如下代码会很奇怪:

DateTime invalid = new DateTime(2020, 1, 1) { TimeSpan.FromDays(10) };   *1*
  • 1 无效

编译器在编译集合初始化器时永远不会使用IEnumerable的实现。我有时发现,在测试项目中使用具有Add方法和仅抛出NotImplementedExceptionIEnumerable实现的类型很方便。这可以用于构建测试数据,但我不建议在生产代码中这样做。我会很感激有一个属性可以让我表达这个想法,即这个类型应该可用于集合初始化器而不需要实现IEnumerable,但我怀疑这永远不会发生。

3.3.4. 初始化的单个表达式的优势

你可能想知道所有这些与 LINQ 有什么关系。我说 C# 3 中的几乎所有特性都是为了构建 LINQ,那么对象和集合初始化器是如何融入这个图景的呢?答案是,其他 LINQ 特性要求代码可以表示为单个表达式。(例如,在查询表达式中,你不能编写一个需要多个语句来生成给定输入输出的select子句。)

在单个表达式中初始化新对象的能力不仅对 LINQ 有用。它还可以简化字段初始化、方法参数,甚至条件?:运算符中的操作数。我发现它特别适用于静态字段初始化,例如构建有用的查找表。当然,初始化表达式越大,你可能就越想考虑将其分离出来。

这甚至对特性本身也是递归重要的。例如,如果我们不能使用对象初始化器来创建我们的OrderItem对象,集合初始化器在填充Order.Items属性时就不会那么方便。

在本书的其余部分,每当提到一个新特性或改进特性具有针对单个表达式的特殊情况(例如,第 3.5 节中的 lambda 表达式或第 8.3 节中的表达式主体成员)时,值得记住的是,对象和集合初始化器立即使该特性比其他情况下更有用。

对象和集合初始化器允许编写更简洁的代码来创建类型的实例并填充它,但它们确实要求你已经有一个合适的类型来构造。我们的下一个特性,匿名类型,允许你创建对象,甚至不需要事先声明对象的类型。这听起来可能有些奇怪,但并不像听起来那么奇怪。

3.4. 匿名类型

匿名类型允许你在不事先声明类型的情况下,以静态类型方式引用对象。这听起来像类型可能在执行时动态创建,但实际情况要微妙得多。我们将探讨匿名类型在源代码中的样子,编译器如何处理它们,以及它们的一些限制。

3.4.1. 语法和基本行为

解释匿名类型的简单方法是从一个例子开始。以下列表显示了一段创建具有 NameScore 属性的对象的简单代码。

列表 3.5. 带有 NameScore 属性的匿名类型
var player = new                                       *1*
{ *1*
 Name = "Rajesh", *1*
 Score = 3500 *1*
};                                                     *1*

Console.WriteLine("Player name: {0}", player.Name);    *2*
Console.WriteLine("Player score: {0}", player.Score);  *2*
  • 1 创建具有名称和分数属性的匿名类型对象

  • 2 显示属性值

这个简短的例子展示了关于匿名类型的重要观点:

  • 语法有点像对象初始化器,但没有指定类型名称;只是 new,开括号,属性,闭括号。这被称为 匿名对象创建表达式。属性值可以是嵌套的匿名对象创建表达式。

  • 你使用 var 来声明 player 变量,因为类型没有为你提供替代 var 的名称。(如果你使用 object,声明将有效,但几乎没什么用。)

  • 这段代码仍然是静态类型的。Visual Studio 可以自动完成 player 变量的 NameScore 属性。如果你忽略这一点并尝试访问一个不存在的属性(例如尝试使用 player.Points),编译器将引发错误。属性类型是从分配给它们的值推断出来的;player.Name 是一个 string 属性,而 player.Score 是一个 int 属性。

这就是匿名类型的样子,但它们是用来做什么的呢?这就是 LINQ 发挥作用的地方。在执行查询时,无论是使用 SQL 数据库作为底层数据存储还是使用对象集合,通常都希望得到一个特定形状的数据,而不是原始类型,并且可能在外部查询中意义不大。

例如,假设你正在使用一组表示各自表达过最喜欢的颜色的人来构建一个查询。你可能希望结果是一个直方图:结果集合中的每个条目都是颜色和选择该颜色作为他们最喜欢的颜色的人数。这种表示最喜欢的颜色和类型的类型可能在其他地方不太有用,但在这种特定上下文中很有用。匿名类型允许我们简洁地表达这些一次性情况,同时不失静态类型的好处。

与 Java 匿名类的比较

如果你熟悉 Java,你可能想知道 C# 的匿名类型和 Java 的匿名类之间的关系。它们听起来可能很相似,但在语法和目的上都有很大的不同。

从历史上看,Java 中匿名类的主要用途是实现接口或扩展抽象类以覆盖一个或两个方法。C# 的匿名类型不允许你实现接口或从 System.Object 之外的其他类派生;它们的目的更多的是关于数据而不是可执行代码。

C# 在匿名对象创建表达式中提供了一种额外的简写方式,当你从其他地方复制属性或字段,并且愿意使用相同的名称时。这种语法称为投影初始化器。为了举例说明,让我们回到我们简化的电子商务数据模型。你有三个类:

  • OrderOrderId, Customer, Items

  • CustomerName, Address

  • OrderItemItemId, Quantity

在你的代码的某个地方,你可能想要一个包含所有这些信息的特定订单项的对象。如果你有名为ordercustomeritem的相关类型的变量,你可以轻松地使用匿名类型来表示扁平化的信息:

var flattenedItem = new
{
    order.OrderId,
    CustomerName = customer.Name,
    customer.Address,
    item.ItemId,
    item.Quantity
};

在这个例子中,除了CustomerName属性外,每个属性都使用了投影初始化器。结果是等同于以下代码,它明确指定了匿名类型中的属性名称:

var flattenedItem = new
{
    OrderId = order.OrderId,
    CustomerName = customer.Name,
    Address = customer.Address,
    ItemId = item.ItemId,
    Quantity = item.Quantity
};

投影初始化器在你执行查询并只想选择属性子集或从多个对象中合并属性到一个对象时最有用。如果你想要在匿名类型中给属性取的名称与你要复制的字段或属性的名称相同,编译器可以为你推断出这个名称。所以你不需要写这个

SomeProperty = variable.SomeProperty

你可以简单地写这个:

variable.SomeProperty

如果你在复制多个属性,投影初始化器可以显著减少你的源代码中的重复。它可以使表达式足够短,可以保持在同一行,或者足够长,值得为每个属性单独一行。

重构和投影初始化器

尽管可以说前两个列表的结果是相同的,但这并不意味着它们在其他方面表现相同。考虑将Address属性重命名为CustomerAddress

在使用投影初始化器的版本中,匿名类型中的属性名称也会改变。在显式属性名称的版本中,则不会。在我的经验中,这很少成为问题,但了解这种差异是值得的。

我已经描述了匿名类型的语法,你知道生成的对象具有你可以像使用普通类型一样使用的属性。但是幕后发生了什么?

3.4.2. 编译器生成的类型

尽管该类型从未出现在源代码中,编译器确实生成了一个类型。运行时没有魔法要处理;它只是看到了一个恰好具有在 C#中无效名称的类型。这个类型有几个有趣的特点。其中一些由规范保证;其他则不是。当使用 Microsoft C#编译器时,匿名类型具有以下特性:

  • 它是一个类(保证)。

  • 它的基类是object(保证)。

  • 它是密封的(虽然不保证,但很难想象使其非密封会有什么用)。

  • 属性都是只读的(保证)。

  • 构造函数参数的名称与属性相同(不保证;偶尔对反射可能有用)。

  • 它是程序集内部的(不保证;在处理动态类型时可能会很烦人)。

  • 它重写了GetHashCode()Equals()方法,使得只有当两个实例的所有属性都相等时,它们才被认为是相等的。(它处理了属性为 null 的情况。)这些方法被重写是保证的,但计算哈希码的确切方式并没有保证。

  • 它以有帮助的方式重写了ToString()方法,并列出了属性名称及其值。这并不是保证的,但在诊断问题时非常有帮助。

  • 类型是泛型的,每个属性有一个类型参数。具有相同属性名称但不同属性类型的多个匿名类型将使用不同的类型参数来表示相同的泛型类型。这并不保证,并且可能会根据编译器而容易变化。

  • 如果两个匿名对象创建表达式在同一个程序集中使用相同的属性名称、相同的顺序和相同的属性类型,那么结果保证是相同类型的两个对象。

最后一点对于变量重新赋值和使用匿名类型隐式数组的数组非常重要。根据我的经验,你很少想要重新赋值一个用匿名类型初始化的变量,但很高兴它可行。例如,这完全是有效的:

var player = new { Name = "Pam", Score = 4000 };
player = new { Name = "James", Score = 5000 };

同样,使用第 3.2.3 节中描述的隐式数组语法创建匿名类型数组是完全可以的:

var players = new[]
{
    new { Name = "Priti", Score = 6000 },
    new { Name = "Chris", Score = 7000 },
    new { Name = "Amanda", Score = 8000 },
};

注意,为了使两个匿名对象创建表达式使用相同的类型,属性必须具有相同的名称和类型,并且顺序相同。例如,这将是无效的,因为第二个数组元素中属性的顺序与其他元素不同:

var players = new[]
{
    new { Name = "Priti", Score = 6000 },
 new { Score = 7000, Name = "Chris" },
    new { Name = "Amanda", Score = 8000 },
};

虽然每个数组元素单独都是有效的,但第二个元素的类型阻止了编译器推断数组类型。如果你添加一个额外的属性或更改某个属性的类型,情况也会相同。

虽然匿名类型在 LINQ 中很有用,但这并不意味着这个特性是每个问题的正确工具。让我们简要地看看你可能不想使用它们的地方。

3.4.3. 限制

当你只想对数据进行本地化表示时,匿名类型非常出色。通过“本地化”,我的意思是,你感兴趣的数据形状仅在特定方法内相关。一旦你想要在多个地方表示相同的形状,你就需要寻找不同的解决方案。虽然可以从方法中返回匿名类型的实例或作为参数接受它们,但你只能通过使用泛型或object类型来实现。类型是匿名的,这阻止你在方法签名中表达它们。

直到 C# 7,如果你想在多个方法中使用公共数据结构,你通常需要为它声明自己的类或结构。C# 7 引入了元组,正如你将在 第十一章 中看到的,它可以作为一个替代解决方案,具体取决于你想要的封装程度。

说到封装,匿名类型基本上不提供任何封装。你无法在类型中放置任何验证或为其添加额外的行为。如果你发现自己想要这样做,那可能是一个很好的迹象,表明你可能应该创建自己的类型。

最后,我之前提到,由于类型是内部的,通过 C# 4 的动态类型在程序集之间使用匿名类型变得更加困难。我通常在 MVC 网络应用程序中看到这种尝试,其中页面的模型可能使用匿名类型构建,然后在视图中使用 dynamic 类型(你将在 第四章 中了解)进行访问。如果这两段代码在同一个程序集中,或者包含模型代码的程序集已经使用 [InternalsVisibleTo] 使其内部成员对包含视图代码的程序集可见,那么这就可以工作。根据你使用的框架,安排这两者中的任何一个可能都有些棘手。鉴于静态类型的好处,我通常建议将模型声明为常规类型。这比使用匿名类型需要更多的工作,但可能会在长期内节省你的时间。

注意

Visual Basic 也有匿名类型,但它们的行为并不完全相同。在 C# 中,所有属性都用于确定相等性和哈希码,并且它们都是只读的。在 VB 中,只有使用 Key 修饰符声明的属性才具有这种行为。非键属性是可读/写的,并且不影响相等性或哈希码。

我们已经完成了 C# 3 特性的大约一半,到目前为止,它们都与数据有关。接下来的特性更多地关注可执行代码,首先是 lambda 表达式,然后是扩展方法。

3.5. Lambda 表达式

在 第二章 中,你看到了如何通过像这样内联包含它们的代码来使创建委托实例变得容易得多:

Action<string> action = delegate(string message)      *1*
{                                                     *1*
    Console.WriteLine("In delegate: {0}", message);   *1*
};                                                    *1*
action("Message");                                    *2*
  • 1 使用匿名方法创建委托

  • 2 调用委托

Lambda 表达式 在 C# 3 中引入,以使其更加简洁。术语 匿名函数 用于指代匿名方法和 lambda 表达式。我将在本书的其余部分中多次使用它,并且在 C# 规范中广泛使用。

注意

“Lambda 表达式”这个名字来源于 lambda 演算,这是由 Alonzo Church 在 1930 年代开创的数学和计算机科学领域。Church 在他的函数表示法中使用了希腊字母 lambda(λ),这个名字就留了下来。

有很多原因使得语言设计者投入大量精力来简化委托实例的创建是有用的,但 LINQ 是最重要的一个。当你查看 第 3.7 节 中的查询表达式时,你会发现它们实际上被转换成了使用 lambda 表达式的代码。尽管如此,你也可以不使用查询表达式来使用 LINQ,而这几乎总是涉及到在你的源代码中直接使用 lambda 表达式。

首先,我们将查看 lambda 表达式的语法以及它们的一些行为细节。最后,我们将讨论表示代码为数据的 表达式树

3.5.1. Lambda 表达式语法

Lambda 表达式的基本语法始终是这样的形式:

*parameter-list* => *body*

然而,参数列表和体都有多种表示形式。在它的最明确的形式中,lambda 表达式的参数列表看起来像正常方法或匿名方法的参数列表。同样,lambda 表达式的体可以是块:一系列都在一对花括号内的语句。在这种形式中,lambda 表达式看起来类似于匿名方法:

Action<string> action = (string message) =>
{
    Console.WriteLine("In delegate: {0}", message);
};
action("Message");

到目前为止,这看起来并没有好多少;你只是将 delegate 关键字换成了 =>,但仅此而已。但是,特殊情况下可以使 lambda 表达式变得更短。

让我们从使体更简洁开始。由单个返回语句或单个表达式组成的体可以简化为那个单一表达式。如果有,将移除返回关键字。在上面的例子中,我们的 lambda 表达式的体只是一个方法调用,因此你可以简化它:

Action<string> action =
    (string message) => Console.WriteLine("In delegate: {0}", message);

你很快就会看到一个返回值的例子。这样缩短的 lambda 表达式被称为有 表达式体,而使用花括号的 lambda 表达式被称为有 语句体

接下来,如果你能根据你试图将 lambda 表达式转换到的类型推断出参数类型,你可以使参数列表更短。Lambda 表达式没有类型,但可以转换为兼容的委托类型,编译器通常可以在转换过程中推断出参数类型。

例如,在上面的代码中,编译器知道 Action<string> 有一个类型为 string 的单个参数,因此它能够推断出该参数类型。当编译器可以推断出参数类型时,你可以省略它。因此,我们的例子可以缩短:

Action<string> action =
    (message) => Console.WriteLine("In delegate: {0}", message);

最后,如果 lambda 表达式恰好有一个参数,并且该参数的类型被推断出来,则可以省略参数列表中的括号:

Action<string> action =
    message => Console.WriteLine("In delegate: {0}", message);

现在,让我们看看几个返回值的例子。在每种情况下,你将应用你能做的每一个步骤来使其更短。首先,你将构造一个委托来相乘两个整数并返回结果:

Func<int, int, int> multiply =                            *1*
    (int x, int y) => { return x * y; };                  *1*

Func<int, int, int> multiply = (int x, int y) => x * y;   *2*

Func<int, int, int> multiply = (x, y) => x * y;           *3*
**(Two parameters, so you can't remove parentheses)**
  • 1 最长形式

  • 2 使用表达式体

  • 3 推断参数类型

接下来,你将使用一个委托来获取一个字符串的长度,将这个长度乘以自身,并返回结果:

Func<string, int> squareLength = (string text) =>  *1*
{                                                  
    int length = text.Length;                      
    return length * length;                        
};                                                 

Func<string, int> squareLength = (text) =>         *2*
{
    int length = text.Length;
    return length * length;
};

Func<string, int> squareLength = text =>           *3*
{
    int length = text.Length;
    return length * length;
};
(Can't do anything else immediately; body has two statements)
  • 1 最长形式

  • 2 推断参数类型

  • 3 移除单个参数的括号

如果你愿意两次评估 Length 属性,你可以简化这个第二个例子:

Func<string, int> squareLength = text => text.Length * text.Length;

然而,这与其他类型的改变不同;这是改变 行为(尽管可能很微小),而不仅仅是语法。拥有所有这些特殊情况可能看起来很奇怪,但在实践中,所有这些情况都适用于大量情况,尤其是在 LINQ 中。现在你了解了语法,你可以开始查看委托实例的行为,特别是它捕获的任何变量。

3.5.2. 捕获变量

在 第 2.3.2 节中,当我描述匿名方法中的捕获变量时,我承诺会在 lambda 表达式的上下文中回到这个话题。这可能是 lambda 表达式中最令人困惑的部分。它肯定也是 Stack Overflow 上许多问题的原因。

要从 lambda 表达式创建委托实例,编译器会将 lambda 表达式中的代码转换为某个地方的方法。然后,委托可以在执行时创建,就像你有一个方法组一样。本节展示了编译器执行的类型转换。我将其写成编译器将源代码转换为不包含 lambda 表达式的更多源代码,但当然编译器不需要这种转换后的源代码。它可以直接生成适当的 IL。

让我们从回顾一下什么算是捕获变量开始。在 lambda 表达式中,你可以使用任何在那个点你能在常规代码中使用的变量。这可能是一个静态字段,一个实例字段(如果你在实例方法中编写 lambda 表达式^([1])),this 变量,方法参数或局部变量。所有这些都是捕获变量,因为它们是在 lambda 表达式直接上下文之外声明的变量。将此与 lambda 表达式的参数或 lambda 表达式内部声明的局部变量进行比较;那些 不是 捕获变量。以下列表显示了一个捕获各种变量的 lambda 表达式。然后你将查看编译器如何处理这段代码。

¹

你也可以在构造函数、属性访问器等地方编写 lambda 表达式,但为了简单起见,我将假设你在方法中编写它们。

列表 3.6. 在 lambda 表达式中捕获变量
class CapturedVariablesDemo
{
    private string instanceField = "instance field";

    public Action<string> CreateAction(string methodParameter)
    {
        string methodLocal = "method local";
        string uncaptured = "uncaptured local";

        Action<string> action = lambdaParameter =>
        {
            string lambdaLocal = "lambda local";
            Console.WriteLine("Instance field: {0}", instanceField);
            Console.WriteLine("Method parameter: {0}", methodParameter);
            Console.WriteLine("Method local: {0}", methodLocal);
            Console.WriteLine("Lambda parameter: {0}", lambdaParameter);
            Console.WriteLine("Lambda local: {0}", lambdaLocal);
        };
        methodLocal = "modified method local";
        return action;
    }
}

In other code
var demo = new CapturedVariablesDemo();
Action<string> action = demo.CreateAction("method argument");
action("lambda argument");

这里涉及了很多变量:

  • instanceFieldCapturedVariablesDemo 类中的一个实例字段,并且被 lambda 表达式捕获。

  • methodParameterCreateAction 方法中的一个参数,并且被 lambda 表达式捕获。

  • methodLocalCreateAction方法中的局部变量,并被 lambda 表达式捕获。

  • uncapturedCreateAction方法中的局部变量,但它从未被 lambda 表达式使用,因此没有被它捕获。

  • lambdaParameter 是 lambda 表达式本身中的参数,因此它不是捕获的变量。

  • lambdaLocal 是 lambda 表达式中的局部变量,因此它不是捕获的变量。

理解 lambda 表达式捕获的是变量本身,而不是在创建委托时变量的值是非常重要的。^([2)] 如果你在创建委托和调用它之间修改了任何捕获的变量,输出将反映这些更改。同样,lambda 表达式也可以更改捕获变量的值。编译器是如何使所有这些工作正常进行的?它是如何确保在调用时所有这些变量仍然可用给委托的?

²

我会多次重复这一点,对此我并不道歉。如果你对捕获的变量是新手,这可能需要一段时间才能习惯。

使用生成的类实现捕获的变量

有三个主要情况需要考虑:

  • 如果没有捕获任何变量,编译器可以创建一个静态方法。不需要额外的上下文。

  • 如果捕获的唯一变量是实例字段,编译器可以创建一个实例方法。捕获一个实例字段相当于捕获了 100 个,因为你只需要访问this

  • 如果捕获了局部变量或参数,编译器会创建一个私有嵌套类来包含该上下文,然后在那个类中创建一个包含 lambda 表达式代码的实例方法。包含 lambda 表达式的那个方法会改为使用那个嵌套类来访问捕获的变量。

实现细节可能有所不同

你可能会看到我所描述的内容有所变化。例如,对于没有捕获变量的 lambda 表达式,编译器可能会创建一个具有单个实例的嵌套类,而不是静态方法。根据它们创建的具体方式,执行委托的效率可能会有细微的差异。在本节中,我描述了编译器为了使捕获的变量可用而必须执行的最小工作。如果它想引入更多的复杂性,它可以这样做。

最后一种情况显然是最复杂的一种,所以我们将重点关注它。让我们从列表 3.6 开始。作为提醒,以下是创建 lambda 表达式的那个方法;为了简洁起见,我省略了类声明:

public Action<string> CreateAction(string methodParameter)
{
    string methodLocal = "method local";
    string uncaptured = "uncaptured local";

    Action<string> action = lambdaParameter =>
    {
        string lambdaLocal = "lambda local";
        Console.WriteLine("Instance field: {0}", instanceField);
        Console.WriteLine("Method parameter: {0}", methodParameter);
        Console.WriteLine("Method local: {0}", methodLocal);
        Console.WriteLine("Lambda parameter: {0}", lambdaParameter);
        Console.WriteLine("Lambda local: {0}", lambdaLocal);
    };
    methodLocal = "modified method local";
    return action;
}

正如我之前所描述的,编译器为所需的额外上下文创建一个私有嵌套类,然后在那个类中创建一个用于 lambda 表达式代码的实例方法。上下文存储在嵌套类的实例变量中。在我们的例子中,这意味着以下内容:

  • CapturedVariablesDemo原始实例的引用,以便你可以在以后访问instanceField

  • 用于捕获方法参数的字符串变量

  • 用于捕获局部变量的字符串变量

下面的列表展示了嵌套类以及它是如何被CreateAction方法使用的。

列表 3.7. 带有捕获变量的 lambda 表达式的翻译
private class LambdaContext                           *1*
{
    public CapturedVariablesDemoImpl originalThis;    *2*
    public string methodParameter;                    *2*
    public string methodLocal;                        *2*

    public void Method(string lambdaParameter)        *3*
    {
        string lambdaLocal = "lambda local";
        Console.WriteLine("Instance field: {0}",
            originalThis.instanceField);
        Console.WriteLine("Method parameter: {0}", methodParameter);
        Console.WriteLine("Method local: {0}", methodLocal);
        Console.WriteLine("Lambda parameter: {0}", lambdaParameter);
        Console.WriteLine("Lambda local: {0}", lambdaLocal);
    }
}

public Action<string> CreateAction(string methodParameter)
{
    LambdaContext context = new LambdaContext();      *4*
    context.originalThis = this;                      *4*
    context.methodParameter = methodParameter;        *4*
    context.methodLocal = "method local";             *4*
    string uncaptured = "uncaptured local";           *4*

                                                      *4*
    Action<string> action = context.Method;           *4*
    context.methodLocal = "modified method local";    *4*
    return action;
}
  • 1 生成类以保存捕获的变量

  • 2 捕获变量

  • 3 Lambda 表达式的主体成为一个实例方法。

  • 4 生成的类用于所有捕获的变量。

注意在CreateAction方法接近结束时对context.methodLocal的修改。当委托最终被调用时,它将“看到”这个修改。同样,如果委托修改了任何捕获的变量,每次调用都会看到之前调用的结果。这只是在强调编译器确保捕获变量而不是其值的快照。

在列表 3.6 和 3.7 中,你只需要为捕获的变量创建一个上下文。在规范的术语中,每个局部变量只实例化了一次。让我们使事情变得稍微复杂一些。

局部变量的多次实例化

为了使事情稍微简单一些,这次你将捕获一个局部变量,没有参数或实例字段。下面的列表展示了一个创建动作列表并逐个执行它们的方法。每个动作捕获一个text变量。

列表 3.8. 多次实例化一个局部变量
static List<Action> CreateActions()
{
    List<Action> actions = new List<Action>();
    for (int i = 0; i < 5; i++)
    {
        string text = string.Format("message {0}", i);   *1*
        actions.Add(() => Console.WriteLine(text));      *2*
    }
    return actions;
}

In other code
List<Action> actions = CreateActions();
foreach (Action action in actions)
{
    action();
}
  • 1 在循环内声明一个局部变量

  • 2 在 lambda 表达式中捕获变量

事实是text在循环内部声明非常重要。每次到达那个声明时,变量都会被实例化。每个 lambda 表达式捕获变量的不同实例。实际上有五个不同的text变量,每个变量都被单独捕获。它们是完全独立的变量。尽管这个代码在初始赋值后没有修改它们,但它确实可以在 lambda 表达式内部或循环内的其他地方进行修改。修改一个变量不会对其他变量产生影响。

编译器通过为每个实例化创建生成的类型的不同实例来模拟这种行为。因此,列表 3.8 中的CreateAction方法可以翻译成以下列表。

列表 3.9. 为每个实例化创建多个上下文实例
private class LambdaContext
{
    public string text;

    public void Method()
    {
        Console.WriteLine(text);
    }
}

static List<Action> CreateActions()
{
    List<Action> actions = new List<Action>();
    for (int i = 0; i < 5; i++)
    {
        LambdaContext context = new LambdaContext();      *1*
        context.text = string.Format("message {0}", i);
        actions.Add(context.Method);                      *2*
    }
    return actions;
}
  • 1 为每个循环迭代创建一个新的上下文

  • 2 使用上下文创建一个动作

希望这仍然有意义。你已经从为 lambda 表达式有一个上下文变成了为循环的每次迭代有一个上下文。我将通过一个更复杂的例子来结束对捕获变量的讨论,这是一个两者的混合体。

从多个作用域捕获变量

是文本变量的作用域意味着它在循环的每次迭代中只实例化一次。但单个方法中可以存在多个作用域,每个作用域可以包含局部变量声明,而单个 lambda 表达式可以捕获多个作用域的变量。列表 3.10 提供了一个示例。你创建了两个委托实例,每个实例都捕获两个变量。它们都捕获相同的 outerCounter 变量,但每个实例都捕获一个单独的 innerCounter 变量。委托简单地打印出计数器的当前值并增加它们。你执行每个委托两次,这使得捕获变量的差异变得明显。

列表 3.10. 从多个作用域捕获变量
static List<Action> CreateCountingActions()
{
    List<Action> actions = new List<Action>();
    int outerCounter = 0;                        *1*
    for (int i = 0; i < 2; i++)
    {
        int innerCounter = 0;                    *2*
        Action action = () =>
        {
            Console.WriteLine(                   *3*
                "Outer: {0}; Inner: {1}",        *3*
                outerCounter, innerCounter);     *3*
            outerCounter++;                      *3*
            innerCounter++;                      *3*
        };
        actions.Add(action);
    }
    return actions;
}

In other code
List<Action> actions = CreateCountingActions();  
actions[0]();                                    *4*
actions[0]();                                    *4*
actions[1]();                                    *4*
actions[1]();                                    *4*
  • 1 两个委托都捕获的一个变量

  • 2 每次循环迭代一个新变量

  • 3 显示并增加计数器

  • 4 调用每个委托两次

列表 3.10 的输出如下:

Outer: 0; Inner: 0
Outer: 1; Inner: 1
Outer: 2; Inner: 0
Outer: 3; Inner: 1

前两行是由第一个委托打印的。最后两行是由第二个委托打印的。正如我之前所描述的列表,两个委托都使用了相同的循环外部计数器,但它们有独立的内部计数器。

编译器会对此做什么呢?每个委托都需要自己的上下文,但这个上下文还需要引用共享的上下文。编译器创建两个私有嵌套类而不是一个。以下列表显示了编译器如何处理列表 3.10 的示例。

列表 3.11. 从多个作用域捕获变量导致多个类
private class OuterContext                               *1*
{                                                        *1*
    public int outerCounter;                             *1*
}                                                        *1*

private class InnerContext                               *2*
{                                                        *2*
    public OuterContext outerContext;                    *2*
    public int innerCounter;                             *2*

    public void Method()                                 *3*
    {
        Console.WriteLine(
            "Outer: {0}; Inner: {1}",
            outerContext.outerCounter, innerCounter);
        outerContext.outerCounter++;
        innerCounter++;
    }
}

static List<Action> CreateCountingActions()
{
    List<Action> actions = new List<Action>();
    OuterContext outerContext = new OuterContext();      *4*
    outerContext.outerCounter = 0;
    for (int i = 0; i < 2; i++)
    {
        InnerContext innerContext = new InnerContext();  *5*
        innerContext.outerContext = outerContext;        *5*
        innerContext.innerCounter = 0;                   *5*
        Action action = innerContext.Method;
        actions.Add(action);
    }
    return actions;
}
  • 1 外部作用域的上下文

  • 2 引用外部上下文的内部作用域上下文

  • 3 创建委托的方法

  • 4 创建一个单独的外部上下文

  • 5 每次循环迭代创建一个内部上下文

你很少需要查看这样的生成代码,但它可以在性能方面产生影响。如果你在性能关键代码中使用 lambda 表达式,你应该意识到将创建多少对象来支持它捕获的变量。

我可以给出更多例子,其中在相同的作用域内使用多个 lambda 表达式捕获不同的变量集或值类型方法中的 lambda 表达式。我发现探索编译器生成的代码非常有趣,但你可能不想看一整本书。如果你想知道编译器是如何处理特定的 lambda 表达式的,运行反编译器或 ildasm 在结果上就足够简单了。

到目前为止,你只看到了将 lambda 表达式转换为委托的过程,这你本来就可以用匿名方法做到。然而,lambda 表达式还有一个超级能力:它们可以被转换为表达式树。

3.5.3. 表达式树

表达式树 是代码作为数据的表示。这是 LINQ 能够高效地与如 SQL 数据库等数据提供者一起工作的核心。你编写的 C# 代码可以在执行时进行分析,并转换为 SQL。

虽然委托提供了可以运行的代码,但表达式树提供了可以检查的代码,有点像反射。虽然你 可以 在代码中直接构建表达式树,但更常见的是要求编译器为你这样做,通过将 lambda 表达式转换为表达式树。以下列表通过创建一个仅用于将两个数字相加的表达式树来给出一个简单的例子。

列表 3.12. 一个简单的表达式树,用于添加两个整数
Expression<Func<int, int, int>> adder = (x, y) => x + y;
Console.WriteLine(adder);

考虑到这只有两行代码,其中包含了很多内容。让我们从输出开始。如果你尝试打印一个常规的委托,结果将只是类型,没有任何行为指示。列表 3.12 的输出显示了表达式树的确切作用:

(x, y) => x + y

编译器并没有通过在某个地方硬编码一个字符串来作弊。这个字符串表示形式是由表达式树构建的。这表明代码在执行时是可检查的,这正是表达式树的全部意义。

让我们看看 adder 的类型:Expression<Func<int, int, int>>。最简单的方法是将它分成两部分:Expression<TDelegate>Func<int, int, int>。第二部分用作第一部分的类型参数。第二部分是一个具有两个整数参数和一个整数返回类型的委托类型。(返回类型由最后一个类型参数表示,因此 Func<string, double, int> 将接受一个 string 和一个 double 作为输入,并返回一个 int。)

Expression<TDelegate> 是与 TDelegate 关联的表达式树类型,TDelegate 必须是一个委托类型。(这没有作为类型约束表达出来,但在执行时强制执行。)这是涉及表达式树的许多类型之一。它们都在 System.Linq.Expressions 命名空间中。非泛型的 Expression 类是所有其他表达式类型的抽象基类,它也被用作创建具体子类实例的工厂方法的方便容器。

我们的 adder 变量类型是一个接受两个整数并返回整数的函数的表达式树表示。然后你使用 lambda 表达式为该变量赋值。编译器在执行时生成代码来构建适当的表达式树。在这种情况下,它相当简单。你可以自己编写相同的代码,如下面的列表所示。

列表 3.13. 编写代码以创建添加两个整数的表达式树
ParameterExpression xParameter = Expression.Parameter(typeof(int), "x");
ParameterExpression yParameter = Expression.Parameter(typeof(int), "y");
Expression body = Expression.Add(xParameter, yParameter);
ParameterExpression[] parameters = new[] { xParameter, yParameter };

Expression<Func<int, int, int>> adder =
    Expression.Lambda<Func<int, int, int>>(body, parameters);
Console.WriteLine(adder);

这是一个小例子,但它仍然比 lambda 表达式长得多。当你添加方法调用、属性访问、对象初始化器等等时,它会变得复杂且容易出错。这就是为什么编译器能够通过将 lambda 表达式转换为表达式树为你做这项工作如此重要的原因。尽管如此,还有一些规则。

转换为表达式树的限制

最重要的限制是只有表达式体的 lambda 表达式可以转换为表达式树。虽然我们之前的(x, y) => x + ylambda 表达式是好的,但以下代码会导致编译错误:

Expression<Func<int, int, int>> adder = (x, y) => { return x + y; };

自从.NET 3.5 以来,表达式树 API 已经扩展,包括块和其他结构,但 C#编译器仍然有这个限制,这与表达式树在 LINQ 中的使用是一致的。这是对象和集合初始化器如此重要的一个原因:它们允许初始化被捕获在一个单独的表达式中,这意味着它可以在表达式树中使用。

此外,lambda 表达式不能使用赋值运算符,也不能使用 C# 4 的动态类型,或者使用 C# 5 的异步操作。(尽管对象和集合初始化器确实使用了=符号,但那个上下文中的=不是赋值运算符。)

将表达式树编译为委托

如我之前提到的,能够对远程数据源执行查询并不是表达式树的唯一用途。它们可以在执行时动态构建高效委托的强大方式,尽管这通常是一个至少部分表达式树是用手写代码构建而不是从 lambda 表达式转换而来的领域。

Expression<TDelegate>有一个Compile()方法,它返回委托类型。然后你可以像处理任何其他委托一样处理这个委托。作为一个简单的例子,下面的列表将我们之前添加的表达式树编译为委托,然后调用它,输出结果为 5。

列表 3.14。将表达式树编译为委托并调用结果
Expression<Func<int, int, int>> adder = (x, y) => x + y;
Func<int, int, int> executableAdder = adder.Compile();     *1*
Console.WriteLine(executableAdder(2, 3));                  *2*
  • 1 将表达式树编译为委托

  • 2 正常调用委托

这种方法可以与反射结合使用,用于属性访问和方法调用以生成委托并缓存它们。结果是如果你手动编写等效代码一样高效。对于单个方法调用或属性访问,已经存在直接创建委托的方法,但有时你需要额外的转换或操作步骤,这些步骤在表达式树中很容易表示。

当我们将一切联系起来时,我们将回到为什么表达式树在 LINQ 中如此重要的原因。你还有两个语言特性要查看。扩展方法接下来。

3.6. 扩展方法

扩展方法在最初描述时听起来可能没有意义。它们是静态方法,可以基于它们的第一个参数像实例方法一样调用。假设你有一个这样的静态方法调用:

ExampleClass.Method(x, y);

如果你将 ExampleClass.Method 转换为扩展方法,你可以这样调用它:

x.Method(y);

扩展方法就是这样。这是 C# 编译器所做的最简单的转换之一。当涉及到链式调用方法时,它在代码可读性方面有很大的影响。你将在后面查看这一点,最终使用 LINQ 的真实示例,但首先让我们看看语法。

3.6.1. 声明扩展方法

扩展方法通过在第一个参数之前添加关键字 this 来声明。方法必须在非嵌套、非泛型静态类中声明,并且在 C# 7.2 之前,第一个参数不能是 ref 参数。(你将在第 13.5 节中了解更多关于这一点。)虽然包含方法的类不能是泛型的,但扩展方法本身可以是。

第一个参数的类型有时被称为扩展方法的 目标扩展类型。(不幸的是,规范没有给这个概念命名。)

以 Noda Time 为例,我们有一个将 DateTimeOffset 转换为 Instant 的扩展方法。Instant 结构体中已经有一个静态方法来做这个转换,但将其作为扩展方法也很有用。列表 3.15 展示了该方法的代码。这一次,我包括了命名空间声明,因为当你看到 C# 编译器如何找到扩展方法时,这将是重要的。

列表 3.15. Noda Time 中针对 DateTimeOffsetToInstant 扩展方法
using System;

namespace NodaTime.Extensions
{
    public static class DateTimeOffsetExtensions
    {
        public static Instant ToInstant(this DateTimeOffset dateTimeOffset)
        {
            return Instant.FromDateTimeOffset(dateTimeOffset);
        }
    }
}

编译器将 [Extension] 属性添加到方法和声明它的类上,仅此而已。这个属性位于 System.Runtime.CompilerServices 命名空间中。它是一个标记,表示开发人员应该能够像在 DateTimeOffset 中声明实例方法一样调用 ToInstant()

3.6.2. 调用扩展方法

你已经看到了调用扩展方法的语法:你就像调用第一个参数类型的实例方法一样调用它。但你需要确保编译器也能找到这个方法。

首先,有一个优先级问题:如果有一个适用于方法调用的常规实例方法,编译器总是会优先选择它,而不管扩展方法是否有“更好”的参数;如果编译器可以使用实例方法,它甚至不会寻找扩展方法。

在它耗尽对实例方法的搜索后,编译器将根据调用代码所在的命名空间和任何存在的using指令查找扩展方法。假设你从CSharpInDepth.Chapter03命名空间中的ExtensionMethodInvocation类中进行调用.^([3]) 下面的列表显示了如何进行操作,为编译器提供所有它需要找到扩展方法的信息。

³

如果你正在跟随下载的代码,你可能已经注意到示例位于 Chapter01、Chapter02 等命名空间中,为了简单起见。我在这里做了例外,以便展示命名空间检查的层次结构性质。

列表 3.16. 在 Noda Time 外部调用ToInstant()扩展方法
using NodaTime.Extensions;                          *1*
using System;

namespace CSharpInDepth.Chapter03
{
    class ExtensionMethodInvocation
    {
        static void Main()
        {
            var currentInstant =
                DateTimeOffset.UtcNow.ToInstant();  *2*
            Console.WriteLine(currentInstant);
        }
    }
}
  • 1 导入 NodaTime.Extensions 命名空间

  • 2 调用扩展方法

编译器将在以下方面检查扩展方法:

  • CSharpInDepth.Chapter03命名空间中的静态类。

  • CSharpInDepth命名空间中的静态类。

  • 全局命名空间中的静态类。

  • 使用 using namespace 指令指定的命名空间中的静态类。(这些是只指定命名空间的 using 指令,如using System。)

  • 在 C# 6 中仅限,使用 using static 指令指定的静态类。我们将在第 10.1 节中回到这一点。

编译器有效地从最深层的命名空间向外扩展,直到全局命名空间,并在每一步检查该命名空间中的静态类或通过在命名空间声明中使用指令提供的类。排序的细节几乎从不重要。如果你发现自己处于一种情况,即移动一个 using 指令会改变所使用的扩展方法,那么最好是将其中一个重命名。但重要的是要理解,在每一步中,可以找到多个对于调用有效的扩展方法。在这种情况下,编译器将在该步骤中找到的所有扩展方法之间执行正常的重载解析。编译器定位到要调用的正确方法后,它为调用生成的 IL(中间语言)与如果你用扩展方法的能力而不是用常规静态方法调用写出来的情况完全相同。

扩展方法可以在 null 值上调用

扩展方法在 null 处理方面与实例方法不同。让我们回顾一下我们的初始示例:

x.Method(y);

如果Method是一个实例方法并且x是一个空引用,那么会抛出NullReferenceException。相反,如果Method是一个扩展方法,即使x是空,它也会以x作为第一个参数被调用。有时方法会指定第一个参数不能为空,在这种情况下,它应该验证它并抛出ArgumentNullException。在其他情况下,扩展方法可能已经被明确设计来优雅地处理空第一个参数。

让我们回到为什么扩展方法对 LINQ 很重要的原因。是我们第一次查询的时候了。

3.6.3. 方法调用链

列表 3.17 展示了一个简单的查询。它接受一系列单词,通过长度过滤它们,以自然方式排序,然后将它们转换为大写。它使用了 lambda 表达式和扩展方法,但没有使用其他 C# 3 特性。我们将在本章末尾将所有其他内容组合在一起。目前,我想专注于这段简单代码的可读性。

列表 3.17. 字符串上的简单查询
string[] words = { "keys", "coat", "laptop", "bottle" };  *1*
IEnumerable<string> query = words
    .Where(word => word.Length > 4)                       *2*
    .OrderBy(word => word)                                *2*
    .Select(word => word.ToUpper());                      *2*

foreach (string word in query)                            *3*
{                                                         *3*
    Console.WriteLine(word);                              *3*
}                                                         *3*
  • 1 简单的数据源

  • 2 过滤器、排序、转换

  • 3 显示结果

注意我们代码中WhereOrderBySelect调用的顺序。这是操作发生的顺序。LINQ 的延迟和尽可能流式处理的本性使得很难确切地说出何时发生什么,但查询的读取顺序与执行顺序相同。下面的列表是相同的查询,但没有利用这些方法是扩展方法的事实。

列表 3.18. 不使用扩展方法的简单查询
string[] words = { "keys", "coat", "laptop", "bottle" };
IEnumerable<string> query =
    Enumerable.Select(
        Enumerable.OrderBy(
            Enumerable.Where(words, word => word.Length > 4),
            word => word),
        word => word.ToUpper());

我已经尽可能地将列表 3.18 格式化为可读的形式,但它仍然很糟糕。调用在源代码中的顺序与它们执行顺序相反:Where是首先执行的操作,但在列表中是最后一个方法调用。接下来,不清楚哪个 lambda 表达式与哪个调用相对应:word => word.ToUpper()Select调用的一部分,但在这两段文本之间有大量的代码。

你可以通过将每个方法调用的结果分配给一个局部变量,然后通过该变量进行方法调用来解决这个问题。列表 3.19 展示了这样做的一个选项。(在这种情况下,你可以在每一行开始时声明查询,并在每一行重新分配它,但并不总是如此。)这次,我也使用了var,只是为了简洁。

列表 3.19. 多语句中的简单查询
string[] words = { "keys", "coat", "laptop", "bottle" };
var tmp1 = Enumerable.Where(words, word => word.Length > 4);
var tmp2 = Enumerable.OrderBy(tmp1, word => word);
var query = Enumerable.Select(tmp2, word => word.ToUpper());

这比列表 3.18 要好;操作顺序已经恢复正确,并且很明显哪个 lambda 表达式用于哪个操作。但是额外的局部变量声明会分散注意力,并且很容易使用错误的变量。

方法链式调用的好处不仅限于 LINQ。使用一次调用的结果作为另一次调用的起点是常见的。但是扩展方法允许你以可读的方式对任何类型执行此操作,而不是类型本身声明支持链式调用的方法。IEnumerable<T>对 LINQ 一无所知;它的唯一责任是表示一个通用序列。是System.Linq.Enumerable类添加了所有过滤、分组、连接等操作。

C# 3 本可以到此为止。到目前为止描述的特性已经为语言增添了大量功能,并使得许多 LINQ 查询能够以完美的可读性形式编写。但是当查询变得更加复杂,尤其是当它们包括连接和分组时,直接使用扩展方法可能会变得复杂。这时就出现了查询表达式。

3.7. 查询表达式

虽然 C# 3 中的几乎所有特性都对 LINQ 有贡献,但只有查询表达式是 LINQ 特有的。查询表达式允许你通过使用查询特定的子句(selectwhereletgroup by等)来编写简洁的代码。然后,编译器将查询转换为非查询形式,并按常规编译.^([4]) 让我们从简短的例子开始,以便使这一点更清晰。作为提醒,在列表 3.17 中,你有了这个查询:

这听起来像是 C 语言中的宏,但它比那要复杂一些。C#仍然没有宏。

IEnumerable<string> query = words
    .Where(word => word.Length > 4)
    .OrderBy(word => word)
    .Select(word => word.ToUpper());

以下列表显示了以查询表达式编写的相同查询。

列表 3.20. 带有过滤、排序和投影的查询表达式简介
IEnumerable<string> query = from word in words
 where word.Length > 4
 orderby word
 select word.ToUpper();

粗体显示的列表 3.20 部分是查询表达式,它确实非常简洁。将word作为 lambda 表达式的参数重复使用,已经被在from子句中一次指定一个范围变量的名称,然后在其他每个子句中使用它所取代。列表 3.20 中的查询表达式会发生什么?

3.7.1. 查询表达式从 C#转换为 C#

在这本书中,我通过更多的 C#源代码来表述了许多语言特性。例如,当查看第 3.5.2 节中捕获的变量时,我展示了你可以编写的 C#代码,以实现与使用 lambda 表达式相同的结果。这只是为了解释编译器生成的代码。我不期望编译器生成任何 C#代码。规范描述了捕获变量的效果,而不是源代码的转换。

查询表达式的工作方式不同。规范描述它们为在任何重载解析或绑定之前发生的语法转换。列表 3.20 中的代码不仅仅具有与 列表 3.17 相同的最终效果;它实际上在进一步处理之前被转换成了 列表 3.17 中的代码。语言对进一步处理的结果没有具体期望。在许多情况下,转换的结果将是调用扩展方法,但这不是语言规范的要求。它们可以是实例方法调用或由名为 SelectWhere 等属性的属性返回的委托的调用。

查询表达式的规范设定了对某些方法存在的期望,但并没有具体要求它们都必须存在。例如,如果你编写了一个包含合适的 SelectOrderByWhere 方法的 API,即使你不能使用包含 join 子句的查询表达式,你仍然可以使用列表 3.20 中所示的那种查询。

虽然我们不会详细查看查询表达式中所有可用的子句,但我需要引起你的注意两个相关概念。在某种程度上,这些为语言设计者将查询表达式引入语言提供了更大的合理性。

3.7.2. 范围变量和透明标识符

查询表达式引入了范围变量,它们与其他任何常规变量都不一样。它们在每个查询子句中充当每个项目的输入。你已经看到了查询表达式开头处的 from 子句是如何引入范围变量的。以下是将 列表 3.20 中的查询表达式再次列出,并突出显示范围变量:

from word in words         *1*
where word.Length > 4    *2*
orderby word *2*
select word.ToUpper()    *2*
  • 1 在 FROM 子句中引入范围变量

  • 2 在以下子句中使用范围变量

当只有一个范围变量时,这一点很容易理解,但最初的 from 子句并不是引入范围变量的唯一方式。引入新范围变量的子句中最简单的例子可能是 let。假设你希望在查询中多次引用单词的长度,而不必每次都调用 Length 属性。例如,你可以按顺序排列它并将其包含在输出中。let 子句允许你将查询编写如下所示。

列表 3.21. 引入新范围变量的 let 子句
from word in words
let length = word.Length
where length > 4
orderby length
select string.Format("{0}: {1}", length, word.ToUpper());

你现在同时有多个范围变量在作用域内,正如你在select子句中使用lengthword时可以看到的。这引发了一个问题:如何在查询翻译中表示这一点。你需要一种方法来获取我们原始的单词序列,并创建一个单词/长度对的序列,实际上就是这样做。然后,在可以使用这些范围变量的子句中,你需要访问对中的相关项。以下列表显示了编译器如何使用匿名类型来表示值对,将列表 3.21 翻译成查询。

列表 3.22. 使用透明标识符进行查询翻译
words.Select(word => new { word, length = word.Length })
     .Where(tmp => tmp.length > 4)
     .OrderBy(tmp => tmp.length)
     .Select(tmp =>
         string.Format("{0}: {1}", tmp.length, tmp.word.ToUpper()));

这里使用的名称tmp不是查询翻译的一部分。规范使用*代替,并且在构建查询表达式树表示时没有指明应该给参数赋予什么名称。名称并不重要,因为你编写查询时看不到它。这被称为透明标识符

我不会深入探讨查询翻译的所有细节。那可能是一整章的内容。但我提到透明标识符有两个原因。首先,如果你了解额外范围变量的引入方式,当你看到它们时就不会感到惊讶,尤其是当你反编译一个查询表达式时。其次,它们为我提供了使用查询表达式的最大动力。

3.7.3. 决定何时使用哪种 LINQ 语法的时机

查询表达式可能很有吸引力,但它们并不总是表示查询的最简单方式。它们总是需要以一个from子句开始,并以一个selectgroup by子句结束。这听起来合理,但这也意味着,如果你想进行单个过滤操作的查询,例如,你最终会得到相当多的冗余。例如,如果你只取我们基于单词的查询中的过滤部分,你会得到以下查询表达式:

from word in words
where word.Length > 4
select word

将其与查询的方法语法版本进行比较:

words.Where(word => word.Length > 4)

它们都会编译成相同的代码,^([5]) 但对于如此简单的查询,我会使用第二种语法。

编译器对仅选择当前查询项的select子句有特殊处理。

注意

对于不使用查询表达式语法的做法,没有单一的通用术语。我见过它被称为方法语法点语法流畅语法lambda 语法,仅举四例。我会一致地称其为方法语法,但如果听到其他术语,请不要试图寻找其细微的意义差异。

即使查询变得稍微复杂一些,方法语法也可以更加灵活。LINQ 中有许多方法没有对应的查询表达式语法,包括 SelectWhere 的重载,它们不仅提供了序列中项的索引,还提供了项本身。此外,如果你想在查询的末尾进行方法调用(例如,ToList() 将结果实体化为 List<T>),你必须将整个查询表达式放在括号中,而使用方法语法,你可以在末尾添加调用。

我并不像听起来那样反对查询表达式。在许多情况下,两种语法选项之间没有明显的胜者,我可能会把我们的早期过滤、排序、投影示例包括在内。查询表达式在编译器为你处理所有那些透明标识符时表现得尤为出色。当然,你可以手动完成所有这些,但我发现构建匿名类型作为结果并在每个后续步骤中解构它们很快就变得令人厌烦。查询表达式使所有这些变得更加容易。

所有这些的结果是,我强烈建议你熟悉这两种查询风格。如果你总是使用查询表达式或从不使用查询表达式,你将错过使你的代码更易读的机会。我们已经涵盖了 C# 3 中的所有功能,但我要花一点时间回顾一下它们是如何组合在一起形成 LINQ 的。

3.8. 最终结果:LINQ

我不会尝试涵盖目前可用的各种 LINQ 提供者。我使用最多的 LINQ 技术是 LINQ to Objects,使用 Enumerable 静态类和委托。但为了展示所有这些部分是如何结合起来的,让我们假设你有一个来自类似 Entity Framework 的查询。这不是你可以测试的真实代码,但如果你有一个合适的数据库结构,这将是完全可以接受的:

var products = from product in dbContext.Products
               where product.StockCount > 0
               orderby product.Price descending
               select new { product.Name, product.Price };

在这个仅有四行的简单示例中,所有这些特性都被使用了:

  • 匿名类型,包括投影初始化器(仅选择产品的名称和价格)

  • 使用 var 进行隐式类型,因为否则你无法以有用的方式声明 products 变量的类型

  • 查询表达式,在这种情况下你可以不用,但它们使更复杂的查询生活变得更加简单

  • Lambda 表达式,这是查询表达式翻译的结果

  • 扩展方法允许通过实现 IQueryable<Product>dbContext.Products 表达翻译后的查询,因为它们使用了 Queryable

  • 表达式树,它允许将查询中的逻辑作为数据传递给 LINQ 提供者,以便它可以被转换为 SQL 并在数据库中高效执行

取消任何一项功能,LINQ 都将变得非常有用。当然,没有表达式树,你可以在内存中进行集合处理。你可以编写没有查询表达式的可读性简单的查询。你可以有所有相关方法的专用类,而不使用扩展方法。但所有这些功能都完美地结合在一起。

摘要

  • C# 3 中的所有功能都与以某种形式处理数据相关,其中大多数是 LINQ 的关键部分。

  • 自动实现属性提供了一种简洁的方式来暴露不需要额外行为的状态。

  • 使用var关键字(以及数组)进行隐式类型转换对于处理匿名类型是必要的,同时也方便避免冗长的重复。

  • 对象和集合初始化器使初始化更加简单和可读。它们还允许初始化作为一个单独的表达式发生,这对于处理 LINQ 的其他方面至关重要。

  • 匿名类型允许你以轻量级的方式为单个局部目的创建一个类型。

  • Lambda 表达式提供了一种比匿名方法更简单的方式来构造委托。它们还允许代码通过表达式树以数据的形式表达,这些表达式树可以被 LINQ 提供者用来将 C#查询转换为其他形式,如 SQL。

  • 扩展方法是可以像实例方法一样在其他地方调用的静态方法。这允许为原本未设计为这种方式的类型编写流畅的接口。

  • 查询表达式被转换成更多使用 lambda 表达式来表示查询的 C#代码。虽然这些对于复杂查询来说很棒,但简单的查询通常使用方法语法更容易编写。

第四章:C# 4:提高互操作性

本章涵盖

  • 使用动态类型以实现互操作性和更简单的反射

  • 为参数提供默认值,这样调用者就不需要指定它们

  • 为参数指定名称以使调用更清晰

  • 以更简洁的方式对 COM 库进行编码

  • 使用泛型方差在泛型类型之间进行转换

C# 4 版本是一个有趣的发布。最引人注目的变化是引入了dynamic类型的动态类型。这个特性使得 C#在大多数代码中是静态类型(对于静态类型),而在使用dynamic时是动态类型。这在编程语言中是很少见的。

动态类型是为了互操作性而引入的,但结果发现它在许多开发者的日常工作中并不相关。其他版本中的主要功能(泛型、LINQ、async/await)已经成为大多数 C#开发者工具箱的自然部分,但动态类型仍然相对较少使用。我相信它对需要它的人来说是有用的,至少它是一个有趣的功能。

C# 4 中的其他特性也提高了互操作性,尤其是与 COM。一些改进是针对 COM 的,例如命名索引器、隐式引用参数和嵌入式互操作类型。可选参数和命名参数在 COM 中很有用,但它们也可以用于纯托管代码。这两个特性是我在日常使用中从 C# 4 中使用的特性。

最后,C# 4 公开了从 v2(包含泛型的第一个运行时版本)以来在 CLR 中存在的泛型特性。泛型方差既简单又复杂。乍一看,这似乎很明显:字符串序列显然是对象序列,例如。但后来我们发现字符串列表不是对象列表,这打破了某些开发者的预期。这是一个有用的特性,但当你仔细检查时,它容易引起头痛。大多数时候,你可以利用它,甚至不需要意识到你在这样做。希望本章的覆盖范围意味着,如果你需要更仔细地查看,因为你的代码没有按预期工作,你将处于一个很好的位置来解决问题,而不会感到困惑。我们将从查看动态类型开始。

4.1. 动态类型

一些特性伴随着大量的新语法,但解释完语法之后,就没有太多可说的了。动态类型则正好相反:语法极其简单,但我可以几乎无穷尽地详细说明其影响和实现。本节将向您展示基础知识,然后深入一些细节,最后提出一些关于如何以及何时使用动态类型的一些建议。

4.1.1. 动态类型简介

让我们从例子开始。以下列表显示了从某些文本中获取子字符串的两个尝试。目前,我并不是试图解释为什么你想使用动态类型,只是说明它做了什么。

列表 4.1. 使用动态类型获取子字符串
dynamic text = "hello world";      *1*
string world = text.Substring(6);  *2*
Console.WriteLine(world);

string broken = text.SUBSTR(6);    *3*
Console.WriteLine(broken);
  • 1 声明一个动态类型的变量

  • 2 调用Substring方法;这是可行的。

  • 3 尝试调用 SUBSTR;这会抛出异常。

在如此少的代码中,发生了许多事情。最重要的方面是它能够编译。如果你将第一行改为使用string类型声明textSUBSTR的调用将在编译时失败。相反,编译器乐于编译它,甚至不需要寻找名为SUBSTR的方法。它也不会寻找Substring。相反,这两个查找都是在执行时进行的。

在执行时,第二行将寻找一个可以接受 6 个参数的名为Substring的方法。找到了该方法,并返回一个字符串,然后将其分配给world变量,并以常规方式打印。当代码寻找一个可以接受 6 个参数的名为SUBSTR的方法时,找不到这样的方法,代码将因RuntimeBinderException而失败。

如 第三章 中所述,在特定上下文中查找名称含义的过程称为 绑定。动态类型完全是关于将绑定发生的时间从编译时改为执行时。编译器不是仅仅生成在执行时具有精确签名的 IL 来调用方法,而是生成执行绑定并在结果上操作的 IL。所有这些都是由使用 dynamic 类型触发的。

什么是动态类型?

列表 4.1 声明了 text 变量为 dynamic 类型:

dynamic text = "hello world";

什么是 dynamic 类型?它与你在 C# 中看到的其他类型不同,因为它只存在于 C# 语言范围内。与它相关的 System.Type 不存在,CLR 完全不知道它。任何时候你在 C# 中使用 dynamic,如果需要,IL 会使用带有 [Dynamic] 装饰的 object

注意

如果动态类型用于方法签名,编译器需要将此信息提供给针对它编译的代码。对于局部变量则不需要这样做。

dynamic 类型的基本规则很简单:

  1. 从任何非指针类型到 dynamic 的隐式转换。

  2. 从类型 dynamic 的表达式到任何非指针类型的隐式转换。

  3. 涉及类型 dynamic 值的表达式通常在执行时绑定。

  4. 大多数涉及类型 dynamic 值的表达式在编译时类型也是 dynamic

你很快就会看到最后两个点的例外。使用这个规则列表,你可以再次用新的眼光看 列表 4.1。让我们考虑前两行:

dynamic text = "hello world";
string world = text.Substring(6);

在第一行,你将 string 转换为 dynamic,这是可以的,因为规则 1。第二行演示了其他三个规则:

  • text.Substring(6) 在执行时绑定(规则 3)。

  • 该表达式的编译时类型是 dynamic(规则 4)。

  • 从该表达式到 string 的隐式转换(规则 2)。

从类型 dynamic 的表达式到非动态类型的转换也是动态绑定的。如果你将 world 变量声明为 int 类型,那么代码可以编译但在执行时会产生 RuntimeBinderException 异常。如果你将其声明为 XNamespace 类型,那么代码可以编译,并在执行时绑定器会使用用户定义的从 stringXNamespace 的隐式转换。考虑到这一点,让我们看看更多关于动态绑定的示例。

在各种上下文中应用动态绑定

到目前为止,你已经看到了基于方法调用动态目标和转换的动态绑定,但几乎任何执行方面都可以是动态的。以下列表在加法运算符的上下文中演示了这一点,并基于执行时动态值的类型执行三种类型的加法。

列表 4.2. 动态值的添加
static void Add(dynamic d)
{
    Console.WriteLine(d + d);       *1*
}

Add("text");                        *2*
Add(10);                            *2*
Add(TimeSpan.FromMinutes(45));      *2*
  • 1 根据执行时的类型执行加法

  • 2 使用不同值调用方法

列表 4.2 的结果如下:

texttext
20
01:30:00

每种类型的加法对于涉及的类型都有意义,但在静态类型上下文中,它们看起来会不同。作为一个最后的例子,以下列表显示了动态方法参数的方法重载行为。

列表 4.3. 动态方法重载解析
static void SampleMethod(int value)
{
    Console.WriteLine("Method with int parameter");
}

static void SampleMethod(decimal value)
{
    Console.WriteLine("Method with decimal parameter");
}

static void SampleMethod(object value)
{
    Console.WriteLine("Method with object parameter");
}
static void CallMethod(dynamic d)
{
    SampleMethod(d);       *1*
}

CallMethod(10);            *2*
CallMethod(10.5m);         *2*
CallMethod(10L);           *2*
CallMethod("text");        *2*
  • 1 动态调用 SampleMethod

  • 2 间接使用不同类型调用 SampleMethod

列表 4.3 的输出如下:

Method with int parameter
Method with decimal parameter
Method with decimal parameter
Method with object parameter

输出的第三行和第四行特别有趣。它们显示执行时的重载解析仍然了解转换。在第三行中,一个 long 值被转换为 decimal 而不是 int,尽管它在 int 的范围内。在第四行中,一个 string 值被转换为 object。目标是尽可能使执行时的绑定行为与编译时相同,只是使用在执行时发现的动态值的类型。

只有动态值被认为是动态的

编译器努力确保在执行时提供正确的信息。当绑定涉及多个值时,编译时类型用于任何静态类型值,但执行时类型用于任何类型为 dynamic 的值。大多数情况下,这种细微差别并不重要,但我已在可下载的源代码中提供了一个带有注释的示例。

任何动态绑定方法调用的编译时类型都是 dynamic。当绑定发生时,如果选择的方法具有 void 返回类型并且方法的结果被使用(例如,被分配给变量),则绑定失败。大多数动态绑定操作都是这种情况:编译器对动态操作将涉及什么知之甚少。该规则有一些例外。

编译器在动态绑定上下文中可以检查什么?

如果在编译时已知方法调用的上下文,编译器能够检查存在哪些具有指定名称的方法。如果在执行时没有可能匹配的方法,编译器仍然会报告编译时错误。这适用于以下情况:

  • 实例方法和索引器,其中目标不是动态值

  • 静态方法

  • 构造函数

以下列表显示了使用动态值进行调用并在编译时失败的多种示例。

列表 4.4. 涉及动态值的编译时失败示例
dynamic d = new object();
int invalid1 = "text".Substring(0, 1, 2, d);   *1*
bool invalid2 = string.Equals<int>("foo", d);  *2*
string invalid3 = new string(d, "broken");     *3*
char invalid4 = "text"[d, d];                  *4*
  • 1 没有四个参数的 String.Substring 方法

  • 2 没有泛型 String.Equals 方法

  • 3 没有两个参数的 String 构造函数接受字符串作为第二个参数

  • 4 没有两个参数的 String 索引器

仅因为编译器能够确定这些特定的例子肯定是有问题的,并不意味着它总能做到这一点。除非你非常小心地处理涉及的值,否则动态绑定总是一种对未知的跳跃。

我给出的例子如果编译的话,仍然会使用动态绑定。只有少数情况下不是这样。

涉及动态值的操作哪些不是动态绑定的?

几乎你用动态值做的每一件事都涉及到某种绑定和找到正确的方法调用、属性、转换、运算符等等。只有少数事情编译器不需要生成任何绑定代码:

  • 将值赋给 objectdynamic 类型的变量。不需要转换,因此编译器可以直接复制现有的引用。

  • 将参数传递给具有相应类型 objectdynamic 的方法。这就像赋值一样,但变量是参数。

  • 使用 is 运算符测试值的类型。

  • 尝试使用 as 运算符转换值。

尽管执行时绑定基础设施乐于在将动态值转换为特定类型时找到用户定义的转换,无论是通过类型转换还是隐式地这样做,但 isas 运算符永远不会使用用户定义的转换,因此不需要绑定。以类似的方式,几乎所有涉及动态值的操作都有也是动态的结果。

涉及动态值的操作哪些仍然具有静态类型?

再次强调,编译器尽可能地提供帮助。如果一个表达式始终只能是特定类型,编译器很乐意将其作为表达式的编译时类型。例如,如果 ddynamic 类型的变量,以下都是正确的:

  • 表达式 new SomeType(d) 在编译时的类型是 SomeType,尽管构造函数在执行时是动态绑定的。

  • 表达式 d is SomeType 在编译时的类型是 bool

  • 表达式 d as SomeType 在编译时的类型是 SomeType

这就是本介绍所需的全部细节。在 第 4.1.4 节 中,你将看到编译时和执行时的一些意外转折。但现在你已经了解了动态类型的感觉,你可以看看它在执行时进行常规绑定之外的强大功能。

4.1.2. 超越反射的动态行为

动态类型的一个用途是有效地请求编译器和框架根据在类型中声明的成员以通常的方式为你执行反射操作。尽管这是一个完全合理的用途,但动态类型具有更好的扩展性。引入它的部分原因是为了允许与允许动态绑定即时更改的动态语言更好地互操作。许多动态语言允许在执行时拦截调用。这有如透明缓存和日志记录或使看起来有函数和字段从未在源代码中以名称声明等用途。

数据库访问的想象示例

作为(未实现)的一个例子,想象一下你可能想要做的类似事情,比如你有一个包含书籍及其作者的数据库表。动态类型可以使这种代码成为可能:

dynamic database = new Database(connectionString);
var books = database.Books.SearchByAuthor("Holly Webb");
foreach (var book in books)
{
    Console.WriteLine(book.Title);
}

这将涉及以下动态操作:

  • Database 类会响应对 Books 属性的请求,通过查询名为 Books 的数据库模式表,并返回某种类型的表对象。

  • 那个表对象会响应 SearchByAuthor 方法调用,注意到它以 SearchBy 开头,并在模式中寻找名为 Author 的列。然后它会生成使用提供的参数查询该列的 SQL 语句,并返回一个行对象列表。

  • 每个行对象会响应 Title 属性,通过返回 Title 列的值。

如果你习惯了 Entity Framework 或类似的对象关系映射(ORM),这可能听起来并不新鲜。你可以轻松编写启用相同查询代码的类,或者从模式生成这些类。这里的区别在于它全部是动态的:没有 BookBooksTable 类。所有这些都是在执行时发生的。在 4.1.5 节 中,我将讨论这通常是一个好是坏的事情,但我希望你能至少看到它在某些情况下可能是有用的。

在我向你介绍允许这一切发生的类型之前,让我们看看两个已实现的示例。首先,你将查看框架中的一个类型,然后是 Json.NET。

ExpandoObject:动态的数据和方法集合

.NET 框架在 System.Dynamic 命名空间中提供了一个名为 ExpandoObject 的类型。它根据你是否将其用作动态值在两种模式下运行。以下列表提供了一个简短的示例,以帮助您理解随后的描述。

列表 4.5. 在 ExpandoObject 中存储和检索项
dynamic expando = new ExpandoObject();
expando.SomeData = "Some data";                                 *1*
Action<string> action =                                         *2*
    input => Console.WriteLine("The input was '{0}'", input);   *2*
expando.FakeMethod = action;                                    *2*

Console.WriteLine(expando.SomeData);                            *3*
expando.FakeMethod("hello");                                    *3*

IDictionary<string, object> dictionary = expando;               *4*
Console.WriteLine("Keys: {0}",                                  *4*
    string.Join(", ", dictionary.Keys));                        *4*

dictionary["OtherData"] = "other";                              *5*
Console.WriteLine(expando.OtherData);                           *5*
  • 1 将数据分配给属性

  • 2 将委托分配给属性

  • 3 动态访问数据和委托

  • 4 将 ExpandoObject 作为字典来打印键

  • 5 使用静态上下文填充数据并从动态值中获取它

ExpandoObject在静态类型上下文中使用时,它是一个键/值对的字典,并实现了IDictionary<string, object>,正如您从普通字典中期望的那样。您可以那样使用它,查找在执行时提供的键,等等。

更重要的是,它还实现了IDynamicMetaObjectProvider。这是动态行为的入口点。您稍后会查看该接口本身,但ExpandoObject实现了它,这样您可以在代码中通过名称访问字典键。当您在动态上下文中对一个ExpandoObject调用方法时,它将在字典中查找方法名作为键。如果与该键关联的值是一个具有适当参数的委托,则执行该委托,并将委托的结果用作方法调用的结果。

列表 4.5 仅存储了一个数据值和一个委托,但您可以存储许多具有任何名称的数据。它只是一个可以动态访问的字典。

您可以通过使用ExpandoObject来实现大部分早期的数据库示例。您会创建一个来表示Books表,然后使用单独的ExpandoObject来表示每本书。表将有一个SearchByAuthor键,以及一个合适的委托值来执行查询。每本书将有一个Title键来存储标题等等。然而,在实践中,您可能希望直接实现IDynamicMetaObjectProvider或使用DynamicObject。在深入研究这些类型之前,让我们看看另一种实现:动态访问 JSON 数据。

Json.NET 的动态视图

JSON 现在无处不在,而用于消费和创建 JSON 最受欢迎的库之一是 Json.NET.^([1]) 它提供了多种处理 JSON 的方法,包括直接解析到用户提供的类以及解析到一个更接近 LINQ to XML 的对象模型。后者被称为 LINQ to JSON,具有JObjectJArrayJProperty等类型。它可以像 LINQ to XML 一样使用,通过字符串进行访问,或者可以动态使用。以下列表展示了相同 JSON 的两种方法。

¹

当然,还有其他 JSON 库可用。我只是恰好对 Json.NET 最熟悉。

列表 4.6. 动态使用 JSON 数据
string json = @"                             *1*
    {                                        *1*
      'name': 'Jon Skeet',                   *1*
      'address': {                           *1*
        'town': 'Reading',                   *1*
        'country': 'UK'                      *1*
      }                                      *1*
    }".Replace('\'', '"');                   *1*

JObject obj1 = JObject.Parse(json);          *2*

Console.WriteLine(obj1["address"]["town"]);  *3*

dynamic obj2 = obj1;                         *4*
Console.WriteLine(obj2.address.town);        *4*
  • 1 硬编码的样本 JSON

  • 2 将 JSON 解析为 JObject

  • 3 使用静态类型视图

  • 4 使用动态类型视图

这个 JSON 很简单,但包含一个嵌套对象。代码的第二部分展示了如何通过使用 LINQ to JSON 中的索引器或使用它提供的动态视图来访问它。

你更喜欢哪一个?每种方法都有支持和反对的理由。两者都容易出错,无论是在字符串字面量中还是在动态属性访问中。静态类型视图有利于将属性名称提取到常量中以供重用,但动态类型视图在原型设计时更容易阅读。我将在 第 4.1.5 节 中提出一些关于何时何地使用动态类型建议,但在到达那里之前,值得反思你的初始反应。接下来,我们将快速看一下如何自己完成所有这些。

在自己的代码中实现动态行为

动态行为很复杂。让我们先把这个讲清楚。请不要期望从这个部分离开后就能准备好编写一个针对任何令人惊叹的想法的生产就绪的优化实现。这只是一个起点。话虽如此,这应该足以让你探索和实验,以便你可以决定你愿意投入多少精力去学习所有细节。

当我介绍 ExpandoObject 时,我提到它实现了 IDynamicMetaObjectProvider 接口。这个接口表示一个对象实现了自己的动态行为,而不是仅仅满足于让基于反射的基础设施按正常方式工作。作为一个接口,它看起来欺骗性地简单:

public interface IDynamicMetaObjectProvider
{
    DynamicMetaObject GetMetaObject(Expression parameter);
}

复杂性在于 DynamicMetaObject,这是驱动其他一切的东西的类。它的官方文档给出了当你与它一起工作时需要思考的水平的一个线索:

表示参与动态绑定的对象的动态绑定和绑定逻辑。

即使使用了这个类,我也不敢声称我完全理解那个句子,也无法写出更好的描述。通常,你会创建一个从 DynamicMetaObject 继承的类,并重写它提供的一些虚拟方法。例如,如果你想动态处理方法调用,你会重写这个方法:

public virtual DynamicMetaObject BindInvokeMember
    (InvokeMemberBinder binder, DynamicMetaObject[] args);

binder 参数提供了有关被调用方法名称和调用者是否期望按大小写敏感地执行绑定等信息。args 参数提供了调用者以更多 DynamicMetaObject 值的形式提供的参数。结果是另一个 DynamicMetaObject,表示应该如何处理方法调用。它不会立即执行调用,而是创建一个表达式树,表示调用将会做什么。

所有这些都极其复杂,但允许高效地处理复杂情况。幸运的是,你不必自己实现 IDynamicMetaObjectProvider,我也不打算尝试这样做。相反,我将给出一个使用更友好的类型的示例:DynamicObject

DynamicObject 类作为想要尽可能简单实现动态行为的类型的基类。结果可能不如直接实现 IDynamicMetaObjectProvider 那样高效,但它更容易理解。

以一个简单的例子,你将创建一个具有以下动态行为的类 (SimpleDynamicExample):

  • 在它上调用任何方法都会在控制台打印一条消息,包括方法名称和参数。

  • 获取属性通常返回带有前缀的属性名称,以显示你确实调用了动态行为。

以下列表显示了如何使用该类。

列表 4.7. 动态行为预期使用示例
dynamic example = new SimpleDynamicExample();
example.CallSomeMethod("x", 10);
Console.WriteLine(example.SomeProperty);

输出应该是这样的:

Invoked: CallSomeMethod(x, 10)
Fetched: SomeProperty

CallSomeMethodSomeProperty 这两个名称并没有什么特别之处,但如果你想要对特定的名称做出不同的反应,你完全可以这样做。即使到目前为止所描述的简单行为,使用低级接口来实现也是相当棘手的,但下面的列表展示了使用 DynamicObject 是多么简单。

列表 4.8. 实现 SimpleDynamicExample
class SimpleDynamicExample : DynamicObject
{
    public override bool TryInvokeMember(            *1*
        InvokeMemberBinder binder,                   *1*
        object[] args,                               *1*
        out object result)                           *1*
    {                                                *1*
        Console.WriteLine("Invoked: {0}({1})",       *1*
            binder.Name, string.Join(", ", args));   *1*
        result = null;                               *1*
        return true;                                 *1*
    }                                                *1*

    public override bool TryGetMember(               *2*
        GetMemberBinder binder,                      *2*
        out object result)                           *2*
    {                                                *2*
        result = "Fetched: " + binder.Name;          *2*
        return true;                                 *2*
    }                                                *2*
}
  • 1 处理方法调用

  • 2 处理属性访问

就像在 DynamicMetaObject 上的方法一样,当你覆盖 DynamicObject 中的方法时,你仍然会收到绑定器,但你不再需要担心表达式树或其他 DynamicMetaObject 值了。每个方法的返回值都指示动态对象是否成功处理了操作。如果你返回 false,将会抛出 RuntimeBinderException

在实现动态行为方面,我就要展示这些内容了,但我希望 列表 4.8 的简单性能够鼓励你尝试使用 DynamicObject。即使你永远不会在生产环境中使用它,玩弄它也可以很有趣。如果你想尝试但还没有具体想法,你始终可以尝试实现我在本节开头给出的 Database 示例。作为提醒,以下是你要尝试启用的代码:

dynamic database = new Database(connectionString);
var books = database.Books.SearchByAuthor("Holly Webb");
foreach (var book in books)
{
    Console.WriteLine(book.Title);
}

接下来,你将查看 C# 编译器在遇到动态值时生成的代码。

4.1.3. 简要了解幕后情况

你现在可能已经知道,我喜欢查看 C# 编译器用来实现其各种功能的 IL(中间语言)。你已经看到了 lambda 表达式中的捕获变量如何导致生成额外的类,以及 lambda 表达式转换为表达式树如何导致调用 Expression 类中的方法。动态类型在创建源代码的数据表示方面与表达式树类似,但规模更大。

这一节比上一节更不详细。虽然细节很有趣,但你几乎肯定不需要知道它们.^([2]) 好消息是,它全部都是开源的,所以如果你被这个主题的简要介绍所吸引,你可以深入到底层。我们将从考虑哪个子系统负责动态类型的哪个方面开始。

²

坦白说,我对细节了解得不够,无法对整个主题进行公正的讨论。

谁负责什么?

通常当你考虑一个 C# 功能时,很自然地将其责任分为三个领域:

  • C# 编译器

  • CLR

  • 框架库

一些功能纯粹属于 C# 编译器的领域。隐式类型就是一个例子。框架不需要提供任何类型来支持 var,而运行时对你是使用隐式还是显式类型毫无察觉。

在光谱的另一端是泛型,它们需要大量的编译器支持、运行时支持和框架支持,特别是在反射 API 方面。LINQ 处于中间位置:编译器提供了你在第三章中看到的各种功能,而框架不仅提供了 LINQ to Objects 的实现,还提供了表达式树的 API。另一方面,运行时不需要进行任何更改。对于动态类型,情况要复杂一些。图 4.1 给出了涉及元素的图形表示。

图 4.1. 动态类型中涉及的组件的图形表示

CLR 没有要求进行更改,尽管我相信从 v2 到 v4 的优化在一定程度上是由这项工作驱动的。编译器显然参与了生成不同的 IL,我们将在稍后的例子中看到这一点。对于框架/库支持,有两个方面。第一个是 动态语言运行时 (DLR),它提供了一些语言无关的基础设施,如 DynamicMetaObject。它负责执行所有的动态行为。但第二个库并不是核心框架本身的一部分:Microsoft.CSharp.dll。

注意

这个库与框架一起分发,但并不是系统框架库的一部分。我发现把它想象成一个第三方依赖很有帮助,而这个第三方恰好是微软。另一方面,微软的 C# 编译器与它相当紧密地耦合在一起。它不适合任何盒子特别整齐地放入。

这个库负责所有与 C# 相关的事情。例如,如果你在一个方法调用中使用了动态值作为参数,那么在执行时进行重载解析的就是这个库。它是 C# 编译器负责绑定的部分的副本,但在所有动态 API 的上下文中进行操作。

如果你曾在你的项目中看到过对 Microsoft.CSharp.dll 的引用并想知道它是用来做什么的,那就是原因。如果你在任何地方都没有使用动态类型,你可以安全地移除这个引用。如果你使用了动态类型但移除了引用,你将在编译时遇到错误,因为 C# 编译器会生成对该程序集的调用。说到由 C# 编译器生成的代码,让我们现在看看一些。

为动态类型生成的 IL 代码

我们将回到我们最初的动态类型示例,但让它更简短。以下是您看到的动态代码的前两行:

dynamic text = "hello world";
string world = text.Substring(6);

很简单,对吧?这里有两个动态操作:

  • Substring 方法的调用

  • 将结果转换为字符串

下面的列表是上述两行代码生成的代码的反编译版本。为了清晰起见,我包括了类声明和 Main 方法的周围上下文。

列表 4.9. 解析两个简单动态操作的输出结果
using Microsoft.CSharp.RuntimeBinder;
using System;
using System.Runtime.CompilerServices;

class DynamicTypingDecompiled
{
  private static class CallSites                                  *1*
  {
    public static CallSite<Func<CallSite, object, int, object>>
       method;
    public static CallSite<Func<CallSite, object, string>>
       conversion;
  }

  static void Main()
  {
    object text = "hello world";
    if (CallSites.method == null)                                 *2*
    {
      CSharpArgumentInfo[] argumentInfo = new[]
      {
        CSharpArgumentInfo.Create(
          CSharpArgumentInfoFlags.None, null),
        CSharpArgumentInfo.Create(
          CSharpArgumentInfoFlags.Constant |
            CSharpArgumentInfoFlags.UseCompileTimeType,
          null)
      };
      CallSiteBinder binder =
        Binder.InvokeMember(CSharpBinderFlags.None, "Substring",
          null, typeof(DynamicTypingDecompiled), argumentInfo);
      CallSites.method =
        CallSite<Func<CallSite, object, int, object>>.Create(binder);
    }

    if (CallSites.conversion == null)                             *3*
    {
      CallSiteBinder binder =
        Binder.Convert(CSharpBinderFlags.None, typeof(string),
          typeof(DynamicTypingDecompiled));
      CallSites.conversion =
        CallSite<Func<CallSite, object, string>>.Create(binder);
    }
    object result = CallSites.method.Target(                      *4*
      CallSites.method, text, 6);                                 *4*

    string str =                                                  *5*
      CallSites.conversion.Target(CallSites.conversion, result);
  }
}
  • 1 调用位置的缓存

  • 2 如果需要,为方法调用创建调用位置

  • 3 如果需要,创建转换的调用位置

  • 4 调用方法调用位置

  • 5 调用转换调用位置

我为这个格式化表示歉意。我已经尽我所能使其可读,但它涉及很多长名字的代码。好消息是,你几乎肯定永远不需要查看这样的代码,除非是出于兴趣。有一点需要注意,CallSiteSystem.Runtime.CompilerServices 命名空间中,因为它语言中立,而使用的 Binder 类来自 Microsoft.CSharp.RuntimeBinder

如你所见,涉及了很多 调用位置。每个调用位置都被生成的代码缓存,DLR 也有多级缓存。绑定是一个相当复杂的过程。调用位置内的缓存通过存储每个绑定操作的结果来提高性能,以避免重复工作,同时意识到如果某些上下文在调用之间发生变化,相同的调用可能会得到不同的绑定结果。

所有这些努力的结果是一个非常高效的系统。它的性能并不如静态类型代码,但出奇地接近。我预计,在大多数情况下,如果动态类型是其他原因下的合适选择,其性能不会成为限制因素。为了总结动态类型的覆盖范围,我将解释你可能会遇到的一些局限性,并给出一些关于何时以及如何使用动态类型作为有效选择的指导。

4.1.4. 动态类型中的局限性和惊喜

将动态类型集成到一个从一开始就被设计为静态类型的语言中是困难的。两个不和谐相处并不奇怪。我整理了一份关于动态类型的一些方面的列表,包括在执行时可能遇到的限制或潜在惊喜。这个列表并不全面,但它涵盖了最常见的常见问题。

动态类型和泛型

使用泛型中的 dynamic 类型可能很有趣。编译时应用了关于你可以在哪里使用 dynamic 的规则:

  • 类型不能在类型参数的任何位置使用 dynamic 来指定它实现了接口。

  • 你不能在类型约束中任何地方使用 dynamic

  • 一个类可以指定一个在类型参数中使用 dynamic 的基类,即使它是接口类型参数的一部分。

  • 你可以将 dynamic 用作变量接口类型参数。

以下是一些无效代码的示例:

class DynamicSequence : IEnumerable<dynamic>
class DynamicListSequence : IEnumerable<List<dynamic>>
class DynamicConstraint1<T> : IEnumerable<T> where T : dynamic
class DynamicConstraint2<T> : IEnumerable<T> where T : List<dynamic>

但所有这些都是有效的:

class DynamicList : List<dynamic>
class ListOfDynamicSequences : List<IEnumerable<dynamic>>
IEnumerable<dynamic> x = new List<dynamic> { 1, 0.5 }.Select(x => x * 2);
扩展方法

执行时绑定器不会解析扩展方法。理论上它可以这样做,但它需要在每个方法调用位置保留有关每个相关 using 指令的额外信息。重要的是要注意,这不会影响静态绑定的调用,即使类型参数中某处使用了动态类型。所以,例如,以下列表可以编译并运行而不会出现任何问题。

列表 4.10. 在动态值列表上进行的 LINQ 查询
List<dynamic> source = new List<dynamic>
{
    5,
    2.75,
    TimeSpan.FromSeconds(45)
};
IEnumerable<dynamic> query = source.Select(x => x * 2);
foreach (dynamic value in query)
{
    Console.WriteLine(value);
}

这里的唯一动态操作是乘法 (x * 2) 和 Console.WriteLine 中的重载解析。Select 的调用在编译时被正常绑定。作为一个失败的例子,让我们尝试将源本身设置为动态,并将你使用的 LINQ 操作简化为 Any()。 (如果你像之前一样继续使用 Select,你将遇到另一个问题,你将在下一刻看到。)以下列表显示了更改。

列表 4.11. 尝试在动态目标上调用扩展方法
dynamic source = new List<dynamic>
{
    5,
    2.75,
    TimeSpan.FromSeconds(45)
};
bool result = source.Any();

我没有包括输出部分,因为执行不会到达那里。相反,它因为 List<T> 不包含名为 Any 的方法而失败,并抛出 RuntimeBinderException

如果你想要像目标是一个动态值一样调用扩展方法,你需要将其作为常规静态方法调用。例如,你可以将 列表 4.11 的最后一行重写为以下内容:

bool result = Enumerable.Any(source);

调用仍然会在执行时绑定,但仅限于重载解析。

匿名函数

匿名函数有三个限制。为了简单起见,我将使用 lambda 表达式展示它们。

首先,匿名方法不能分配给类型为 dynamic 的变量,因为编译器不知道要创建哪种委托。如果你要么进行类型转换,要么使用中间的静态类型变量(然后复制值),并且也可以动态地调用委托,这是可以的。例如,以下是不合法的:

dynamic function = x => x * 2;
Console.WriteLine(function(0.75));

但这没问题,会打印出 1.5:

dynamic function = (Func<dynamic, dynamic>) (x => x * 2);
Console.WriteLine(function(0.75));

其次,由于相同的原因,lambda 表达式不能出现在动态绑定操作中。这就是为什么我没有在 列表 4.11 中使用 Select 来演示扩展方法的问题。以下是 列表 4.11 在其他情况下可能的样子:

dynamic source = new List<dynamic>
{
    5,
    2.75,
    TimeSpan.FromSeconds(45)
};
dynamic result = source.Select(x => x * 2);

你知道在执行时这不会工作,因为它找不到 Select 扩展方法,但它甚至无法编译,因为使用了 lambda 表达式。编译时问题的解决方案与之前相同:只需将 lambda 表达式转换为委托类型或先将其分配给静态类型变量。对于 Select 这样的扩展方法,这仍然会在执行时失败,但如果调用的是像 List<T>.Find 这样的常规方法,那就没问题了。

最后,转换为表达式树的 lambda 表达式不能包含任何动态操作。考虑到 DLR 内部使用表达式树的方式,这听起来可能有些奇怪,但在实践中很少成为问题。在大多数情况下,表达式树是有用的,但动态类型意味着什么或如何实现并不清楚。

例如,你可以尝试调整 列表 4.10(使用静态类型的 source 变量)以使用 IQueryable<T>,如下所示。

列表 4.12. 尝试在 IQueryable<T> 中使用动态元素类型
List<dynamic> source = new List<dynamic>
{
    5,
    2.75,
    TimeSpan.FromSeconds(45)
};
IEnumerable<dynamic> query = source
    .AsQueryable()
    .Select(x => x * 2);               *1*
  • 1 这行代码现在无法编译。

AsQueryable() 调用的结果是 IQueryable<dynamic>。这是静态类型,但其 Select 方法接受一个表达式树而不是委托。这意味着 lambda 表达式 (x => x * 2) 必须转换为表达式树,但它执行了一个动态操作,因此无法编译。

匿名类型

我在第一次介绍匿名类型时提到了这个问题,但值得再次强调:C# 编译器会将匿名类型作为常规类在 IL 中生成。它们具有 internal 访问权限,因此无法在声明它们的程序集之外使用。通常情况下这不是问题,因为匿名类型通常只在一个方法中使用。使用动态类型,你可以读取匿名类型实例的属性,但前提是代码可以访问生成的类。以下列表展示了这种情况下的一个示例,其中它是有效的。

列表 4.13. 动态访问匿名类型的属性
static void PrintName(dynamic obj)
{
    Console.WriteLine(obj.Name);
}

static void Main()
{
    var x = new { Name = "Abc" };
    var y = new { Name = "Def", Score = 10 };
    PrintName(x);
    PrintName(y);
}

此列表有两个匿名类型,但绑定过程并不关心它是否绑定到匿名类型。不过,它确实会检查它是否有权访问它找到的属性。如果您将此代码拆分到两个程序集中,这将导致问题;绑定器会注意到匿名类型是在创建它的程序集内部,并抛出 RuntimeBinderException。如果您遇到这个问题,并且可以使用 [InternalsVisibleTo] 允许执行动态绑定的程序集访问创建匿名类型的程序集,那么这是一个合理的解决方案。

显式接口实现

运行时绑定器使用任何动态值的运行时类型,然后以您将其作为变量的编译时类型的方式绑定。不幸的是,这并不与现有的 C# 特性显式接口实现很好地配合。当您使用显式接口实现时,这实际上意味着正在实现的成员仅在您使用对象上的接口视图而不是类型本身时才可用。

展示这一点比解释它更容易。下面的列表使用 List<T> 作为示例。

列表 4.14. 显式接口实现的示例
List<int> list1 = new List<int>();
Console.WriteLine(list1.IsFixedSize);     *1*

IList list2 = list1;
Console.WriteLine(list2.IsFixedSize);     *2*

dynamic list3 = list1;
Console.WriteLine(list3.IsFixedSize);     *3*
  • 1 编译时错误

  • 2 成功;打印 False

  • 3 运行时错误

List<T> 实现了 IList 接口。该接口有一个名为 IsFixedSize 的属性,但 List<T> 类显式地实现了它。任何尝试通过具有静态类型为 List<T> 的表达式访问它的尝试都会在编译时失败。您可以通过具有静态类型为 IList 的表达式来访问它,并且它总是返回 false。但是,动态访问它呢?绑定器将始终使用动态值的实际类型,因此找不到该属性,并抛出 RuntimeBinderException。这里的解决方案是,如果您知道您想使用接口成员,请将动态值转换回接口(通过强制转换或单独的变量)。

我相信,任何经常使用动态类型的人都能给你一个越来越难以理解的边缘案例的长列表,但前面提到的项目应该能让你不会太频繁地感到惊讶。我们将通过一些关于何时以及如何使用动态类型的指导来完成对动态类型的覆盖。

4.1.5. 使用建议

我会坦白地说:我通常不是动态类型的粉丝。我不记得我上次在生产代码中使用它是什么时候,而且我只会谨慎地使用它,并在进行了大量的正确性和性能测试之后。

我对静态类型情有独钟。根据我的经验,它提供了四个显著的好处:

  • 当我犯错误时,我可能会更早地发现它们——在编译时而不是运行时。这对于可能难以彻底测试的代码路径尤其重要。

  • 编辑器可以提供代码补全。这在打字速度方面并不是特别重要,但作为探索我可能想要做什么的方式,尤其是如果我在使用一个我不熟悉的类型时,是非常好的。如今,动态语言的编辑器可以提供令人瞩目的代码补全功能,但它们永远不会像静态类型语言的那些一样精确,因为可用的信息少得多。

  • 这让我思考我提供的 API,包括参数、返回类型等方面。在我决定接受和返回哪些类型之后,这就像现成的文档:我只需要为那些不明显的内容添加注释,例如可接受值的范围。

  • 通过在编译时而不是执行时进行工作,静态类型代码通常比动态类型代码具有性能优势。我不想过分强调这一点,因为现代运行时可以做到惊人的事情,但这确实值得考虑。

我相信一个动态类型爱好者能够给你列出动态类型的一些类似的好处,但我不太适合做这件事。我怀疑这些好处在从一开始就设计为动态类型的语言中更容易获得。C# 主要 是一种静态类型语言,其遗产是明显的,这也是为什么之前提到的边缘情况存在。话虽如此,以下是一些建议,关于你可能想要使用动态类型的情况。

更简单的反射

假设你发现自己正在使用反射来访问属性或方法;你编译时知道名称,但由于某种原因不能引用静态类型。使用动态类型请求运行时绑定器执行该访问比直接使用反射 API 要简单得多。如果你原本需要执行多个反射步骤,这种好处会更大。例如,考虑以下代码片段:

dynamic value = ...;
value.SomeProperty.SomeMethod();

涉及的反射步骤如下:

  1. 根据初始值的类型获取PropertyInfo

  2. 获取该属性的值并记住它。

  3. 根据属性结果的类型获取MethodInfo

  4. 在属性结果上执行方法。

在你添加验证以确保属性和方法都存在之前,你将看到几行代码。其结果将不会比之前展示的动态方法更安全,但阅读起来会更困难。

没有共同接口的常见成员

有时候你确实可以提前知道一个值的所有可能类型,并且你想要在它们上面使用具有相同名称的成员。如果这些类型实现了一个共同接口或共享一个声明该成员的共同基类,那很好,但这种情况并不总是发生。如果每个都独立声明该成员(如果你不能改变这一点),你将面临不愉快的选择。

这次,你不需要使用反射,但可能需要执行几个重复的步骤,比如检查类型、转换、访问成员。C# 7 的模式使这变得显著简单,但它仍然可能是重复的。相反,你可以使用动态类型来有效地表达“相信我,我知道这个成员将会存在,即使我无法用静态类型的方式表达。”我会在测试中这样做(因为错误成本是测试失败),但在生产代码中我会更加谨慎。

使用为动态类型构建的库

.NET 生态系统相当丰富,并且一直在不断改进。开发者正在创建各种有趣的库,我怀疑有些人可能会接受动态类型。例如,我可以想象一个库,它允许通过 REST 或 RPC 基于的 API 进行易于原型设计,而不涉及任何代码生成。这在开发初期,当一切都很灵活时,可能会很有用,在生成用于后续开发的静态类型库之前。

这与您之前看到的 Json.NET 示例类似。在模型定义良好之后,你可能很乐意编写表示数据模型的类,但在原型设计时,可能更简单的是先更改 JSON,然后是动态访问它的代码。同样,你稍后会发现 COM 的改进意味着你通常可以最终使用动态类型而不是执行大量的转换。

简而言之,我认为在简单易行的情况下使用静态类型仍然是有意义的,但你应该接受动态类型作为某些情况可能有用的工具。我鼓励你在每个上下文中权衡利弊。例如,适用于原型或甚至测试代码的代码可能不适合生产代码。

除此之外,通过使用 DynamicObjectIDynamicMetaObjectProvider 来实现动态行为的能力,确实为有趣的发展提供了很多空间。尽管我可能对动态类型有所保留,但它在 C# 中已经得到了良好的设计和实现,为探索提供了丰富的途径。

我们下一个特性在某种程度上有些不同,尽管当你查看 COM 互操作性时,两者会结合在一起。我们回到了静态类型,以及它的一个特定方面:为参数提供参数。

4.2. 可选参数和命名参数

可选参数和命名参数的适用范围有限:给定一个你想要调用的方法、构造函数、索引器或委托,你如何提供调用参数?可选参数 允许调用者完全省略一个参数,而 命名参数 允许调用者向编译器和任何人类读者清楚地表明参数与哪个参数相关联。

让我们从简单的例子开始,然后深入细节。在本节中,我将只考虑方法。相同的规则适用于所有可以具有参数的其他类型的成员。

4.2.1. 带默认值的参数和带名称的参数

以下列表显示了一个简单的有三个参数的方法,其中两个是可选的。对方法的多次调用展示了不同的功能。

列表 4.15. 调用一个带有可选参数的方法
static void Method(int x, int y = 5, int z = 10)       *1*
{
    Console.WriteLine("x={0}; y={1}; z={2}", x, y, z); *2*
}

...

Method(1, 2, 3);                                       *3*
Method(x: 1, y: 2, z: 3);                              *3*
Method(z: 3, y: 2, x: 1);                              *3*
Method(1, 2);                                          *4*
Method(1, y: 2);                                       *4*
Method(1, z: 3);                                       *5*
Method(1);                                             *6*
Method(x: 1);                                          *6*
  • 1 一个必需参数,两个可选

  • 2 只打印参数值。

  • 3 x=1; y=2; z=3

  • 4 x=1; y=2; z=10

  • 5 x=1; y=5; z=3

  • 6 x=1; y=5; z=10

图 4.2 展示了相同的方法声明和一个方法调用,以使术语清晰。

图 4.2. 可选/必需参数和命名/位置参数的语法

图片

语法很简单:

  • 一个参数可以在其名称之后指定一个默认值,名称和值之间用等号分隔。任何具有默认值的参数都是可选的;任何没有默认值的参数都是必需的。不允许对具有 refout 修饰符的参数使用默认值。

  • 一个参数可以在值之前指定一个名称,名称和值之间用冒号分隔。没有名称的参数称为位置参数

参数的默认值必须是以下表达式之一:

  • 一个编译时常量,例如数字或字符串字面量,或空字面量。

  • 一个默认表达式,例如 default(CancellationToken)。正如你将在第 14.5 节中看到的那样,C# 7.1 引入了默认字面量,因此你可以写 default 而不是 default(CancellationToken)

  • 一个新表达式,例如 new Guid()new CancellationToken()。这仅适用于值类型。

所有可选参数都必须在所有必需参数之后,但参数数组除外。(参数数组是具有 params 修饰符的参数。)

警告

尽管你可以声明一个带有可选参数后跟参数数组的方法,但调用起来会让人困惑。我强烈建议你避免这样做,并且我不会深入探讨如何解析此类方法的调用。

使参数可选的目的是允许调用者省略它,如果它提供的值与默认值相同。让我们看看编译器如何处理可能涉及默认参数和/或命名参数的方法调用。

4.2.2. 确定方法调用的含义

如果你阅读了规范,你会看到确定哪个参数对应哪个参数的过程是重载解析的一部分,并且与类型推断交织在一起。这比你可能预期的要复杂,所以我会在这里简化一下。我们将关注一个单独的方法签名,假设它已经被重载解析选中,然后从那里开始。

规则列出来是相当简单的:

  • 所有位置参数必须位于所有命名参数之前。在 C# 7.2 中,这个规则略有放宽,如第 14.6 节 section 14.6 中所示。

  • 位置参数始终对应于方法签名中相同位置的参数。第一个位置参数对应第一个参数,第二个位置参数对应第二个参数,依此类推。

  • 命名参数通过名称而不是位置匹配:名为 x 的参数对应于名为 x 的参数。命名参数可以按任何顺序指定。

  • 任何参数只能有一个相应的参数。你不能在两个命名参数中指定相同的名称,也不能为已经有一个位置参数对应的参数使用命名参数。

  • 每个必需参数都必须有一个相应的参数来提供值。

  • 可选参数可以没有相应的参数,在这种情况下,编译器将提供默认值作为参数。

为了看到这些规则的实际应用,让我们考虑我们的原始简单方法签名:

static void Method(int x, int y = 5, int z = 10)

你可以看到 x 是一个必需参数,因为它没有默认值,但 yz 是可选参数。表 4.1 显示了几个有效调用及其结果。

表 4.1. 命名参数和可选参数的有效方法调用示例
调用 结果参数 备注
方法(1, 2, 3) x=1; y=2; z=3 所有位置参数。C# 4.0 之前的常规调用。
方法(1) x=1; y=5; z=10 编译器为 y 和 z 提供值,因为没有相应的参数。
方法() n/a 无效:没有参数对应于 x。
方法(y: 2) n/a 无效:没有参数对应于 x。
方法(1, z: 3) x=1; y=5; z=3 编译器为 y 提供值,因为没有相应的参数。它通过使用 z 的命名参数而跳过。
方法(1, x: 2, z: 3) n/a 无效:两个参数对应于 x。
方法(1, y: 2, y: 2) n/a 无效:两个参数对应于 y。
方法(z: 3, y: 2, x: 1) x=1; y=2; z=3 命名参数可以按任何顺序,

在评估方法调用时,有两个重要方面需要注意。首先,参数的评估顺序与它们在方法调用源代码中出现的顺序相同,从左到右。在大多数情况下,这不会产生影响,但如果参数评估有副作用,则可能会产生影响。作为一个例子,考虑我们对示例方法的这两个调用:

int tmp1 = 0;
Method(x: tmp1++, y: tmp1++, z: tmp1++);    *1*

int tmp2 = 0;
Method(z: tmp2++, y: tmp2++, x: tmp2++);    *2*
  • 1 x=0; y=1; z=2

  • 2 x=2; y=1; z=0

这两个调用仅在命名参数的顺序上有所不同,但这会影响传递给方法的价值。在两种情况下,代码的阅读难度都比可能的情况要高。当参数评估的副作用很重要时,我鼓励你将它们作为单独的语句进行评估,并将结果赋值给新的局部变量,然后将这些变量直接作为参数传递给方法,如下所示:

int tmp3 = 0;
int argX = tmp3++;
int argY = tmp3++;
int argZ = tmp3++;
Method(x: argX, y: argY, z: argZ);

在这一点上,无论你是否命名参数,都不会改变行为;你可以选择你认为最易读的任何形式。在我看来,将参数评估与方法调用分离使得理解参数评估的顺序更加简单。

第二个需要注意的点是,如果编译器必须为参数指定任何默认值,这些值将嵌入到调用代码的 IL 中。编译器无法说“这个参数我没有值;请使用你有的任何默认值。”这就是为什么默认值必须是编译时常量,这也是可选参数影响版本化的方式之一。

4.2.3. 对版本化的影响

在库中版本化公共 API 是一个难题。这真的很困难,并且比我们愿意假装的要复杂得多。尽管语义版本化表示任何破坏性变更都意味着你需要迁移到新的主要版本,但如果你愿意包括一些晦涩的情况,几乎任何变更都可能破坏依赖于库的某些代码。话虽如此,可选参数和命名参数在版本化方面尤其棘手。让我们看看各种因素。

参数名称更改是破坏性的

假设你有一个包含你之前查看的方法的库,但它是公开的:

public static Method(int x, int y = 5, int z = 10)

现在假设你想要在新版本中将以下内容进行更改:

public static Method(int a, int b = 5, int c = 10)

这是一个破坏性的变更;任何在调用方法时使用命名参数的代码都将被破坏,因为它们之前指定的名称已不再存在。请像检查你的类型和成员名称一样仔细检查你的参数名称!

默认值更改至少是令人惊讶的

正如我之前提到的,默认值被编译到调用代码的 IL 中。当它在同一个程序集内时,更改默认值不会引起问题。当它在不同的程序集内时,更改默认值只有在调用代码重新编译时才会可见。

这并不总是问题,如果你预计你可能想要更改默认值,那么在方法文档中明确地声明这一点并不完全不合理。但这可能会让一些使用你代码的开发者感到惊讶,尤其是如果涉及到复杂的依赖链。避免这种情况的一种方法是用一个始终意味着“让方法在执行时选择”的专用默认值。例如,如果你有一个通常具有int参数的方法,你可以使用Nullable<int>代替,默认值为null表示“方法将选择”。你可以稍后更改方法的实现以做出不同的选择,并且使用新版本的每个调用者都会得到新的行为,无论他们是否重新编译。

添加重载很麻烦

如果你认为在单版本场景下重载解析已经棘手,那么在你尝试添加重载而不破坏任何人的情况下,问题会变得更加严重。所有原始方法签名都必须在新版本中存在,以避免破坏二进制兼容性,并且所有针对原始方法的调用在新版本中应该解析为相同的调用,或者至少是等效的调用。一个参数是必需的还是可选的不是方法签名本身的一部分;通过将可选参数更改为必需的,或者相反,你不会破坏二进制兼容性。但你可能会破坏源兼容性。如果你不小心,你很容易通过添加一个具有更多可选参数的新方法来引入重载解析的歧义。

如果两个方法在重载解析中都适用(与调用相关),并且就涉及的参数到参数的转换而言,没有一个比另一个更好,那么可以使用默认参数作为决定性因素。没有对应参数的没有可选参数的方法比至少有一个没有对应参数的可选参数的方法“更好”。但一个未填写的参数的方法并不比有两个此类参数的方法“更好”。

如果在涉及可选参数的情况下,你可以避免向方法添加重载,我强烈建议你这样做——并且,理想情况下,从一开始就记住这一点。对于可能有很多选项的方法,可以考虑创建一个表示所有这些选项的类,然后在方法调用中将它作为可选参数。然后,你可以通过向选项类添加属性来添加新选项,而无需更改方法签名。

尽管有所有这些警告,我还是支持在合理简化常见情况下的调用代码时使用可选参数,并且我是命名参数以澄清调用代码能力的忠实粉丝。这在多个相同类型的参数可能相互混淆的情况下尤其相关。作为一个例子,我总是在需要调用 Windows Forms 的MessageBox.Show方法时使用它们。我总是记不清消息框的标题或文本哪个在前。IntelliSense 在编写代码时能帮助我,但在阅读代码时并不明显,除非我使用命名参数:

MessageBox.Show(text: "This is text", caption: "This is the title");

我们接下来要讨论的主题可能是许多读者不需要的,而其他读者则可能每天都会用到。尽管在许多情况下 COM 是一种遗留技术,但仍然有大量的代码在使用它。

4.3. COM 互操作性改进

在 C# 4 之前,如果你想要与 COM 组件进行交互,VB(Visual Basic)是一个更好的选择。它一直是一个相对宽松的语言,至少如果你要求它如此的话,并且它从一开始就支持命名参数和可选参数。C# 4 让与 COM 交互的工作变得更加简单。尽管如此,如果你不使用 COM,跳过这一节你也不会错过任何重要的内容。这里提到的所有功能都与 COM 无关。

注意

COM 是微软在 1993 年引入的组件对象模型,它是一种在 Windows 上的跨语言互操作性形式。完整的描述超出了本书的范围,但如果你需要了解它,你很可能已经知道了。最常用的 COM 库可能是 Microsoft Office 的库。

让我们从超越语言本身的一个特性开始。这主要关于部署,尽管它也影响了操作如何被暴露。

4.3.1. 链接主要互操作程序集

当你针对 COM 类型进行编码时,你使用为组件库生成的程序集。通常,你使用组件发布者生成的主要互操作程序集(PIA)。你可以使用类型库导入工具(tlbimp)为你自己的 COM 库生成这个程序集。

在 C# 4 之前,完整的 PIA 必须在代码最终运行的机器上存在,并且它必须与编译时使用的版本相同。这意味着要么将 PIA 与应用程序一起分发,要么相信正确的版本已经安装。

从 C# 4 和 Visual Studio 2010 开始,你可以选择链接 PIA(Primary Interop Assembly)而不是引用它。在 Visual Studio 中,在引用的属性页中,这是嵌入互操作类型选项。

当此选项设置为 True 时,PIA 的相关部分将直接嵌入到你的程序集中。只包含你应用程序中使用的位。当代码运行时,只要客户端机器上有你应用程序所需的一切,无论你用于编译的组件的确切版本是否相同,都没有关系。图 4.3 显示了在代码运行方面引用(旧方法)和链接(新方法)之间的差异。

图 4.3. 比较引用和链接

图片

除了部署更改外,将 PIA 链接会影响 COM 类型中 VARIANT 类型的处理方式。当引用 PIA 时,任何返回 VARIANT 值的操作都会在 C# 中使用 object 类型公开。然后你必须将其转换为适当的类型以使用其方法和属性。

当 PIA 被链接时,返回的是 dynamic 而不是 object。正如你之前看到的,从 dynamic 类型的表达式到任何非指针类型的隐式转换是存在的,然后在执行时进行检查。以下列表显示了一个打开 Excel 并填充一个范围内的 20 个单元格的示例。

列表 4.16. 使用隐式动态转换在 Excel 中设置一系列值
var app = new Application { Visible = true };
app.Workbooks.Add();
Worksheet sheet = app.ActiveSheet;
Range start = sheet.Cells[1, 1];
Range end = sheet.Cells[1, 20];
sheet.Range[start, end].Value = Enumerable.Range(1, 20).ToArray();

列表 4.16 静默地使用了一些即将出现的功能,但此时请关注对 sheetstartend 的赋值。每个赋值通常都需要转换,因为被赋值的值将是 object 类型。你不必指定变量的静态类型;如果你为变量类型使用了 vardynamic,你将使用动态类型进行更多操作。我更喜欢在我知道期望它是什么类型的地方指定静态类型,部分是因为它执行的隐式验证,部分是为了在后续代码中启用 IntelliSense。

对于广泛使用 VARIANT 的 COM 库,这是动态类型最重要的好处之一。下一个 COM 功能也是基于 C# 4 中的一个新特性,并将可选参数提升到了新的水平。

4.3.2. COM 中的可选参数

一些 COM 方法有很多参数,而且通常它们都是 ref 参数。这意味着在 C# 4 之前,像在 Word 中保存文件这样的简单操作可能会非常痛苦。

列表 4.17. 在 C# 4 之前创建 Word 文档并保存
object missing = Type.Missing;                             *1*

Application app = new Application { Visible = true };      *2*
Document doc = app.Documents.Add                           *3*
    ref missing, ref missing,                              *3*
    ref missing, ref missing);                             *3*
Paragraph para = doc.Paragraphs.Add(ref missing);          *3*
para.Range.Text = "Awkward old code";                      *3*

object fileName = "demo1.docx";                            *4*
doc.SaveAs2(ref fileName, ref missing,                     *4*
    ref missing, ref missing, ref missing,                 *4*
    ref missing, ref missing, ref missing,                 *4*
    ref missing, ref missing, ref missing,                 *4*
    ref missing, ref missing, ref missing,                 *4*
    ref missing, ref missing);                             *4*

doc.Close(ref missing, ref missing, ref missing);          *5*
app.Application.Quit(                                      *5*
    ref missing, ref missing, ref missing);                *5*
  • 1 ref 参数的占位符变量

  • 2 启动 Word

  • 3 创建并填充文档

  • 4 保存文档

  • 5 关闭 Word

仅为了创建和保存文档就需要大量的代码,包括 20 次的 ref missing 出现。在你不关心的参数森林中,很难看到代码的有用部分。

C# 4 提供了使这一过程变得简单得多的功能:

  • 命名参数可用于明确指定哪个参数对应哪个参数,正如您已经看到的。

  • 仅对于 COM 库,可以直接将值作为 ref 参数的参数指定。编译器将在幕后创建一个局部变量并将其通过引用传递。

  • 仅对于 COM 库,ref 参数可以是可选的,并且在调用代码中省略。使用 Type.Missing 作为默认值。

在所有这些功能都起作用的情况下,您可以将 列表 4.17 转换为更短、更干净的代码。

列表 4.18. 使用 C# 4 创建并保存 Word 文档
Application app = new Application { Visible = true };
Document doc = app.Documents.Add();                     *1*
Paragraph para = doc.Paragraphs.Add();
para.Range.Text = "Simple new code";

doc.SaveAs2(FileName: "demo2.docx");                    *2*

doc.Close();
app.Application.Quit();
  • 1 在所有地方省略了可选参数

  • 2 使用命名参数提高清晰度

这在可读性方面是一个巨大的转变。所有 20 个 ref missing 出现的地方以及该变量本身都消失了。碰巧的是,传递给 SaveAs2 的参数对应于方法的第一参数。您可以使用位置参数而不是命名参数,但指定名称会增加清晰度。如果您还想为后续参数指定值,您可以通过名称指定,而无需为所有其他参数提供值。

那个传递给 SaveAs2 的参数也展示了隐式 ref 功能。在我们的源代码中,您可以直接传递值,而不是在 demo2.docx 的初始值中声明一个变量然后通过引用传递它。编译器会为您将其转换为 ref 参数。最终的 COM 相关功能揭示了 VB 在某些方面比 C# 更丰富。

4.3.3. 命名索引器

索引器自 C# 诞生以来就存在。它们主要用于集合:例如,通过索引从列表中检索元素或通过键从字典中检索值。但 C# 的索引器在源代码中从不命名。您只能为类型编写 默认索引器。您可以使用属性指定名称,并且该名称将被其他语言消费,但 C# 不允许您通过名称区分索引器。至少,在 C# 4 之前是这样的。

其他语言允许您编写和消费具有名称的索引器,因此您可以通过名称访问对象的不同方面,以明确您想要的内容。C# 仍然没有为常规 .NET 代码做这件事,但它为 COM 类型做出了例外。以下示例将使这一点更清晰。

Word 中的 Application 类型公开了一个名为 SynonymInfo 的命名索引器。它的声明如下:

SynonymInfo SynonymInfo[string Word, ref object LanguageId = Type.Missing]

在 C# 4 之前,您可以像调用方法 get_SynonymInfo 一样调用索引器,但这有些尴尬。在 C# 4 中,您可以通过名称访问它,如下面的列表所示。

列表 4.19. 访问命名索引器
Application app = new Application { Visible = false };

object missing = Type.Missing;                                      *1*
SynonymInfo info = app.get_SynonymInfo("method", ref missing);      *1*
Console.WriteLine("'method' has {0} meanings", info.MeaningCount);

info = app.SynonymInfo["index"];                                    *2*
Console.WriteLine("'index' has {0} meanings", info.MeaningCount);
  • 1 在 C# 4 之前访问同义词

  • 2 使用命名索引器编写更简洁的代码

列表 4.19 展示了如何在命名索引器和常规方法调用中使用可选参数。在 C# 4 之前,代码必须声明一个变量并将其通过引用传递给名称古怪的方法。在 C# 4 中,你可以通过名称使用索引器,并且可以省略第二个参数的参数。

这只是对 C# 4 中 COM 相关特性的简要概述,但我希望其好处是显而易见的。尽管我并不经常与 COM 一起工作,但这里显示的更改如果将来需要,会让我感到少些沮丧。好处的程度将取决于你正在工作的 COM 库的结构。例如,如果它使用了大量的 ref 参数和 VARIANT 返回类型,那么与参数较少且具有具体返回类型的库相比,差异将更加显著。但即使只是链接 PIA 的选项,也能使部署显著简化。

我们现在正接近 C# 4 的尾声。最后一个特性可能有点难以理解,但你可能在不经意间就使用了它。

4.4. 泛型变体

泛型变体比描述起来更容易展示。它涉及到基于它们的类型参数安全地在泛型类型之间进行转换,并特别注意数据流动的方向。

4.4.1. 变体在行动中的简单示例

我们将从一个使用熟悉接口的示例开始,即 IEnumerable<T>,它表示类型 T 的元素序列。任何字符串序列也是对象序列是有意义的,变体允许这一点:

IEnumerable<string> strings = new List<string> { "a", "b", "c" };
IEnumerable<object> objects = strings;

这可能看起来如此自然,以至于你会惊讶它无法编译,但这正是 C# 4 之前会发生的事情。

注意

在这些示例中,我始终使用 stringobject,因为它们是所有 C# 开发者都知道的类,并且与任何特定上下文无关。具有相同基类/派生类关系的其他类也会以同样的方式工作。

可能还有更多的惊喜在等着我们;即使是在 C# 4 中,也不是所有听起来应该工作的事情都能工作。例如,你可能会尝试将关于序列的推理扩展到列表。任何字符串列表是否都是对象列表?你可能这么认为,但事实并非如此:

IList<string> strings = new List<string> { "a", "b", "c" };
IList<object> objects = strings;                               *1*
  • 1 无效:无法从 IList<string> 转换到 IList<object>

IEnumerable<T>IList<T> 之间有什么区别?为什么不允许这样做?答案是这不会是安全的,因为 IList<T> 中的方法允许 T 类型的值作为输入和输出。你使用 IEnumerable<T> 的任何方式最终都会返回 T 类型的值作为输出,但 IList<T> 有像 Add 这样的方法,它接受一个 T 类型的值作为输入。这会使允许变体变得危险。如果你尝试稍微扩展我们的无效示例,你会看到这一点:

IList<string> strings = new List<string> { "a", "b", "c" };
IList<object> objects = strings;
objects.Add(new object());                                  *1*
string element = strings[3];                                *2*
  • 1 向列表中添加一个对象

  • 2 以字符串的形式检索它

除了第二行之外的其他每一行单独来看都是有意义的。将一个object引用添加到IList<object>中是可以的,从IList<string>中获取一个字符串引用也是可以的。但是,如果你可以将字符串列表视为对象列表,那么这两个能力就会产生冲突。使第二行无效的语言规则实际上是在保护代码的其余部分。

到目前为止,你已经看到了值作为输出返回(IEnumerable<T>)和值既用作输入又用作输出(IList<T>)。在某些 API 中,值始终只用作输入。这个例子最简单,就是Action<T>委托,当你调用委托时,你会传递一个类型为T的值。可变性仍然适用,但方向相反。一开始这可能会让人感到困惑。

如果你有一个Action<object>委托,它可以接受任何对象引用。它肯定可以接受一个字符串引用,并且语言规则允许你从Action<object>转换为Action<string>

Action<object> objectAction = obj => Console.WriteLine(obj);
Action<string> stringAction = objectAction;
stringAction("Print me");

带着这些例子,我可以定义一些术语:

  • 协变发生在只返回值作为输出的情况下。

  • 逆变发生在只接受值作为输入的情况下。

  • 不变性发生在值既用作输入又用作输出的情况下。

这些定义现在故意有些模糊。它们更多地关于一般概念,而不是 C#。在你看过 C#用来指定可变性的语法之后,我们可以使它们更精确。

4.4.2. 接口和委托声明中可变性的语法

关于 C#中的可变性,首先要知道的是,它只能指定为接口和委托。例如,你不能使类或结构体协变。接下来,每个类型参数的可变性是分别定义的。尽管你可能会松散地说“IEnumerable<T>是协变的”,但更精确的说法是“IEnumerable<T>在 T 上是协变的。”这导致了接口和委托声明中的语法,其中每个类型参数都有一个单独的修饰符。以下是IEnumerable<T>IList<T>接口以及Action<T>委托的声明:

public interface IEnumerable<out T>
public delegate void Action<in T>
public interface IList<T>

如你所见,inout修饰符被用来指定类型参数的可变性:

  • 带有out修饰符的类型参数是协变的。

  • 带有in修饰符的类型参数是逆变的。

  • 没有修饰符的类型参数是不变的。

编译器会检查你使用的修饰符是否适合整个声明中的其余部分。例如,这个委托声明是无效的,因为协变类型参数被用作输入:

public delegate void InvalidCovariant<out T>(T input)

而这个接口声明是无效的,因为逆变类型参数被用作输出:

public interface IInvalidContravariant<in T>
{
    T GetValue();
}

任何单个类型参数只能有一个这些修饰符,但在同一声明中的两个类型参数可以有不同的修饰符。例如,考虑Func<T, TResult>委托。它接受类型为T的值并返回类型为TResult的值。对于T来说,逆变性和对于TResult来说的可变性是自然的。委托声明如下:

public TResult Func<in T, out TResult>(T arg)

在日常开发中,你可能会比声明它们更频繁地使用现有的可变接口和委托。在可以使用类型参数方面存在一些限制。现在让我们来看看它们:

4.4.3. 使用可变性的限制

重申之前提到的观点,可变性只能在接口和委托中声明。这种可变性不会被实现接口的类或结构体继承;类和结构体始终是不变的。例如,假设你创建了一个这样的类:

public class SimpleEnumerable<T> : IEnumerable<T>   *1*
{
                                                    *2*
}
  • 1 在这里不允许使用 out 修饰符。

  • 2 实现

你仍然无法将SimpleEnumerable<string>转换为SimpleEnumerable<object>。你可以使用IEnumerable<T>的可变性将SimpleEnumerable<string>转换为IEnumerable<object>

假设你正在处理具有某些可变或逆变类型参数的委托或接口。有哪些可用的转换?你需要定义来解释规则:

  • 考虑到可变性的转换被称为可变性转换

  • 可变性转换是引用转换的一个例子。引用转换是一种不改变涉及值的转换(该值始终是引用);它只改变编译时类型。

  • 恒等转换是从一个类型到与 CLR 相关的相同类型的转换。这可能是从 C#的角度来看相同的类型(例如,从stringstring),或者它可能是仅从 C#语言的角度来看不同的类型之间的转换,例如从objectdynamic

假设你想将IEnumerable<A>转换为IEnumerable<B>,对于某些类型参数AB。如果存在从AB的恒等或隐式引用转换,这是有效的。例如,这些转换是有效的:

  • IEnumerable<string> to IEnumerable<object>: 从一个类到其基类(或其基类的基类,等等)存在隐式引用转换。

  • IEnumerable<string> to IEnumerable<IConvertible>: 从一个类到它实现的任何接口存在隐式引用转换。

  • IEnumerable<IDisposable> to IEnumerable<object>: 从任何引用类型到objectdynamic存在隐式引用转换。

这些转换是无效的:

  • IEnumerable<object> to IEnumerable<string>: 从objectstring存在显式引用转换,但不是隐式的。

  • IEnumerable<string> to IEnumerable<Stream>: 字符串和 Stream 类之间没有关联。

  • IEnumerable<int>IEnumerable<IConvertible>:存在从 intIConvertible 的隐式转换,但这是一种装箱转换而不是引用转换。

  • IEnumerable<int>IEnumerable<long>:存在从 intlong 的隐式转换,但这是一种数值转换而不是引用转换。

如您所见,类型参数之间转换的要求是引用或等价转换,这会影响值类型,可能让您感到惊讶。

那个使用 IEnumerable<T> 的例子只有一个需要考虑的类型参数。那么当您有多个类型参数时怎么办?实际上,它们是从转换的源到目标成对检查的,确保每个转换都适合相关的类型参数。

为了更正式地说明,考虑一个具有 n 个类型参数的泛型类型声明:T<X``[1]``, ..., X``[n]``>。从 T<A``[1]``, ..., A``[n]``>T<B``[1]``, ..., B``[n]``> 的转换是按每个类型参数及其对应的类型参数对依次考虑的。对于 1n 之间的每个 i

  • 如果 X``[i] 是协变的,那么必须存在从 A``[i]B``[i] 的等价或隐式引用转换。

  • 如果 X``[i] 是逆变的,那么必须存在从 B``[i]A``[i] 的等价或隐式引用转换。

  • 如果 X``[i] 是不变的,那么必须存在从 A``[i]B``[i] 的等价转换。

为了将此具体化,让我们考虑 Func<in T, out TResult>。规则意味着以下内容:

  • Func<object, int>Func<string, int> 存在一个有效的转换,因为

    • 第一个类型参数是逆变的,并且存在从 stringobject 的隐式引用转换。

    • 第二个类型参数是协变的,并且存在从 intint 的等价转换。

  • Func<dynamic, string>Func<object, IConvertible> 存在一个有效的转换,因为

    • 第一个类型参数是逆变的,并且存在从 dynamicobject 的等价转换。

    • 第二个类型参数是协变的,并且存在从 stringIConvertible 的隐式引用转换。

  • Func<string, int>Func<object, int> 没有转换,因为

    • 第一个类型参数是逆变的,并且不存在从 objectstring 的隐式引用转换。

    • 第二个类型参数无关紧要;由于第一个类型参数,转换已经无效。

如果这一切都让您感到有些不知所措,请不要担心;99% 的时间您甚至不会注意到您正在使用泛型变异性。我提供了这些细节,以防您在编译时遇到错误且不理解原因.^([3]) 让我们通过查看几个泛型变异性有用的例子来总结。

³

如果这不足以证明某个特定错误,我建议转向第三版,其中包含更多细节。

4.4.4. 实际中的泛型变异性

很多的时间,你可能会在不自觉的情况下使用泛型变体,因为事情就像你可能会希望的那样工作。没有特别必要意识到你正在使用泛型变体,但我将指出几个它有用的例子。

首先,让我们考虑 LINQ 和 IEnumerable<T>。假设你有一些字符串想要进行查询,但你希望最终得到一个 List<object> 而不是 List<string>。例如,你可能需要在之后向列表中添加其他项。下面的列表显示了在协变之前,这样做最简单的方法是使用额外的 Cast 调用。

列表 4.20. 从字符串查询创建一个没有变体的 List<object>
IEnumerable<string> strings = new[] { "a", "b", "cdefg", "hij" };
List<object> list = strings
    .Where(x => x.Length > 1)
 .Cast<object>()
    .ToList();

这对我来说感觉有点烦恼。为什么要在管道中创建一个额外的步骤只是为了改变类型,而这总是会成功?有了变体,你可以在 ToList() 调用中指定一个类型参数,以指定你想要的列表类型,如下面的列表所示。

列表 4.21. 通过使用变体从字符串查询创建一个 List<object>
IEnumerable<string> strings = new[] { "a", "b", "cdefg", "hij" };
List<object> list = strings
    .Where(x => x.Length > 1)
    .ToList<object>();

这之所以有效,是因为 Where 调用的输出是一个 IEnumerable<string>,而你要求编译器将 ToList() 调用的输入视为 IEnumerable<object>。这很好,因为存在变体。

我发现协变与 IComparer<T>(用于对其他类型进行排序比较的接口)结合使用很有用。例如,假设你有一个具有 Area 属性的 Shape 基类,以及 CircleRectangle 派生类。你可以编写一个实现 IComparer<Shape>AreaComparer,这对于使用 List<T>.Sort() 在原地对 List<Shape> 进行排序是不错的。但是,如果你有一个 List<Circle>List<Rectangle>,你该如何排序呢?在泛型变体出现之前,存在各种解决方案,但下面的列表显示了现在这变得多么简单。

列表 4.22. 使用 IComparer<Shape>List<Circle> 进行排序
List<Circle> circles = new List<Circle>
{
    new Circle(5.3),
    new Circle(2),
    new Circle(10.5)
};
circles.Sort(new AreaComparer());
foreach (Circle circle in circles)
{
    Console.WriteLine(circle.Radius);
}

列表 4.22 中使用的类型的完整源代码在可下载的代码中,但它们就像你预期的那样简单。关键点是你可以将 AreaComparer 转换为 IComparer<Circle> 以用于 Sort 方法调用。在 C# 4 之前并不是这样。

如果你声明自己的泛型接口或委托,始终值得考虑类型参数是否可以是协变的或逆变的。如果它不是自然地这样,我通常不会尝试强迫这个问题,但花点时间思考一下是值得的。使用一个可能具有变体型参数的接口,但开发者没有考虑它可能对某人有用,这可能会让人感到烦恼。

摘要

  • C# 4 支持动态类型,它将绑定从编译时推迟到执行时。

  • 动态类型通过 IDynamicMetaObjectProviderDynamicObject 类支持自定义行为。

  • 动态类型通过编译器和框架功能实现。框架通过优化和大量缓存来提高效率。

  • C# 4 允许参数指定默认值。任何具有默认值的参数都是 可选参数,调用者不必提供。

  • C# 4 允许参数指定它打算提供值的参数名称。这与可选参数一起工作,允许您为某些参数指定参数,而不为其他参数指定。

  • C# 4 允许 COM 主要互操作程序集 (PIAs) 被链接而不是引用,这导致了一个更简单的部署模型。

  • 通过动态类型公开变体值的链接 PIAs,这避免了大量的类型转换。

  • 可选参数在 COM 库中被扩展,允许 ref 参数是可选的。

  • COM 库中的引用参数可以通过值指定。

  • 泛型方差允许基于值作为输入或输出进行安全转换的泛型接口和委托。

第五章. 编写异步代码

本章涵盖

  • 编写异步代码的含义

  • 使用 async 修饰符声明异步方法

  • 使用 await 操作符异步等待

  • 自 C# 5 以来 async/await 的语言变化

  • 遵循异步代码的使用指南

异步已经多年困扰着开发者。它已知可以作为避免在等待某些任意任务完成时占用线程的一种方式是有用的,但它正确实现起来也很痛苦。

即使在.NET 框架(在事物的大背景下仍然相对较年轻)中,我们也尝试了三种模型来使事情变得更简单:

  • .NET 1.x 中的 BeginFoo/EndFoo 方法,使用 IAsyncResultAsyncCallback 来传播结果

  • .NET 2.0 中的基于事件的异步模式,由 BackgroundWorkerWebClient 实现

  • .NET 4.0 中引入并在 .NET 4.5 中扩展的任务并行库 (TPL)

尽管 TPL 的设计总体上非常优秀,但用它来编写健壮且易于阅读的异步代码仍然很困难。尽管对并行性的支持很好,但一些通用异步的方面在语言中固定得更好,而不是仅仅在库中。

C# 5 的主要特性通常被称为 async/await,它建立在 TPL 的基础上。它允许你在适当的地方编写看起来同步的代码,使用异步。回调、事件订阅和碎片化错误处理的混乱已经消失;相反,异步代码清晰地表达了其意图,并以开发者已经熟悉的结构为基础。C# 5 中引入的语言结构允许你等待异步操作。这种等待看起来非常像正常的阻塞调用,即你的代码在操作完成之前不会继续执行,但它设法在不阻塞当前执行线程的情况下做到这一点。如果你觉得这个陈述听起来完全矛盾,不要担心;所有这些都会在章节的进程中变得清晰。

Async/await 在时间上有所发展,为了简化,我将 C# 6 和 C# 7 的新特性与原始的 C# 5 描述一起包含在内。我已经指出了这些变化,以便你知道何时需要 C# 6 或 C# 7 编译器。

.NET Framework 在 4.5 版本中完全拥抱了异步,通过遵循 基于任务的异步模式 提供了许多操作的异步版本,以在多个 API 中提供一致的经验。同样,Windows 运行时平台(它是通用 Windows 应用程序(UWA/UWP)的基础)对所有长时间运行(或可能长时间运行)的操作强制执行异步。许多其他现代 API 也大量依赖异步,例如 Roslyn 和 HttpClient。简而言之,大多数 C# 开发者至少需要在他们的工作中使用异步的某些部分。

注意

Windows 运行时平台通常被称为 WinRT;它不要与 Windows RT 混淆,Windows RT 是为 ARM 处理器设计的 Windows 8.x 版本。通用 Windows 应用程序 是 Windows Store 应用的一个演变。UWP 是从 Windows 10 开始对 UWA 的进一步演变。

明确一点,C# 并没有变得无所不知,猜测你可能想要在哪里执行并发或异步操作。编译器很聪明,但它不会尝试消除异步执行的固有复杂性。你仍然需要仔细思考,但 async/await 的美妙之处在于,所有那些曾经需要的繁琐且令人困惑的样板代码都消失了。没有一开始就需要使代码异步的所有干扰,你可以专注于难点。

提醒一句:这个主题相当高级。它具有不幸的特性,即它非常重要(实际上,即使是入门级开发者也需要对此有合理的理解),但一开始又相当难以理解。

本章从“普通开发者”的角度关注异步性,因此你可以使用 async/await 而无需了解太多细节。第六章将深入探讨实现的复杂性。我认为如果你了解幕后发生的事情,你会成为一个更好的开发者,但你当然可以在深入了解之前,从本章中学到的东西来使用 async/await 并变得高效。即使在本书中,你也会以迭代的方式查看这个功能,随着你深入,细节会越来越多。

5.1. 异步函数的介绍

到目前为止,我声称 C# 5 使异步操作更容易,但我只给出了涉及功能的微小描述。让我们来解决这个问题,然后看看一个例子。

C# 5 引入了异步函数的概念。这始终是一个方法或使用async修饰符声明的匿名函数,它可以使用await运算符进行 await 表达式。

注意

作为提醒,匿名函数可以是 lambda 表达式或匿名方法。

await 表达式是语言角度上事物变得有趣的地方:如果表达式所等待的操作尚未完成,异步函数将立即返回,并在值变得可用时(在适当的线程中)继续执行。在执行下一个语句之前等待这一语句完成的自然流程仍然保持,但不会阻塞。我将在稍后用更具体的概念和行为来解释这个模糊的描述,但在你理解它之前,你需要先看到一个例子。

5.1.1. 异步类型的首次接触

让我们从一些简单的内容开始,以实际的方式展示异步性。我们经常诅咒网络延迟导致我们的实际应用中延迟,但延迟确实使展示异步性为何如此重要变得容易——尤其是在使用 Windows Forms 这样的 GUI 框架时。我们的第一个例子是一个微型的 Windows Forms 应用程序,它获取这本书主页的文本,并在标签中显示 HTML 的长度。

列表 5.1. 异步显示页面长度
public class AsyncIntro : Form
{
    private static readonly HttpClient client = new HttpClient();
    private readonly Label label;
    private readonly Button button;
    public AsyncIntro()
    {
        label = new Label
        {
            Location = new Point(10, 20),
            Text = "Length"
        };
        button = new Button
        {
            Location = new Point(10, 50),
            Text = "Click"
        };
        button.Click += DisplayWebSiteLength;        *1*
        AutoSize = true;
        Controls.Add(label);
        Controls.Add(button);
    }

    async void DisplayWebSiteLength(object sender, EventArgs e)
    {
        label.Text = "Fetching...";
        string text = await client.GetStringAsync(   *2*
            "http://csharpindepth.com");             *2*
        label.Text = text.Length.ToString();         *3*
    }

    static void Main()
    {
        Application.Run(new AsyncIntro());           *4*
    }
}
  • 1 连接事件处理器

  • 2 开始获取页面

  • 3 更新 UI

  • 4 入口点;仅运行表单

代码的这一部分以直接的方式创建了用户界面,并为按钮连接了一个事件处理器。这里感兴趣的是DisplayWebSiteLength方法。当你点击按钮时,会获取主页的文本,并将标签更新以显示 HTML 的字符长度。

注意

我并没有释放GetStringAsync返回的任务,尽管Task实现了IDisposable。幸运的是,你通常不需要释放任务。这个背景有些复杂,但 Stephen Toub 在一篇专门讨论这个主题的博客文章中解释了它:mng.bz/E6L3

我本来可以写一个更小的示例程序作为控制台应用程序,但希望列表 5.1 能提供一个更有说服力的演示。特别是,如果你移除了asyncawait上下文关键字,将HttpClient改为WebClient,并将GetStringAsync改为DownloadString,代码仍然可以编译并运行,但在获取页面内容时 UI 会冻结。如果你运行异步版本(理想情况下,通过慢速网络连接),你会看到UI是响应的;在网页获取内容的同时,你仍然可以移动窗口。

注意

在某些意义上,HttpClient是改进后的WebClient;它是从.NET 4.5 开始的推荐 HTTP API,并且只包含异步操作。

大多数开发者都熟悉在 Windows Forms 开发中线程的两大黄金法则:

  • 不要在 UI 线程上执行任何耗时操作。

  • 不要在 UI 线程上访问任何 UI 控件之外的控件。

你现在可能认为 Windows Forms 是一种过时的技术,但大多数 GUI 框架都有相同的规则,而且它们比遵守起来更容易。作为一个练习,你可能想尝试几种不使用 async/await 创建类似列表 5.1 的代码的方法。对于这个极其简单的示例,使用基于事件的WebClient.DownloadStringAsync方法还不错,但一旦涉及到更复杂的流程控制(错误处理、等待多个页面完成等),旧代码很快就会变得难以维护,而 C# 5 代码可以自然地修改。

到目前为止,DisplayWebSiteLength方法感觉有些神奇:你知道它做了你需要它做的事情,但你不知道它是如何做到的。让我们稍微分解一下,并将细节留到以后再说。

5.1.2. 分解第一个示例

你将从稍微扩展该方法开始。在列表 5.1 中,我直接在HttpClient.GetStringAsync的返回值上使用了await,但你也可以将调用与等待部分分开:

async void DisplayWebSiteLength(object sender, EventArgs e)
{
    label.Text = "Fetching...";
    Task<string> task = client.GetStringAsync("http://csharpindepth.com");
    string text = await task;
    label.Text = text.Length.ToString();
}

注意到task的类型是Task<string>,但await task表达式的类型仅仅是string。从这个意义上说,await操作符执行了解包操作——至少当被等待的值是Task<TResult>时。(正如你将看到的,你也可以等待其他类型,但Task<TResult>是一个好的起点。)这是await的一个方面,它似乎与异步性没有直接关系,但使生活变得更简单。

await 的主要目的是在你等待耗时操作完成时避免阻塞。你可能想知道这一切在具体的线程术语中是如何工作的。你在方法的开头和结尾设置 label.Text,因此可以合理地假设这两个语句都是在 UI 线程上执行的,而你显然在等待网页下载时并没有阻塞 UI 线程。

诀窍在于方法在遇到 await 表达式时立即返回。直到那时,它像任何其他事件处理器一样在 UI 线程上同步执行。如果你在第一行设置断点并在调试器中触发它,你会看到堆栈跟踪显示按钮正在忙于引发其 Click 事件,包括 Button.OnClick 方法。当你到达 await 时,代码检查结果是否已经可用,如果不可用(这几乎肯定是这样),它将调度一个后续操作在网页操作完成时执行。在这个例子中,后续操作执行方法的其余部分,实际上跳转到 await 表达式的末尾。后续操作在 UI 线程上执行,这是你所需要的,以便你可以操作 UI。

定义

一个 后续操作 有效地是一个在异步操作(或任何 Task)完成时要执行的回调。在异步方法中,后续操作保持方法的状态。就像闭包在变量方面保持其环境一样,后续操作会记住它达到的点,因此当它执行时可以从那里继续。Task 类有一个专门用于附加后续操作的方法:Task.ContinueWith

如果你然后在 await 表达式之后的代码中设置一个断点并再次运行代码,那么假设 await 表达式需要调度后续操作,你会发现堆栈跟踪中不再包含 Button.OnClick 方法。该方法早已执行完毕。调用堆栈现在实际上将是一个裸露的 Windows Forms 事件循环,上面有几层异步基础设施。调用堆栈将类似于你在后台线程中调用 Control.Invoke 以适当地更新 UI 时所看到的情况,但所有这些都已经为你完成了。一开始可能会觉得脚下调用堆栈的变化令人不安,但这对异步操作的有效性是绝对必要的。

编译器通过创建一个复杂的状态机来实现所有这些。这是你将在第六章中查看的实现细节,但就目前而言,你需要集中精力研究 async/await 提供的功能。首先,你需要一个更具体的描述,说明你试图实现什么以及语言指定了什么。

5.2. 考虑异步操作

如果你要求一个开发者描述异步执行,他们很可能会开始谈论多线程。尽管那是异步使用的典型部分,但它并不是异步执行所必需的。要完全理解 C# 5 的异步功能是如何工作的,最好是抛开任何关于线程的想法,回到基础。

5.2.1. 异步执行的基本原理

异步攻击了 C#开发者熟悉的执行模型的核心。考虑以下简单的代码:

Console.WriteLine("First");
Console.WriteLine("Second");

你期望第一次调用完成之后,第二次调用才开始。执行流程是从一个语句到下一个语句,按顺序进行。但是异步执行模型并不是这样工作的。相反,它完全是关于延续。当你开始做某事时,你告诉那个操作,当那个操作完成时你希望发生什么。你可能听说过(或使用过)回调这个术语,但它的含义比我们在这里追求的含义更广泛。在异步的上下文中,我使用这个术语来指代那些保留程序状态的回调,而不是用于其他目的的任意回调,例如 GUI 事件处理器。

延续在.NET 中自然地表示为委托,并且通常是接收异步操作结果的动作。这就是为什么,在 C# 5 之前使用WebClient的异步方法时,你需要连接各种事件,以说明在成功、失败等情况下的代码应该执行什么。问题是,为复杂的一系列步骤创建所有这些委托最终变得非常复杂,即使有 lambda 表达式的帮助也是如此。当你试图确保错误处理正确时,情况变得更糟。(在好日子里,我可以相当自信地认为手写的异步代码的成功路径是正确的。我通常不太确定它在失败时是否反应正确。)

实际上,C#中的所有await所做的只是要求编译器为你构建一个延续。然而,对于这样一个可以如此简单表达的想法,它对可读性和开发者宁静的影响是显著的。

我之前对异步的描述是一个理想化的描述。在基于任务的异步模式中,现实情况略有不同。不是将延续传递给异步操作,而是异步操作开始并返回一个你可以用来稍后提供延续的令牌。这个令牌代表了正在进行的操作,这个操作可能在返回到调用代码之前已经完成,也可能仍在进行中。这个令牌在你想表达这个想法时被使用:我无法继续进行,直到这个操作完成。通常,这个令牌的形式是TaskTask<TResult>,但它不必是。

注意

这里描述的令牌与取消令牌不同,尽管两者都强调你不需要了解幕后发生的事情;你只需要知道令牌允许你做什么。

在 C# 5 中,异步方法的执行流程通常遵循以下步骤:

  1. 执行一些工作。

  2. 启动一个异步操作并记住它返回的令牌。

  3. 可能还会执行更多工作。(通常,直到异步操作完成,你才能取得进一步进展,在这种情况下,这一步是空的。)

  4. 等待异步操作完成(通过令牌)。

  5. 执行更多工作。

  6. 完成。

如果你不在乎等待部分的确切含义,你可以在 C# 4 中完成所有这些。如果你愿意阻塞直到异步操作完成,令牌通常会提供一种方法让你这样做。对于 Task,你可以简单地调用 Wait()。然而,在那个时刻,你正在占用一个宝贵的资源(一个线程)而没有做任何有用的工作。这有点像打电话订购送货披萨,然后站在你家门前直到它到达。你真正想做的应该是继续做其他事情,忽略披萨直到它到达。这就是 await 的作用。

当你等待异步操作时,你是在说“我现在已经走到尽头了。操作完成后继续。”但如果你不想阻塞线程,你能做什么呢?非常简单,你可以在那里直接返回。你将自行继续异步操作。而且,如果你想让你的调用者知道你的异步方法何时完成,你可以将令牌传回调用者,他们可以选择阻塞或者(更可能的是)使用另一个延续。通常,你最终会得到一整个堆栈的异步方法相互调用;这几乎就像你进入了一段代码的“异步模式”。语言中没有规定必须这样做,但消耗异步操作的同一段代码也表现出异步操作的行为,这确实鼓励了这样做。

5.2.2. 同步上下文

之前我提到,UI 代码的黄金法则之一是,除非你在正确的线程上,否则你不应该更新用户界面。在列表 5.1 中,该列表异步检查网页长度,你需要确保 await 表达式之后的代码在 UI 线程上执行。异步函数通过使用 SynchronizationContext 返回正确的线程,这是一个自 .NET 2.0 以来就存在的类,也被其他组件如 BackgroundWorker 使用。SynchronizationContext 通用化在适当线程上执行委托的想法;它的 Post(异步)和 Send(同步)消息类似于 Windows Forms 中的 Control.BeginInvokeControl.Invoke

不同的执行环境使用不同的上下文;例如,一个上下文可能允许线程池中的任何线程执行它给出的操作。在同步上下文中存在更多的上下文信息,但如果你开始想知道异步方法是如何精确地在你想要它们执行的地方执行的,那么你需要关注的是同步上下文。

更多关于 SynchronizationContext 的信息,请阅读 Stephen Cleary 在 MSDN 杂志上关于该主题的文章(mng.bz/5cDw)。特别是,如果你是 ASP.NET 开发者,请特别注意;ASP.NET 上下文很容易使粗心的开发者在其看似良好的代码中创建死锁。对于 ASP.NET Core,情况略有不同,Stephen 另有一篇博客文章涵盖了这个话题:mng.bz/5YrO

示例中 Task.Wait() 和 Task.Result 的使用

我在一些示例代码中使用了 Task.Wait()Task.Result,因为这会导致简单的示例。在控制台应用程序中这样做通常是安全的,因为在这种情况下没有同步上下文;异步方法的继续执行总是在线程池中。

在实际应用中,使用这些方法时应格外小心。它们都会阻塞直到完成,这意味着如果你从需要在该线程上执行继续操作的线程中调用它们,你很容易使应用程序发生死锁。

理论部分已经讲完,让我们更仔细地看看异步方法的具体细节。异步匿名函数符合相同的思维模型,但讨论异步方法要容易得多。

5.2.3. 模拟异步方法

我发现将异步方法想象成 图 5.1 中展示的那样是有用的。

图 5.1. 模拟异步边界

图片

这里你有三个代码块(方法)和两种边界类型(方法返回类型)。作为一个简单的例子,在我们的页面长度获取应用程序的控制台版本中,你可能会有以下代码。

列表 5.2. 在异步方法中检索页面长度
static readonly HttpClient client = new HttpClient();

static async Task<int> GetPageLengthAsync(string url)
{
    Task<string> fetchTextTask = client.GetStringAsync(url);
    int length = (await fetchTextTask).Length;
    return length;
}

static void PrintPageLength()
{
    Task<int> lengthTask =
        GetPageLengthAsync("http://csharpindepth.com");
    Console.WriteLine(lengthTask.Result);
}

图 5.2 展示了 列表 5.2 中的具体细节如何映射到 图 5.1 中的概念。

图 5.2. 将 列表 5.2 的细节应用于 图 5.1 中显示的通用模式

图片

你主要对 GetPageLengthAsync 方法感兴趣,但我包括了 PrintPageLength,这样你可以看到这些方法是如何交互的。特别是,你绝对需要了解方法边界处的有效类型。我将在本章中以各种形式重复这个图。

你终于准备好查看编写异步方法及其行为方式了。这里有很多内容需要介绍,因为你可以做的事情和当你这样做时会发生的事情在很大程度上是融合在一起的。

只有两处新的语法:async是在声明异步方法时使用的修饰符,而await运算符用于消费异步操作。但是,随着信息在程序各部分之间传递的方式变得复杂,尤其是在你必须考虑出错时,这会变得特别复杂。我已经尝试将不同的方面分开,但你的代码将一次性处理所有这些。如果你在阅读这一节时发现自己正在问“但是……怎么办?”的问题,请继续阅读;很可能你的问题很快就会得到解答。

接下来的三个部分将探讨异步方法在三个阶段的表现:

  • 声明异步方法

  • 使用await运算符异步等待操作完成

  • 方法完成时返回值

图 5.3 展示了这些章节如何融入我们的概念模型。

图 5.3. 展示 5.3 节、5.4 节和 5.5 节如何融入异步概念模型

让我们从方法声明本身开始;这是最容易的部分。

5.3. 异步方法声明

异步方法声明的语法与任何其他方法的语法完全相同,只是它必须包含async上下文关键字。这个关键字可以出现在返回类型之前的任何位置。所有这些都是有效的:

public static async Task<int> FooAsync() { ... }
public async static Task<int> FooAsync() { ... }
async public Task<int> FooAsync() { ... }
public async virtual Task<int> FooAsync() { ... }

我的偏好是将async修饰符直接放在返回类型之前,但没有任何理由你不应该制定自己的约定。像往常一样,与你的团队讨论,并尽量在单个代码库中保持一致性。

现在,async上下文关键字有一个小小的秘密:语言设计者其实根本不需要包含它。就像编译器在方法中使用yield returnyield break时进入一种迭代器块模式一样,编译器本可以检测到方法中await的使用,并利用这一点进入异步模式。但我很高兴async是必需的,因为它使得使用异步方法编写的代码更容易阅读。它立即设定了你的期望,因此你会主动寻找await表达式,你可以主动寻找任何应该转换为异步调用和await表达式的阻塞调用。

虽然async修饰符在生成的代码中没有表示,但这很重要。从调用方法的角度来看,它是一个正常的方法,碰巧返回一个任务。你可以将现有的方法(具有适当的签名)更改为使用 async,或者你可以朝相反的方向进行;这是源代码和二进制文件兼容的更改。由于这是方法实现的一个细节,因此你不能使用 async 声明抽象方法或接口中的方法。完全可能存在一个指定方法返回类型为 Task<int> 的接口;该接口的一个实现可以使用 async/await,而另一个实现则使用常规方法。

5.3.1. 异步方法的返回类型

调用者与异步方法之间的通信实际上是以返回的值来进行的。在 C# 5 中,异步函数限制为以下返回类型:

  • void

  • Task

  • Task<TResult>(对于某种类型 TResult,该类型本身也可以是类型参数)

在 C# 7 中,这个列表扩展到包括 任务类型。你将在 5.8 节 和 第六章 中再次回到这些内容。

.NET 4 的 TaskTask<TResult> 类型都表示一个可能尚未完成的操作;Task<TResult> 继承自 Task。这两个之间的区别在于,Task<TResult> 表示返回类型为 TResult 的操作,而 Task 不必产生任何结果。尽管如此,返回 Task 仍然很有用,因为它允许调用代码将其自己的延续附加到返回的任务上,检测任务何时失败或完成,等等。在某些情况下,你可以将 Task 视为类似于 Task<void> 类型,如果这样的类型是有效的话。

注意

在这一点上,F# 开发者可以理直气壮地夸耀 Unit 类型,它与 void 类似,但是一个真正的类型。TaskTask<TResult> 之间的差异可能会令人沮丧。如果你可以使用 void 作为类型参数,你就不需要 Action 家族的委托了;例如,Action<string> 等同于 Func<string, void>

异步方法能够返回 void 是为了与事件处理器兼容。例如,你可能有一个这样的 UI 按钮点击处理器:

private async void LoadStockPrice(object sender, EventArgs e)
{
    string ticker = tickerInput.Text;
    decimal price = await stockPriceService.FetchPriceAsync(ticker);
    priceDisplay.Text = price.ToString("c");
}

这是一个异步方法,但调用代码(按钮的 OnClick 方法或引发事件的任何框架代码片段)并不关心。它不需要知道你何时完成事件处理——何时加载股票价格并更新用户界面。它只是调用它被提供的那个事件处理器。编译器生成的代码最终会变成一个状态机,将一个延续附加到 FetchPriceAsync 返回的任何内容上,这是一个实现细节。

你可以使用前面的方法订阅事件,就像它是任何其他事件处理器一样:

loadStockPriceButton.Click += LoadStockPrice;

最终(是的,我故意这样做的),对调用代码来说,它只是一个普通的方法。它有一个void返回类型和类型为objectEventArgs的参数,这使得它适合作为EventHandler委托实例的动作。

警告

事件订阅几乎是唯一推荐从异步方法返回void的情况。在其他任何不需要返回特定值的情况下,最好将方法声明为返回Task。这样,调用者就能await操作完成,检测失败等。

尽管异步方法的返回类型相当严格,但大多数其他方面都很正常:异步方法可以是泛型、静态或非静态,并指定任何常规访问修饰符。然而,对你可以使用的参数存在限制。

5.3.2. 异步方法中的参数

在异步方法中,没有任何参数可以使用outref修饰符。这很有道理,因为那些修饰符是用来将信息传回调用代码的;异步方法可能还没有运行,当控制权返回给调用者时,引用参数的值可能还没有被设置。实际上,情况可能比这还要复杂:想象一下将局部变量作为ref参数的参数传递;异步方法最终可能会在调用方法已经完成后尝试设置该变量。尝试这样做并没有太多意义,因此编译器禁止这样做。此外,指针类型不能用作异步方法参数类型。

在你声明了方法之后,你可以开始编写方法体并await其他异步操作。让我们看看你可以在哪里以及如何使用await表达式。

5.4. await表达式

声明带有async修饰符的方法的整个目的是在该方法中使用await表达式。关于方法的其他方面看起来都很正常:你可以使用各种控制流——循环、异常、using语句,等等。那么,你可以在哪里使用await表达式,它做什么呢?

await表达式的语法很简单:它是await运算符后跟另一个产生值的表达式。你可以await方法调用的结果、变量、属性。它不必是一个简单的表达式。你可以将方法调用链接起来并await其结果:

int result = await foo.Bar().Baz();

await运算符的优先级低于点运算符,因此以下代码与以下代码等价:

int result = await (foo.Bar().Baz());

然而,对可以await的表达式有限制。它们必须是awaitable的,这就是awaitable模式的作用所在。

5.4.1. awaitable模式

awaitable pattern 用于确定可以使用 await 操作符的类型。图 5.4 是一个提醒,我在谈论第二个边界从 图 5.1:异步方法如何与另一个异步操作交互。awaitable pattern 是一种将我们所说的异步操作进行编码的方式。

图 5.4。awaitable pattern 允许异步方法异步等待操作完成

图片

您可能期望这将以接口的形式表达,就像编译器要求类型实现 IDisposable 以支持 using 语句一样。相反,它基于一个模式。想象一下,您有一个类型为 T 的表达式,您想等待它。编译器执行以下检查:

  • T 必须有一个无参数的 GetAwaiter() 实例方法,或者必须有一个接受单个参数类型为 T 的扩展方法。GetAwaiter 方法不能是 void。该方法的返回类型被称为 awaiter type

  • awaiter 类型必须实现 System.Runtime.INotifyCompletion 接口。该接口有一个方法:void OnCompleted (Action)

  • awaiter 类型必须有一个可读的实例属性 IsCompleted,其类型为 bool

  • awaiter 类型必须有一个非泛型的无参数实例方法 GetResult

  • 列出的成员不必是公共的,但它们需要从您尝试从其中获取值的异步方法中可访问。(因此,您可能可以从某些代码中等待特定类型的值,但不能从所有代码中等待。但这非常不寻常。)

如果 T 通过了所有这些检查,恭喜您——您可以从类型 T 等待一个值!但是,编译器还需要更多信息来确定 await 表达式的类型。这由 awaiter 类型的 GetResult 方法的返回类型确定。如果它是一个 void 方法,那么 await 表达式被分类为无结果的表达式,就像直接调用 void 方法的表达式一样。否则,await 表达式被分类为产生与 GetResult 返回类型相同类型的值。

例如,让我们考虑静态 Task.Yield() 方法。与其他大多数 Task 上的方法不同,Yield() 方法本身不返回任务;它返回一个 YieldAwaitable。以下是涉及类型的简化版本:

public class Task
{
    public static YieldAwaitable Yield();
}

public struct YieldAwaitable
{
    public YieldAwaiter GetAwaiter();

    public struct YieldAwaiter : INotifyCompletion
    {
        public bool IsCompleted { get; }
        public void OnCompleted(Action continuation);
        public void GetResult();
    }
}

如您所见,YieldAwaitable 遵循之前描述的 awaitable pattern。因此,这是有效的:

public async Task ValidPrintYieldPrint()
{
    Console.WriteLine("Before yielding");
    await Task.Yield();                      *1*
    Console.WriteLine("After yielding");
}
  • 1 有效

但以下是不合法的,因为它试图使用等待 YieldAwaitable 的结果:

public async Task InvalidPrintYieldPrint()
{
    Console.WriteLine("Before yielding");
    var result = await Task.Yield();         *1*
    Console.WriteLine("After yielding");
}
  • 1 无效;这个 await 表达式不会产生值。

InvalidPrintYieldPrint 的中间行无效,原因与直接编写此代码无效的原因完全相同:

var result = Console.WriteLine("WriteLine is a void method");

没有产生任何结果,因此你不能将其分配给一个变量。

令人惊讶的是,Task的 awaiter 类型有一个返回void类型的GetResult方法,而Task<TResult>的 awaiter 类型有一个返回TResultGetResult方法。

扩展方法的历史重要性

GetAwaiter可以是一个扩展方法这一事实,其历史重要性大于当代重要性。C# 5 与.NET 4.5 在同一时间段发布,而.NET 4.5 引入了GetAwaiter方法到TaskTask<TResult>中。如果GetAwaiter必须是一个真正的实例方法,那么这将会使那些依赖于.NET 4.0 的开发者陷入困境。但是,由于扩展方法的支持,TaskTask<TResult>可以通过使用 NuGet 包来分别提供这些扩展方法,从而实现 async/await 功能。这也意味着社区可以在不测试.NET 4.5 预发布版本的情况下测试 C# 5 编译器的预发布版本。

在针对现代框架的代码中,其中所有相关的GetAwaiter方法都已经存在,你很少需要使用通过扩展方法使现有类型可 await 的能力。

你可以在第 5.6 节中看到更多关于 awaitable 模式中成员如何使用的详细信息,当你考虑异步方法的执行流程时。尽管如此,你对 await 表达式的工作还没有完成;存在一些限制。

5.4.2. await 表达式的限制

yield return一样,限制限制了你可以使用 await 表达式的位置。最明显的限制是,你只能在异步方法和异步匿名函数(你将在第 5.7 节中看到)中使用它们。即使在异步方法中,你也不能在匿名函数中使用await运算符,除非那个匿名函数也是异步的。

await运算符也不允许在不安全上下文中使用。这并不意味着你无法在异步方法中使用不安全代码;只是你不能在那个部分使用await运算符。以下列表显示了一个虚构的例子,其中使用指针遍历字符串中的字符以找到该字符串中 UTF-16 代码单元的总数。它并没有做任何真正有用的事情,但它演示了在异步方法中使用不安全上下文。

列表 5.3. 在异步方法中使用不安全代码
static async Task DelayWithResultOfUnsafeCode(string text)
{
    int total = 0;
    unsafe                                               *1*
    {
        fixed (char* textPointer = text)
        {
            char* p = textPointer;
            while (*p != 0)
            {
                total += *p;
                p++;
            }
        }
    }
    Console.WriteLine("Delaying for " + total + "ms");
    await Task.Delay(total);                            *2*
    Console.WriteLine("Delay complete");
}
  • 1 在异步方法中有一个不安全上下文是可以的。

  • 2 但是,await 表达式不能在其中使用。

你也不能在 lock 中使用 await 操作符。如果你发现自己想在异步操作完成时保持锁定,你应该重新设计你的代码。不要通过手动调用 Monitor.TryEnterMonitor.Exit 并使用 try/finally 块来规避编译器的限制;改变你的代码,以便在操作期间不需要锁定。如果这种情况真的很尴尬,考虑使用 SemaphoreSlim,它具有 WaitAsync 方法。

lock 语句使用的监视器只能由最初获取它的同一线程释放,这与在 await 表达式之前执行代码的线程将不同于之后执行代码的线程的明确可能性相矛盾。即使使用的是同一线程(例如,因为你处于 GUI 同步上下文),在异步操作的开始和结束之间,同一线程上可能已经执行了其他代码,并且那其他代码能够进入同一监视器的 lock 语句,这几乎肯定不是你想要的。基本上,lock 语句和异步操作不太相容。

在以下情况下,await 操作符在 C# 5 中无效,但从 C# 6 开始是有效的:

  • 任何带有 catch 块的 try

  • 任何 catch

  • 任何 finally

在只有一个 finally 块的 try 块中使用 await 操作符一直是可以的,这意味着在 using 语句中使用 await 一直是可以的。在 C# 5 发布之前,C# 设计团队没有想出如何在之前列出的上下文中安全可靠地包含 await 表达式。这偶尔会不方便,团队在实现 C# 6 时找到了构建适当状态机的方法,因此那里的限制被取消了。

你现在知道了如何声明一个异步方法以及如何在其中使用 await 操作符。当你完成工作后怎么办?让我们看看如何将值返回到调用代码。

5.5. 返回值的包装

我们已经探讨了如何声明调用代码和异步方法之间的边界以及如何在异步方法内等待任何异步操作。现在让我们看看如何使用返回语句来实现将值返回到调用代码的第一个边界;参见图 5.5。

图 5.5. 从异步方法返回结果到其调用者

图片

你已经看到了一个返回数据的例子,但让我们再次看看它,这次只关注返回方面。这里是列表 5.2 的相关部分:

static async Task<int> GetPageLengthAsync(string url)
{
    Task<string> fetchTextTask = client.GetStringAsync(url);
    int length = (await fetchTextTask).Length;
    return length;
}

你可以看到length的类型是int,但方法的返回类型是Task<int>。生成的代码会为你处理包装,所以调用者会得到一个Task<int>,当它完成时将包含方法返回的值。返回非泛型Task的方法就像一个普通的void方法:它根本不需要返回语句,并且任何返回语句都必须是简单的return,而不是尝试指定一个值。在任何情况下,任务也会传播在异步方法中抛出的任何异常。(你将在第 5.6.5 节中更详细地了解异常。)

希望到现在,你应该对为什么这种包装是必要的有一个很好的直觉;方法几乎肯定会在到达return语句之前返回给调用者,并且它必须以某种方式将信息传播给那个调用者。Task<TResult>(在计算机科学中通常被称为future)是在未来某个时间点返回一个值或异常的承诺。

与正常的执行流程一样,如果return语句出现在与一个相关联的finally块(包括所有这些因为using语句而发生)的作用域内,用于计算返回值的表达式将立即被评估,但它不会成为任务的结果,直到所有清理工作都完成。如果finally块抛出异常,你不会得到一个既成功又失败的任务;整个操作将失败。

为了重申我之前提到的一个观点,正是自动包装和解除包装的组合使得异步功能与组合工作得如此之好;异步方法可以轻松消费异步方法的输出,因此你可以从许多小块构建复杂的系统。你可以将这看作是类似于 LINQ:你在 LINQ 中对序列的每个元素执行操作,包装和解除包装意味着你可以将这些操作应用于序列,并返回序列。在异步世界中,你很少需要显式处理任务;相反,你await任务来消费它,并且作为异步方法机制的一部分自动生成一个结果任务。现在你了解了异步方法的样子,更容易给出示例来展示执行流程。

5.6. 异步方法流程

你可以从多个层面来思考 async/await:

  • 你可以简单地期望 await 会做你想做的事情,而不必精确地定义这意味着什么。

  • 你可以就代码将如何执行进行推理,从何时以及在哪条线程上发生什么的角度来看,但无需理解它是如何实现的。

  • 你可以深入了解使这一切发生的基础设施。

到目前为止,我们主要是在第一层思考,偶尔会深入到第二层。本节重点讨论第二层,实际上是在查看语言所承诺的内容。我们将把第三点留到下一章,在那里你会看到编译器在幕后做了什么。(即使那样,你仍然可以更进一步;这本书不讨论低于 IL 层的内容。我们不会涉及操作系统或对异步和线程的硬件支持。)

在你开发的大部分时间里,根据你的上下文在第一和第二层之间切换是完全可以的。除非我正在编写需要协调多个操作的代码,否则我很少需要深入到第二层细节。大多数时候,我只是让事情按自己的方式运行。重要的是,当你需要时,你可以思考细节。

5.6.1. 什么是 await 以及何时使用?

让我们先简化一下事情。有时await会与链式方法调用的结果或偶尔的属性一起使用,如下所示:

string pageText = await new HttpClient().GetStringAsync(url);

这使得await似乎可以改变整个表达式的意义。实际上,await始终只作用于单个值。前面的行等价于以下行:

Task<string> task = new HttpClient().GetStringAsync(url);
string pageText = await task;

同样,await 表达式的结果可以用作方法参数或另一个表达式的一部分。再次强调,如果你能从其他所有内容中分离出await特定的部分,那就更好了。

假设你有两个方法,GetHourlyRateAsync()GetHoursWorkedAsync(),分别返回Task<decimal>Task<int>。你可能会有这样一个复杂的语句:

AddPayment(await employee.GetHourlyRateAsync() *
           await timeSheet.GetHoursWorkedAsync(employee.Id));

C#表达式评估的正常规则适用,并且*运算符的左操作数必须在右操作数评估之前完全评估,因此前面的语句可以展开如下:

Task<decimal> hourlyRateTask = employee.GetHourlyRateAsync();
decimal hourlyRate = await hourlyRateTask;
Task<int> hoursWorkedTask = timeSheet.GetHoursWorkedAsync(employee.Id);
int hoursWorked = await hoursWorkedTask;
AddPayment(hourlyRate * hoursWorked);

你编写代码的方式是另一回事。如果你发现单行版本更容易阅读,那很好;如果你想全部展开,你将得到更多的代码,但这可能更容易理解和调试。你可以决定使用一种看起来相似但实际上并不完全相同的形式:

Task<decimal> hourlyRateTask = employee.GetHourlyRateAsync();
Task<int> hoursWorkedTask = timeSheet.GetHoursWorkedAsync(employee.Id);
AddPayment(await hourlyRateTask * await hoursWorkedTask);

我发现这是最易读的形式,并且它也有潜在的性能优势。你将在第 5.10.2 节中再次回到这个例子。

本节的关键要点是您需要能够弄清楚正在等待什么以及何时等待。在这种情况下,从GetHourlyRateAsyncGetHoursWorkedAsync返回的任务正在被等待。在所有情况下,它们都是在执行AddPayment调用之前被等待的,这是有意义的,因为您需要中间结果,以便可以将它们相乘并将乘积的结果作为参数传递。如果这是使用同步调用,所有这些都会很明显;我的目标是揭开等待部分的神秘面纱。现在您知道了如何将复杂代码简化为等待的值以及何时等待它,您可以继续了解在等待部分本身发生的事情。

5.6.2. await表达式的评估

当执行到达await表达式时,你有两种可能性:要么你等待的异步操作已经完成,要么还没有。如果操作已经完成,执行流程很简单:它继续进行。如果操作失败并捕获异常来表示该失败,则抛出异常。否则,从操作中获取任何结果(例如,从Task<string>中提取string)并继续程序的下一部分。所有这些都是在没有任何线程上下文切换或将延续附加到任何东西的情况下完成的。

在更有趣的场景中,异步操作仍在进行中。在这种情况下,方法异步等待操作完成,然后在适当的环境中继续。这种异步等待实际上意味着方法根本就没有执行。将延续附加到异步操作上,方法返回。异步基础设施确保延续在正确的线程上执行:通常是线程池线程(在这种情况下,使用哪个线程无关紧要)或 UI 线程,这在有意义的场景中。这取决于同步上下文(在第 5.2.2 节中讨论),也可以使用Task.ConfigureAwait来控制,我们将在第 5.10.1 节中讨论。

返回与完成

描述异步行为最难的部分可能是谈论方法何时返回(无论是返回给原始调用者还是返回给调用延续的任何东西)以及何时完成。与大多数方法不同,异步方法可以多次返回——实际上,当它暂时没有更多工作可做时。

回到我们之前的比萨饼配送类比,如果你有一个EatPizzaAsync方法,它涉及到调用比萨饼公司下单、遇到配送员、等待比萨饼稍微冷却一下,然后最后吃掉它,该方法可能会在第一部分的每个部分之后返回,但只有在比萨饼被吃掉之后才会完成。

从开发者的角度来看,这感觉就像方法在异步操作完成时被暂停了。编译器确保方法内部使用的所有局部变量在继续之前与它们具有相同的值,就像迭代器块一样。

让我们通过一个小型的控制台应用程序的例子来看这两个情况,该应用程序使用单个异步方法等待两个任务。Task.FromResult总是返回一个已完成的任务,而Task.Delay返回一个在指定延迟后完成的任务。

列表 5.4. 等待完成和未完成的任务
static void Main()
{
    Task task = DemoCompletedAsync();         *1*
    Console.WriteLine("Method returned");
    task.Wait();                              *2*
    Console.WriteLine("Task completed");
}

static async Task DemoCompletedAsync()
{
    Console.WriteLine("Before first await");
    await Task.FromResult(10);                *3*
    Console.WriteLine("Between awaits");
    await Task.Delay(1000);                   *4*
    Console.WriteLine("After second await");
}
  • 1 调用异步方法

  • 2 等待任务完成

  • 3 等待一个完成的任务

  • 4 等待一个未完成的任务

列表 5.4 的输出如下:

Before first await
Between awaits
Method returned
After second await
Task completed

排序的重要方面如下:

  • 当 await 完成的任务时,异步方法不会返回;方法继续以同步方式执行。这就是为什么你会看到前两行之间没有任何内容。

  • 当 await 延迟任务时,异步方法确实会返回。这就是为什么在Main方法中打印出第三行Method returned。异步方法可以知道它等待的操作(延迟任务)尚未完成,因此返回以避免阻塞。

  • 从异步方法返回的任务只有在方法完成时才会完成。这就是为什么在After second await之后打印出Task completed

我尝试在图 5.6 中捕捉 await 表达式的流程,尽管经典的流程图并不是真正考虑到异步行为。

图 5.6. await 处理的用户可见模型

你可以将虚线看作是流向流程图顶部的另一条线作为替代。请注意,我假设 await 表达式的目标是有一个结果。如果你正在等待一个普通的Task或类似的东西,获取结果实际上意味着检查操作是否成功完成。

值得停下来简要思考一下从异步方法返回意味着什么。再次强调,有两种可能性存在:

  • 这是你必须等待的第一个 await 表达式,所以你的堆栈中仍然有原始调用者。(记住,在你真正需要等待之前,方法以同步方式执行。)

  • 你已经等待了其他尚未完成的事情,所以你在一个由某物调用的继续中。你的调用堆栈几乎肯定会与你在第一次进入方法时看到的堆栈有显著不同。

在第一种情况下,你通常会返回一个TaskTask<TResult>给调用者。显然,你还没有方法的结果;即使没有要返回的值,你也不知道方法是否会无异常地完成。因此,你将返回的任务必须是一个未完成的任务。

在后一种情况下,调用你的东西取决于你的上下文。例如,在一个 Windows Forms UI 中,如果你在 UI 线程上启动了你的异步方法并且没有故意切换到其他线程,整个方法就会在 UI 线程上执行。在方法的前一部分,你会在某个事件处理器或其他地方——无论是什么触发了异步方法。然而,稍后,你将直接由 Windows Forms 内部机制(通常称为消息泵)调用,就像你使用Control.BeginInvoke(continuation)一样。在这里,调用代码——无论是 Windows Forms 消息泵、线程池机制的一部分还是其他东西——都不关心你的任务。

作为提醒,直到你遇到第一个真正的异步await表达式,方法完全以同步方式执行。调用异步方法并不像在单独的线程中启动一个新任务,确保你始终编写快速返回的异步方法是你的责任。诚然,这取决于你编写代码的上下文,但你应该通常避免在异步方法中执行长时间运行的阻塞工作。将其分离到另一个方法中,你可以为它创建一个Task

我想简要回顾一下你等待的值已经完成的情况。你可能想知道为什么一个立即完成的操作最初要以异步方式表示。这有点像在 LINQ 中对序列调用Count()方法:在一般情况下,你可能需要遍历序列中的每个项目,但在某些情况下(例如,当序列最终是一个List<T>时),可以有一个简单的优化。有一个单一的抽象来覆盖这两种情况是有用的,但不需要支付执行时间的代价。

作为异步 API 案例中的现实世界示例,考虑从与磁盘上的文件关联的流中异步读取。你可能已经从磁盘中检索了你想要读取的所有数据并将其加载到内存中,可能是作为之前的ReadAsync调用请求的一部分,因此立即使用它而不需要通过所有其他异步机制是有意义的。作为另一个例子,你可能在你的架构中有一个缓存;如果你有一个异步操作,它可以从内存缓存(返回一个完成的任务)或击中存储(返回一个非完成的任务,当存储调用完成时完成),那么这将是透明的。现在你了解了流程的基本知识,你可以看到可等待模式如何融入拼图。

5.6.3. 可等待模式成员的使用

在第 5.4.1 节中,我描述了类型必须实现的可等待模式,以便你能够等待该类型的表达式。你现在可以将模式的各个部分映射到你试图实现的行为。图 5.7与图 5.6 相同,但略有扩展并重新措辞,以使用可等待模式而不是一般描述。

图 5.7. 通过可等待模式处理 await

当它写成这样时,你可能想知道所有这些喧嚣是什么意思;为什么语言支持本身就有价值?尽管附加一个延续可能比你想象的要复杂,但。在简单的情况下,当控制流完全线性(做一些工作,等待某事,做更多的工作,等待其他事情)时,很容易想象延续可能看起来像 lambda 表达式,即使它可能并不愉快。然而,一旦代码包含循环或条件,并且你希望将代码保持在单个方法中,生活就会变得复杂得多。正是在这里,async/await 的好处真正显现出来。尽管你可以争论编译器只是在应用语法糖,但手动创建延续和让编译器为你这样做之间的可读性差异是巨大的。

到目前为止,我描述了所有我们等待的值都成功完成的愉快路径。失败会发生什么呢?

5.6.4. 异常展开

在 .NET 中,表示失败的传统方式是通过异常。就像向调用者返回一个值一样,异常处理需要语言提供额外的支持。当你等待一个失败的异步操作时,它可能很久以前在完全不同的线程上失败了。常规的同步方式向上传播异常并不自然。相反,async/await 基础设施采取了一些步骤,使处理异步失败的经历尽可能类似于同步失败。如果你认为失败是另一种结果,那么异常和返回值以类似方式处理是有意义的。你将在第 5.6.5 节中看到异常是如何从一个异步方法中传播出来的,但在那之前,你将看到当你等待一个失败的操作时会发生什么。

就像等待器的 GetResult() 方法旨在获取返回值(如果有的话)一样,它也负责将异步操作中发生的任何异常传播回方法。这并不像听起来那么简单,因为在异步世界中,一个 Task 可以代表多个操作,从而导致多个失败。尽管有其他可等待模式实现,但具体考虑 TaskTask<TResult> 是有价值的,因为它们是你可能要等待的大多数时间所使用的类型。

TaskTask<TResult> 以多种方式指示失败:

  • 当异步操作失败时(IsFaulted 返回 true),任务的状态变为 Faulted

  • Exception 属性返回一个包含所有(可能多个)导致任务失败的异常的 AggregateException,如果任务没有出错则返回 null

  • 如果任务最终处于错误状态,Wait() 方法将抛出一个 AggregateException

  • Task<TResult>Result 属性(它也等待完成)同样会抛出一个 AggregateException

此外,任务支持通过 CancellationTokenSourceCancellationToken 实现取消操作的概念。如果一个任务被取消,Wait() 方法以及 Result 属性将抛出一个包含 OperationCanceledException(实际上是一个从 OperationCanceledException 派生的 TaskCanceledException)的 AggregateException,但状态变为 Canceled 而不是 Faulted

当你等待一个任务时,如果它处于错误或取消状态,将抛出一个异常,但不是 AggregateException。相反,为了方便(在大多数情况下),将抛出 AggregateException 中的第一个异常。在大多数情况下,这正是你想要的。这与异步功能的精神相符,允许你编写看起来与同步代码非常相似的异步代码。例如,考虑以下列表,它尝试一次获取一个 URL,直到其中一个成功或你尝试的 URL 已用尽。

列表 5.5. 在获取网页时捕获异常
async Task<string> FetchFirstSuccessfulAsync(IEnumerable<string> urls)
{
    var client = new HttpClient();
    foreach (string url in urls)
    {
        try
        {
            return await client.GetStringAsync(url);    *1*
        }
        catch (HttpRequestException exception)          *2*
        {
            Console.WriteLine("Failed to fetch {0}: {1}",
                url, exception.Message);
        }
    }
    throw new HttpRequestException("No URLs succeeded");
}
  • 1 如果成功则返回字符串

  • 2 捕获并显示失败,否则

目前,忽略你丢失了所有原始异常以及你正在按顺序获取所有页面的情况。我试图说明的是,在这里捕获 HttpRequestException 是你所期望的;你正在尝试使用 HttpClient 进行异步操作,如果出现错误,它将抛出 HttpRequestException。你想要捕获并处理它,对吧?这确实 感觉 是你想要做的——但是 GetStringAsync() 调用不能因为服务器超时等错误抛出 HttpRequestException,因为该方法只 开始 操作。在它发现错误时,方法已经返回了。它所能做的就是返回一个最终失败的包含 HttpRequestException 的任务。如果你简单地调用任务的 Wait(),将抛出一个包含其内部的 HttpRequestExceptionAggregateException。任务等待器的 GetResult 方法会抛出 HttpRequestException,并且它会被 catch 块正常捕获。

当然,这可能会丢失信息。如果一个有故障的任务中有多个异常,GetResult 只能抛出一个,并且它任意地使用了第一个。你可能想要重写前面的代码,以便在失败时,调用者可以捕获一个 AggregateException 并检查失败的 所有 原因。重要的是,一些框架方法就是这样做的。例如,Task.WhenAll() 是一个异步等待多个任务(在方法调用中指定)完成的异步方法。如果其中任何一个失败,结果将是一个包含所有有故障任务异常的失败。但是,如果你只等待 WhenAll() 返回的任务,你将只会看到第一个异常。通常,如果你想详细检查异常,最简单的方法是使用 Task.Exception 对每个原始任务进行操作。

总结来说,你知道在等待时,awaiter 类型的 GetResult() 方法用于传播成功的结果和异常。在 TaskTask<TResult> 的情况下,GetResult() 解包失败任务的 AggregateException 以抛出其内部异常的第一个。这就解释了异步方法是如何消耗另一个异步操作的——但是它是如何将自身的返回结果传播给调用代码的呢?

5.6.5. 方法完成

让我们回顾几个要点:

  • 异步方法通常在完成之前就返回了。

  • 一旦遇到一个 await 表达式,其中等待的操作尚未完成,它就会立即返回。

  • 假设它不是一个 void 方法(在这种情况下,调用者没有简单的方法来了解发生了什么),方法返回的值将是一个某种类型的任务:在 C# 7 之前是 TaskTask<TResult>,从 C# 7 开始有自定义任务类型的选项(这将在 第 5.8 节 中解释)。为了简单起见,让我们假设它是一个 Task<TResult>

  • 该任务负责指示异步方法何时以及如何完成。如果方法正常完成,任务状态将变为RanToCompletion,并且Result属性包含返回值。如果方法体抛出异常,任务状态将变为Faulted(或根据异常变为Canceled),异常将被包装到任务的Exception属性中的AggregateException中。

  • 当任务状态变为这些终端状态之一时,与其关联的任何延续(例如,在等待任务的任何异步方法中的代码)都可以被安排运行。

是的,这听起来像是重复

您可能想知道是否不小心跳回了几页并读了两遍。您在等待某事时不是刚刚看到了同样的想法吗?

绝对如此。我所做的只是展示异步方法如何指示其完成情况,而不是展示 await 表达式如何检查其他事物的完成情况。如果这些感觉不同,那就很奇怪,因为通常异步方法会串联在一起:您在一个异步方法中等待的值可能是由另一个异步方法返回的值。用更华丽的词来说,异步操作易于组合

所有这些工作都是由编译器在大量基础设施的帮助下为您完成的。您将在下一章中看到其中的一些细节(尽管不是每一个角落和缝隙;即使是我也有限制)。本章更多地关注您可以在代码中依赖的行为。

成功返回

成功情况是最简单的情况:如果方法声明为返回Task<TResult>,则返回语句必须提供类型为T(或可以转换为TResult的类型)的值,并且异步基础设施将此值传播到任务中。

如果返回类型是Taskvoid,任何返回语句都必须是return形式,不带值,或者允许执行到达方法的末尾,就像非异步void方法一样。在这两种情况下,都没有要传播的值,但任务的状态会相应地改变。

懒异常和参数验证

关于异常的最重要的一点是,异步方法永远不会直接抛出异常。即使方法体首先做的事情是抛出异常,它也会返回一个故障任务。(在这种情况下,任务将立即故障。)这在参数验证方面有点麻烦。假设您想在异步方法中验证参数没有 null 值之后做一些工作。如果您像在正常同步代码中那样验证参数,调用者直到任务被等待时才会发现问题。以下列表提供了一个示例。

列表 5.6. 异步方法中的断言参数验证错误
static async Task MainAsync()
{
    Task<int> task = ComputeLengthAsync(null);              *1*
    Console.WriteLine("Fetched the task");
    int length = await task;                                *2*
    Console.WriteLine("Length: {0}", length);
}

static async Task<int> ComputeLengthAsync(string text)
{
    if (text == null)
    {
        throw new ArgumentNullException("text");            *3*
    }
    await Task.Delay(500);                                  *4*
    return text.Length;
}
  • 1 故意传递一个错误的参数

  • 2 等待结果

  • 3 尽早抛出异常

  • 4 模拟真实异步工作

输出在失败之前显示了 Fetched the task。由于在验证之前没有 await 表达式,异常在输出之前就已经同步抛出了,但调用代码直到它等待返回的任务时才会看到它。某些参数验证可以合理地在前端进行,而不会花费很长时间(或产生其他异步操作)。在这些情况下,如果失败能够立即报告,在系统陷入更大的麻烦之前,那就更好了。例如,如果你向 HttpClient.GetStringAsync 传递一个 null 引用,它将立即抛出异常。

注意

如果你曾经编写过需要验证其参数的迭代器方法,这可能会听起来很熟悉。它并不完全相同,但它有类似的效果。在迭代器块中,方法中的任何代码,包括参数验证,都不会在方法返回的序列上的第一次调用 MoveNext() 之前执行。在异步情况下,参数验证立即发生,但异常直到你等待结果时才会变得明显。

你可能不会对此过于担忧。在许多情况下,急切参数验证可能被视为一个锦上添花的特性。我当然在我的代码中对此变得不那么吹毛求疵了,这是一个实用主义的问题;在大多数情况下,时间上的差异并不十分重要。但是,如果你确实想在返回任务的方法中同步抛出异常,你有三种选择,它们都是基于相同主题的变体。

策略是编写一个返回任务的非异步方法,通过验证参数然后调用一个假设参数已经被验证的单独的异步函数来实现。这三种变体在于异步函数的表示方式:

  • 你可以使用一个单独的异步方法。

  • 你可以使用异步匿名函数(你将在下一节中看到)。

  • 在 C# 7 及以上版本中,你可以使用局部异步方法。

我的偏好是最后一种;它有好处,即不会在类中引入另一个方法,而不需要创建代理。列表 5.7 展示了第一种选择,因为那不依赖于我们尚未涵盖的内容,但其他选项的代码是相似的(并且可以在书籍的可下载代码中找到)。这只是一个 ComputeLengthAsync 方法;调用代码不需要更改。

列表 5.7. 使用单独方法的急切参数验证
static Task<int> ComputeLengthAsync(string text)             *1*
{
    if (text == null)
    {
        throw new ArgumentNullException("text");
    }
    return ComputeLengthAsyncImpl(text);                     *2*
}

static async Task<int> ComputeLengthAsyncImpl(string text)
{
    await Task.Delay(500);                                   *3*
    return text.Length;
}
  • 1 非异步方法,因此异常不会被封装在任务中

  • 2 验证后,委托给实现方法。

  • 3 实现异步方法假设已验证的输入

现在当 ComputeLengthAsync 被一个 null 参数调用时,异常会同步抛出,而不是返回一个已故障的任务。

在继续讨论异步匿名函数之前,让我们简要回顾一下取消操作。我曾在几个地方提到过这一点,但更详细地考虑一下是有价值的。

处理取消

任务并行库(TPL)在 .NET 4 中引入了统一的取消模型,使用了两种类型:CancellationTokenSourceCancellationToken。想法是你可以创建一个CancellationTokenSource,然后请求一个CancellationToken,这个令牌会被传递给一个异步操作。你可以在源上执行取消操作,但这会反映到令牌上。(因此,你可以将相同的令牌传递给多个操作,而不必担心它们之间相互干扰。)有各种使用取消令牌的方法,但最符合习惯的方法是调用ThrowIfCancellationRequested,如果令牌已被取消,它将抛出OperationCanceledException,否则不做任何操作.^([1]) 同样的异常也会在同步调用(如Task.Wait)被取消时抛出。

¹

以下是一个可下载源代码中的示例。

在 C# 规范中,关于这如何与异步方法交互没有文档记录。根据规范,如果一个异步方法体抛出任何异常,该方法返回的任务将处于错误状态。错误的确切含义是特定于实现的,但现实中,如果一个异步方法抛出OperationCanceledException(或其派生类型,如TaskCanceledException),返回的任务最终将具有Canceled状态。你可以通过直接抛出OperationCanceledException而不使用任何取消令牌来证明只有异常类型决定了状态。

列表 5.8. 通过抛出OperationCanceledException创建已取消的任务
static async Task ThrowCancellationException()
{
    throw new OperationCanceledException();
}
...
Task task = ThrowCancellationException();
Console.WriteLine(task.Status);

这输出的是Canceled,而不是你根据规范可能期望的Faulted。如果你在任务上调用Wait()或请求其结果(在Task<TResult>的情况下),异常仍然会在AggregateException内部抛出,所以你不需要在使用的每个任务上显式开始检查取消。

比赛开始了吗?

你可能想知道在列表 5.8 中是否存在竞态条件。毕竟,你调用了一个异步方法,然后立即期望状态被固定。如果这段代码是启动一个新线程,那将是危险的——但事实并非如此。

记住,在第一个await表达式之前,异步方法以同步方式运行。它仍然执行结果和异常包装,但它是异步方法的事实并不一定意味着涉及更多的线程。ThrowCancellationException方法不包含任何await表达式,所以整个方法以同步方式运行;你知道它返回时会有一个结果。Visual Studio 会对任何不包含任何await表达式的异步函数发出警告,但在这个情况下,这正是你想要的。

重要的是,如果你等待一个被取消的操作,将会抛出原始的OperationCanceledException。因此,除非你采取任何直接行动,否则异步方法返回的任务也将被取消;取消会以自然的方式传播。

恭喜你走到了这一步。你现在已经覆盖了本章大部分的难点。你仍然需要学习一些功能,但它们比前面的部分容易理解得多。在下一章,当我们剖析编译器在幕后所做的工作时,难度会再次增加,但到目前为止,你可以享受相对的简单性。

5.7. 异步匿名函数

我不会在异步匿名函数上花费太多时间。正如你可能预期的那样,它们是两个功能的组合:匿名函数(lambda 表达式和匿名方法)和异步函数(可以包含await表达式的代码)。它们允许你创建代表异步操作的委托。你迄今为止关于异步方法所学的所有内容也适用于异步匿名函数。

注意

如果你有所疑问,你不能使用异步匿名函数来创建表达式树。

你可以通过在开始处添加async修饰符来创建异步匿名函数,就像创建其他匿名方法或 lambda 表达式一样简单。以下是一个示例:

Func<Task> lambda = async () => await Task.Delay(1000);
Func<Task<int>> anonMethod = async delegate()
{
    Console.WriteLine("Started");
    await Task.Delay(1000);
    Console.WriteLine("Finished");
    return 10;
};

你创建的委托必须具有适合异步方法的签名(对于 C# 5 和 6,是voidTaskTask<TResult>,C# 7 中可以选择自定义任务类型)。你可以像其他匿名函数一样捕获变量,并添加参数。异步操作不会在委托被调用之前开始,多次调用会创建多个操作。尽管委托调用会启动操作,但与对异步方法的调用一样,它不是在等待启动操作的任务,你也不必在异步匿名函数的结果上使用await。以下列表显示了一个稍微更完整(尽管仍然没有意义)的示例。

列表 5.9. 使用 lambda 表达式创建和调用异步函数
Func<int, Task<int>> function = async x =>
{
    Console.WriteLine("Starting... x={0}", x);
    await Task.Delay(x * 1000);
    Console.WriteLine("Finished... x={0}", x);
    return x * 2;
};
Task<int> first = function(5);
Task<int> second = function(3);
Console.WriteLine("First result: {0}", first.Result);
Console.WriteLine("Second result: {0}", second.Result);

我故意选择了这些值,以便第二个操作比第一个操作完成得更快。但是,因为你需要在打印结果之前等待第一个操作完成(使用 Result 属性,它会在任务完成之前阻塞——再次提醒,注意你运行此代码的位置),所以输出看起来是这样的:

Starting... x=5
Starting... x=3
Finished... x=3
Finished... x=5
First result: 10
Second result: 6

所有这些行为都完全相同,就像你将异步代码放入异步方法中一样。

我写的异步方法比异步匿名函数多得多,但它们非常有用,尤其是在 LINQ 中。你无法在 LINQ 查询表达式中使用它们,但直接调用等效方法是可以的。尽管如此,它也有一些限制:因为异步函数永远不能返回 bool,所以你不能用异步函数调用 Where,例如。我通常使用 Select 将一个类型的任务序列转换为另一个类型的任务序列。现在我将讨论我已经多次提到的功能:C# 7 引入的额外泛化级别。

5.8. C# 7 中的自定义任务类型

在 C# 5 和 C# 6 中,异步函数(即异步方法和异步匿名函数)只能返回 voidTaskTask<TResult>。C# 7 稍微放宽了这一限制,允许任何以特定方式装饰的类型用作异步函数的返回类型。

作为提醒,async/await 功能始终允许我们等待遵循可等待模式的自定义类型。这里的新功能允许编写返回自定义类型的异步方法。

这既复杂又简单。它之所以复杂,是因为如果你想要创建自己的任务类型,你将面临一些繁琐的工作。这不是给胆小的人准备的。它之所以简单,是因为你几乎肯定不会想这么做,除非是为了实验;你将想要使用 ValueTask<TResult>。现在让我们来看看它。

5.8.1. 99.9% 的情况:ValueTask

在撰写本文时,System.Threading.ValueTask<TResult> 类型仅在 netcoreapp2.0 框架中作为内置类型存在,但它也通过 NuGet 的 System.Threading.Tasks.Extensions 包提供,这使得它具有更广泛的应用性。(最重要的是,该包包括对 netstandard1.0 的目标。)

ValueTask<TResult> 的描述很简单:它就像 Task<TResult>,但它是值类型。它有一个 AsTask 方法,允许你在需要时(例如,在 Task.WhenAllTask.WhenAny 调用中将它作为一个元素包含)从它获取一个常规任务,但大多数时候,你将像等待一个任务一样等待它。

ValueTask<TResult>相对于Task<TResult>的优势是什么?这全部归结于堆分配和垃圾收集。Task<TResult>是一个类,尽管在某些情况下异步基础设施会重用完成的Task<TResult>对象,但大多数异步方法仍然需要创建一个新的Task<TResult>。在.NET 中分配对象成本足够低,在许多情况下你不需要担心它,但如果你在做很多或者如果你在严格的性能约束下工作,你想要尽可能地避免这种分配。

如果一个异步方法在某个不完整的事物上使用await表达式,对象分配是不可避免的。它将立即返回,但必须安排一个后续操作在等待的操作完成时执行方法的其余部分。在大多数异步方法中,这是常见的情况;你不会期望你等待的操作在你等待之前完成。在这些情况下,ValueTask<TResult>没有提供任何优势,甚至可能更昂贵。

然而,在少数情况下,已经完成的案例是最常见的一种,这就是ValueTask<TResult>发挥作用的地方。为了演示这一点,让我们考虑一个简化版的真实世界示例。假设你想要逐字节从System.IO.Stream读取,并且异步地这样做。你可以轻松地添加一个缓冲抽象层来避免在底层Stream上频繁调用ReadAsync,但随后你可能需要添加一个异步方法来封装从流中填充缓冲区的操作,然后返回下一个字节。你可以使用byte?与空值来表示你已经到达了数据的末尾。这个方法很容易编写,但如果每次调用它都分配一个新的Task<byte?>,你将非常频繁地敲打垃圾收集器。使用ValueTask<TResult>,只有在需要从流中重新填充缓冲区的情况下才需要堆分配。以下列表显示了包装类型(ByteStream)及其使用示例。

列表 5.10. 包装流以实现高效的异步字节访问
public sealed class ByteStream : IDisposable
{
    private readonly Stream stream;
    private readonly byte[] buffer;
    private int position;                                    *1*
    private int bufferedBytes;                               *2*

    public ByteStream(Stream stream)
    {
        this.stream = stream;
        buffer = new byte[1024 * 8];                         *3*
    }

    public async ValueTask<byte?> ReadByteAsync()
    {
        if (position == bufferedBytes)                       *4*
        {
            position = 0;
            bufferedBytes = await
                stream.ReadAsync(buffer, 0, buffer.Length)   *5*
                       .ConfigureAwait(false);               *6*
            if (bufferedBytes == 0)
            {
                return null;                                 *7*
            }
        }
        return buffer[position++];                           *8*
    }

    public void Dispose()
    {
        stream.Dispose();
    }
}

Sample usage
using (var stream = new ByteStream(File.OpenRead("file.dat")))
{
    while ((nextByte = await stream.ReadByteAsync()).HasValue)
    {
        ConsumeByte(nextByte.Value);                         *9*
    }
}
  • 1 下一个要返回的缓冲区索引

  • 2 缓冲区中读取的字节数

  • 3 8 KB 缓冲区意味着你很少需要等待。

  • 4 如果需要则填充缓冲区

  • 5 异步从底层流读取

  • 6 配置等待操作以忽略上下文

  • 7 在适当的位置指示流的末尾

  • 8 从缓冲区返回下一个字节

  • 9 以某种方式使用字节

目前,你可以忽略ReadByteAsync中的ConfigureAwait调用。当你查看如何有效地使用 async/await 时,你将回到这一点,在第 5.10 节。其余的代码很简单,所有这些都可以在不使用ValueTask<TResult>的情况下编写;它只是效率要低得多。

在这种情况下,我们 ReadByteAsync 方法的多数调用甚至不会使用 await 操作符,因为你仍然有缓冲的数据可以返回,但如果你在等待另一个通常立即完成的值,这同样是有用的。正如我在 5.6.2 节 中解释的那样,当你等待一个已经完成的操作时,执行会同步继续,这意味着你不需要安排一个后续操作,可以避免对象分配。

这是 Google.Protobuf 包中 CodedInputStream 类原型的简化版本,是 Google 协议缓冲区序列化协议的 .NET 实现。实际上,有多个方法,每个方法要么同步要么异步地读取一小部分数据。使用包含大量整数字段的消息进行反序列化可能涉及大量的方法调用,并且每次使异步方法返回 Task<TResult> 都会非常低效。

注意

你可能想知道,如果你有一个不返回值(通常返回类型为 Task)的异步方法,但仍然属于无需安排任何后续操作即可完成的情况,你应该怎么做。在这种情况下,你可以坚持返回 Task:async/await 基础设施会缓存一个任务,它可以返回任何声明为返回 Task 且同步完成且无异常的异步方法。如果方法同步完成但带有异常,分配 Task 对象的成本很可能会被异常开销所淹没。

对于我们大多数人来说,能够将 ValueTask<TResult> 用作异步方法的返回类型是 C# 7 在异步方面真正的优势。但这已经被以通用方式实现,允许你为异步方法创建自己的返回类型。

5.8.2. 0.1%的情况:构建自己的自定义任务类型

我再次强调,你几乎肯定永远不需要这些信息。我甚至不会尝试提供超出 ValueTask<TResult> 的用例,因为我能想到的任何用例都可能很晦涩。话虽如此,如果我不展示编译器用来确定一个类型是任务类型的模式的例子,这本书就不完整。我将在下一章中展示编译器如何使用这个模式,届时你将看到为异步方法生成的代码。

显然,自定义任务类型必须实现可等待模式,但这不仅仅是可等待模式。要创建自定义任务类型,你必须编写相应的构建器类型,并使用System.Runtime.CompilerServices.AsyncMethodBuilderAttribute来让编译器知道这两个类型之间的关系。这是一个与ValueTask<TResult>相同的 NuGet 包中可用的新属性,但如果你不想有额外的依赖项,你可以包含自己声明的属性(在正确的命名空间中,并具有适当的BuilderType属性)。然后编译器将接受这作为装饰任务类型的一种方式。

任务类型可以是单个类型参数的泛型或非泛型。如果是泛型,则该类型参数必须是 awaiter 类型中GetResult的类型;如果是非泛型,则GetResult必须有一个void返回类型.^([2]) 构建器必须与任务类型一样是泛型或非泛型。

²

这让我有些惊讶。这意味着你不能编写一个总是表示返回字符串的操作的自定义任务类型,例如。鉴于整个功能的细分程度,真正想要在该功能中实现细分用例的可能性相当小。

构建器类型是编译器在编译返回你的自定义类型的方法时与你的代码交互的部分。它需要知道如何创建你的自定义任务,传播完成或异常,在延续后恢复,等等。你需要提供的方法和属性集比可等待模式复杂得多。最容易的方式是展示一个完整的示例,关于你需要提供的成员,而不涉及任何实现。

列表 5.11. 泛型任务类型所需的成员框架
[AsyncMethodBuilder(typeof(CustomTaskBuilder<>))]
public class CustomTask<T>
{
    public CustomTaskAwaiter<T> GetAwaiter();
}

public class CustomTaskAwaiter<T> : INotifyCompletion
{
    public bool IsCompleted { get; }
    public T GetResult();
    public void OnCompleted(Action continuation);
}

public class CustomTaskBuilder<T>
{
    public static CustomTaskBuilder<T> Create();

    public void Start<TStateMachine>(ref TStateMachine stateMachine)
        where TStateMachine : IAsyncStateMachine;

    public void SetStateMachine(IAsyncStateMachine stateMachine);
    public void SetException(Exception exception);
    public void SetResult(T result);

    public void AwaitOnCompleted<TAwaiter, TStateMachine>
        (ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine;

    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>
        (ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine;

    public CustomTask<T> Task { get; }
}

这段代码展示了一个泛型自定义任务类型。对于非泛型类型,构建器中唯一的区别是SetResult将是一个无参数的方法。

一个有趣的要求是AwaitUnsafeOnCompleted方法。正如你将在下一章中看到的,编译器有安全等待和不安全等待的概念,后者依赖于可等待类型来处理上下文传播。自定义任务构建器类型必须处理从这两种等待中恢复。

注意

这里的“不安全”一词与unsafe关键字没有直接关系,尽管在“这里可能有龙,小心!”这一方面存在相似之处。

再次强调一次,你几乎肯定不希望这样做,除非是出于兴趣。我不期望在生产代码中实现自己的任务类型,但我肯定会使用ValueTask<TResult>,所以我对这个功能的存在仍然感到非常感激。

说到有用的新功能,C# 7.1 还有一个额外的功能要提一下。幸运的是,它比自定义任务类型简单得多。

5.9. C# 7.1 中的异步主方法

对于入口点的需求在 C#中已经保持不变很长时间了:

  • 它必须是一个名为Main的方法。

  • 它必须是静态的。

  • 它必须有voidint返回类型。

  • 它必须是无参数的,或者有一个单一的非ref、非out类型的string[]参数。

  • 它必须是非泛型的,并且在非泛型类型中声明(包括任何包含的非泛型类型,如果它在嵌套类型中声明)。

  • 它不能是一个没有实现的部分方法。

  • 它不能有async修饰符。

在 C# 7.1 中,最终要求已经被取消,但关于返回类型的要求略有不同。在 C# 7.1 中,您可以编写一个异步入口点(仍然称为Main,而不是MainAsync),但它必须有TaskTask<int>的返回类型,对应于同步返回类型voidint。与大多数异步方法不同,异步入口点不能有void返回类型或使用自定义任务类型。

除了这个之外,它就是一个普通的异步方法。例如,以下列表显示了一个异步入口点,该入口点在控制台打印两行文本,并在它们之间有延迟。

列表 5.12. 一个简单的异步入口点
static async Task Main()
{
    Console.WriteLine("Before delay");
    await Task.Delay(1000);
    Console.WriteLine("After delay");
}

编译器通过创建一个同步包装方法来处理异步入口点,并将其标记为进入程序集的真实入口点。包装方法要么是无参数的,要么有一个string[]参数,要么返回void,要么返回int,具体取决于异步入口点的参数和返回类型。包装方法调用实际代码,然后对返回的任务调用GetAwaiter(),对等待者调用GetResult()。例如,为列表 5.11 生成的包装方法可能看起来像这样:

static void <Main>()                   *1*
{
    Main().GetAwaiter().GetResult();
}
  • 1 方法在 C#中名称无效但在 IL 中有效。

异步入口点对于编写使用异步 API(如 Roslyn)的小工具或探索性代码来说非常方便。

这些都是从语言角度的异步功能。但了解语言的能力与了解如何有效地使用这些能力是不同的。这对于异步来说尤其如此,因为异步是一个本质上复杂的话题。

5.10. 使用技巧

本节永远不能成为有效使用异步的完整指南;那可以填满一本整本书。我们即将结束一个已经非常长的章节,所以我只限制自己提供我经验中最重要的一些提示。我强烈建议您阅读其他开发者的观点。特别是,Stephen Cleary 和 Stephen Toub 已经撰写了大量博客文章和文章,深入探讨了众多方面。不分先后,本节提供了我可以合理简洁地提出的最有用的建议。

5.10.1. 使用 ConfigureAwait(在适当的情况下)避免上下文捕获

在第 5.2.2 节和 5.6.2 节中,我描述了同步上下文及其对await操作符的影响。例如,如果你在 WPF 或 Win-Forms 的 UI 线程上运行,并等待一个异步操作,UI 同步上下文和异步基础设施将确保在await操作符之后运行的延续仍然在同一个 UI 线程上执行。这正是 UI 代码所希望看到的,因为这样你就可以在之后安全地访问 UI。

但当你正在编写库代码——或者编写不接触 UI 的应用程序代码时——你不想回到 UI 线程,即使你最初就是在它上面运行的。一般来说,在 UI 线程上执行的代码越少,越好。这可以让 UI 更新得更平滑,并避免 UI 线程成为瓶颈。当然,如果你正在编写 UI 库,你可能确实想回到 UI 线程,但大多数库——如业务逻辑、网络服务、数据库访问等——并不需要这样做。

ConfigureAwait方法正是为此目的而设计的。它接受一个参数,用于确定返回的 awaitable 在等待时是否会捕获上下文。在实践中,我想我总是看到将值false作为参数传递。在库代码中,你不会像之前看到的那样编写页面长度获取代码:

static async Task<int> GetPageLengthAsync(string url)
{
    var fetchTextTask = client.GetStringAsync(url);
    int length = (await fetchTextTask).Length;
                                                   *1*
    return length;
}
  • 1 想象更多的代码在这里

相反,你会在client.GetStringAsync(url)返回的任务上调用ConfigureAwait(false)并等待结果:

static async Task<int> GetPageLengthAsync(string url)
{
    var fetchTextTask = client.GetStringAsync(url).ConfigureAwait(false);
    int length = (await fetchTextTask).Length;
                                                   *1*
    return length;
}
  • 1 相同的附加代码

我在这里稍微作弊了一下,为fetchTextTask变量使用了隐式类型。在第一个例子中,它是一个Task<int>;在第二个例子中,它是一个ConfiguredTaskAwaitable<int>。然而,我看到的绝大多数代码都是直接等待结果,就像这样:

string text = await client.GetStringAsync(url).ConfigureAwait(false);

调用ConfigureAwait(false)的结果是,延续不会针对原始同步上下文进行调度;它将在线程池线程上执行。请注意,只有当任务在等待时还没有完成,这种行为才会与原始代码不同。如果它已经完成,即使在ConfigureAwait(false)的情况下,方法也会继续同步执行。因此,在库中等待的每个任务都应该这样配置。你不可能只在异步方法中的第一个任务上调用ConfigureAwait(false)并依赖于方法的其他部分在线程池线程上执行。

所有这些都意味着你在编写库代码时需要小心。我预计最终可能存在更好的解决方案(例如,设置整个程序集的默认值),但到目前为止,你需要保持警惕。我建议使用 Roslyn 分析器来查找你忘记在等待之前配置任务的地方。我使用ConfigureAwaitChecker.Analyzer NuGet 包有积极的体验,但其他分析器也是可用的。

如果你担心这会对调用者造成什么影响,你不必担心。假设调用者正在等待GetPageLengthAsync返回的任务,然后更新用户界面以显示结果。即使GetPageLengthAsync中的延续在线程池线程上运行,UI 代码中执行的await表达式也会捕获 UI 上下文并安排的延续在 UI 线程上运行,因此 UI 仍然可以在之后更新。

5.10.2. 通过启动多个独立任务启用并行性

在第 5.6.1 节中,你查看了几段代码以实现相同的目标:找出根据员工的时薪和工时来确定应支付多少工资。最后两段代码如下所示:

Task<decimal> hourlyRateTask = employee.GetHourlyRateAsync();
decimal hourlyRate = await hourlyRateTask;
Task<int> hoursWorkedTask = timeSheet.GetHoursWorkedAsync(employee.Id);
int hoursWorked = await hoursWorkedTask;
AddPayment(hourlyRate * hoursWorked);

和这个

Task<decimal> hourlyRateTask = employee.GetHourlyRateAsync();
Task<int> hoursWorkedTask = timeSheet.GetHoursWorkedAsync(employee.Id);
AddPayment(await hourlyRateTask * await hoursWorkedTask);

除了更短之外,第二段代码引入了并行性。两个任务可以独立启动,因为你不需要第二个任务的输出作为第一个任务的输入。这并不意味着异步基础设施创建了更多的线程。例如,如果这里的两个异步操作是网络服务,那么对网络服务的两个请求可以在没有任何线程被阻塞在结果上时同时进行。

短小在这里只是附带的好处。如果你想要并行性但喜欢有单独的变量,那也行:

Task<decimal> hourlyRateTask = employee.GetHourlyRateAsync();
Task<int> hoursWorkedTask = timeSheet.GetHoursWorkedAsync(employee.Id);
decimal hourlyRate = await hourlyRateTask;
int hoursWorked = await hoursWorkedTask;
AddPayment(hourlyRate * hoursWorked);

与原始代码之间的唯一区别是我交换了第二行和第三行。不是等待hourlyRateTask然后启动hoursWorkedTask,而是同时启动两个任务,然后等待两个任务。

在大多数情况下,如果你可以并行执行独立的工作,那么这样做是个好主意。请注意,如果hourlyRateTask失败,你将不会观察到hoursWorkedTask的结果,包括该任务中的任何失败。例如,如果你需要记录所有任务失败,你可能想使用Task.WhenAll

当然,这种并行化依赖于任务一开始就是独立的。在某些情况下,依赖关系可能并不完全明显。如果你有一个任务正在验证用户身份,另一个任务代表他们执行操作,你希望在开始操作之前等待验证完成,即使可以编写并行执行的代码。异步/等待特性不能为你做出这些决定,但它使得在决定适当的情况下并行化异步操作变得容易。

5.10.3. 避免混合同步和异步代码

虽然异步不是完全非此即彼,但当你的代码中既有同步部分又有异步部分时,正确实现会变得更加困难。在两种方法之间切换充满了困难——有些微妙,有些不那么微妙。如果你有一个只公开同步操作的网络库,为这些操作编写异步包装器是难以安全完成的,反之亦然。

特别要注意使用Task<TResult>.Result属性和Task.Wait()方法来尝试同步检索异步操作的结果的危险。这很容易导致死锁。在最常见的情况下,异步操作需要在被阻塞等待操作完成的线程上执行后续操作。

Stephen Toub 关于这个主题有两篇优秀且详细的博客文章:“我应该为异步方法公开同步包装器吗?”和“我应该为同步方法公开异步包装器吗?”(剧透:正如你可能猜到的,两种情况下的答案都是否定的。)与所有规则一样,总有一些例外,但我强烈建议你在打破规则之前彻底理解规则。

5.10.4. 尽可能允许取消

取消是一个在同步代码中没有强大对应物的领域,在同步代码中,你通常必须等待方法返回才能继续。取消异步操作的能力非常强大,但它确实依赖于整个堆栈的合作。如果你想要使用不允许你传递取消令牌的方法,你几乎无能为力。你可以编写一些复杂的代码,以便你的异步方法以取消状态完成,并忽略不可取消任务的最终结果,但这远非理想。你真正想要的是能够停止任何正在进行的工作,而且你也不想担心异步方法最终完成时可能返回的任何可丢弃的资源。

幸运的是,大多数低级异步 API 确实将取消令牌作为参数暴露出来。你所需要做的就是遵循相同的模式,通常是将你在参数中接收到的相同的取消令牌作为参数传递给所有你调用的异步方法。即使你目前没有允许取消的要求,我也建议从一开始就提供这一选项,因为后来添加它会很痛苦。

再次强调,Stephen Toub 有一篇关于尝试绕过不可取消的异步操作的微妙困难的优秀博客文章。搜索“如何取消不可取消的异步操作?”以找到它。

5.10.5. 测试异步

测试异步代码可能非常棘手,尤其是如果你想测试异步本身。例如,“如果在方法中的第二次和第三次异步调用之间取消操作会发生什么?”这样的测试需要相当复杂的工作。

这并非不可能,但如果要进行全面测试,则要做好打一场艰难的战斗的准备。当我撰写这本书的第三版时,我希望到 2019 年会有稳健的框架使所有这些相对简单。不幸的是,我感到失望。

大多数单元测试框架都支持异步测试。这种支持对于编写异步方法的测试来说几乎是必不可少的,原因如我之前提到的,混合同步和异步代码的困难。通常,编写一个异步测试就像编写一个带有 async 修饰符的测试方法,并声明它返回 Task 而不是 void 一样简单:

[Test]
public async Task FooAsync()
{
                            *1*
}
  • *1. 测试你的 FooAsync 生产方法的代码

测试框架通常提供了一个 Assert.ThrowsAsync 方法来测试异步方法的调用最终返回一个变成故障的任务。

在测试异步代码时,你通常会想要创建一个已经完成的任务,并带有特定的结果或错误。Task.FromResultTask.FromExceptionTask.FromCanceled 方法在这里非常有用。

为了获得更大的灵活性,你可以使用 TaskCompletionSource<TResult>。这种类型在框架的许多异步基础设施中被使用。它有效地允许你创建一个表示正在进行的操作的任务,然后在稍后设置结果(包括任何异常或取消),此时任务将完成。当你想要从模拟的依赖项返回一个任务,但希望在测试中稍后完成该任务时,这非常有用。

关于 TaskCompletionSource<TResult> 有一个方面需要了解,那就是当你设置结果时,附加到相关任务上的延续可以在同一线程上同步运行。延续如何运行的精确细节取决于涉及的线程和同步上下文的各个方面,在你意识到这是一个可能性之后,相对容易考虑到这一点。现在你已经意识到了,希望可以避免像我曾经那样浪费时间感到困惑。

这是我过去四五年编写异步代码所学到的知识的不完整总结,但我不想失去本书主题(C# 语言,而不是异步)的焦点。你已经从开发者的角度看到了 async/await 功能的作用,但你还没有详细查看底层发生了什么,尽管可等待模式提供了一些线索。

如果你还没有尝试过 async/await,我强烈建议你在开始下一章之前先尝试一下,下一章将探讨实现细节。这些细节很重要,但在最好的情况下也难以理解,如果你没有使用 async/await 的经验,理解起来会更困难。如果你还没有这种经验,现在也不特别想花时间,我建议你现在跳过下一章。它只关于异步的实现细节;我保证你不会错过任何其他内容。

摘要

  • 异步的核心是关于开始一个操作,然后在操作完成时继续,而不需要在中间阻塞。

  • Async/await 允许你编写看起来熟悉的异步代码。

  • Async/await 处理同步上下文,以便 UI 代码可以在异步操作完成后在 UI 线程上继续执行。

  • 异步操作中的成功结果和异常会传播。

  • 限制条件限制了你可以使用 await 操作符的位置,但 C# 6(及以后)版本的限制比 C# 5 少。

  • 编译器使用可等待模式来确定哪些类型可以被等待。

  • C# 7 允许你创建自己的自定义任务类型,但你几乎肯定想使用 ValueTask<TResult>

  • C# 7.1 允许你将异步 Main 方法作为程序入口点。

第六章. 异步实现

本章涵盖

  • 异步代码的结构

  • 与框架构建器类型交互

  • 在异步方法中执行单个步骤

  • 理解 await 表达式之间的执行上下文流

  • 与自定义任务类型交互

我清晰地记得 2010 年 10 月 28 日晚。安德斯·海尔斯伯格在 PDC 上介绍 async/await,在他的演讲开始前不久,大量可下载的材料被发布,包括 C#规范变更的草案、C# 5 编译器的社区技术预览(CTP),以及安德斯将要展示的幻灯片。在某个时刻,我正在实时观看演讲并快速浏览幻灯片,同时 CTP 正在安装。当安德斯演讲结束时,我正在编写异步代码并尝试各种操作。

在接下来的几周里,我开始拆解代码,查看编译器生成的确切代码,试图编写与 CTP 一起提供的库的简单实现,并从各个角度对其进行检查。随着新版本的发布,我了解了发生了什么变化,并对幕后发生的事情越来越熟悉。我看到的越多,就越欣赏编译器为我们编写的样板代码。这就像在显微镜下观察一朵美丽的花朵:美丽依然存在,但其中还有更多东西超出了第一眼所见。

当然,并不是每个人都像我一样。如果你只想依赖我已经描述的行为,并且简单地相信编译器会做正确的事情,那绝对是完全可以的。或者,如果你现在跳过这一章,稍后再回来,你也不会错过任何内容;这本书的其余部分都不依赖于它。你不太可能需要将你的代码调试到这里的程度,但我相信这一章会给你更多关于 async/await 如何协同工作的洞察。在查看生成的代码之后,可等待模式和对自定义任务类型的要求会更有意义。我不想对此过于神秘化,但通过研究这些实现细节,语言和开发者之间确实存在某种联系,这种联系因研究这些实现细节而变得更加丰富。

作为一种粗略的近似,我们将假设 C# 编译器将使用 async/await 的 C# 代码转换成不使用 async/await 的 C# 代码。当然,编译器能够以比这更低的级别操作,使用可以发出 IL 的中间表示形式。实际上,在 async/await 的某些方面,生成的 IL 无法用常规的 C# 表示,但解释这些地方很容易。

调试和发布构建不同,未来的实现可能也是如此

在编写这一章的过程中,我意识到异步代码的调试和发布构建之间存在差异:在调试构建中,生成的状态机是类而不是结构体。(这是为了提供更好的调试体验;特别是,它为编辑和继续场景提供了更多的灵活性。)在我编写第三版时并非如此;编译器实现已经改变。未来也可能再次改变。如果你反编译由 C# 8 编译器编译的异步代码,它可能看起来与这里展示的略有不同。

虽然这很令人惊讶,但也不应该过于令人担忧。根据定义,实现细节可能会随时间而改变。这并不会使从研究特定实现中获得的所有洞察力失效。只需意识到,这与“这些是 C# 的规则,并且它们只会以良好定义的方式改变”这种学习方式不同。

在这一章中,我将展示发布构建生成的代码。这些差异主要影响性能,我相信大多数读者对发布构建的性能比对调试构建的性能更感兴趣。

生成的代码有点像洋葱;它有复杂的层次结构。我们将从最外面开始,逐步深入到棘手的部分:await 表达式和 awaiter 与 continuation 的舞蹈。为了简洁起见,我将只展示异步方法,而不是异步匿名函数;两者之间的机制是一样的,所以重复这项工作没有什么特别有趣的内容可以学习。

6.1. 生成代码的结构

如我在第五章中提到的,实现(无论是这种近似还是由真实编译器生成的代码)都是以状态机的形式进行的。编译器将生成一个私有的嵌套结构来表示异步方法,并且它还必须包含一个与您声明的签名相同的函数。我称这个为存根方法;它并没有什么特别的,但它启动了所有其他的过程。

备注

经常,我会谈论状态机的暂停。这对应于异步方法达到await表达式,而等待的操作尚未完成的情况。如您从第五章中记得的那样,当这种情况发生时,会安排一个延续来在等待的操作完成后执行异步方法的其余部分,然后异步方法返回。同样,谈论异步方法采取步骤也很有用:它在暂停之间执行的代码。这些不是官方术语,但作为缩写很有用。

状态机跟踪你在异步方法中的位置。从逻辑上讲,有四种状态,按常见的执行顺序如下:

  • 未开始

  • 执行中

  • 暂停

  • 完成(无论是成功还是故障)

只有暂停的状态集依赖于异步方法的结构。方法内的每个await表达式都是一个独特的状态,以便返回以触发更多执行。当状态机正在执行时,它不需要跟踪正在执行的精确代码片段;在那个点上,它只是常规代码,CPU 就像同步代码一样跟踪指令指针。状态是在状态机需要暂停时记录的;整个目的就是允许它从达到的点继续代码执行。图 6.1 显示了可能状态之间的转换。

图 6.1. 状态转换图

图片

让我们用一个实际的代码片段来具体说明。下面的列表显示了一个简单的异步方法。它并不像你可以让它那样简单,但它可以同时演示几个事情。

列表 6.1. 简单的异步方法介绍
static async Task PrintAndWait(TimeSpan delay)
{
    Console.WriteLine("Before first delay");
    await Task.Delay(delay);
    Console.WriteLine("Between delays");
    await Task.Delay(delay);
    Console.WriteLine("After second delay");
}

在这个阶段有三个需要注意的点:

  • 你有一个参数需要在状态机中使用。

  • 该方法包含两个await表达式。

  • 该方法返回Task,因此你需要返回一个任务,该任务将在最后一行打印后完成,但没有特定的结果。

这很好,也很简单,因为你没有循环或需要担心的try/catch/finally块。控制流程很简单,当然,除了等待之外。让我们看看编译器为这段代码生成了什么。

请务必在家尝试

我通常使用 ildasm 和 Redgate Reflector 的混合体来做这类工作,将优化级别设置为 C# 1,以防止反编译器为我们重建异步方法。其他反编译器也可用,但无论你选择哪个,我都建议检查 IL。我在await方面看到过反编译器中的微妙错误,通常与执行顺序有关。

如果你不想做这些,那也行,但如果你发现自己想知道编译器对某个特定代码结构做了什么,而这章没有提供答案,那就去做吧。不过,别忘了调试和发布构建之间的区别,也不要被编译器生成的名称所困扰,这些名称可能会使结果更难阅读。

使用可用的工具,你可以将列表 6.1 反编译成类似列表 6.2 的东西。C# 编译器生成的许多名称都不是有效的 C# 名称;为了得到可运行的代码,我已经将它们重写为有效的标识符。在其他情况下,我已将标识符重命名,使代码更易于阅读。后来,我对状态机的案例和标签的顺序做了一些调整;它与生成的代码在逻辑上是完全等价的,但更容易阅读。在其他地方,即使只有两个案例,我也使用了switch语句,而编译器可能会有效地使用if/else。在这些地方,switch语句代表更通用的案例,可以在有多个跳转点时工作,但编译器可以为更简单的情况生成更简单的代码。

列表 6.2. 列表 6.1(除MoveNext外)生成的代码
Stub method
[AsyncStateMachine(typeof(PrintAndWaitStateMachine))]
[DebuggerStepThrough]
private static unsafe Task PrintAndWait(TimeSpan delay)
{
    var machine = new PrintAndWaitStateMachine               *1*
    {                                                        *1*
        delay = delay,                                       *1*
        builder = AsyncTaskMethodBuilder.Create(),           *1*
        state = -1                                           *1*
    };                                                       *1*
    machine.builder.Start(ref machine);                      *2*
    return machine.builder.Task;                             *3*
}

Private struct for the state machine
[CompilerGenerated]
private struct PrintAndWaitStateMachine : IAsyncStateMachine
{
    public int state;                                        *4*
    public AsyncTaskMethodBuilder builder;                   *5*
    private TaskAwaiter awaiter;                             *6*
    public TimeSpan delay;                                   *7*

    void IAsyncStateMachine.MoveNext()                       *8*
    {                                                        *8*
    }                                                        *8*

    [DebuggerHidden]
    void IAsyncStateMachine.SetStateMachine(
        IAsyncStateMachine stateMachine)
    {
        this.builder.SetStateMachine(stateMachine);          *9*
    }
}
  • 1 初始化状态机,包括方法参数

  • 2 运行状态机直到它需要等待

  • 3 返回表示异步操作的任务

  • 4 状态机的状态(从哪里恢复

  • 5 构建器连接到异步基础设施类型

  • 6 在恢复时从其中获取结果的等待者

  • 7 原始方法参数

  • 8 主要状态机的工作内容在这里。

  • 9 连接构建器和装箱状态机

这个列表看起来已经相当复杂了,但我应该警告你,大部分工作都是在MoveNext方法中完成的,我现在已经完全移除了该方法的实现。列表 6.2 的目的是设定场景并提供结构,这样当你到达MoveNext实现时,它就有意义了。让我们依次查看列表的各个部分,从存根方法开始。

6.1.1. 存根方法:准备和迈出第一步

除了AsyncTaskMethodBuilder之外,列表 6.2 中的存根方法很简单。这是一个值类型,它是通用异步基础设施的一部分。你将在本章的其余部分看到状态机如何与构建器交互。

[AsyncStateMachine(typeof(PrintAndWaitStateMachine))]
[DebuggerStepThrough]
private static unsafe Task PrintAndWait(TimeSpan delay)
{
    var machine = new PrintAndWaitStateMachine
    {
        delay = delay,
        builder = AsyncTaskMethodBuilder.Create(),
        state = -1
    };
    machine.builder.Start(ref machine);
    return machine.builder.Task;
}

应用到方法上的属性基本上是为了工具。它们对常规执行没有影响,你不需要了解任何关于它们的细节就能理解生成的异步代码。状态机始终在存根方法中创建,带有三块信息:

  • 任何参数(在这个例子中,就是delay),每个都在状态机中作为单独的字段

  • 构建器,它取决于异步方法的返回类型

  • 初始状态始终是-1

注意

名称AsyncTaskMethodBuilder可能会让你想到反射,但它并不是在 IL 中创建方法或类似操作。构建器提供了生成代码用来传播成功和失败、处理等待等功能。如果你觉得“辅助器”这个名字更适合你,请随意这样想。

在创建状态机后,存根方法请求机器的构建器启动它,通过引用传递机器本身。你将在接下来的几页中看到很多通过引用传递的情况,这归结于对效率和一致性的需求。状态机和AsyncTaskMethodBuilder都是可变的值类型。通过引用传递machineStart方法避免了复制状态,这更高效并确保在Start方法返回时,对状态所做的任何更改仍然可见。特别是,机器内部的builder状态可能在Start期间发生变化。这就是为什么在使用machine.builder进行Start调用和随后的Task属性很重要的原因。假设你将machine.builder提取到一个局部变量中,如下所示:

var builder = machine.builder;     *1*
builder.Start(ref machine);        *1*
return builder.Task;               *1*
  • 1 无效的重构尝试

使用那段代码,在builder.Start()内部直接做出的状态更改不会被machine.builder(或反之)看到,因为那将是一个构建器的副本。这就是为什么machine.builder引用一个字段而不是属性很重要的原因。你不想在状态机中操作构建器的副本;相反,你希望直接操作状态机包含的值。这正是你不想自己处理的细节,也是为什么可变值类型和公共字段几乎总是坏主意的原因。(你将在第十一章中看到,在仔细考虑的情况下,它们可以是有用的。)

启动机器不会创建任何新线程。它只是运行状态机的MoveNext()方法,直到状态机需要暂停以等待另一个异步操作或完成。换句话说,它只迈出一小步。无论如何,MoveNext()都会返回,此时machine.builder.Start()也会返回,然后你可以返回一个表示整体异步方法的任务给我们的调用者。构建器负责创建任务并确保在异步方法的过程中适当地改变状态。

那就是存根方法。现在让我们看看状态机本身。

6.1.2. 状态机的结构

我仍然省略了状态机的大部分代码(在MoveNext()方法中),但这里是对类型结构的提醒:

[CompilerGenerated]
private struct PrintAndWaitStateMachine : IAsyncStateMachine
{
    public int state;
    public AsyncTaskMethodBuilder builder;
    private TaskAwaiter awaiter;
    public TimeSpan delay;

    void IAsyncStateMachine.MoveNext()
    {
                 *1*
    }

    [DebuggerHidden]
    void IAsyncStateMachine.SetStateMachine(
        IAsyncStateMachine stateMachine)
    {
        this.builder.SetStateMachine(stateMachine);
    }
}
  • 1 实现省略

再次强调,属性并不重要。类型的重要方面如下:

  • 它实现了IAsyncStateMachine接口,该接口用于异步基础设施。该接口只有两个显示的方法。

  • 字段,用于存储状态机在一步与下一步之间需要记住的信息。

  • MoveNext()方法,当状态机启动时调用一次,每次在暂停后恢复时也调用一次。

  • SetStateMachine()方法,它始终具有相同的实现(在发布版本中)。

你已经看到了一个实现IAsyncStateMachine类型的用法,尽管它有些隐藏:AsyncTaskMethodBuilder.Start()是一个泛型方法,其类型参数必须实现IAsyncStateMachine。在执行一些家务后,Start()调用MoveNext()使状态机执行异步方法的第一步。

涉及的字段可以大致分为五类:

  • 当前状态(例如,未开始、在特定的 await 表达式处暂停等)

  • 方法构建器用于与异步基础设施通信并提供要返回的Task

  • 等待者

  • 参数和局部变量

  • 临时栈变量

状态和构建器相当简单。状态只是一个具有以下值的整数:

  • –1—未开始,或当前正在执行(无论哪种情况)

  • –2—完成(无论是成功还是故障)

  • 其他任何情况—在特定的 await 表达式处暂停

如我之前提到的,构建器的类型取决于异步方法的返回类型。在 C# 7 之前,构建器类型始终是AsyncVoidMethodBuilderAsyncTaskMethodBuilderAsyncTaskMethodBuilder<T>。随着 C# 7 和自定义任务类型,由AsyncTaskMethodBuilderAttribute指定的构建器类型应用于自定义任务类型。

其他字段稍微复杂一些,因为它们都依赖于异步方法的主体,编译器会尽量使用尽可能少的字段。需要记住的关键点是,你只需要为那些在状态机在某个时刻恢复后需要返回的值使用字段。有时编译器可以为多个目的使用字段,有时它可以完全省略它们。

编译器重用字段的第一种例子是等待者。一次只有一个等待者是相关的,因为任何特定的状态机一次只能等待一个值。编译器为每种使用的等待者类型创建一个字段。如果你在一个异步方法中等待两个 Task<int> 值、一个 Task<string> 和三个非泛型 Task 值,你将得到三个字段:一个 TaskAwaiter<int>、一个 TaskAwaiter<string> 和一个非泛型 TaskAwaiter。编译器根据等待者类型使用适当的字段为每个 await 表达式。

注意

这假设等待者是由编译器引入的。如果你自己调用 GetAwaiter() 并将结果赋值给局部变量,那么它就像任何其他局部变量一样处理。我在谈论的是作为 await 表达式结果产生的等待者。

接下来,让我们考虑局部变量。在这里,编译器不会重用字段,但可以完全省略它们。如果一个局部变量只在使用两个 await 表达式之间而不是跨越 await 表达式使用,它可以保持在 MoveNext() 方法中的局部变量。

通过一个例子更容易理解我的意思。考虑以下异步方法:

public async Task LocalVariableDemoAsync()
{
    int x = DateTime.UtcNow.Second;   *1*
    int y = DateTime.UtcNow.Second;   *2*
    Console.WriteLine(y);             *2*
    await Task.Delay();
    Console.WriteLine(x);             *3*
}
  • 1await 之前将 x 赋值。

  • 2 y 只在 await 之前使用。

  • 3await 之后使用 x

编译器会为 x 生成一个字段,因为当状态机暂停时,必须保留其值,但 y 在代码执行期间可以只是一个栈上的局部变量。

注意

编译器在创建所需字段数量方面做得相当不错。但有时,你可能会发现编译器可以执行但未执行的优化。例如,如果有两个变量具有相同的类型并且都在 await 表达式中使用(因此需要字段),但它们从未同时处于作用域中,编译器可以为它们使用一个字段,就像它对等待者所做的那样。在撰写本文时,它并没有这样做,但谁知道未来会怎样呢?

最后,还有临时栈变量。这些是在将 await 表达式用作更大表达式的一部分并且需要记住一些中间值时引入的。在我们的简单示例 列表 6.1 中不需要这些,这就是为什么 列表 6.2 只显示了四个字段:状态、构建器、等待者和参数。作为一个例子,考虑以下方法:

public async Task TemporaryStackDemoAsync()
{
    Task<int> task = Task.FromResult(10);
    DateTime now = DateTime.UtcNow;
    int result = now.Second + now.Hours * await task;
}

C# 对操作数评估的规则不会因为你在一个异步方法中而改变。属性 now.Secondnow.Hours 都必须在任务被等待之前被评估,并且它们的必须被记住以便在状态机在任务完成后恢复时执行算术运算。这意味着它需要使用字段。

注意

在这种情况下,你知道 Task.FromResult 总是返回一个完成的任务。但是编译器不知道这一点,并且它必须以某种方式生成状态机,以便在任务未完成时可以暂停和恢复。

你可以把它想象成编译器重写代码以引入额外的局部变量:

public async Task TemporaryStackDemoAsync()
{
    Task<int> task = Task.FromResult(10);
    DateTime now = DateTime.UtcNow;
    int tmp1 = now.Second;
    int tmp2 = now.Hours;
    int result = tmp1 + tmp2 * await task;
}

然后将局部变量转换为字段。与真正的局部变量不同,编译器确实会重用相同类型的临时堆栈变量,并且只生成它需要的字段数量。

这解释了状态机中的所有字段。接下来,你需要查看 MoveNext() 方法——但首先是概念上,为了开始。

6.1.3. MoveNext() 方法(高级)

我现在不会向你展示 列表 6.1 的 MoveNext() 方法的反编译代码,因为它很长且令人畏惧.^([1]) 在你知道流程看起来像什么之后,它就更容易处理了,所以我将在这里以抽象的方式描述它。

¹

如果《少数人统治》是关于异步的,那么台词将是:“你想要 MoveNext 吗?你处理不了 MoveNext!”

每次调用 MoveNext() 时,状态机都会迈出另一步。每次它遇到一个 await 表达式时,如果等待的值已经完成,它将继续;否则,它将暂停。如果以下任何一种情况发生,MoveNext() 将返回:

  • 状态机需要暂停以等待一个不完整的价值。

  • 执行到达方法末尾或返回语句。

  • 在异步方法中抛出异常但没有被捕获。

注意,在最终情况下,MoveNext() 方法并没有最终抛出异常。相反,与异步调用关联的任务变得有故障。(如果你对此感到惊讶,请参阅 第 5.6.5 节 以了解异步方法与异常行为的关系。)

图 6.2 展示了一个异步方法的一般流程图,该流程图专注于 MoveNext() 方法。我没有在图中包含异常处理,因为流程图没有表示 try/catch 块的方法。当你最终查看代码时,你会看到它是如何处理的。同样,我没有显示 SetStateMachine 被调用的位置,因为流程图本身已经足够复杂。

图 6.2. 异步方法的流程图

关于MoveNext()方法的最后一个要点:它的返回类型是void,而不是任务类型。只有存根方法需要返回任务,它在状态机的构建器调用Start()方法并执行第一步后从状态机的构建器那里获得。所有其他对MoveNext()的调用都是基础设施的一部分,用于从暂停状态恢复状态机,这些调用不需要相关的任务。你将在第 6.2 节(现在不久了)中看到所有这些在代码中的样子,但首先,简要谈谈SetStateMachine

6.1.4. SetStateMachine方法和状态机装箱舞蹈

我已经展示了SetStateMachine的实现。很简单:

void IAsyncStateMachine.SetStateMachine(
    IAsyncStateMachine stateMachine)
{
    this.builder.SetStateMachine(stateMachine);
}

发布版本中的实现总是这样。(在调试版本中,由于状态机是一个类,实现是空的。)从高层次上解释该方法的目的很容易,但细节很繁琐。当状态机迈出第一步时,它作为存根方法的局部变量位于堆栈上。如果它暂停,它必须将自己装箱(到堆上),以便在它恢复时所有这些信息仍然在位。在装箱之后,使用装箱值作为参数在装箱值上调用SetStateMachine。换句话说,在基础设施的深处,有看起来有点像这样的代码:

void BoxAndRemember<TStateMachine>(ref TStateMachine stateMachine)
    where TStateMachine : IStateMachine
{
    IStateMachine boxed = stateMachine;
    boxed.SetStateMachine(boxed);
}

这并不是那么简单,但已经传达了正在发生的事情的本质。然后SetStateMachine的实现确保AsyncTaskMethodBuilder有一个对其所参与的单个装箱状态机的引用。必须在装箱值上调用该方法;只能在装箱之后调用,因为那时你才有对装箱值的引用,如果在装箱之后在未装箱值上调用它,那么这不会影响装箱值。(记住,AsyncTaskMethodBuilder本身是一个值类型。)这种复杂的舞蹈确保当将延续委托传递给 awaiter 时,该延续将调用同一装箱实例的MoveNext()方法。

结果是,如果不需要装箱,状态机根本不会装箱;如果需要,则恰好装箱一次。在装箱之后,所有事情都在装箱版本上发生。这是一堆为了效率而编写的复杂代码。

我认为这个小小的舞蹈是整个异步机制中最引人入胜和奇特的部分。听起来这似乎毫无意义,但这是由于装箱的工作方式所必需的,而装箱是必要的,以便在状态机暂停时保留信息。

完全不必完全理解这段代码。如果你发现自己正在低级别调试异步代码,可以回到这一节。对于所有其他目的和用途,这段代码更像是一个新奇事物,而不是其他任何东西。

这就是状态机的组成部分。本章的大部分内容都致力于 MoveNext() 方法以及它在各种情况下的操作。我们将从简单的情况开始,然后逐步深入。

6.2. 一个简单的 MoveNext() 实现

我们将从你在 列表 6.1 中看到的简单异步方法开始。它之所以简单,并不是因为它很短(尽管这也有帮助),而是因为它不包含任何循环、try 语句或 using 语句。它有简单的控制流程,这导致了一个相对简单的状态机。让我们开始吧。

6.2.1. 一个完整的具体示例

我将首先展示完整的方法。不要期望这一切现在都能理解,但请花几分钟时间仔细查看。有了这个具体的例子,更通用的结构更容易理解,因为你可以随时回顾,看看结构中的每一部分是如何体现在这个例子中的。冒着让你感到无聊的风险,这里再次列出 列表 6.1 作为编译器输入的提醒:

static async Task PrintAndWait(TimeSpan delay)
{
    Console.WriteLine("Before first delay");
    await Task.Delay(delay);
    Console.WriteLine("Between delays");
    await Task.Delay(delay);
    Console.WriteLine("After second delay");
}

以下列表是经过轻微重写以提高可读性的反编译代码版本。(是的,这是易于阅读的版本。)

列表 6.3. 从 列表 6.1 反编译的 MoveNext() 方法
void IAsyncStateMachine.MoveNext()
{
    int num = this.state;
    try
    {
        TaskAwaiter awaiter1;
        switch (num)
        {
            default:
                goto MethodStart;
            case 0:
                goto FirstAwaitContinuation;
            case 1:
                goto SecondAwaitContinuation;
        }
    MethodStart:
        Console.WriteLine("Before first delay");
        awaiter1 = Task.Delay(this.delay).GetAwaiter();
        if (awaiter1.IsCompleted)
        {
            goto GetFirstAwaitResult;
        }
        this.state = num = 0;
        this.awaiter = awaiter1;
        this.builder.AwaitUnsafeOnCompleted(ref awaiter1, ref this);
        return;
    FirstAwaitContinuation:
        awaiter1 = this.awaiter;
        this.awaiter = default(TaskAwaiter);
        this.state = num = -1;
    GetFirstAwaitResult:
        awaiter1.GetResult();
        Console.WriteLine("Between delays");
        TaskAwaiter awaiter2 = Task.Delay(this.delay).GetAwaiter();
        if (awaiter2.IsCompleted)
        {
            goto GetSecondAwaitResult;
        }
        this.state = num = 1;
        this.awaiter = awaiter2;
        this.builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);
        return;
    SecondAwaitContinuation:
        awaiter2 = this.awaiter;
        this.awaiter = default(TaskAwaiter);
        this.state = num = -1;
    GetSecondAwaitResult:
        awaiter2.GetResult();
        Console.WriteLine("After second delay");
    }
    catch (Exception exception)
    {
        this.state = -2;
        this.builder.SetException(exception);
        return;
    }
    this.state = -2;
    this.builder.SetResult();
}

这段代码很多,你可能注意到它有很多 goto 语句和代码标签,这在手写的 C# 中几乎很少见。目前,我预计它可能有些难以理解,但我想要先展示一个具体的例子,这样你就可以在需要的时候随时参考。我将进一步将其分解为一般结构和 await 表达式的具体细节。到本节结束时,列表 6.3 可能仍然对你来说非常丑陋,但你将更好地理解它在做什么以及为什么这么做。

6.2.2. MoveNext() 方法的一般结构

我们已经进入了异步洋葱的下一层。MoveNext() 方法是异步状态机的核心,其复杂性提醒我们异步代码的正确实现是多么困难。状态机越复杂,你就越有理由感激是 C# 编译器而不是你自己在编写代码。

注意

为了简洁起见,现在是时候引入更多术语了。在每一个 await 表达式中,被等待的值可能已经完成或可能仍然不完整。如果你等待它的时候它已经完成,状态机将继续执行。我称之为 快速路径。如果它还没有完成,状态机将安排一个后续操作并暂停。我称之为 慢速路径

作为提醒,MoveNext()方法在异步方法首次调用时被调用一次,然后在每次从 await 表达式暂停处恢复时再次调用。 (如果每个 await 表达式都采取快速路径,则MoveNext()只会被调用一次。)该方法负责以下内容:

  • 从正确的位置执行(无论是原始异步代码的开始还是中途)

  • 在需要暂停时保留状态,无论是局部变量还是代码中的位置

  • 在需要暂停时安排延续

  • 从 awaiter 检索返回值

  • 通过构建器传播异常(而不是让MoveNext()自身因异常而失败)

  • 通过构建器传播任何返回值或方法完成

考虑到这一点,以下列表显示了MoveNext()方法的一般结构伪代码。你将在后面的章节中看到,由于额外的控制流,这可能会变得更加复杂,但它是一个自然的扩展。

列表 6.4. MoveNext()方法的伪代码
void IAsyncStateMachine.MoveNext()
{
    try
    {
        switch (this.state)
        {
            default: goto MethodStart;
            case 0: goto Label0A;
            case 1: goto Label1A;
            case 2: goto Label2A;
                                        *1*
        }
    MethodStart:
                                        *2*
                                        *3*
    Label0A:
                                        *4*
    Label0B:
                                        *5*
                                        *6*
    }
    catch (Exception e)                 *7*
    {                                   *7*
        this.state = -2;                *7*
        builder.SetException(e);        *7*
        return;                         *7*
    }                                   *7*
    this.state = -2;                    *8*
    builder.SetResult();                *8*
}
  • 1 有多少个 await 表达式就有多少种情况

  • 2 在第一个 await 表达式之前的代码

  • 3 设置第一个 awaiter

  • 4 从延续处恢复代码

  • 5 快速路径和慢速路径重新汇合

  • 6 代码的其余部分,有更多的标签、awaiter 等

  • 7 通过构建器传播所有异常

  • 8 通过构建器传播方法完成

大的try/catch块覆盖了原始异步方法的所有代码。如果其中任何内容抛出异常,无论通过等待故障操作、调用抛出异常的同步方法,还是直接抛出异常,该异常都会被捕获并通过构建器传播。只有特殊的异常(例如ThreadAbortExceptionStackOverflowException)才会导致MoveNext()以异常结束。

try/catch块中,MoveNext()方法的开始始终是一个用于根据状态跳转到方法中正确代码的switch语句。如果状态为非负,这意味着你在 await 表达式之后恢复。否则,假设你正在第一次执行MoveNext()

关于其他状态呢?

在第 6.1 节中,我列出了可能的状态为未开始、执行中、暂停和完成(其中暂停是每个 await 表达式的独立状态)。为什么状态机不对未开始、执行中和完成状态进行不同的处理?

答案是MoveNext()在执行或完成状态下永远不会最终被调用。你可以通过编写有缺陷的等待者实现或使用反射来强制这样做,但在正常操作中,MoveNext()只被调用以启动或恢复状态机。甚至没有未开始和执行的不同状态编号;两者都使用-1。完成状态有一个-2 的状态编号,但状态机永远不会检查该值。

需要注意的一个小技巧是状态机中的 return 语句与原始异步代码中的 return 语句之间的区别。在状态机内部,return用于在为 awaiter 安排了继续执行后状态机暂停时。原始代码中的任何 return 语句最终都会下降到状态机底部的try/catch块之外,方法完成通过构建器传播。

如果你比较列表 6.3 和 6.4,希望你能看到我们的具体示例如何融入一般模式。到目前为止,我已经几乎解释了关于你开始时简单异步方法生成的代码的各个方面。唯一缺失的部分就是 await 表达式周围的确切发生情况。

6.2.3. 深入研究 await 表达式

让我们再次思考,当你执行异步方法并遇到 await 表达式时,每次必须发生什么,假设你已经评估操作数以获取可以 await 的东西:

  1. 你通过调用GetAwaiter()从 awaitable 中获取等待者,并将其存储在堆栈上。

  2. 你检查等待者是否已经完成。如果是,你可以直接跳转到获取结果(步骤 9)。这是快速路径。

  3. 看起来你正在走慢路。哦,好吧。记住你通过状态字段达到的位置。

  4. 记住字段中的等待者。

  5. 使用等待者安排一个继续执行,确保当继续执行时,你会回到正确的状态(如果需要,做装箱舞蹈)。

  6. MoveNext()方法返回,无论是第一次暂停,还是由什么安排了继续执行。

  7. 当继续执行时,将你的状态设置回运行中(值为-1)。

  8. 将等待者从字段中复制出来,并将其放回堆栈上,以清除字段,这可能会帮助垃圾收集器。现在你准备好重新加入快速路径。

  9. 从堆栈上的等待者获取结果,无论你选择了哪条路径。即使没有结果,你也必须调用GetResult(),以便等待者可以传播错误(如果需要的话)。

  10. 继续你的愉快之旅,使用结果值(如果有的话)执行原始代码的其余部分。

在记住这个列表的情况下,让我们回顾一下列表 6.3 中对应我们第一个 await 表达式的部分。

列表 6.5. 对应单个await的列表 6.3 的一部分
    awaiter1 = Task.Delay(this.delay).GetAwaiter();
    if (awaiter1.IsCompleted)
    {
        goto GetFirstAwaitResult;
    }
    this.state = num = 0;
    this.awaiter = awaiter1;
    this.builder.AwaitUnsafeOnCompleted(ref awaiter1, ref this);
    return;
FirstAwaitContinuation:
    awaiter1 = this.awaiter;
    this.awaiter = default(TaskAwaiter);
    this.state = num = -1;
GetFirstAwaitResult:
    awaiter1.GetResult();

毫不奇怪,代码精确地遵循了步骤集合。^([2]) 两个标签代表根据路径必须跳转的两个地方:

²

这并不奇怪,因为我写了一系列步骤,然后展示的代码没有遵循这些步骤,那就太奇怪了。

  • 在快速路径中,你跳过了慢速路径的代码。

  • 在慢速路径中,当调用后续操作时,你会跳回代码的中间部分。(记住,这就是方法开头switch语句的作用。)

builder.AwaitUnsafeOnCompleted(ref awaiter1, ref this)的调用是执行与SetStateMachine(如果需要;每个状态机只发生一次)的回调并安排后续操作的部分。在某些情况下,你会看到对AwaitOnCompleted而不是AwaitUnsafeOnCompleted的调用。这些调用在处理执行上下文方面有所不同。你将在第 6.5 节中更详细地了解这一点。

可能有一个方面看起来稍微有点不清楚,那就是对num局部变量的使用。它总是在与state字段相同的时间被赋值,但总是读取字段而不是变量。(它的初始值是从字段中复制出来的,但这是字段唯一被读取的时候。)我相信这纯粹是为了优化。每次你读取num时,都可以将其视为this.state

查看列表 6.5,这 16 行代码原本只是以下内容:

await Task.Delay(delay);

好消息是,除非你正在做这种类型的练习,否则你几乎不需要看到所有这些代码。坏消息是,代码膨胀意味着即使是小的异步方法——即使是使用ValueTask<TResult>的方法——也无法被 JIT 编译器合理地内联。尽管如此,在大多数情况下,这只是为了异步/await 带来的好处而付出的微小代价。

这就是控制流简单的简单情况。有了这个背景,你可以探索一些更复杂的情况。

6.3. 控制流如何影响 MoveNext()

你迄今为止看到的例子只是一个方法调用的序列,其中只有await操作符引入了复杂性。当你想要编写带有所有你习惯的正常控制流语句的真实代码时,生活就会变得有点艰难。

在本节中,我将向你展示控制流的两个元素:循环和try/finally语句。这不是要全面介绍,但它应该足以让你窥见编译器必须执行的控件流技巧,以便在需要时帮助你理解其他情况。

6.3.1. await表达式之间的控制流简单

在我们深入到棘手的部分之前,我将给出一个例子,说明引入控制流不会使生成的代码复杂性增加,就像在同步代码中一样。在下面的列表中,我们在示例方法中引入了一个循环,所以你打印了三次Between delays而不是一次。

列表 6.6. 在 await 表达式之间引入循环
static async Task PrintAndWaitWithSimpleLoop(TimeSpan delay)
{
    Console.WriteLine("Before first delay");
    await Task.Delay(delay);
 for (int i = 0; i < 3; i++)
 {
 Console.WriteLine("Between delays");
 }
    await Task.Delay(delay);
    Console.WriteLine("After second delay");
}

反编译后是什么样子?非常像列表 6.2!唯一的区别是这一点

GetFirstAwaitResult:
    awaiter1.GetResult();
    Console.WriteLine("Between delays");
    TaskAwaiter awaiter2 = Task.Delay(this.delay).GetAwaiter();

变成以下:

GetFirstAwaitResult:
    awaiter1.GetResult();
    for (int i = 0; i < 3; i++)
 {
 Console.WriteLine("Between delays");
 }
    TaskAwaiter awaiter2 = Task.Delay(this.delay).GetAwaiter();

状态机的变化与原始代码的变化完全相同。没有额外的字段,也没有关于如何继续执行方面的复杂性;它只是一个循环。

我之所以提到这一点,是为了帮助你思考为什么在接下来的例子中需要额外的复杂性。在列表 6.6 中,你从不需要从外部跳入循环,也从不需要暂停执行并跳出循环,从而暂停状态机。这些都是 await 表达式在循环内部等待时引入的情况。现在让我们这样做。

6.3.2. 循环中的等待

我们到目前为止的例子中包含了两个 await 表达式。为了在引入其他复杂性时保持代码的相对可控性,我将将其减少到一。下面的列表显示了你在本节中将反编译的异步方法。

列表 6.7. 循环中的等待
static async Task AwaitInLoop(TimeSpan delay)
{
    Console.WriteLine("Before loop");
 for (int i = 0; i < 3; i++)
 {
 Console.WriteLine("Before await in loop");
 await Task.Delay(delay);
 Console.WriteLine("After await in loop");
 }
    Console.WriteLine("After loop delay");
}

Console.WriteLine 调用在反编译代码中主要作为标记存在,这使得将其映射到原始列表变得更加容易。

编译器为这个生成了什么?我不会展示完整的代码,因为其中大部分都是你之前见过的。(它都在可下载的源代码中。)状态机和方法几乎与之前的例子完全相同,只是在状态机中增加了一个对应于i(循环计数器)的字段。有趣的部分在MoveNext()中。

你可以用 C#忠实地表示这段代码,但不使用循环结构。问题是,在状态机从Task.Delay暂停返回后,你想要跳入原始循环的中间部分。在 C#中你不能用goto语句做到这一点;如果goto语句不在该标签的作用域内,语言禁止指定标签的goto语句。

这是可以的;你可以用很多goto语句实现for循环,而不引入任何额外的作用域。这样,你可以毫无问题地跳到其中。下面的列表显示了MoveNext()方法主体的反编译代码的大部分。我只包括了try块内的部分,因为这是我们这里关注的重点。(其余的都是简单的样板代码。)

列表 6.8. 不使用任何循环结构的反编译循环
    switch (num)
    {
        default:
            goto MethodStart;
        case 0:
            goto AwaitContinuation;
    }
MethodStart:
    Console.WriteLine("Before loop");
    this.i = 0;                                    *1*
    goto ForLoopCondition;                         *2*
ForLoopBody:                                       *3*
    Console.WriteLine("Before await in loop");
    TaskAwaiter awaiter = Task.Delay(this.delay).GetAwaiter();
    if (awaiter.IsCompleted)
    {
        goto GetAwaitResult;
    }
    this.state = num = 0;
    this.awaiter = awaiter;
    this.builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
    return;
AwaitContinuation:                                 *4*
    awaiter = this.awaiter;
    this.awaiter = default(TaskAwaiter);
    this.state = num = -1;
GetAwaitResult:
    awaiter.GetResult();
    Console.WriteLine("After await in loop");
    this.i++;                                      *5*
ForLoopCondition:                                  *6*
    if (this.i < 3)                                *6*
    {                                              *6*
        goto ForLoopBody;                          *6*
    }                                              *6*
    Console.WriteLine("After loop delay");
  • 1 for 循环初始化器

  • 2 直接跳转到检查循环条件

  • 3 for 循环的主体

  • 4 状态机恢复时的跳转目标

  • 5 循环迭代器

  • 6 检查循环条件并在条件成立时跳回主体

我本可以完全跳过这个示例,但它提出了几个有趣的观点。首先,C# 编译器不会将异步方法转换为不使用 async/await 的等效 C# 代码。它只需要生成适当的 IL。在某些地方,C# 的规则比 IL 更严格。(有效标识符的集合就是这种情况的另一个例子。)

第二,虽然反编译器在查看异步代码时可能很有用,但有时它们会产生无效的 C# 代码。当我第一次反编译 列表 6.7 的输出时,输出包括一个包含标签的 while 循环和一个试图跳入该循环的 goto 语句。有时,通过告诉反编译器不要那么努力地生成惯用的 C# 代码,你可以得到有效的(但更难阅读的)C# 代码,这时你会看到大量的 goto 语句。

第三,即使你还没有被说服,你也不想手动编写这种类型的代码。如果你必须为这种任务编写 C# 4 代码,你无疑会以非常不同的方式来做,但它仍然会比 C# 5 中可用的异步方法要丑陋得多。

你已经看到了在循环中等待可能会给人类带来一些压力,但这不会让编译器感到压力。对于我们的最后一个控制流示例,你将给它一些更困难的工作来做:一个 try/finally 块。

6.3.3. 在 try/finally 块中等待

只是为了提醒你,在 try 块中使用 await 一直是有效的,但在 C# 5 中,在 catchfinally 块中使用它是无效的。这种限制在 C# 6 中被取消,尽管我不会展示任何利用它的代码。

注意

这里有很多可能性,无法一一列举。本章的目的是让你了解 C# 编译器对 async/await 所做的事情,而不是提供一个详尽的翻译列表。

在本节中,我只将展示一个在只有一个 finally 块的 try 块中等待的示例。这可能是最常见的 try 块类型,因为它与 using 语句等价。下面的列表显示了你要反编译的异步方法。同样,所有的控制台输出都只是为了使状态机的理解更简单。

列表 6.9. 在 try 块中等待
static async Task AwaitInTryFinally(TimeSpan delay)
{
    Console.WriteLine("Before try block");
    await Task.Delay(delay);
    try
    {
        Console.WriteLine("Before await");
        await Task.Delay(delay);
        Console.WriteLine("After await");
    }
    finally
    {
        Console.WriteLine("In finally block");
    }
    Console.WriteLine("After finally block");
}

你可能会想象反编译后的代码看起来可能像这样:

    switch (num)
    {
        default:
            goto MethodStart;
        case 0:
            goto AwaitContinuation;
    }
MethodStart:
    ...
    try
    {
        ...
    AwaitContinuation:
        ...
    GetAwaitResult:
        ...
    }
        finally
    {
        ...
    }
    ...

这里,每个省略号(...)代表更多的代码。但这种方法有一个问题:即使在 IL 中,你也不允许从一个 try 块外部跳转到内部。这有点像你在上一节中看到的循环问题,但这次不是 C# 规则,而是 IL 规则。

为了实现这一点,C# 编译器使用了一种我愿意称之为 弹跳板 的技术。(这不是官方术语,尽管在其他类似用途中使用了这个术语。)它跳转到 try 块之前,然后 try 块中的第一段代码就是跳转到块内正确位置的代码。

除了弹跳板之外,finally 块也需要小心处理。有三种情况下你会执行生成的代码中的 finally 块:

  • 你到达了 try 块的末尾。

  • try 块抛出一个异常。

  • 你需要在 try 块内暂停,因为有一个 await 表达式。

(如果异步方法包含一个返回语句,那将是一个选项。)如果 finally 块正在执行,是因为你暂停了状态机并返回到调用者,原始异步方法的 finally 块中的代码不应该执行。毕竟,你逻辑上在 try 块内暂停,当延迟完成时你将从中恢复。幸运的是,这很容易检测:如果状态机仍在执行或已完成,局部变量 num(它始终与 state 字段相同)将是负数;如果你在暂停,它将是非负数。

所有这些加在一起导致了以下列表,这又是 MoveNext() 的外部 try 块中的代码。尽管代码仍然很多,但大部分都是你之前见过的。我用粗体突出了 try/finally-specific 的方面。

列表 6.10. try/finally 中的反编译 await
    switch (num)
    {
        default:
            goto MethodStart;
        case 0:
            goto AwaitContinuationTrampoline;    *1*
    }
MethodStart:
    Console.WriteLine("Before try");
AwaitContinuationTrampoline:
    try
    {
        switch (num) *2*
        { *2*
            default: *2*
                goto TryBlockStart; *2*
            case 0: *2*
                goto AwaitContinuation; *2*
        } *2*
    TryBlockStart:                               *2*
        Console.WriteLine("Before await");
        TaskAwaiter awaiter = Task.Delay(this.delay).GetAwaiter();
        if (awaiter.IsCompleted)
        {
            goto GetAwaitResult;
        }
        this.state = num = 0;
        this.awaiter = awaiter;
        this.builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
        return;
     AwaitContinuation:                             *3*
        awaiter = this.awaiter;
        this.awaiter = default(TaskAwaiter);
        this.state = num = -1;
    GetAwaitResult:
        awaiter.GetResult();
        Console.WriteLine("After await");
    }
    finally
    {
        if (num < 0)                               *4*
        {                                          *4*
            Console.WriteLine("In finally block"); *4*
        }                                          *4*
    }
    Console.WriteLine("After finally block");
  • 1 跳转到弹跳板之前,以便可以将执行弹跳到正确位置

  • 2 try 块内的弹跳板

  • 3 实际的继续目标

  • 4 如果暂停,则有效忽略 finally 块

我保证这是本章的最后一段反编译内容。我想达到这个复杂程度,以便在你需要时帮助你导航生成的代码。这并不是说你在查看它时不需要保持清醒,尤其是考虑到编译器可以执行许多转换,使代码比我展示的更简单。正如我之前所说的,我总是使用 switch 语句来处理“跳转到 X”的代码片段,编译器有时可以使用更简单的分支代码。在阅读源代码时,在多种情况下保持一致性很重要,但这对于编译器来说并不重要。

我到目前为止略过的一个方面是,为什么 awaiter 必须实现 INotifyCompletion 但也可以实现 ICriticalNotifyCompletion,以及这对生成的代码产生的影响。现在让我们更仔细地看看。

6.4. 执行上下文和流程

在 第 5.2.2 节 中,我描述了同步上下文,这些上下文用于控制代码执行的线程。这是 .NET 中许多上下文之一,尽管它可能是最知名的。上下文提供了一种透明地维护信息的环境方式。例如,SecurityContext 跟踪当前的安全主体和代码访问安全。你不需要明确传递所有这些信息;它只是跟随你的代码,在几乎所有情况下都做正确的事情。一个类用于管理所有其他上下文:ExecutionContext

深入且令人畏惧的内容

我几乎不包括这一节。这已经是我对异步了解的极限了。如果你需要了解其细节,你将需要比这里包含的更多关于这个主题的知识。

我之所以详细说明这一点,仅仅是因为否则就没有任何解释可以解释为什么在构建器中既有 AwaitOnCompletedAwaitUnsafeOnCompleted,以及为什么等待者通常实现 ICriticalNotifyCompletion

作为提醒,TaskTask<T> 管理任何正在等待的任务的同步上下文。如果你在 UI 线程上等待一个任务,你的异步方法的后继将在 UI 线程上执行。你可以通过使用 Task.ConfigureAwait 来选择退出这种模式。你需要这样做,以便明确表示“我知道我的方法的其他部分不需要在相同的同步上下文中执行。”执行上下文并不像那样;当你异步方法继续执行时,你几乎总是希望保持相同的执行上下文,即使它是在不同的线程上。

这种保持执行上下文的行为被称为 。执行上下文被说成是跨越 await 表达式流动,这意味着所有代码都在相同的执行上下文中操作。是什么确保了这一点呢?嗯,AsyncTaskMethodBuilder 总是这样做,而 TaskAwaiter 有时会这样做。这就是事情变得复杂的地方。

INotifyCompletion.OnCompleted 方法只是一个普通的方法;任何人都可以调用它。相比之下,ICriticalNotifyCompletion.UnsafeOnCompleted 被标记为 [SecurityCritical]。它只能由受信任的代码调用,例如框架的 AsyncTaskMethodBuilder 类。

如果你编写自己的等待者类,并且关心在部分受信任的环境中正确且安全地运行代码,你应该确保你的 INotifyCompletion.OnCompleted 代码使执行上下文流动(通过 ExecutionContext.CaptureExecutionContext.Run)。你也可以实现 ICriticalNotifyCompletion,在这种情况下不使执行上下文流动,相信异步基础设施已经这样做了。实际上,这为 awaiter 只由异步基础设施使用的常见情况提供了一种优化。在你可以安全地只做一次的情况下,没有必要捕获和恢复执行上下文两次。

在编译异步方法时,编译器会在每个 await 表达式处创建对 builder.AwaitOnCompletedbuilder.AwaitUnsafeOnCompleted 的调用,具体取决于 awaiter 是否实现了 ICriticalNotifyCompletion。这些构建器方法是泛型的,并具有约束以确保传递给它们的 awaiter 实现了适当的接口。

如果你曾经实现过自己的自定义任务类型(并且,再次强调,除了教育目的之外,这几乎是不可能的),你应该遵循 AsyncTaskMethodBuilder 的相同模式:在 AwaitOnCompletedAwaitUnsafeOnCompleted 中捕获执行上下文,这样在需要时调用 ICriticalNotifyCompletion.UnsafeOnCompleted 是安全的。说到自定义任务,既然你已经看到了编译器如何使用 AsyncTaskMethodBuilder,现在让我们回顾一下自定义任务构建器的需求。

6.5. 重新审视自定义任务类型

列表 6.11 显示了 列表 5.10 中构建器部分的重复,你首先在那里查看自定义任务类型。在查看了许多反编译的状态机之后,这组方法可能感觉要熟悉得多。你可以使用本节作为 AsyncTaskMethodBuilder 上的方法如何被调用的提醒,因为编译器以相同的方式处理所有构建器。

列表 6.11. 一个示例自定义任务构建器
public class CustomTaskBuilder<T>
{
    public static CustomTaskBuilder<T> Create();
    public void Start<TStateMachine>(ref TStateMachine stateMachine)
        where TStateMachine : IAsyncStateMachine;
    public CustomTask<T> Task { get; }

    public void AwaitOnCompleted<TAwaiter, TStateMachine>
        (ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine;
    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>
        (ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine;
    public void SetStateMachine(IAsyncStateMachine stateMachine);

    public void SetException(Exception exception);
    public void SetResult(T result);
}

我已按它们被调用的正常时间顺序将方法分组。

存根方法调用 Create 来创建构建器实例,作为新创建的状态机的一部分。然后它调用 Start 使状态机采取第一步,并返回 Task 属性的结果。

在状态机内部,每个 await 表达式将生成对 AwaitOnCompletedAwaitUnsafeOnCompleted 的调用,正如前一小节所讨论的。假设一个类似任务的设计,第一个这样的调用最终将调用 IAsyncStateMachine.SetStateMachine,这将反过来调用构建器的 SetStateMachine,以便以一致的方式解决任何装箱问题。有关详细信息的提醒,请参阅第 6.1.4 节。

最后,状态机通过在构建器上调用 SetExceptionSetResult 来指示异步操作已完成。该最终状态应传播到原始由存根方法返回的自定义任务。

这章是本书中迄今为止最深入的探讨。在其他地方,我没有如此详细地查看 C#编译器生成的代码。对于许多开发者来说,本章中的所有内容似乎都是多余的;你实际上并不需要它来正确编写 C#中的异步代码。但对于好奇的开发者,我希望它已经提供了启示。你可能永远不需要反编译生成的代码,但了解底层发生的事情可能会有所帮助。如果你确实需要详细查看正在发生的事情,我希望本章能帮助你理解你所看到的内容。

我用两章的篇幅来介绍 C# 5 的一个主要特性。在下一章中,我将介绍剩下的两个特性。在异步的细节之后,它们会带来一些轻松的缓解。

摘要

  • 异步方法通过使用构建器作为异步基础设施被转换为存根方法和状态机。

  • 状态机跟踪构建器、方法参数、局部变量、awaiters 以及在哪里继续执行。

  • 当它恢复时,编译器创建代码以返回方法中的中间位置。

  • INotifyCompletionICriticalNotifyCompletion接口帮助控制执行上下文流。

  • 自定义任务构建器的由 C#编译器调用。

第七章. C# 5 新增功能

本章涵盖

  • foreach循环中变量捕获的变化

  • 调用者信息属性

如果 C#的设计考虑到了图书作者,那么这一章就不会存在,或者它的长度会更标准。我可以说,我想包含一个非常短的章节,作为 C# 5 提供的异步代码和 C# 6 的甜蜜之间的调色板清洁剂,但现实是,C# 5 中需要涵盖的两个更多更改无法适应异步章节。其中之一与其说是一个特性,不如说是对语言设计早期错误的一种纠正。

7.1. 在foreach循环中捕获变量

在 C# 5 之前,foreach循环在语言规范中被描述为每个循环声明一个单独的迭代变量,该变量在原始代码中是只读的,但在循环的每次迭代中都会获得不同的值。例如,在 C# 3 中,对List<string>foreach循环如下

foreach (string name in names)
{
    Console.WriteLine(name);
}

将广泛等同于以下内容:

string name;                                  *1*
using (var iterator = names.GetEnumerator())  *2*
{
    while (iterator.MoveNext())
    {
        name = iterator.Current;              *3*
        Console.WriteLine(name);              *4*
    }
}
  • 1 单个迭代变量的声明

  • 2 不可见的迭代变量

  • 3 在每次迭代中为迭代变量分配新值

  • 4 foreach循环的原始主体

注意

规范中还有许多关于集合和元素可能转换的其他细节,但它们与这次更改无关。此外,迭代变量的作用域仅限于循环的作用域;你可以想象在整段代码周围添加额外的花括号。

在 C# 1 中,这没问题,但自从 C# 2 引入匿名方法以来,它开始引起问题。那是第一次一个变量可以被 捕获,从而显著改变其生命周期。当一个变量在匿名函数中使用时,它就会被捕获,编译器需要在幕后做工作以使其使用感觉自然。尽管 C# 2 中的匿名方法很有用,但我的印象是,真正鼓励开发者更广泛地使用委托的是 C# 3,它引入了 lambda 表达式和 LINQ。

我们之前使用单个迭代变量扩展 foreach 循环时遇到了什么问题?如果这个迭代变量被用于委托的匿名函数中,那么每次委托被调用时,委托将使用该单个变量的当前值。以下列表显示了一个具体的例子。

列表 7.1. 在 foreach 循环中捕获迭代变量
List<string> names = new List<string> { "x", "y", "z" };
var actions = new List<Action>();
foreach (string name in names)                    *1*
{
    actions.Add(() => Console.WriteLine(name));   *2*
}
foreach (Action action in actions)                *3*
{                                                 *3*
    action();                                     *3*
}                                                 *3*
  • 1 遍历名称列表

  • 2 创建一个捕获名称的委托

  • 3 执行所有委托

如果我没有提醒你这个问题,你期望它会打印出什么?大多数开发者会期望它打印 x,然后 y,然后 z。这是有用的行为。实际上,在版本 5 之前的 C# 编译器中,它会打印 z 三次,这实际上并不 helpful。

自 C# 5 开始,foreach 循环的规范已经改变,以便在循环的每次迭代中引入一个新的变量。在 C# 5 及以后的版本中,相同的代码会产生预期的 xyz 结果。

注意,这个更改仅影响 foreach 循环。如果你使用常规的 for 循环代替,你仍然只会捕获一个变量。以下列表与 列表 7.1 相同,除了显示加粗的更改。

列表 7.2. 在 for 循环中捕获迭代变量
List<string> names = new List<string> { "x", "y", "z" };
var actions = new List<Action>();
for (int i = 0; i < names.Count; i++) *1*
{
    actions.Add(() => Console.WriteLine(names[i]));  *2*
}
foreach (Action action in actions)                   *3*
{                                                    *3*
    action();                                        *3*
}                                                    *3*
  • 1 遍历名称列表

  • 2 创建一个捕获名称和 i 的委托

  • 3 执行所有委托

这不会打印最后一个名字三次;它会因为 ArgumentOutOfRangeException 而失败,因为当你开始执行委托时,i 的值已经是 3。

这不是 C# 设计团队的疏忽。只是当 for 循环初始化器声明一个局部变量时,它在整个循环期间只声明一次。循环的语法使得这种模型很容易看到,而 foreach 循环的语法则鼓励一种每迭代一个变量的心理模型。接下来,我们来看看 C# 5 的最后一个特性:调用者信息属性。

7.2. 通话者信息属性

一些功能是通用的,例如 lambda 表达式、隐式类型局部变量、泛型等。其他功能则更具体:LINQ 的目的是查询某种形式的数据,尽管它旨在泛化许多数据源。C# 5 的最后一个功能非常具体:有两个重要的用例(一个明显,一个稍微不那么明显),我不期望它会在那些情况之外被广泛使用。

7.2.1. 基本行为

.NET 4.5 引入了三个新的属性:

  • CallerFilePathAttribute

  • CallerLineNumberAttribute

  • CallerMemberNameAttribute

这些都在 System.Runtime.CompilerServices 命名空间中。就像其他属性一样,当你应用这些属性中的任何一个时,你可以省略 Attribute 后缀。因为这是使用属性最常见的方式,所以我在本书的其余部分将适当地缩写这些名称。

所有三个属性只能应用于参数,并且只有在它们应用于具有适当类型的可选参数时才有用。这个想法很简单:如果调用位置没有提供参数,编译器将使用当前文件、行号或成员名称来填充参数,而不是采用正常的默认值。如果调用者提供了参数,编译器将保持不变。

注意

在正常使用中,参数类型几乎总是 intstring。在适当的转换可用的情况下,它们可以是其他类型。如果你对此感兴趣,请参阅规范以获取详细信息,但我很惊讶你真的需要知道。

以下列表是所有三个属性以及编译器指定和用户指定值混合的示例。

列表 7.3. 基本演示调用者成员属性
static void ShowInfo(
    [CallerFilePath] string file = null,
    [CallerLineNumber] int line = 0,
    [CallerMemberName] string member = null)
{
    Console.WriteLine("{0}:{1} - {2}", file, line, member);
}

static void Main()
{
    ShowInfo();                                  *1*
    ShowInfo("LiesAndDamnedLies.java", -10);     *2*
}
  • 1 编译器提供上下文中的所有三个参数

  • 2 编译器仅提供上下文中的成员名称

我机器上 列表 7.3 的输出如下:

C:\Users\jon\Projects\CSharpInDepth\Chapter07\CallerInfoDemo.cs:20 - Main
LiesAndDamnedLies.java:-10 - Main

你通常不会为这些参数中的任何一个提供虚假值,但能够显式传递这些值是有用的,尤其是如果你想使用相同的属性记录当前方法的调用者时。

成员名称以通常方式适用于所有成员。属性的默认值通常无关紧要,但我们将回到一些有趣的边缘情况,在第 7.2.4 节中。首先,我们将查看我之前提到的两个常见用例。其中最普遍的是日志记录。

7.2.2. 日志记录

调用者信息最有用的明显情况是在写入日志文件时。以前在记录日志时,你通常会构建一个堆栈跟踪(例如使用 System .Diagnostics.StackTrace),以找出日志调用来自何处。这在日志框架中通常被隐藏起来,但它仍然存在——并且很丑陋。它可能在性能方面成为一个问题,并且面对 JIT 编译器的内联时很脆弱。

很容易看出,日志框架如何利用新特性以低成本记录调用者信息,甚至在面对调试信息被移除的构建和混淆之后,仍然可以保留行号和成员名称。当然,这并不能帮助你记录完整的堆栈跟踪,但这也并没有剥夺你记录的能力。

基于在 2017 年底进行的快速抽样,似乎这个功能还没有被特别广泛地使用.^([1]) 特别地,我没有看到它在 ASP.NET Core 中常用的ILogger接口中被使用。但是,为ILogger编写自己的扩展方法,使用这些属性并创建适当的要记录的状态对象是完全合理的。

¹

NLog 是我找到的唯一一个具有直接支持的日志框架,而且仅基于目标框架的条件性支持。

对于项目来说,包含它们自己的原始日志框架并不特别罕见,这些日志框架也可能适合使用这些属性。项目特定的日志框架不太可能需要担心针对不包含这些属性的框架。

注意

缺乏一个高效的系统级日志框架是一个棘手的问题。这对于希望提供日志功能但不想添加第三方依赖项且不知道用户将针对哪些日志框架的类库开发者来说尤其如此。

而对于日志用例,框架需要具体考虑,我们的第二个用例则简单得多。

7.2.3. 简化 INotifyPropertyChanged 实现

其中一个属性,[CallerMemberName]的用法可能对经常实现INotifyPropertyChanged的人来说很明显。如果你不熟悉INotifyPropertyChanged接口,它通常用于厚客户端应用程序(与 Web 应用程序相对),以允许用户界面响应对模型或视图模型的变化。它在System.ComponentModel命名空间中,因此它与任何特定的 UI 技术无关。例如,它在 Windows Forms、WPF 和 Xamarin Forms 中使用。该接口很简单;它是一个类型为PropertyChangedEventHandler的单个事件。这是一个具有以下签名的委托类型:

public delegate void PropertyChangedEventHandler(
    Object sender, PropertyChangedEventArgs e)

PropertyChangedEventArgs类有一个单一的构造函数:

public PropertyChangedEventArgs(string propertyName)

在 C# 5 之前,INotifyPropertyChanged的典型实现可能看起来像以下列表。

列表 7.4. 以旧方式实现INotifyPropertyChanged
class OldPropertyNotifier : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
    private int firstValue;
    public int FirstValue
    {
        get { return firstValue; }
        set
        {
            if (value != firstValue)
            {
                firstValue = value;
                NotifyPropertyChanged("FirstValue");
            }
        }
    }

    // (Other properties with the same pattern)

    private void NotifyPropertyChanged(string propertyName)
    {
        PropertyChangedEventHandler handler = PropertyChanged;
        if (handler != null)
        {
            handler(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

辅助方法的目的在于避免在每个属性中都要进行空值检查。你可以轻松地将其作为一个扩展方法,以避免在每个实现中重复。

这不仅仅是冗长(这并没有改变);它还非常脆弱。问题是属性的名称(FirstValue)被指定为一个字符串字面量,如果你将属性名称重构为其他名称,你很容易忘记更改字符串字面量。如果你很幸运,你的工具和测试将帮助你发现错误,但仍然很丑陋。你将在第九章中看到,C# 6 中引入的 nameof 运算符将使此代码更易于重构,但它仍然容易受到复制粘贴错误的影响。

使用调用者信息属性,大部分代码保持不变,但你可以在辅助方法中使用 CallerMemberName 来让编译器填写属性名称,如下面的列表所示。

列表 7.5. 使用调用者信息实现 INotifyPropertyChanged
if (value != firstValue)      *1*
{                             *1*
    firstValue = value;       *1*
    NotifyPropertyChanged(); *1*
}                             *1*

void NotifyPropertyChanged([CallerMemberName] string propertyName = null)
{
                              *2*
}
  • 1 属性设置器中的更改

  • 2 与之前相同的方法体

我只展示了代码中更改的部分;就这么简单。现在当你更改属性名称时,编译器将使用新的名称。这不是一个翻天覆地的改进,但仍然更好。

与日志记录不同,这种模式已被提供视图模型和模型基类的模型-视图-视图模型(MVVM)框架所接受。例如,在 Xamarin Forms 中,BindableObject 类有一个使用 CallerMemberNameOnPropertyChanged 方法。同样,Caliburn Micro MVVM 框架有一个具有 NotifyOfPropertyChange 方法的 PropertyChangedBase 类。这就是你可能需要了解的关于调用者信息属性的所有内容,但还有一些有趣的异常情况,尤其是与调用者成员名称相关。

7.2.4. 调用者信息属性的边缘情况

在几乎所有情况下,很明显编译器应该为调用者信息属性提供哪个值。然而,看看那些不明显的地方也很有趣。我应该强调,这主要是一个好奇的问题和对语言设计选择的一个聚焦,而不是会影响常规开发的问题。首先,有一点限制。

动态调用的成员

在许多方面,围绕动态类型的基础设施都尽力在执行时应用与常规编译器在编译时相同的规则。但调用者信息并未为此目的保留。如果被调用的成员包含一个具有调用者信息属性的可选参数,但调用没有包含相应的参数,则使用参数中指定的默认值,就像属性中没有该属性一样。

除了其他任何事情之外,编译器必须为每个动态调用的成员嵌入所有行号信息,以防万一需要,从而在不影响 99.9%的情况下增加生成的汇编大小。然后还有执行时所需的额外分析,以检查是否需要调用者信息,这可能会干扰缓存。我怀疑如果 C#设计团队认为这是一个常见且重要的情况,他们会找到一种使其工作的方式,但我认为他们决定有更多有价值的功能要花费时间在上是完全合理的。基本上,你只需要了解这种行为并接受它。尽管在某些情况下存在解决方案。

如果你传递的方法参数碰巧是动态的,但你不需要它是动态的,你可以将其强制转换为适当类型。在那个点上,方法调用将是一个常规调用,不涉及任何动态类型。^([2]) 如果你确实需要动态行为,但你知道你调用的成员使用调用者信息属性,你可以显式调用一个使用调用者信息属性来返回值的辅助方法。这有点难看,但这是一个边缘情况。以下列表显示了问题以及两种解决方案。

²

该调用将具有额外的编译时检查成员存在的好处,以及改进的执行时间效率。

列表 7.6. 调用者信息属性和动态类型
static void ShowLine(string message,                *1*
    [CallerLineNumber] int line = 0)                *1*
{                                                   *1*
    Console.WriteLine("{0}: {1}", line, message);   *1*
}                                                   *1*

static int GetLineNumber(                           *2*
    [CallerLineNumber] int line = 0)                *2*
{                                                   *2*
    return line;                                    *2*
}                                                   *2*

static void Main()
{
    dynamic message = "Some message";
    ShowLine(message);                              *3*
    ShowLine((string) message);                     *4*
    ShowLine(message, GetLineNumber());             *5*
}
  • 1 你试图调用的方法,该方法使用行号

  • 2 解决方案 2 的辅助方法

  • 3 简单动态调用;行号将报告为 0。

  • 4 解决方案 1:将值强制转换为移除动态类型。

  • 5 解决方案 2:使用辅助方法显式提供行号。

列表 7.6 对于第一次调用打印出 0 行号,但对于两种解决方案都打印出正确的行号。这是在简单代码和保留更多信息之间的一种权衡。当需要使用动态重载解析时,这两种解决方案都不适用,当然,一些重载需要调用者信息,而一些则不需要。在我看来,作为限制来说,这是相当合理的。接下来,让我们考虑不寻常的名称。

非直观的成员名称

当调用者成员名称由编译器提供且调用者是方法时,名称是明显的:它是方法的名称。但并非所有内容都是方法。以下是一些需要考虑的情况:

  • 从实例构造函数中的调用

  • 从静态构造函数中的调用

  • 从终结器中的调用

  • 从运算符中的调用

  • 作为字段、事件或属性初始化器一部分的调用^([3])

    ³

    自动实现的属性的初始化器是在 C# 6 中引入的。有关详细信息,请参阅第 8.2.2 节,但如果你猜测这是什么意思,你很可能会猜对。

  • 来自索引器的调用

这四个指定为实现相关;由编译器决定如何处理它们。第五个(初始化器)完全没有指定,最后一个(索引器)指定为使用名称Item,除非已应用IndexerNameAttribute到索引器上。

Roslyn 编译器使用 IL 中存在的名称,对于前四个:.ctor.cctorFinalize以及像op_Addition这样的运算符名称。对于初始化器,它使用正在初始化的字段、事件或属性的名称。

可下载的代码包含了一个完整的示例,展示了所有这些内容;我没有在这里包含代码,因为结果比代码本身更有趣。所有的名字都是最明显的选择,我很难想象不同的编译器会选择不同的选项。然而,我在另一个方面发现编译器之间存在差异:确定编译器何时应该填充调用者信息属性。

隐式构造函数调用

C# 5 语言规范要求仅在源代码中显式调用函数时才使用调用者信息,除非是认为属于语法扩展的查询表达式。其他基于模式的 C#语言结构无论如何都不适用于具有可选参数的方法,但构造函数初始化器肯定适用。(解构是 C# 7 的一个特性,在第 12.2 节中描述。)语言规范将构造函数作为一个例子,指出除非调用是显式的,否则编译器不会提供调用者成员信息。以下列表显示了一个使用调用者成员信息的单个抽象基类和三个派生类。

列表 7.7. 构造函数中的调用者信息
public abstract class BaseClass
{
    protected BaseClass(                                    *1*
        [CallerFilePath] string file = "Unspecified file",         
        [CallerLineNumber] int line = -1,                          
        [CallerMemberName] string member = "Unspecified member")   
    {
        Console.WriteLine("{0}:{1} - {2}", file, line, member);
    }
}

public class Derived1 : BaseClass { }                       *2*

public class Derived2 : BaseClass
{
    public Derived2() { }                                   *3*
}

public class Derived3 : BaseClass
{
    public Derived3() : base() {}                           *4*
}
  • 1 基类构造函数使用调用者信息属性。

  • 2 添加无参数构造函数是隐式的。

  • 3 使用隐式调用 base()的构造函数

  • 4 显式调用 base

使用 Roslyn,只有Derived3会显示实际的调用者信息。在Derived1Derived2中,由于对BaseClass构造函数的调用是隐式的,它们使用参数中指定的默认值,而不是提供文件名、行号和成员名。

这与 C# 5 规范一致,但我认为这是一个设计缺陷。我相信大多数开发者会期望这三个派生类是精确等价的。有趣的是,Mono 编译器(mcs)目前为这些派生类中的每一个都打印出相同的输出。我们将不得不等待,看看语言规范是否会改变,Mono 编译器是否会改变,或者不兼容性是否会延续到未来。

查询表达式调用

如我之前所述,语言规范指出查询表达式是编译器提供调用者信息的一个地方,尽管调用是隐式的。我怀疑这会被经常使用,但我已经在可下载的源代码中提供了一个完整的示例。它需要的代码比在这里包含的更合理,但其使用看起来如下所示。

列表 7.8. 查询表达式中的调用者信息
string[] source =
{
    "the", "quick", "brown", "fox",
    "jumped", "over", "the", "lazy", "dog"
};
var query = from word in source               *1*
            where word.Length > 3             *1*
            select word.ToUpperInvariant();   *1*
Console.WriteLine("Data:");
Console.WriteLine(string.Join(", ", query));  *2*
Console.WriteLine("CallerInfo:");
Console.WriteLine(string.Join(                *3*
    Environment.NewLine, query.CallerInfo));  *3*
  • 1 使用捕获调用者信息的方法的查询表达式

  • 2 记录数据

  • 3 记录查询的调用者信息

尽管它包含了一个常规的查询表达式,但我引入了新的扩展方法(与示例相同的命名空间,因此它们在 System.Linq 之前被找到),包含调用者信息属性。输出显示调用者信息被捕获在查询中,以及数据本身:

Data:
QUICK, BROWN, JUMPED, OVER, LAZY
CallerInfo:
CallerInfoLinq.cs:91 - Main
CallerInfoLinq.cs:92 - Main

这有用吗?坦白说,可能没有。但它确实突出了当语言设计者引入这个特性时,他们必须仔细考虑很多情况。如果有人发现查询表达式中的调用者信息有很好的用途,但规范没有明确说明应该发生什么,那将会很烦人。我们还有最后一种成员调用要考虑,这对我来说甚至比构造函数初始化和查询表达式更微妙:属性实例化。

带有调用者信息属性的属性

我倾向于将应用属性视为只是指定额外的数据。它感觉不像是在调用什么,但属性也是代码,当属性对象被构造(通常是为了从反射调用中返回)时,它会调用构造函数和属性设置器。如果你创建一个在构造函数中使用调用者信息属性的属性,调用者是什么?让我们找出答案。

首先,你需要一个属性类。这部分很简单,如下所示。

列表 7.9. 捕获调用者信息的属性类
[AttributeUsage(AttributeTargets.All)]
public class MemberDescriptionAttribute : Attribute
{
    public MemberDescriptionAttribute(
        [CallerFilePath] string file = "Unspecified file",
        [CallerLineNumber] int line = 0,
        [CallerMemberName] string member = "Unspecified member")
    {
        File = file;
        Line = line;
        Member = member;
    }

    public string File { get; }
    public int Line { get; }
    public string Member { get; }

    public override string ToString() =>
        $"{Path.GetFileName(File)}:{Line} - {Member}";
}

为了简洁起见,这个类使用了 C# 6 的几个特性,但现在的有趣之处在于构造函数参数使用了调用者信息属性。

当你应用我们新的 MemberDescriptionAttribute 时会发生什么?在下一个列表中,让我们将其应用于一个类和方法的各个方面,然后看看你得到什么。

列表 7.10. 将属性应用于一个类和方法
using MDA = MemberDescriptionAttribute;                         *1*

[MemberDescription]                                             *2*
class CallerNameInAttribute                                     *2*
{
    [MemberDescription]                                         *3*
    public void Method<[MemberDescription] T>(                  *3*
        [MemberDescription] int parameter) { }                  *3*

    static void Main()
    {
        var typeInfo = typeof(CallerNameInAttribute).GetTypeInfo();
        var methodInfo = typeInfo.GetDeclaredMethod("Method");
        var paramInfo = methodInfo.GetParameters()[0];
        var typeParamInfo =
            methodInfo.GetGenericArguments()[0].GetTypeInfo();
        Console.WriteLine(typeInfo.GetCustomAttribute<MDA>());
        Console.WriteLine(methodInfo.GetCustomAttribute<MDA>());
        Console.WriteLine(paramInfo.GetCustomAttribute<MDA>());
        Console.WriteLine(typeParamInfo.GetCustomAttribute<MDA>());
    }
}
  • 1 帮助保持反射代码简短

  • 2 将属性应用于一个类

  • 3. 以多种方式将属性应用于方法

Main 方法使用反射从所有应用了属性的地方获取该属性。您可以将 MemberDescriptionAttribute 应用于其他地方:字段、属性、索引器等。请随意使用可下载的代码进行实验,以了解确切会发生什么。我发现有趣的是,编译器在所有情况下都乐于捕获行号和文件路径,但它不使用类名作为成员名称,因此输出如下:

CallerNameInAttribute.cs:36 - Unspecified member
CallerNameInAttribute.cs:39 - Method
CallerNameInAttribute.cs:40 - Method
CallerNameInAttribute.cs:40 - Method

再次强调,这属于 C# 5 规范的一部分,它规定了当属性应用于函数成员(方法、属性、事件等)而不是类型时的行为。也许将类型也包括在内会更有用。因为类型被定义为命名空间成员,所以成员名称映射到类型名称是合情合理的。

只是为了再次强调,我包含这一部分的原因不仅仅是为了完整性。它突出了一些有趣的语言选择。在什么情况下,语言设计可以接受限制以避免实现成本?在什么情况下,语言设计的选择与用户期望发生冲突是合理的?在什么情况下,将决策明确转化为实现选择是有意义的?在元层面,语言设计团队应该花多少时间来指定相对较小特性的边缘情况?在我们结束这一章之前,还有最后一个实际细节:在属性不存在的框架上启用此功能。

7.2.5. 使用旧版.NET 的调用者信息属性

希望到现在为止,大多数读者都将目标定位在.NET 4.5+或.NET Standard 1.0+上,这两个版本都包含调用者信息属性。但在某些情况下,您仍然可以使用现代编译器,但需要针对旧框架。

在这些情况下,您仍然可以使用调用者信息属性,但您需要使属性对编译器可用。最简单的方法是使用 Microsoft.Bcl NuGet 包,它提供了属性以及框架后续版本提供的许多其他功能。

如果由于某种原因您不能使用 NuGet 包,您可以自己提供属性。它们是没有任何参数或属性的简单属性,因此您可以直接从 API 文档中复制声明。它们仍然需要位于 System.Runtime.CompilerServices 命名空间中。为了避免类型冲突,您需要确保这些属性仅在系统提供的属性不可用的情况下才可用。这可能很棘手(因为所有版本控制都倾向于如此),而且这些细节超出了本书的范围。

当我开始撰写这一章时,我没有预料到会写这么多关于通话者信息属性的内容。我无法说我日常工作中经常使用这个功能,但我发现其设计方面非常吸引人。这并不是因为这个功能是次要的;恰恰相反,正是因为它是次要的。你可能会预期主要功能——动态类型、泛型、async/await——需要大量的语言设计工作,但次要功能也可能存在各种边缘情况。功能之间通常会有交互,因此引入新功能的一个风险是它可能会使未来的功能设计或实现变得更加困难。

摘要

  • 在 C# 5 中,捕获的 foreach 迭代变量更有用。

  • 你可以使用通话者信息属性来请求编译器根据调用者的源文件、行号和成员名称填写参数。

  • 通话者信息属性展示了语言设计通常需要达到的详细程度。

第三部分. C# 6

C# 6 是我最喜欢的版本之一。它有很多特性,但它们大多相互独立,解释简单,并且容易应用到现有代码中。在某些方面,它们在阅读时可能并不令人兴奋,但它们仍然对你的代码可读性产生了巨大的影响。如果我有必要在 C# 的旧版本中编写代码,我会发现自己最怀念的是 C# 6 的特性。

虽然每个早期的 C# 版本都引入了一种全新的思考代码的方式(分别是泛型、LINQ、动态类型和 async/await),但 C# 6 更多的是对现有代码进行一些润色。

我将特性分为三个章节:关于属性的特性、关于字符串的特性,以及既不关于属性也不关于字符串的特性,但这有些随意。我建议按照自然顺序阅读这些章节,但与 LINQ 相比,这里并没有什么宏伟的计划。

由于 C# 6 的功能很容易应用到现有代码中,我建议你在使用过程中尝试它们。如果你维护一个项目,其中包含你一段时间没有接触过的旧代码,你可能会发现这是利用 C# 6 进行重构的理想之地。

第八章. 超简洁的属性和表达式主体成员

本章涵盖

  • 自动实现只读属性

  • 在声明时自动初始化实现属性

  • 使用表达式主体成员去除不必要的仪式

一些版本的 C# 有一个统一的大特性,几乎所有其他特性都与之相关。例如,C# 3 引入了 LINQ,C# 5 引入了异步操作。C# 6 并非如此,但它确实有一个总体主题。几乎所有特性都致力于编写更干净、更简单、更易读的代码。C# 6 不是关于做更多;它是关于用更少的代码做同样的事情。

本章中你将看到的特性是关于属性和其他简单代码片段的。当逻辑不是很多时,即使去除最小的仪式——例如大括号和返回语句——也能产生很大的影响。尽管这里的特性可能听起来并不令人印象深刻,但我对它们在实际代码中的影响感到惊讶。我们将从属性开始,然后转向方法、索引器和运算符。

8.1. 属性的简要历史

C# 从第一版开始就有属性。尽管它们的核心功能随着时间的推移并没有改变,但它们在源代码中的表达方式逐渐变得简单,并且更加灵活。属性允许你区分在 API 中如何暴露状态访问和操作,以及状态是如何实现的。

例如,假设你想表示二维空间中的一个点。你可以像以下列表所示那样轻松地使用公共字段来表示。

列表 8.1. Point 类的公共字段
public sealed class Point
{
    public double X;
    public double Y;
}

初看似乎还不错,但类的功能(“我可以访问其 X 和 Y 值”)与实现(“我将使用两个双精度字段”)紧密相关。但在这个阶段,实现已经失去了控制。只要类的状态通过字段直接暴露,你就不能做以下事情:

  • 在设置新值时执行验证(例如,防止 X 和 Y 坐标无限或非数字值)

  • 在获取值时执行计算(例如,如果你想要以不同的格式存储字段——对于点来说不太可能,但在其他情况下完全可行)

你可能会争辩说,当你发现你需要这样的东西时,你可以总是将字段更改为属性,但这是一种破坏性的更改,你可能会想避免。(这会破坏源兼容性、二进制兼容性和反射兼容性。仅仅为了避免一开始就使用属性,就承担这样的风险是很大的。)

在 C# 1 中,语言在属性方面几乎没有提供帮助。一个基于属性的 列表 8.1 版本将需要手动声明后置字段,以及每个属性的 getter 和 setter,如下一个列表所示。

列表 8.2. Point 类的 C# 属性 1
public sealed class Point
{
    private double x, y;
    public double X { get { return x; } set { x = value; } }
    public double Y { get { return y; } set { y = value; } }
}

你可能会争辩说,许多属性最初只是简单地读取和写入字段,没有额外的验证、计算或其他任何东西,并且在整个代码历史中保持这种状态。这样的属性本可以公开为字段,但很难预测哪些属性以后可能需要额外的代码。即使你可以准确做到这一点,这也感觉像是在没有理由的情况下在两个抽象级别上操作。对我来说,属性充当类型提供的契约的一部分:其宣传的功能。字段仅仅是实现细节;它们是盒子内部的机制,在绝大多数情况下用户不需要了解。我几乎在所有情况下都更喜欢字段是私有的。

注意

就像所有好的经验法则一样,总有例外。在某些情况下,直接公开字段是有意义的。当你查看 C# 7 提供的元组时,你会在 第十一章 中看到一个有趣的案例。

C# 2 中属性的唯一改进是允许 getter 和 setter 使用不同的访问修饰符——例如,公共 getter 和私有 setter。(这并不是唯一可用的组合,但这是最常见的一种。)

C# 3 然后添加了自动实现属性,这使得 列表 8.2 可以以更简单的方式重写,如下所示。

列表 8.3. Point 类的 C# 3 属性
public sealed class Point
{
    public double X { get; set; }
    public double Y { get; set; }
}

这段代码几乎与列表 8.2 中的代码完全相同,除了无法直接访问支持字段之外。它们被赋予了难以言喻的名称,这些名称不是有效的 C# 标识符,但在运行时是可行的。

重要的是,C# 3 只允许自动实现只读/写属性。我这里不会详细讨论不可变性的所有好处(以及陷阱),但有许多原因你可能想让你的 Point 类成为不可变的。为了使你的属性真正只读,你需要手动编写代码。

列表 8.4. 使用 C# 3 的手动实现只读属性的 Point
public sealed class Point
{
    private readonly double x, y;             *1*
    public double X { get { return x; } }     *2*
    public double Y { get { return y; } }     *2*

    public Point(double x, double y)
    {
        this.x = x;                           *3*
        this.y = y;                           *3*
    }
}
  • 1 声明只读字段

  • 2 声明返回字段值的只读属性

  • 3 在构造时初始化字段

这至少是令人烦恼的。许多开发者——包括我——有时会作弊。如果我们想要只读属性,我们会使用具有私有设置器的自动实现属性,如下面的列表所示。

列表 8.5. 使用 C# 3 的私有设置器自动实现公开只读属性的 Point
public sealed class Point
{
    public double X { get; private set; }
    public double Y { get; private set; }

    public Point(double x, double y)
    {
        X = x;
        Y = y;
    }
}

这虽然可行,但并不令人满意。它没有表达你的意图。它允许你在类内部更改属性的值,即使你不想这样做;你想要一个可以在构造函数中设置但之后在其他地方永远不会改变的属性,并且你希望它以简单的方式由一个字段支持。直到包括 C# 5 在内,语言迫使你选择实现简单性和意图清晰之间的权衡,每个选择都牺牲了另一个。自从 C# 6 以来,你不再需要妥协;你可以编写简洁的代码,清楚地表达你的意图。

8.2. 自动实现属性的升级

C# 6 引入了两个新特性来自动实现属性。这两个特性都易于解释和使用。在前一节中,我强调了暴露属性而不是公共字段的重要性,以及简洁实现不可变类型所面临的困难。你可能会猜到我们的第一个新特性在 C# 6 中是如何工作的,但还有一些限制也被放宽了。

8.2.1. 只读自动实现属性

C# 6 允许以简单的方式表达由只读字段支持的真正只读属性。只需一个空的获取器,没有设置器,就像在下一条列表中所示。

列表 8.6. 使用只读自动实现的属性的 Point
public sealed class Point
{
    public double X { get; }             *1*
    public double Y { get; }             *1*

    public Point(double x, double y)
    {
        X = x;                           *2*
        Y = y;                           *2*
    }
}
  • 1 自动声明只读属性

  • 2 在构造时初始化属性

从 列表 8.5 中改变的部分是 XY 属性的声明;它们根本不再有设置器。鉴于没有设置器,你可能想知道如何在构造函数中初始化属性。它正好像在 列表 8.4 中手动实现的那样发生:自动实现的属性声明的字段是只读的,并且对属性的任何赋值都会由编译器转换为直接的字段赋值。在构造函数之外尝试设置属性将导致编译时错误。

作为不可变性的粉丝,这对我来说是一个真正的进步。它让你可以用少量的代码表达你的理想结果。现在,懒惰不再是代码卫生的障碍,至少在这个小方面是这样。

C# 6 中移除的下一个限制与初始化有关。到目前为止,我展示的属性要么根本未显式初始化,要么在构造函数中初始化。但如果你想要像字段一样初始化一个属性呢?

8.2.2. 自动实现属性的初始化

在 C# 6 之前,自动实现属性的任何初始化都必须在构造函数中;你无法在声明的地方初始化属性。例如,假设你有一个 C# 2 中的 Person 类,如下面的列表所示。

列表 8.7. C# 2 中具有手动属性的 Person
public class Person
{
    private List<Person> friends = new List<Person>();     *1*
    public List<Person> Friends                            *2*
    {
        get { return friends; }
        set { friends = value; }
    }
}
  • 1 声明并初始化字段

  • 2 暴露属性以读写字段

如果你想要将此代码更改为使用自动实现属性,你必须将初始化移动到构造函数中,而之前你根本未显式声明任何构造函数。你最终会得到如下列表所示的代码。

列表 8.8. C# 3 中具有自动实现属性的 Person
public class Person
{
    public List<Person> Friends { get; set; }      *1*

    public Person()
    {
        Friends = new List<Person>();              *2*
    }
}
  • 1 声明属性;不允许初始化器

  • 2 在构造函数中初始化属性

这就是之前那么冗长!在 C# 6 中,这个限制被移除了。你可以在属性声明的地方进行初始化,如下面的列表所示。

列表 8.9. C# 6 中具有自动实现读写属性的 Person
public class Person
{
    public List<Person> Friends { get; set; } =   *1*
        new List<Person>();                       *1*
}
  • 1 声明并初始化读写自动实现

自然地,你也可以使用这个特性与只读自动实现属性一起。一个常见的模式是有一个只读属性暴露一个可变集合,这样调用者可以添加或从集合中删除项目,但永远不能更改属性以引用不同的集合(或将其设置为 null 引用)。正如你所预期的,这只是一个移除设置器的问题。

列表 8.10. C# 6 中具有自动实现只读属性的 Person
public class Person
{
    public List<Person> Friends { get; } =   *1*
        new List<Person>();                  *1*
}
  • 1 声明并初始化只读自动实现

我很少发现 C# 早期版本的这个特定限制是一个大问题,因为通常我想要根据构造函数参数初始化属性,但这个变化无疑是一个受欢迎的补充。下一个被移除的限制与只读自动实现属性结合使用时变得更为重要。

8.2.3. 结构体中的自动实现属性

在 C# 6 之前,我总是觉得自动实现属性在结构体中有点问题。有两个原因:

  • 我总是编写不可变结构体,因此缺少只读自动实现属性一直是一个痛点。

  • 由于确定赋值规则,我只能在链接到另一个构造函数之后才能在构造函数中赋值给自动实现属性。

注意

通常,确定赋值规则是关于编译器跟踪在代码的特定点哪些变量将被赋值,无论你是如何到达那里的。这些规则主要与局部变量相关,以确保你不会尝试从尚未赋值的局部变量中读取。这里,我们正在查看相同规则的不同用途。

以下列表展示了这两个点在我们之前 Point 类的结构体版本中的示例。仅仅写下它就让我感到有些不舒服。

列表 8.11. C# 5 中使用自动实现属性的 Point 结构体
public struct Point
{
    public double X { get; private set; }           *1*
    public double Y { get; private set; }           *1*

    public Point(double x, double y) : this()       *2*
    {
        X = x;                                      *3*
        Y = y;                                      *3*
    }
}
  • 1 具有公共获取器和私有设置器的属性

  • 2 链接到默认构造函数

  • 3 属性初始化

这不是我会包含在真实代码库中的代码。自动实现属性的好处被其丑陋性所抵消。你已经熟悉属性的只读方面,但为什么你需要在构造函数初始化器中调用默认构造函数?

答案在于结构体字段赋值规则中的细微差别。这里有两个规则在起作用:

  • 在结构体中,直到编译器认为所有字段都已确定赋值之前,你不能使用任何属性、方法、索引器或事件。

  • 每个结构体构造函数在返回控制权给调用者之前必须为所有字段分配值。

在 C# 5 中,如果不调用默认构造函数,你将违反两条规则。设置 XY 属性仍然算作使用了结构体的值,因此不允许这样做。设置属性并不算作分配字段,所以无论如何你都不能从构造函数中返回。链接到默认构造函数是一个解决方案,因为这样会在你的构造函数体执行之前分配所有字段。然后你可以设置属性并在最后返回,因为编译器很高兴地看到所有字段都已设置。

在 C# 6 中,语言和编译器对自动实现属性及其所依赖的字段之间的关系有了更深入的理解:

  • 你可以在所有字段初始化之前设置自动实现的属性。

  • 设置自动实现的属性算作初始化字段。

  • 在其他字段初始化之前,你可以读取一个自动实现的属性,只要你事先已经设置了它。

另一种思考方式是,在构造函数中,自动实现的属性被视为字段。

在这些新规则生效并且有真正的只读自动实现属性的情况下,C# 6 中 Point 结构的版本与下一列表中的类版本相同,除了声明为结构体而不是密封类。

列表 8.12. C# 6 中使用自动实现的属性的 Point 结构
public struct Point
{
    public double X { get; }
    public double Y { get; }

    public Point(double x, double y)
    {
        X = x;
        Y = y;
    }
}

结果是干净简洁的,正是你想要的。

注意

你可能会问是否应该将 Point 实现为结构体。在这种情况下,我处于两可之间。点确实感觉像是相当自然的值类型,但我仍然通常默认创建类。在 Noda Time(该库以结构体为主)之外,我很少编写自己的结构体。这个例子当然不是试图建议你应该更广泛地使用结构体,但如果你确实编写了自己的结构体,语言比以前更有帮助。

到目前为止,你所看到的一切都使得自动实现的属性更容易使用,这通常减少了样板代码的数量。但并非所有属性都是自动实现的。从你的代码中移除杂乱的任务并没有停止在这里。

8.3. 表达式成员

我不会规定 C# 中的特定编码风格。除了其他任何事情之外,不同的问题领域适合不同的方法。但我确实遇到过具有许多简单方法和属性的类型。C# 6 通过 表达式成员 帮助你在这里。我们将从属性开始,因为你之前已经看过它们,然后我们将看到同样的想法可以应用于其他函数成员。

8.3.1. 更简单的只读计算属性

有些属性很简单:如果字段的实现与类型的逻辑状态相匹配,属性可以直接返回字段值。这就是自动实现属性的作用。其他属性涉及基于其他字段或属性的运算。为了展示 C# 6 解决的问题,以下列表为我们的 Point 类添加了另一个属性:DistanceFromOrigin,它以简单的方式使用勾股定理来返回点与原点的距离。

注意

如果这里的数学不熟悉,细节不重要,重要的是它是一个使用 XY 的只读属性。

列表 8.13. 向 Point 添加 DistanceFromOrigin 属性
public sealed class Point
{
    public double X { get; }
    public double Y { get; }

    public Point(double x, double y)
    {
        X = x;
        Y = y;
    }

    public double DistanceFromOrigin                   *1*
    {                                                  *1*
        get { return Math.Sqrt(X * X + Y * Y); }       *1*
    }                                                  *1*
}
  • 1 只读属性用于计算距离

我不会声称这很难阅读,但它确实包含了很多我可以描述为仪式的语法:它只存在于让编译器知道有意义代码如何适应的地方。图 8.1 显示了相同的属性,但已注释以突出有用的部分;仪式(花括号、返回语句和分号)以较浅的色调显示。

图 8.1. 属性声明注释,显示重要方面

图片

C# 6 允许你以更简洁的方式表达:

public double DistanceFromOrigin => Math.Sqrt(X * X + Y * Y);

在这里,=>用于表示表达式成员体——在这种情况下,一个只读属性。不再需要花括号,不再需要关键字。只读属性部分以及表达式用于返回值的事实都是隐式的。与图 8.1 进行比较,你会发现表达式成员体形式包含所有有用的部分(以不同的方式表示它是只读属性)以及没有多余的部分。完美!

不,这不是一个 lambda 表达式

是的,你之前已经见过这个语法元素。Lambda 表达式在 C# 3 中引入,作为一种声明委托和表达式树的简短方式。例如:

Func<string, int> stringLength = text => text.Length;

表达式成员体使用=>语法,但不是 lambda 表达式。DistanceFromOrigin前面的声明不涉及任何委托或表达式树;它只指示编译器创建一个计算给定表达式并返回结果的只读属性。

当大声谈论语法时,我通常将=>描述为粗箭头

你可能想知道这是否在现实世界中是有用的,而不仅仅是书中虚构的例子。为了向您展示具体的例子,我将使用 Noda Time。

透传或委托属性

我们将简要考虑 Noda Time 中的三种类型:

  • LocalDate—特定日历中的日期,没有时间组件

  • LocalTime—一天中的时间,没有日期组件

  • LocalDateTime—日期和时间的组合

不要担心初始化等细节;只需考虑你希望从三种类型中得到什么。显然,日期将具有年、月和日等属性,而时间将具有小时、分钟、秒等属性。那么,结合这两种类型的类型呢?能够分别获取日期和时间组件很方便,但通常你想要日期和时间的子组件。LocalDateLocalTime的每个实现都经过了精心优化,我不希望在LocalDateTime中重复该逻辑,因此子组件属性是透传的,委托给日期或时间组件的属性。以下列表中所示的实施现在非常简洁。

列表 8.14. Noda Time 中的委托属性
public struct LocalDateTime
{
    public LocalDate Date { get; }          *1*
    public int Year => Date.Year;           *2*
    public int Month => Date.Month;         *2*
    public int Day => Date.Day;             *2*

    public LocalTime TimeOfDay { get; }     *3*
    public int Hour => TimeOfDay.Hour;      *4*
    public int Minute => TimeOfDay.Minute;  *4*
    public int Second => TimeOfDay.Second;  *4*

                                            *5*
}
  • 1 日期组件的属性

  • 2 委托给日期子组件的属性

  • 3 时间组件的属性

  • 4 将属性委托给时间子组件

  • 5 初始化、其他属性和成员

许多属性都像这样;从每个属性中移除 { get { return ... } } 部分确实是一种乐趣,并且使代码更加清晰。

在另一个状态上执行简单的逻辑

LocalTime 中,只有一个状态:一天中的纳秒数。所有其他属性都是基于这个值来计算值的。例如,计算纳秒级子秒值的代码是一个简单的余数操作:

public int NanosecondOfSecond =>
    (int) (NanosecondOfDay % NodaConstants.NanosecondsPerSecond);

在第十章中,这段代码将变得更加简单,但到目前为止,你只需享受表达式主体属性的简洁性。

重要注意事项

表达式主体属性有一个缺点:只读属性和公共可读写字段之间只有一个字符的差异。在大多数情况下,如果你犯了一个错误,由于在字段初始化器中使用其他属性或字段,编译时错误将会发生,但对于静态属性或返回常量值的属性,这是一个容易犯的错误。考虑以下声明之间的差异:

// Declares a read-only property
public int Foo => 0;
// Declares a read/write public field
public int Foo = 0;

这对我来说已经成了几次问题,但一旦你意识到它,检查起来就足够简单。确保你的代码审查人员也了解这一点,这样你就不太可能被抓住。

到目前为止,我们一直专注于属性,作为从其他新属性相关功能自然过渡的一部分。然而,正如章节标题所暗示的,其他类型的成员也可以有表达式主体。

8.3.2. 表达式主体方法、索引器和操作符

除了表达式主体属性外,你还可以编写表达式主体方法、只读索引器和操作符,包括用户定义的转换。=> 的使用方式相同,表达式周围没有大括号,也没有显式的返回语句。

例如,一个简单的 Add 方法及其等价的操作符,用于将一个具有明显 XY 属性的 Vector 添加到 Point 中,在 C# 5 中可能看起来像以下代码示例。

列表 8.15. C# 5 中的简单方法和操作符
public static Point Add(Point left, Vector right)
{
    return left + right;                             *1*
}

public static Point operator+(Point left, Vector right)
{
    return new Point(left.X + right.X,               *2*
        left.Y + right.Y);                           *2*
}
  • 1 只委托给操作符。

  • 2 简单的构造函数调用以实现 +

在 C# 6 中,它可能看起来更简单,这两个都可以使用表达式主体成员来实现,如下面的代码示例所示。

列表 8.16. C# 6 中的表达式主体方法和操作符
public static Point Add(Point left, Vector right) => left + right;

public static Point operator+(Point left, Vector right) =>
    new Point(left.X + right.X, left.Y + right.Y);

注意我在 operator+ 中使用的格式化;将所有内容放在一行会使代码变得非常长。一般来说,我在声明部分的末尾放置 => 并像往常一样缩进主体。你格式化代码的方式完全取决于你,但我发现这个约定对所有类型的表达式成员都很有用。

你还可以使用表达式主体为 void 返回类型的方法。在这种情况下,没有要省略的 return 语句;只需移除大括号。

备注

这与 lambda 表达式的工作方式一致。提醒一下,表达式成员不是 lambda 表达式,但它们有这个共同点。

例如,考虑一个简单的日志方法:

public static void Log(string text)
{
    Console.WriteLine("{0:o}: {1}", DateTime.UtcNow, text)
}

这可以用一个表达式成员方法写成这样:

public static void Log(string text) =>
    Console.WriteLine("{0:o}: {1}", DateTime.UtcNow, text);

这里的好处确实较小,但对于声明和主体可以放在一行的方法来说,这仍然值得做。在 第九章 中,你会看到一种使用插值字符串字面量使其更干净的方法。

对于一个包含方法、属性和索引器的最终示例,让我们假设你想要创建自己的 IReadOnlyList<T> 实现,以提供对任何 IList<T> 的只读视图。当然,ReadOnlyCollection<T> 已经做到了这一点,但它也实现了可变接口(IList<T>, ICollection<T>)。有时你可能想精确地了解集合通过其实现的接口允许什么。使用表达式成员,这样的包装器实现确实很短。

列表 8.17. 使用表达式成员的 IReadOnlyList<T> 实现
public sealed class ReadOnlyListView<T> : IReadOnlyList<T>
{
    private readonly IList<T> list;                           

    public ReadOnlyListView(IList<T> list)
    {
        this.list = list;
    }
    public T this[int index] => list[index];          *1*
    public int Count => list.Count;                   *2*
    public IEnumerator<T> GetEnumerator() =>          *3*
        list.GetEnumerator();                         *3*
    IEnumerator IEnumerable.GetEnumerator() =>        *4*
        GetEnumerator();                              *4*
}
  • 1 将索引器委托给列表索引器

  • 2 属性委托给列表属性

  • 3 方法委托给列表方法

  • 4 方法委托给其他 GetEnumerator 方法

这里展示的唯一新特性是表达式成员索引器的语法,我希望它与其他类型成员的语法足够相似,以至于你没有注意到它是新的。

然而,有什么让你印象深刻的地方吗?有什么让你感到惊讶的吗?那个构造函数看起来有点丑,不是吗?

8.3.3. C# 6 中表达式成员的限制

通常,在这个时候,我刚刚评论了代码的冗长性,我会透露 C# 实现的另一个新特性,以使其变得更好。但这次恐怕不行——至少不是在 C# 6 中。

即使构造函数只有一个语句,但在 C# 6 中没有表达式成员构造函数。它也不是独一无二的。你不能有表达式成员

  • 静态构造函数

  • 析构函数

  • 实例构造函数

  • 读写或只写属性

  • 读写或只写索引器

  • 事件

这些都没有让我夜不能寐,但显然不一致性让 C# 团队感到足够的困扰,以至于 C# 7 允许所有这些使用表达式成员。它们通常不会节省任何可打印的字符,但格式化约定允许它们节省垂直空间,并且仍然有这种可读性提示,即这只是一个简单的成员。它们都使用你已经习惯的相同语法,列表 8.18 提供了一个完整的示例,纯粹是为了展示语法。这段代码并不打算作为有用的代码,而只是作为一个示例,在事件处理程序的情况下,与简单的字段式事件相比,它是不安全的非线程安全的。

列表 8.18. C# 7 中的额外表达式成员
public class Demo
{
    static Demo() =>                                          *1*
        Console.WriteLine("Static constructor called");       *1*
    ~Demo() => Console.WriteLine("Finalizer called");         *2*

    private string name;
    private readonly int[] values = new int[10];

    public Demo(string name) => this.name = name;             *3*

    private PropertyChangedEventHandler handler;
    public event PropertyChangedEventHandler PropertyChanged  *4*
    {                                                         *4*
        add => handler += value;                              *4*
        remove => handler -= value;                           *4*
    }                                                         *4*

    public int this[int index]                                *5*
    {                                                         *5*
        get => values[index];                                 *5*
        set => values[index] = value;                         *5*
    }                                                         *5*

    public string Name                                        *6*
    {                                                         *6*
        get => name;                                          *6*
        set => name = value;                                  *6*
    }                                                         *6*
}
  • 1 静态构造函数

  • 2 析构函数

  • 3 构造函数

  • 4 带有自定义访问器的事件

  • 5 读写索引器

  • 6 读写属性

这个优点之一是,即使 set 访问器不是表达式成员,get 访问器也可以是表达式成员,反之亦然。例如,假设你想让你的索引器设置器验证新值不是负数。你仍然可以保留一个表达式成员的获取器:

public int this[int index]
{
    get => values[index];
    set
    {
        if (value < 0)
        {
            throw new ArgumentOutOfRangeException();
        }
        Values[index] = value;
    }
}

我预计在未来这将会相当普遍。根据我的经验,设置器通常包含验证,而获取器通常是微不足道的。

提示

如果你发现自己在一个获取器中写了很多逻辑,那么考虑它是否应该是一个方法是有意义的。有时边界可能很模糊。

虽然表达式成员有许多好处,但它们是否有其他缺点?你应该在将所有可能的内容转换为使用它们时有多激进?

8.3.4. 使用表达式成员的指南

我的经验是,表达式成员特别适用于运算符、转换、比较、相等检查和 ToString 方法。这些通常由简单的代码组成,但对于某些类型,可能会有很多这样的成员,可读性的差异可能非常显著。

与一些相对小众的功能不同,表达式成员可以在我所遇到的几乎每一个代码库中发挥重要作用。当我将 Noda Time 转换为使用 C# 6 时,我移除了代码中大约 50% 的返回语句。这是一个巨大的差异,而且随着我逐渐利用 C# 7 提供的额外机会,这个比例只会增加。

请注意,表达式成员不仅仅是可读性。我发现它们提供了一种心理效应:感觉我比以前更多地在进行函数式编程。这反过来又让我觉得自己更聪明。是的,听起来很愚蠢,但确实感觉令人满意。当然,你可能会比我更理性。

总是存在过度使用的危险。在某些情况下,你不能使用表达式成员,因为你的代码中包含了一个 for 语句或类似的内容。在许多情况下,将常规方法转换为表达式成员是可能的,但你真的不应该这样做。我发现,这类成员可以分为两类:

  • 执行前置条件检查的成员

  • 使用解释性变量的成员

作为第一类成员的例子,我有一个名为 Preconditions 的类,它有一个通用的 CheckNotNull 方法,接受一个引用和一个参数名称。如果引用为空,它将使用参数名称抛出 ArgumentNullException;否则,它返回值。这允许在构造函数等地方方便地将检查和赋值语句组合在一起。

这也允许——但当然不是强制——你将结果用作方法调用的目标或,实际上,作为它的参数。问题是,如果不小心,理解正在发生的事情会变得困难。这里有一个我从之前描述的 LocalDateTime 结构体中提取的方法:

public ZonedDateTime InZone(
    DateTimeZone zone,
    ZoneLocalMappingResolver resolver)
{
    Preconditions.CheckNotNull(zone);
    Preconditions.CheckNotNull(resolver);
    return zone.ResolveLocal(this, resolver);
}

这读起来既简洁又简单:检查参数是否有效,然后通过委托给另一个方法来完成工作。这可以写成表达式体成员,如下所示:

public ZonedDateTime InZone(
    DateTimeZone zone,
    ZoneLocalMappingResolver resolver) =>
    Preconditions.CheckNotNull(zone)
        .ResolveLocal(
            this,
            Preconditions.CheckNotNull(resolver);

这会产生完全相同的效果,但阅读起来要困难得多。根据我的经验,一个验证检查将方法放在表达式体成员的边缘;有两个这样的检查,那就太痛苦了。

对于解释变量,我之前提供的 NanosecondOfSecond 示例只是 LocalTime 上的许多属性之一。大约一半使用表达式体,但相当多的属性有两个语句,就像这样:

public int Minute
{
    get
    {
        int minuteOfDay = (int) NanosecondOfDay / NanosecondsPerMinute;
        return minuteOfDay % MinutesPerHour;
    }
}

这可以很容易地写成表达式体属性,通过有效地内联 minuteOfDay 变量:

public int Minute =>
    ((int) NanosecondOfDay / NodaConstants.NanosecondsPerMinute) %
    NodaConstants.MinutesPerHour;

再次强调,代码实现了完全相同的目标,但在原始版本中,minuteOfDay 变量增加了关于子表达式的意义的信息,这使得代码更容易阅读。

在任何给定的一天,我可能得出不同的结论。但在更复杂的情况下,遵循一系列步骤并命名结果,当你六个月后回到代码时,可能会有很大的不同。这也帮助你在需要时在调试器中逐步执行代码,因为你可以轻松地一次执行一个语句并检查结果是否是你预期的。

好消息是你可以随意进行实验和改变主意。表达式体成员纯粹是语法糖,所以如果你的品味随时间改变,你总是可以转换更多代码来使用它们,或者撤销过于急切地使用表达式体的代码。

摘要

  • 自动实现的属性现在可以是只读的,并由只读字段支持。

  • 自动实现的属性现在可以有初始化器,而不是在构造函数中初始化非默认值。

  • 结构体可以使用自动实现的属性,而无需将构造函数链接在一起。

  • 表达式体成员允许用更少的仪式编写简单的(单表达式)代码。

  • 虽然限制限制了在 C# 6 中使用表达式体编写的成员类型,但这些限制在 C# 7 中被取消了。

第九章. 字符串特性

本章涵盖

  • 使用插值字符串字面量进行更易读的格式化

  • 使用 FormattableString 进行本地化和自定义格式化

  • 使用 nameof 进行重构友好的引用

每个人都知道如何使用字符串。如果string不是你首先了解的.NET 数据类型,那么它很可能是第二个。在.NET 的历史进程中,string类本身并没有发生太多变化,自从 C# 1 以来,作为一门语言,C#并没有引入很多以字符串为导向的特性。然而,C# 6 通过另一种字符串字面量和一个新的运算符改变了这一点。你将在本章中详细了解这两个方面,但值得记住的是,字符串本身并没有发生任何变化。这两个特性提供了获取字符串的新方法,但这只是全部。

就像你在第八章中看到的功能一样,字符串插值并不允许你做之前不能做的事情;它只是允许你以更易读和简洁的方式去做。这并不是要贬低该功能的重要性。任何能够让你更快地编写更清晰代码,并且之后更快地阅读代码的东西,都会让你更有效率。

nameof运算符是 C# 6 中真正的新功能,但它是一个相对较小的特性。它所做的只是允许你在执行时获取一个已经在你的代码中出现的标识符,但作为字符串。它不会像 LINQ 或 async/await 那样改变你的世界,但它有助于避免错误,并允许重构工具为你做更多的工作。在我向你展示任何新内容之前,让我们回顾一下你已经知道的内容。

9.1. .NET 中的字符串格式化回顾

你几乎肯定知道本节中的所有内容。你可能已经使用字符串很多年了,而且几乎肯定是从使用 C#开始就一直在使用。然而,为了理解 C# 6 中插值字符串字面量功能的工作原理,最好是将所有这些知识都放在心中。请耐心等待,我们将回顾.NET 处理字符串格式化的基础知识。我保证我们很快就会到达新内容。

9.1.1. 简单字符串格式化

如果你像我一样,你喜欢通过编写无用的控制台应用程序来尝试新语言,这些应用程序除了提供信心和坚实的基础外,不会做任何有用的事情。因此,我记不起我使用过多少种语言来实现下一个功能——询问用户的姓名,然后向该用户说你好:

Console.Write("What's your name? ");
string name = Console.ReadLine();
Console.WriteLine("Hello, {0}!", name);

本章最相关的一行是最后一行。它使用了一个Console.WriteLine的重载,该重载接受一个包含复合格式字符串格式项的参数,然后是替换这些格式项的参数。前面的例子中有一个格式项{0},它被name变量的值替换。格式项中的数字指定了你要填充的参数的索引(其中 0 代表第一个值,1 代表第二个,以此类推)。

这种模式在各种 API 中使用。最明显的例子是string类中的静态Format方法,它所做的只是适当地格式化字符串。到目前为止,一切顺利。让我们做一些稍微复杂一点的事情。

9.1.2. 使用格式字符串进行自定义格式化

为了明确起见,我包括这个子节的原因,不仅是为了未来的自己,也是为了亲爱的读者。如果 MSDN 显示了我在任何给定页面上访问的次数,那么复合格式字符串页面的数字会令人恐惧。我总是忘记确切的位置和应该使用的术语,我想如果我在这里包含这些信息,我可能会更好地记住它们。我希望你也能以同样的方式找到它有帮助。

复合格式字符串中的每个格式项指定要格式化的参数的索引,但它也可以指定以下选项来格式化值:

  • 一个对齐,它指定最小宽度和值应该是左对齐还是右对齐。右对齐由正值表示;左对齐由负值表示。

  • 一个用于值的格式字符串。这通常用于日期和时间值或数字。例如,要按照 ISO-8601 格式化日期,可以使用格式字符串yyyy-MM-dd。要将数字格式化为货币值,可以使用格式字符串C。格式字符串的含义取决于正在格式化的值的类型,因此您需要查找相关文档以选择正确的格式字符串。

图 9.1 显示了可以用于显示价格的复合格式字符串的所有部分。

图 9.1. 一个包含用于显示价格的格式项的复合格式字符串

对齐和格式字符串是独立可选的;您可以指定任何一个、两个或都不指定。格式项中的逗号表示对齐,而冒号表示格式字符串。如果您需要在格式字符串中包含逗号,那没问题;没有第二个对齐值的观念。

作为稍后扩展的具体示例,让我们在一个更广泛的环境中使用图 9.1 中的代码,以展示不同长度的结果,以说明对齐的要点。列表 9.1 显示了一个价格($95.25)、小费($19.05)和总计($114.30),将标签对齐在左侧,将值对齐在右侧。

默认使用美国英语文化设置的机器上的输出将如下所示:

Price:    $95.25
Tip:      $19.05
Total:   $114.30

要使值右对齐(或用空格左填充,从另一个角度来看),代码使用对齐值为 9。如果你有一大笔钱(比如一百万美元),对齐就没有效果;它只指定最小宽度。如果你想编写将每可能的一组值都右对齐的代码,你首先必须计算出最大的宽度是多少。这是一段相当不愉快的代码,而且我担心 C# 6 中没有任何东西能使其更容易。

列表 9.1. 显示带有对齐值的单价、小费和总计
decimal price = 95.25m;
decimal tip = price * 0.2m;                       *1*
Console.WriteLine("Price: {0,9:C}", price);
Console.WriteLine("Tip:   {0,9:C}", tip);
Console.WriteLine("Total: {0,9:C}", price + tip);
  • 1 20% 小费

当我在美国英语文化的机器上展示了 列表 9.1 的输出时,关于文化的那部分很重要。在使用英国英语文化的机器上,代码会使用英镑符号。在法语文化的机器上,小数分隔符会变成逗号,货币符号会变成欧元符号,而这个符号会在字符串的末尾而不是开头!这就是本地化的乐趣,你将在下一节中看到。

9.1.3. 本地化

从广义上讲,本地化是指确保你的代码无论用户在世界任何地方都能正确运行的任务。任何声称本地化很简单的人要么比我更有经验,要么还没有足够地做本地化工作以看到它可能有多么痛苦。考虑到世界基本上是圆的,它似乎有很多棘手的边缘情况需要处理。本地化在所有编程语言中都是一种痛苦,但每种语言都有稍微不同的方式来解决这些问题。

注意

虽然我在本节中使用术语 本地化,但其他人可能更喜欢使用术语 全球化。微软在本地化和全球化这两个术语的使用上与其他行业机构略有不同,这种差异是相当微妙的。专家们,请原谅我在这里的手势挥舞;整体图景比术语的细微差别更重要,就这一次。

在 .NET 中,对于本地化目的,最重要的类型是 CultureInfo。它负责语言(如英语)的文化偏好,或特定位置的语言(如加拿大的法语),或特定位置的语言的特定变体(如台湾使用的简体中文)。这些文化偏好包括各种翻译(例如,用于星期的单词)以及指示文本的排序方式和数字的格式化方式(是否使用句点或逗号作为小数分隔符)等等。

通常,你不会在方法签名中看到 CultureInfo,而是看到 IFormatProvider 接口,这是 CultureInfo 实现的。大多数格式化方法都有重载,其中 IFormatProvider 作为格式字符串本身之前的第一参数。例如,考虑以下来自 string.Format 的两个签名:

static string Format(IFormatProvider provider,
    string format, params object[] args)
static string Format(string format, params object[] args)

通常,如果你提供仅通过单个参数不同的重载,那么该参数就是最后一个,所以你可能预计 provider 参数应该在 args 之后。但这不会起作用,因为 args 是一个参数数组(它使用 params 修饰符)。如果一个方法有一个参数数组,那么它必须是最后一个参数。

即使参数类型为 IFormatProvider,你作为参数传入的值几乎总是 CultureInfo。例如,如果你想为美国英语格式化我的出生日期——1976 年 6 月 19 日——你可以使用以下代码:

var usEnglish = CultureInfo.GetCultureInfo("en-US");
var birthDate = new DateTime(1976, 6, 19);
string formatted = string.Format(usEnglish, "Jon was born on {0:d}", birthDate);

在这里,d 是标准日期/时间格式说明符,用于短日期,在美国英语中对应于月/日/年。例如,我的出生日期将被格式化为 6/19/1976。在英式英语中,短日期格式是日/月/年,所以相同的日期将被格式化为 19/06/1976。请注意,不仅仅是顺序不同:在英式格式中,月份也是 0 填充到两位数字。

其他文化可以使用完全不同的格式。看到相同值在不同文化中格式化的结果差异可能很有教育意义。例如,你可以像在下一个列表中所示的那样,格式化 .NET 知道的每种文化中的相同日期。

列表 9.2. 在每种文化中格式化单个日期
var cultures = CultureInfo.GetCultures(CultureTypes.AllCultures);
var birthDate = new DateTime(1976, 6, 19);
foreach (var culture in cultures)
{
    string text = string.Format(
        culture, "{0,-15} {1,12:d}", culture.Name, birthDate);
    Console.WriteLine(text);
}

泰国的输出显示我在泰国佛教历中出生于 2519 年,而阿富汗的输出显示我在伊斯兰历中出生于 1355 年:

...
tg-Cyrl           19.06.1976
tg-Cyrl-TJ        19.06.1976
th                 19/6/2519
th-TH              19/6/2519
ti                19/06/1976
ti-ER             19/06/1976
...
ur-PK             19/06/1976
uz                19/06/1976
uz-Arab           29/03 1355
uz-Arab-AF        29/03 1355
uz-Cyrl           19/06/1976
uz-Cyrl-UZ        19/06/1976
...

此示例还显示了一个负对齐值,用于使用 {0,-15} 格式项将文化名称左对齐,同时使用 {1,12:d} 格式项将日期右对齐。

使用默认文化进行格式化

如果你没有指定格式提供程序,或者如果你传递 null 作为对应于 IFormatProvider 参数的参数,将使用 CultureInfo.CurrentCulture 作为默认值。这具体意味着什么将取决于你的上下文;它可以在每个线程的基础上设置,并且一些网络框架会在处理特定线程上的请求之前设置它。

关于使用默认值,我唯一能建议的是要小心:确保你知道你特定线程中的值是合适的。(如果你开始并行化跨多个线程的操作,检查确切的行为特别有价值。)如果你不想依赖于默认文化,你需要知道你需要为格式化文本的最终用户的文化,并明确这样做。

为机器进行格式化

到目前为止,我们假设你正在尝试为最终用户格式化文本。但情况往往并非如此。对于机器到机器的通信(例如,在由网络服务解析的 URL 查询参数中),你应该使用不变文化,这可以通过静态 CultureInfo.InvariantCulture 属性获得。

例如,假设你正在使用一个 Web 服务从出版社获取畅销书列表。该 Web 服务可能使用 manning.com/webservices/bestsellers 这样的 URL,但允许一个名为 date 的查询参数,以便你可以找到特定日期上的畅销书。^([1)] 我预计该查询参数将使用 ISO-8601 格式(年份首先,年份、月份和日期之间使用连字符)来表示日期。例如,如果你想检索 2017 年 3 月 20 日开始的销售量最高的书籍,你将想要使用 manning.com/webservices/bestsellers?date=2017-03-20 这样的 URL。在允许用户选择特定日期的应用程序中构建该 URL 的代码可能如下所示:

¹

根据我所知,这是一个虚构的 Web 服务。

string url = string.Format(
    CultureInfo.InvariantCulture,
    "{0}?date={1:yyyy-MM-dd}",
    webServiceBaseUrl,
    searchDate);

注意,大多数时候,你不应该直接为机器到机器通信格式化数据。我建议你在可能的情况下避免字符串转换;它们通常是一个代码异味,表明你可能没有正确使用库或框架,或者你有数据设计问题(例如,在数据库中将日期存储为文本而不是作为原生日期/时间类型)。话虽如此,你可能会发现自己比预期的更频繁地手动构建字符串;只需注意你应该使用哪种文化即可。

好吧,这是一个很长的介绍。但是,当所有这些格式化信息在你的脑海中嗡嗡作响,以及一些有些丑陋的例子在你心中萦绕时,你正处于欢迎 C# 6 中的插值字符串字面量的正确心态。所有那些调用 string.Format 的代码看起来都过于冗长,而且不得不在格式字符串和参数列表之间来回查找,以确定内容将如何放置,这真的很烦人。当然,我们可以使我们的代码比这更清晰。

9.2. 插值字符串字面量的介绍

C# 6 中的插值字符串字面量允许你以更简单的方式执行所有这些格式化操作。格式字符串和参数的概念仍然适用,但使用插值字符串字面量时,你可以在行内指定值及其格式化信息,这使得代码更容易阅读。如果你查看你的代码并发现很多使用硬编码格式字符串调用 string.Format 的情况,你一定会喜欢插值字符串字面量。

字符串插值并不是一个新概念。它已经在许多编程语言中存在很长时间了,但我从未觉得它在 C# 中如此整洁地集成。当你考虑到在语言已经成熟的情况下添加一个功能比将其构建到第一版中更难时,这一点尤其引人注目。

在本节中,你将先查看一些简单的示例,然后再探索插值文本字面量。你将学习如何使用FormattableString应用本地化,然后更详细地了解编译器如何处理插值字符串字面量。我们将通过讨论这个特性最有用的地方以及其局限性来结束本节。

9.2.1. 简单插值

在 C# 6 中演示插值字符串字面量的最简单方法就是展示我们之前要求用户姓名的早期示例的等效代码。代码看起来并没有太大不同;特别是,只有最后一行有所改变。

C# 5—旧式格式化 C# 6—插值字符串字面量

|

Console.Write("What's your name? ");
string name = Console.ReadLine();
Console.WriteLine("Hello, {0}!",
    name);

|

Console.Write("What's your name? ");
string name = Console.ReadLine();
Console.WriteLine($"Hello, {name}!");

|

插值字符串字面量以粗体显示。它在开双引号之前有一个$,这就是它成为插值字符串字面量而不是普通字符串的原因,至少对编译器来说是这样。它使用{name}而不是{0}作为格式项。花括号中的文本是一个表达式,它在字符串内被评估并格式化。因为你已经提供了所有需要的信息,所以不再需要WriteLine的第二个参数。

注意

我在这里稍微撒了个谎,为了简化。这段代码并不完全以原始代码的方式工作。原始代码将所有参数传递给适当的Console.WriteLine重载,该重载为你执行了格式化。现在,所有格式化操作都通过string.Format调用完成,然后Console.WriteLine调用使用仅有一个字符串参数的重载。结果将会相同。

就像表达式成员一样,这看起来并没有太大的改进。对于单个格式项,原始代码并没有太多可以混淆的地方。第一次看到这个时,阅读插值字符串字面量可能甚至比阅读字符串格式化调用要花更长的时间。我对于自己到底有多喜欢它们持怀疑态度,但现在我经常发现自己几乎自动地将旧代码的部分转换为使用它们,我发现可读性的提升通常是显著的。

现在你已经看到了最简单的示例,让我们来做一些更复杂的事情。你将遵循之前的顺序,首先仔细查看控制值格式的操作,然后考虑本地化。

9.2.2. 插值字符串字面量中的格式化字符串

好消息!这里没有新的内容需要学习。如果你想要提供一个带有插值字符串字面量的对齐或格式化字符串,你将按照在正常复合格式化字符串中的方式操作:在对齐之前添加一个逗号,在格式化字符串之前添加一个冒号。我们之前复合格式化的示例以明显的方式进行了更改,如下面的列表所示。

列表 9.3. 使用插值字符串字面量对齐的值
decimal price = 95.25m;
decimal tip = price * 0.2m;                       *1*
Console.WriteLine($"Price: {price,9:C}");         *2*
Console.WriteLine($"Tip:   {tip,9:C}");           *2*
Console.WriteLine($"Total: {price + tip,9:C}");   *2*
  • 1 20%的小费

  • 2 使用九位对齐右对齐价格

注意,在最后一行,插值字符串不仅仅包含一个简单的变量作为参数;它执行了小费与价格的加法。表达式可以是任何计算值的表达式。(例如,你不能只是调用一个返回void类型的方法。)如果值实现了IFormattable接口,它的ToString(string, IFormatProvider)方法将被调用;否则,使用System.Object.ToString()

9.2.3. 插值实际字符串字面量

你肯定之前见过实际字符串字面量;它们在双引号前以@开头。在实际字符串字面量中,反斜杠和换行符包含在字符串中。例如,在实际字符串字面量@"c:\Windows"中,反斜杠确实是一个反斜杠;它不是转义序列的开始。实际字符串字面量中唯一的转义序列是在你有两个双引号字符一起时,这会在结果字符串中产生一个双引号字符。实际字符串字面量通常用于以下情况:

  • 跨多行的字符串

  • 正则表达式(使用反斜杠进行转义,与 C#编译器在常规字符串字面量中使用的转义完全不同)

  • 固定硬编码的 Windows 文件名

注意

在多行字符串中,你应该注意哪些字符最终出现在你的字符串中。尽管在大多数代码中,“回车”和“回车换行分隔符”之间的区别无关紧要,但在实际字符串字面量中却很重要。

以下展示了这些功能的快速示例:

string sql = @"                                        *1*
  SELECT City, ZipCode                                 *1*
  FROM Address                                         *1*
  WHERE Country = 'US'";                               *1*
Regex lettersDotDigits = new Regex(@"[a-z]+\.\d+");    *2*
string file = @"c:\users\skeet\Test\Test.cs"           *3*
  • 1 当 SQL 语句跨多行时更容易阅读。

  • 2 反斜杠在正则表达式中很常见。

  • 3 Windows 文件名

实际字符串字面量也可以进行插值;你只需在@前加上一个$,就像插值常规字符串字面量一样。我们之前的多行输出可以使用单个插值实际字符串字面量来编写,如下所示。

列表 9.4. 使用单个插值实际字符串字面量对齐值
decimal price = 95.25m;
decimal tip = price * 0.2m;              *1*
Console.WriteLine($@"Price: {price,9:C}
Tip:   {tip,9:C}
Total: {price + tip,9:C}");
  • 1 20%的小费

我可能不会这样做;这并不像使用三个单独的语句那样干净。我使用前面的代码仅作为一个简单示例,说明可能实现的功能。考虑在已经合理使用实际字符串字面量的地方使用它。

小贴士

符号的顺序很重要。$@"Text"是一个有效的插值实际字符串字面量,但@$"Text"不是。我承认我还没有找到一个好的记忆技巧来记住这个。只需尝试你认为正确的方式,如果编译器有抱怨就改变它!

这非常方便,但我只展示了表面现象。我假设你买这本书是因为你想了解其内部和外部功能。

9.2.4. 编译器对插值字符串字面量的处理(第一部分)

这里的编译器转换很简单。它将插值字符串字面量转换为对 string.Format 的调用,并从格式项中提取表达式,在复合格式字符串之后将它们作为参数传递。表达式被替换为适当的索引,因此第一个格式项变为 {0},第二个变为 {1},依此类推。

为了使这一点更清晰,让我们考虑一个简单的例子,这次将格式化与输出分开,以便于理解:

int x = 10;
int y = 20;
string text = $"x={x}, y={y}";
Console.WriteLine(text);

编译器会像你写了以下代码一样处理这个问题:

int x = 10;
int y = 20;
string text = string.Format("x={0}, y={1}", x, y);
Console.WriteLine(text);

转换就这么简单。如果你想深入了解并自己验证,可以使用像 ildasm 这样的工具来查看编译器生成的 IL。

这种转换的一个副作用是,与常规或字面量字符串字面量不同,插值字符串字面量不作为常量表达式。尽管在某些情况下编译器可以合理地认为它们是常量(如果它们没有格式项,或者所有格式项只是没有对齐或格式字符串的字符串常量),但这些将是边缘情况,会为语言增加复杂性而带来很少的好处。

到目前为止,我们所有的插值字符串都导致了对 string.Format 的调用。但这并不总是发生,而且有很好的理由,你将在下一节中看到。

9.3. 使用 FormattableString 进行本地化

在 第 9.1.3 节 中,我演示了字符串格式化如何利用不同的格式提供者(通常使用 CultureInfo)来执行本地化。你之前看到的所有插值字符串都会使用执行线程的默认文化进行评估,所以 9.1.2 和 9.2.2 中的价格示例在你的机器上的输出可能与我展示的结果不同。

要在特定文化中进行格式化,你需要三个信息:

  • 复合格式字符串,它包括硬编码的文本和作为真实值占位符的格式项

  • 这些值本身

  • 你想要格式化字符串的文化

你可以稍微修改我们的第一个格式化示例,在一个文化中存储这些值,然后在最后调用 string.Format

var compositeFormatString = "Jon was born on {0:d}";
var value = new DateTime(1976, 6, 19);
var culture = CultureInfo.GetCultureInfo("en-US");
var result = string.Format(culture, compositeFormatString, value);

你如何使用插值字符串字面量来做这件事?插值字符串字面量包含前两个信息(复合格式字符串和要格式化的值),但没有地方可以放置文化。如果你之后可以获取这些单独的信息,那将是好的,但到目前为止你看到的每个插值字符串的使用都执行了字符串格式化,只留下一个字符串作为结果。

这就是 FormattableString 的作用。这是一个在 .NET 4.6(以及在 .NET Core 世界中的 .NET Standard 1.3)中引入的 System 命名空间中的类。它持有复合格式字符串和值,以便可以在你想要的任何文化中稍后进行格式化。编译器知道 FormattableString,可以在必要时将其转换为 FormattableString 而不是字符串。这允许你将我们的简单出生日期示例重写如下:

var dateOfBirth = new DateTime(1976, 6, 19);
FormattableString formattableString =
 $"Jon was born on {dateofBirth:d}"; *1*
var culture = CultureInfo.GetCultureInfo("en-US");
var result = formattableString.ToString(culture);      *2*
  • 1 将复合格式字符串和值保存在 FormattableString 中*

  • 2 指定文化中的格式*

现在你已经知道了 FormattableString 存在的基本原因,你可以看看编译器是如何使用它的,然后更详细地检查本地化。尽管本地化无疑是 FormattableString 的主要动机,但它也可以在其他情况下使用,你将在 第 9.3.3 节 中看到。该节随后总结了如果你的代码针对的是 .NET 的早期版本,你的选项。

9.3.1. 编译器处理插值字符串字面量(第二部分)

在反转我之前的方法后,这次有道理谈谈编译器在详细检查其用法之前是如何考虑 FormattableString 的。插值字符串字面量的编译时类型是 string。没有从 string 转换到 FormattableString 或到 IFormattableFormattableString 实现的接口),但是有从插值字符串字面量表达式到 FormattableStringIFormattable 的转换。

从表达式到类型的转换和从类型到其他类型的转换之间的区别有些微妙,但这并不是什么新鲜事。例如,考虑整数字面量 5。它的类型是 int,所以如果你声明 var x = 5x 的类型将是 int,但你也可以用它来初始化一个 byte 类型的变量。例如,byte y = 5; 是完全有效的。这是因为语言指定,对于范围在 byte 内的常量整数表达式(包括整数字面量),存在从表达式到 byte 的隐式转换。如果你能理解这一点,你就可以将完全相同的思想应用到字面量字符串。

当编译器需要将插值字符串字面量转换为 FormattableString 时,它执行与转换为 string 时的大部分相同步骤。但是,它调用的是 System.Runtime.CompilerServices.FormattableStringFactory 类的静态 Create 方法,而不是 string.Format。这是与 FormattableString 同时引入的另一种类型。回到之前的例子,假设你有以下源代码:

int x = 10;
int y = 20;
FormattableString formattable = $"x={x}, y={y}";

这将由编译器处理,就像你写了以下代码一样(当然,带有适当的命名空间):

int x = 10;
int y = 20; 
FormattableString formattable = FormattableStringFactory.Create(
    "x={0}, y={1}", x, y);

FormattableString 是一个抽象类,其成员如下所示。

列表 9.5. 由 FormattableString 声明的成员
public abstract class FormattableString : IFormattable
{
    protected FormattableString();
    public abstract object GetArgument(int index);
    public abstract object[] GetArguments();
    public static string Invariant(FormattableString formattable);
    string IFormattable.ToString
        (string ignored, IFormatProvider formatProvider);
    public override string ToString();
    public abstract string ToString(IFormatProvider formatProvider);
    public abstract int ArgumentCount { get; }
    public abstract string Format { get; }
}

现在你已经知道了何时以及如何构建 FormattableString 实例,让我们看看你可以用它们做什么。

9.3.2. 在特定文化中格式化 FormattableString

到目前为止,FormattableString 的最常见用途将是显式地在指定文化中进行格式化,而不是在线程的默认文化中进行格式化。我预计大多数用途将针对单一文化:不变文化。这如此常见,以至于它有自己的静态方法:Invariant。调用此方法相当于将 CultureInfo.InvariantCulture 传递给 ToString(IFormatProvider) 方法,它的工作方式与你预期的完全一样。但将 Invariant 作为静态方法意味着它比作为你刚刚在 9.3.1 节 中查看的语言细节的微妙推论更容易调用。它接受 FormattableString 作为参数的事实意味着你可以直接使用插值字符串字面量作为参数,编译器知道它必须应用相关的转换;不需要进行转换或使用单独的变量。

让我们考虑一个具体的例子来说明这一点。假设你有一个 DateTime 值,并且你只想将其日期部分格式化为 ISO-8601 格式,作为机器到机器通信的 URL 查询参数的一部分。你希望使用不变文化来避免使用默认文化可能产生意外结果。

注意

即使你为日期和时间指定了自定义的格式字符串,即使该自定义格式仅使用数字,文化仍然会有影响。其中最大的影响是,该值以文化默认的日历系统表示。如果你在 ar-SA(沙特阿拉伯的阿拉伯语)文化中格式化 2016 年 10 月 21 日(格里高利历),你会得到一个年份为 1438 的结果。

你可以通过四种方式来完成这种格式化,所有这些方式都在下面的列表中一起展示。所有四种方法都给出完全相同的结果,但我展示了所有这些方法来演示多个语言特性如何协同工作以给出一个干净的最终选项。

列表 9.6. 在不变文化中格式化日期
DateTime date = DateTime.UtcNow;

string parameter1 = string.Format(                         *1*
    CultureInfo.InvariantCulture,                          *1*
    "x={0:yyyy-MM-dd}",                                    *1*
    date);

string parameter2 =                                        *2*
    ((FormattableString)$"x={date:yyyy-MM-dd}")            *2*
    .ToString(CultureInfo.InvariantCulture);               *2*

string parameter3 = FormattableString.Invariant(           *3*
    $"x={date:yyyy-MM-dd}");                               *3*

string parameter4 = Invariant($"x={date:yyyy-MM-dd}");     *4*
  • 1 传统的 string.Format 格式化

  • 2 转换为 FormattableString 并调用 ToString(IFormatProvider)

  • 3 正常调用 FormattableString.Invariant

  • 4 简化的 FormattableString.Invariant 调用

主要有趣的不同之处在于parameter2parameter3的初始化器。为了确保你有一个FormattableString用于parameter2而不是仅仅是一个string,你必须将插值字符串字面量强制转换为该类型。另一种选择是声明一个单独的局部变量,其类型为FormattableString,但这将会很冗长。将其与parameter3的初始化方式进行比较,它使用接受FormattableString类型参数的Invariant方法。这允许编译器推断你想要使用从插值字符串字面量到FormattableString的隐式转换,因为这是调用有效的唯一方式。

我在parameter4上作弊了。我使用了一个你还没有见过的特性,即使用using static指令从类型中创建静态方法。你可以稍后查看详细信息(第 10.1.1 节)或者现在就相信它是有效的。你只需要在你的using指令列表中包含using static System.FormattableString

非不变文化的格式化

如果你想在除了不变文化之外的任何文化中格式化FormattableString,你需要使用其中一个ToString方法。在大多数情况下,你将直接调用ToString(IFormatProvider)重载。作为一个比之前看到的例子略短的例子,以下是如何使用“一般日期/时间带短时间”标准格式字符串("g")来格式化当前日期和时间的 US English 代码:

FormattableString fs = $"The current date and time is: {DateTime.Now:g}";
string formatted = fs.ToString(CultureInfo.GetCultureInfo("en-US"));

有时,你可能想要将FormattableString传递给其他代码以执行最终的格式化步骤。在这种情况下,值得记住的是FormattableString实现了IFormattable接口,因此任何接受IFormattable的方法都将接受FormattableStringFormattableStringIFormattable.ToString(string, IFormatProvider)的实现忽略了字符串参数,因为它已经拥有所需的一切:它使用IFormatProvider参数来调用ToString(IFormatProvider)方法。

现在你已经知道了如何使用带有插值字符串字面量的文化,你可能想知道FormattableString的其他成员为什么存在。在下一节中,你将看到一个示例。

9.3.3. FormattableString的其他用途

我并不期望FormattableString在第 9.3.2 节中展示的文化场景之外被广泛使用,但考虑一下可以做什么是有价值的。我选择这个例子,因为它立即可识别,并且以它自己的方式优雅。但我不会那么推荐它的使用。除了这里展示的代码缺少验证和一些功能外,它可能给一个随意读者(以及静态代码分析工具)留下错误的印象。无论如何,你可以将其作为一个想法来追求,但请使用适当的谨慎。

大多数开发人员都知道 SQL 注入攻击作为一种安全漏洞,许多人知道参数化 SQL 的常见解决方案。列表 9.7 展示了您不想做的事情。如果用户输入包含撇号的值,他们将拥有对您的数据库的大量控制权。想象一下,您有一个数据库,其中包含用户可以添加标签以按用户标识分区的一些条目。您试图列出针对用户指定的标签的所有描述,并限制在该用户范围内。

列表 9.7. Awooga! Awooga! 不要使用此代码!
var tag = Console.ReadLine();                           *1*
using (var conn = new SqlConnection(connectionString))
{
    conn.Open();
    string sql =
        $@"SELECT Description FROM Entries              *2*
           WHERE Tag='{tag}' AND UserId={userId}";      *2*
    using (var command = new SqlCommand(sql, conn))
    {
        using (var reader = command.ExecuteReader())    *3*
        {
            ...                                         *4*
        }
    }
}
  • 1 从用户读取任意数据

  • 2 动态构建包含用户输入的 SQL

  • 3 执行不可信的 SQL

  • 4 使用结果

我在 C# 中看到的绝大多数 SQL 注入漏洞都使用字符串连接而不是字符串格式化,但情况是一样的。它以令人不安的方式混合了代码(SQL)和数据(用户输入的值)。

我将假设您知道如何使用参数化 SQL 并适当地调用 command.Parameters.Add(...) 来修复这个问题。代码和数据得到了适当的分离,生活再次变得美好。不幸的是,这段安全代码看起来并不像 列表 9.7 中的代码那样吸引人。如果可以两者兼得会怎样?如果可以编写既明显表示您要做什么又安全参数化的 SQL 会怎样?使用 FormattableString,您就可以做到这一点。

您将逆向工作,从我们期望的用户代码开始,通过实现它来启用它。以下列表显示了即将到来的安全等效于 列表 9.7。

列表 9.8. 使用 FormattableString 进行安全的 SQL 参数化
var tag = Console.ReadLine();                               *1*
using (var conn = new SqlConnection(connectionString))
{
    conn.Open();
    using (var command = conn.NewSqlCommand(                *2*
        $@"SELECT Description FROM Entries                  *2*
           WHERE Tag={tag:NVarChar}                         *2*
           AND UserId={userId:Int}"))                       *2*
    {
        using (var reader = command.ExecuteReader())        *3*
        {
            // Use the data                                 *4*
        }
    }
}
  • 1 从用户读取任意数据

  • 2 从插值字符串字面量构建 SQL 命令

  • 3 安全执行 QL

  • 4 使用结果

列表中的大部分与 列表 9.7 相同。唯一的区别在于您构建 SqlCommand 的方式。您不是使用插值字符串字面量将值格式化为 SQL 并将其传递给 SqlCommand 构造函数,而是使用一个名为 NewSqlCommand 的新方法,这是一个您将很快编写的扩展方法。可预测的是,该方法的第二个参数不是 string,而是 FormattableString。插值字符串字面量不再围绕 {tag} 有撇号,并且您已指定每个参数的数据库类型作为格式字符串。这确实很不同。它在做什么?

首先,让我们思考一下编译器为您做了什么。它将插值字符串字面量分成两部分:一个复合格式字符串和格式项的参数。编译器创建的复合格式字符串看起来会是这样:

SELECT Description FROM Entries
WHERE Tag={0:NVarChar} AND UserId={1:Int}

您希望 SQL 看起来像这样:

SELECT Description FROM Entries
WHERE Tag=@p0 AND UserId=@p1

这很容易做到;你只需要格式化复合格式字符串,传入将评估为"@p0""@p1"的参数。如果这些参数的类型实现了IFormattable,调用string.Format将传递NVarCharInt格式字符串,因此你可以适当地设置SqlParameter对象的类型。你可以自动生成名称,值直接来自FormattableString

IFormattable.ToString实现产生副作用是非常不寻常的,但你只在这个单一调用中使用这种格式捕获类型,并且你可以安全地将它隐藏在其他代码之外。以下是一个完整的实现。

列表 9.9. 实现安全的 SQL 格式化
public static class SqlFormattableString
{
    public static SqlCommand NewSqlCommand(                              
        this SqlConnection conn,FormattableString formattableString)    
    {
        SqlParameter[] sqlParameters = formattableString.GetArguments()  
            .Select((value, position) =>                                 
                new SqlParameter(Invariant($"@p{position}"), value))     
            .ToArray();                                                  
        object[] formatArguments = sqlParameters                         
            .Select(p => new FormatCapturingParameter(p))                
            .ToArray();                                                  
        string sql = string.Format(formattableString.Format,
            formatArguments);
        var command = new SqlCommand(sql, conn);                         
        command.Parameters.AddRange(sqlParameters);                      
        return command;
    }

    private class FormatCapturingParameter : IFormattable                
    {
        private readonly SqlParameter parameter;

        internal FormatCapturingParameter(SqlParameter parameter)       
        {
            this.parameter = parameter;
        }
        public string ToString(string format, IFormatProvider formatProvider)
        {
            if (!string.IsNullOrEmpty(format))                              
            {                                                               
                parameter.SqlDbType = (SqlDbType) Enum.Parse(
                    typeof(SqlDbType), format, true);
            }                                                               
            return parameter.ParameterName;                               
        }
    }
}

这里的公开部分只有SqlFormattableString静态类及其NewSqlCommand方法。其他一切都是隐藏的实现细节。对于格式字符串中的每个占位符,你创建一个SqlParameter和一个相应的FormatCapturingParameter。后者用于将参数名称格式化为 SQL 中的@p0@p1等,并将提供给ToString方法的价值设置到SqlParameter中。如果用户在格式字符串中指定了参数类型,也会设置参数类型。

到这个阶段,你需要自己决定是否想在你的生产代码库中看到这样的功能。我想要实现额外的功能(例如,在格式字符串中包含大小;你不能使用格式项的对齐部分,因为string.Format会自己处理),但这当然可以适当地在生产环境中实现。但是,这会不会太过聪明?你难道要带着每个新加入项目的开发者走过这一过程,说,“是的,我知道这看起来我们有一个巨大的 SQL 注入漏洞,但真的没关系”?

无论这个具体的例子如何,你很可能都能找到类似的情况,你可以使用编译器从插值字符串字面量的文本中提取数据并分离数据。始终仔细思考这种解决方案是否真的提供了好处,或者它只是给你一个感觉聪明的机会。

所有这些对于你目标是.NET 4.6 的情况都是有用的,但如果你卡在较老的框架版本上怎么办?仅仅因为你使用的是 C# 6 编译器,并不意味着你一定在针对框架的现代版本。幸运的是,C#编译器并没有将这一点绑定到特定的框架版本;它只需要以某种方式提供正确的类型。

9.3.4. 使用 FormattableString 与较老的.NET 版本

就像扩展方法和调用者信息属性一样,C#编译器并没有一个固定的想法,即哪个程序集应该包含它所依赖的FormattableStringFormattableStringFactory类型。编译器关心的是命名空间,并期望在FormattableStringFactory上存在适当的静态Create方法,但仅此而已。如果你想利用FormattableString的好处,但你被困在早期版本的框架中,你可以自己实现这两个类型。

在我向你展示代码之前,我应该指出,这应该被视为最后的手段。当你最终将你的环境升级到针对 .NET 4.6 的目标时,你应该立即删除这些类型,以避免编译器警告。尽管你可以在 .NET 4.6 中执行自己的实现,但我尽量避免这种情况;根据我的经验,不同程序集中的相同类型可能会导致难以诊断的问题。

在所有注意事项都解决之后,实现是简单的。列表 9.10 展示了这两种类型。我没有包含任何验证,为了简洁起见,我将FormattableString设为具体类型,并且将两个类都设为内部类,但编译器并不介意这些更改。将类型设为内部类的原因是避免其他程序集对你的实现产生依赖;是否适合你的具体情况很难预测,但在将类型公开之前,请仔细考虑。

列表 9.10. 从头开始实现FormattableString
using System.Globalization;

namespace System.Runtime.CompilerServices
{
    internal static class FormattableStringFactory
    {
        internal static FormattableString Create(
            string format, params object[] arguments) =>
            new FormattableString(format, arguments);
    }
}

namespace System
{
    internal class FormattableString : IFormattable
    {
        public string Format { get; }
        private readonly object[] arguments;

        internal FormattableString(string format, object[] arguments)
        {
            Format = format;
            this.arguments = arguments;
        }

        public object GetArgument(int index) => arguments[index];
        public object[] GetArguments() => arguments;
        public int ArgumentCount => arguments.Length;
        public static string Invariant(FormattableString formattable) =>
            formattable?.ToString(CultureInfo.InvariantCulture);
        public string ToString(IFormatProvider formatProvider) =>
            string.Format(formatProvider, Format, arguments);
        public string ToString(
            string ignored, IFormatProvider formatProvider) =>
            ToString(formatProvider);
    }
}

我不会解释代码的细节,因为每个成员都非常简单。可能需要稍作解释的部分是在Invariant方法调用formattable?.ToString(CultureInfo.InvariantCulture)。这个表达式中的?.部分是空条件运算符,你将在第 10.3 节中更详细地了解它。现在你已经知道了你可以用插值字符串字面量做什么,但关于你应该如何使用它们呢?

9.4. 用法、指南和限制

与表达式成员类似,插值字符串字面量是一个安全的特性,可以用来进行实验。你可以调整你的代码以满足你自己的(或团队范围内的)阈值。如果你后来改变主意,想要回到旧代码,这样做是微不足道的。除非你在你的 API 中使用FormattableString,否则插值字符串字面量的使用是一个隐藏的实现细节。当然,这并不意味着它应该在绝对每个地方都使用。在本节中,我们将讨论在哪里使用插值字符串字面量是合理的,在哪里不合理,以及即使你想要,你可能会发现你甚至无法使用它。

9.4.1. 开发者和机器,但可能不是最终用户

首先,是好消息:几乎在任何你已经在使用硬编码组合格式字符串进行字符串格式化或任何你使用纯字符串连接的地方,你都可以使用插值字符串。大多数情况下,代码在之后会更容易阅读。

这里的硬编码部分很重要。插值字符串字面量不是动态的。组合格式字符串就在你的源代码中;编译器只是稍微修改一下,以便使用常规格式项。当你事先知道所需字符串的文本和格式时,这很好,但它不够灵活。

一种对字符串进行分类的方法是考虑谁或什么将消费它们。为了本节的目的,我将考虑三个消费者:

  • 为其他代码解析而设计的字符串

  • 面向其他开发者的消息

  • 面向最终用户的消息

让我们逐一查看每种字符串,并思考插值字符串字面量是否有用。

可读性强的字符串

大量代码被构建来读取其他字符串。有可读性强的日志格式、URL 查询参数和基于文本的数据格式,如 XML、JSON 或 YAML。所有这些都有固定的格式,任何值都应该使用不变的文化格式化。这是一个使用FormattableString的好地方,正如你之前看到的,如果你需要自己执行格式化。提醒一下,你通常应该利用适当的 API 来格式化可读性强的字符串。

请记住,这些字符串中也可能包含针对人类的嵌套字符串;日志文件的每一行可能以特定方式格式化,以便将其作为单个记录处理,但消息部分可能针对其他开发者。你需要跟踪代码的每一部分在哪个嵌套级别上工作。

面向其他开发者的消息

如果你查看一个大型代码库,你很可能会发现许多字符串字面量是针对其他开发者的,无论是同公司的同事还是使用你发布的 API 的开发者。这些主要如下:

  • 工具字符串,例如控制台应用程序中的帮助信息

  • 写入日志或控制台的诊断或进度消息

  • 异常消息

根据我的经验,这些通常是用英语编写的。尽管一些公司——包括微软——会费心将错误信息本地化,但大多数公司不会这么做。本地化在数据翻译和正确使用代码方面都有显著的成本。如果你知道你的受众至少对阅读英语感到相当舒适,尤其是如果他们可能想在 Stack Overflow 等以英语为主的网站上分享这些信息,那么本地化这些字符串通常不值得付出努力。

确保文本中的值都按照固定文化格式化,这是一回事。这肯定有助于提高一致性,但我怀疑我不是唯一一个没有像我希望的那样重视这一点的人。我鼓励你使用非歧义性的日期格式。yyyy-MM-dd 的 ISO 格式易于理解,并且没有 dd/MM/yyyy 或 MM/dd/yyyy 的“月先还是日先?”问题。如我之前所述,文化可能会影响产生哪些数字,因为世界上不同地区使用不同的日历系统。仔细考虑你是否想使用不变的文化来强制使用公历。例如,抛出无效参数异常的代码可能看起来像这样:

throw new ArgumentException(Invariant(
    $"Start date {start:yyyy-MM-dd} should not be earlier than year 2000."))

如果你知道阅读这些字符串的所有开发者都将处于同一非英语文化中,那么完全有理由用那种文化来编写所有这些消息。

最终用户的消息

最后,几乎所有应用程序至少都会显示一些文本给最终用户。与开发者一样,你需要了解每个用户的期望,以便为他们做出如何呈现文本的正确决定。在某些情况下,你可以确信所有最终用户都愿意使用单一的文化。这通常是在你为在一个特定地点内部使用的应用程序构建时的情况。在这里,你更有可能使用当地的文化而不是英语,但你不必担心两个用户想要以不同的方式看到相同的信息。

到目前为止,所有这些情况都适用于插值字符串字面量。我特别喜欢用它们来编写异常消息。它们让我能够编写简洁的代码,同时仍然为不幸的、正在查看日志并试图找出这次出了什么问题的开发者提供有用的上下文。

但是,当你有多个文化的最终用户时,插值字符串字面量很少有帮助,如果你不进行本地化,它们可能会损害你的产品。在这里,格式字符串可能位于资源文件中而不是你的代码中,所以你甚至不太可能看到使用插值字符串字面量的可能性。偶尔会有例外,比如当你只是格式化一小段信息以放入特定的 HTML 标签或类似的东西时。在这些异常情况下,插值字符串字面量应该是可以的,但不要期望会经常使用它们。

你已经看到你不能为资源文件使用插值字符串字面量。接下来,你将查看其他一些情况,对于这些情况,该功能根本就没有设计来帮助你。

9.4.2. 插值字符串字面量的硬限制

每个特性都有其局限性,插值字符串字面量也不例外。这些限制有时有解决方案,我会在建议你首先不要尝试它们之前向你展示。

没有动态格式化

你已经看到,你不能改变构成插值字符串字面量的复合格式字符串的大部分。然而,有一块感觉应该可以动态表达,但实际上不能:单个格式字符串。让我们从一个早期的例子中取出一部分:

Console.WriteLine($"Price: {price,9:C}");

在这里,我选择了9作为对齐,因为我所格式化的值将很好地适应九个字符。但如果你知道有时你需要格式化的所有值都会很小,而有时它们可能很大呢?如果能让那个9部分动态变化,那会很好,但并没有简单的方法来做这件事。你最容易想到的方法是使用插值字符串字面量作为string.Format或等效的Console.WriteLine重载的输入,如下面的示例所示:

int alignment = GetAlignmentFromValues(allTheValues);
Console.WriteLine($"Price: {{0,{alignment}:C}}", price);

字符串格式中的第一个和最后一个花括号被用作转义机制,因为你想得到的结果是一个字符串,如"Price: {0,9}",它可以使用price变量来填充格式项。这不是我想写或读的代码。

没有表达式重新评估

编译器总是将插值字符串字面量转换为代码,该代码立即评估格式项中的表达式,并使用它们构建一个stringFormattableString。评估不能延迟或重复。请考虑以下列表中的简短示例。它两次打印相同的值,尽管开发者可能期望它使用延迟执行。

列表 9.11. 即使 FormattableString 也急于评估表达式
string value = "Before";
FormattableString formattable = $"Current value: {value}";
Console.WriteLine(formattable);                            *1*

value = "After";
Console.WriteLine(formattable);                            *2*
  • 1 打印 "Current value: Before"

  • 2 仍然打印 "Current value: Before"

如果你绝望了,你可以解决这个问题。如果你将表达式改为包含捕获value的 lambda 表达式,你可以利用这一点在每次格式化时评估它。尽管 lambda 表达式本身会立即转换为委托,但生成的委托会捕获value变量,而不是它的当前值,你可以在每次格式化FormattableString时强制委托被评估。这是一个足够糟糕的想法,尽管我在书的可下载示例中包含了一个例子,但我不会将这些页面弄脏。(这确实是一种有趣的滥用。)

没有裸露的分号

尽管你几乎可以使用任何计算值的表达式在插值字符串字面量中,但条件?:运算符有一个问题:它会混淆编译器,实际上也会混淆 C#语言的语法。除非你小心,否则冒号最终会被处理为表达式和格式字符串之间的分隔符,这会导致编译时错误。例如,这是无效的:

Console.WriteLine($"Adult? {age >= 18 ? "Yes" : "No"}");

通过在条件表达式周围使用括号可以简单地修复它:

Console.WriteLine($"Adult? {(age >= 18 ? "Yes" : "No")}");

我很少发现这成问题,部分原因是我通常尽量保持表达式比这更短。我可能会首先将是/否值提取到一个单独的字符串变量中。这很自然地引出了关于何时选择是否使用插值字符串字面量真正归结为品味问题的一场讨论。

9.4.3. 当你可以但真的不应该

编译器可能不会介意你滥用插值字符串字面量,但你的同事可能会。即使你可以使用它们,也有两个主要的原因不使用它们。

延迟格式化可能不会使用的字符串

有时你想要传递一个格式字符串和将要格式化的参数到一个可能使用它们或可能不使用它们的函数中。例如,如果你有一个前置条件验证方法,你可能想传入要检查的条件以及异常消息的格式和参数,以创建(仅在条件失败时):

Preconditions.CheckArgument(
    start.Year < 2000,
    Invariant($"Start date {start:yyyy-MM-dd} should not be earlier than year
     2000."));

或者,你可以有一个日志框架,只有当在执行时配置了适当的级别时才会记录。例如,你可能想记录服务器接收到的请求数量:

Logger.Debug("Received request with {0} bytes", request.Length);

你可能会被诱惑使用插值字符串字面量来做这件事,通过将代码更改为以下内容:

Logger.Debug($"Received request with {request.Length} bytes");

那将是一个糟糕的想法;它迫使字符串被格式化,即使它只是会被丢弃,因为格式化将在方法调用之前无条件地执行,而不是仅在需要时在方法内部执行。尽管字符串格式化在性能方面并不昂贵,但你不想不必要地进行格式化。

你可能想知道FormattableString在这里是否会有帮助。如果验证或日志库接受FormattableString作为输入参数,你就可以延迟格式化,并在一个地方控制用于格式化的文化。尽管这是真的,但仍然需要每次都创建对象,这仍然是不必要的开销。

格式化以提高可读性

不使用插值字符串字面量的第二个原因是它们可以使代码更难以阅读。简短的表达式绝对可以,并且有助于可读性。但是当表达式变长时,确定字面量中的哪些部分是代码,哪些是文本需要更多时间。我发现括号是杀手;如果你在表达式中有多于几个方法或构造函数调用,它们最终会变得令人困惑。当文本还包括括号时,这一点加倍。

这里有一个来自 Noda Time 的真实例子。它在一个测试而不是生产代码中,但我仍然希望测试是可读的:

private static string FormatMemberDebugName(MemberInfo m) =>
    string.Format("{0}.{1}({2})",
        m.DeclaringType.Name,
        m.Name,
        string.Join(", ", GetParameters(m).Select(p => p.ParameterType)));

这并不太糟糕,但想象一下将三个参数放在字符串中。我试过,结果并不好看;您最终得到一个超过 100 个字符的文本。您不能将其拆分以使用垂直格式,使每个参数独立,就像我在前面的布局中所做的那样,所以它最终变成了噪音。

为了给出一个最后的幽默例子,说明这可以是一个多么糟糕的想法,请记住用于启动本章的代码:

Console.Write("What's your name? ");
string name = Console.ReadLine();
Console.WriteLine("Hello, {0}!", name);

您可以使用一个插值字符串字面量将所有这些内容放在一个单独的语句中。您可能持怀疑态度;毕竟,这段代码由三个独立的语句组成,而插值字符串字面量只能包含表达式。这是真的,但是语句体的 lambda 表达式仍然是表达式。您需要将 lambda 表达式转换为特定的委托类型,然后您需要调用它以获取结果,但这都是可以做到的。只是不太愉快。这里有一个选项,它至少通过逐字插值的字符串字面量为每个语句使用了单独的行,但这几乎就是它能说的所有优点:

Console.WriteLine($@"Hello {((Func<string>)(() =>
{
    Console.Write("What's your name? ");
    return Console.ReadLine();
}))()}!");

我强烈推荐运行:运行代码以证明它的工作,然后尽可能快地远离它。当您从那件事中恢复过来时,让我们看看 C# 6 的另一个字符串相关特性。

9.5. 访问使用 nameof 的标识符

nameof 运算符很容易描述:它接受一个引用成员或局部变量的表达式,结果是该成员或变量的简单名称的编译时常量字符串。就这么简单。每次您在代码中硬编码类、属性或方法的名称时,您都会发现使用 nameof 运算符会更好。您的代码现在和面对变化时都会更加健壮。

9.5.1. nameof 的第一个示例

在语法方面,nameof 运算符类似于 typeof 运算符,只不过括号中的标识符不必是类型。以下列表显示了一个包含几种成员的简短示例。

列表 9.12. 打印出类、方法、字段和参数的名称
using System;

class SimpleNameof
{
    private string field;

    static void Main(string[] args)
    {
        Console.WriteLine(nameof(SimpleNameof));
        Console.WriteLine(nameof(Main));
        Console.WriteLine(nameof(args));
        Console.WriteLine(nameof(field));
    }
}

结果正是您可能期望的:

SimpleNameof
Main
args
field

到目前为止,一切顺利。但,显然,您可以通过使用字符串字面量达到相同的结果。代码也会更短。那么为什么使用 nameof 会更好呢?一句话,健壮性。如果您在字符串字面量中输入了错误,没有任何东西可以告诉您,而如果您在 nameof 操作数中输入了错误,您将得到一个编译时错误。

注意

如果您使用一个类似名称的不同成员进行引用,编译器仍然无法发现问题。如果您有两个成员,它们仅在大小写上有所不同,例如 filenamefileName,您可以在编译器没有注意到的情况下轻松地引用错误的一个。这是一个避免此类类似名称的好理由,但总是将事物以如此相似的方式命名是一个坏主意;即使您没有混淆编译器,您也容易混淆人类读者。

不仅编译器会告诉你是否出错,而且它知道你的nameof代码与你正在命名的成员或变量相关联。如果你以重构感知的方式进行重命名,你的nameof操作数也会改变。

例如,考虑以下代码示例。其目的无关紧要,但请注意oldName出现了三次:用于参数声明、使用nameof获取其名称以及作为简单表达式获取其值。

列表 9.13. 在方法体中使用其参数两次的简单方法
static void RenameDemo(string oldName)
{
    Console.WriteLine($"{nameof(oldName)} = {oldName}");
}

在 Visual Studio 中,如果你将光标放在oldName的任意一处并按 F2 进行重命名操作,所有三个都会一起重命名,如图 9.2 所示。

图 9.2. 在 Visual Studio 中重命名标识符

同样的方法也适用于其他名称(方法、类型等)。基本上,nameof在重构方面比硬编码的字符串字面量更友好。但你应该在何时使用它?

9.5.2. nameof的常见用法

我不会声称这里提供的示例是nameof的唯一合理用法。它们只是我遇到的最常见的用法。它们大多是在 C# 6 之前你会看到硬编码的名称或,可能的话,使用作为重构友好但复杂的解决方案的表达式树的地方。

参数验证

在第八章中,当我展示了 Noda Time 中Preconditions.CheckNotNull的使用时,那并不是库中实际存在的代码。真正的代码包括带有 null 值的参数名称,这使得它更有用。我在那里展示的InZone方法看起来是这样的:

public ZonedDateTime InZone(
    DateTimeZone zone,
    ZoneLocalMappingResolver resolver)
{
    Preconditions.CheckNotNull(zone, nameof(zone));
    Preconditions.CheckNotNull(resolver, nameof(resolver));
    return zone.ResolveLocal(this, resolver);
}

其他前置条件方法以类似的方式使用。这可能是我发现nameof最常见的使用方式。如果你还没有开始验证公共方法的参数,我强烈建议你开始这样做;nameof使得进行健壮的验证并带有信息性消息变得比以往任何时候都更容易。

计算属性的改变通知

正如你在第 7.2 节中看到的,CallerMemberNameAttribute使得在INotifyPropertyChanged实现中当属性本身发生变化时引发事件变得容易。但如果一个属性值的改变会影响另一个属性呢?例如,假设你有一个具有读写HeightWidth属性以及只读Area属性的Rectangle类。能够引发Area属性的事件并以安全的方式指定属性名称是有用的,如下面的代码示例所示。

列表 9.14. 使用nameof引发属性更改通知
public class Rectangle : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private double width;
    private double height;

    public double Width
    {
        get { return width; }
        set
        {
            if (width == value)                                 *1*
            {
                return;
            }
            width = value;
            RaisePropertyChanged();                             *2*
            RaisePropertyChanged(nameof(Area));                 *3*
        }
    }

    public double Height { ... }                                *4*

    public double Area => Width * Height;                       *5*

    private void RaisePropertyChanged(                          *6*
        [CallerMemberName] string propertyName = null) { ... }

}
  • 1 避免在值未改变时引发事件。

  • 2 引发宽度属性的引发事件

  • 3 引发面积属性的引发事件

  • 4 实现方式与 Width 相同

  • 5 计算属性

  • 6 根据第 7.2 节进行更改通知

大多数列表项与你在 C# 5 中编写的完全一样,但粗体行原本应该是 RaisePropertyChanged("Area")RaisePropertyChanged(() => Area)。后者在 RaisePropertyChanged 代码方面会更复杂,而且效率低下,因为它仅仅为了检查名称而构建了一个表达式树。nameof 解决方案要干净得多。

属性

有时属性会引用其他成员来指示成员之间的关系。当你想引用一个类型时,你可以使用 typeof 来建立这种关系,但这不适用于任何其他类型的成员。作为一个具体的例子,NUnit 允许测试使用 TestCaseSource 属性从字段、属性或方法中提取的值进行参数化。nameof 运算符允许你以安全的方式引用该成员。以下列表展示了 Noda Time 的另一个示例,测试从时区数据库(TZDB,现在由 IANA 托管)加载的所有时区在时间开始和结束时是否表现适当。

列表 9.15. 使用 nameof 指定测试用例源
static readonly IEnumerable<DateTimeZone> AllZones =     *1*
    DateTimeZoneProviders.Tzdb.GetAllZones();            *1*

[Test]
[TestCaseSource(nameof(AllZones))]                       *2*
public void AllZonesStartAndEnd(DateTimeZone zone)       *3*
{
  ...                                                    *4*
}
  • 1 获取所有 TZDB 时区的字段

  • 2 使用 nameof 引用字段

  • 3 按顺序调用每个时区的测试方法

  • 4 测试方法主体省略

这里的实用程序不仅限于测试。它适用于属性指示关系的任何地方。你可以想象一个更复杂的 RaisePropertyChanged 方法,如前所述,其中属性之间的关系可以用属性而不是代码来指定:

[DerivedProperty(nameof(Area))
public double Width { ... }

事件引发方法可以保留一个缓存的数据结构,指示每当它被通知 Width 属性已更改时,它还应引发 Area 的更改通知。

类似地,在对象关系映射技术(如 Entity Framework)中,一个类中通常有两个属性:一个用于外键,另一个用于表示该键的实体。以下示例展示了这一点:

public class Employee
{
    [ForeignKey(nameof(Employer))]
    public Guid EmployerId { get; set; }
    public Company Employer { get; set; }
}

肯定还有许多其他属性可以利用这种方法。现在你已经知道了这一点,你可能会发现现有代码库中有一些地方可以从 nameof 中受益。特别是,你应该寻找需要使用反射,但在编译时已知名称但之前无法以干净方式指定的代码。然而,为了完整性,我们仍需涵盖一些小的细微差别。

9.5.3. 使用 nameof 时的技巧和陷阱

你可能永远不需要知道本节中的任何细节。这部分内容主要在这里,以防你发现自己对 nameof 的行为感到惊讶。一般来说,这是一个相当简单的功能,但一些方面可能会让你感到惊讶。

引用其他类型的成员

通常,能够从另一个类型的代码中引用一个类型的成员是有用的。以 TestCaseSource 属性为例,除了名称外,你还可以指定 NUnit 将在其中查找该名称的类型。如果你有一个将在多个测试中使用的信息来源,将其放在一个公共位置是有意义的。使用 nameof 来实现这一点,你还需要指定类型。结果将是简单的名称:

[TestCaseSource(typeof(Cultures), nameof(Cultures.AllCultures))]

这等同于以下内容,除了 nameof 的所有正常好处:

[TestCaseSource(typeof(Cultures), "AllCultures")]

你还可以使用相关类型的变量来访问成员名称,尽管仅限于实例成员。相反,你可以使用类型的名称来访问静态和实例成员。以下列表显示了所有有效的排列。

列表 9.16. 访问其他类型成员名称的所有有效方式
class OtherClass
{
    public static int StaticMember => 3;
    public int InstanceMember => 3;
}

class QualifiedNameof
{
    static void Main()
    {
        OtherClass instance = null;
        Console.WriteLine(nameof(instance.InstanceMember));
        Console.WriteLine(nameof(OtherClass.StaticMember));
        Console.WriteLine(nameof(OtherClass.InstanceMember));
    }
}

我更喜欢在可能的情况下始终使用类型名称;如果你使用一个变量,它 看起来 变量的值可能很重要,但实际上它仅在编译时使用,以确定类型。如果你使用匿名类型,就没有可用的类型名称,因此你必须使用变量。

成员仍然必须可访问,你才能使用 nameof 来引用它;如果 列表 9.16 中的 StaticMemberInstanceMember 是私有的,尝试访问其名称的代码将无法编译。

泛型

你可能想知道如果你尝试获取一个泛型类型或方法的名称会发生什么,以及它应该如何指定。特别是,typeof 允许使用已绑定和未绑定的类型名称;typeof(List<string>)typeof(List<>) 都是有效的,并且会给出不同的结果。

使用 nameof,必须指定类型参数,但该参数不会包含在结果中。此外,结果中也没有类型参数数量的指示:nameof(Action<string>)nameof(Action<string, string>) 都有值为 "Action"。这可能会令人烦恼,但它消除了关于结果名称应该如何表示数组、匿名类型、进一步泛型类型等问题。

我觉得未来可能取消对类型参数指定的要求,以与 typeof 保持一致,并避免需要指定对结果没有影响的类型。但是,将结果更改为包括类型参数的数量或类型参数本身将是一个破坏性更改,我不认为会发生这种情况。在大多数相关情况下,使用 typeof 获取 Type 可能会更可取。

你可以使用 nameof 操作符与类型参数一起使用,但与 typeof(T) 不同,它总是返回类型参数的名称,而不是在执行时用于该类型参数的类型参数的名称。以下是一个最小示例:

static string Method<T>() => nameof(T);       *1*
  • 1 总是返回 "T"

无论你如何调用方法:Method<Guid>()Method<Button>()都将返回"T"。

使用别名

通常,提供类型或命名空间别名的使用指令在执行时没有效果。它们只是引用相同类型或命名空间的不同方式。nameof运算符是这一规则的例外。以下列表的输出是GuidAlias,而不是Guid

列表 9.17. 在nameof运算符中使用别名
using System;

using GuidAlias = System.Guid;

class Test
{
    static void Main()
    {
        Console.WriteLine(nameof(GuidAlias));
    }
}
预定义别名、数组和可空值类型

nameof运算符不能与任何预定义别名(如intcharlong等)或?后缀(表示可空值类型或数组类型)一起使用。因此,以下都是无效的:

nameof(float)      *1*
nameof(Guid?)      *2*
nameof(String[])   *3*
  • 1 System.Single 的预定义别名

  • 2 Nullable的缩写

  • 3 数组

这些有点令人烦恼,但您必须使用 CLR 类型名称作为预定义别名,并使用Nullable<T>语法表示可空值类型:

nameof(Single)
nameof(Nullable<Guid>)

如前节所述的泛型中提到的,Nullable<T>的名称始终是Nullable

名称、简单名称,以及仅名称

在某些方面,nameof运算符是神话中的infoof运算符的远亲,后者从未在 C#语言设计会议使用的房间里出现过。(有关infoof的更多信息,请参阅mng.bz/6GVe。)如果团队能够捕捉并驯服infoof,它将返回MethodInfoEventInfoPropertyInfo对象及其朋友的引用。唉,到目前为止,infoof仍然难以捉摸,但它用来逃避捕捉的许多技巧并不适用于更简单的nameof运算符。试图获取重载方法的名称?那没问题;它们无论如何都有相同的名称。难以轻易确定你是指属性还是类型?再次,如果它们都有相同的名称,使用哪个都无关紧要。虽然如果能够合理设计,infoof无疑会提供比nameof更多的好处,但nameof运算符仍然相当简单,并且仍然解决了许多相同的使用场景。

关于返回的内容有一点需要注意——简单名称或更非规范术语中的“末尾的位”:如果你使用nameof(Guid)或从导入System命名空间类的内部使用nameof(System.Guid),结果仍然只是"Guid"

命名空间

我没有详细说明nameof可以与之一起使用的所有成员,因为这是你预期的集合:基本上,所有成员除了终结器和构造函数。但由于我们通常从类型及其成员的角度考虑成员,你可能会惊讶地发现你可以获取命名空间的名称。是的,命名空间也是成员——其他命名空间的成员。

但是,鉴于前面关于只返回简单名称的规则,这并不是非常有用。如果你使用 nameof(System.Collections.Generic),我怀疑你希望结果是 System.Collections.Generic, 但实际上,它只是 Generic。我从未遇到过这种有用的行为,但话又说回来,通常知道一个命名空间作为编译时常量并不重要。

摘要

  • 插值字符串字面量允许你编写更简单的字符串格式化代码。

  • 你仍然可以在插值字符串字面量中使用格式字符串来提供更多的格式化细节,但格式字符串必须在编译时已知。

  • 插值文本字面量提供了插值字符串字面量和文本字符串字面量的特征混合。

  • FormattableString 类型提供了在格式化之前格式化字符串所需的所有信息。

  • FormattableString 在 .NET 4.6 和 .NET Standard 1.3 中可以直接使用,但如果你在早期版本中提供了自己的实现,编译器将使用它。

  • nameof 运算符提供了对 C# 代码中名称的重构友好和拼写安全的访问。

第十章。简洁代码的功能拼盘

本章涵盖

  • 在引用静态成员时避免代码杂乱

  • 在导入扩展方法时更加选择性地使用

  • 在集合初始化器中使用扩展方法

  • 在对象初始化器中使用索引器

  • 显式空检查要少得多

  • 只捕获你真正感兴趣的异常

本章是一个杂烩,除了以更简洁的方式表达代码意图之外,没有特定的主题贯穿其中。本章中的功能是在使用所有明显的功能分组方式之后留下的。然而,这并不以任何方式削弱了它们的有用性。

10.1. 使用静态指令

我们将要查看的第一个功能提供了一种更简单的方式来引用类型的静态成员,包括扩展方法。

10.1.1. 导入静态成员

这个功能的典型示例是 System.Math,它是一个静态类,因此只有静态成员。你将编写一个方法,将极坐标(一个角度和一个距离)转换为笛卡尔坐标(熟悉的 (x, y) 模型),使用更人性化的度数而不是弧度来表示角度。图 10.1 给出了在两种坐标系中如何表示一个点的具体示例。如果你对数学部分不是完全舒服,不要担心;它只是一个使用大量静态成员的简短代码示例。

图 10.1. 极坐标和笛卡尔坐标的示例

图片

假设你已经有一个 Point 类型,它以简单的方式表示笛卡尔坐标。转换本身相当简单,是三角学:

  • 将角度乘以 π/180 转换为弧度。常数 π 通过 Math.PI 提供。

  • 使用 Math.CosMath.Sin 方法来计算具有大小 1 的点的 xy 分量,并相乘。

以下列表显示了包含 System.Math 使用情况的完整方法,粗体文本突出显示了这些使用情况。为了方便,省略了类声明。它可以在 CoordinateConverter 类中,或者它可以是 Point 类型本身的工厂方法。

列表 10.1. C# 5 中的极坐标到笛卡尔坐标转换
using System;
...
static Point PolarToCartesian(double degrees, double magnitude)
{
    double radians = degrees * Math.PI / 180;      *1*
    return new Point(                              *2*
        Math.Cos(radians) * magnitude,             *2*
        Math.Sin(radians) * magnitude);            *2*
}
  • 1 将角度转换为弧度

  • 2 使用三角函数完成转换

虽然这段代码并不难读,但你可以想象,随着你编写更多与数学相关的代码,Math. 的重复使用会大大增加代码的混乱。

C# 6 引入了 using static 指令 以简化此类代码。以下列表与 列表 10.1 等效,但导入了 System.Math 的所有静态成员。

列表 10.2. C# 6 中的极坐标到笛卡尔坐标转换
using static System.Math;
...
static Point PolarToCartesian(double degrees, double magnitude)
{
    double radians = degrees * PI / 180;                  *1*
    return new Point(                                     *2*
        Cos(radians) * magnitude,                         *2*
        Sin(radians) * magnitude);                        *2*
}
  • 1 将角度转换为弧度

  • 2 使用三角函数完成转换

如你所见,using static 指令的语法很简单:

using static *type-name-or-alias*;

在此基础上,所有以下成员都可以通过使用它们的简单名称直接使用,而无需用类型限定:

  • 静态字段和属性

  • 静态方法

  • 枚举值

  • 嵌套类型

直接使用枚举值的能力在 switch 语句和任何结合枚举值的地方特别有用。以下并排示例展示了如何使用反射检索类型的所有字段。粗体文本突出显示了可以使用适当的 using static 指令删除的代码。

C# 5 代码 使用 C# 6 中的 static

| 使用 System.Reflection; ...

var fields = type.GetFields(

BindingFlags.Instance |

BindingFlags.Static |

BindingFlags.Public |

BindingFlags.NonPublic) | 使用静态 System.Reflection.BindingFlags; ...

var fields = type.GetFields(

Instance | Static | Public | NonPublic); |

类似地,可以通过避免在每个情况标签中重复枚举类型名称来简化响应特定 HTTP 状态码的 switch 语句:

C# 5 代码 使用 C# 6 中的 static

| 使用 System.Net;

...

switch (response.StatusCode)

{

case HttpStatusCode.OK:

...

case HttpStatusCode.TemporaryRedirect:

case HttpStatusCode.Redirect:

case HttpStatusCode.RedirectMethod:

...

case HttpStatusCode.NotFound:

...

default:

... | } 使用静态

System.Net.HttpStatusCode;

...

switch (response.StatusCode)

{

case OK:

...

case TemporaryRedirect:

case Redirect:

case RedirectMethod:

...

case NotFound:

...

default:

...

} |

嵌套类型在手工编写的代码中相对较少,但在生成的代码中更为常见。即使偶尔使用它们,C# 6 直接导入它们的能力也可以显著清理你的代码。例如,我将 Google Protocol Buffers 序列化框架实现为 C# 的代码生成了嵌套类型来表示原始 .proto 文件中声明的嵌套消息。一个特点是嵌套的 C# 类型是双重嵌套的,以避免命名冲突。假设你有一个包含如下消息的原始 .proto 文件:

message Outer {
  message Inner {
     string text = 1;
  }

  Inner inner = 1;
}

生成的代码具有以下结构,当然还有更多其他成员:

public class Outer
{
    public static class Types
    {
        public class Inner
        {
            public string Text { get; set; }
        }
    }

    public Types.Inner Inner { get; set; }
}

要在 C# 5 中从你的代码中引用 Inner,你必须使用 Outer.Types.Inner,这很痛苦。在 C# 6 中,双重嵌套变得相当不那么不便,因为它变成了单个 using static 指令:

using static Outer.Types;
...
Outer outer = new Outer { Inner = new Inner { Text = "Some text here" } };

在所有这些情况下,通过静态导入可用的成员仅在考虑了其他成员之后才在成员查找期间考虑。例如,如果你有一个 System.Math 的静态导入,但你的类中也声明了一个 Sin 方法,那么对 Sin() 的调用将找到你的 Sin 方法而不是 Math 中的那个。

导入的类型不必是静态的

using staticstatic 部分并不意味着你导入的类型必须是静态的。到目前为止显示的示例都是这样的,但你也可以导入常规类型。这让你可以无条件地访问这些类型的静态成员:

using static System.String;
...
string[] elements = { "a", "b" };
Console.WriteLine(Join(" ", elements));     *1*
  • 1 通过简单名称访问 String.Join

我发现这并不像早期示例那样有用,但它可供使用。任何嵌套类型都可以通过它们的简单名称提供。有一个例外是 using static 指令导入的静态成员集,它并不那么直接,那就是扩展方法。

10.1.2. 扩展方法和 using static

我从未特别热衷于 C# 3 中扩展方法发现的方式。导入命名空间和导入扩展方法都是通过单个 using 指令完成的;没有一种方法可以在不导入另一个的情况下完成,也没有一种方法可以从单个类型导入扩展方法。C# 6 改善了这种情况,尽管一些我不喜欢的方面无法在不破坏向后兼容性的情况下修复。

C# 6 中扩展方法和 using static 指令交互的两种重要方式容易陈述,但具有微妙的影响:

  • 来自单个类型的扩展方法可以使用该类型的 using static 指令导入,而无需从命名空间的其他部分导入任何扩展方法。

  • 从类型导入的扩展方法并不像调用常规静态方法(如 Math.Sin)那样可用。相反,你必须像调用扩展类型的实例方法一样调用它们。

我将通过使用在所有 .NET 中最常用的扩展方法集来演示第一个点:LINQ 的扩展方法。System.Linq.Queryable 类包含接受表达式树的 IQueryable<T> 扩展方法,而 System.Linq.Enumerable 类包含接受委托的 IEnumerable<T> 扩展方法。由于 IQueryable<T> 通过常规 using 指令继承自 IEnumerable<T>,因此你可以使用接受委托的 IQueryable<T> 扩展方法,尽管你通常不想这样做。下面的列表显示了仅对 System.Linq.Queryable 使用 using static 指令意味着 System.Linq.Enumerable 中的扩展方法不会被选中。

列表 10.3. 选择性导入扩展方法
using static System.Linq.Queryable;
...
var query = new[] { "a", "bc", "d" }.AsQueryable();     *1*

Expression<Func<string, bool>> expr =                   *2*
    x => x.Length > 1;                                  *2*
Func<string, bool> del = x => x.Length > 1;             *2*

var valid = query.Where(expr);                          *3*
var invalid = query.Where(del);                         *4*
  • 1 创建了一个 IQueryable

  • 2 创建了一个委托和表达式树

  • 3 有效:使用 Queryable.Where

  • 4 无效:Where 方法不接受委托

值得注意的是,如果你不小心使用常规的 using 指令导入了 System.Linq,例如为了允许 query 明确指定类型,那么这会静默地使最后一行有效。

库作者应仔细考虑这一变化的影响。如果你希望包含一些扩展方法但允许用户明确选择它们,我鼓励使用单独的命名空间。好消息是,你现在可以确信任何用户——至少是那些使用 C# 6 的用户——可以选择性导入哪些扩展方法,而无需你创建许多命名空间。例如,在 Noda Time 2.0 中,我引入了一个 NodaTime.Extensions 命名空间,其中包含针对许多类型的扩展方法。我预计一些用户可能只想导入这些扩展方法的一个子集,因此我将方法声明拆分成了几个类,每个类包含扩展单个类型的方法。在其他情况下,你可能希望根据不同的标准拆分你的扩展方法。重要的是,你应该仔细考虑你的选择。

扩展方法不能像常规静态方法那样被调用的事实,也可以通过 LINQ 轻易地演示。列表 10.4 通过在字符串序列上调用 Enumerable.Count 方法来展示这一点:一次以有效的方式作为扩展方法,就像在 IEnumerable<T> 中声明的实例方法一样,另一次尝试将其作为常规静态方法使用。

列表 10.4. 以两种方式尝试调用 Enumerable.Count
using System.Collections.Generic;
using static System.Linq.Enumerable;
...
IEnumerable<string> strings = new[] { "a", "b", "c" };

int valid = strings.Count();         *1*
int invalid = Count(strings);        *2*
  • 1 有效:将 Count 作为实例方法调用

  • 2 无效:扩展方法没有作为常规静态方法导入

实际上,语言正在鼓励你将扩展方法视为与其他静态方法不同的方式,这在之前是没有的。再次强调,这对库开发者有影响:将已经存在于静态类中的方法转换为扩展方法(通过在第一个参数上添加this修饰符)曾经是一个非破坏性更改。从 C# 6 开始,这变成了一个破坏性更改:使用using static指令导入方法的调用者会发现,在方法成为扩展方法后,他们的代码不再编译。

注意

通过静态导入发现的扩展方法并不比通过命名空间导入发现的扩展方法更受欢迎。如果你调用一个不是由常规方法调用处理的方法,但通过导入的命名空间或类有多个扩展方法适用,则应用重载解析。

就像扩展方法一样,对象和集合初始化器主要是作为 LINQ 更大功能的一部分被添加到语言中的。就像扩展方法一样,它们在 C# 6 中经过了调整,使其变得更加强大。

10.2. 对象和集合初始化器增强

作为提醒,对象和集合初始化器是在 C# 3 中引入的。对象初始化器用于设置新创建对象中的属性(或更少的情况下,字段);集合初始化器用于通过集合类型支持的Add方法向新创建的集合中添加元素。以下简单示例展示了如何使用文本和背景颜色初始化 Windows Forms Button,以及如何使用三个值初始化List<int>

Button button = new Button { Text = "Go", BackColor = Color.Red };
List<int> numbers = new List<int> { 5, 10, 20 };

C# 6 增强了这两个功能,并使它们稍微灵活一些。这些增强并不像 C# 6 中的其他一些功能那样具有全局性,但它们仍然是受欢迎的补充。在两种情况下,初始化器都被扩展,包括之前不能在那里使用的成员:对象初始化器现在可以使用索引器,集合初始化器现在可以使用扩展方法。

10.2.1. 对象初始化器中的索引器

在 C# 6 之前,对象初始化器只能调用属性设置器或直接设置字段。C# 6 允许使用与常规代码中调用它们的相同语法[index] = value来调用索引设置器。

为了以简单的方式演示这一点,我将使用StringBuilder。这将会是一个相当不寻常的使用方式,但我们很快会讨论最佳实践。该示例从现有字符串("This text needs truncating")初始化StringBuilder,将构建器截断到设置的长度,并将最后一个字符修改为 Unicode 省略号(...)。当打印到控制台时,结果是"This text..."。在 C# 6 之前,你无法在初始化器中修改最后一个字符,所以你最终会得到类似这样的结果:

string text = "This text needs truncating";
StringBuilder builder = new StringBuilder(text)             
{                                                           
    Length = 10                                *1*
};
builder[9] = '\u2026';                         *2*
Console.OutputEncoding = Encoding.UTF8;        *3*
Console.WriteLine(builder);                    *4*
  • 1 将 Length 属性设置为截断构建器

  • 2 将最后一个字符修改为“...”

  • 3 确保控制台支持 Unicode

  • 4 打印出构建器的内容

考虑到初始化器提供的很少(一个属性),我至少会考虑在单独的语句中设置长度。C# 6 允许你在单个表达式中执行所有需要的初始化,因为你可以使用对象初始化器中的索引器。以下列表以略微人为的方式展示了这一点。

列表 10.5. 在 StringBuilder 对象初始化器中使用索引器
string text = "This text needs truncating";
StringBuilder builder = new StringBuilder(text)             
{                                                           
    Length = 10,                              *1*
    [9] = '\u2026'                            *2*
};
Console.OutputEncoding = Encoding.UTF8;       *3*
Console.WriteLine(builder);                   *4*
  • 1 将 Length 属性设置为截断构建器

  • 2 将最后一个字符修改为“...”

  • 3 确保控制台支持 Unicode

  • 4 打印出构建器的内容

我故意选择在这里使用 StringBuilder 不是因为它是最明显包含索引器的类型,而是为了清楚地表明这是一个 对象 初始化器而不是 集合 初始化器。

你可能预期我会使用某种 Dictionary<,> 来实现,但这里有一个隐藏的风险。如果你的代码是正确的,它将按预期工作,但我建议在大多数情况下坚持使用集合初始化器。为了了解原因,让我们看看初始化两个字典的例子:一个使用对象初始化器中的索引器,另一个使用集合初始化器。

列表 10.6. 初始化字典的两种方式
var collectionInitializer = new Dictionary<string, int>    *1*
{
    { "A", 20 },
    { "B", 30 },
    { "B", 40 }
};

var objectInitializer = new Dictionary<string, int>        *2*
{
    ["A"] = 20,
    ["B"] = 30,
    ["B"] = 40
};
  • 1 C# 3 中的常规集合初始化器

  • 2 C# 6 中带有索引器的对象初始化器

表面上看,这些可能看起来是等效的。当你没有重复的键时,它们是等效的,我甚至更喜欢对象初始化器的样子。但是,字典索引器设置器会覆盖任何具有相同键的现有条目,而 Add 方法如果键已存在则会抛出异常。

列表 10.6 故意包含了两次的 "B" 键。这是一个容易犯的错误,通常是由于复制粘贴一行然后忘记修改键部分而造成的。在两种情况下,错误都不会在编译时被捕获,但至少在集合初始化器中,它不会默默地做错事。如果你有任何执行此段代码的单元测试——即使它们没有明确检查字典的内容——你很可能会快速发现这个错误。

Roslyn 来拯救?

当然,能够在编译时发现这个错误会更好。应该能够编写一个分析器来检测集合和对象初始化器中的这个问题。对于使用索引器的对象初始化器,很难想象有多少合法的情况需要多次指定相同的常量索引器键,因此弹出警告似乎是完全合理的。

我不知道有这样的分析器,但我希望它最终会出现。清除了这个风险后,就没有理由不使用索引器与字典一起使用了。

那么,在什么情况下你应该在对象初始化器中使用索引器而不是集合初始化器呢?你应该在以下几种合理明显的情况下这样做:

  • 如果你不能使用集合初始化器,因为类型没有实现 IEnumerable 或没有合适的 Add 方法。(然而,你可以作为扩展方法引入自己的 Add 方法,正如你将在下一节中看到的。)例如,ConcurrentDictionary<,> 没有提供 Add 方法,但它有一个索引器。它有 TryAddAddOrUpdate 方法,但这些方法不被集合初始化器使用。当你处于对象初始化器中时,你不需要担心字典的并发更新,因为只有初始化线程知道新的字典。

  • 如果索引器和 Add 方法以相同的方式处理重复键。尽管字典遵循“在添加时抛出异常,在索引器中覆盖”的模式,但这并不意味着所有类型都这样做。

  • 如果你真正试图替换元素而不是添加它们。例如,你可能基于另一个字典创建一个字典,然后替换特定键对应的值。

还存在一些不太明显的情况,你需要在这类错误的可能性与可读性之间进行权衡。列表 10.7 展示了一个具有两个常规属性的无模式实体类型的示例,但除此之外,它允许其数据具有任意键/值对。然后,你将查看初始化实例的选项。

列表 10.7. 具有键属性的无模式实体类型
public sealed class SchemalessEntity
    : IEnumerable<KeyValuePair<string, object>>
{
    private readonly IDictionary<string, object> properties =
        new Dictionary<string, object>();

    public string Key { get; set; }
    public string ParentKey { get; set; }

    public object this[string propertyKey]
    {
        get { return properties[propertyKey]; }
        set { properties[propertyKey] = value; }
    }

    public void Add(string propertyKey, object value)
    {
        properties.Add(propertyKey, value);
    }

    public IEnumerator<KeyValuePair<string, object>> GetEnumerator() =>
        properties.GetEnumerator();

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

让我们考虑两种初始化实体的方法,对于这个实体,你需要指定一个父键、新实体的键以及两个属性(例如,一个名称和一个位置,就像简单的字符串)。你可以使用集合初始化器,然后之后设置其他属性,或者使用对象初始化器来完成整个操作,但可能会在键中出错。下面的列表展示了这两种选项。

列表 10.8. 初始化 SchemalessEntity 的两种方式
SchemalessEntity parent = new SchemalessEntity { Key = "parent-key" };
SchemalessEntity child1 = new SchemalessEntity       *1*
{                                                                   
    { "name", "Jon Skeet" },                                        
    { "location", "Reading, UK" }                                   
};                                                                  
child1.Key = "child-key";                            *2*
child1.ParentKey = parent.Key;                       *2*

SchemalessEntity child2 = new SchemalessEntity
{
    Key = "child-key",                               *3*
    ParentKey = parent.Key,                          *3*
    ["name"] = "Jon Skeet",                          *4*
    ["location"] = "Reading, UK"                     *4*
};
  • 1 使用集合初始化器指定数据属性

  • 2 分别指定键属性

  • 3 在对象初始化器中指定键属性

  • 4 使用索引器指定数据属性

哪种方法更好?对我来说,第二种看起来干净得多。我通常会提取名称和位置键到字符串常量中,这样至少可以减少意外使用重复键的风险。

如果你控制着这样的类型,你可以添加额外的成员以允许你使用集合初始化器。你可以添加一个Properties属性,该属性可以直接暴露字典或暴露其视图。到那时,你可以在设置KeyParentKey的对象初始化器中使用集合初始化器来初始化Properties。或者,你也可以提供一个接受键和父键的构造函数,在这种情况下,你可以使用这些值显式调用构造函数,然后使用集合初始化器指定名称和位置属性。

这可能感觉像是选择在对象初始化器中使用索引器或使用与之前版本类似的集合初始化器时需要的大量细节。重点是选择权在你手中:没有一本书能给出简单的规则来指导你,让你在每种情况下都能得到最佳答案。要意识到利弊,并运用自己的判断。

10.2.2. 在集合初始化器中使用扩展方法

C# 6 中与对象和集合初始化器相关的第二个变化是关于在集合初始化器中可用的方法。提醒一下,为了使用与类型相关的集合初始化器,必须满足两个条件:

  • 类型必须实现IEnumerable。我发现这是一个令人烦恼的限制;有时我仅仅实现IEnumerable以便可以在集合初始化器中使用该类型。但就是这样。这个限制在 C# 6 中没有改变。

  • 集合初始化器中的每个元素都必须有一个合适的Add方法。任何不在花括号中的元素都假定是对Add方法的单参数调用。当需要多个参数时,它们必须放在花括号中。

有时,这可能会有些限制。有时你想要以Add方法提供者本身不支持的方式轻松地创建一个集合。在 C# 6 中,前面的条件仍然成立,但现在第二个条件中“合适”的定义包括了扩展方法。在某种程度上,这简化了转换。以下是一个使用集合初始化器的声明:

List<string> strings = new List<string>
{
    10,
    "hello",
    { 20, 3 }
};

该声明本质上等同于以下内容:

List<string> strings = new List<string>();
strings.Add(10);
strings.Add("hello");
strings.Add(20, 3);

正常的过载解析被用来确定每个方法调用意味着什么。如果这失败了,集合初始化器将无法编译。仅使用常规的List<T>,前面的代码将无法编译,但如果你添加一个单独的扩展方法,它就可以:

public static class StringListExtensions
{
    public static void Add(
    this List<string> list, int value, int count = 1)
    {
        list.AddRange(Enumerable.Repeat(value.ToString(), count));
    }
}

在此基础上,我们之前代码中Add的第一个和最后一个调用最终调用了扩展方法。列表最终包含五个元素("10", "hello", "20", "20", "20"),因为最后一个Add调用添加了三个元素。这是一个不寻常的扩展方法,但它有助于说明三个要点:

  • 扩展方法可以在集合初始化器中使用,这正是本书这一节的主要内容。

  • 这不是一个泛型扩展方法;它只适用于 List<string>。这是一种在 List<T> 本身中无法执行的特殊化。 (泛型扩展方法也很好,只要类型参数可以推断出来。)

  • 扩展方法中可以使用可选参数;我们第一次调用 Add 将实际上被编译为 Add(10, 1),因为第二个参数的默认值。

现在你已经知道了你可以做什么,让我们更仔细地看看在哪里使用这个特性是有意义的。

创建其他通用 Add 签名

我在我的 Protocol Buffers 工作中发现的一个有用技术是创建接受集合的 Add 方法。这个过程类似于使用 AddRange,但它可以在集合初始化器中使用。这在对象初始化器中尤其有用,其中你正在初始化的属性是只读的,但你希望添加 LINQ 查询的结果。

例如,考虑一个 Person 类,它有一个只读的 Contacts 属性,你希望用居住在 Reading 的所有联系人填充它。在 Protocol Buffers 中,Contacts 属性的类型将是 RepeatedField<Person>,而 RepeatedField<T> 有适当的 Add 方法,允许你使用集合初始化器:

Person jon = new Person
{
    Name = "Jon",
    Contacts = { allContacts.Where(c => c.Town == "Reading") }
};

这可能需要一点时间来适应,但一旦适应了,它就极其有用,并且绝对比单独调用 jon.Contacts.AddRange(...) 更好。但如果你没有使用 Protocol Buffers,并且 Contacts 只作为 List<Person> 公开呢?在 C# 6 中,这不成问题:你可以为 List<T> 创建一个扩展方法,它添加了一个接受 IEnumerable<T>Add 重载,并用它来调用 AddRange,如下所示。

列表 10.9. 通过扩展方法公开显式接口实现
static class ListExtensions
{
    public static void Add<T>(this List<T> list, IEnumerable<T> collection)
    {
        list.AddRange(collection);
    }
}

在有了这个扩展方法之后,之前的代码即使在 List<T> 中也能正常工作。如果你想更广泛一些,你可以编写一个针对 IList<T> 的扩展方法,尽管如果你选择这条路,你需要在方法体中编写循环,因为 IList<T> 没有提供 AddRange 方法。

创建专门的 Add 签名

假设你有一个 Person 类,如前所述,它有一个 Name 属性,并且在某个代码区域中你经常使用 Dictionary<string, Person> 对象,总是按名称索引 Person 对象。通过简单的调用 dictionary.Add(person) 向字典中添加条目可能是方便的,但 Dictionary<string, Person> 作为一种类型,并不知道你是在按名称索引。你的选择是什么?

你可以创建一个从 Dictionary<string, Person> 派生的类,并向其中添加一个 Add(Person) 方法。但这对我来说没有吸引力,因为你并没有以任何有意义的方面来专门化字典的行为;你只是让它更方便使用。

你可以创建一个更通用的类,实现 IDictionary<TKey, TValue>,它接受一个委托来解释从 TValueTKey 的映射,并通过组合来实现。这可能很有用,但可能对于这个任务来说有点过度。最后,你可以为这个特定情况创建一个扩展方法,如下面的列表所示。

列表 10.10. 为字典添加特定类型参数的 Add 方法
static class PersonDictionaryExtensions
{
    public static void Add(
        this Dictionary<string, Person> dictionary, Person person)
    {
        dictionary.Add(person.Name, person);
    }
}

在 C# 6 之前,这已经是一个不错的选择,但使用 using static 功能来限制扩展方法导入的方式以及使用扩展方法在集合初始化器中的使用使得它更具吸引力。然后你可以初始化一个字典,而不需要重复名称:

var dictionary = new Dictionary<string, Person>
{
    { new Person { Name = "Jon" } },
    { new Person { Name = "Holly" } }
};

这里的一个重要观点是,你如何专门化了一个特定类型参数组合的 API,但没有改变你创建的对象的类型。其他代码不需要知道这里的专门化,因为它只是表面的;它只存在于我们的方便,而不是对象固有行为的一部分。

注意

这种方法也有缺点,其中之一是没有任何东西可以阻止使用除人名之外的东西添加条目。一如既往,我鼓励你自己思考利弊;不要盲目相信我的建议或任何人的建议。

重新公开由显式接口实现“隐藏”的现有方法

在 第 10.2.1 节 中,我使用了 ConcurrentDictionary<,> 作为你可能想使用索引器而不是集合初始化器的例子。没有额外的帮助,你不能使用集合初始化器,因为没有暴露 Add 方法。但 ConcurrentDictionary<,> 确实有一个 Add 方法;只是它使用显式接口实现来实现 IDictionary<,>.Add。通常,如果你想访问使用显式接口实现的成员,你必须将其转换为接口——但在集合初始化器中你不能这样做。相反,你可以公开一个扩展方法,如下面的列表所示。

列表 10.11. 通过扩展方法公开显式接口实现
public static class DictionaryExtensions
{
    public static void Add<TKey, TValue>(
        this IDictionary<TKey, TValue> dictionary,
        TKey key, TValue value)
    {
        dictionary.Add(key, value);
    }
}

乍一看,这似乎完全没有意义。这是一个扩展方法,用于调用具有完全相同签名的函数。但这种方法实际上绕过了显式接口实现,使得 Add 方法始终可用,包括在集合初始化器中。现在你可以为 ConcurrentDictionary<,> 使用集合初始化器:

var dictionary = new ConcurrentDictionary<string, int>
{
    { "x", 10 },
    { "y", 20 }
};

当然,这应该谨慎使用。当一个方法被显式接口实现所掩盖时,这通常意味着它需要你小心地调用,而不需要一定的谨慎。这就是使用 using static 选择性导入扩展方法的能力所在:你可以有一个只打算选择性使用的静态类命名空间,并且只为每个情况导入相关的类。不幸的是,它仍然将 Add 方法暴露给同一类中的其余代码,但再次强调,你需要权衡这是否比其他选择更糟糕。

列表 10.11 中的扩展方法很广泛,扩展了所有字典。你可以决定只针对 ConcurrentDictionary<,> 进行目标定位,以避免无意中使用来自其他字典类型的显式实现的 Add 方法。

10.2.3. 测试代码与生产代码

你可能已经注意到了本节中有许多注意事项。很少有明确的案例可以让你“肯定在这里使用”这些功能。但大多数我提到的缺点都是在这样一个领域,即该功能在某个代码片段中很方便,但你不想让它影响到其他地方。

我的经验是,对象和集合初始化器通常用于两个地方:

  • 在类型初始化后永远不会被修改的集合的静态初始化器

  • 测试代码

关于暴露和正确性的担忧仍然适用于静态初始化器,但对于测试代码来说则少得多。如果你决定在你的测试程序集中使用Add扩展方法来简化集合初始化,那是可以的。这根本不会影响你的生产代码。同样,如果你在测试中的集合初始化器中使用了索引器,并且不小心设置了相同的键两次,那么你的测试很可能失败。再次强调,缺点被最小化了。

这并不是只影响这对功能的区别。测试代码仍然应该是高质量的,但与生产代码相比,衡量这种质量以及做出任何特定权衡的影响对于测试代码来说是不同的,尤其是对于公共 API。

LINQ 中作为一部分添加的扩展方法鼓励了一种更流畅的方法来组合多个操作。在许多情况下,现在更习惯于在一个语句中链接多个方法调用,而不是使用多个语句。这正是 LINQ 查询一直所做的,但随着 LINQ to XML 等 API 的出现,这成为一种更习惯的模式。这可能导致我们长期以来在链接属性访问时遇到的问题:一旦遇到空值,一切都会崩溃。C# 6 允许你在这个点上安全地终止这些链之一,而不是让代码因为异常而崩溃。

10.3. 空值条件运算符

我不会深入探讨空值的优点,但这是我们经常不得不忍受的事情,包括具有多层属性的复杂对象模型。C#语言团队长期以来一直在思考如何使空值更容易处理。其中一些工作仍在进行中,但 C# 6 已经迈出了第一步。再次强调,它可以让你编写的代码更短、更简单,表达你如何处理空值,而无需在各个地方重复表达式。

10.3.1. 简单且安全的属性解引用

作为一个工作示例,让我们假设你有一个具有Profile属性的Customer类型,该Profile属性又有一个DefaultShippingAddress属性,它有一个Town属性。现在假设你想要找到所有默认发货地址的城镇名为 Reading 的顾客。无需担心空值,你可以使用以下方法:

var readingCustomers = allCustomers
    .Where(c => c.Profile.DefaultShippingAddress.Town == "Reading");

如果你知道每个顾客都有一个配置文件,每个配置文件都有一个默认的发货地址,每个地址都有一个城镇,那么这会工作得很好。但如果其中任何一个为空呢?当你可能只想排除该顾客时,你将遇到NullReferenceException。以前,你必须将这段代码重写为一些糟糕的东西,通过使用短路&&操作符逐个检查每个属性的空值:

var readingCustomers = allCustomers
    .Where(c => c.Profile != null &&
                c.Profile.DefaultShippingAddress != null &&
                c.Profile.DefaultShippingAddress.Town == "Reading");

哎呀。重复太多了。如果你需要在最后进行方法调用而不是使用==(它已经正确处理了空值,至少对于引用来说是这样;参见第 10.3.3 节以了解可能的惊喜),情况会更糟。那么 C# 6 是如何改进这一点的呢?它引入了空条件 ?. 操作符,这是一个短路操作符,如果表达式评估为空值,则会停止。查询的空安全版本如下:

var readingCustomers = allCustomers
    .Where(c => c.Profile?.DefaultShippingAddress?.Town == "Reading");

这与我们的第一个版本完全相同,但使用了两次空条件操作符。如果c.Profilec.Profile.DefaultShippingAddress中的任何一个为空,那么==左侧的表达式将评估为空。你可能想知道为什么只有两个使用,而四个东西都可能为空:

  • c

  • c.Profile

  • c.Profile.DefaultShippingAddress

  • c.Profile.DefaultShippingAddress.Town

我已经假设allCustomers中的所有元素都是非空引用。如果你需要处理空元素的可能性,你可以在开始时使用c?.Profile。这涵盖了第一个要点;==操作符已经处理了空操作数,所以你不需要担心最后一个要点。

10.3.2. 空条件操作符的详细说明

这个简短的例子只展示了属性,但空条件操作符也可以用来访问方法、字段和索引器。基本规则是,当遇到空条件操作符时,编译器会在?左侧的值上注入一个空值检查。如果该值为空,则评估停止,整个表达式的结果是空。否则,评估会继续进行,使用?右侧的属性、方法、字段或索引访问,而不会重新评估表达式的第一部分。如果整个表达式的类型在没有空条件操作符的情况下将是一个不可为空的值类型,那么如果序列中任何地方涉及空条件操作符,它就变成了可空等效类型。

这里整体的表达式——遇到空值时评估突然停止的部分——基本上是涉及属性、字段、索引器和方法访问的序列。其他操作符,如比较操作符,由于优先级规则而中断序列。为了演示这一点,让我们更仔细地看看第 10.3.1 节中Where方法的条件。我们的 lambda 表达式如下:

c => c.Profile?.DefaultShippingAddress?.Town == "Reading"

编译器大致将其处理为如果你这样写:

string result;
var tmp1 = c.Profile;
if (tmp1 == null) 
      {
    result = null;
      }
else
{
    var tmp2 = tmp1.DefaultShippingAddress;
    if (tmp2 == null)
    {
        result = null;
    }
    else
    {
        result = tmp2.Town;
    }
}
return result == "Reading";

注意到每个属性访问(我在粗体中突出显示)只发生一次。在我们 C# 6 之前的版本中检查空值时,你可能会评估c.Profile三次,以及c.Profile.DefaultShippingAddress两次。如果这些评估依赖于其他线程修改的数据,你可能会遇到麻烦:你可能会通过前两个空值测试,但仍然因为NullReferenceException而失败。C#代码更安全、更高效,因为你只评估一次。

10.3.3. 处理布尔比较

目前,你仍然在末尾使用==操作符进行比较;如果任何内容为空,则不会短路。假设你想使用Equals方法并编写如下:

c => c.Profile?.DefaultShippingAddress?.Town?.Equals("Reading")

不幸的是,这无法编译。你添加了第三个空条件操作符,所以如果你有一个具有空Town属性的运输地址,你不会调用Equals。但现在整体结果是Nullable<bool>而不是bool,这意味着我们的 lambda 表达式还不适合Where方法。

这在空条件操作符中是一个相当常见的情况。任何你在任何条件中使用空条件操作符的时候,都需要考虑三种可能性:

  • 表达式的每一部分都会被评估,结果是真的。

  • 表达式的每一部分都会被评估,结果是假的。

  • 由于空值,表达式短路了,结果是空。

通常,你希望将这三个可能性合并为两个,使第三个选项映射为真或假的结果。有两种常见的方法来做这件事:比较一个bool常量或使用空合并??操作符。

可空布尔比较的语言设计选择

在 C# 2 版本中,bool?与不可为 null 的值进行比较时的行为让语言设计者感到担忧。当x是一个bool?变量时,x == truex != false都是有效的,但具有不同的含义,这可能会让人感到惊讶。(如果x为 null,x == true评估为false,而x != false评估为true。)

这是否是正确的设计选择?也许吧。通常,所有可用的选择在某个方面都是不愉快的。不过,现在改变已经不可能了,所以最好对此有所了解,并为可能不太了解的读者尽可能清晰地编写代码。

为了简化示例,让我们假设你已经有了一个名为name的变量,它包含相关的字符串值,但它可以是 null。你想要编写一个if语句,并根据Equals方法,如果城镇是 X,则执行语句的主体。这是演示条件的最简单方式:在现实生活中,你可能会条件性地访问一个布尔属性,例如。表 10.1 显示了你可以使用的选项,具体取决于你是否还希望在name为 null 时进入语句的主体。

表 10.1. 使用空条件运算符执行布尔比较的选项
如果名称为空,则不希望进入主体 如果名称为空,则希望进入主体
if (name?.Equals("X") ?? false) if (name?.Equals("X") == true)
if (name?.Equals("X") ?? true) if (name?.Equals("X") != false)

我更喜欢空合并运算符的方法;我把它读作“尝试执行比较,但如果需要提前停止,则默认使用??后面的值。”在你理解了表达式的类型(在这种情况下是name?.Equals("X"))是Nullable<bool>之后,这里就没有什么新的内容了。只是碰巧,你现在遇到这种情况的可能性比空条件运算符可用之前要大得多。

10.3.4. 索引器和空条件运算符

如我之前提到的,空条件运算符不仅适用于索引器,还适用于字段、属性和方法。语法再次是添加一个问号,但这次是在开方括号之前。这对于数组访问以及用户定义的索引器都适用,并且如果结果类型原本是一个不可为 null 的值类型,则结果类型变为可空的。以下是一个简单的示例:

int[] array = null;
int? firstElement = array?[0];

关于空条件运算符与索引器的工作方式,没有太多可说的;就是这样简单。我没有发现这像与属性和方法一起使用那样有用,但了解它的存在仍然很好,这不仅是为了保持一致性。

10.3.5. 高效使用空条件运算符

你已经看到,当与可能为 null 或不为 null 的属性的对象模型一起工作时,空条件运算符非常有用,但还存在其他令人信服的使用案例。在这里,我们将探讨其中的两个,但这并不是一个详尽的列表,你自己也可能想出额外的创新用法。

安全且方便的事件引发

即使在多线程的情况下安全引发事件的模式已经为人所熟知多年。例如,为了引发类似于字段的 Click 事件,类型为 EventHandler,你会编写如下代码:

EventHandler handler = Click;
if (handler != null)
{
    handler(this, EventArgs.Empty);
}

这里有两个重要的方面:

  • 你不只是调用 Click(this, EventArgs.Empty),,因为 Click 可能是 null。(如果没有处理器订阅该事件,就会是这样。)

  • 你首先将 Click 字段的值复制到一个局部变量中,这样即使在你检查 null 性之后它在另一个线程中改变了,你仍然有一个非 null 引用。你可能调用一个“稍微旧一点”(刚刚取消订阅)的事件处理器,但这是一种合理的竞争条件。

到目前为止,一切顺利——但有点冗长。然而,空条件运算符来了,它不能用于 handler(...) 的简写风格委托调用,但你可以用它来有条件地调用 Invoke 方法,并且一行搞定:

Click?.Invoke(this, EventArgs.Empty);

如果这是你方法(OnClick 或类似)中的唯一一行,那么它现在具有复合的好处,即它现在是一个单表达式主体,因此可以写成表达式主体方法。它和早期模式一样安全,但更加简洁。

充分利用返回 null 的 API

在 第九章 中,我谈到了日志记录以及插值字符串字面量在性能方面并没有帮助。但是,如果你有一个考虑到这种模式的日志 API,你可以干净地将其与空条件运算符结合使用。例如,假设你有一个类似于以下列表中所示的日志 API。

列表 10.12. 兼容空条件日志 API 的草图
public interface ILogger                   *1*
{
    IActiveLogger Debug { get; }           *2*
    IActiveLogger Info { get; }            *2*
    IActiveLogger Warning { get; }         *2*
    IActiveLogger Error { get; }           *2*
}

public interface IActiveLogger             *3*
{
    void Log(string message);
}
  • 1 GetLog 方法等返回的接口

  • 2 当日志禁用时返回 null 的属性

  • 3 表示启用日志接收器的接口

这只是一个草图;一个完整的日志 API 将会有更多内容。但通过将获取特定日志级别的活动日志器的步骤与执行日志记录的步骤分开,你可以编写高效且信息丰富的日志:

logger.Debug?.Log($"Received request for URL {request.Url}");

如果禁用了调试日志记录,你甚至不会到达格式化插值字符串字面量的步骤,你可以在不创建任何对象的情况下确定这一点。如果启用了调试日志记录,插值字符串字面量将像往常一样被评估并传递给 Log 方法。不过,不要过于感伤,这类事情正是让我热爱 C# 不断进化的原因。

当然,你需要首先使用日志 API 以适当的方式处理这个问题。如果你使用的日志 API 没有类似的功能,扩展方法可能有助于你。

许多反射 API 在适当的时候返回 null,LINQ 的FirstOrDefault(以及类似)方法可以很好地与空条件运算符一起工作。同样,LINQ to XML 也有许多方法,如果找不到你请求的内容,它们会返回 null。例如,假设你有一个包含可选的<author>元素的 XML 元素,该元素可能或可能没有name属性。你可以使用以下两种语句中的任意一种轻松检索作者名称:

string authorName = book.Element("author")?.Attribute("name")?.Value;
string authorName = (string) book.Element("author")?.Attribute("name");

第一个使用空条件运算符两次:一次用于访问元素的属性,一次用于访问属性的值。第二种方法使用了 LINQ to XML 已经在其显式转换运算符中采用的接受空值的方式。

10.3.6. 空条件运算符的限制

除了偶尔需要处理之前仅使用非空值时的可空值类型之外,空条件运算符几乎没有不愉快的情况。唯一可能让你感到惊讶的是,表达式的结果始终被分类为值而不是变量。结果是,你不能将空条件运算符用作赋值语句的左侧。例如,以下都是无效的:

person?.Name = "";
stats?.RequestCount++;
array?[index] = 10;

在那些情况下,你需要使用老式的if语句。我的经验是,这种限制很少成为问题。

空条件运算符非常适合避免NullReferenceException,但有时异常发生的原因更加合理,你需要能够处理它们。异常过滤器代表了自 C#首次引入以来catch块结构的第一次变化。

10.4. 异常过滤器

本章的最后一个特性有点尴尬:这是 C#在追赶 VB。是的,VB 一直都有异常过滤器,但它们是在 C# 6 中引入的。这是另一个你可能很少使用的特性,但它是对 CLR 内部结构的有趣一瞥。基本前提是,你现在可以编写catch块,这些块仅在过滤器表达式返回truefalse时才会偶尔捕获异常。如果返回true,则捕获异常。如果返回false,则忽略catch块。

例如,假设你正在执行网络操作,并且知道你连接的服务器有时会离线。如果你无法连接到它,你还有另一个选择,但任何其他类型的失败都应导致异常以正常方式冒泡。在 C# 6 之前,你必须捕获异常,并在没有正确状态时重新抛出它:

try
{
    ...                                                 *1*
}
catch (WebException e)
{
    if (e.Status != WebExceptionStatus.ConnectFailure)  *2*
    {                                                   *2*
        throw;                                          *2*
    }                                                   *2*
    ...                                                 *3*
}
  • 1 尝试执行网络操作

  • 2 如果不是连接失败则重新抛出

  • 3 处理连接失败

如果你不想要处理异常,你不需要捕获它;你可以通过过滤从你的catch块中将其移除,从以下开始:

try
{
    ...                                                    *1*
}
catch (WebException e)
    when (e.Status == WebExceptionStatus.ConnectFailure) *2*
{    
    ...                                                    *3*
}
  • 1 尝试执行网络操作

  • 2 只捕获连接失败

  • 3 处理连接失败

除了这种特定情况之外,我可以看到异常过滤器在两个通用用例中很有用:重试和记录。在重试循环中,你通常只想在将要重试操作时捕获异常(如果它满足某些条件且你没有用尽尝试次数);在记录场景中,你可能永远不想捕获异常,但可以在它“飞行”时记录它,换句话说。在更详细地讨论具体用例之前,让我们看看这个特性在代码中的样子以及它的行为。

10.4.1. 异常过滤器的语法和语义

我们的第一个完整示例,如以下列表所示,很简单:它遍历一组消息并为每个消息抛出异常。你有一个异常过滤器,它只会在消息包含单词catch时捕获异常。异常过滤器以粗体突出显示。

列表 10.13:抛出三个异常并捕获其中两个
string[] messages =
{
    "You can catch this",
    "You can catch this too",
    "This won't be caught"
};
foreach (string message in messages)                  *1*
{
    try
    {
        throw new Exception(message);                 *2*
    }
    catch (Exception e)
        when (e.Message.Contains("catch")) *3*
    {
        Console.WriteLine($"Caught '{e.Message}'");   *4*
    }
}
  • 1 每条消息在 try/catch 语句外循环一次

  • 2 每次抛出带有不同消息的异常

  • 3 只有当异常包含"catch"时才捕获异常

  • 4 输出捕获异常的消息

对于捕获的异常,输出是两行:

Caught 'You can catch this'
Caught 'You can catch this too'

未捕获异常的输出是一个消息“这不会被捕获”。(具体看起来如何取决于你如何运行代码,但这是一个正常的未处理异常。)

从语法上讲,这就是异常过滤器:上下文关键字when后面跟着一个括号内的表达式,该表达式可以使用catch子句中声明的异常变量,并且必须评估为布尔值。不过,其语义可能并不完全符合你的预期。

双遍历异常模型

你可能已经习惯了 CLR 在异常“冒泡”直到被捕获时回溯调用栈的想法。更令人惊讶的是,这到底是如何发生的。这个过程比你可能预期的更复杂,它使用了一个两遍模型。^([1]) 此模型使用以下步骤:

¹

我不清楚这个异常处理模型的起源。我怀疑它以一种直接的方式映射到 Windows 结构化异常处理(通常缩写为 SEH)机制,但这已经深入到 CLR(公共语言运行时)的内部,超出了我愿意探索的范围。

  • 异常被抛出,并开始第一遍处理。

  • CLR 沿着调用栈向下遍历,试图找到哪个catch块将处理异常。(我们将称之为“处理 catch 块”作为简称,但这不是官方术语。)

  • 只有与兼容异常类型匹配的catch块才会被考虑。

  • 如果一个 catch 块有一个异常过滤器,则执行该过滤器;如果过滤器返回 false,则此 catch 块将不会处理异常。

  • 没有异常过滤器的 catch 块等同于有一个返回 true 的异常过滤器的 catch 块。

  • 现在已经确定了处理 catch 块,开始进行 第二次遍历

  • CLR 从抛出异常的点开始回溯堆栈,直到确定的 catch 块。

  • 在回溯堆栈时遇到的任何 finally 块都将被执行。(这不包括与处理 catch 块关联的任何 finally 块。)

  • 执行处理 catch 块。

  • 如果有,与处理 catch 块关联的 finally 语句将被执行。

列表 10.14 展示了所有这些内容的具体示例,包括三个重要方法:BottomMiddleTopBottom 调用 Middle,而 Middle 调用 Top,因此堆栈最终会自我描述。Main 方法调用 Bottom 来启动整个过程。请别被这段代码的长度吓倒;它并没有做任何特别复杂的事情。再次强调,异常过滤器以粗体突出显示。LogAndReturn 方法只是追踪执行的一个方便方式。它被异常过滤器用来记录特定方法,然后返回指定的值以表明是否应该捕获异常。

列表 10.14. 异常过滤器的三级演示
static bool LogAndReturn(string message, bool result)      *1*
{                                                          *1*
    Console.WriteLine(message);                            *1*
    return result;                                         *1*
}                                                          *1*

static void Top()
{
    try
    {
        throw new Exception();
    }
    finally                                                *2*
    {                                                      *2*
        Console.WriteLine("Top finally");                  *2*
    }                                                      *2*
}

static void Middle()
{
    try
    {
        Top();
    }
    catch (Exception e)
        when (LogAndReturn("Middle filter", false)) *3*
    {
        Console.WriteLine("Caught in middle");             *4*
    }
    finally                                                *5*
    {                                                      *5*
        Console.WriteLine("Middle finally");               *5*
    }                                                      *5*
}

static void Bottom()
{
    try
    {
        Middle();
    }
    catch (IOException e)
        when (LogAndReturn("Never called", true)) *6*
    {
    }
    catch (Exception e)
        when (LogAndReturn("Bottom filter", true)) *7*
    {
        Console.WriteLine("Caught in Bottom");             *8*
    }
}

static void Main()
{
    Bottom();
}
  • 1 由异常过滤器调用的便利方法

  • 2 第二次遍历中执行的 finally 块(没有 catch)

  • 3 永远不会捕获异常的异常过滤器

  • 4 这永远不会打印,因为过滤器返回了 false

  • 5 第二次遍历中执行的 finally

  • 6 永远不会被调用的异常过滤器——错误的异常类型

  • 7 总是捕获异常的异常过滤器

  • 8 这将被打印出来,因为在这里捕获了异常。

呼吸!有了前面的描述和列表中的注释,你已经有足够的信息来推断输出结果。我们将逐步分析以确保其清晰易懂。首先,让我们看看打印出来的内容:

Middle filter
Bottom filter
Top finally
Middle finally
Caught in Bottom

图 10.2 展示了此过程。在每一步中,左侧显示堆栈(忽略 Main),中间部分描述正在发生的事情,右侧显示该步骤的任何输出。

图 10.2. 列表 10.14 的执行流程

两遍模型的安全影响

finally 块的执行时机也会影响 usinglock 语句。如果你正在编写可能在包含恶意代码的环境中执行代码的代码,这对你可以使用 try/finallyusing 的含义有重要的启示。如果你的方法可能被你不信任的代码调用,并且你允许异常从该方法中逃逸,那么调用者可以使用异常过滤器在执行你的 finally 块之前执行代码。

所有这些意味着你不应该将 finally 用于任何安全敏感的操作。例如,如果你的 try 块进入了一个更高级别的状态,而你依赖于 finally 块来返回到一个较低级别的状态,那么在你仍然处于高级别状态时,其他代码可能会执行。很多代码不需要担心这类问题——它们总是在友好的条件下运行——但你确实应该对此有所了解。如果你对此感到担忧,你可以使用一个空的 catch 块,并带有过滤器的功能来移除权限并返回 false(这样异常就不会被捕获),但这不是我想经常做的事情。

多次捕获相同的异常类型

在过去,在同一个 try 块中指定相同的异常类型在多个 catch 块中总是错误的。这没有意义,因为第二个块永远不会被执行。有了异常过滤器,这就有更多的意义。

为了演示这一点,让我们扩展我们最初的 WebException 示例。假设你正在根据用户提供的 URL 获取网络内容。你可能希望以一种方式处理连接失败,以另一种方式处理名称解析失败,并让任何其他类型的异常冒泡到更高级别的 catch 块。使用异常过滤器,你可以简单地做到这一点:

try
{
    ...                              *1*
}
catch (WebException e)
    when (e.Status == WebExceptionStatus.ConnectFailure)
{
    ...                              *2*
}
catch (WebException e)
    when (e.Status == WebExceptionStatus.NameResolutionFailure)
{
    ...                              *3*
}
  • 1 尝试执行网络操作

  • 2 处理连接失败

  • 3 处理名称解析失败

如果你想要以相同级别处理所有其他的 WebException,在两个特定状态之后的 catch (WebException e) { ... } 块中不使用异常过滤器是有效的。

既然你已经了解了异常过滤器的工作原理,让我们回到我之前给出的两个通用示例。这些不是唯一的用途,但它们应该能帮助你识别其他类似的情况。让我们从重试开始。

10.4.2. 重试操作

随着云计算变得越来越普遍,我们通常越来越意识到可能会失败的操作以及我们需要考虑这些失败对我们代码产生的影响。对于远程操作——例如网络服务调用和数据库操作——有时会有短暂的失败,这些失败是完全可以重试的。

跟踪你的重试策略

虽然能够像这样重试很有用,但值得注意你代码的每一层都可能在尝试重试失败的操作。如果你有多个抽象层,每个层都在尝试优雅且透明地重试可能是一时性的失败,你可能会延迟很长时间记录真正的失败。简而言之,这是一个与自身不太兼容的模式。

如果你控制着整个应用程序的堆栈,你应该考虑你希望在哪个地方进行重试。如果你只负责其中的一方面,你应该考虑使重试可配置,以便控制整个堆栈的开发者可以确定是否在你的层中进行重试。

生产环境中的重试处理有些复杂。你可能需要复杂的启发式方法来确定何时以及多长时间重试,以及尝试之间的延迟中包含一些随机性,以避免重试客户端彼此同步。列表 10.15 提供了一个高度简化的版本^([2]),以避免分散你对异常过滤器方面的注意力。

²

至少,我期望任何现实世界的重试机制都能接受一个过滤器来检查哪些失败是可以重试的,以及调用之间的延迟。

你的代码只需要知道以下内容:

  • 你试图执行的操作

  • 你愿意尝试多少次

在那个时刻,使用异常过滤器仅在你要重试操作时捕获异常,代码就很简单了。

列表 10.15. 一个简单的重试循环
static T Retry<T>(Func<T> operation, int attempts)
{   
    while (true)
    {
        try
        {
            attempts--;
            return operation();
        }
        catch (Exception e) when (attempts > 0)
        {
            Console.WriteLine($"Failed: {e}");
            Console.WriteLine($"Attempts left: {attempts}");
            Thread.Sleep(5000);
        }
    }
}

虽然无限循环 (while(true)) 往往不是一个好主意,但这个例子是有意义的。你可以编写一个基于 retryCount 条件的循环,但异常过滤器实际上已经提供了这个功能,所以这样做会误导。此外,从编译器的角度来看,循环的结束将是可到达的,所以如果没有在方法末尾添加 returnthrow 语句,它将无法编译。

当这个机制到位后,调用它进行重试很简单:

Func<DateTime> temporamentalCall = () =>
{
    DateTime utcNow = DateTime.UtcNow;
    if (utcNow.Second < 20)
    {
        throw new Exception("I don't like the start of a minute");
    }
    return utcNow;
};

var result = Retry(temporamentalCall, 3);
Console.WriteLine(result);

通常,这会立即返回结果。有时,如果你在一分钟大约 10 秒时执行它,它可能会失败几次然后成功。有时,如果你在一分钟的开始时执行它,它可能会失败几次,捕获异常并记录,然后第三次失败,此时异常不会被捕获。

10.4.3. 作为副作用进行日志记录

我们的第二个例子是记录飞行中的异常的方法。我意识到我已经使用了日志来展示许多 C# 6 的特性,但这只是一个巧合。我不相信 C# 团队决定他们要专门针对这次发布进行日志记录;它只是作为一个熟悉的场景而很好地工作。

关于在哪里以及何时记录异常的准确方法是一个备受争议的话题,我并不打算参与这场辩论。相反,我将断言,至少在某些情况下,在方法调用中记录异常是有用的,即使它将被捕获(并且可能被第二次记录)在堆栈的更深处。

你可以使用异常过滤器以不会以任何其他方式干扰执行流程的方式记录异常。你所需要的只是一个调用方法来记录异常并返回false以表示你实际上并不想捕获异常的异常过滤器。以下列表展示了在Main方法中如何实现这一点,该方法最终将以错误代码完成进程,但只有在记录了异常和带有时戳之后。

列表 10.16. 过滤器中的日志记录
static void Main()
{
    try
    {
        UnreliableMethod();
    }
    catch (Exception e) when (Log(e))
    {
    }
}        

static void UnreliableMethod()
{
    throw new Exception("Bang!");
}

static bool Log(Exception e)
{
    Console.WriteLine($"{DateTime.UtcNow}: {e.GetType()} {e.Message}");
    return false;
}

这个列表在许多方面只是列表 10.14 的一个变体,在列表 10.14 中,我们使用了日志来调查两遍异常系统的语义。在这种情况下,你永远不会在过滤器中捕获异常;整个try/catch和过滤器只存在于记录的副作用。

10.4.4. 个别、特定情况的异常过滤器

除了这些通用示例之外,特定的业务逻辑有时需要捕获某些异常,而让其他异常进一步传播。如果你怀疑这永远不会有用,考虑你是否总是捕获Exception,或者你是否倾向于捕获特定的异常类型,如IOExceptionSqlException。考虑以下代码块:

catch (IOException e)
{
    ...
}

你可以将这个块视为大致等同于这个:

catch (Exception tmp) when (tmp is IOException)
{
    IOException e = (IOException) tmp;
    ...
}

C# 6 中的异常过滤器是对此的泛化。通常,相关信息不在类型中,而是以某种其他方式公开。以SqlException为例;它有一个Number属性,对应于一个底层原因。以某种方式处理某些 SQL 失败,而以不同的方式处理其他 SQL 失败是完全合理的。从WebException获取底层 HTTP 状态稍微有些棘手,因为 API 的原因,但同样,你可能会想以不同的方式处理 404(未找到)响应和 500(内部错误)响应。

一点注意事项:我强烈建议你不要根据异常消息进行过滤(除了实验目的之外,就像我在列表 10.13 中所做的那样)。异常消息通常不被视为需要在发布之间保持稳定,并且它们可能根据来源而本地化。基于特定异常消息表现不同的代码是脆弱的。

10.4.5. 为什么不直接抛出?

你可能想知道所有这些喧嚣究竟是为了什么。毕竟,我们一直以来都能够重新抛出异常。使用类似这样的异常过滤器

catch (Exception e) when (condition)
{
    ...
}

与此并不非常不同:

catch (Exception e)
{
    if (!condition)
    {
        throw;
    }
    ...
}

这是否真正达到了新语言特性的高标准?这是有争议的。

两个代码片段之间确实存在差异:你已经看到 condition 的评估时机相对于调用堆栈中更高的 finally 块发生了变化。此外,尽管简单的 throw 语句在大多数情况下确实保留了原始堆栈跟踪,但可能存在细微的差异,尤其是在捕获和重新抛出异常的堆栈帧中。这确实可能使诊断错误变得简单或痛苦。

我怀疑异常过滤器会极大地改变许多开发者的生活。当我在 C# 5 代码库上工作时,我不会错过它们,例如与表达式成员体和插值字符串字面量相比,但它们仍然很受欢迎。

在本章描述的功能中,using static 和空值条件运算符无疑是我在大多数情况下使用的。它们适用于广泛的场景,有时可以使代码的阅读性大大提高。(特别是,如果你有处理大量在其他地方定义的常量的代码,using static 可以在可读性方面产生重大差异。)

空值条件运算符和对象/集合初始化器改进的共同之处在于能够在一个表达式中表达一个复杂操作。这加强了对象/集合初始化器在 C# 3 中引入的好处:它允许表达式用于字段初始化或可能需要单独和不太方便计算的方法参数。

摘要

  • using static 指令允许你的代码在不需要再次指定类型名称的情况下引用静态类型成员(通常是常量或方法)。

  • using static 也会导入指定类型中所有的扩展方法,因此你不需要从命名空间中导入所有的扩展方法。

  • 扩展方法导入的更改意味着将常规静态方法转换为扩展方法不再是所有情况下向后兼容的更改。

  • 集合初始化器现在可以使用 Add 扩展方法以及初始化的集合类型上定义的方法。

  • 对象初始化器现在可以使用索引器,但使用索引器和集合初始化器之间存在权衡。

  • 空值条件 ?. 运算符使得处理链式操作变得容易得多,其中链的一个元素可以返回 null。

  • 异常过滤器允许根据异常的数据而不是类型来控制确切捕获哪些异常。

第四部分. C# 7 及以后

C# 7 是自 C# 1 以来的第一个发布版本,它有多个小版本发布.^([1]) 共有四个版本:

¹

Visual Studio 2002 包含了 C# 1.0,而 Visual Studio 2003 包含了 C# 1.2。我不知道为什么版本号跳过了 1.1,而且两个版本之间的差异也不清楚。

  • 2017 年 3 月发布的 C# 7.0 与 Visual Studio 2017 版本 15.0

  • 2017 年 8 月发布的 C# 7.1 与 Visual Studio 2017 版本 15.3

  • 2017 年 12 月发布的 C# 7.2 与 Visual Studio 2017 版本 15.5

  • 2018 年 5 月发布的 C# 7.3 与 Visual Studio 2017 版本 15.7

大多数的小版本发布都是基于之前 C# 7.x 版本中引入的新特性进行扩展,而不是引入全新的领域,尽管在第十三章中涵盖的与 ref 相关的特性在 C# 7.2 中得到了极大的扩展。

据我所知,目前没有 C# 7.4 发布的计划,尽管我不会完全排除它。多个版本似乎已经工作得相当好,我预计 C# 8 也将有类似的发布周期。

在 C# 7 中要讨论的内容比 C# 6 要多,因为特性更加复杂。元组在编译器视为的类型和 CLR 使用的类型之间有一个有趣的分离。局部方法在比较它们的实现与 lambda 表达式时让我着迷。模式匹配相对简单易懂,但要在最佳优势下使用它,需要一定的思考。与 ref 相关的特性即使听起来简单,本质上也是复杂的。(我指的是in参数。)

尽管我预计大多数开发者会发现 C# 6 的特性在日常使用中非常有用,但你可能会发现一些 C# 7 的特性对你来说一点用处都没有。我很少在我的代码中使用元组,因为我通常针对的平台不支持它们。由于我编写的代码环境不特别需要这些特性,所以我很少使用与 ref 相关的特性。这并不妨碍它们成为好的特性;它们只是不是普遍适用的。其他 C# 7 特性,如模式匹配、throw表达式和数字字面量改进,更有可能对所有开发者都有用,但可能影响不如更针对性的特性大。

我只是提到这些,只是为了设定预期。就像往常一样,当你阅读关于某个特性的内容时,考虑一下你如何在你的代码中应用它。不要感到被迫去应用它;使用最多的语言特性在最短的代码中并不会得到任何分数。如果你发现你现在没有用到那个特性,那也没关系。只需记住它在那里,这样如果你在未来的不同环境中,你就知道有哪些可用。

对于我来说,设定对第十五章的期望也很重要,该章节探讨了 C#的未来。本章的大部分内容演示了已在 C# 8 预览版本中可用的功能,但并不能保证所有这些功能都将包含在最终版本中,而且可能还有我没有提到的其他功能。我希望你会发现我写的功能和我一样令人兴奋,并关注 C#团队的新预览和博客文章。对于 C#开发者来说,这是一个激动人心的时刻,无论是从我们今天拥有的东西,还是从光明的未来前景来看。

第十一章. 使用元组进行组合

本章涵盖

  • 使用元组组合数据

  • 元组语法:字面量和类型

  • 转换元组

  • 元组在 CLR 中的表示

  • 元组的替代方案及其使用指南

回到 C# 3,LINQ 革命性地改变了我们编写代码来处理数据集合的方式。它实现这一目标的方式之一是允许我们以我们想要处理每个单独项目的方式表达许多操作:如何将一个项目从一种表示形式转换为另一种表示形式,或者如何从结果中过滤掉项目,或者如何根据每个项目的特定方面对集合进行排序。对于所有这些,LINQ 并没有为我们提供许多用于处理非集合的新工具。

匿名类型提供了一种组合方式,但有一个巨大的限制,即仅在代码块内有效。你不能声明一个方法返回匿名类型,正是因为返回类型无法命名。

C# 7 引入了对元组的支持,以便简化数据的组合以及从复合类型到其单个组件的解构。如果你现在正在想,C#已经以System.Tuple类型的形式有了元组,你是对的,在某种程度上;这些类型已经在框架中存在,但没有任何语言支持。为了增加混乱,C# 7 并没有使用这些元组类型来支持其语言支持的元组。它使用了一组新的System.ValueTuple类型,你将在第 11.4 节中了解它们。在第 11.5.1 节中有与System.Tuple的比较。

11.1. 元组简介

元组允许你从多个单个值创建一个单一的复合值。它们是用于值之间相关联但不需要创建新类型的组合的简写。C# 7 引入了新的语法,使处理元组变得简单。

例如,假设你有一系列整数,并且你想要在一次遍历中找到最小值和最大值。这听起来你应该能够将这段代码放入一个单独的方法中,但你应该将返回类型设为什么?你可以返回最小值并使用out参数来获取最大值,或者使用两个out参数,但这些都感觉相当笨拙。你可以创建一个单独的命名类型,但这对于仅仅一个例子来说工作量很大。你可以使用.NET 4 中引入的Tuple<,>类返回Tuple<int, int>,但这样你就不容易区分哪个是最小值,哪个是最大值(你最终会分配一个对象来返回这两个值)。或者你可以使用 C# 7 的元组。你可以这样声明方法:

static (int min, int max) MinMax(IEnumerable<int> source)

然后,你可以这样调用它:

int[] values = { 2, 7, 3, -5, 1, 0, 10 };
var extremes = MinMax(values);             *1*
Console.WriteLine(extremes.min);           *2*
Console.WriteLine(extremes.max);           *3*
  • 1 调用方法计算最小值和最大值,并以元组的形式返回

  • 2 打印出最小值(-5)

  • 3 打印出最大值(10)

你很快就会看到MinMax的几个实现,但这个例子应该足以让你了解该特性的目的,使得阅读本章中相当详细的所有描述都值得。对于一个听起来简单的特性,元组有很多东西可以讨论,而且它们都是相互关联的,这使得很难按逻辑顺序描述。如果你在阅读时发现自己想“但是……怎么办?”的话,我强烈建议你在本节结束时再思考这个问题。这里没有什么复杂的,但有很多东西需要理解,尤其是因为我旨在全面。希望在你读到本章末尾时,所有的问题都会得到解答.^([1])

¹

如果它们不是,你当然应该在作者在线论坛或 Stack Overflow 上请求更多信息。

11.2. 元组字面量和元组类型

你可以将元组视为将一些类型引入 CLR 以及一些语法糖,以便于使用这些类型,无论是指定它们(对于变量等)还是构造值。我将从 C#语言的角度开始解释一切,而不太关心它如何映射到 CLR;然后我会回过头来解释编译器为你幕后所做的一切。

11.2.1. 语法

C# 7 引入了两种新的语法:元组字面量和元组类型。它们看起来很相似:它们都是用逗号分隔的括号中两个或更多元素的序列。在元组字面量中,每个元素都有一个值和一个可选的名称。在元组类型中,每个元素都有一个类型和一个可选的名称。图 11.1 展示了一个元组字面量的例子;图 11.2 展示了一个元组类型的例子。每个都有一个命名元素和一个未命名元素。

图 11.1. 元组字面量,元素值为 5"text"。第二个元素被命名为 title

图 11.2. 元组类型,元素类型为 intGuid。第一个元素被命名为 x

实际上,所有元素都有名称或没有任何名称的情况更为常见。例如,你可能会有 (int, int)(int x, int y, int z) 这样的元组类型,以及 (x: 1, y: 2)(1, 2, 3) 这样的元组字面量。但这只是一个巧合;没有任何东西将元素绑定在一起,关于它们是否有名称。不过,有两个关于名称的限制需要注意:

  • 名称必须在类型或字面量内是唯一的。不允许有 (x: 1, x: 2) 这样的元组字面量,并且这也没有任何意义。

  • 形如 ItemN 的名称,其中 N 是一个整数,只有在 N 的值与字面量或类型中的位置相匹配时才允许,从 1 开始。所以 (Item1: 0, Item2: 0) 是可以的,但 (Item2: 0, Item1: 0) 是禁止的。你将在下一节中看到为什么这是这种情况。

元组类型用于在与其他类型名称相同的位置指定类型:变量声明、方法返回类型等。元组字面量像任何其他指定值的表达式一样使用;它们只是将这些元素组合成一个元组值。

元组字面量中的元素值可以是任何非指针值。本章中的大多数示例为了方便起见使用了常量(主要是整数和字符串),但你通常会在字面量中使用变量作为元素值。同样,元组中的元素类型可以是任何非指针类型:数组、类型参数,甚至是其他元组类型。

现在你已经知道了元组类型的样子,你可以理解我们的 MinMax 方法的返回类型 (int min, int max)

  • 它是一个包含两个元素的元组类型。

  • 第一个元素是一个名为 minint

  • 第二个元素是一个名为 maxint

你也知道如何通过使用元组字面量来创建元组,因此你可以完全实现我们的方法,如下所示。

列表 11.1. 将序列的最小和最大值表示为元组
static (int min, int max) MinMax(                      *1*
    IEnumerable<int> source)     
{
    using (var iterator = source.GetEnumerator())
    {
        if (!iterator.MoveNext())                      *2*
        {
            throw new InvalidOperationException(
                "Cannot find min/max of an empty sequence");
        }
        int min = iterator.Current;                    *3*
        int max = iterator.Current;                    *3*
        while (iterator.MoveNext())
        {
            min = Math.Min(min, iterator.Current);     *4*
            max = Math.Max(max, iterator.Current);     *4*
        }
        return (min, max);                             *5*
    }
}
  • 1 返回类型是一个具有命名元素的元组。

  • 2 禁止空序列

  • 3 使用常规的 int 变量来跟踪 min/max

  • 4 更新变量以获取新的 min/max

  • 5 从 min 和 max 构造元组

列表 11.1 中涉及新特性的只有我已经解释过的返回类型和使用了元组字面量的 return 语句:

return (min, max);

到目前为止,我还没有谈论元组字面量的类型。我只说过,它们用于创建元组值,但我会故意暂时对此保持模糊。我将指出,我们的元组字面量目前没有任何元素名称,至少在 C# 7.0 中是这样。minmax部分使用方法中的局部变量为元素提供值。

好的元组元素名称与好的变量名称相匹配

使用字面量中的变量名称与方法返回类型中使用的名称匹配是巧合吗?就编译器而言,绝对是。编译器不会关心你是否声明方法返回(waffle: int, iceCream : int)

对于人类读者来说,这绝对不是巧合;名称表明返回的元组中的值与在方法中的含义相同。如果你发现自己提供了非常不同的名称,你可能想检查是否有错误,或者是否可能某些名称的选择更好。

当我们在定义术语时,让我们定义元组类型或字面量的元数为其拥有的元素数量。例如,(int, long)的元数为 2,而("a", "b", "c")的元数为 3。元素类型本身与元数无关。

注意

这实际上不是新术语。arity 的概念已经在泛型中存在,arity 是类型参数的数量。List<T>类型具有 1 个 arity,而Dictionary<TKey, TValue>具有 2 个 arity。

关于好的元素名称与好的变量名称的建议实际上为元组字面量改进的一个方面提供了线索。

11.2.2. 元组字面量的推断元素名称(C# 7.1)

在 C# 7.0 中,元组元素名称必须在代码中显式声明。这通常会导出看起来冗余的代码:元组字面量中指定的名称将与提供值的属性或局部变量名称匹配。在最简单的情况下,这可能类似于以下内容:

var result = (min: min, max: max);

推断不仅适用于你的代码使用简单变量时;元组通常也初始化自属性。这在 LINQ 中的投影尤其普遍。

在 C# 7.1 中,当值来自变量或属性时,元组元素名称的推断方式与匿名类型中名称的推断方式完全相同。为了了解这有多有用,让我们考虑三种在 LINQ to Objects 中编写查询的方法,该方法将两个集合连接起来以获取员工的姓名、职位和部门。首先,这是使用匿名类型的传统 LINQ:

from emp in employees
join dept in departments on emp.DepartmentId equals dept.Id
select new { emp.Name, emp.Title, DepartmentName = dept.Name };

接下来,我们将使用具有显式元素名称的元组:

from emp in employees
join dept in departments on emp.DepartmentId equals dept.Id
select (name: emp.Name, title: emp.Title, departmentName: dept.Name);

最后,我们将使用 C# 7.1 的推断元素名称:

from emp in employees
join dept in departments on emp.DepartmentId equals dept.Id
select (emp.Name, emp.Title, DepartmentName: dept.Name);

与前一个示例相比,这改变了元组元素的名称,但仍然达到了使用简洁代码创建具有有用名称的元组的目标。

尽管我已经在 LINQ 查询中演示了该功能,但它适用于你使用元组字面量的任何地方。例如,给定一个元素列表,你可以通过使用计数元素的名称推断来创建一个包含计数、最小值和最大值的元组:

List<int> list = new List<int> { 5, 1, -6, 2 };
var tuple = (list.Count, Min: list.Min(), Max: list.Max());
Console.WriteLine(tuple.Count);
Console.WriteLine(tuple.Min);
Console.WriteLine(tuple.Max);

注意,你仍然需要为MinMax指定元素名称,因为这些值是通过方法调用获得的。方法调用既不提供元组元素的推断名称,也不提供匿名类型属性的推断名称。

作为一个小小的细节,如果两个名称都会被推断为相同,则都不会被推断。如果推断的名称与显式名称发生冲突,则显式名称具有优先级,其他元素保持未命名。现在你已经知道了如何指定元组类型和元组字面量,那么你可以用它们做什么呢?

11.2.3. 将元组视为变量的袋子

下一个句子可能会让你感到震惊,所以请做好准备:元组类型是具有公共、可读写字段的值类型。当然不是!我通常强烈反对使用可变值类型,同样我也总是建议字段应该是私有的。一般来说,我坚持这些建议,但元组略有不同。

大多数类型不仅仅是原始数据;它们给数据附加了意义。有时会有数据验证。有时会在多个数据项之间强制执行关系。通常,只有当数据附加了意义时,某些操作才有意义。

元组根本不做这样的事情。它们只是表现得像是一袋变量。如果你有两个变量,你可以独立地改变它们;它们之间没有固有的联系,也没有强制的关系。元组允许你做完全相同的事情,但额外的好处是你可以将整个变量袋作为一个值传递。这在方法方面尤为重要,因为方法只能返回一个值。

图 11.3 以图形方式展示了这一点。左侧显示了声明三个独立局部变量的代码和心智模型,右侧显示了类似的代码,但其中两个变量在一个元组(椭圆形)中。在右侧,名称和分数作为元组组合在player变量中。当你想要将它们作为单独的变量处理时,你仍然可以这样做(例如,打印出player.score),但你也可以将它们作为一个组处理(例如,为player分配一个新的值)。

图 11.3. 左侧有三个单独的变量;右侧有两个变量,其中一个变量是元组

一旦你开始将元组视为变量的袋子,许多事情开始变得更有意义。但这些变量是什么?你已经看到,当你在一个元组中有命名元素时,你可以通过名称来引用它们,但如果没有名称的元素怎么办?

通过名称和位置访问元素

你可能还记得,对形式为 ItemN 的元素名称有限制,其中 N 是一个数字。嗯,那是因为元组中的每个变量都可以通过其位置以及它被赋予的任何名称来引用。每个元素仍然只有一个变量;只是可能有两种方式来引用该变量。以下示例中最容易展示这一点。

列表 11.2. 通过名称和位置读取和写入元组元素
var tuple = (x: 5, 10);
Console.WriteLine(tuple.x);       *1*
Console.WriteLine(tuple.Item1);   *1*
Console.WriteLine(tuple.Item2);   *2*
tuple.x = 100;                    *3*
Console.WriteLine(tuple.Item1);   *4*
  • 1 通过名称和位置显示第一个元素

  • 2 第二个元素没有名称;只能使用位置。

  • 3 通过名称修改第一个元素

  • 4 通过位置显示第一个元素(打印 100

到目前为止,你可能已经明白为什么 (Item1: 10, 20) 是允许的,但 (Item2: 10, 20) 不被允许。在前一种情况下,你是在重复命名元素,但在第二种情况下,你造成了歧义,即 Item2 是指第一个元素(按名称)还是第二个元素(按位置)。你可以争论 (Item5: 10, 20) 应该被允许,因为只有两个元素;Item5 不存在,因为元组只有两个元素。这是那种即使技术上不会造成歧义,但肯定会引起混淆的情况,所以仍然被禁止。

现在你已经知道可以在创建元组后修改其值,你可以重写你的 MinMax 方法,使用单个元组局部变量来表示“到目前为止的结果”,而不是使用你分开的 minmax 变量,如以下列表所示。

列表 11.3. 在 MinMax 中使用元组代替两个局部变量
static (int min, int max) MinMax(IEnumerable<int> source)
{
    using (var iterator = source.GetEnumerator())
    {
        if (!iterator.MoveNext())
        {
            throw new InvalidOperationException(
                "Cannot find min/max of an empty sequence");
        }
        var result = (min: iterator.Current, *1*
 max: iterator.Current); *1*
        while (iterator.MoveNext())
        {
 result.min = Math.Min(result.min, iterator.Current);    *2*
 result.max = Math.Max(result.max, iterator.Current);    *2*
        }
 return result; *3*
    }
}
  • 1 使用第一个值作为最小值和最大值构建元组

  • 2 分别修改元组的每个字段

  • 3 直接返回元组

列表 11.3 在工作方式上与 列表 11.1 非常非常接近。你只是将四个局部变量中的两个组合在一起;而不是 sourceiteratorminmax,你有 sourceiteratorresult,其中 result 包含 minmax 元素。内存使用量和性能将相同;这只是不同的编写方式。这是否是更好的编写代码的方式?这是一个相当主观的问题,但至少它是一个局部决策;这是一个纯粹的实施细节。

将元组视为单个值

当你在考虑方法的替代实现时,让我们考虑另一个。你可以取这个首先将新值赋给 result.min 然后将新值赋给 result.max 的代码:

result.min = Math.Min(result.min, iterator.Current);
result.max = Math.Max(result.max, iterator.Current);

如果你直接赋值给 result,你可以用一个单一的赋值来替换整个集合,如以下列表所示。

列表 11.4. 在 MinMax 中用一条语句重新赋值结果元组
static (int min, int max) MinMax(IEnumerable<int> source)
{
    using (var iterator = source.GetEnumerator())
    {
        if (!iterator.MoveNext())
        {
            throw new InvalidOperationException(
                "Cannot find min/max of an empty sequence");
        }
        var result = (min: iterator.Current, max: iterator.Current);
        while (iterator.MoveNext())
        {
 result = (Math.Min(result.min, iterator.Current),   *1*
 Math.Max(result.max, iterator.Current));  *1*
        }
 return result;
    }
}
  • 1 将整个结果赋予新的值

再次强调,在 列表 11.3 中,元组的两个元素是分别更新的,只引用了相同元素的先前值。一个更有说服力的例子是编写一个方法,返回斐波那契数列^([2]) 作为 IEnumerable<int>。C# 已经通过提供带有 yield 的迭代器来帮助你这样做,但这可能有点麻烦。下面的列表展示了完全合理的 C# 6 实现。

²

前两个元素是 0 和 1;之后,序列中的任何元素都是前两个元素的和。

列表 11.5. 不使用元组实现斐波那契数列
static IEnumerable<int> Fibonacci()
{
    int current = 0;
    int next = 1;
    while (true)
    {
        yield return current;
        int nextNext = current + next;
        current = next;
        next = nextNext;
    }
}

在迭代过程中,你跟踪序列的当前元素和下一个元素。在每次迭代中,你从代表“当前和下一个”的元组转换到“下一个和下一个下一个”。要做到这一点,你需要一个临时变量;你不能简单地依次直接给 currentnext 赋新值,因为第一个赋值会丢失第二个赋值所需的信息。

元组允许你执行一个改变两个元素的单一赋值。临时变量仍然存在于 IL 中,但以下列表中显示的源代码在我看来是美丽的。

列表 11.6. 使用元组实现斐波那契数列
static IEnumerable<int> Fibonacci()
{
    var pair = (current: 0, next: 1);
    while (true)
    {
        yield return pair.current;
        pair = (pair.next, pair.current + pair.next);
    }
}

在你走到这一步之后,很难抗拒将其进一步泛化以生成任意序列,将所有斐波那契数列代码提取到方法调用中的参数。下面的列表介绍了一个通用的 GenerateSequence 方法,它可以根据其参数生成各种序列。

列表 11.7. 分离斐波那契数列生成关注点
static IEnumerable<TResult>                             *1*
    GenerateSequence<TState, TResult>(                  *1*
        TState seed,                                    *1*
        Func<TState, TState> generator,                 *1*
        Func<TState, TResult> resultSelector)           *1*
{                                                       *1*
    var state = seed;                                   *1*
    while (true)                                        *1*
    {                                                   *1*
        yield return resultSelector(state);             *1*
        state = generator(state);                       *1*
    }                                                   *1*
}                                                       *1*

Sample usage
var fibonacci = GenerateSequence(                       *2*
    (current: 0, next: 1),                              *2*
    pair => (pair.next, pair.current + pair.next),      *2*
    pair => pair.current);                              *2*
  • 1 允许根据先前状态生成任意序列的方法

  • 2 特定于斐波那契数列的序列生成器使用

这当然可以使用匿名类型甚至命名类型来实现,但这不会那么优雅。有其他编程语言经验的读者可能不会特别印象深刻——C# 7 并没有为世界带来全新的范式——但能够在 C# 中写出这样美丽的代码是令人兴奋的。

现在你已经看到了元组的基本工作原理,让我们更深入地探讨一下。在下一节中,我们将主要考虑转换,但也会看看元素名称何时重要,何时不重要。

11.3. 元组类型和转换

到目前为止,我小心地避免深入探讨元组字面量的类型细节。通过保持一定的模糊性,我能够展示大量的代码,以便你可以感受到元组的使用方式。现在是时候证明这本书标题中的“深入”部分了。首先,想想你看到的所有使用 var 和元组字面量的声明。

11.3.1. 元组字面量的类型

一些元组字面量有类型,但一些没有。这是一个简单的规则:当元组字面量中的每个元素表达式都有类型时,它就有类型。在 C# 中,没有类型的表达式的概念并不新鲜;lambda 表达式、方法组和 null 字面量也是没有类型的表达式。就像那些例子一样,你不能使用没有类型的元组字面量来给隐式类型的局部变量赋值。例如,这是有效的,因为 10 和 20 都是具有类型的表达式:

var valid = (10, 20);

但这是无效的,因为 null 字面量没有类型:

var invalid = (10, null);

就像 null 字面量一样,没有类型的元组字面量仍然可以转换为类型。当一个元组有类型时,任何元素名称也是类型的一部分。

例如,在这些情况中,左边等同于右边:

|

var tuple = (x: 10, 20);

var array = new[] {("a", 10)};

string[] input = {"a", "b" };
var query = input
    .Select(x => (x, x.Length));

|

(int x, int) tuple = (x: 10, 20);

(string, int)[] array = {("a", 10)};

string[] input = {"a", "b" };
IEnumerable<(string, int)> query =
  input.Select<string, (string, int)>
  (x => (x, x.Length));

|

第一个示例演示了元素名称是如何从元组字面量传播到元组类型的。最后一个示例显示了类型推断在复杂情况下的工作方式:input 的类型允许 lambda 表达式中的 x 类型被固定为 string,这然后允许表达式 x.Length 被适当地绑定。这留下了一个具有 stringint 元素类型的元组字面量,因此 lambda 表达式的返回类型被推断为 (string, int)。你曾在 列表 11.7 中看到过类似类型的推断,当时我们使用序列生成方法实现了斐波那契数列,但你当时并没有关注涉及到的类型。

对于有类型的元组字面量来说,这是可以的。但对于没有类型的元组字面量,你能做什么呢?如何将没有名称的元组字面量转换为有名称的元组类型?为了回答这些问题,你需要查看元组转换的一般情况。

你需要考虑两种类型的转换:从元组字面量到元组类型的转换,以及从一个元组类型到另一个元组类型的转换。你已经在 第八章 中看到过这种差异:存在从字符串字面量表达式到 FormattableString 的转换,但没有从 string 类型到 FormattableString 的转换。这里的工作原理是相同的。你首先将查看字面量转换。

Lambda 表达式参数看起来像元组

单参数的 lambda 表达式并不令人困惑,但如果你使用两个参数,它们看起来可能像元组。作为一个例子,让我们看看一个只使用 LINQ Select 重载的有用方法,它提供了带有元素索引和值的投影。在操作中传播索引通常很有用,所以将这两部分数据放在一个元组中是有意义的。这意味着你最终得到这个方法:

static IEnumerable<(T value, int index)> WithIndex<T>
    (this IEnumerable<T> source) =>
    source.Select((value, index) => (value, index));

专注于 lambda 表达式:

(value, index) => (value, index)

这里 (value, index) 的第一次出现不是一个元组字面量;它是 lambda 表达式的参数序列。第二次出现 一个元组字面量,lambda 表达式的结果。

这里没有问题。我只是不希望当你看到类似的情况时感到惊讶。

11.3.2. 从元组字面量到元组类型的转换

正如 C# 的许多其他部分一样,存在从元组字面量到元组类型的隐式转换和显式转换。我预计显式转换的使用将很少,原因我将在稍后展示。但一旦你理解了隐式转换的工作原理,显式转换基本上就自然而然地出现了。

隐式转换

如果以下两个条件都成立,元组字面量可以隐式转换为元组类型:

  • 字面量和类型具有相同的秩。

  • 字面量中的每个表达式都可以隐式转换为相应的元素类型。

第一条要点很简单。能够将 (5, 5) 转换为 (int, int, int),例如,这会显得有些奇怪。最后一个值从哪里来呢?第二条要点稍微复杂一些,但我会通过例子来澄清。首先,让我们尝试这个转换:

(byte, object) tuple = (5, "text");

根据前面的描述,你需要查看源元组字面量 (5, "text") 中的每个元素表达式,并检查是否存在到目标元组类型 (byte, object) 中相应元素类型的隐式转换。如果每个元素都可以转换,则转换是有效的:

即使没有从 intbyte 的隐式转换,但整数常量 5 到 byte 的隐式转换是存在的(因为 5 在有效的 byte 值范围内)。还有一个从字符串字面量到 object 的隐式转换。所有转换都是有效的,因此整个转换是有效的。太好了!现在让我们尝试不同的转换:

(byte, string) tuple = (300, "text");

再次,你尝试逐元素应用隐式转换:

在这种情况下,你试图将整数常量 300 转换为 byte。这超出了有效值的范围,因此没有隐式转换。存在显式转换,但这在你试图实现元组字面量整体隐式转换时并没有帮助。从字符串字面量到 string 类型的隐式转换是存在的,但由于并非所有转换都是有效的,整个转换是无效的。如果你尝试编译此代码,你将得到一个错误,指向元组字面量中的 300

error CS0029: Cannot implicitly convert type 'int' to 'byte'

这个错误信息有些误导。它暗示我们的前一个例子也可能不合法。编译器实际上并不是试图将类型 int 转换为 byte;它试图将表达式 300 转换为 byte

显式转换

元组字面量的显式转换遵循与隐式转换相同的规则,但每个元素表达式到对应类型的转换都需要显式转换。如果满足这个条件,则从元组字面量到元组类型的转换是显式的,因此你可以按正常方式进行类型转换。

小贴士

C#中的每个隐式转换也计为显式转换,这有点令人困惑。如果你觉得这样更清晰,你可以将条件视为“每个元素必须有可用的转换,无论是显式还是隐式”。

回到我们转换(300, "text")的情况,有一个显式转换到元组类型(byte, string)。但将这个确切的表达式转换为需要未检查的上下文才能工作,因为编译器知道常量值300超出了byte的正常范围。一个更现实的例子将使用来自其他地方的int变量:

int x = 300;
var tuple = ((byte, string)) (x, "text");

转换部分——((byte, string))——看起来比需要的括号多,但它们都是必需的。内层的括号指定了元组类型,外层的括号表示了类型转换。图 11.4 以图形方式展示了这一点。

图 11.4. 解释显式元组转换中的括号

在我看来这看起来很丑,但至少它可用。在许多情况下,一个更简单的替代方案是在元组字面量中的每个元素表达式中写出适当的类型转换,这样不仅元组转换将是有效的,而且字面量的推断类型也会变成你想要的。例如,我可能会把前面的例子写成如下:

int x = 300;
var tuple = ((byte) x, "text");

这两种选项是等价的;当转换应用于整个元组字面量时,编译器仍然会对每个元素表达式发出显式转换。但我发现后者更易读。除此之外,它还清楚地表明了意图:你知道从intbyte需要显式转换,但你希望字符串保持原样。如果你试图将多个值转换为特定的元组类型(而不是使用推断的类型),这将有助于清楚地表明哪些转换是显式的,因此可能是损失性的,而不是由于整个元组显式转换而意外丢失数据。

元组字面量转换中元素名称的作用

你可能已经注意到,本节根本没有提到名称。在元组字面量转换中,它们几乎完全无关紧要。最重要的是,从没有名称的元素表达式转换为有名称的类型元素是可以的。你在这个章节中已经做了很多次,而我并没有把它当作问题提出。你从我们的第一个MinMax方法实现开始就做对了。作为提醒,该方法声明如下:

static (int min, int max) MinMax(IEnumerable<int> source)

然后我们的返回语句是这样的:

return (min, max);

你正在尝试将没有元素名称的元组字面量^([3])转换为(int min, int max)。当然,这是有效的;否则,我就不会向你展示了。尽管元素名称在元组字面量转换中并不完全无关紧要。当在元组字面量中显式指定元素名称时,如果转换到的类型中没有相应的元素名称,或者两个名称不同,编译器会警告你。以下是一个例子:

³

在 C# 7.0 中至少是这样。如 11.2.2 节所述,在 C# 7.1 中,名称是推断出来的。

(int a, int b, int c, int, int) tuple =
    (a: 10, wrong: 20, 30, pointless: 40, 50);

这显示了元素名称的所有可能组合,顺序如下:

  1. 目标类型和元组字面量指定了相同的元素名称。

  2. 目标类型和元组字面量为元素指定了名称,但名称不同。

  3. 目标类型指定了元素名称,但元组字面量没有。

  4. 目标类型没有指定元素名称,但元组字面量指定了。

  5. 目标类型和元组字面量都没有指定元素名称。

其中,第二个和第四个结果会在编译时产生警告。编译该代码的结果如下:

warning CS8123: The tuple element name 'wrong' is ignored because a different
     name is specified by the target type '(int a, int b, int c, int, int)'.
warning CS8123: The tuple element name 'pointless' is ignored because a
     different name is specified by the target type '(int a, int b, int c,
     int, int)'

第二个警告信息并不像可能的那样有帮助,因为目标类型根本未指定对应元素的名称。希望你能弄清楚出了什么问题。

这有用吗?当然有用。当你在一行语句中声明变量并构造值时没有用,但当声明和构造被分开时就有用了。例如,假设我们的MinMax方法在列表 11.1 中真的很长,难以重构。你应该返回(min, max)还是(max, min)?是的,在这种情况下,方法名本身就能使顺序非常明显,但在某些情况下可能并不那么清晰。在这种情况下,在return语句中添加元素名称可以用作验证。这样编译不会产生警告:

return (min: min, max: max);

但如果你反转元素,每个元素都会产生警告:

return (max: max, min: min);        *1*
  • 1 警告 CS8123,两次

注意,这仅适用于显式指定的名称。即使在 C# 7.1 中,当元素名称从(max, min)的元组字面量中推断出来时,将其转换为(int min, int max)的元组类型也不会产生警告。

我总是更喜欢将代码结构化,使其如此清晰,以至于你不需要进行额外的检查。但了解它在需要时可用是很好的,例如,在重构方法以使其更短之前,这可能是一个第一步。

11.3.3. 元组类型之间的转换

在掌握了元组字面量转换之后,隐式和显式元组类型转换相对简单,因为它们以类似的方式工作。在这里,你不需要担心任何表达式,只需要类型。如果每个源元素类型到相应目标元素类型都存在隐式转换,则从源元组类型到相同秩的目标元组类型存在隐式转换。同样,如果每个源元素类型到相应目标元素类型都存在显式转换,则从源元组类型到相同秩的目标元组类型存在显式转换。以下是一个示例,展示了从源类型 (int, string) 进行多个转换:

var t1 = (300, "text");                       *1*
(long, string) t2 = t1;                       *2*
(byte, string) t3 = t1;                       *3*
(byte, string) t4 = ((byte, string)) t1;      *4*
(object, object) t5 = t1;                     *5*
(string, string) t6 = ((string, string)) t1;  *6*
  • 1 t1 的类型被推断为 (int, string)。

  • 2 从 (int, string) 到 (long, string) 的有效隐式转换

  • 3 无效:无法从 int 转换到 byte

  • 4 从 (int, string) 到 (byte, string) 的有效显式转换

  • 5 从 (int, string) 到 (object, object) 的有效隐式转换

  • 6 无效:从 int 到 string 无法进行转换

在这种情况下,第四行中从 (int, string)(byte, string) 的显式转换将导致 t4.Item1 的值为 44,因为这是将 int 值 300 显式转换为 byte 的结果。

与元组字面量转换不同,如果元素名称不匹配,则不会有警告。我可以用一个与我们的秩为 5 的元组字面量转换类似的例子来展示这一点。你所需要做的只是首先将元组值存储在一个变量中,这样你执行的是类型到类型的转换,而不是字面量到类型的转换:

var source = (a: 10, wrong: 20, 30, pointless: 40, 50);
(int a, int b, int c, int, int) tuple = source;

这没有任何警告就能编译。元组类型转换的一个方面很重要,但在字面量转换中不适用,那就是转换不仅仅是隐式转换,而是身份转换。

元组类型身份转换

身份转换的概念自 C# 诞生以来就存在,尽管随着时间的推移它得到了扩展。在 C# 7 之前,规则是这样的:

  • 每个类型都可以与其自身进行身份转换。

  • objectdynamic 之间存在身份转换。

  • 如果两个数组类型的元素类型之间存在身份转换,则这两个数组类型之间存在身份转换。例如,object[]dynamic[] 之间存在身份转换。

  • 当对应类型参数之间存在身份转换时,身份转换扩展到构造的泛型类型。例如,List<object>List<dynamic> 之间存在身份转换。

元组引入了另一种身份转换:当每个对应元素类型之间存在身份转换时,无论名称如何,相同秩的元组类型之间也存在身份转换。换句话说,以下类型之间存在身份转换(双向;身份转换总是对称的):

  • (int x, object y)

  • (int a, dynamic d)

  • (int, object)

同样,这可以应用于构造类型,元组元素类型也可以构造,只要仍然存在身份转换。例如,身份转换存在于以下两种类型之间:

  • Dictionary<string, (int, List<object>)>

  • Dictionary<string, (int index, List<dynamic values)>>

当涉及到构造类型时,标识符转换对于元组来说尤为重要。如果你可以轻松地从(int, int)转换为(int x, int y),但不能从IEnumerable<(int, int)>转换为IEnumerable<(int x, int y)>,或者相反,这将会很烦人。

身份转换对于重载也同样重要。与两个重载不能仅通过返回类型而不同一样,它们也不能仅通过参数类型以及它们之间的身份转换而不同。你不能在同一个类中编写如下两个方法:

public void Method((int, int) tuple) {}
public void Method((int x, int y) tuple) {}

如果你这样做,你将收到如下编译时错误:

error CS0111: Type 'Program' already defines a member called 'Method' with
     the same parameter types

从 C#语言的角度来看,参数类型并不完全相同,但要在身份转换方面使错误信息绝对精确,这将使理解变得更加困难。

如果你发现官方对身份转换的定义令人困惑,一个简单(尽管不那么官方)的思考方式是这样的:如果执行时无法区分两种类型,则这两种类型是相同的。我们将在第 11.4 节中详细介绍这一点。

缺乏泛型方差转换

考虑到身份转换,你可能会希望可以使用具有泛型方差界面的元组类型和委托类型。遗憾的是,情况并非如此。方差仅适用于引用类型,而元组类型始终是值类型。例如,感觉这个应该可以编译:

IEnumerable<(string, string)> stringPairs = new (string, string)[10];
IEnumerable<(object, object)> objectPairs = stringPairs;

但它并不这样做。对此表示歉意。我不认为这会经常作为一个实际问题出现,但我希望在你期望它工作但最终没有时,能减少你的失望感。

11.3.4. 转换的使用

现在你已经知道了这些可用的功能,你可能想知道何时会想要使用这些元组转换。这主要取决于你更广泛地使用元组的方式。在单个方法内使用或在同一类中返回以供使用的私有方法中使用的元组很少需要转换。你只需从正确的类型开始,在构造初始值时,可能需要在元组字面量中进行类型转换。

更可能的情况是,当你使用接受或返回元组的内部或公共方法时,你需要从一种元组类型转换为另一种类型,因为你对元素类型的控制会更少。元组类型的使用范围越广,它在每次使用中都恰好是所需类型的可能性就越小。

11.3.5. 继承中的元素名称检查

虽然在转换中元素名称并不重要,但编译器在继承中使用时对它们的挑剔。当一个元组类型出现在你正在从基类重写或从接口实现成员时,你指定的元素名称必须与原始定义中的名称匹配。不仅必须匹配原始定义中指定的任何名称,而且如果原始定义中没有名称,你也不能在实现中添加一个。实现中的元素类型必须与原始定义中的元素类型进行身份转换。

例如,考虑这个ISample接口和一些尝试实现ISample.Method的方法(当然,每个方法都会在单独的实现类中):

interface ISample
{
    void Method((int x, string) tuple);
}

public void Method((string x, object) tuple) {}      *1*
public void Method((int, string) tuple) {}           *2*
public void Method((int x, string extra) tuple) {}   *3*
public void Method((int wrong, string) tuple) {}     *4*
public void Method((int x, string, int) tuple) {}    *5*
public void Method((int x, string) tuple) {}         *6*
  • 1 错误类型元素

  • 2 第一个元素缺少名称。

  • 3 第二个元素有名称;在原始定义中并没有。

  • 4 第一个元素名称错误。

  • 5 错误元组类型数量

  • 6 有效

那个例子只处理接口实现,但在重写基类成员时,相同的限制也适用。同样,那个例子只使用参数,但限制也适用于返回类型。请注意,这意味着在接口成员或虚拟/抽象类成员中添加、删除或更改元组元素名称是一个破坏性更改。在公共 API 中这样做之前请仔细思考!

注意

在某些方面,这是一个稍微不一致的步骤,因为编译器以前从未担心过类作者在重写方法或实现接口时更改方法参数名称。指定参数名称的能力意味着如果调用者根据他们是否引用接口或实现更改代码,这可能会引起问题。我的怀疑是,如果 C#语言设计者从头开始,这也会被禁止。

C# 7.3 为元组添加了一个新的语言特性:使用==!=运算符比较它们的能力。

11.3.6. 相等性和不等性运算符(C# 7.3)

正如你在第 11.4.5 节中看到的,从开始起,值元组的 CLR 表示就通过Equals方法支持了相等性。但它没有重载==!=运算符。然而,截至 C# 7.3,编译器提供了在元组之间进行身份转换时==!=的实现。(除了其他身份转换的方面,这意味着元素名称并不重要。)

编译器将==运算符扩展为使用每个对应值对的==运算符进行逐元素比较,将!=运算符扩展为使用每个对应值对的!=运算符进行逐元素比较。以下示例可能更容易说明这一点。

列表 11.8. 相等性和不等性运算符
var t1 = (x: "x", y: "y", z: 1);
var t2 = ("x", "y", 1);

Console.WriteLine(t1 == t2);                  *1*
Console.WriteLine(t1.Item1 == t2.Item1 &&     *2*
                  t1.Item2 == t2.Item2 &&     *2*
                  t1.Item3 == t2.Item3);      *2*

Console.WriteLine(t1 != t2);                  *3*
Console.WriteLine(t1.Item1 != t2.Item1 ||     *4*
                  t1.Item2 != t2.Item2 ||     *4*
                  t1.Item3 != t2.Item3);      *4*
  • 1 等价运算符

  • 2 编译器生成的等效代码

  • 3 不等价运算符

  • 4 编译器生成的等效代码

列表 11.8 显示了两个元组(一个带有元素名称,一个没有)并比较了它们的等价性和不等价性。在每种情况下,我都展示了编译器为该运算符生成的代码。这里需要注意的重要点是,生成的代码使用了元素类型提供的任何重载运算符。没有使用反射,CLR 类型无法提供相同的功能。这是一个最好由编译器处理的任务。

我们已经深入探讨了元组的语言规则,这是我们需要的。元素名称在类型推断等中的精确传播细节最好由语言规范来处理。即使这本书在深度上也有一定的限制。尽管你可以使用所有前面的信息并忽略 CLR 对元组所做的处理,但如果你稍微深入一点,了解编译器如何将这些规则转换为 IL,你将能够用元组做更多的事情,并更好地理解其行为。

我们已经覆盖了大量的内容。如果你还没有尝试使用元组编写代码,现在是时候这样做。从书中休息一下,看看你能否在学习它们是如何实现之前对元组有一个感觉。

11.4. CLR 中的元组

虽然在理论上 C#语言与.NET 没有绑定,但现实是,我所看到的每个实现至少在某种程度上都试图看起来像常规.NET Framework,即使它是预编译的,并在非 PC 桌面设备上运行。C#语言规范对最终环境提出了一些要求,包括某些类型是可用的。在撰写本文时,还没有 C# 7 规范,但我设想当它被引入时,它将需要本节中描述的类型来使用元组。

与匿名类型不同,匿名类型中每个组件内的唯一属性名称序列都会导致编译器生成一个新的类型,元组不需要编译器生成任何额外的类型。相反,它使用框架中的一组新类型。现在让我们来认识它们。

11.4.1. 介绍 System.ValueTuple<...>

C# 7 中的元组是通过System.ValueTuple类型系列实现的。这些类型位于System.ValueTuple.dll程序集中,它是.NET Standard 2.0 的一部分,但不是任何旧版.NET Framework 版本的一部分。你可以通过添加对System.ValueTuple NuGet 包的依赖项来在针对旧框架时使用它。

有九个ValueTuple结构体,具有 0 到 8 的泛型参数:

  • System.ValueTuple (非泛型)

  • System.ValueTuple<T1>

  • System.ValueTuple<T1, T2>

  • System.ValueTuple<T1, T2, T3>

  • System.ValueTuple<T1, T2, T3, T4>

  • System.ValueTuple<T1, T2, T3, T4, T5>

  • System.ValueTuple<T1, T2, T3, T4, T5, T6>

  • System.ValueTuple<T1, T2, T3, T4, T5, T6, T7>

  • System.ValueTuple<T1, T2, T3, T4, T5, T6, T7, TRest>

目前,我们将忽略前两个和最后一个,尽管我在 11.4.7 和 11.4.8 部分提到了后者。这让我们只剩下具有 2 到 7 个泛型参数的类型的处理。 (实际上,这些是您最可能使用的类型。)

任何特定的ValueTuple<...>类型的描述非常类似于早期元组类型的描述:它是一个具有公共字段的值类型。字段被称为Item1Item2等,直到Item7。具有 8 个元素的元组的最后一个字段称为Rest

任何使用 C#元组类型的时候,它都会映射到一个ValueTuple<...>类型。当 C#元组类型没有元素名称时,这种映射是非常明显的;例如(int, string, byte)映射到ValueTuple<int, string, byte>。但是,C#元组类型中的可选元素名称怎么办?泛型类型仅在它们的类型参数上是泛型的;你不能神奇地给两个构造类型不同的字段名称。编译器是如何处理这种情况的呢?

11.4.2. 元素名称处理

实际上,C#编译器在将 C#元组类型映射到 CLR ValueTuple<...>类型时忽略了名称。尽管(int, int)(int x, int y)从 C#语言的角度来看是不同的类型,但它们都映射到ValueTuple<int, int>。然后编译器将任何使用元素名称的用法映射到相关的ItemN名称。图 11.5 显示了将 C#元组字面量转换为 C#的有效翻译,它仅引用 CLR 类型。

图 11.5. 编译器将元组类型处理转换为使用 ValueTuple

图片

注意到图 11.5 的下半部分已经失去了名称。对于这样的局部变量,它们仅在编译时使用。在执行时,它们唯一的痕迹将是在为调试器提供更多信息而创建的 PDB 文件中。那么,对于在方法相对较小的上下文之外可见的元素名称怎么办?

元素名称在元数据中

回想一下在本章中你已经多次使用的MinMax方法。假设你将这个方法公开作为整个聚合方法包的一部分,以补充 LINQ to Objects。失去由元组元素名称提供的可读性将是一件非常遗憾的事情,但你现在知道该方法的 CLR 返回类型无法传播它们。幸运的是,编译器可以使用已经在其他不受 CLR 直接支持的功能中实施的技术,例如out参数和默认参数值;属性来拯救!

在这种情况下,编译器使用一个名为 TupleElementNamesAttribute 的属性(与许多类似属性位于同一命名空间中:System.Runtime.CompilerServices)来在程序集中对元素名称进行编码。例如,一个公共的 MinMax 方法声明可以用 C# 6 表示如下:

[return: TupleElementNames(new[] {"min", "max"})]
public static ValueTuple<int, int> MinMax(IEnumerable<int> numbers)

C# 7 编译器不会让你编译这段代码。编译器会给出一个错误,告诉你直接使用元组语法。但是,使用 C# 6 编译器编译相同的代码会得到一个可以在 C# 7 中使用的程序集,并且返回的元组的元素可以通过名称访问。

当涉及到嵌套元组类型时,属性会变得稍微复杂一些,但你几乎不需要直接解释这个属性。只需知道它存在,以及元素名称是如何在局部变量之外进行通信的。即使没有它们,编译器也可以处理私有成员,因此值得知道这一点。我怀疑无论成员的访问修饰符如何,以相同的方式处理所有成员都会简单得多。

执行时间没有元素名称

如果前面的所有内容都没有让你明白,元组值在执行时间没有元素名称的概念。如果你对一个元组值调用 GetType(),你会得到一个带有适当元素类型的 ValueTuple<...> 类型,但你在源代码中设置的任何元素名称都不会出现在任何地方。如果你在代码中单步执行,并且调试器显示元素名称,那是因为它使用了额外的信息来推断原始元素名称;这不是 CLR 直接知道的东西。

注意

这种方法可能对 Java 开发者来说很熟悉。它类似于 Java 处理在执行时间没有类型信息的泛型的方式。在 Java 中,没有 ArrayList<Integer> 对象或 ArrayList<String> 对象这样的东西;它们只是 ArrayList 对象。这在 Java 中证明是痛苦的,但元组的元素名称在泛型类型参数中不如基本重要,所以希望它不会导致同样的问题。

在 C# 语言中,元组存在元素名称,但在 CLR 中并不存在。那么转换呢?

11.4.3. 元组转换实现

ValueTuple 家族的类型在 CLR 方面不提供任何转换。它们无法做到;C# 语言提供的转换无法在类型信息中表达。相反,当需要时,C# 编译器会为每个元素创建一个新的值,并执行适当的转换。以下有两个转换示例,一个是隐式转换(使用从 intlong 的隐式转换)和一个显式转换(使用从 intbyte 的显式转换):

(int, string) t1 = (300, "text");
(long, string) t2 = t1;
(byte, string) t3 = ((byte, string)) t1;

编译器生成的代码就像你这样写:

var t1 = new ValueTuple<int, string>(300, "text");
var t2 = new ValueTuple<long, string>(t1.Item1, t1.Item2);       
var t3 = new ValueTuple<byte, string>((byte) t1.Item1, t1.Item2));

那个例子只处理了你已经看到的元组类型之间的转换,但元组字面量到元组类型的转换以完全相同的方式进行:从元素表达式到目标元素类型的任何所需转换都只是作为调用适当的ValueTuple<...>构造函数的一部分来执行。

你现在已经了解了编译器为了提供元组语法所需的所有内容,但ValueTuple<...>类型提供了更多功能,使它们易于使用。鉴于它们的通用性,它们不能做很多事情,但ToString()方法提供了可读的输出,并且有多个选项用于比较它们。让我们看看有哪些可用功能。

11.4.4. 元组的字符串表示

元组的字符串表示形式类似于 C#源代码中的元组字面量:由逗号分隔的值序列,用括号括起来。对此输出没有精细的控制;例如,如果你使用(DateTime, DateTime)元组来表示日期间隔,你不能传递一个格式字符串来指示你希望元素格式化为日期。ToString()方法对每个非空元素调用ToString(),对每个空元素使用空字符串。

作为提醒,你提供给元组元素的名称在执行时是未知的,因此它们不能出现在调用ToString()的结果中。这可能会使其比匿名类型的字符串表示形式稍微不那么有用,尽管如果你打印大量相同类型的元组,你会对缺乏重复而感到感激。一个简短的例子就足以展示所有前面的信息:

var tuple = (x: (string) null, y: "text", z: 10);   *1*
Console.WriteLine(tuple.ToString());                *2*
  • 1 将 null 转换为字符串以便推断元组类型

  • 2 将元组值写入控制台

此代码片段的输出如下:

(, text, 10)

我在这里明确地调用了ToString()方法,只是为了证明没有其他操作在进行。调用Console.WriteLine(tuple)也会得到相同的结果。

元组的字符串表示对于诊断目的肯定是有用的,但在直接面向最终用户的应用程序中很少适合直接显示。你可能会想要提供更多上下文,指定某些类型的格式信息,并可能更清晰地处理空值。

11.4.5. 常规相等性和排序比较

每个ValueTuple<...>类型实现了IEquatable<T>IComparable<T>,其中T与类型本身相同。例如,ValueTuple<T1, T2>实现了IEquatable<ValueTuple<T1, T2>>IComparable<ValueTuple<T1, T2>>

每个类型也实现了非泛型的IComparable接口,并以自然的方式重写了object.Equals(object)方法:如果传入的是不同类型的实例,Equals(object)将返回false,如果传入的是不同类型的实例,CompareTo(object)将抛出ArgumentException。否则,每个方法都会委托给IEquatable<T>IComparable<T>中的对应方法。

相等性测试是使用每个元素类型的默认相等性比较器逐元素执行的。同样,元素哈希码是使用默认相等性比较器计算的,然后以特定实现的方式将这些哈希码组合起来,为元组提供一个整体的哈希码。元组之间的排序比较也是逐元素执行的,较早的元素在比较中被认为比较晚的元素更重要,例如,(1, 5) 被认为小于 (3, 2)

这些比较使得元组在 LINQ 中易于处理。假设你有一个表示 (x, y) 坐标的 (int, int) 元组集合。你可以使用熟悉的 LINQ 操作来查找列表中的不同点并对它们进行排序。这在下述列表中展示。

列表 11.9. 查找和排序不同点
var points = new[]
{
    (1, 2), (10, 3), (-1, 5), (2, 1),
    (10, 3), (2, 1), (1, 1)
};
var distinctPoints = points.Distinct();
Console.WriteLine($"{distinctPoints.Count()} distinct points");
Console.WriteLine("Points in order:");
foreach (var point in distinctPoints.OrderBy(p => p))
{
    Console.WriteLine(point);
}

Distinct() 调用意味着你只会在输出中看到一次 (2, 1)。但是,由于相等性是逐元素检查的,所以 (2, 1) 并不等于 (1, 2)。

因为元组中的第一个元素在排序中被认为是最重要的,所以我们的点将按照它们的 x 坐标进行排序;如果多个点的 x 坐标相同,则将按照它们的 y 坐标进行排序。因此,输出如下:

5 distinct points
Points in order:
(-1, 5)
(1, 1)
(1, 2)
(2, 1)
(10, 3)

常规的比较无法指定如何比较每个特定的元素。当然,你可以很容易地创建自己的 IEqualityComparer<T>IComparer<T> 的自定义实现,用于特定的元组类型,但在这个时候,你可能想要考虑是否值得为你要表示的数据实现一个完全自定义的类型,并完全避免使用元组。或者,在某些情况下,使用结构比较可能更简单。

11.4.6. 结构相等性和排序比较

除了常规的 IEquatableIComparable 接口之外,每个 ValueTuple 结构体还明确实现了 IStructuralEquatableIStructuralComparable 接口。这些接口自 .NET 4.0 以来就存在了,并由数组以及不可变的 Tuple 类家族实现。我无法说我曾经自己使用过这些接口,但这并不意味着它们不能被使用并且用得好。它们反映了常规的相等性和排序 API,但每个方法都接受一个比较器,该比较器旨在用于单个元素:

public interface IStructuralEquatable
{
    bool Equals(Object, IEqualityComparer);
    int GetHashCode(IEqualityComparer);
}

public interface IStructuralComparable
{
    int CompareTo(Object, IComparer);
}

接口背后的思想是允许通过使用给定的比较器进行成对比较来比较复合对象以进行相等性或排序。ValueTuple 类型实现的常规泛型比较是静态类型安全的,但相对不灵活,因为它们总是使用元素的默认比较。而结构比较则相对不安全,但提供了额外的灵活性。以下列表通过使用字符串并传递不区分大小写的比较器来演示这一点。

列表 11.10. 使用不区分大小写的比较器进行结构比较
static void Main()
{
    var Ab = ("A", "b");                            *1*
    var aB = ("a", "B");                            *1*
    var aa = ("a", "a");                            *1*
    var ba = ("b", "a");                            *1*

    Compare(Ab, aB);                                *2*
    Compare(aB, aa);                                *2*
    Compare(aB, ba);                                *2*
}

static void Compare<T>(T x, T y)
    where T : IStructuralEquatable, IStructuralComparable
{
    var comparison = x.CompareTo(                   *3*
        y, StringComparer.OrdinalIgnoreCase);       *3*
    var equal = x.Equals(                           *3*
        y, StringComparer.OrdinalIgnoreCase);       *3*

    Console.WriteLine(
        $"{x} and {y} - comparison: {comparison}; equal: {equal}");
}
  • 1. 反映值的非常规变量名

  • 2 执行有趣的比较选择

  • 3 以不区分大小写的方式执行排序和相等比较

列表 11.10 的输出表明,比较确实是以不区分大小写的方式成对进行的:

(A, b) and (a, B) - comparison: 0; equal: True
(a, B) and (a, a) - comparison: 1; equal: False
(a, B) and (b, a) - comparison: -1; equal: False

这种比较的好处在于它完全是组合的结果:比较器知道如何仅对单个元素执行比较,而元组实现将每个比较委托给比较器。这有点像 LINQ,你在其中表达对单个元素的操作,但随后要求它们在集合上执行。

如果你的元组元素都是同一类型,这一切都很完美。如果你想在具有不同类型元素的元组上执行结构比较,例如比较(string, int, double)值,那么你需要确保你的比较器可以处理比较字符串、比较整数和比较双精度浮点数。然而,每个比较只需要比较相同类型的两个值。ValueTuple实现仍然只允许比较具有相同类型参数的元组;例如,如果你比较(string, int)(int, string),则会立即抛出异常,在比较任何元素之前。

这就结束了我们对 arity-2 到 arity-7 的ValueTuple<...>类型的介绍,但我确实提到我会回到你在第 11.4.1 节中看到的另外三种类型。首先,让我们看看ValueTuple<T1>ValueTuple<T1, T2, T3, T4, T5, T6, T7, TRest>,它们比你想象的更紧密相关。

11.4.7. Womples 和大型元组

单值元组(ValueTuple<T1>),被 C#团队亲切地称为womple,不能单独使用元组语法构建,但它可以是另一个元组的一部分。如前所述,存在具有最多八个类型参数的泛型ValueTuple结构。如果 C#编译器遇到一个包含超过八个元素的元组字面量,它应该怎么做?它使用具有 8 个参数的ValueTuple<...>,前七个类型参数对应于元组字面量中的前七个类型,最后一个元素是剩余元素的嵌套元组类型。如果你有一个包含正好八个int元素的元组字面量,涉及的类型如下:

ValueTuple<int, int, int, int, int, int, int, ValueTuple<int>>

这里有womple,用粗体突出显示。ValueTuple<...>具有 8 个参数,专门为此用途设计;最后的类型参数(TRest)被限制为必须是值类型,并且,正如我在第 11.4.1 节开头提到的,没有Item8字段。相反,有一个Rest字段。

在一个参数为 8 的ValueTuple<...>中,最后一个元素始终预期是一个包含更多元素的元组,而不是一个最终的单独元素,以避免歧义。例如,这样的元组类型

ValueTuple<A, B, C, D, E, F, G, ValueTuple<H, I>>

可以被视为具有 9 个参数的 C#-语法类型(A, B, C, D, E, F, G, H, I)或具有 8 个参数的类型(A, B, C, D, E, F, G, (H, I)),其中最后一个元素是元组类型。

作为开发者,你不需要担心所有这些,因为 C#编译器允许你使用ItemX名称来表示元组中的所有元素,无论元素数量多少,以及你是否使用了元组语法或显式引用了ValueTuple。例如,考虑一个相当长的元组:

var tuple = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16);
Console.WriteLine(tuple.Item16);

这段代码完全有效,但编译器会将tuple.Item16表达式转换为tuple.Rest.Rest.Item2。如果你想使用真正的字段名,当然可以这样做;我只是不建议这样做。现在从巨大的元组到完全相反的情况。

11.4.8. 非泛型 ValueTuple 结构

如果一开始 womple 听起来有点愚蠢,那么nuple——一个非泛型元组,一个没有任何元素的元组——听起来更是毫无意义。你可能预期非泛型的ValueTuple会是一个静态类,就像非泛型的Nullable类一样,但它是一个结构体,看起来和其他元组结构体一样,除了没有任何数据。它实现了本节前面描述的所有接口,但每个 nuple 值(在普通相等和排序意义上)都等于其他每个 nuple 值,这是有道理的,因为没有什么可以区分它们。

它确实有静态方法,如果没有元组字面量,这些方法将非常有用,用于创建ValueTuple<...>值。这些方法主要在你想要从 C# 6 或没有内置支持的另一种语言中使用元组类型,并且想要使用元素类型的类型推断时有用。(记住,当你调用构造函数时,你总是必须指定所有类型参数,这可能会很烦人。)例如,要在 C# 6 中使用类型推断构造一个(int, int)值元组,你可以使用这个:

var tuple = ValueTuple.Create(5, 10);

C#团队暗示,未来可能会有一些地方 nuples 在模式匹配和分解中很有用,但目前这更多的是一个占位符。

11.4.9. 扩展方法

System.TupleExtensions静态类与System.ValueTuple类型位于同一程序集。它包含对System.TupleSystem.ValueTuple类型的扩展方法。有三种类型的方法:

  • Deconstruct,它扩展了Tuple类型

  • ToValueTuple,它扩展了Tuple类型

  • ToTuple,它扩展了ValueTuple类型

每种方法都通过使用你之前看到的相同模式,通过泛型参数数量进行 21 次重载,以处理 8 个或更多的参数数量。你将在第十二章(kindle_split_029_split_000.html#ch12)中查看 Deconstruct,但 ToValueTupleToTuple 完全按照你的预期执行:它们在 .NET 4.0 时代的不可变引用类型元组和新的可变值类型元组之间进行转换。我预计这些主要用于与使用 Tuple 的遗留代码一起工作。

哇!这就是我想知道的关于在 CLR 上实现元组所涉及类型的一切了。接下来,我们将考虑你的其他选项:如果你在考虑使用元组,你应该知道这仅仅是你的工具箱中的一个工具,而且并不总是最合适的选择。

11.5. 元组的替代方案

虽然这听起来可能有些陈词滥调,但你在过去用于变量集合的每个选项仍然有效。你并不必须在任何地方使用 C# 7 的元组。本节简要探讨了其他选项的优缺点。

11.5.1. System.Tuple<...>

.NET 4 的 System.Tuple<...> 类型是不可变的引用类型,尽管它们内部的元素类型可能是可变的。你可以将其视为在浅层上不可变,就像 readonly 字段一样。

这里最大的缺点是缺乏任何语言集成。传统的元组更难创建,类型指定更冗长,我在第 11.3 节(kindle_split_028_split_000.html#ch11lev1sec3)中描述的转换根本不存在,最重要的是,你只能使用 ItemX 命名风格。尽管附加到 C# 7 元组上的名称仅在编译时有效,但它们在可用性上仍然有巨大的差异。

此外,引用类型元组感觉像是完整的对象,而不是值的集合,这取决于上下文,可能是好是坏。它们通常不太方便使用,但复制一个大型 Tuple<...> 对象的单个引用肯定比复制一个 ValueTuple<...> 更有效率,后者涉及到复制所有元素值。这也对安全的多线程有影响:复制引用是原子的,而复制值元组则不是。

11.5.2. 匿名类型

匿名类型作为 LINQ 的一部分被引入,在我的经验中,这仍然是它们的主要用途。你可以在方法内的常规变量中使用它们,但我记不起在生产代码中看到过这种用法。

匿名类型的许多优点也存在于 C# 7 元组中:命名元素、自然相等性和清晰的字符串表示。匿名类型的主要问题正是它们是匿名的;你不能从方法或属性中返回它们而不会丢失所有类型安全。你基本上必须使用objectdynamic。在执行时信息仍然存在,但编译器不知道这一点。C# 7 元组没有这个问题。从方法中返回元组是完全可以的,就像你看到的。

我可以看到匿名类型相对于元组的四个优点:

  • 在 C# 7.0 中,提供单个标识符中名称和值的投影初始化器比元组简单;例如,比较new { p.Name, p.Age }(name: p.Name, age: p.Age)。这在 C# 7.1 中得到解决,因为元组元素名称可以被推断,从而产生如(p.Name, p.Age)这样的紧凑表示。

  • 在匿名类型的字符串表示中使用名称对于诊断目的来说可能很有用。

  • 匿名类型由进程外 LINQ 提供程序(数据库等)支持。元组字面量目前不能在表达式树中使用,这使得其价值主张显著减弱。

  • 由于在管道中传递单个引用,匿名类型在某些情况下可能更有效率。在大多数情况下,我不期望这会成为一个问题,而且元组不会为垃圾收集器创建任何需要清理的对象,这在其他方面是一个效率优势,当然。

在 LINQ to Objects 中,我预计会广泛使用元组,尤其是在使用 C# 7.1 及其推断的元组元素名称时。

11.5.3. 命名类型

元组只是变量的集合。它们没有封装;除了你决定如何使用它们之外,它们没有其他含义。有时这正是你想要的,但要注意不要走得太远。考虑一个(double, double)。它可以用来表示

  • 2D 笛卡尔坐标(x, y)

  • 2D 极坐标(半径,角度)

  • 1D 起始/结束对

  • 任何其他数量的事物

当作为一等类型建模时,这些用例中的每一个都会对其执行不同的操作。你不需要担心名称没有传播或者不小心使用了笛卡尔坐标而不是极坐标,例如。

如果你只需要临时分组值,或者如果你正在原型设计且不确定你需要什么,元组是个不错的选择。但如果你发现你在代码的几个地方使用了相同的元组结构,我建议你用命名类型来替换它。

注意

一个 Roslyn 代码分析器可以自动化大部分这些操作,使用元组元素名称来检测不同的用法,这将是非常棒的。不幸的是,目前我不知道有这样的工具。

在这个替代选项的背景下,让我们以一些更详细的建议来结束这一章,关于元组可能有用的情况。

11.6. 使用和建议

首先,重要的是要记住,在 C# 7 中,对元组的语言支持是新的。这里提出的任何建议都是基于对元组的思考,而不是对元组的广泛使用。理性可以带你走得很远,但它并不能给你太多关于实际经验的洞察。我过去对何时使用新语言特性的预期证明是有些错误的,所以请带着一颗宽容的心来看待这里的一切。话虽如此,但希望至少能提供一些思考的食物。

11.6.1. 非公共 API 和易于更改的代码

在社区普遍对元组有更多经验并且最佳实践通过艰苦的战斗经验确立之前,我会避免在公共 API 中使用元组,包括在其他程序集可以派生的类型中的受保护成员。如果你处于幸运的情况,可以控制(并且可以任意修改)与你的代码交互的所有代码,你可以更加推测。但你不希望处于这样的情况:仅仅因为从公共方法返回元组很容易,后来却发现你实际上更希望更彻底地封装这些值。命名类型需要更多设计和实现工作,但结果可能不会比调用者使用起来更难。元组主要方便的是实现者而不是调用者。

我目前的偏好是更进一步,只在类型内部将元组用作实现细节。我可以在私有方法中返回元组,但我会避免在生产代码中的内部方法中这样做。一般来说,决策越局部化,改变主意就越容易,你也不必过多地思考。

11.6.2. 本地变量

元组主要是为了允许在不需要使用out参数或专用返回类型的情况下从方法中返回多个值而设计的。但这并不意味着你只能在这些地方使用它们。

在方法中,自然地分组变量并不罕见。当你查看变量时,如果它们有一个共同的词缀,你通常可以判断出来。例如,列表 11.11 显示了一个可能在游戏中出现的方法,用于显示特定日期的最高得分玩家。尽管 LINQ to Objects 有一个Max方法可以返回投影的最高值,但没有东西可以返回与该值关联的原始序列元素。

注意

另一种选择是使用OrderByDescending(...).FirstOrDefault(),但这会在你需要找到单个值时引入排序。MoreLinq 包有MaxBy方法,可以解决这个问题。另一种保持两个变量的替代方法是保持一个单一的highestGame变量,并在比较中使用该变量的Score属性。在更复杂的情况下,这可能不太可行。

列表 11.11. 显示特定日期的最高得分玩家
public void DisplayHighScoreForDate(LocalDate date)
{
    var filteredGames = allGames.Where(game => game.Date == date);
    string highestPlayer = null;
    int highestScore = -1;
    foreach (var game in filteredGames)
    {
        if (game.Score > highestScore)
        {
            highestPlayer = game.PlayerName;
            highestScore = game.Score;
        }
    }
    Console.WriteLine(highestPlayer == null
        ? "No games played"
        : $"Highest score was {highestScore} by {highestPlayer}");
}

这里你有四个局部变量,包括参数:

  • date

  • filteredGames

  • highestPlayer

  • highestScore

最后两个问题紧密相关;它们同时初始化并一起更改。这表明你可以 考虑 使用元组变量,如下面的列表所示。

列表 11.12. 使用元组局部变量的重构
public void DisplayHighScoreForDate(LocalDate date)
{
    var filteredGames = allGames.Where(game => game.Date == date);
    (string player, int score) highest = (null, -1);
    foreach (var game in filteredGames)
    {
        if (game.Score > highest.score)
        {
            highest = (game.PlayerName, game.Score);
        }
    }
    Console.WriteLine(highest.player == null
        ? "No games played"
        : $"Highest score was {highest.score} by {highest.player}");
}

改变的内容以粗体显示。这是否更好?也许吧。从元组被视为变量集合的角度来看,从哲学上讲,代码是完全相同的。对我来说,它感觉稍微干净一些,因为它减少了方法在顶层考虑的概念数量。显然,在适用于书籍的简单示例中,清晰度的差异可能很小。但是,如果你有一个难以重构为多个较小方法的方法,元组局部变量可能会产生更显著的影响。对于字段,这种考虑也是合理的。

11.6.3. 字段

正如局部变量有时会自然地聚集在一起一样,字段也是如此。以下是从 Noda Time 的 PrecalculatedDateTimeZone 中的一个示例:

private readonly ZoneInterval[] periods;
private readonly IZoneIntervalMapWithMinMax tailZone;
private readonly Instant tailZoneStart;
private readonly ZoneInterval firstTailZoneInterval;

我不会解释所有这些字段的意义,但希望可以合理明显地看出最后三个字段与尾区相关。我们可以考虑将它们改为使用两个字段,其中一个字段是元组:

private readonly ZoneInterval[] periods;
private readonly
    (IZoneIntervalMapWithMinMax intervalMap,
     Instant start,
     ZoneInterval firstInterval) tailZone;

之后的代码可以引用 tailZone.starttailZone.intervalMap 等等。请注意,因为 tailZone 变量被声明为 readonly,除了在构造函数中之外,对单个元素的赋值都是无效的。存在一些限制和注意事项:

  • 元组的元素仍然可以在构造函数中单独赋值,但如果你初始化了某些元素但没有全部初始化,则不会有警告。例如,如果你在原始代码中忘记了初始化 tailZoneStart,你会看到一个警告,但如果你忘记了初始化 tailZone.start,则没有等效的警告。

  • 要么整个元组字段是只读的,要么都不是。如果你有一组相关的字段,其中一些是只读的,而另一些不是,你或者必须放弃只读的特性,或者不使用这种技术。在那个点上,我通常会直接不使用元组。

  • 如果某些字段是自动生成的字段,作为自动实现属性的支撑,你必须编写完整的属性来使用元组。再次,在那个点上,我会跳过元组。

最后,元组的一个可能不明显方面是它与动态类型的交互。

11.6.4. 元组与动态类型并不总是相处融洽

我自己并不经常使用 dynamic,而且我怀疑动态类型和元组的良好用途交集不会很大。然而,值得注意的是关于元素访问的两个问题。

动态绑定器不知道元素名称

记住,元素名称主要是在编译时考虑的问题。再结合动态绑定仅在执行时发生的特性,我猜你大概能预见接下来会发生什么。作为一个简单的例子,考虑以下代码:

dynamic tuple = (x: 10, y: 20);
Console.WriteLine(tuple.x);

乍一看,这似乎应该输出 10,但会抛出异常:

Unhandled Exception: Microsoft.CSharp.RuntimeBinder.RuntimeBinderException:
     'System.ValueTuple<int,int>' does not contain a definition for 'x'

虽然这很不幸,但要保留元素名称信息以便动态绑定器工作,需要做大量的调整。我不期望这种情况会改变。如果你修改代码以打印tuple.Item1而不是tuple.Item9,那没问题。至少,对于前七个元素来说,这是可以的。

动态绑定器(目前)不知道高元素编号

在 11.5.4 节中,你看到了编译器如何处理包含超过七个元素的元组。编译器使用具有 8 个参数的ValueTuple<...>,其最后一个元素包含另一个通过Rest字段访问的元组,而不是Item8字段。除了转换类型本身之外,编译器还会转换编号元素访问;例如,源代码中引用tuple.Item9在生成的 IL 中会引用tuple.Rest.Item2

在撰写本文时,动态绑定器对此并不知情,因此你将再次看到在编译时绑定中相同的代码会抛出异常。例如,你可以轻松地测试和尝试以下操作:

var tuple = (1, 2, 3, 4, 5, 6, 7, 8, 9);
Console.WriteLine(tuple.Item9);              *1*
dynamic d = tuple;
Console.WriteLine(d.Item9);                  *2*
  • 1 成功,引用 tuple.Rest.Item2

  • 2 在执行时失败

与前一个问题不同,可以通过使动态绑定器更智能来修复这个问题。但这样执行时的行为将取决于你的应用程序最终使用哪个版本的动态绑定器。通常,编译器版本、程序集和框架版本之间存在合理的分离。要求特定的动态绑定器版本无疑会使得问题更加复杂。

摘要

  • 元组作为没有封装的元素集合。

  • C# 7 中的元组具有独特的语言和 CLR 表示。

  • 元组是具有公共、可变字段的值类型。

  • C#元组支持灵活的元素名称。

  • CLR ValueTuple<...> 结构体始终使用Item1Item2等元素名称。

  • C#为元组类型和元组文字提供了转换。

第十二章。解构和模式匹配

本章涵盖

  • 将元组解构到多个变量中

  • 非元组类型的解构

  • 在 C# 7 中应用模式匹配

  • 使用 C# 7 中引入的三种模式

在第十一章中,你了解到元组允许你简单地组合数据,而不必创建新类型,并允许一个变量充当其他变量的集合。当你使用元组时——例如,从整数序列中打印最小值然后打印最大值——你会逐个从元组中提取值。

这当然有效,在许多情况下,这已经足够了。但在很多情况下,你可能希望将复合值分解为单独的变量。这种操作称为 分解。这个复合值可能是一个元组,也可能是其他类型——例如 KeyValuePair。C# 7 提供了简单的语法,允许在单个语句中声明或初始化多个变量。

分解以无条件的方式进行,就像一系列赋值操作。模式匹配与此类似,但处于更动态的环境中;输入值必须匹配模式才能执行其后的代码。C# 7 在几个上下文中引入了模式匹配,以及几种模式,未来版本可能还会更多。我们将从分解你刚刚创建的元组开始,构建第十一章。

12.1. 元组的分解

C# 7 提供了两种分解方式:一种用于元组,另一种用于其他所有类型。它们的语法相同,具有相同的一般特性,但在抽象层面上谈论它们可能会造成混淆。我们首先来看元组,我会指出任何特定于元组的内容。在第 12.2 节中,你将看到相同的概念如何应用于其他类型。为了给你一个大致的概念,下面的列表展示了分解的几个特性,你将在接下来的内容中更详细地研究它们。

列表 12.1. 使用元组的分解概述
var tuple = (10, "text");                 *1*

var (a, b) = tuple;                       *2*

(int c, string d) = tuple;                *3*

int e;                                    *4*
string f;                                 *4*
(e, f) = tuple;                           *4*

Console.WriteLine($"a: {a}; b: {b}");     *5*
Console.WriteLine($"c: {c}; d: {d}");     *5*
Console.WriteLine($"e: {e}; f: {f}");     *5*
  • 1 创建一个类型为 (int, string) 的元组

  • 2 隐式分解为新的变量 a, b

  • 3 明确分解为新的变量 c, d

  • 4 分解为现有变量

  • 5 证明分解是有效的

我怀疑,如果你看到那段代码并被告知它将编译,即使你之前没有阅读过有关元组或分解的内容,你也已经能够猜出输出结果:

a: 10; b: text
c: 10; d: text
e: 10; f: text

你所做的一切只是以比以前更少代码的方式声明和初始化了六个变量 abcdef。这并不是要贬低该特性的有用性,但这次相对没有太多细微之处需要探讨。在所有情况下,操作都像是从元组中复制一个值到变量中一样简单。它不会将变量与元组关联;稍后更改变量不会更改元组,反之亦然。

元组声明和分解语法

语言规范将分解视为与其他元组特性密切相关。即使你不在分解元组时,分解语法也是以 元组表达式 的形式描述的(你将在第 12.2 节中看到)。你可能不需要过于担心这一点,但你应该意识到潜在的混淆原因。考虑以下两个语句:

(int c, string d) = tuple;
(int c, string d) x = tuple;

第一个使用解构来声明两个变量(cd);第二个是声明一个元组类型的单个变量(x),类型为(int c, string d)。我认为这种相似性并不是设计错误,但就像表达式成员看起来像 lambda 表达式一样,这需要一点时间来习惯。

让我们先更详细地看看示例的前两部分,其中你在一条语句中声明和初始化。

12.1.1. 解构到新变量

始终可以在一条语句中声明多个变量,但前提是它们必须是同一类型。我通常坚持每条语句一个声明,以保持可读性。但是,当你可以在一条语句中声明和初始化多个变量,并且初始值都来自同一来源时,那就很方便了。特别是,如果这个来源是函数调用,你可以避免声明一个额外的变量来避免多次调用。

最容易理解的语法可能是每个变量都显式类型化的那种——与参数列表或元组类型相同的语法。为了阐明我前面关于额外变量的观点,以下列表显示了方法调用结果解构为三个新变量后的元组。

列表 12.2. 调用一个方法并将结果解构到三个变量中
static (int x, int y, string text) MethodReturningTuple() => (1, 2, "t");

static void Main()
{
    (int a, int b, string name) = MethodReturningTuple();
    Console.WriteLine($"a: {a}; b: {b}; name: {name}");
}

优点并不那么明显,直到你考虑不使用解构的等效代码。这就是编译器将前面的代码转换成的内容:

static void Main()
{
    var tmp = MethodReturningTuple();
    int a = tmp.x;
    int b = tmp.y;
    string name = tmp.text;

    Console.WriteLine($"a: {a}; b: {b}; name: {name}");
}

这三个声明语句并没有让我太烦恼,尽管我确实欣赏原始代码的简洁性,但tmp变量真的很让人烦恼。正如其名称所暗示的,它只是临时存在的;它的唯一目的是记住方法调用的结果,以便可以用来初始化你真正想要的三个变量:abname。即使你只想在那一小段代码中使用tmp,但它与其他变量的作用域相同,这让我感觉有些混乱。如果你想要对某些变量使用隐式类型,而对其他变量使用显式类型,那也是可以的,如图 12.1 所示。

图 12.1. 解构中混合隐式和显式类型

如果你想使用隐式转换来指定与原始元组元素类型不同的类型,这在需要时特别有用;请参见图 12.2。

图 12.2. 涉及隐式转换的解构

如果你愿意为所有变量使用隐式类型,C# 7 提供了简写来简化操作;只需在名称列表之前使用 var

var (a, b, name) = MethodReturningTuple();

这等价于在参数列表内部为每个变量使用 var,而这又等价于根据被分配的值的类型显式指定推断类型。就像常规隐式类型变量声明一样,使用 var 并不会使你的代码变为动态类型;它只是让编译器推断类型。

尽管你可以在括号内指定的类型方面在隐式类型和显式类型之间混合使用,但你不能在变量列表之前使用 var 然后为一些变量提供类型:

var (a, long b, name) = MethodReturningTuple();     *1*
  • 1 无效:混合“内部和外部”声明
一个特殊的标识符:_ 被丢弃

C# 7 有三个特性允许在新的地方引入局部变量:

  • 解构(本节和 12.2)

  • 模式 (第 12.3 节 到 第 12.7 节)

  • 输出变量 (第 14.2 节)

在所有这些情况下,指定变量名为 _(单个下划线)具有特殊含义。它是一个 丢弃,意味着“我不关心结果。我甚至不希望它作为一个变量——只是去掉它。”当使用丢弃时,它不会引入新的变量到作用域中。你可以使用多个丢弃而不是为多个你不在乎的变量指定不同的变量名。

这里是一个在元组解构中使用丢弃的示例:

var tuple = (1, 2, 3, 4);        *1*
var (x, y, _, _) = tuple;        *2*
Console.WriteLine(_);            *3*
  • 1 四个元素的元组

  • 2 解构元组但只保留前两个元素

  • 3 错误 CS0103:名称 ' _ ' 在当前上下文中不存在

如果你已经有一个名为 _ 的变量在作用域内(使用常规变量声明),你仍然可以在解构中使用丢弃来赋值给其他新的变量集,而现有的变量将保持不变。

正如你在我们的原始概述中看到的那样,你不必声明新变量来使用解构。解构可以作为一个赋值序列来执行。

12.1.2. 解构赋值给现有变量和属性

上一节解释了我们的原始概述示例的大部分内容。在本节中,我们将查看代码的这一部分:

var tuple = (10, "text");
int e;
string f;
(e, f) = tuple;

在这种情况下,编译器并不是将解构视为一系列具有相应初始化表达式的声明,而是一系列赋值。这和之前章节中看到的避免临时变量的好处相同。以下列表提供了一个使用之前使用的相同 MethodReturningTuple() 的示例。

列表 12.3. 使用解构赋值给现有变量
static (int x, int y, string text) MethodReturningTuple() => (1, 2, "t");

static void Main()
{
    int a = 20;                                           *1*
    int b = 30;                                           *1*
    string name = "before";                               *1*
    Console.WriteLine($"a: {a}; b: {b}; name: {name}");   *1*

    (a, b, name) = MethodReturningTuple();                *2*

    Console.WriteLine($"a: {a}; b: {b}; name: {name}");   *3*
}
  • 1 声明、初始化和使用三个变量

  • 2 使用解构赋值给所有三个变量

  • 3 显示新值

到目前为止,一切顺利,但这个特性不仅仅局限于对局部变量的赋值能力。任何可以作为单独语句有效执行的赋值操作,都可以使用解构来实现。这可以是对字段、属性或索引器的赋值,包括对数组和其它对象的操作。

声明或赋值:不要混合

解构允许你声明和初始化变量,或者执行一系列赋值。你不能混合使用这两种方式。例如,以下是不合法的:

int x;
(x, int y) = (1, 2);

赋值可以使用各种目标,这是可以的:一些现有的局部变量,一些字段,一些属性,等等。

除了常规赋值外,你还可以将值赋给一个丢弃(即 _ 标识符),如果作用域中没有名为 _ 的变量,这实际上会丢弃该值。如果你在作用域中有一个名为 _ 的变量,解构会像平常一样将其赋值。

在解构中使用 _:赋值还是丢弃?

起初这看起来有点令人困惑:有时当存在同名的现有变量时,将 _ 用于解构会改变其值,有时则会丢弃它。你可以通过两种方式避免这种困惑。第一种是查看解构的其余部分,以确定它是否引入了新的变量(在这种情况下 _ 是一个丢弃),或者是否将值赋给现有变量(在这种情况下 _ 被赋予一个新值,就像其他变量一样)。

避免混淆的第二种方法是不要使用 _ 作为局部变量名。

实际上,我预计几乎所有的赋值解构都会针对局部变量或 this 的字段和属性。事实上,有一种巧妙的小技巧可以在构造函数中使用,这使得 C# 7 中引入的表达式主体构造函数变得更加有用。许多构造函数根据构造函数参数将值赋给属性或字段。如果你首先将参数收集到一个元组字面量中,就可以在一个表达式中完成所有这些赋值,如下一列表所示。

列表 12.4. 使用解构和元组字面量进行简单的构造函数赋值
public sealed class Point
{
    public double X { get; }
    public double Y { get; }

    public Point(double x, double y) => (X, Y) = (x, y);
}

我真的很喜欢这种简洁性。我喜欢从构造函数参数到属性的映射的清晰性。C# 编译器甚至将其识别为一种模式,并避免构造 ValueTuple<double, double>。不幸的是,它仍然需要依赖 System.ValueTuple.dll 来构建,这足以让我在项目其他地方也使用元组或目标框架已经包含 System.ValueTuple 的情况下才使用它。

这是不是地道的 C#语法?

正如我描述的,这个技巧有优点也有缺点。这是构造函数的纯实现细节;它甚至不影响类的其余部分。如果你决定接受这种风格,然后又决定不喜欢它,移除它应该是微不足道的。现在还太早说这会不会流行起来,但我希望如此。不过,一旦元组字面量需要不仅仅是精确的参数值,我就要小心了。即使添加一个前置条件,在我看来,也会使常规的赋值序列更有优势。

与声明解构相比,赋值解构在排序方面有一个额外的复杂性。使用赋值的解构有三个不同的阶段:

  1. 评估赋值的目标

  2. 评估赋值运算符右侧的表达式

  3. 执行赋值操作

这三个阶段将严格按照这个顺序执行。在每个阶段内,评估按照正常的从左到右的源顺序进行。这种情况很少能产生影响,但有可能。

小贴士

如果你必须担心这个部分才能理解你面前的代码,那是一个强烈的代码气味。当你 确实 理解它时,我强烈建议你重构它。解构具有在表达式内部使用副作用的所有相同注意事项,但由于你需要在每个阶段执行多个评估,所以这些注意事项被放大了。

我不会在这个问题上停留太久;一个例子就足以展示你可能会遇到的问题。但这绝对不是你可能会找到的最糟糕的例子。你可以做很多事来使这个问题更加复杂。下面的列表将一个 (StringBuilder, int) 元组解构到一个现有的 StringBuilder 变量和与该变量关联的 Length 属性。

列表 12.5. 评估顺序重要的解构
StringBuilder builder = new StringBuilder("12345");
StringBuilder original = builder;                   *1*

(builder, builder.Length) =                         *2*
    (new StringBuilder("67890"), 3);                *2*

Console.WriteLine(original);                        *3*
Console.WriteLine(builder);                         *3*
  • 1 为了诊断原因保留原始 builder 的引用

  • 2 执行解构赋值

  • 3 显示旧的和新的 builder 的内容

这里的中间行是有点棘手的。需要考虑的关键问题是哪个 StringBuilderLength 属性被设置:是 builder 原先引用的那个,还是解构第一部分中分配的新值?正如我之前描述的,所有赋值的靶点都会先被评估,然后再执行任何赋值操作。下面的列表以某种爆炸版本的形式展示了相同的代码,其中手动执行了解构操作。

列表 12.6. 缓慢动作解构以显示评估顺序
StringBuilder builder = new StringBuilder("12345");
StringBuilder original = builder;

StringBuilder targetForLength = builder; *1*

(StringBuilder, int) tuple =                *2*
    (new StringBuilder("67890"), 3);        *2*

builder = tuple.Item1;                      *3*
targetForLength.Length = tuple.Item2;       *3*

Console.WriteLine(original);
Console.WriteLine(builder);
  • 1 评估赋值目标

  • 2 评估元组字面量

  • 3 在目标上执行赋值操作

当目标是局部变量时,不需要额外的评估;你可以直接将其赋值。但是,将属性赋给变量需要将变量值作为第一阶段的组成部分进行评估;这就是为什么你有targetForLength变量。

在从字面量构造元组之后,你可以将不同的项赋值给你的目标,确保在赋值Length属性时使用targetForLength而不是 builder。Length属性是在内容为 12345 的原始StringBuilder上设置的,而不是内容为 67890 的新一个。这意味着列表 12.5 和 12.6 的输出如下:

123
67890

在处理完这些之后,在继续讨论非元组解构之前,还有最后一个——相当令人愉快——的元组构造细节需要讨论。

12.1.3. 元组字面量解构的细节

如我在第 11.3.1 节中所述,并非所有元组字面量都有类型。例如,元组字面量(null, x => x * 2)没有类型,因为它的元素表达式都没有类型。但你知道它可以转换为类型(string, Func<int, int>),因为每个表达式都有一个转换为对应类型的转换。

好消息是元组解构正好具有与声明解构和赋值解构相同的“按元素赋值兼容性”。这适用于声明解构和赋值解构。以下是一个简短的例子:

(string text, Func<int, int> func) =
    (null, x => x * 2);                    *1*
(text, func) = ("text", x => x * 3);       *2*
  • 1 声明解构文本和 func

  • 2 将解构赋值给文本和 func

这也适用于需要从表达式到目标类型的隐式转换的解构。例如,使用我们最喜欢的“int常量在byte范围内”的例子,以下是有效的:

(byte x, byte y) = (5, 10);

就像许多优秀的语言特性一样,这可能是你可能隐含期望的,但语言需要精心设计和指定才能允许它。现在你已经相当广泛地研究了元组解构,非元组解构相对简单。

12.2. 非元组类型的解构

非元组类型的解构使用与 async/await 和foreach相同的方式基于模式的策略。就像任何具有合适的GetAwaiter方法或扩展方法的类型都可以被等待一样,任何具有合适的Deconstruct方法或扩展方法的类型都可以使用与元组相同的语法进行解构。让我们从使用常规实例方法进行解构开始。

¹

这与第 12.3 节中出现的模式完全不同。对于术语冲突,我表示歉意。

12.2.1. 实例解构方法

使用现在在多个示例中使用的Point类来演示解构是最简单的。你可以像这样向它添加一个Deconstruct方法:

public void Deconstruct(out double x, out double y)
{
    x = X;
    y = Y;
}

然后,你可以像以下列表中那样将任何 Point 解构为两个 double 变量。

列表 12.7. 将 Point 解构为两个变量
var point = new Point(1.5, 20);   *1*
var (x, y) = point;               *2*
Console.WriteLine($"x = {x}");    *3*
Console.WriteLine($"y = {y}");    *3*
  • 1 构造点实例

  • 2 将其解构为两个 double 类型的变量

  • 3 显示两个变量的值

Deconstruct 方法的任务是使用解构的结果填充 out 参数。在这种情况下,你只是将解构为两个 double 值。正如其名称所暗示的,就像一个构造函数的反向操作。

但等等;你使用了一个巧妙的技巧,用元组在构造函数中一次性将参数值赋给属性。你能在这里做到吗?是的,你可以,而且我个人非常喜欢它。以下是构造函数和 Deconstruct 方法,以便你可以看到它们的相似之处:

public Point(double x, double y) => (X, Y) = (x, y);
public void Deconstruct(out double x, out double y) => (x, y) = (X, Y);

这种简单性很美,至少在你习惯了之后。

用于解构的 Deconstruct 实例方法的规则相当简单:

  • 方法必须对执行解构的代码是可访问的。(例如,如果所有内容都在同一个程序集内,Deconstruct 是一个内部方法就很好。)

  • 它必须是一个 void 方法。

  • 必须至少有两个参数。(你不能解构为单个值。)

  • 它必须是非泛型的。

你可能想知道为什么设计使用 out 参数而不是要求 Deconstruct 无参数但有元组返回类型。答案是,能够解构到多组值是有用的,这可以通过多个方法实现,但你不能仅基于返回类型来重载方法。为了使这一点更清晰,我将使用一个解构 DateTime 的例子,但当然,你不能向 DateTime 添加你自己的实例方法。是时候介绍扩展解构方法了。

12.2.2. 扩展解构方法和重载

正如我在介绍中简要提到的,编译器会找到任何遵循相关模式的 Deconstruct 方法,包括扩展方法。你可能可以想象解构扩展方法的样子,但以下列表提供了一个具体的例子,使用了 DateTime

列表 12.8. 使用扩展方法解构 DateTime
static void Deconstruct(                           *1*
    this DateTime dateTime,                        *1*
    out int year, out int month, out int day) =>   *1*
    (year, month, day) =                           *1*
    (dateTime.Year, dateTime.Month, dateTime.Day); *1*

static void Main()
{
    DateTime now = DateTime.UtcNow;
    var (year, month, day) = now;                  *2*
    Console.WriteLine(
        $"{year:0000}-{month:00}-{day:00}");       *3*
}
  • **1 扩展方法用于解构 DateTime**

  • 2 将当前日期解构为年/月/日

  • 3 使用三个变量显示日期

事实上,这是一个在同一个(静态)类中声明的私有扩展方法,你从那里使用它,但更常见的是它是公共或内部的,就像大多数扩展方法一样。

如果你想将 DateTime 解构为不仅仅是日期呢?这正是重载有用的地方。你可以有两个具有不同参数列表的方法,编译器将根据参数数量确定使用哪个。让我们添加另一个扩展方法,以日期和时间的形式解构 DateTime,然后使用这两个方法解构不同的值。

列表 12.9. 使用 Deconstruct 重载
static void Deconstruct(                                *1*
    this DateTime dateTime,                             *1*
    out int year, out int month, out int day) =>        *1*
    (year, month, day) =                                *1*
    (dateTime.Year, dateTime.Month, dateTime.Day);      *1*

static void Deconstruct(                                *2*
    this DateTime dateTime,                             *2*
    out int year, out int month, out int day,           *2*
    out int hour, out int minute, out int second) =>    *2*
    (year, month, day, hour, minute, second) =          *2*
    (dateTime.Year, dateTime.Month, dateTime.Day,       *2*
    dateTime.Hour, dateTime.Minute, dateTime.Second);   *2*

static void Main()
{
    DateTime birthday = new DateTime(1976, 6, 19);
    DateTime now = DateTime.UtcNow;

    var (year, month, day, hour, minute, second) = now; *3*
    (year, month, day) = birthday;                      *4*
}
  • 1 将日期解构为年/月/日

  • 2 将日期解构为年/月/日/小时/分钟/秒

  • 3 使用六值解构器

  • 4 使用三值解构器

你可以使用扩展 Deconstruct 方法为已经具有实例 Deconstruct 方法的类型,如果实例方法在解构时不适用,它们将被使用,就像在正常方法调用中一样。

扩展 Deconstruct 方法的限制自然地来源于实例方法的限制:

  • 它必须对调用代码是可访问的。

  • 除了第一个参数(扩展方法的调用目标)之外,所有参数都必须是 out 参数。

  • 至少必须有两个这样的 out 参数。

  • 方法可能是泛型的,但只有调用接收者(第一个参数)可以参与类型推断。

指示方法何时可以是泛型或不能是泛型的规则值得更仔细的审查,尤其是因为它们还揭示了为什么在重载 Deconstruct 时需要使用不同数量的参数。关键在于编译器如何处理 Deconstruct 方法。

12.2.3. 编译器对解构调用的处理

当一切按预期工作的时候,你可以不必过多地思考编译器是如何决定使用哪个 Deconstruct 方法的。然而,如果你遇到问题,尝试将自己置于编译器的位置可能会有所帮助。

当使用方法进行解构时,元组分解的时机仍然适用,因此我将专注于方法调用本身。让我们来看一个相对具体的例子,看看编译器在遇到这种解构时会如何处理:

(int x, string y) = target;

我说这是一个 相对 具体的例子,因为我没有展示 target 的类型。这是故意的,因为你所需要知道的是它不是一个元组类型。编译器将其扩展为类似以下的内容:

target.Deconstruct(out var tmpX, out var tmpY);
int x = tmpX;
string y = tmpY;

然后它使用所有正常的方法调用规则来尝试找到要调用的正确方法。我意识到 out var 的使用是你之前没有见过的。你将在 第 14.2 节 中更详细地了解它,但你现在需要知道的是,它使用 out 参数的类型来推断隐式类型的变量。

重要的是要注意,你在原始代码中声明的变量的类型并不作为 Deconstruct 调用的一部分使用。这意味着它们不能参与类型推断。这解释了三件事:

  • 实例 Deconstruct 方法不能是泛型的,因为没有信息供类型推断使用。

  • 扩展 Deconstruct 方法可以是泛型的,因为编译器可能能够使用 target 推断类型参数,但那将是唯一有用的参数,从类型推断的角度来看。

  • 当重载Deconstruct方法时,重要的是out参数的数量,而不是它们的类型。如果你引入多个具有相同数量out参数的Deconstruct方法,这将会阻止编译器使用任何一个,因为调用代码将无法确定你指的是哪一个。

我就说到这里,因为我不想做得比必要的更多。如果你遇到无法理解的问题,尝试执行之前展示的转换,这可能会使事情更加清晰。

这就是你需要了解的所有关于解构的内容。本章的其余部分专注于模式匹配,这是一个理论上完全独立于解构的功能,但在可用工具方面与解构有相似的感觉,这些工具可以用于以新的方式使用现有数据。

12.3. 模式匹配简介

就像许多其他特性一样,模式匹配对于 C#来说是新的,但对于编程语言总体来说并不是新的。特别是,函数式语言通常大量使用模式。C# 7.0 中的模式满足了许多相同的用例,但以一种与语言其余语法相匹配的方式。

模式的基本思想是测试一个值的某个方面,并使用该测试的结果执行另一个操作。是的,这听起来就像一个if语句,但模式通常用于提供更多上下文,或者根据模式在动作本身中提供更多上下文。再次强调,这个特性并不允许你做之前不能做的事情;它只是让你更清晰地表达相同的目的。

我不想在没有给出例子的情况下走得太远。如果你现在觉得它有点奇怪,不用担心;目的是给你一个感觉。假设你有一个定义了抽象Area属性和派生类RectangleCircleTriangle的抽象类Shape。不幸的是,对于你当前的应用程序,你不需要形状的面积;你需要它的周长。你可能无法修改Shape来添加Perimeter属性(你可能根本无法控制其源代码),但你知道如何计算你感兴趣的类中的周长。在 C# 7 之前,一个Perimeter方法可能看起来像以下列表。

列表 12.10. 不使用模式计算周长
static double Perimeter(Shape shape)
{
    if (shape == null)
        throw new ArgumentNullException(nameof(shape));
    Rectangle rect = shape as Rectangle;
    if (rect != null)
        return 2 * (rect.Height + rect.Width);
    Circle circle = shape as Circle;
    if (circle != null)
        return 2 * PI * circle.Radius;
    Triangle triangle = shape as Triangle;
    if (triangle != null)
        return triangle.SideA + triangle.SideB + triangle.SideC;
    throw new ArgumentException(
        $"Shape type {shape.GetType()} perimeter unknown", nameof(shape));
}
注意

如果内部缺少花括号让你感到不适,我为此道歉。我通常会在所有循环、if 语句等地方使用它们,但在这个例子中,它们最终使有用的代码在这里以及一些后续的模式示例显得微不足道。为了简洁,我已经将它们移除了。

这看起来很糟糕。它重复且冗长;相同的模式“检查形状是否是特定类型,然后使用该类型的属性”出现了三次。呃。重要的是,尽管这里有几个 if 语句,但每个语句的主体都返回一个值,所以你总是只选择其中一个来执行。以下列表显示了如何使用 C# 7 中的模式在 switch 语句中编写相同的代码。

列表 12.11. 使用模式计算周长
static double Perimeter(Shape shape)
{
    switch (shape)
    {
        case null:                                            *1*
            throw new ArgumentNullException(nameof(shape));   *1*
        case Rectangle rect:                                  *2*
            return 2 * (rect.Height + rect.Width);            *2*
        case Circle circle:                                   *2*
            return 2 * PI * circle.Radius;                    *2*
        case Triangle tri:                                    *2*
            return tri.SideA + tri.SideB + tri.SideC;         *2*
        default:                                              *3*
            throw new ArgumentException(...);                 *3*
    }
}
  • 1 处理空值

  • 2 处理您所知道的每种类型

  • 3 如果不知道该做什么,就抛出一个异常。

这与 C# 早期版本中的 switch 语句有很大的不同,在早期版本中,case 标签都是常量值。在这里,你有时只对值匹配(对于 null 的情况)感兴趣,有时对值的类型(矩形、圆形和三角形的情况)感兴趣。当你按类型匹配时,这个匹配也会引入一个新的变量,你可以使用这个变量来计算周长。

C# 中模式的话题有两个不同的方面:

  • 模式的语法

  • 可以使用模式的环境

起初,可能会觉得一切都是新的,区分这两个方面可能似乎没有意义。但 C# 7.0 中可用的模式只是开始:C# 设计团队已经明确表示,语法已经被设计为随着时间的推移使新的模式可用。当你知道语言中允许模式的位置时,你可以轻松地掌握新的模式。这有点像鸡生蛋的问题——很难在不展示另一个的情况下展示一个部分——但我们将从查看 C# 7.0 中可用的模式类型开始。

12.4. C# 7.0 中可用的模式

C# 7.0 引入了三种类型的模式:常量模式、类型模式和 var 模式。我将通过 is 操作符来演示每种模式,is 操作符是使用模式的一个上下文。

每个模式都试图匹配一个输入。这可以是任何非指针表达式。为了简单起见,我在模式描述中将其称为 input,就像它是一个变量一样,但它不必是。

12.4.1. 常量模式

一个 常量模式 正如其名:该模式完全由编译时常量表达式组成,然后检查它与 input 是否相等。如果 input 和常量都是整数表达式,它们将使用 == 进行比较。否则,将调用静态的 object.Equals 方法。重要的是调用的是静态方法,因为这使你可以安全地检查空值。以下列表显示了一个示例,它甚至比书中大多数其他示例的实际用途还要少,但它确实演示了几个有趣的观点。

列表 12.12. 简单常量匹配
static void Match(object input)
{
    if (input is "hello")
        Console.WriteLine("Input is string hello");
    else if (input is 5L)
        Console.WriteLine("Input is long 5");
    else if (input is 10)
        Console.WriteLine("Input is int 10");
    else
        Console.WriteLine("Input didn't match hello, long 5 or int 10");
}
static void Main()
{
    Match("hello");
    Match(5L);
    Match(7);
    Match(10);
    Match(10L);
}

输出大部分都很直接,但你可能会对倒数第二行感到惊讶:

Input is string hello
Input is long 5
Input didn't match hello, long 5 or int 10
Input is int 10
Input didn't match hello, long 5 or int 10

如果使用 == 比较整数,为什么最后的 Match(10L) 调用没有匹配成功?答案是 input 的编译时类型不是一个整型,它只是 object,因此编译器生成的代码相当于调用 object.Equals(x, 10)。当 x 的值是一个装箱的 Int64 而不是一个装箱的 Int32 时,它会返回 false,正如我们在最后的 Match 调用中所遇到的情况。要使用 == 的例子,你需要像这样:

long x = 10L;
if (x is 10)
{
    Console.WriteLine("x is 10");
}

在这样的is表达式中,这并不实用;它更可能用于switch,其中你可能有一些整数常量(比如一个预模式匹配的switch语句)以及其他模式。一种更明显有用的模式类型是类型模式。

12.4.2. 类型模式

一个 类型模式 由一个类型和一个标识符组成——有点像变量声明。如果 input 是该类型的值,则模式匹配,就像常规的 is 操作符一样。使用模式的好处是,它还引入了一个新的 模式变量,该变量以匹配的值初始化。如果模式不匹配,变量仍然存在;它只是没有被明确赋值。如果 input 为空,则不会匹配任何类型。如 12.1.1 节 所述,可以使用下划线标识符 _,在这种情况下,它是一个 丢弃,不会引入任何变量。以下列表是将我们之前的一组 as 后跟 if 语句 (列表 12.10) 转换为使用模式匹配,而不采取使用 switch 语句的更极端步骤。

列表 12.13. 使用类型模式代替 as/if
static double Perimeter(Shape shape)
{
    if (shape == null)
        throw new ArgumentNullException(nameof(shape));
    if (shape is Rectangle rect)
        return 2 * (rect.Height + rect.Width);
    if (shape is Circle circle)
        return 2 * PI * circle.Radius;
    if (shape is Triangle triangle)
        return triangle.SideA + triangle.SideB + triangle.SideC;
    throw new ArgumentException(
        $"Shape type {shape.GetType()} perimeter unknown", nameof(shape));
}

在这个情况下,我确实更喜欢 switch 语句选项,但如果只有一个 as/if 需要替换,那就太过分了。类型模式通常用于替换一个 as/if 组合或 if 后跟一个类型转换。后者在你要测试的类型是非可空值类型时是必需的。

类型模式中指定的类型不能是可空值类型,但它可以是类型参数,并且该类型参数在执行时最终可能成为可空值类型。在这种情况下,只有当值非空时,模式才会匹配。以下列表展示了这一点,使用 int? 作为使用类型参数的类型模式的方法的类型参数,即使表达式 value is int? t 不会编译。

列表 12.14. 类型模式中可空值类型的行为
static void Main()
{
    CheckType<int?>(null);
    CheckType<int?>(5);     
    CheckType<int?>("text");
    CheckType<string>(null);
    CheckType<string>(5);
    CheckType<string>("text");
}

static void CheckType<T>(object value)
{
    if (value is T t)
    {
        Console.WriteLine($"Yes! {t} is a {typeof(T)}");
    }
    else
    {
        Console.WriteLine($"No! {value ?? "null"} is not a {typeof(T)}");
    }
}

输出如下:

No! null is not a System.Nullable`1[System.Int32]
Yes! 5 is a System.Nullable`1[System.Int32]
No! text is not a System.Nullable`1[System.Int32]
No! null is not a System.String
No! 5 is not a System.String
Yes! text is a System.String

为了总结本节关于类型模式的内容,C# 7.0 中有一个问题在 C# 7.1 中得到了解决。这是那些如果你已经将项目设置为使用 C# 7.1 或更高版本,你可能甚至都没有注意到的情况之一。我包括这个主要是为了避免你从 C# 7.1 项目复制代码到 C# 7.0 项目时发现它无法工作而感到困惑。

在 C# 7.0 中,像这样的类型模式

x is SomeType y

需要 x 的编译时类型可以被转换为 SomeType。这听起来完全合理,直到你开始使用泛型。考虑以下使用模式匹配显示提供的形状详细信息的泛型方法。

列表 12.15. 使用类型模式的泛型方法
static void DisplayShapes<T>(List<T> shapes) where T : Shape
{
    foreach (T shape in shapes)          *1*
    {
        switch (shape)                   *2*
        {
            case Circle c:               *3*
                Console.WriteLine($"Circle radius {c.Radius}");
                break;
            case Rectangle r:
                Console.WriteLine($"Rectangle {r.Width} x {r.Height}");
                break;
            case Triangle t:
                Console.WriteLine(
                    $"Triangle sides {t.SideA}, {t.SideB}, {t.SideC}");
                break;
        }
    }
}
  • 1 变量类型是类型参数 (T)

  • 2 在该变量上切换

  • 3 尝试使用类型匹配转换为具体形状类型

在 C# 7.0 中,这个列表无法编译,因为以下代码也无法编译:

if (shape is Circle)
{
    Circle c = (Circle) shape;
}

使用 is 操作符是有效的,但转换不是。在 C# 中直接转换类型参数的能力一直是一个烦恼,通常的解决方案是首先将其转换为 object

if (shape is Circle)
{
    Circle c = (Circle) (object) shape;
}

在正常的转换中,这已经足够笨拙,但在尝试使用优雅的类型模式时,情况更糟。

在列表 12.15 中,可以通过接受 IEnumerable<Shape>(利用泛型协变允许将 List<Circle> 转换为 IEnumerable<Shape>,例如)或指定 shape 的类型为 Shape 而不是 T 来解决这个问题。在其他情况下,解决方案并不那么简单。C# 7.1 通过允许任何可以使用 as 操作符的有效类型的类型模式来解决这个问题,这使得列表 12.15 有效。

我预计类型模式将是 C# 7.0 中引入的三个模式中最常用的模式。我们的最后一个模式几乎听起来根本不像一个模式。

12.4.3. 变量模式

var 模式看起来像是一个类型模式,但使用 var 作为类型,所以它只是 var 后跟一个标识符:

someExpression is var x

与类型模式一样,它引入了一个新变量。但与类型模式不同,它不测试任何内容。它总是匹配,结果是一个具有与 input 相同的编译时类型和相同值的新的变量。与类型模式不同,即使 input 是一个空引用,var 模式仍然匹配。

因为它总是匹配,所以在 if 语句中使用 var 模式与 is 操作符,就像我在其他模式中演示的那样,是相当没有意义的。它最常与 switch 语句结合使用,并与 保护子句(在第 12.6.1 节中描述)一起使用,尽管如果你想在没有将其分配给变量的情况下对更复杂的表达式进行切换,偶尔也可能有用。

只为了展示一个不使用守卫子句的var模式的例子,列表 12.16 显示了一个类似于列表 12.11 中的Perimeter方法。但这次,如果shape参数有一个 null 值,就会创建一个随机的形状。如果你不能计算周长,你可以使用var模式来报告形状的类型。现在你不需要带有null值的常量模式,因为你在确保你永远不会在 null 引用上切换。

列表 12.16. 使用var模式在错误中引入变量
static double Perimeter(Shape shape)
{
    switch (shape ?? CreateRandomShape())
    {
        case Rectangle rect:
            return 2 * (rect.Height + rect.Width);
        case Circle circle:
            return 2 * PI * circle.Radius;
        case Triangle triangle:
            return triangle.SideA + triangle.SideB + triangle.SideC;
 case var actualShape:
            throw new InvalidOperationException(
                $"Shape type {actualShape.GetType()} perimeter unknown");
    }
}

在这种情况下,一个替代方案是在switch语句之前引入actualShape变量,然后根据它进行切换,然后像以前一样使用default情况。

这些就是 C# 7.0 中可用的所有模式。你已经看到了它们可以使用的两种上下文——与is运算符和switch语句一起使用——但在每种情况下都有更多要说的话。

12.5. 使用is运算符的模式

is运算符可以在任何地方作为正常表达式的一部分使用。它几乎总是与if语句一起使用,但绝对不必如此。在 C# 7 之前,is运算符的右侧必须只是一个类型,但现在它可以是一个任何模式。尽管这确实允许你使用常量或var模式,但现实情况下你几乎总是使用类型模式。

var模式和类型模式都引入了一个新的变量。在 C# 7.3 之前,这带来了一个额外的限制:你无法在字段、属性或构造函数初始化器或查询表达式中使用它们。例如,这将是不合法的:

static int length = GetObject() is string text ? text.Length : -1;

我还没有发现这成为一个问题,但限制在 C# 7.3 中已经被取消了。

这就留下了引入局部变量的模式,这引发了一个明显的问题:新引入的变量的作用域是什么?我明白这曾在 C#语言团队和社区中引发了大量讨论,但最终结果是引入的变量的作用域是包含的代码块。

由于这是一个激烈争论的话题,所以它既有优点也有缺点。我从未喜欢过的as/if模式列表 12.10 之一是,即使你通常不想在值匹配你测试的类型之外的条件中使用它们,你最终会在作用域中拥有很多变量。不幸的是,在使用类型模式时,这种情况仍然存在。这并不是完全相同的情况,因为当模式不匹配时,变量在分支中不会被肯定赋值。

为了比较,在这段代码之后

string text = input as string;
if (text != null)
{
    Console.WriteLine(text);
}

text变量在作用域内,并且肯定被赋值了。大致等价的类型模式代码如下:

if (input is string text)
{
    Console.WriteLine(text);
}

在此之后,text 变量在作用域内,但不是确定赋值。尽管这确实会污染声明空间,但如果您试图提供获取值的一种替代方式,这可能是有用的。例如:

if (input is string text)
{
    Console.WriteLine("Input was already a string; using that");
}
else if (input is StringBuilder builder)
{
    Console.WriteLine("Input was a StringBuilder; using that");    
    text = builder.ToString();
}
else
{
    Console.WriteLine(
        $"Unable to use value of type ${input.GetType()}. Enter text:");    
    text = Console.ReadLine();
}
Console.WriteLine($"Final result: {text}");

在这里,您确实希望 text 变量保持作用域,因为您想使用它;您可以通过两种方式之一对其赋值。您真的不希望在中间块之后还有 builder 的作用域,但您不能两者兼得。

要更技术性地讨论确定赋值,在具有引入模式变量的模式表达式的 is 表达式之后,该变量(在语言规范术语中)是“在真表达式之后确定赋值”。如果您想使 if 条件做更多的事情,而不仅仅是测试类型,这可能很重要。例如,假设您想检查提供的值是否是大型整数。这是可以的:

if (input is int x && x > 100)
{
 Console.WriteLine($"Input was a large integer: {x}");
}

您可以在 && 之后使用 x,因为只有当第一个操作数评估为 true 时,您才会评估该操作数。您也可以在 if 语句中使用 x,因为只有当两个 && 操作数都评估为 true 时,您才会执行 if 语句的主体。但如果您想同时处理 intlong 值怎么办?您可以测试该值,但这样您就无法知道哪个条件匹配了:

if ((input is int x && x > 100) || (input is long y && y > 100))
{
 Console.WriteLine($"Input was a large integer of some kind");
}

在这里,xy 都在 if 语句内部及其之后的作用域内,即使看起来声明 y 的部分可能不会执行。但变量仅在检查值大小的小块代码中确定赋值。

所有这些都符合逻辑,但第一次看到时可能会有些惊讶。本节的两点收获如下:

  • 预期在 is 表达式中声明的模式变量的作用域是包含的块。

  • 如果编译器阻止您使用模式变量,这意味着语言规则无法证明在该点变量将被赋值。

在本章的最后部分,我们将探讨在 switch 语句中使用的模式。

12.6. 使用 switch 语句的模式

规范通常不是用算法本身来编写的,而是用情况来编写的。以下是一些与计算相去甚远的例子:

  • 税费和福利—您的税率可能取决于您的收入和其他一些因素。

  • 旅行票务—可能会有团体折扣,以及针对儿童、成人和老年人的单独价格。

  • 外卖订餐—如果您的订单符合某些标准,可能会有优惠。

在过去,我们有两种检测特定输入适用哪种情况的方法:switch 语句和 if 语句,其中 switch 语句仅限于简单的常量。我们仍然只有这两种方法,但 if 语句已经更简洁,如您所见,而 switch 语句则更强大。

注意

基于模式的 switch 语句与过去只允许常量值的 switch 语句感觉相当不同。除非你有过使用具有类似功能的其他语言的经验,否则你应该预计需要一段时间才能习惯这种变化。

带有模式的 switch 语句在很大程度上等同于一系列的 if/else 语句,但它们鼓励你更多地从“这种输入导致这种输出”的角度去思考,而不是步骤。

所有 switch 语句都可以被视为基于模式的

在本节中,我谈论基于常量的 switch 语句和基于模式的 switch 语句,好像它们是不同的。因为常量模式 确实是 模式,所以每个有效的 switch 语句都可以被视为基于模式的 switch 语句,并且它们的行为将完全相同。关于执行顺序和引入新变量的差异,在常量模式中并不适用。

我发现至少在目前,将这些视为两个独立的构造,它们恰好使用了相同的语法,是非常有帮助的。你可能觉得不区分它们会更舒服。使用任何心理模型都是安全的;它们都会正确预测代码的行为。

你已经在 12.3 节 中看到了 switch 语句中模式的例子,其中你使用常量模式来匹配 null 和类型模式来匹配不同类型的形状。除了在 case 标签中简单地放置一个模式之外,还有一个新的语法元素需要介绍。

12.6.1. 守卫子句

每个 case 标签也可以有一个守卫子句,它由一个表达式组成:

case *pattern* when *expression*:

该表达式必须评估为布尔值^([2)),就像 if 语句的条件一样。只有当表达式评估为 true 时,才会执行 case 的主体。表达式可以使用更多的模式,从而引入额外的模式变量。

²

它也可以是一个可以隐式转换为布尔值或提供 true 操作符的类型的值。这些要求与 if 语句中的条件相同。

让我们看看一个具体的例子,这个例子也将说明我关于规范的观点。考虑以下斐波那契数列的定义:

  • fib(0) = 0

  • fib(1) = 1

  • fib(n) = fib(n-2) + fib(n-1) 对于所有 n > 1

在 第十一章 中,你看到了如何使用元组生成斐波那契数列,当将其视为序列时,这是一种干净的方法。然而,如果你只将其视为函数,前面的定义会导致以下列表:一个简单的使用模式和守卫子句的 switch 语句。

列表 12.17. 使用模式递归实现斐波那契数列
static int Fib(int n)
{
    switch (n)
    {
        case 0: return 0;                                       *1*
        case 1: return 1;                                       *1*
        case var _ when n > 1: return Fib(n - 2) + Fib(n - 1);  *2*
        default: throw new ArgumentOutOfRangeException(         *3*
            nameof(n), "Input must be non-negative");           *3*
    }                                                           *3*
}
  • 1 使用常量模式处理基本案例

  • 2 使用 var 模式和守卫子句处理递归情况

  • 3 如果没有匹配任何模式,输入是无效的。

这是一个效率极低且我永远不会在实际生活中使用的实现,但它清楚地展示了如何将规范直接转换为代码。

在这个例子中,保护子句不需要使用模式变量,所以我使用了带有_标识符的丢弃操作。在许多情况下,如果模式引入了新的变量,它将会在保护子句或至少在 case 体中使用。

当你使用保护子句时,同一个模式出现多次是完全合理的,因为第一次模式匹配时,保护子句可能评估为false。以下是从用于构建文档的工具中的 Noda Time 的一个示例:

private string GetUid(TypeReference type, bool useTypeArgumentNames)
{
    switch (type)
    {
        case ByReferenceType brt:
            return $"{GetUid(brt.ElementType, useTypeArgumentNames)}@";
        case GenericParameter gp when useTypeArgumentNames:
            return gp.Name;
        case GenericParameter gp when gp.DeclaringType != null:
            return $"`{gp.Position}";
        case GenericParameter gp when gp.DeclaringMethod != null:
            return $"``{gp.Position}";
        case GenericParameter gp:
            throw new InvalidOperationException(
                "Unhandled generic parameter");
        case GenericInstanceType git:
            return "(This part of the real code is long and irrelevant)";
        default:
            return type.FullName.Replace('/', '.');
    }
}

我有四个模式,根据useTypeArgumentNames方法参数以及泛型类型参数是在方法还是类型中引入来处理泛型参数。抛出异常的情况几乎是一个泛型参数的default情况,表明它遇到了我还没有考虑过的情况。我使用相同的模式变量名(gp)为多个情况命名的事实又提出了另一个自然的问题:在case标签中引入的模式变量的作用域是什么?

12.6.2. case标签的模式变量作用域

如果你直接在case体内部声明局部变量,该变量的作用域是整个switch语句,包括其他case体。这一点依然成立(并且在我看来,这是不幸的),但它不包括在 case 标签内声明的变量。这些变量的作用域仅仅是与该 case 标签关联的体。这适用于由模式声明的模式变量、在保护子句内声明的模式变量以及在任何保护子句中声明的任何out变量(参见第 14.2 节)。

这几乎肯定是你想要的,并且它在允许你为处理类似情况的不同情况使用相同的模式变量方面很有用,正如在 Noda Time 工具代码中所示。这里有一个怪癖:就像正常的switch语句一样,可以有多个具有相同体的case标签。在这种情况下,所有这些case标签内声明的变量都需要有不同的名字(因为它们正在贡献同一个声明空间)。但在case体内部,这些变量中没有一个会被明确赋值,因为编译器无法确定哪个标签匹配。尽管如此,引入这些变量仍然可能是有用的,但主要是为了在保护子句中使用它们。

例如,假设你正在匹配一个object输入,并确保如果它是数值类型,它处于特定的范围内,而这个范围可能因类型而异。你可以为每种数值类型使用一个类型模式,并相应地使用保护子句。以下列表显示了intlong的情况,但你也可以将其扩展到其他类型。

列表 12.18. 使用模式为单个 case 体提供多个case标签
static void CheckBounds(object input)
{
    switch (input)
    {
        case int x when x > 1000:
        case long y when y > 10000L:
            Console.WriteLine("Value is too large");
            break;
        case int x when x < -1000:
        case long y when y < -10000L:
            Console.WriteLine("Value is too low");
            break;
        default:
            Console.WriteLine("Value is in range");
            break;
    }
}

模式变量肯定在保护子句中被赋值,因为只有当模式最初匹配时,执行才会到达保护子句,并且它们在主体中仍然有效,但它们不是肯定被赋值的。你可以给它们赋新值并在之后使用它们,但我感觉这不会经常有用。

除了模式匹配的基本前提是新颖和不同之外,过去基于常量的switch语句和新的基于模式的switch语句之间还有一个巨大的区别:case 的顺序现在比以前更重要。

12.6.3. 基于模式的 switch 语句的评估顺序

在几乎所有情况下,基于常量的switch语句的case标签可以自由地重新排序,而不会改变行为。3 这是因为每个case标签都匹配一个单一的常量值,并且任何switch语句中使用的所有常量都必须是不同的,所以任何输入最多只能匹配一个case标签。但是,对于模式来说,情况就不再是这样了。

³

唯一不成立的情况是,当你在某个 case 体中使用在早期 case 体中声明的变量时。这几乎总是个坏主意,而且这只是一个问题,因为这样的变量具有共享的作用域。

基于模式的switch语句的逻辑评估顺序可以简单地总结如下:

  • 每个 case 标签按源代码顺序进行评估。

  • 只有当所有case标签都已评估时,才会执行default标签的代码体,无论default标签在switch语句中的位置如何。

提示

尽管你现在知道与default标签关联的代码仅在没有任何 case 标签匹配的情况下执行,无论它出现在哪里,但阅读你代码的一些人可能并不了解这一点。(实际上,当你下次再次阅读自己的代码时,你可能已经忘记了这一点。)如果你将default标签放在switch语句的最后部分,其行为总是清晰的。

有时候这并不重要。例如,在我们的斐波那契计算方法中,case 只有 0、1 和大于 1,因此它们可以自由地重新排序。然而,我们的 Noda Time 工具代码有四个必须按顺序检查的 case:

case GenericParameter gp when useTypeArgumentNames:
    return gp.Name;
case GenericParameter gp when gp.DeclaringType != null:
    return $"`{gp.Position}";
case GenericParameter gp when gp.DeclaringMethod != null:
    return $"``{gp.Position}";
case GenericParameter gp:
    throw new InvalidOperationException(...);

在这里,当useTypeArgumentNames为真(即第一个案例)时,你应始终使用泛型类型参数名称。第二个和第三个案例是互斥的(以你知道的方式,但编译器不知道),所以它们的顺序无关紧要。最后一个案例必须是这四个中的最后一个,因为你希望只有当输入是一个未处理的GenericParameter时才抛出异常。

在这里编译器很有帮助:最后的案例没有守卫子句,所以如果类型模式匹配,它总是有效的。编译器知道这一点;如果你将这个案例放在具有相同模式的其它案例标签之前,它知道这实际上是在隐藏它们,并报告错误。

多个案例体只能以一种方式执行,那就是使用很少使用的goto语句。这在基于模式的switch语句中仍然有效,但你只能跳转到常量值,并且必须有一个没有守卫子句的case标签与该值关联。例如,你不能跳转到类型模式,也不能在关联的守卫子句也评估为true的条件下跳转到值。实际上,我在switch语句中看到这么少的goto语句,以至于我认为这并不是一个很大的限制。

我之前故意提到了逻辑评估顺序。尽管 C#编译器可以将每个switch语句有效地转换成一系列的if/else语句,但它可以比这更高效地执行。例如,如果有多个类型模式对应同一类型但具有不同的守卫子句,它可以一次评估类型模式部分,然后依次检查每个守卫子句。同样,对于没有守卫模式的常量值(它们仍然必须像 C#的早期版本一样是唯一的),编译器可以使用 IL switch指令,这可能在执行隐式类型检查之后。编译器执行的确切优化超出了本书的范围,但如果你偶然查看与switch语句关联的 IL,并且它与源代码几乎没有相似之处,这可能是原因。

12.7. 使用思考

本节提供了关于本章描述的特性如何最佳使用的初步思考。这两个特性很可能会进一步发展,甚至可能结合解构模式。其他相关的潜在特性,例如用于编写基于模式的switch表达式的语法,可能会影响这些特性的使用位置。你将在第十五章(chapter 15)中看到一些这样的 C# 8 特性。

模式匹配是一个实现问题,这意味着如果你后来发现你过度使用了它,你不需要担心。如果你发现模式没有给你预期的可读性好处,你可以回退到更老的编码风格。这在某种程度上也适用于解构。但是,如果你在你的 API 中到处添加了公共的Deconstruct方法,移除它们将是一个破坏性的变更。

更重要的是,我建议大多数类型本身并不是自然可解构的,就像大多数类型没有自然的IComparable<T>实现一样。我建议只有在组件的顺序明显且无歧义的情况下才添加Deconstruct方法。这对于坐标、具有层次性质的东西(如日期/时间值)或甚至有共同约定的地方(如将颜色视为带有可选 alpha 的 RGB)都是可以的。然而,大多数与业务相关的实体可能不属于这一类别;例如,在线购物车中的商品有各种方面,但它们之间没有明显的顺序。

12.7.1. 发现解构机会

使用的最简单的解构类型可能与你想要解构的元组有关。如果你调用一个返回元组的方法,而你不需要保留这些值在一起,考虑解构它们。例如,在我们的第十一章中的MinMax方法[kindle_split_028_split_000.html#ch11],我几乎总是立即解构,而不是将返回值作为一个元组保留:

int[] values = { 2, 7, 3, -5, 1, 0, 10 };
var (min, max) = MinMax(values);
Console.WriteLine(min);
Console.WriteLine(max);

我怀疑非元组解构的使用会更少,但如果你处理的是点、颜色、日期/时间值或类似的东西,你可能会发现,如果你在其他情况下多次通过属性引用组件,那么在早期解构值可能是值得的。你可以在 C# 7 之前这样做,但通过解构声明多个局部变量的便利性可以轻易地改变是否值得做的平衡。

12.7.2. 发现模式匹配机会

你应该在两个明显的地方考虑使用模式匹配:

  • 任何你使用isas运算符,并通过使用更具体类型的值来有条件地执行代码的地方。

  • 任何你有一个使用相同值的所有条件的if/else-if/else-if/else序列的地方,你可以使用switch语句代替。

如果你发现自己多次使用形式为var ... when的模式(换句话说,当唯一条件出现在守卫子句中时),你可能想问自己这真的是模式匹配吗。我确实遇到过这样的场景,到目前为止,我仍然倾向于使用模式匹配。即使这感觉有点滥用,在我看来,它比if/else序列更清楚地传达了匹配单个条件并执行单个动作的意图。

这两种都是对现有代码结构的转换,只是对实现细节进行了修改。它们并没有改变你对逻辑思考和组织的思考方式。那种更大的改变风格——可能仍然是在单个类型的可见 API 内进行重构,或者可能是在程序集的公共 API 内通过改变内部细节来实现——更难以察觉。有时,它可能意味着从使用继承转向其他方式;一个计算的逻辑可能在一个考虑了所有不同情况的单一位置中表达得更加清晰,而不是作为代表每个情况的类型的组成部分。第 12.3 节中形状案例的边界就是一个例子,但你很容易将这些相同的想法应用到许多业务案例中。这就是为什么在 C#中,非元组类型可能会变得更加普遍。

正如我所说的,这些只是初步的想法。一如既往,我鼓励你通过有意识的内省进行实验:在编码时考虑机会,如果你尝试了新的东西,在你完成之后反思其优缺点。

摘要

  • 解构允许你使用与元组和非元组一致的语法将值分解成多个变量。

  • 非元组类型使用具有out参数的Deconstruct方法进行解构。这可以是一个扩展方法或实例方法。

  • 如果编译器可以推断出所有类型,则可以使用单个var解构声明多个变量。

  • 模式匹配允许你测试值的类型和内容,并且一些模式允许你声明新的变量。

  • 模式匹配可以使用is运算符或switch语句。

  • switch语句中的模式可以引入一个额外的保护子句,由when上下文关键字引入。

  • switch语句包含模式时,case标签的顺序可能会改变行为。

第十三章. 通过更多按引用传递提高效率

本章内容涵盖

  • 使用ref关键字别名变量

  • 使用 ref 返回值按引用返回变量

  • 使用in参数进行高效的参数传递

  • 使用只读 ref 返回值、只读 ref 局部变量和只读结构声明来防止数据更改

  • 使用inref目标的扩展方法

  • 类似于 ref 的结构和Span<T>

当 C# 7.0 发布时,它有几个我觉得有点奇怪的功能:只读局部变量和只读返回值。我对有多少开发者需要它们有些怀疑,因为它们似乎针对的是涉及大型值类型的情况,这种情况很少见。我的预期是,只有接近实时服务和游戏会发现这些功能有用。

C# 7.2 带来了另一批与引用相关的特性:in 参数、只读引用局部变量和返回值、只读结构体和类似引用的结构体。这些特性与 7.0 版本的特性相辅相成,但似乎仍然是为了一小部分用户的利益而使语言变得更加复杂。

我现在确信,尽管许多开发者可能不会直接在他们项目中看到更多基于引用的代码,但他们将从框架中提供的更高效功能中受益。在撰写本文时,还太早确定这将证明有多么革命性,但我认为它很可能会是重大的。

通常,性能是以可读性为代价的。我仍然认为,本章中描述的许多特性也是这样;我预计它们将在已知性能足够重要以证明其成本的情况下被少量使用。然而,所有这些带来的框架变化是另一回事。它们应该使减少对象分配、节省内存和垃圾收集器的工作量变得相对容易,而不会使你的代码更难以阅读。

我提到这些,是因为你可能会有类似的反应。在阅读本章时,决定尽量避免这里的大多数语言特性是完全合理的。尽管如此,我敦促你继续读到最后一部分,以了解框架相关的益处。关于类似引用的结构体的最后一部分介绍了 Span<T>。关于跨度可以说的内容远不止这本书能写的,但我预计跨度和相关类型将成为未来开发者工具箱中的重要部分。

在本章中,我会提到何时一个特性仅在 C# 7 的某个点版本中可用。与其他点版本特性一样,这意味着如果你使用的是 C# 7 编译器,你只能通过适当的设置来指定语言版本,才能利用这些特性。我建议你对引用相关特性采取全有或全无的方法:要么全部使用,并设置适当的选项来允许这样做;要么一个都不用。仅使用 C# 7.0 中的特性可能不会令人满意。尽管如此,让我们首先回顾一下在 C# 早期版本中使用 ref 关键字的情况。

13.1. 回顾:你对引用了解多少?

为了理解 C# 7 中的引用相关特性,你需要牢固掌握 C# 6 及更早版本中引用参数的工作方式。这反过来又需要你牢固掌握变量与其值之间的区别。

不同的开发者对变量的思考方式不同,但我的心理模型始终是一张纸,如图 13.1 所示。这张纸上有三项信息:

图 13.1. 将变量表示为一张纸

图 13.1

  • 变量的名称

  • 编译时类型

  • 当前值

将新值赋给变量只是删除当前值并写入新值的问题。当变量的类型是引用类型时,纸上的值永远不是一个对象;它始终是一个对象引用。对象引用只是导航到对象的一种方式,就像街道地址是导航到建筑的方式一样。两张写有相同地址的纸指向同一栋建筑,就像两个具有相同引用值的变量指向同一个对象一样。

提示

ref关键字和对象引用是不同的概念。当然,它们之间有相似之处,但你需要区分它们。例如,通过值传递对象引用与通过引用传递变量不是一回事。在本节中,我通过使用对象引用而不是仅仅引用来强调它们之间的区别。

重要的是,当一个赋值操作将一个变量的值复制到另一个变量时,实际上复制的是值;两张纸保持独立,对任一变量的后续更改都不会影响另一个。图 13.2 展示了这一概念。

图 13.2. 将值赋值到新变量中

图 13.2

这种值复制正是当你调用方法时值参数所发生的情况;方法参数的被复制到一张新的纸上——参数,如图 13.3 所示。参数不必是变量;它可以是一个适当类型的任何表达式。

图 13.3. 使用值参数调用方法:参数是新的变量,它们从参数的值开始。

图 13.3

ref 参数的行为不同,如图 13.4 所示。它不是作为一张新纸来使用,而是要求调用者提供一个现有的纸张,而不仅仅是初始值。你可以把它想象成一张写有两个名字的纸:一个是调用代码用来识别它的名字,另一个是参数的名字。

图 13.4. ref 参数使用相同的纸张,而不是用值的副本创建新的纸张。

图 13.4

如果该方法修改了 ref 参数的值,从而改变了纸上写的内容,那么当方法返回时,这种变化对调用者来说是可见的,因为它是在原始的纸张上。

注意

关于 ref 参数和变量的思考方式有很多种。你可能读过其他作者将 ref 参数视为完全独立的变量,这些变量只是有一个自动的间接层,以便任何对 ref 参数的访问都首先遵循间接层。这更接近于 IL 所表示的内容,但我发现它不太有帮助。

没有要求每个 ref 参数使用不同的纸。下面的列表提供了一个相当极端的例子,但在你继续学习 ref 局部变量之前检查你的理解是很好的。

列表 13.1. 使用相同的变量作为多个 ref 参数
static void Main()
{
    int x = 5;
    IncrementAndDouble(ref x, ref x);
    Console.WriteLine(x);
}

static void IncrementAndDouble(ref int p1, ref int p2)
{
    p1++;
    p2 *= 2;
}

这里的输出是 12:xp1p2都代表同一张纸。它从值 5 开始;p1++将其递增到 6,而p2 *= 2将其加倍到 12。图 13.5 Figure 13.5 展示了涉及的变量的图形表示。

图 13.5. 两个 ref 参数指向同一张纸

图片

关于这个话题的一种常见说法是别名:在上面的例子中,变量xp1p2都是同一存储位置的别名。它们是到达同一内存的不同方式。

如果这听起来冗长且陈旧,那么你现在可以继续学习 C# 7 的真正新特性了。有了将变量视为纸张的心理模型,理解新特性将会容易得多。

13.2. Ref 局部变量和 ref 返回值

许多与 ref 相关的 C# 7 特性是相互关联的,这使得当你一次看到它们时,理解它们的优点变得更加困难。当我在描述这些特性时,示例将比正常情况下更加牵强,因为它们试图一次只证明一个点。你将首先查看的是在 C# 7.0 中引入的特性,尽管它们在 C# 7.2 中得到了增强。首先是 ref 局部变量。

13.2.1. Ref 局部变量

让我们继续之前的类比:ref 参数允许在两个方法之间共享一张纸。调用者使用的同一张纸就是方法使用的参数。ref 局部变量通过允许你声明一个新的局部变量,该变量与现有变量共享同一张纸,将这个想法进一步发展。

下面的列表展示了这样一个简单的例子,通过不同的变量进行两次递增,然后显示结果。请注意,你必须在声明和初始化时都使用ref关键字。

列表 13.2. 通过两个变量进行两次递增
int x = 10;
ref int y = ref x;
x++;
y++;
Console.WriteLine(x);

这会输出 12,就像你两次递增x一样。

任何被分类为变量的适当类型的表达式都可以用来初始化一个 ref 局部变量,包括数组元素。如果你有一个包含大可变值类型的数组,这可以避免不必要的复制操作,以便进行多次更改。下面的列表创建了一个元组数组,然后修改了每个数组元素中的两个项目,而不进行复制。

列表 13.3. 使用 ref 局部变量修改数组元素
var array = new (int x, int y)[10];

for (int i = 0; i < array.Length; i++)         *1*
{                                              *1*
    array[i] = (i, i);                         *1*
}                                              *1*

for (int i = 0; i < array.Length; i++)         *2*
{                                              *2*
    ref var element = ref array[i];            *2*
    element.x++;                               *2*
    element.y *= 2;                            *2*
}                                              *2*
  • 1 初始化数组为 (0, 0), (1, 1),依此类推

  • 2 对于数组的每个元素,递增 x 并加倍 y

在 ref locals 之前,修改数组有两种选择。你可以使用多个数组访问表达式,如下所示:

for (int i = 0; i < array.Length; i++)
{
    array[i].x++;
    array[i].y *= 2;
}

或者,你可以将整个元组从数组中复制出来,修改它,然后再复制回去:

for (int i = 0; i < array.Length; i++)
{
    var tuple = array[i];
    tuple.x++;
    tuple.y *= 2;
    array[i] = tuple;
}

这两种方法都不太吸引人。ref local 方法表达了我们的目标,即在工作循环体中将数组元素作为普通变量处理。

Ref locals 也可以与字段一起使用。静态字段的行怍是可预测的,但实例字段的行怍可能会让你感到惊讶。考虑以下列表,它创建一个 ref local 来通过变量 (obj) 别名一个实例的字段,然后更改 obj 的值以指向不同的实例。

列表 13.4. 通过 ref local 修改特定对象的字段别名
class RefLocalField
{
    private int value;

    static void Main()
    {
        var obj = new RefLocalField();         *1*
        ref int tmp = ref obj.value;           *2*
        tmp = 10;                              *3*
        Console.WriteLine(obj.value);          *4*

        obj = new RefLocalField();             *5*
        Console.WriteLine(tmp);                *6*
        Console.WriteLine(obj.value);          *7*
    }
}
  • 1 创建 RefLocalField 的实例

  • 2 声明一个 ref local 变量,它引用第一个实例的字段

  • 3 将新值赋给 ref local

  • 4 证明了这已经修改了字段

  • 5 将 obj 变量重新赋值以指向 RefLocalField 的第二个实例

  • 6 证明了 tmp 仍然使用第一个实例的字段

  • 7 证明了第二个实例的字段值确实是 0

输出如下所示:

10
10
0

可能令人惊讶的行是中间的那一行。它表明使用 tmp 并不等于每次都使用 obj.value。相反,tmp 在初始化点充当 obj.value 表达的字段的别名。图 13.6 显示了在 Main 方法末尾涉及的变量和对象快照。

图 13.6. 在 列表 13.4 的末尾,tmp 变量指向第一个创建的实例中的字段,而 obj 的值指向不同的实例。

作为此的推论,tmp 变量将防止第一个实例在方法中 tmp 的最后使用之后被垃圾收集。同样,使用 ref local 对数组元素会阻止包含该元素的数组被垃圾收集。

注意

指向对象内部字段或数组元素的 ref 变量会让垃圾收集器的工作变得更困难。它必须确定变量属于哪个对象,并保持该对象存活。常规对象引用更简单,因为它们直接标识了涉及的对象。每个指向对象中字段的 ref 变量都会在垃圾收集器维护的数据结构中引入一个 内部指针。同时存在大量这些指针会非常昂贵,但 ref 变量只能出现在栈上,这使得不太可能出现足够的 ref 变量导致性能问题。

引用局部变量在其使用上确实有一些限制。其中大部分都很明显,不会妨碍你,但了解它们仍然值得,这样你就不会尝试去规避它们。

初始化:一旦,仅一次,且在声明时(在 C# 7.3 之前)

引用局部变量必须在声明点进行初始化。例如,以下代码是无效的:

int x = 10;
ref int invalid;
invalid = ref int x;

同样,没有方法可以将引用局部变量更改为别名不同的变量。(在我们的模型术语中,你不能擦掉名字然后写在另一张纸上。)当然,同一个变量可以有效地声明多次;例如,在列表 13.3 中,你在循环中声明了element变量:

for (int i = 0; i < array.Length; i++)
{
    ref var element = ref array[i];
    ...
}

在循环的每次迭代中,element将别名不同的数组元素。但这没关系,因为它在每次迭代中实际上是一个新变量。

用于初始化引用局部变量的变量也必须是已确定的。你可能期望这些变量共享确定的赋值状态,但为了不使确定的赋值规则更加复杂,语言设计者确保引用局部变量始终是已确定的。以下是一个例子:

int x;
ref int y = ref x;      *1*
x = 10;
Console.WriteLine(y);
  • 1 无效,因为 x 没有确定的赋值

此代码在尝试从任何变量读取之前不会尝试读取,但它仍然无效。

C# 7.3 取消了重新赋值的限制,但引用局部变量仍然必须在声明点使用已确定的变量进行初始化。例如:

int x = 10;
int y = 20;
ref int r = ref x;
r++;
r = ref y; *1*
r++;
Console.WriteLine($"x={x}; y={y}");    *2*
  • 1 仅在 C# 7.3 中有效

  • 2 打印 x = 11, y = 21

我敦促在使用此功能时要谨慎。如果你需要在方法执行过程中让同一个引用变量指向不同的变量,我建议至少尝试重构方法以使其更简单。

没有引用字段,或会在方法调用后继续存在的局部变量

虽然可以使用字段来初始化引用局部变量,但你不能使用ref来声明字段。这是防止引用变量像别名一样指向具有更短生命周期的另一个变量的一个方面。如果你可以创建一个具有别名方法中局部变量的字段的对象,那么在方法返回后该字段会发生什么?

与生命周期相关的相同问题也适用于三种情况下的局部变量:

  • 迭代块不能包含引用局部变量。

  • 异步方法不能包含引用局部变量。

  • 引用局部变量不能被匿名方法或局部方法捕获。(局部方法在第十四章中描述。)

这些都是局部变量可以超出原始方法调用范围的情况。有时,编译器可能会证明这不会引起问题,但语言规则被选择为了简单性。(一个简单的例子是,一个仅由包含方法调用的局部方法,而不是在方法组转换中使用的方法。)

不可引用只读变量

在 C# 7.0 中引入的任何 ref 局部变量都是可写的;你可以在纸上写一个新的值。如果你尝试使用不可写的纸来初始化 ref 局部变量,这就会引起问题。考虑以下违反readonly修饰符的尝试:

class MixedVariables
{
    private int writableField;
    private readonly int readonlyField;

    public void TryIncrementBoth()
    {
        ref int x = ref writableField;     *1*
        ref int y = ref readonlyField;     *2*

        x++;                               *3*
        y++;                               *3*
    }
}
  • 1 将可写字段重命名

  • 2 尝试重命名只读字段

  • 3 同时增加两个变量

如果这是有效的,那么我们多年来关于只读字段的推理都将丢失。幸运的是,情况并非如此;编译器会阻止对y的赋值,就像它会阻止对readonlyField的任何直接修改一样。但这段代码在MixedVariables类的构造函数中是有效的,因为在那种情况下,你也能直接写入readonlyField。简而言之,你只能以其他情况下可以写入变量的方式初始化 ref 局部变量。这与从 C# 1.0 开始的将字段用作 ref 参数的参数的行为相匹配。

如果你想利用 ref 局部变量的共享特性而不需要可写特性,这种限制可能会让你感到沮丧。在 C# 7.0 中,这是一个问题;但你在第 13.2.4 节中会看到 C# 7.2 提供了一个解决方案。

类型:仅允许身份转换

ref 局部变量的类型必须与其初始化时使用的变量的类型相同,或者两者之间必须有身份转换。任何其他转换——甚至是在许多其他场景中允许的引用转换——都不够。以下列表展示了使用基于元组的身份转换的 ref 局部变量声明示例,这是你在第十一章中学到的。

注意

有关身份转换的提醒,请参阅第 11.3.3 节。

列表 13.5. ref 局部变量声明中的身份转换
(int x, int y) tuple1 = (10, 20);
ref (int a, int b) tuple2 = ref tuple1;
tuple2.a = 30;
Console.WriteLine(tuple1.x);

这将输出 30,因为tuple1tuple2共享相同的存储位置;tuple1.xtuple2.a彼此等价,同样tuple1.ytuple2.b也彼此等价。

在本节中,你已经看到了从局部变量、字段和数组元素初始化 ref 局部变量的方法。在 C# 7 中,一种新的表达式类型被归类为变量:由 ref 返回方法返回的变量。

13.2.2. Ref 返回

在某些方面,理解 ref 返回应该很容易。使用我们之前的模型,这是一个方法可以返回一张纸而不是一个值的概念。你需要将ref关键字添加到返回类型和任何返回语句中。调用代码通常会声明一个局部 ref 来接收返回值。这意味着你必须在代码中广泛地使用ref关键字,以使其非常清楚你试图做什么。以下列表显示了关于最简单的 ref 返回使用;RefReturn方法返回传递给它的任何变量。

列表 13.6. 最简单的 ref 返回演示
static void Main()
{
    int x = 10;
    ref int y = ref RefReturn(ref x);
    y++;
    Console.WriteLine(x);
}

static ref int RefReturn(ref int p)
{
    return ref p;
}

这会打印 11,因为xy在同一张纸上,就像你写了

ref int y = ref x;

该方法本质上是一个恒等函数,只是为了展示语法。它也可以写成表达式主体方法,但我想要使返回部分清晰。

到目前为止,很简单,但很多细节都被阻碍了,主要是因为编译器确保任何返回的纸张在方法返回完成后仍然存在。它不能是方法中创建的纸张。

用实现术语来说,一个方法不能返回它刚刚在栈上创建的存储位置,因为当栈弹出时,该存储位置将不再有效。在描述 C#语言的工作方式时,埃里克·利普特喜欢说栈是一个实现细节(见mng.bz/oVvZ)。在这种情况下,这是一个泄漏到语言中的实现细节。这些限制的原因与为什么不允许 ref 字段相同,所以如果你觉得你理解了其中之一,你可以将相同的逻辑应用到另一个上。

我不会详尽无遗地列出可以使用 ref 返回返回和不能返回的每种类型的变量,但这里有一些最常见的例子:

有效
  • refout参数

  • 引用类型字段

  • 结构变量字段,其中结构变量是refout参数

  • 数组元素

无效
  • 在方法中声明的局部变量(包括值参数)

  • 在方法中声明的结构变量字段

除了这些关于可以和不能返回的限制之外,ref 返回在异步方法和迭代块中完全无效。与指针类型类似,你无法在类型参数中使用ref修饰符,尽管它可以出现在接口和委托声明中。例如,这是完全有效的

delegate ref int RefFuncInt32();

但通过尝试引用Func<ref int>,你无法得到相同的结果。

Ref 返回不必与 ref 局部一起使用。如果你想对结果执行单个操作,你可以直接这样做。以下列表显示了这一点,使用与列表 13.6 相同的代码,但没有 ref 局部。

列表 13.7. 直接增加 ref 返回的结果
static void Main()
{
    int x = 10;
    RefReturn(ref x)++; *1*
    Console.WriteLine(x);
}

static ref int RefReturn(ref int p)
{
    return ref p;
}
  • 1 直接增加返回变量的值

同样,这相当于递增 x,所以输出是 11。除了修改结果变量外,您还可以将其用作另一个方法的 ref 参数。为了使我们的纯粹演示性示例更加荒谬,您甚至可以调用 RefReturn 并使用其结果(两次):

RefReturn(ref RefReturn(ref RefReturn(ref x)))++;

Ref 返回对索引器和方法都有效。这通常最有用,如以下列表所示,通过引用返回数组元素。

列表 13.8. 通过引用返回数组元素的 ref 返回索引器
class ArrayHolder
{
    private readonly int[] array = new int[10];
    public ref int this[int index] => ref array[index];    *1*
}

static void Main()
{
    ArrayHolder holder = new ArrayHolder();
    ref int x = ref holder[0];                             *2*
    ref int y = ref holder[0];                             *2*

    x = 20;                                                *3*
    Console.WriteLine(y);                                  *4*
}
  • 1 索引器通过引用返回数组元素

  • 2 声明两个 ref 本地变量,它们引用相同的数组元素

  • 3 通过 x 改变数组元素的值

  • 4 通过 y 观察变化

您现在已经涵盖了 C# 7.0 中所有的新特性,但后续的版本发布扩展了与 ref 相关的特性集。第一个特性是我最初撰写本章初稿时相当沮丧的一个:缺乏条件 ?: 运算符的支持。

13.2.3. 条件运算符 ?: 和 ref 值(C# 7.2)

条件运算符 ?: 自 C# 1.0 版本以来就存在,并且在其他语言中也很常见:

condition ? expression1 : expression2

它评估其第一个操作数(条件),然后评估第二个或第三个操作数以提供整体结果。使用 ref 值来实现相同的事情感觉很自然,根据条件选择一个或另一个变量。

使用 C# 7.0,这是不可行的,但在 C# 7.2 中可以。条件运算符可以使用 ref 值作为第二个和第三个操作数,此时条件运算符的结果也是一个可以用作 ref 修饰符的变量。以下列表显示了一个方法,它计算序列中的偶数和奇数值,并将结果作为元组返回。

列表 13.9. 在序列中计算偶数和奇数元素
static (int even, int odd) CountEvenAndOdd(IEnumerable<int> values)
{
    var result = (even: 0, odd: 0);
    foreach (var value in values)
    {
 ref int counter = ref (value & 1) == 0 ?    *1*
 ref result.even : ref result.odd;       *1*
        counter++; *2*
    }
    return result;
}
  • 1 选择合适的变量进行递增

  • 2 递增它

在这里使用元组有些偶然,尽管它有助于展示元组可变性的有用性。这一添加使语言感觉更加一致。条件运算符的结果可以用作 ref 参数的参数,分配给 ref 本地变量,或用于 ref 返回。这一切都自然而然地发生了。下一个 C# 7.2 特性解决了一个您在 第 13.2.1 节 中讨论 ref 本地变量限制时考虑的问题:如何获取只读变量的引用?

13.2.4. Ref readonly(C# 7.2)

到目前为止,您所别名的所有变量都是可写的。在 C# 7.0 中,这仅是可用的。但在两个并行场景中,这还不够:

  • 为了提高效率,您可能希望将只读字段别名为 ref,以避免复制。

  • 您可能希望仅通过 ref 变量允许只读访问。

ref readonly 在 C# 7.2 中的引入解决了这两个场景。ref 局部和 ref 返回都可以用 readonly 修饰符声明,结果是只读的,就像只读字段一样。你不能给变量赋新值,如果它是结构体类型,你不能修改任何字段或调用属性设置器。

小贴士

由于使用 ref readonly 的一个原因是为了避免复制,你可能会惊讶地听到有时它会产生相反的效果。你将在 第 13.4 节 中详细了解这一点。在没有阅读该部分的情况下,不要在生产代码中使用 ref readonly

你可以放置修饰符的两个地方协同工作:如果你用一个 ref readonly 返回值调用一个方法或索引器,并且想在局部变量中存储结果,那么这个局部变量也必须是 ref readonly 的。以下列表展示了只读方面是如何连锁在一起的。

列表 13.10. ref readonly 返回和局部变量
static readonly int field = DateTime.UtcNow.Second;      *1*

static ref readonly int GetFieldAlias() => ref field;    *2*

static void Main()
{
    ref readonly int local = ref GetFieldAlias();        *3*
    Console.WriteLine(local);
}
  • 1 使用任意值初始化只读字段

  • 2 返回字段的只读别名

  • 3 使用方法初始化只读引用局部变量

这也适用于索引器,并且它允许不可变集合直接暴露其数据,没有任何复制,也没有任何内存被突变的风险。请注意,你可以返回一个 ref readonly,即使底层变量不是只读的,这提供了一个对数组的只读视图,就像 ReadOnlyCollection 对任意集合所做的那样,但具有无复制读取访问。以下列表展示了这个想法的简单实现。

列表 13.11. 对数组的只读视图,无复制读取
class ReadOnlyArrayView<T>
{
    private readonly T[] values;

    public ReadOnlyArrayView(T[] values) =>     *1*
        this.values = values;                   *1*

    public ref readonly T this[int index] =>    *2*
        ref values[index];                      *2*
}
...
static void Main()
{
    var array = new int[] { 10, 20, 30 };
    var view = new ReadOnlyArrayView<int>(array);

    ref readonly int element = ref view[0];
    Console.WriteLine(element);                  *3*
    array[0] = 100;                              *3*
    Console.WriteLine(element);                  *3*
}
  • 1 复制数组引用而不克隆内容

  • 2 返回数组元素的只读别名

  • 3 通过局部变量可见对数组的修改。

这个例子在效率提升方面并不令人信服,因为 int 已经是一个小类型,但在使用较大的结构体以避免过多的堆分配和垃圾回收的场景中,好处可能是显著的。

实现细节

在 IL 中,ref readonly 方法被实现为一个常规的返回引用的方法(返回类型是一个按引用类型),但应用了来自 System.Runtime.InteropServices 命名空间的 [InAttribute] 属性。这个属性反过来在 IL 中用 modreq 修饰符指定:如果编译器不知道 InAttribute,它应该拒绝对该方法的任何调用。这是一个安全机制,以防止方法返回值的误用。想象一下,一个 C# 7.0 编译器(一个知道 ref 返回但不了解 ref readonly 返回的编译器)尝试从一个其他程序集调用 ref readonly 返回的方法。它可能允许调用者将结果存储在可写的引用局部变量中,然后修改它,从而违反了 ref readonly 返回的意图。

你不能声明 ref readonly 返回方法,除非编译器可以使用 InAttribute。这很少是问题,因为它自从 .NET 1.1 以来就在桌面框架中,并且在 .NET Standard 1.1 中。如果你绝对需要,你可以在正确的命名空间中声明自己的属性,编译器将使用它。

readonly 修饰符可以应用于局部变量和返回类型,正如你所见,但对于参数呢?如果你有一个 ref readonly 局部变量,并且想要将其传递给一个方法而不只是复制其值,你有什么选择?你可能期望答案是再次使用 readonly 修饰符,只是应用于参数,但现实略有不同,正如你将在下一节中看到的。

13.3. in 参数(C# 7.2)

C# 7.2 添加了 in 作为参数的新修饰符,其风格与 refout 相同,但目的不同。当一个参数有 in 修饰符时,其意图是方法不会更改参数值,因此可以通过引用传递变量以避免复制。在方法内部,in 参数的行为类似于 ref readonly 局部变量。它仍然是调用者传递的存储位置的别名,因此方法不修改值是很重要的;调用者会看到这种变化,这与 in 参数的目的相悖。

in 参数与 refout 参数之间有很大的区别:调用者不需要为参数指定 in 修饰符。如果缺少 in 修饰符,编译器将根据参数是变量还是值来决定是否通过引用传递参数;如果参数是变量,则通过引用传递;如果需要,则将值作为隐藏的局部变量复制并传递。如果调用者明确指定 in 修饰符,则调用有效仅当参数可以直接通过引用传递时。以下列表显示了所有可能性。

列表 13.12. 传递 in 参数时的有效和无效可能性
static void PrintDateTime(in DateTime value)    *1*
{
    string text = value.ToString(
        "yyyy-MM-dd'T'HH:mm:ss",
        CultureInfo.InvariantCulture);
    Console.WriteLine(text);
}

static void Main()
{
    DateTime start = DateTime.UtcNow;
    PrintDateTime(start);                       *2*
    PrintDateTime(in start);                    *3*
    PrintDateTime(start.AddMinutes(1));         *4*
    PrintDateTime(in start.AddMinutes(1));      *5*
}
  • 1 声明具有 in 参数的方法

  • 2 变量隐式通过引用传递。

  • 3 显式通过引用传递变量(由于 in 修饰符)。

  • 4 结果被复制到隐藏的局部变量中,该变量通过引用传递。

  • 5 编译时错误:参数不能通过引用传递。

在生成的 IL 中,该参数相当于一个带有 [IsReadOnlyAttribute]ref 参数,该属性来自 System.Runtime.CompilerServices 命名空间。这个属性比 InAttribute 更晚引入;它在 .NET 4.7.1 中,但甚至不在 .NET Standard 2.0 中。如果必须这样做,可以在正确的命名空间中声明自己的属性,编译器将使用该属性。

该属性在 IL 中没有 modreq 修饰符;任何不理解 IsReadOnlyAttribute 的 C# 编译器都会将其视为常规的 ref 参数。(CLR 也不需要了解该属性。)任何使用编译器后续版本重新编译的调用者将突然无法编译,因为他们现在需要 in 修饰符而不是 ref 修饰符。这引出了向后兼容性的一个更大话题。

13.3.1. 兼容性考虑

in 修饰符在调用站点是可选的,这导致了一个有趣的向后兼容性问题。将方法参数从值参数(默认值,没有修饰符)更改为 in 参数始终是 兼容的(你应该总是能够在不更改调用代码的情况下重新编译)但永远不会 二进制 兼容的(任何现有已编译的调用该方法的程序集将在执行时失败)。这具体意味着什么将取决于你的情况。假设你想要将一个方法参数更改为 in 参数,而这个程序集已经发布:

  • 如果你的方法可以被你无法控制的调用者访问(例如,如果你正在将库发布到 NuGet),这是一个破坏性变更,应该像对待任何其他破坏性变更一样处理。

  • 如果你的代码只能被那些在使用你程序集的新版本时一定会重新编译的调用者访问(即使你无法更改调用代码),那么这不会破坏这些调用者。

  • 如果你的方法仅限于你的程序集内部,^([1)) 你不需要担心二进制兼容性,因为所有调用者都会重新编译。

    ¹

    如果你的程序集使用了 InternalsVisibleTo,情况会更加复杂;这种详细程度超出了本书的范围。

另一个稍微不太可能的情况是:如果你有一个仅为了避免复制(你永远不会在方法中修改参数)而具有 ref 参数的方法,将其更改为 in 参数始终是 二进制 兼容的,但永远不会 兼容的。这与将值参数更改为 in 参数正好相反。

所有这些都假设使用 in 参数的行为不会破坏方法本身的语义。这并不总是有效的假设;让我们看看原因。

13.3.2. in 参数的意外可变性:外部更改

到目前为止,听起来如果你不修改方法内的参数,将其更改为 in 参数是安全的。但这并不是情况,而且这是一个危险预期。编译器阻止 方法 修改参数,但它无法阻止其他代码修改它。你需要记住,in 参数是其他代码可能能够修改的存储位置的别名。让我们先看一个简单的例子,这可能会显得非常明显。

列表 13.13. 面对副作用时 in 参数和值参数的差异
static void InParameter(in int p, Action action)
{
    Console.WriteLine("Start of InParameter method");
    Console.WriteLine($"p = {p}");
    action();
    Console.WriteLine($"p = {p}");
}

static void ValueParameter(int p, Action action)
{
    Console.WriteLine("Start of ValueParameter method");
    Console.WriteLine($"p = {p}");
    action();
    Console.WriteLine($"p = {p}");
}

static void Main()
{
    int x = 10;
    InParameter(x, () => x++);
    int y = 10;
    ValueParameter(y, () => y++);
}

前两个方法除了显示的日志消息和参数的性质外,都是相同的。在Main方法中,你以相同的方式调用这两个方法,传递一个初始值为 10 的局部变量作为参数和一个增加变量的操作。输出显示了语义上的差异:

Start of InParameter method
p = 10
p = 11
Start of ValueParameter method
p = 10
p = 10

如你所见,InParameter方法能够观察到调用action()引起的更改;ValueParameter方法则不能。这并不奇怪;in参数旨在共享存储位置,而值参数旨在获取副本。

问题在于,尽管在这个特定情况下由于代码很少,所以很明显,但在其他例子中可能不是。例如,in参数可能恰好是同一类中字段的别名。在这种情况下,对字段的任何修改,无论是直接在方法中还是在方法调用的其他代码中,都将通过参数可见。这在调用代码或方法本身中也不明显。当涉及多个线程时,预测会发生什么变得更加困难。

我在这里故意有些夸张,但我认为这是一个真正的问题。我们习惯于通过指定参数上的修饰符和参数来突出显示这种行为的可能性^([2])。此外,ref修饰符感觉上与参数变化如何可见有关,而in修饰符则是关于改变参数。在第 13.3.4 节中,我将提供更多关于使用in参数的指导,但在此期间,你应该只是意识到参数意外改变其值的潜在风险。

²

我喜欢把它想象成类似于量子纠缠现象中所谓的“遥远距离的神秘作用”。

13.3.3. 使用输入参数进行重载

我还没有涉及到的一个方面是方法重载:如果你想要两个具有相同名称和相同参数类型的方法,但在一种情况下参数是in参数,而在第二种情况下不是,会发生什么?

记住,就 CLR 而言,这只是一个另一个ref参数。你不能仅仅通过在refoutin修饰符之间切换来重载方法;它们对 CLR 来说看起来都一样。但你可以用常规值参数重载in参数:

void Method(int x) { ... }
void Method(in int x) { ... }

在重载解析中的新规则使具有值参数的方法在具有没有in修饰符的参数方面表现得更好:

int x = 5;
Method(5);         *1*
Method(x);         *2*
Method(in x);      *3*
  • 1 调用第一个方法

  • 2 调用第一个方法

  • 3 由于输入修饰符调用第二个方法

这些规则允许你在现有方法名称上添加重载,而无需过多考虑兼容性问题,如果现有方法具有值参数,而新方法具有输入参数。

13.3.4. 关于输入参数的指导

完全坦白:我还没有在实际代码中使用过in参数。这里提供的指导是推测性的。

首先要注意的是,in参数旨在提高性能。作为一个一般原则,在你以有意义和可重复的方式测量性能并为其设定目标之前,我不会开始对你的代码进行任何更改以改善性能。如果你不小心,你可能会以优化的名义使你的代码复杂化,结果发现即使你大幅提高了一个或两个方法的性能,但这些方法根本不是应用程序的关键路径。你具体的目标将取决于你正在编写的代码类型(游戏、Web 应用程序、库、物联网应用程序或其他),但仔细的测量是非常重要的。对于微基准测试,我推荐 BenchmarkDotNet 项目。

in参数的好处在于减少需要复制的数量。如果你只使用引用类型或小的结构体,可能根本不会发生任何改进;从逻辑上讲,存储位置仍然需要传递给方法,即使该存储位置中的值没有被复制。由于 JIT 编译和优化的黑盒性质,我不会在这里做出过多的断言。不测试就推理性能是一个坏主意:涉及到的复杂因素足够多,以至于这种推理最多只能是一个有根据的猜测。然而,我预计随着涉及的结构体大小的增加,in参数的好处也会增加。

我对in参数的主要担忧是它们可以使你对代码的推理变得更加困难。你可以读取相同参数的值两次并得到不同的结果,尽管你的方法没有做任何改变,就像你在第 13.3.2 节中看到的那样。这使得编写正确的代码变得更加困难,并且容易编写看似正确但实际上不正确的代码。

有一种方法可以在避免这种情况的同时,仍然获得in参数的许多好处:通过仔细减少或去除它们改变的可能性。如果你有一个通过私有方法调用深层堆栈实现的公共 API,你可以为那个公共 API 使用值参数,然后在私有方法中使用in参数。以下列表提供了一个示例,尽管它并没有进行任何有意义的计算。

列表 13.14. 安全使用in参数
public static double PublicMethod(                         *1*
    LargeStruct first,                                     *1*
    LargeStruct second)                                    *1*
{
    double firstResult = PrivateMethod(in first);
    double secondResult = PrivateMethod(in second);
    return firstResult + secondResult;
}

private static double PrivateMethod(                       *2*
 in LargeStruct input)                                 *2*
{
    double scale = GetScale(in input);
    return (input.X + input.Y + input.Z) * scale;
}

private static double GetScale(in LargeStruct input) =>    *3*
    input.Weight * input.Score;
  • 1 使用值参数的公共方法

  • 2 使用in参数的私有方法

  • 3 另一个带有in参数的方法

使用这种方法,你可以防止意外的变化;因为所有的方法都是私有的,你可以检查所有调用者,确保他们不会传递在方法执行期间可能发生变化的值。当调用PublicMethod时,将创建每个结构的单个副本,但这些副本随后被别名用于私有方法,从而将你的代码与调用者在其他线程中或作为其他方法副作用所做的任何更改隔离开。在某些情况下,你可能希望参数可变,但以一种你仔细记录和控制的方式。

将相同的逻辑应用于内部调用也是合理的,但需要更多的自律,因为可以调用该方法的方法代码更多。出于个人偏好,我在调用点以及参数声明中明确使用了in修饰符,以便在阅读代码时可以清楚地了解正在发生什么。

我将这些总结成了一小份推荐列表:

  • 只有在有可测量和显著性能提升的情况下才使用in参数。这很可能涉及到大型结构体。

  • 除非你的方法可以在参数值在方法执行期间任意变化的情况下正确运行,否则请避免在公共 API 中使用in参数。

  • 考虑将公共方法用作防止变化的屏障,然后在私有实现中使用in参数来避免复制。

  • 考虑在调用接受in参数的方法时显式使用in修饰符,除非你故意使用编译器的功能通过引用传递隐藏的局部变量。

许多这些指南都可以很容易地通过 Roslyn 分析器进行检查。虽然我在写作时不知道有这样的分析器,但我不会对出现一个 NuGet 包感到惊讶。

注意

如果你在这里发现了隐式的挑战,你是正确的。如果你能告诉我这样的分析器,我会在网站上添加一个注释。

所有这些都取决于复制量的真正减少,这并不像听起来那么简单。我之前提到了这一点,但现在我们需要更仔细地看看编译器在什么情况下隐式复制结构体,以及你如何避免这种情况。

13.4. 将结构体声明为只读(C# 7.2)

in参数的目的是通过减少结构体的复制来提高性能。这听起来很棒,但除非我们小心,否则 C#的一个不为人知的方面会阻碍我们。我们将首先探讨这个问题,然后看看 C# 7.2 是如何解决这个问题的。

13.4.1. 背景:使用只读变量的隐式复制

C#已经隐式复制结构体很长时间了。这都在规范中有详细说明,但直到我在 Noda Time 中意外忘记将字段设置为只读时发现了神秘的性能提升,我才意识到这一点。

让我们来看一个简单的例子。你将声明一个具有三个只读属性:YearMonthDayYearMonthDay结构体。你不会使用内置的DateTime类型,原因将在稍后变得清晰。下面的列表显示了YearMonthDay的代码;它真的很简单。(这里没有验证;这部分只是为了演示。)

列表 13.15. 一个简单的年/月/日结构体
public struct YearMonthDay
{
    public int Year { get; }
    public int Month { get; }
    public int Day { get; }

    public YearMonthDay(int year, int month, int day) =>
        (Year, Month, Day) = (year, month, day);
}

现在让我们创建一个具有两个YearMonthDay字段的类:一个只读和一个可读写。然后你将在这两个字段中访问Year属性。

列表 13.16. 通过只读或可读写字段访问属性
class ImplicitFieldCopy
{
    private readonly YearMonthDay readOnlyField =
        new YearMonthDay(2018, 3, 1);
    private YearMonthDay readWriteField =
        new YearMonthDay(2018, 3, 1);

    public void CheckYear()
    {
        int readOnlyFieldYear = readOnlyField.Year;
        int readWriteFieldYear = readWriteField.Year;
    }
}

为这两个属性访问生成的 IL 在微妙但重要的方式上有所不同。以下是只读字段的 IL;为了简单起见,我已经从 IL 中删除了命名空间:

ldfld valuetype YearMonthDay ImplicitFieldCopy::readOnlyField
stloc.0
ldloca.s V_0
call instance int32 YearMonthDay::get_Year()

它加载字段的值,从而将其复制到堆栈上。只有在这种情况下,它才能调用get_Year()成员,这是Year属性的 getter。与使用可读写字段的代码进行比较:

ldflda valuetype YearMonthDay ImplicitFieldCopy::readWriteField
call instance int32 YearMonthDay::get_Year()

这使用ldflda指令将字段的地址加载到堆栈上,而不是ldfld,后者加载字段的值。这完全是 IL,这不是你的计算机直接执行的内容。在某些情况下,JIT 编译器可能能够优化这一点,但在 Noda Time 中我发现,将字段设置为可读写(仅用属性来解释为什么它们不是只读的)对性能产生了显著影响。

编译器采取这种复制的原因是为了避免只读字段在属性(或如果你调用了一个方法)内部的代码中被修改。只读字段的意图是没有任何东西可以改变它的值。如果readOnlyField.SomeMethod()能够修改字段,那就很奇怪了。C#设计为期望任何属性设置器都会修改数据,因此它们被完全禁止用于只读字段。但即使是属性 getter 也可能尝试修改值。采取复制是一个安全措施。

这仅影响值类型

就作为一个提醒,有一个只读的字段是引用类型,并且方法可以修改它们所引用的对象中的数据是可以的。例如,你可以有一个只读的StringBuilder字段,你仍然可以向那个StringBuilder中追加内容。字段的值只是一个引用,这就是不能改变的东西。

在本节中,我们关注的是字段类型是一个值类型,如decimalDateTime。字段包含的类型是类还是结构体无关紧要。

直到 C# 7.2,只有字段可以是只读的。现在我们有ref readonly局部变量和in参数需要担心。让我们编写一个方法,该方法从值参数中打印出年、月和日:

private void PrintYearMonthDay(YearMonthDay input) =>
    Console.WriteLine($"{input.Year} {input.Month} {input.Day}");

这个 IL 使用的是已经位于堆栈上的值的地址。每个属性访问看起来都像这样:

ldarga.s input
call instance int32 Chapter13.YearMonthDay::get_Year()

这不会创建任何额外的副本。假设如果属性改变了值,那么你的 input 变量被改变是可以接受的;毕竟,它只是一个读写变量。但如果你决定将输入改为像这样的 in 参数,事情就会改变:

private void PrintYearMonthDay(in YearMonthDay input) =>
    Console.WriteLine($"{input.Year} {input.Month} {input.Day}");

现在在方法的 IL 中,每个属性访问都有如下代码:

ldarg.1
ldobj Chapter13.YearMonthDay
stloc.0
ldloca.s V_0
call instance int32 YearMonthDay::get_Year()

ldobj 指令将值从地址(参数)复制到堆栈上。你试图避免调用者进行一次复制,但在这样做的同时,你在方法内部引入了三个复制。你也会看到 readonly ref 本地变量有完全相同的行为。这可不是什么好事!正如你可能猜到的,C# 7.2 有一个解决方案:只读结构来拯救!

13.4.2. 结构的只读修饰符

总结一下,C# 编译器需要为只读值类型变量创建副本的原因是为了避免那些类型内部的代码改变变量的值。如果结构能承诺它不会这样做会怎样?毕竟,大多数结构都是设计为不可变的。在 C# 7.2 中,你可以将 readonly 修饰符应用于结构声明来做到这一点。

让我们修改我们的年/月/日结构以使其成为只读。它已经遵守了实现中的语义,所以你只需要添加 readonly 修饰符:

public readonly struct YearMonthDay
{
    public int Year { get; }
    public int Month { get; }
    public int Day { get; }

    public YearMonthDay(int year, int month, int day) =>
        (Year, Month, Day) = (year, month, day);
}

在声明进行简单更改之后,并且没有对使用该结构的代码进行任何更改,为 PrintYearMonthDay(in YearMonthDay input) 生成的 IL 变得更高效。每个属性访问现在看起来像这样:

ldarg.1
call instance int32 YearMonthDay::get_Year()

最后,你成功避免了整个结构被复制哪怕一次。

如果你查看书中附带的可下载源代码,你会在一个单独的结构声明中看到这个:ReadOnlyYearMonthDay。这是必要的,这样我才能有带有前后对比的样本,但在你自己的代码中,你只需将现有的结构设置为只读,而不会破坏源代码或二进制兼容性。然而,向相反方向进行是一个隐秘的破坏性变更;如果你决定移除修饰符并修改现有的成员以改变值的状态,之前编译的期望结构为只读的代码可能会以令人不安的方式修改只读变量。

你只能在你结构真正是只读的情况下应用修饰符,并且因此满足以下条件:

  • 每个实例字段和自动实现的实例属性必须是只读的。静态字段和属性仍然可以是读写。

  • 你只能在构造函数中向 this 赋值。在规范术语中,this 在构造函数中被视为一个 out 参数,在常规结构的成员中是一个 ref 参数,在只读结构的成员中是一个 in 参数。

假设你原本打算你的结构体应该是只读的,添加readonly修饰符可以让编译器帮助你检查你是否违反了这一规定。我预计大多数用户定义的结构体会立即工作。不幸的是,当涉及到 Noda Time 时,这里有一个小问题,这也可能影响到你。

13.4.3. XML 序列化默认是读写模式

目前,Noda Time 中的大多数结构体实现了IXmlSerializable。不幸的是,XML 序列化是以一种对只读结构体有敌意的方式定义的。我在 Noda Time 中的实现通常如下所示:

void IXmlSerializable.ReadXml(XmlReader reader)
{
    var pattern = /* some suitable text parsing pattern for the type */;
    var text = /* extract text from the XmlReader */;
    this = pattern.Parse(text).Value;
}

你能看出问题吗?它在最后一行对this进行了赋值。这阻止了我使用readonly修饰符来声明这些结构体,这让我感到很沮丧。目前我有三个选择:

  • 保持结构体不变,这意味着in参数和ref readonly局部变量是低效的。

  • 从 Noda Time 的下一个主要版本中移除 XML 序列化。

  • ReadXml中使用不安全代码来违反readonly修饰符。System.Runtime.CompilerServices.Unsafe包使这变得简单。

这些选项中没有一个令人愉快,而且在我揭示一个巧妙的方法来满足所有这些担忧之前,没有转折。目前,我相信实现IXmlSerializable的结构体不能真正是只读的。毫无疑问,还有其他接口可能是隐式可变的,就像你可能在结构体中实现的那样,但我怀疑IXmlSerializable将是其中最常见的一个。

好消息是,大多数读者可能不会遇到这个问题。在你能够使你的用户定义结构体只读的情况下,我鼓励你这样做。但请记住,这是一个单向的改变,对于公共代码来说;只有在你能够重新编译使用该结构体的所有代码的特权位置,你才能安全地移除修饰符。我们下一个特性实际上是整理一致性:为扩展方法提供已经在结构体实例方法中存在的相同功能。

13.5. 带有 ref 或 in 参数的扩展方法(C# 7.2)

在 C# 7.2 之前,任何扩展方法中的第一个参数都必须是值参数。在 C# 7.2 中,这一限制部分被放宽,以更彻底地拥抱新的类似 ref 的语义。

13.5.1. 在扩展方法中使用 ref/in 参数以避免复制

假设你有一个大的结构体,你希望避免复制,并且有一个基于该结构体属性值计算结果的方法——例如 3D 向量的模。如果结构体提供了这个方法(或属性),那么你没问题,尤其是如果结构体声明了readonly修饰符。你可以无问题地避免复制。但也许你正在做一些结构体作者没有考虑到的更复杂的事情。本节中的示例使用了一个简单的只读Vector3D结构体,该结构体在下面的列表中引入。这个结构体仅公开了XYZ属性。

列表 13.17. 一个简单的 Vector3D 结构体
public readonly struct Vector3D
{
    public double X { get; }
    public double Y { get; }
    public double Z { get; }

    public Vector3D(double x, double y, double z)
    {
        X = x;
        Y = y;
        Z = z;
    }
}

如果你编写自己的接受具有 in 参数的结构体的方法,那就没问题。你可以避免复制,但调用可能有些尴尬。例如,你可能不得不写点像这样的事情:

double magnitude = VectorUtilities.Magnitude(vector);

那会很难看。你有扩展方法,但像这样的常规扩展方法会在每次调用时复制向量:

public static double Magnitude(this Vector3D vector)

在性能和可读性之间做出选择并不愉快。C# 7.2 以一种合理可预测的方式提供了帮助:你可以在第一个参数上使用 refin 修饰符来编写扩展方法。修饰符可以出现在 this 修饰符之前或之后。如果你只是计算一个值,你应该使用 in 参数,但如果你想能够在原始存储位置修改值而不必创建一个新值并复制它,你也可以使用 ref。以下列表提供了两个在 Vector3D 上的示例扩展方法。

列表 13.18. 使用 refin 的扩展方法
public static double Magnitude(this in Vector3D vec) =>
    Math.Sqrt(vec.X * vec.X + vec.Y * vec.Y + vec.Z * vec.Z);

public static void OffsetBy(this ref Vector3D orig, in Vector3D off) =>
    orig = new Vector3D(orig.X + off.X, orig.Y + off.Y, orig.Z + off.Z);

参数名称的缩写比我通常感到舒适的程度要高,以避免在书中出现冗长的格式化。请注意,OffsetBy 方法中的第二个参数是一个 in 参数;你试图尽可能避免复制。

使用扩展方法很简单。唯一可能令人惊讶的方面是,与常规 ref 参数不同,调用 ref 扩展方法时没有 ref 修饰符的迹象。以下列表使用我展示的两个扩展方法创建两个向量,将第一个向量偏移第二个向量,然后显示结果向量和其大小。

列表 13.19. 调用 refin 扩展方法
var vector = new Vector3D(1.5, 2.0, 3.0);
var offset = new Vector3D(5.0, 2.5, -1.0);

vector.OffsetBy(offset);

Console.WriteLine($"({vector.X}, {vector.Y}, {vector.Z})");
Console.WriteLine(vector.Magnitude());

输出如下:

(6.5, 4.5, 2)
8.15475321515004

这表明 OffsetBy 的调用按预期修改了 vector 变量。

注意

OffsetBy 方法使我们的不可变 Vector3D 结构体感觉有些可变。这个特性还处于早期阶段,但我怀疑我会在使用初始 in 参数的扩展方法上比使用 ref 参数上感到更加自在。

具有初始 in 参数的扩展方法可以在可读写变量上调用(正如你在调用 vector.Magnitude() 时所看到的),但具有初始 ref 参数的扩展方法不能在只读变量上调用。例如,如果你为 vector 创建了一个只读别名,你就不能调用 OffsetBy

ref readonly var alias = ref vector;
alias.OffsetBy(offset);                   *1*
  • 1 错误:尝试将只读变量用作 ref

与常规扩展方法不同,对于初始的 refin 参数,对扩展类型(第一个参数的类型)存在一些限制。

13.5.2. 对 refin 扩展方法的限制

正常的扩展方法可以声明为扩展任何类型。它们可以使用常规类型或带有或不带有约束的类型参数:

static void Method(this string target)
static void Method(this IDisposable target)
static void Method<T>(this T target)
static void Method<T>(this T target) where T : IComparable<T>
static void Method<T>(this T target) where T : struct

相反,refin扩展方法始终必须扩展值类型。在in扩展方法的情况下,该值类型也不能是类型参数。以下内容是有效的:

static void Method(this ref int target)
static void Method<T>(this ref T target) where T : struct
static void Method<T>(this ref T target) where T : struct, IComparable<T>
static void Method<T>(this ref int target, T other)
static void Method(this in int target)
static void Method(this in Guid target)
static void Method<T>(this in Guid target, T other)

但以下内容是无效的:

static void Method(this ref string target)        *1*
static void Method<T>(this ref T target)          *2*
    where T : IComparable<T>                      *2*
static void Method<T>(this in string target)      *3*
static void Method<T>(this in T target)           *4*
    where T : struct                              *4*
  • 1 ref 参数的引用类型目标*

  • 2 没有结构约束的 ref 参数的类型参数目标*

  • 3 in 参数的引用类型目标*

  • 4 in 参数的类型参数目标*

注意inref之间的区别,其中ref参数只要具有struct约束就可以是类型参数。in扩展方法仍然可以是泛型的(如最后一个有效示例所示),但扩展类型不能是类型参数。目前,没有约束可以要求Treadonly struct,这对于泛型in参数是有用的。这可能在 C#的将来版本中发生变化。

您可能会想知道为什么扩展类型必须约束为值类型。有两个主要原因:

  • 这个功能旨在避免值类型的昂贵复制,因此引用类型没有好处。

  • 如果ref参数可以是引用类型,它可以在方法内部设置为 null 引用。这将破坏 C#开发者和工具目前可以做出的一个假设:调用x.Method()(其中x是某种引用类型的变量)永远不会使x为 null。

我不期望会非常频繁地使用refin扩展方法,但它们确实为语言提供了一致性。

本章剩余部分的功能与您迄今为止所考察的功能有所不同。为了回顾,到目前为止,您已经看过以下内容:

  • ref 局部变量

  • ref 返回值

  • ref 局部变量和 ref 返回值的只读版本

  • in参数:ref参数的只读版本

  • 只读结构体,允许in参数和只读 ref 局部变量和返回值以避免复制

  • refin参数为目标扩展方法

如果您从 ref 参数开始,想知道如何进一步扩展这个概念,您可能会想到这个列表中的类似内容。我们现在将转向类似 ref 的结构体,这些结构与上述所有内容相关,但感觉像是一种全新的类型。

13.6. 类似 ref 的结构体(C# 7.2)

C# 7.2 引入了类似 ref的结构体的概念:这种结构体仅存在于栈上。就像自定义任务类型一样,您可能永远不需要声明自己的类似 ref 结构体,但我预计在未来几年内,针对最新框架编写的 C#代码将大量使用框架内构建的类似 ref 结构体。

首先,你将查看类似引用结构的规则,然后了解它们的使用方式和框架对这些规则的支持。我应该指出,这些都是规则的简化形式;有关详细信息,请参阅语言规范。我怀疑相对较少的开发者需要确切知道编译器如何强制执行类似引用结构的栈安全性,但了解它试图实现的原则是很重要的:

类似引用的结构值必须始终保持在栈上,总是如此。

让我们从创建一个类似引用的结构体开始。声明与普通结构体声明相同,只是增加了ref修饰符:

public ref struct RefLikeStruct
{
          *1*
}
  • 1 结构成员作为正常

13.6.1. 类似引用结构的规则

而不是说明你可以用它做什么,这里有一些你不能用RefLikeStruct做的事情以及简要的解释:

  • 你不能将RefLikeStruct包含在任何不是类似引用结构的类型的字段中。即使是普通的结构体也可能会通过装箱或作为类的一个字段而轻易地结束在堆上。即使在另一个类似引用结构中,你也只能将RefLikeStruct用作实例字段的类型——永远不能用作静态字段。

  • 你不能装箱RefLikeStruct。装箱正是设计用来在堆上创建对象,这正是你不想看到的。

  • 你不能将RefLikeStruct用作任何泛型方法或类型的类型参数(无论是显式还是通过类型推断),包括用作泛型类似引用结构类型类型参数。泛型代码可以使用泛型类型参数以各种方式在堆上放置值,例如创建List<T>

  • 你不能将RefLikeStruct[]或任何类似的数组类型用作typeof运算符的操作数。

  • 类型为RefLikeStruct的局部变量不能在任何编译器可能需要将其捕获到特殊生成的类型中的堆上的地方使用。这包括以下内容:

    • 异步方法,尽管这可能会被放宽,以便变量可以在 await 表达式之间声明和使用,只要它从未跨越 await 表达式(在 await 之前声明,在 await 之后使用)。异步方法的参数不能是类似引用结构类型。

    • 迭代器块已经遵循了“只在两个 yield 表达式之间使用RefLikeStruct是允许的”规则。迭代器块的参数不能是类似引用的结构类型。

    • 任何被局部方法、LINQ 查询表达式、匿名方法或 lambda 表达式捕获的局部变量。

此外,复杂的规则^([3])说明了类似引用类型的局部引用变量如何使用。我建议在这里信任编译器;如果你的代码因为类似引用结构而无法编译,你很可能是试图在它不再存在于栈上的位置使其可用。有了这组规则来保持值在栈上,你最终可以查看使用类似引用结构的“宠儿”:Span<T>

³

翻译:我发现它们很难理解。我理解了其一般用途,但防止发生不良事件所需的复杂性超出了我对逐行审查规则的兴趣水平。

13.6.2. Span和 stackalloc

在.NET 中访问内存块有几种方式。数组是最常见的,但ArraySegment<T>和指针也被使用。直接使用数组的一个大缺点是数组实际上拥有所有其内存;数组永远不会是更大内存块的一部分。这听起来并不太糟糕,直到你想到你看到过多少这样的方法签名:

int ReadData(byte[] buffer, int offset, int length)

“缓冲区、偏移量、长度”这一组参数在.NET 中到处都是,它实际上是一个代码异味,表明我们没有放置正确的抽象。Span<T>和相关类型旨在解决这个问题。

注意

Span<T>的一些用法只需添加对System.Memory NuGet 包的引用即可工作。其他则需要框架支持。本节中展示的代码是针对.NET Core 2.1 构建的。一些列表也将针对框架的早期版本构建。

Span<T>是一个类似于 ref 的结构体,它提供了对内存部分的读写、索引访问,就像数组一样,但没有拥有该内存的概念。span 总是从其他东西(可能是指针,可能是数组,甚至是直接在栈上创建的数据)创建而来。当你使用Span<T>时,你不需要关心内存的分配位置。span 可以被切片:你可以创建一个 span 作为另一个 span 的子部分,而不需要复制任何数据。在新版本的框架中,JIT 编译器将了解Span<T>并以高度优化的方式处理它。

Span<T>的 ref-like 特性听起来无关紧要,但它有两个显著的好处:

  • 它允许 span 引用具有紧密控制生命周期的内存,因为 span 不能从栈中逃逸。分配内存的代码可以将 span 传递给其他代码,然后在之后有信心地释放内存,因为不会有任何 span 引用现在已分配的内存。

  • 它允许在 span 中自定义一次性初始化数据,而不需要任何复制,也不存在代码在之后改变数据的风险。

让我们通过编写一个生成随机字符串的方法来简单演示这两个点。尽管Guid.NewGuid经常可以用于此目的,但有时你可能想使用不同的字符集和长度来采取更定制的方法。以下列表显示了你可能过去使用的传统代码。

列表 13.20. 使用char[]生成随机字符串
static string Generate(string alphabet, Random random, int length)
{
    char[] chars = new char[length];
    for (int i = 0; i < length; i++)
    {
        chars[i] = alphabet[random.Next(alphabet.Length)];
    }
    return new string(chars);
}

这里有一个调用方法生成 10 个小写字母字符串的例子:

string alphabet = "abcdefghijklmnopqrstuvwxyz";
Random random = new Random();
Console.WriteLine(Generate(alphabet, random, 10));

列表 13.20 执行了两次堆分配:一次用于字符数组,一次用于字符串。在构建字符串时,需要将数据从一处复制到另一处。如果你知道你将始终生成合理的小字符串,并且你处于可以使用不安全代码的位置,你可以稍微改进这一点。在这种情况下,你可以使用 stackalloc,如下面的列表所示。

列表 13.21. 使用 stackalloc 和指针生成随机字符串
unsafe static string Generate(string alphabet, Random random, int length)
{
    char* chars = stackalloc char[length];
    for (int i = 0; i < length; i++)
    {
        chars[i] = alphabet[random.Next(alphabet.Length)];
    }
    return new string(chars);
}

这只进行了一次堆分配:字符串。临时缓冲区是堆栈分配的,但你需要使用 unsafe 修饰符,因为你正在使用指针。不安全代码让我感到不舒服;尽管我对这段代码相当有信心,但我不想用指针做任何更复杂的事情。从堆栈分配的缓冲区到字符串的复制仍然存在。

好消息是 Span<T> 也支持 stackalloc,无需任何 unsafe 修饰符,如下面的列表所示。你不需要 unsafe 修饰符,因为你依赖于类似 ref 结构的规则来确保一切安全。

列表 13.22. 使用 stackallocSpan<char> 生成随机字符串
static string Generate(string alphabet, Random random, int length)
{
    Span<char> chars = stackalloc char[length];
    for (int i = 0; i < length; i++)
    {
        chars[i] = alphabet[random.Next(alphabet.Length)];
    }
    return new string(chars);
}

这让我更有信心,但效率并没有提高;你仍然以感觉重复的方式复制数据。你可以做得更好。你所需要的就是 System.String 中的这个工厂方法:

public static string Create<TState>(
    int length, TState state, SpanAction<char, TState> action)

这使用了 SpanAction<T, TArg>,这是一个具有以下签名的新的委托:

delegate void SpanAction<T, in TArg>(Span<T> span, TArg arg);

这两个签名一开始可能看起来有点奇怪,所以让我们来分析一下 Create 方法的实现。它执行以下步骤:

  1. 分配具有所需长度的字符串

  2. 创建一个指向字符串内部内存的范围

  3. 调用 action 委托,传入方法所接收的任何状态和范围

  4. 返回字符串

首先要注意的是,我们的委托能够写入字符串的内容。这听起来好像违背了你所知道的所有关于字符串不可变性的知识,但 Create 方法在这里是掌控者。是的,你可以将任何内容写入字符串,就像你可以创建一个包含任何内容的新的字符串一样。但是,当字符串返回时,内容实际上已经嵌入到字符串中。你不能通过保留传递给委托的 Span<char> 来试图作弊,因为编译器确保它不会逃离堆栈。

这仍然留下了关于状态的奇怪部分。为什么你需要传入随后传递回我们的委托的状态?最容易的方式是给你一个例子;下面的列表使用 Create 方法来实现我们的随机字符串生成器。

列表 13.23. 使用 string.Create 生成随机字符串
static string Generate(string alphabet, Random random, int length) =>
    string.Create(length, (alphabet, random), (span, state) =>
    {
        var alphabet2 = state.alphabet;
        var random2 = state.random;
        for (int i = 0; i < span.Length; i++)

        {
            span[i] = alphabet2[random2.Next(alphabet2.Length)];
        }
    });

首先,看起来好像有很多无意义的重复。string.Create的第二个参数是(alphabet, random),这把alphabetrandom参数放入一个元组中,作为状态。然后你再次在 lambda 表达式中从元组中解包这些值:

var alphabet2 = state.alphabet;
var random2 = state.random;

为什么不在 lambda 表达式中捕获参数呢?在 lambda 表达式中使用alphabetrandom可以编译并正确运行,那么为什么还要使用额外的state参数呢?

记住使用 span 的目的:你试图减少堆分配以及复制。当一个 lambda 表达式捕获一个参数或局部变量时,它必须创建一个生成类的实例,以便委托可以访问这些变量。在列表 13.23 中的 lambda 表达式不需要捕获任何东西,因此编译器可以生成一个静态方法并缓存一个单独的委托实例,每次调用Generate时都使用。所有状态都是通过string.Create的参数传递的,因为 C# 7 的元组是值类型,所以没有为该状态分配。

到目前为止,你的简单字符串生成方法已经达到了极限:它只需要一次堆分配,没有额外的数据复制。你的代码直接写入字符串数据。

这只是Span<T>使可能的一种类型的事例。相关的类型也存在;ReadOnlySpan<T>Memory<T>ReadOnlyMemory<T>是最重要的。对这些类型的全面深入探讨超出了本书的范围。

重要的是,我们对Generate方法的优化根本不需要改变它的签名。这是一个纯实现变更,与任何其他东西都隔离,这正是让我兴奋的原因。虽然在整个代码库中通过引用传递大型结构体可以帮助避免过度复制,但这是一种侵入式优化。我更喜欢可以分阶段、有针对性地进行的优化。

正如字符串通过额外的方法来利用 span 一样,许多其他类型也将如此。我们现在认为,任何基于 I/O 的操作都将在框架中有一个异步选项可用,我预计随着时间的推移,span 也将如此;在任何它们有用的地方,它们都将可用。我也预计第三方库将提供接受 span 的重载。

带初始化器的stackalloc(C# 7.3)

当我们谈论堆分配时,C# 7.3 增加了一个额外的转折:初始化器。与之前的版本不同,你只能使用stackalloc来分配你想要的大小,而 C# 7.3 允许你指定分配空间的内 容。这对于指针和 span 都有效:

Span<int> span = stackalloc int[] { 1, 2, 3 };
int* pointer = stackalloc int[] { 4, 5, 6 };

我认为这并没有比手动分配和填充空间有显著的效率提升,但代码确实更容易阅读。

基于模式的固定语句(C# 7.3)

作为提醒,fixed 语句用于获取内存的指针,暂时阻止垃圾回收器移动该数据。在 C# 7.3 之前,这只能与数组、字符串以及获取变量的地址一起使用。C# 7.3 允许它与任何具有可访问方法 GetPinnableReference 并返回指向非托管类型的引用的类型一起使用。例如,如果你有一个返回 ref int 的方法,你可以在类似这样的 fixed 语句中使用它:

fixed (int* ptr = value)      *1*
{
                              *2*
}
  • 1 调用 value.GetPinnableReference

  • 2 使用指针的代码

这不是大多数开发者通常会自己实现的事情,即使在那些经常使用不安全代码的小比例开发者中也是如此。正如你所预期的,你最可能使用这个功能的是 Span<T>ReadOnlySpan<T>,这允许它们与已经使用指针的代码进行交互。

13.6.3. ref-like 结构的 IL 表示

ref-like 结构被装饰了一个 [IsRefLikeAttribute] 属性,这个属性同样来自 System.Runtime.CompilerServices 命名空间。如果你针对的框架版本没有提供该属性,它将在你的程序集中生成。

in 参数不同,编译器不会使用 modreq 修饰符来要求任何消耗该类型的工具了解它;相反,它还会向该类型添加一个带有固定信息的 [ObsoleteAttribute]。任何理解 [IsRefLikeAttribute] 的编译器都可以忽略 [ObsoleteAttribute],如果它有正确的文本。如果类型作者想要使类型过时,他们只需像平常一样使用 [ObsoleteAttribute],编译器就会将其视为任何其他过时类型。

摘要

  • C# 7 在语言的许多方面增加了对按引用语义的支持。

  • C# 7.0 仅包含了一些基本功能;请使用 C# 7.3 以获取完整的功能范围。

  • ref 相关功能的主要目的是性能。如果你不是在编写性能关键代码,你可能不需要使用这些功能中的许多。

  • ref-like 结构允许在框架中引入新的抽象,从 Span<T> 开始。这些抽象不仅适用于高性能场景;随着时间的推移,它们可能会影响大量 .NET 开发者。

第十四章. C# 7 的简洁代码

本章涵盖

  • 在方法内声明方法

  • 通过使用 out 参数简化调用

  • 更易于阅读的数字字面量

  • throw 用作表达式

  • 使用默认字面量

C# 7 带来了大量改变我们编写代码方式的功能:元组、解构和模式。它带来了复杂但有效的功能,这些功能直接针对高性能场景。它还带来了一组小功能,这些功能只是让生活更加愉快。本章没有哪个功能是震撼性的;每个都带来了一点点改变,所有这些功能的组合可以导致代码简洁、清晰。

14.1. 局部方法

如果这不是“C# 深入”,这一节确实会很简短;你可以在方法内部编写方法。当然,这不仅仅是那样,但让我们从一个简单的例子开始。以下列表显示了一个在常规 Main 方法内的简单局部方法。局部方法打印并增加在 Main 内声明的局部变量,这证明了变量捕获与局部方法一起工作。

列表 14.1. 访问局部变量的简单局部方法
static void Main()
{
    int x = 10;                                  *1*
    PrintAndIncrementX();                        *2*
    PrintAndIncrementX();                        *2*
    Console.WriteLine($"After calls, x = {x}");

    void PrintAndIncrementX() *3*
    {                                            *3*
        Console.WriteLine($"x = {x}");           *3*
        x++;                                     *3*
    }                                            *3*
}
  • 1 声明在方法中使用中的局部变量

  • 2 调用局部方法两次

  • 3 局部方法

当你第一次看到它时,这看起来有点奇怪,但很快你就会习惯。局部方法可以出现在你拥有语句块的地方:方法、构造函数、属性、索引器、事件访问器、终结器,甚至匿名函数或嵌套在其他局部方法内。

局部方法声明类似于正常的方法声明,但有以下限制:

  • 它不能有任何访问修饰符(如 public 等)。

  • 它不能有 externvirtualnewoverridestaticabstract 修饰符。

  • 它不能有任何属性(如 [MethodImpl])应用于它。

  • 它不能与同一父级内的另一个局部方法有相同的名称;没有方法可以重载局部方法。

另一方面,局部方法在其他方面表现得像标准方法,例如以下情况:

  • 它可以是 void 或返回值。

  • 它可以有 async 修饰符。

  • 它可以有 unsafe 修饰符。

  • 它可以通过迭代器块实现。

  • 它可以有参数,包括可选的。

  • 它可以是泛型的。

  • 它可以引用任何封装的类型参数。

  • 它可以是方法组转换为委托类型的目标。

如列表 14.1 所示,在方法使用后声明方法是完全可以的。局部方法可以调用自身或作用域内的其他局部方法。然而,位置仍然很重要,这主要是指局部方法如何引用捕获的变量:在封装代码中声明的局部变量,但在局部方法中使用。

事实上,关于局部方法的复杂性问题,无论是语言规则还是实现,都围绕着它们读取和写入捕获变量的能力。让我们从讨论语言强加的规则开始。

14.1.1. 局部方法中的变量访问

你已经看到,封装块中的局部变量可以被读取和写入,但这比这更微妙。这里有很多小的规则,但你不需要担心彻底学习它们。大多数时候你甚至不会注意到它们,如果编译器对预期有效的代码提出抱怨,你可以参考这一节。

局部方法只能捕获作用域内的变量。

你不能在变量的作用域外引用局部变量,这通常是指它声明的代码块。例如,假设你希望你的局部方法使用在循环中声明的迭代变量;局部方法本身也必须在循环中声明。作为一个简单的例子,这是无效的:

static void Invalid()
{
    for (int i = 0; i < 10; i++)
    {
        PrintI();
    }

    void PrintI() => Console.WriteLine(i);      *1*
}
  • 1 无法访问 i;它不在作用域内。

但在循环内的局部方法中,这是有效的:^([1])

¹

读起来可能有点奇怪,但它是有效的。

static void Valid()
{
    for (int i = 0; i < 10; i++)
    {
        PrintI();

        void PrintI() => Console.WriteLine(i);      *1*
    }
}
  • 1 在循环内声明的局部方法;i 在作用域内。
局部方法必须在捕获任何变量的声明之后声明

就像在常规代码中你不能在变量声明之前使用变量一样,你也不能在局部方法中在声明之后使用捕获的变量。这个规则更多的是为了保持一致性,而不是出于必要性;例如,指定语言要求任何对方法的调用都在变量声明之后进行是可行的,但要求所有访问都在声明之后进行要简单得多。这里还有一个无效代码的简单例子:

static void Invalid()
{
    void PrintI() => Console.WriteLine(i);        *1*
    int i = 10;
    PrintI();
}
  • 1 CS0841: 在声明之前不能使用局部变量 i

只需将局部方法声明移动到变量声明之后(无论是在 PrintI() 调用之前还是之后)就可以修复错误。

局部方法不能捕获封装方法的 ref 参数

就像匿名函数一样,局部方法不允许使用封装方法的引用参数。例如,这是无效的:

static void Invalid(ref int p)
{
    PrintAndIncrementP();
    void PrintAndIncrementP() =>
        Console.WriteLine(p++);      *1*
}
  • 1 无效访问引用参数

对于匿名函数禁止此操作的原因是创建的委托可能会比捕获的变量存在的时间更长。在大多数情况下,这个原因不适用于局部方法,但正如你稍后将会看到的,局部方法也可能出现同样的问题。在大多数情况下,你可以通过在局部方法中声明一个额外的参数并通过引用再次传递引用参数来解决这个问题:

static void Valid(ref int p)
{
    PrintAndIncrement(ref p);
    void PrintAndIncrement(ref int x) => Console.WriteLine(x++);

}

如果你不需要在局部方法中修改参数,你可以将其改为值参数。

作为这个限制的推论(再次,与匿名函数的限制相呼应),在结构体内部声明的局部方法不能访问 this。想象一下,this 是每个实例方法参数列表开头的隐式额外参数。对于类方法,它是一个值参数;对于结构体方法,它是一个引用参数。因此,你可以在类中的局部方法中捕获 this,但不能在结构体中。对于其他引用参数,同样的解决方案也适用。

注意

我在书中提供的源代码示例中提供了 LocalMethodUsingThisInStruct.cs。

局部方法与确定赋值交互

C#中明确赋值的规则很复杂,局部方法使它们更加复杂。最简单的方式来思考它就是,方法在任何被调用的地方都被内联。这会影响赋值,有两个方面的影响。

首先,如果一个读取捕获变量的方法在它被明确赋值之前被调用,这将导致编译时错误。以下是一个尝试在两个地方打印捕获变量值的示例:一次是在它被赋值之前,一次是在之后:

static void AttemptToReadNotDefinitelyAssignedVariable()
{
    int i;
    void PrintI() => Console.WriteLine(i);
    PrintI();                                 *1*
    i = 10;
    PrintI();                                 *2*
}
  • 1 CS0165: 使用未分配的局部变量‘i’

  • 2 没有错误:在这里 i 被明确赋值。

注意,这里导致错误的是调用PrintI的位置;方法声明的位置本身是没问题的。如果你在调用PrintI()之前将i的赋值移动到任何位置,那也是可以的,即使它仍然在PrintI()声明之后。

其次,如果局部方法在所有可能的执行流程中都向捕获变量写入,那么在调用该方法的任何地方,变量将在方法调用的末尾被明确赋值。以下是一个在局部方法中分配值但然后在包含方法中读取它的示例:

static void DefinitelyAssignInMethod()
{
    int i;
    AssignI();                   *1*
    Console.WriteLine(i);        *2*
    void AssignI() => i = 10;    *3*
}
  • 1 调用方法使 i 被明确赋值。

  • 2 因此打印出来是没问题的。

  • 3 方法执行了赋值。

关于局部方法和变量,还有一些最终要说明的点,但这次讨论的变量不是捕获变量,而是字段。

局部方法不能分配只读字段

只读字段只能在字段初始化器或构造函数中分配值。这个规则在局部方法中不会改变,但它变得更加严格:即使一个局部方法是在构造函数中声明的,它也不算作在构造函数内部进行字段初始化。以下代码是无效的:

class Demo
{
    private readonly int value;

    public Demo()
    {
        AssignValue();
        void AssignValue()
        {
            value = 10;      *1*
        }
    }
}
  • 1 无效地分配到只读字段

这种限制可能不会成为一个重大问题,但值得注意。这源于 CLR 不需要改变以支持局部方法。它们只是编译器转换。这让我们考虑编译器是如何实现局部方法的,特别是关于捕获变量的方面。

14.1.2. 局部方法实现

在 CLR 级别上不存在局部方法.^([2]) C#编译器通过执行所需的任何转换,将局部方法转换为常规方法,以使最终代码的行为符合语言规则。本节提供了 Roslyn(Microsoft C#编译器)实现的转换示例,并重点介绍了如何处理捕获的变量,因为这是转换中最复杂的部分。

²

如果 C#编译器要针对一个存在局部方法的运行环境,本节中的所有信息可能对该编译器都无关紧要。

实现细节:这里没有保证

这一部分实际上是在讲述 Roslyn 7.0 版本的 C# 如何实现局部方法。这种实现可能会在 Roslyn 的未来版本中发生变化,其他 C# 编译器可能使用不同的实现。这也意味着这里有很多细节,你可能并不感兴趣。

实现确实有性能影响,可能会影响你在性能敏感的代码中使用局部方法的舒适度。但与所有性能问题一样,你应该更多地基于仔细的测量而不是理论来做出决定。

局部方法在捕获周围代码中的局部变量方面类似于匿名函数。但在实现上的显著差异使得局部方法在许多情况下更加高效。这种差异的根源在于涉及的局部变量的生命周期。如果一个匿名函数被转换为委托实例,那么这个委托可以在方法返回很长时间后调用,因此编译器必须进行一些技巧,将捕获的变量提升到类中,并使委托引用该类中的方法。

与局部方法相比:在大多数情况下,局部方法只能在封装方法的调用期间调用;你不必担心在调用完成后它引用捕获的变量。这允许实现一个更高效、基于堆栈的实现,没有堆分配。让我们从一个简单的局部方法开始,该局部方法通过作为局部方法参数指定的数量来增加捕获的变量。

列表 14.2. 修改局部变量的局部方法
static void Main()
{
    int i = 0;
    AddToI(5);
    AddToI(10);
    Console.WriteLine(i);
    void AddToI(int amount) => i += amount;
}

Roslyn 对这个方法做了什么?它创建了一个具有公共字段的私有可变结构体来表示同一作用域中所有被任何局部方法捕获的局部变量。在这种情况下,那就是 i 变量。它在该结构体的 Main 方法中创建一个局部变量,并将变量通过引用传递给从 AddToI 创建的常规方法,当然还有声明的 amount 参数。你最终得到如下所示的内容。

列表 14.3. Roslyn 对 列表 14.2 的处理
private struct MainLocals                               *1*
{
    public int i;
}

static void Main()
{
    MainLocals locals = new MainLocals();               *2*
    locals.i = 0;                                       *2*
    AddToI(5, ref locals);                              *3*
    AddToI(10, ref locals);                             *3*
    Console.WriteLine(locals.i);
}

static void AddToI(int amount, ref MainLocals locals)   *4*
{                                                       *4*
    locals.i += amount;                                 *4*
}                                                       *4*
  • 1 生成可变结构体以存储 Main 中的局部变量

  • 2 在方法内创建和使用结构体值

  • 3 将结构体通过引用传递给生成的局部方法

  • 4 生成方法来表示原始局部方法

如同往常,编译器为方法和结构体生成难以言喻的名字。注意,在这个例子中,生成的局部方法是静态的。这是当局部方法最初包含在静态成员中,或者当它包含在实例成员中但局部方法没有捕获 this(显式或隐式地通过在局部方法中使用实例成员)时的情况。

生成此结构体的重要之处在于,从性能角度来看,这种转换几乎是免费的:所有原本应该在栈上的局部变量现在仍然在栈上;它们只是被组合在一个结构体中,以便可以将它们通过引用传递给生成的函数。通过引用传递结构体有两个好处:

  • 这允许局部方法修改局部变量。

  • 无论捕获了多少局部变量,调用局部方法都是低成本的。(与之相比,如果通过值传递它们,则意味着每个捕获的局部变量都需要创建第二个副本。)

所有这些操作都不会在堆上生成任何垃圾。太好了!现在让我们使事情变得稍微复杂一些。

在多个作用域中捕获变量

在匿名函数中,如果从多个作用域捕获局部变量,则会生成多个类,每个类都有一个字段,表示内部作用域,该字段包含对表示外部作用域的类的实例的引用。由于涉及复制,这不会适用于你刚才看到的局部方法的结构体方法。相反,编译器为每个包含捕获变量的作用域生成一个结构体,并为每个作用域使用一个单独的参数。以下列表故意创建了两个作用域,这样我们就可以看到编译器是如何处理的。

列表 14.4. 从多个作用域捕获变量
static void Main()
{
    DateTime now = DateTime.UtcNow;
    int hour = now.Hour;
    if (hour > 5)
    {
        int minute = now.Minute;
        PrintValues();

        void PrintValues() =>
            Console.WriteLine($"hour = {hour}; minute = {minute}");
    }
}

我使用了一个简单的 if 语句来引入一个新的作用域,而不是 forforeach 循环,因为这使得翻译更容易合理准确地表示。以下列表显示了编译器如何将局部方法转换为常规方法。

列表 14.5. Roslyn 对 列表 14.4 的处理
struct OuterScope                                 *1*
{                                                 *1*
    public int hour;                              *1*
}                                                 *1*
struct InnerScope                                 *2*
{                                                 *2*
    public int minute;                            *2*
}                                                 *2*

static void Main()
{
    DateTime now = DateTime.UtcNow;               *3*
    OuterScope outer = new OuterScope();          *4*
    outer.hour = now.Hour;                        *4*
    if (outer.hour > 5)                           *4*
    {
        InnerScope inner = new InnerScope();      *5*
        inner.minute = now.Minute;                *5*
        PrintValues(ref outer, ref inner);        *6*
    }
}

static void PrintValues(                          *7*
    ref OuterScope outer, ref InnerScope inner)   *7*
{
    Console.WriteLine($"hour = {outer.hour}; minute = {inner.minute}");
}
  • 1 为外部作用域生成的结构体

  • 2 为内部作用域生成的结构体

  • 3 未捕获的局部变量

  • 4 为外部作用域变量 hour 创建并使用结构体

  • 5 为内部作用域变量 minute 创建并使用结构体

  • 6 通过引用将两个结构体传递给生成的方法

  • 7 生成的表示原始局部方法的函数

除了演示如何处理多个作用域之外,此列表还显示未捕获的局部变量不包括在生成的结构体中。

到目前为止,我们已查看的情况是,局部方法只能在包含方法执行时执行,这使得局部变量以这种方式被捕获是安全的。在我的经验中,这涵盖了我想使用局部方法的绝大多数情况。尽管如此,偶尔也会出现一些例外情况。

监狱越狱!本地方法如何逃离其包含的代码

局部方法以四种方式表现得像常规方法,这可以阻止编译器执行我们之前讨论的“将所有内容保持在栈上”的优化:

  • 它们可以是异步的,因此一个几乎立即返回任务的调用不一定会完成逻辑操作。

  • 它们可以用迭代器实现,因此创建序列的调用在请求序列中的下一个值时需要继续执行方法。

  • 它们可以从匿名函数中调用,而这些匿名函数又可以(作为委托)在原始方法完成很久之后被调用。

  • 它们可以是方法组转换的目标,再次创建可以超出原始方法调用生命周期的委托。

下面的列表展示了最后一个要点的一个简单示例。一个局部 Count 方法在其封装的 CreateCounter 方法中捕获一个局部变量。Count 方法用于创建一个 Action 委托,然后在 CreateCounter 方法返回后调用该委托。

列表 14.6. 局部方法的方法组转换
static void Main()
{
    Action counter = CreateCounter();     
    counter();                                    *1*
    counter();                                    *1*
}

static Action CreateCounter()
{
    int count = 0;                                *2*
    return Count;                                 *3*
    void Count() => Console.WriteLine(count++);   *4*
}
  • 1 在 CreateCounter 完成后调用委托

  • 2 Count 捕获的局部变量

  • 3 将 Count 转换为 Action 委托的方法组转换

  • 4 局部方法

你不能再在栈上使用结构体作为 count。当委托被调用时,CreateCounter 的栈将不存在。但现在这非常像匿名函数;你本可以用 lambda 表达式来实现 CreateCounter

static Action CreateCounter()
{
    int count = 0;
    return () => Console.WriteLine(count++);     *1*
}
  • 1 使用 lambda 表达式实现的替代实现

这为你提供了关于编译器如何实现局部方法的线索:它可以为局部方法应用与 lambda 表达式类似的转换,如下面的列表所示。

列表 14.7. Roslyn 对 列表 14.6 的处理
static void Main()
{
    Action counter = CreateCounter();
    counter();
    counter();
}

static Action CreateCounter()
{
    CountHolder holder = new CountHolder();              *1*
    holder.count = 0;                                    *1*
    return holder.Count;                                 *2*
}

private class CountHolder                                *3*
{
    public int count;                                    *4*

    public void Count() => Console.WriteLine(count++);   *5*
}
  • 1 创建并初始化包含捕获变量的对象

  • 2 从持有者转换实例方法的方法组转换

  • 3 包含捕获变量和局部方法的私有类

  • 4 捕获的变量

  • 5 局部方法现在是生成类中的实例方法

如果局部方法在匿名函数中使用(如果是异步方法或迭代器(带有 yield 语句)),则会执行类似的转换。注重性能的人可能希望知道,异步方法和迭代器最终可能会生成多个对象;如果你正在努力防止分配,并且使用局部方法,你可能希望明确将这些参数传递给这些局部方法,而不是捕获局部变量。下一个部分将展示一个这样的例子。

当然,可能的场景集相当庞大;一个局部方法可能使用方法转换来调用另一个局部方法,或者你可以在异步方法中使用局部方法,等等。我当然不会试图在这里涵盖所有可能的案例。本节旨在给你一个很好的概念,了解编译器在处理捕获变量时可以使用的两种转换类型。要查看编译器对你的代码做了什么,请使用反编译器或 ildasm,同时记得禁用反编译器可能为你做的任何“优化”。(否则,它可能只会显示局部方法,这对您没有任何帮助。)现在你已经看到了你可以用局部方法做什么以及编译器如何处理它们,让我们考虑何时使用它们是合适的。

14.1.3. 使用指南

有两种主要模式可以用来发现局部方法可能适用的场景:

  • 你在方法中重复多次相同的逻辑。

  • 你有一个仅从另一个方法中使用的私有方法。

第二种情况是第一种情况的特殊情况,其中你已经花时间重构了通用代码。但是,当有足够的局部状态使重构变得丑陋时,第一种情况可能会发生。局部方法可以通过捕获局部变量的能力使提取变得更具吸引力。

当将现有方法重构为局部方法时,我建议有意识地采取两阶段的方法。首先,将单次使用的方法移动到使用它的代码中,而不改变其签名.^([3]) 第二,查看方法参数:所有调用该方法的是否都使用相同的局部变量作为参数?如果是,那么这些就是使用捕获变量代替参数的好候选,从而从局部方法中移除参数。有时你甚至可能完全移除参数。

³

有时这需要更改签名中的类型参数。通常,如果你有一个泛型方法调用另一个方法,当你将第二个方法移动到第一个方法中时,它可以直接使用第一个方法的类型参数。列表 14.9 展示了这一点。

根据参数的数量和大小,这一步甚至可能对性能产生影响。如果你之前是通过值传递大值类型,那么这些类型在每次调用时都会被复制。使用捕获变量代替可以消除这种复制,如果方法被频繁调用,这可能非常显著。

关于局部方法的重要点是,它们清楚地表明它们是方法的一个实现细节,而不是类型的一个实现细节。如果你有一个作为独立操作有意义的私有方法,但碰巧目前只在一个地方使用,你最好让它保持原样。当私有方法紧密绑定到单个操作,并且你无法轻易想象任何其他使用它的环境时,这种收益——从逻辑类型结构的角度来看——要大得多。

迭代器/异步参数验证和局部方法优化

这的一个常见例子是当你有迭代器或异步方法,并希望立即执行参数验证。例如,列表 14.8 提供了 LINQ to Objects 中Select的一个重载的示例实现。参数验证不在迭代器块中,因此它在方法被调用时立即执行,而foreach循环则根本不会执行,直到调用者开始遍历返回的序列。

列表 14.8. 不使用局部方法实现Select
public static IEnumerable<TResult> Select<TSource, TResult>(
    this IEnumerable<TSource> source,
    Func<TSource, TResult> selector)
{
    Preconditions.CheckNotNull(source, nameof(source));     *1*
    Preconditions.CheckNotNull(                             *1*
        selector, nameof(selector));                        *1*
    return SelectImpl(source, selector);                    *2*
}

private static IEnumerable<TResult> SelectImpl<TSource, TResult>(
    IEnumerable<TSource> source,
    Func<TSource, TResult> selector)
{
    foreach (TSource item in source)                        *3*
    {                                                       *3*
        yield return selector(item);                        *3*
    }                                                       *3*
}
  • 1 主动检查参数

  • 2 委派给实现

  • 3 实现延迟执行

现在,有了局部方法可用,你可以将实现移动到Select方法中,如下所示。

列表 14.9. 使用局部方法实现Select
public static IEnumerable<TResult> Select<TSource, TResult>(
    this IEnumerable<TSource> source,
    Func<TSource, TResult> selector)
{
    Preconditions.CheckNotNull(source, nameof(source));
    Preconditions.CheckNotNull(selector, nameof(selector));
    return SelectImpl(source, selector);

    IEnumerable<TResult> SelectImpl(
        IEnumerable<TSource> validatedSource,
        Func<TSource, TResult> validatedSelector)
    {
        foreach (TSource item in validatedSource)
        {
            yield return validatedSelector(item);
        }
    }
}

我突出显示了一个有趣的实现方面:你仍然将(现在已验证的)参数传递给局部方法。这不是必需的;你可以使局部方法无参数,并仅使用捕获的sourceselector变量,但这是一种性能调整——减少了所需的分配数量。这种性能差异重要吗?使用变量捕获的版本会显著提高可读性吗?这两个问题的答案都取决于上下文,并且可能相当主观。

读写建议

局部方法对我来说仍然很新,所以我对此有些谨慎。目前,我更倾向于保持代码原样,而不是重构为局部方法。特别是,我避免使用以下两个特性:

  • 即使你可以在循环或其他块的范围内声明局部方法,但我发现这很难阅读。我更喜欢只在可以立即在封装方法的底部声明局部方法时使用局部方法。我无法捕获在循环中声明的任何变量,但我对此可以接受。

  • 你可以在其他局部方法中声明局部方法,但这感觉像是一个我不愿意陷入的兔子洞。

当然,你的品味可能各不相同,但就像往常一样,我警告你,不要仅仅因为可以就使用新特性。(当然,为了实验而实验,但不要让新玩意儿诱使你牺牲可读性。)

好消息时间:本章的第一个特性是最大的一个。其余的特性要简单得多。

14.2. out 变量

在 C# 7 之前,out 参数的使用有些痛苦。out 参数需要在将其用作参数的参数之前已经声明一个变量。因为声明是独立的语句,这意味着在某些你想要单个表达式的地方——例如初始化一个变量——你必须重新组织你的代码以包含多个语句。

14.2.1. 为 out 参数的行内变量声明

C# 7 通过允许在方法调用本身内部声明新变量来消除这个痛点。作为一个简单的例子,考虑一个接受文本输入的方法,尝试使用 int.TryParse 将其解析为整数,然后返回解析成功的解析值作为可空整数(如果解析成功)或 null(如果未解析)。在 C# 6 中,这至少需要两个语句来实现:一个用于声明变量,另一个用于调用 int.TryParse 并将新声明的变量作为 out 参数传递:

static int? ParseInt32(string text)
{
    int value;
    return int.TryParse(text, out value) ? value : (int?) null;
}

在 C# 7 中,value 变量可以在方法调用本身内部声明,这意味着你可以使用表达式体实现方法:

static int? ParseInt32(string text) =>
    int.TryParse(text, out int value) ? value : (int?) null;

在几种方式上,out 变量参数的行为类似于由模式匹配引入的变量:

  • 如果你不在乎值,你可以使用单个下划线作为名称来创建一个丢弃项。

  • 你可以使用 var 来声明一个隐式类型的变量(类型是从参数的类型推断出来的)。

  • 你不能在表达式树中使用 out 变量参数。

  • 变量的作用域是周围的块。

  • 在 C# 7.3 之前,你无法在字段、属性或构造函数初始化器或查询表达式中使用 out 变量。你很快就会看到一个例子。

  • 如果(并且仅当)方法被明确调用时,变量将被明确赋值。

为了证明最后一点,考虑以下代码,它尝试解析两个字符串并将结果相加:

static int? ParseAndSum(string text1, string text2) =>
    int.TryParse(text1, out int value1) &&
    int.TryParse(text2, out int value2)
    ? value1 + value2 : (int?) null;

在条件运算符的第三个操作数中,value1 被明确赋值(所以如果你愿意,你可以返回它),但 value2 没有被明确赋值;如果第一次调用 int.TryParse 返回 false,由于 && 运算符的短路性质,你不会第二次调用 int.TryParse

14.2.2. C# 7.3 为 out 变量和模式变量取消的限制

正如我在第 12.5 节中提到的,模式变量不能用于初始化字段或属性,在构造初始化器(this(...)base(...))或查询表达式中。相同的限制也适用于 out 变量,直到 C# 7.3,那时所有这些限制都被取消。以下列表演示了这一点,并显示 out 变量的结果也可以在构造函数体内部使用。

列表 14.10. 在构造函数初始化器中使用out变量
class ParsedText
{
    public string Text { get; }
    public bool Valid { get; }

    protected ParsedText(string text, bool valid)
    {
        Text = text;
        Valid = valid;
    }
}

class ParsedInt32 : ParsedText
{
    public int? Value { get; }

    public ParsedInt32(string text)
        : base(text, int.TryParse(text, out int parseResult))  
    {
        Value = Valid ? parseResult : (int?) null;             
    }
}

虽然在 C# 7.3 之前的规定从未让我烦恼,但现在它们已经被移除,这真是个好事。在那些你需要使用模式或out变量进行初始化的罕见情况下,替代方案相对令人烦恼,通常需要为这个目的创建一个新的方法。

关于out变量参数就这么多。它们只是避免其他可能令人烦恼的变量声明语句的一个有用的简写。

14.3. 数值字面量的改进

在 C#的历史过程中,字面量并没有发生太大的变化。从 C# 1 到 C# 6,没有任何变化发生,直到引入了插值字符串字面量,但这并没有改变数字。在 C# 7 中,有两个特性针对数值字面量,都是为了提高可读性:二进制整数字面量和下划线分隔符。

14.3.1. 二进制整数字面量

与浮点字面量(对于floatdoubledecimal)不同,整数字面量始终有两个选项用于字面量的基数:你可以使用十进制(没有前缀)或十六进制(前缀为0x0X)。^([4]) C# 7 扩展了这一点到二进制字面量,它使用前缀0b0B。这对于实现具有特定位模式的特定值的协议特别有用。它根本不影响执行时的行为,但它可以使代码更容易阅读。例如,以下哪一行初始化了一个字节,其中最高位和最低三位被设置,其他位未被设置?

C#的设计者明智地避免了 Java 从 C 继承的可怕的八进制字面量。011 的值是多少?当然,是 9。

byte b1 = 135;
byte b2 = 0x83;
byte b3 = 0b10000111;

他们都这样做。但你可以很容易地在第三行中看出这一点,而其他两行则需要稍微长一点的时间来检查(至少对我来说是这样)。即使最后一行也花的时间比预期的要长,因为你仍然需要检查你是否有正确数量的总位数。如果有一种方法可以进一步澄清那就更好了。

14.3.2. 下划线分隔符

让我们通过改进之前的示例直接跳到下划线分隔符。如果你想指定字节的全部位并在二进制中这样做,发现你有两个四分位比数所有八个位要容易得多。以下是相同的代码,第四行使用下划线来分隔四分位:

byte b1 = 135;
byte b2 = 0x83;
byte b3 = 0b10000111;
byte b4 = 0b1000_0111;

真喜欢这个!我确实可以一目了然地检查它。不过,下划线分隔符不仅限于二进制字面量,甚至也不限于整数字面量。你可以在任何数值字面量中使用它们,并将它们(几乎)放在字面量中的任何位置。在十进制字面量中,你很可能会每三位使用一次,就像千位分隔符一样(至少在西方文化中是这样)。在十六进制字面量中,它们通常每两个、四个或八个数字最有用,用于在字面量中分隔 8 位、16 位或 32 位部分。例如:

int maxInt32 = 2_147_483_647;
decimal largeSalary = 123_456_789.12m;
ulong alternatingBytes = 0xff_00_ff_00_ff_00_ff_00;
ulong alternatingWords = 0xffff_0000_ffff_0000;
ulong alternatingDwords = 0xffffffff_00000000;

这种灵活性是有代价的:编译器不会检查你放置下划线是否合理。你甚至可以将多个下划线连在一起。以下是一些有效但不太合适的例子:

int wideFifteen = 1____________________5;
ulong notQuiteAlternatingWords = 0xffff_000_ffff_0000;

你还应该注意一些限制:

  • 你不能在字面量的开头放置下划线。

  • 你不能在字面量的末尾放置下划线(包括在后缀之前)。

  • 你不能在浮点字面量的点号前后直接放置下划线。

  • 在 C# 7.0 和 7.1 中,你不能在整数字面量的基数指定符(0x0b)之后放置下划线。

C# 7.2 中已经取消了最后一个限制。虽然可读性是主观的,但我确实更喜欢在其他地方使用下划线时,在基数指定符之后使用下划线,如下面的例子所示:

  • 0b_1000_01110b1000_0111

  • 0x_ffff_00000xffff_0000

就这些!这是一个简单而实用的功能,几乎没有细微差别。下一个功能同样简单直接,允许在某些需要条件抛出异常的情况下简化操作。

14.4. 抛出表达式

早期版本的 C# 总是包含 throw 语句,但你不能将 throw 用作表达式。据推测,原因是你可能不想这样做,因为它总是会抛出异常。结果证明,随着更多需要表达式的语言特性的添加,这种分类变得越来越令人烦恼。在 C# 7 中,你可以在有限的环境中使用 throw 表达式

  • 作为 lambda 表达式的主体

  • 作为表达式主体成员的主体

  • 作为 ?? 运算符的第二个操作数

  • 作为条件运算符 ?: 的第二个或第三个操作数(但不是在同一表达式中同时使用)

所有这些都是有效的:

public void UnimplementedMethod() =>                     *1*
    throw new NotImplementedException();                 *1*

public void TestPredicateNeverCalledOnEmptySequence()
{
    int count = new string[0]
        .Count(x => throw new Exception("Bang!"));       *2*
    Assert.AreEqual(0, count);
}

public static T CheckNotNull<T>(T value, string paramName) where T : class
    => value ??                                          *3*
    throw new ArgumentNullException(paramName);          *3*

public static Name =>
    initialized                                          *4*
    ? data["name"]                                       *4*
    : throw new Exception("...");                        *4*
  • 1 表达式主体方法

  • 2 Lambda 表达式

  • 3 在表达式主体方法中使用 ?? 运算符

  • 4 在表达式主体属性中使用 ?: 运算符

虽然你可以在任何地方使用 throw 表达式,但这并不合理。例如,你不能在赋值或方法参数中无条件地使用它们:

int invalid = throw new Exception("This would make no sense");
Console.WriteLine(throw new Exception("Nor would this"));

C# 团队在我们认为有用的地方给予了我们灵活性(通常,这允许你以前相同的方式表达概念,但更加简洁)但阻止了我们使用在上下文中荒谬的 throw 表达式。

我们下一个特性继续了允许我们以更简洁的方式表达相同逻辑的主题,通过使用默认字面量简化 default 运算符。

14.5. 默认字面量(C# 7.1)

default(T) 运算符是在 C# 2.0 中引入的,主要用于泛型类型。例如,要从列表中检索值,如果索引在范围内或为类型的默认值,你可以编写如下方法:

static T GetValueOrDefault<T>(IList<T> list, int index)
{
    return index >= 0 && index < list.Count ? list[index] : default(T);
}

default 操作符的结果与你在字段未初始化时观察到的类型的默认值相同:对于引用类型是空引用,对于所有数值类型是相应类型的零,对于 char 是 U+0000,对于 boolfalse,对于其他值类型是所有字段都设置为相应的默认值。

当 C# 4 引入可选参数时,指定参数默认值的一种方法就是使用 default 操作符。如果类型名称很长,这可能会变得难以管理,因为类型名称会同时出现在参数类型及其默认值中。在这方面最糟糕的违规者之一是 CancellationToken,尤其是因为该类型参数的传统名称是 cancellationToken。一个常见的异步方法签名可能如下所示:

public async Task<string> FetchValueAsync(
    string key,
    CancellationToken cancellationToken = default(CancellationToken))

第二个参数声明非常长,需要整行来排版书籍格式;它有 64 个字符。

在 C# 7.1 中,在特定上下文中,你可以使用 default 而不是 default(T),让编译器确定你打算使用哪种类型。尽管确实有超出前面示例的好处,但我怀疑这是主要推动因素之一。前面的示例可以变成这样:

public async Task<string> FetchValueAsync(
    string key, CancellationToken cancellationToken = default)

这要干净得多。没有类型跟随它,default 是一个 字面量 而不是一个 操作符,并且它的工作方式与 null 字面量类似,除了它可以适用于所有类型。这个字面量本身没有类型,就像 null 字面量没有类型一样,但它可以被转换为任何类型。这种类型可能从其他地方推断出来,例如隐式类型的数组:

var intArray = new[] { default, 5 };
var stringArray = new[] { default, "text" };

那段代码没有明确列出任何类型名称,但 intArray 是隐式地 int[]default 字面量被转换为 0),而 stringArray 是隐式地 string[]default 字面量被转换为空引用)。就像 null 字面量一样,确实需要涉及某种类型来转换它;你不能只是要求编译器在没有信息的情况下推断类型:

var invalid = default;
var alsoInvalid = new[] { default };

如果转换后的类型是引用类型或原始类型,则 default 字面量被分类为常量表达式。这允许你在需要时在属性中使用它。

需要注意的一个怪癖是,术语 default 有多种含义。它可以指类型的默认值或可选参数的默认值。默认字面量始终指适当的类型的默认值。如果你将其用作具有不同默认值的可选参数的参数,这可能会导致一些混淆。考虑以下列表。

列表 14.11. 将默认字面量作为方法参数指定
static void PrintValue(int value = 10)   *1*
{
    Console.WriteLine(value);
}

static void Main()
{
    PrintValue(default);                 *2*
}
  • 1 参数的默认值是 10。

  • 2 方法参数对 int 是默认值。

这会打印出 0,因为这是int类型的默认值。语言本身是完全一致的,但这段代码可能会因为默认值的多种可能含义而引起混淆。我会尽量避免在这种情况下使用默认字面量。

14.6. 非尾部命名参数(C# 7.2)

可选参数和命名参数在 C# 4 中被引入作为互补特性,并且两者都有顺序要求:可选参数必须位于所有必需参数(除了参数数组)之后,而命名参数必须位于所有位置参数之后。可选参数没有变化,但 C#团队注意到,命名参数经常可以作为提高清晰度的工具,即使对于参数列表中间的参数也是如此。这尤其适用于参数是一个字面量(通常是数字、布尔值、字面量或null)且上下文没有明确说明值的目的时。

例如,我最近一直在编写 BigQuery 客户端库的示例。当你将 CSV 文件上传到 BigQuery 时,你可以指定一个模式,让服务器确定模式,或者如果该模式已经存在,则从表中获取它。在编写自动检测的示例时,我想清楚地说明你可以为schema参数传递一个null引用。以最简单但不是最清晰的形式编写,null参数的含义根本不明显:

client.UploadCsv(table, null, csvData, options);

在 C# 7.2 之前,我使这段代码更清晰的选择是使用命名参数来表示最后三个参数,这最终看起来有点尴尬,或者使用一个解释性的局部变量:

TableSchema schema = null;
client.UploadCsv(table, schema, csvData, options);

这更清晰了,但仍然不是很好。C# 7.2 允许在参数列表中的任何位置使用命名参数,因此我可以清楚地说明第二个参数的含义,而无需任何额外的语句:

client.UploadCsv(table, schema: null, csvData, options);

这也可以帮助在某些情况下区分重载,在这些情况下,参数(通常是null)可以转换为多个重载中的相同参数位置。

非尾部命名参数的规则被精心设计,以避免任何后续的位置参数变得模糊:如果有一个或多个未命名的参数在命名参数之后,则命名参数必须对应于如果它是简单的位置参数时的参数。例如,考虑这个方法声明及其三个调用:

void M(int x, int y, int z){}

M(5, z: 15, y: 10);         *1*
M(5, y: 10, 15);            *2*
M(y: 10, 5, 15);            *3*
  • 1 有效:尾部命名参数顺序错误

  • 2 有效:非尾部命名参数顺序正确

  • 3 无效:非尾部命名参数顺序错误

第一次调用是有效的,因为它由一个位置参数后跟两个命名参数组成;很明显,位置参数对应于参数x,其他两个是命名的。没有歧义。

第二次调用是有效的,因为尽管有一个带名称的参数后面跟着一个位置参数,但带名称的参数与如果它是位置参数(y)时对应的参数相同。再次强调,每个参数应该取什么值是清晰的。

第三次调用是无效的:第一个参数被命名但对应于第二个参数(y)。第二个参数是否应该对应于第一个参数(x),因为它是最先的非命名参数?尽管规则可以按这种方式工作,但这会变得有些混乱;当涉及到可选参数时,情况会更糟。禁止它会更简单,因此语言团队决定这样做。接下来是一个 CLR 中一直存在但仅在 C# 7.2 中公开的特性。

14.7. 私有受保护访问(C# 7.2)

几年前,private protected本应成为 C# 6 的一部分(也许他们计划比这更早引入它)。问题是给这个特性起一个名字。当团队达到 7.2 版本时,他们决定找不到比private protected更好的名字。这种访问修饰符的组合比protectedinternal都更具有限制性。只有从位于同一程序集且在成员声明子类中的代码才能访问private protected成员(或位于同一类型中)。

protected internal相比,它比protectedinternal都更不具限制性。你可以从位于同一程序集或成员声明子类中的代码访问受保护的内部成员(或位于同一类型中)。

关于这一点,没有太多可说的;它甚至不值得举一个例子。从完整性的角度来看,这是件好事,因为在 CLR 中可以表达但 C#中不能表达访问级别确实很奇怪。到目前为止,我仅在自己的代码中使用过一次,并且我不期望它在未来会变得更有用。我们将以一些不适合放在其他地方的零散内容结束本章。

14.8. C# 7.3 的微小改进

正如你在本章以及本书前面的内容中已经看到的,C#设计团队在发布 C# 7.0 后并没有停止对 C# 7 的工作。进行了一些小的调整,主要是为了增强 C# 7.0 中发布的功能。在可能的情况下,我已经将这些细节与一般功能描述一起包含。C# 7.3 中的某些功能不适合这种方式,它们也不太符合本章关于简洁代码的主题。但把它们留在外面又感觉不合适。

14.8.1. 泛型类型约束

当我在第 2.1.5 节简要描述类型约束时,遗漏了一些限制。在 C# 7.3 之前,类型约束不能指定类型参数必须派生自EnumDelegate。这个限制已经被取消,并增加了一种新的约束类型:unmanaged约束。以下列表展示了这些约束是如何指定和使用的。

列表 14.12. C# 7.3 中的新约束
enum SampleEnum {}
static void EnumMethod<T>() where T : struct, Enum {}
static void DelegateMethod<T>() where T : Delegate {}
static void UnmanagedMethod<T>() where T : unmanaged {}
...
EnumMethod<SampleEnum>();                 *1*
EnumMethod<Enum>();                       *2*

DelegateMethod<Action>();                 *3*
DelegateMethod<Delegate>();               *3*
DelegateMethod<MulticastDelegate>();      *3*

UnmanagedMethod<int>();                   *4*
UnmanagedMethod<string>();                *5*
  • 1 有效:枚举值类型

  • 2 无效:不满足结构约束

  • 3 所有都有效(遗憾的是

  • 4 有效:System.Int32 是一个非托管类型。

  • 5 无效:System.String 是一个托管类型。

我已经展示了 where T : struct, Enum 的约束,因为几乎总是这样使用它。这限制了 T 必须是一个真正的枚举类型:一个从 Enum 派生的值类型。struct 约束排除了 Enum 类型本身。如果你试图编写一个可以与任何枚举类型一起工作的方法,你通常不希望处理 Enum,因为 Enum 本身并不是一个真正的枚举类型。不幸的是,为框架中各种枚举解析方法添加这些约束已经太晚了。

很遗憾,没有与委托约束等效的约束。没有方法可以表达“只有使用委托声明声明的类型”的约束。你可以使用 where T : MulticastDelegate 的约束代替,但那样你仍然可以使用 MulticastDelegate 本身作为类型参数。

最后一个约束是针对 非托管 类型。我之前已经提到过这些,但非托管类型是一个非可空、非泛型的值类型,其字段不是引用类型,递归地。框架中的大多数值类型(Int32DoubleDecimalGuid)都是非托管类型。作为一个不是值类型的例子,Noda Time 中的 ZonedDateTime 不会是非托管类型,因为它包含对 DateTimeZone 实例的引用。

14.8.2. 重载解析改进

重载解析的规则已经被反复调整,通常以难以解释的方式,但 C# 7.3 的变化是受欢迎的,并且相对简单。一些在重载解析完成后曾经被认为是模糊或无效的调用现在被认为是有效的。检查如下:

  • 泛型类型参数必须满足类型参数上的任何约束。

  • 静态方法不能像实例方法那样调用。

  • 实例方法不能像静态方法那样调用。

作为第一种场景的例子,考虑以下重载:

static void Method<T>(object x) where T : struct =>          *1*
    Console.WriteLine($"{typeof(T)} is a struct");

static void Method<T>(string x) where T : class =>           *2*
    Console.WriteLine($"{typeof(T)} is a reference type");
...
Method<int>("text");
  • 1 具有结构约束的方法

  • 2 具有类约束的方法

在 C# 的早期版本中,重载解析会首先忽略类型参数约束。它会选择第二个重载,因为 string 是比 object 更具体的常规参数类型,然后发现提供的类型参数(int)违反了类型约束。

使用 C# 7.3,代码编译时没有错误或歧义,因为类型约束是在查找适用方法时检查的。其他检查类似;编译器会丢弃对于调用无效的方法,比以前更早地被丢弃。所有三种场景的例子都在可下载的源代码中。

14.8.3. 自动实现属性的字段属性

假设你想要一个由字段支持的简单属性,但你需要将属性应用于字段以启用其他基础设施。在 C# 7.3 之前,你必须单独声明字段,然后编写一个带有样板代码的简单属性。例如,假设你想要将DemoAttribute(我只是编造的一个属性)应用于支持字符串属性的字段。你可能需要像这样的代码:

[Demo]
private string name;
public string Name
{
    get { return name; }
    set { name = value; }
}

当自动实现属性几乎可以做你想要的所有事情时,这会让人感到烦恼。在 C# 7.3 中,你可以直接将字段属性指定给自动实现的属性:

[field: Demo]
public string Name { get; set; }

这不是属性的新修饰符,但之前在这个上下文中不可用。(至少不是官方的,也不是在微软编译器中。Mono 编译器已经允许这样做一段时间。)这只是规范中另一个语言不一致的粗糙边缘,在 C# 7.3 中已经被平滑处理。

概述

  • 本地方法允许你清楚地表达特定的代码片段是单个操作的实现细节,而不是在类型本身内具有通用用途。

  • out 变量是纯粹的形式简化,它允许一些涉及多个语句(声明一个变量然后使用它)的情况简化为一个单一的表达式。

  • 二进制字面量在需要表达整数值且位模式比大小更重要时提供了更多的清晰度。

  • 对于可能容易让读者感到困惑的许多数字,插入数字分隔符会使它们更清晰。

  • out变量类似,throw表达式通常允许将之前必须用多个语句表达的逻辑表示为一个单一的表达式。

  • 默认字面量消除了冗余。它们还阻止你不得不两次说同样的话.^([5])

    看看冗余有多烦人?抱歉,我忍不住了。

  • 与其他特性不同,使用非尾随命名参数可能会增加你的源代码大小,但一切都是为了清晰。或者,如果你之前在只想命名中间的一个参数时指定了很多命名参数,你现在将能够删除一些名称而不会影响可读性。

第十五章. C# 8 及以后版本

本章涵盖

  • 表达对引用类型的 null 和非 null 期望

  • 使用模式匹配的 switch 表达式

  • 递归地对属性进行匹配模式

  • 使用索引和范围语法以简洁和一致的方式编写代码

  • 使用usingforeachyield语句的异步版本

在撰写本文时,C# 8 仍在设计中。GitHub 存储库显示了大量的潜在功能,但只有少数已经达到公开可用的编译器预览版本阶段。本章是基于推测的;这里的内容都不是确定的。几乎无法想象正在考虑的所有功能都会包含在 C# 8 中,我已经限制了自己只考虑那些我认为有合理可能被采纳的功能。我已经提供了在撰写本文时预览中可用的功能的最大细节,但即使如此,这也并不意味着不会发生进一步的更改。

注意

在撰写本文时,只有少数 C# 8 功能在预览版本中可用,并且有不同的构建版本具有不同的功能。可空引用类型的预览只支持完整的.NET 项目(而不是.NET Core SDK 风格的项目),这使得如果所有项目都使用新的项目格式,则在真实代码中实验它们变得更加困难。我预计这些限制将在以后的构建中克服,可能在你阅读本文时已经实现。

我们将从可空引用类型开始。

15.1. 可空引用类型

啊,空引用。所谓的十亿美元的错误,托尼·霍尔在 1960 年代引入它们后于 2009 年道歉。很难找到一个经验丰富的 C#开发者没有至少被NullReferenceException咬过几次。C#团队有一个计划来驯服空引用,使我们可以更清楚地知道它们在哪里。

15.1.1. 可空引用类型解决了什么问题?

作为本节中我将进一步展开的例子,让我们考虑以下列表中的类。如果你在下载的源代码中跟随,你会看到我将它们声明为每个示例中的独立嵌套类,因为代码会随着时间的推移而变化。

列表 15.1. C# 8 之前的初始模型
public class Customer
{
    public string Name { get; set; }
    public Address Address { get; set; }
}

public class Address
{
    public string Country { get; set; }
}

一个地址通常包含比一个国家更多的信息,但在这个章节的示例中,一个属性就足够了。有了这些类,这段代码有多安全?

Customer customer = ...;
Console.WriteLine(customer.Address.Country);

如果你(以某种方式)知道customer是非空的,并且客户总是有一个相关的地址,那可能没问题。但你如何知道这一点?如果你只是因为查看过文档而知道这一点,代码需要做出什么改变才能更安全?

自从 C# 2 以来,我们就有了可空值类型、非可空值类型和隐式可空引用类型。在可空/非可空与值/引用类型的网格中,已经有三个单元格被填满,但第四个单元格仍然难以捉摸,如表 15.1 所示。

表 15.1. C# 7 中对引用类型和值类型的可空性和非可空性支持
可空 非可空
引用类型 隐式 不支持
值类型 Nullable或?后缀 默认

由于只有顶部行支持一个单元格,我们无法表达某些引用值可能为 null 而其他值则永远不应为 null 的意图。当你遇到意外 null 值的问题时,除非代码已经被仔细记录并一致地实现了 null 检查,否则很难确定错误所在.^([1])

¹

在我写这个段落的前一天,我大部分时间都在试图追踪一个与此完全相同的问题。这个问题非常真实。

由于现在存在大量没有机器可读区分的.NET 代码,其中一些引用可以合理地设置为 null,而另一些则必须始终非 null,因此任何试图纠正这种情况的尝试都只能是非常谨慎的。我们能做什么呢?

15.1.2. 使用引用类型时改变含义

null 安全特性的广泛思想是假设当开发者在有意区分非 null 和可空引用类型时,默认值应该是非可空的。为可空引用类型引入了新的语法:string是非可空引用类型,而string?是可空引用类型。然后,网格会像表 15.2 中所示那样演变。

表 15.2. C# 8 中引用和值类型的 null 性和非 null 性支持
可空 非可空
引用类型 没有 CLR 类型表示,但有?后缀作为注释 启用可空引用类型支持时的默认值
值类型 Nullable或?后缀 默认值

这听起来像是谨慎的反面;它改变了所有处理引用类型的 C#代码的含义!启用此功能将默认值从可空更改为非可空。预期的情况是,意图使 null 引用有效的位置远少于不应出现 null 引用的位置。

让我们回到我们的客户和地址示例。在不修改代码的情况下,编译器会警告我们CustomerAddress类允许非可空属性未初始化。这可以通过添加具有非可空参数的构造函数来修复,如下面的列表所示。

列表 15.2. 遍处使用非可空属性的模型
public class Customer
{
    public string Name { get; set; }
    public Address Address { get; set; }

    public Customer(string name, Address address) =>
        (Name, Address) = (name, address);
}

public class Address
{
    public string Country { get; set; }

    public Address(string country) =>
        Country = country;
}

到目前为止,你“不能”不提供非 null 的名称和地址就构造一个Customer,你“不能”不提供非 null 的国家就构造一个Address。我故意用引号标注了can’t,原因你将在第 15.1.4 节中看到。

但现在再次考虑我们的控制台输出代码:

Customer customer = ...;
Console.WriteLine(customer.Address.Country);

假设每个人都正确遵守了契约,这是安全的。这不仅不会抛出异常,而且你也不会将 null 值传递给Console.WriteLine,因为地址中的国家不会是 null。

好的,所以编译器可以检查事物是否为 null。但是,当你想要允许 null 值时怎么办?是时候探索我之前提到的新的语法了。

15.1.3. 引入可空引用类型

用于表示可空引用类型的语法设计得立即熟悉。它与可空值类型的语法相同:在类型名称后添加一个问号。这可以在引用类型可以出现的大多数地方使用。例如,考虑以下方法:

string FirstOrSecond(string? first, string second) =>
    first ?? second;

方法的签名显示如下:

  • first 的类型是可空字符串。

  • second 的类型是非可空字符串。

  • 返回类型是非可空字符串。

编译器随后使用这些信息来警告你,如果你尝试误用可能为 null 的值。例如,它可以警告你以下操作:

  • 将可能为 null 的值赋给非可空变量或属性。

  • 将可能为 null 的值作为非可空参数的参数传递。

  • 取消对可能为 null 的值的引用。

让我们将其构建到我们的客户模型中。假设客户地址可能是 null。你需要按以下方式修改 Customer 类:

  • 更改属性类型。

  • 要么移除地址的构造函数参数,要么使其可空,或者对其进行重载。

Address 类型本身并没有改变,只是它的使用方式发生了变化。以下列表展示了新的 Customer 类。我选择移除了地址的构造函数参数。

列表 15.3. 使客户 Address 属性可空
public class Customer
{
    public string Name { get; set; }
    public Address? Address { get; set; }   *1*

 public Customer(string name) => *2*
 Name = name;
}
  • 1 地址现在是可选信息。

  • 2 从构造函数中移除了地址参数

太好了,你现在已经清楚地表达了你的意图:Name 属性不会为 null,但 Address 属性可能会。现在当你尝试显示用户地址的国家时,编译器会给出不同的警告:

CS8602 Possible dereference of a null reference.

太好了!现在它正在识别你最初面临的问题,这导致了 NullReferenceException。如何解决这个问题?现在是时候查看可空引用类型的 行为 而不仅仅是语法了。

15.1.4. 编译时和执行时可空引用类型

新特性的一个黄金法则是没有行为会隐式地改变。即使你代码的含义已经改变,假设了非可空类型的意图,但行为并没有改变。唯一的区别是在编译时产生的警告。没有引入新的真实类型;CLR 没有可空与非可空引用类型的概念。使用属性来传播可空性信息,但仅此而已。这类似于元组元素名称的额外信息,这些名称在执行时不是类型的一部分。这有两个重要的后果:

  • 防御性编程仍然是一个最佳实践。根据你迄今为止编写的代码,Name 可能为 null,因为用户可能会忽略警告或使用来自另一个项目且仅使用 C# 7 的代码。参数验证仍然很重要。

  • 要完全理解这个特性,你需要理解编译器警告。你绝对不应该只是忽略它们;它们的存在是为了提供价值。

让我们看看你目前面临的警告,并考虑所有可以避免它的方法。你现在有这个:

Console.WriteLine(customer.Address.Country);

编译器正确地告诉你这是危险的,因为 customer.Address 可能是 null。你将看到三种可以使代码更安全的方法。首先,你可以同时使用空条件运算符和空合并运算符,如下一个列表所示。

列表 15.4. 使用空条件运算符进行安全的取消引用
Console.WriteLine(customer.Address?.Country ?? "(Address unknown)");

如果 customer.Address 是 null,表达式 customer.Address?.Country 不会尝试评估 Country 属性,并且表达式的结果将是 null。空合并运算符随后提供一个默认值以打印。编译器理解你不再尝试取消引用任何可能为 null 的东西,警告就会消失。

你现在可能对此感到有些不自在。如果不小心,很容易在问号的大海中迷失方向。我相信随着时间的推移,C# 开发者会越来越习惯这种方式,但这并不是唯一的解决方案。你可以采取更冗长的但易于遵循的方法,如下面的列表所示。

列表 15.5. 通过局部变量检查引用
Address? address = customer.Address;      *1*
if (address != null)                      *2*
{                                         *2*
    Console.WriteLine(address.Country);   *2*
}                                         *2*
else
{
    Console.WriteLine("(Address unknown)");
}
  • 1 从地址提取到新的局部变量

  • 2 检查空值,仅在非空时取消引用

这里有一个有趣的观点需要注意:编译器需要跟踪的不仅仅是变量的类型。如果规则像“取消引用可空引用类型的值会引发警告”这么简单,那么即使这段代码是安全的,它仍然会生成警告。相反,编译器以类似于跟踪确定赋值的方式,在代码的每个位置跟踪变量的值是否可以为 null。在你到达 if 语句的主体时,编译器知道 address 的值不能为 null,因此在你取消引用它时不会发出警告。我们下面展示的第三种方法与第二种类似,但没有使用局部变量。

列表 15.6. 通过重复属性访问检查引用
if (customer.Address != null)
{
    Console.WriteLine(customer.Address.Country);
}
else
{
    Console.WriteLine("(Address unknown)");
}

即使你理解了第二个示例如何编译而不产生警告,列表 15.6 可能仍然会让你感到有些惊讶。编译器不仅跟踪变量值是否可以为 null,它也跟踪属性。它假设如果你在相同的值上两次访问相同的属性,两次的结果将是相同的。

这可能会让你担心。这意味着该特性并不能保证阻止你的代码解除对 null 值的引用。另一个线程可能在你看过的两次调用之间修改Address属性,或者Address属性本身可能会随机返回一个 null 值。还有其他方法可以欺骗编译器相信你的代码是安全的,尽管实际上并不绝对安全。这是 C#设计团队所知道并接受的,因为它是在安全性和尴尬性之间的一种实用平衡。使用 C# 8 特性的代码将比之前编写的代码更加 null 安全,但使其 100%安全几乎肯定需要更侵入性的更改,这会让许多开发者望而却步。只要你能理解它试图达到的极限,你就可以安心了。

你已经看到编译器努力理解可能或可能不是 null 的东西。当你没有那么多上下文时,你能做什么?

15.1.5. 该鬼玩意儿或砰操作符

你还没有看到的一个额外的语法元素:鬼玩意儿该死叹号操作符。^([2]) 这是一个在表达式末尾的感叹号,它是一种告诉编译器忽略它认为关于该表达式的任何知识,并将其视为非 null 的方式。

²

我怀疑它永远不会正式被称为鬼玩意儿操作符,但我怀疑这个名字将在社区中流传,就像每个人都用 Roslyn 的原名来称呼 Microsoft .NET 编译平台一样。

这在两种相反的情况下很有用:

  • 有时候,你拥有的信息比编译器多,所以你知道一个值不会是 null,即使编译器认为它可能是。

  • 有时候,你可能会故意传递一个 null 值来检查你的参数验证。

第一种情况的一些简短示例有些牵强,因为你通常会尝试重新组织代码以避免陷入那种情况。在小示例中,这几乎总是可行的,但在实际应用中则更困难。以下列表显示了一种打印字符串长度的方法,其中输入可以是 null。

列表 15.7. 使用叹号操作符来满足编译器
static void PrintLength(string? text)                    *1*
{
    if (!string.IsNullOrEmpty(text))                     *2*
    {
        Console.WriteLine($"{text}: {text!.Length}");    *3*
    }
    else
    {
        Console.WriteLine("Empty or null");
    }
}
  • 1 输入可以是 null

  • 2 如果 IsNullOrEmpty 返回 false,则它不是 null。

  • 3 使用叹号操作符来说服编译器。

在这个例子中,你知道编译器不知道的东西,即string.IsNullOrEmpty的输入与返回值之间的关系。如果string.IsNullOrEmpty返回false,则输入不能为 null,因此可以安全地解除引用该值以获取字符串的长度。如果你只是尝试使用text.Length,编译器会发出警告。使用text!.Length,你是在告诉编译器你更了解情况,实际上是在承担对值的推理责任。

现在如果编译器能理解string.IsNullOrEmpty方法的输入/输出关系那就太好了。我们将在 15.1.7 节中回到这个想法。

感叹号运算符的第二次使用可以通过一个现实生活中的例子更容易地演示。我之前提到,你应该仍然验证参数是否为 null,因为仍然完全有可能你收到 null 值。然后你可能想为这个验证添加一个单元测试,但然后编译器会警告你,因为你提供了 null 值,而你说过它不应该为 null。以下列表显示了感叹号运算符是如何解决这个问题。

列表 15.8. 在单元测试中使用感叹号运算符
public class Customer
{
    public string Name { get; }
    public Address? Address { get; }

    public Customer(string name, Address? address)
    {
        Name = name ?? throw new ArgumentNullException(nameof(name));
        Address = address;
    }
}

public class Address
{
    public string Country { get; }

    public Address(string country)
    {
        Country = country ??                                     
            throw new ArgumentNullException(nameof(country));    
    }
}

[Test]
public void Customer_NameValidation()
{
    Address address = new Address("UK");
    Assert.Throws<ArgumentNullException>(
        () => new Customer(null!, address));         *1*
}
  • 1 故意传递一个 null 值给不可空参数

我为了简化,在列表 15.8 中将CustomerAddress类型设置为不可变。值得注意的是,编译器在验证本身并没有发出任何警告。尽管它知道值不应该为 null,但它不会抱怨代码检查它是否为 null。但是,它确实试图强制当你测试中调用构造函数时,第一个参数不能为 null。在 C#的早期版本中,测试中的 lambda 表达式看起来是这样的:

() => new Customer(null, address)

这段代码会生成警告,这在几乎所有情况下都是你想要的。将参数更改为null!可以让编译器满意,并且测试会按照你的意愿执行。这引发了一个问题,即在实际中使用可空引用类型是什么样的,特别是如何将现有代码迁移到使用该功能。

15.1.6. 可空引用类型迁移的经验

没有比尝试它更好的方式来了解一个功能是如何工作的了。我使用 C# 8 预览版和 Noda Time 来查看使其无警告需要多少工作量,以及它是否发现了任何错误。本节描述了这一经历以及我发现自己遵循的一些指南。你的代码可能会面临不同的挑战,但我怀疑会有很多共同点。

在 C# 8 之前使用属性表达可空意图

很长一段时间以来,Noda Time 使用属性(至少对于所有公共方法)来指示引用类型参数是否可以为 null,以及返回值是否可能返回 null。例如,以下是IDateTimeZoneProvider中一个方法的签名:

[CanBeNull] DateTimeZone GetZoneOrNull([NotNull] string id);

这表明id参数的参数不能为 null,但该方法可能返回 null 引用。我已经表达了关于 null 性的意图,只是没有用 C#编译器理解的方式。这意味着我的第一次遍历只是去到代码中所有我说过允许 null 值的地方,并将它们更改为使用可空引用类型。

我偶然使用了与 ReSharper 一起提供的 JetBrains 注解。这使得 ReSharper 能够执行与 C# 8 在语言中执行的同种类型的检查。我不会深入这些注解的细节,只是指出它们是可用的。然而,你根本不需要使用第三方注解集。你可以轻松创建自己的属性并将其立即应用。即使没有任何工具支持,这也可以使你的代码更容易维护,并且你将处于更好的位置,以便将来迁移到 C# 8 的可空引用类型。

迭代是自然的

在这次第一次遍历后,我大约有 100 个警告。我逐一修复了这些警告,然后重新构建。在第二次遍历后,我大约有 110 个警告——比之前还多!我逐一修复了这些警告,然后重新构建。在第三次遍历后,我仍然大约有 100 个警告。我逐一修复了这些警告,然后重新构建。

我不记得这需要多少次迭代,但这并不是表明有任何错误发生的迹象。使代码库符合可空引用类型的过程就像玩打地鼠:你决定改变某个地方的可空性,然后这会导致该值被使用的地方出现警告。你更改这些警告,问题又转移了。关于可空性的决策会在代码中传播,需要仔细检查。这是正常的,也是你应该预料到的情况。

但是,当代码的一部分需要值是可空的,而另一部分需要它不可空时,你就发现了一个问题。这不是 C# 8 引入的问题;这是该特性揭示的问题。你如何处理它将取决于具体情境。

使用感叹号运算符的最佳实践

如果你必须在生产代码中使用感叹号运算符,请添加注释解释你这样做的原因。如果你使用一个易于搜索的格式(例如,在注释中包含NULLABLEREF),你将能够在以后找到它们。你可能能够通过进一步的工具改进来删除该运算符。使用该运算符本身并没有错,但它是一种断言,表明你知道比编译器更多,而我更喜欢不这么信任自己。

我在测试代码中使用该运算符的频率更高,主要用于执行之前章节中看到的验证测试。除此之外,如果因为我设置的测试方式,我期望一个值是非空的,我通常很高兴强迫编译器接受它,尤其是如果我知道它将被我随后调用的代码验证。如果我是错的,结果应该是测试失败,要么是ArgumentNullException,要么是NullReferenceException,这是可以接受的,因为我会知道我的假设是无效的。可以说,测试代码在一般情况下应该比生产代码更少防御性;而不是试图优雅地处理意外情况,它们失败是可以的。

可空不一致的泛型

在 Noda Time 中为引用类型实现IEqualityComparer<T>我觉得很奇怪,因为它是在考虑可空引用类型之前定义的。EqualsGetHashCode都是根据类型为T的参数定义的,但在处理 null 方面不一致:Equals旨在处理 null 值,但GetHashCode旨在抛出ArgumentNullException

在实现中如何表达这一点尚不清楚。如果我有一个Period类的等价比较器,我应该实现IEqualityComparer<Period?>以允许 null 参数,还是实现IEqualityComparer<Period>以禁止它们?无论哪种方式,调用者都可能在编译时或执行时感到惊讶。

不仅仅是实现问题,我在接口本身中如何更清楚地表达这一点也不清楚。可能需要更多的语言设计工作,以表达如何处理泛型类型参数。在接口中使用T?会感觉不正确,因为你不希望接受Nullable<T>T是值类型时。

虽然我偶然遇到了IEqualityComparer<T>,但我预计同样的问题也会出现在其他接口甚至泛型类中。我主要在这里提到这一点,以免你在遇到它时认为你做错了什么。

最终结果

Noda Time 的代码库并不庞大,但也不小。整个过程花费了我大约五小时,包括诊断 Roslyn 预览构建中的错误的时间。最后,我在 Noda Time 中找到了一个错误(现已修复),这个错误与在某些环境中 Mono 上TimeZoneInfo.Local返回 null 的不一致处理有关。我还发现了一些缺失的注释,并不得不澄清一些内部成员的意图。

我对结果感到满意;知道编译器正在检查代码的一致性,这增加了我对它的信心。此外,在我发布使用 C# 8 构建的 Noda Time 版本之后,任何使用 C# 8 的库的用户都将从额外的信息中受益。这将有助于将更多错误从执行时间移至编译时间,使用户对如何使用 Noda Time 更有信心。这是一个双赢的局面。

所有这些经验都是基于 2018 年上半年预览版的。然而,这并不是语言设计或实现的最终状态。让我们来推测一下未来。

15.1.7. 未来改进

2018 年 6 月,我与 C#语言设计团队的负责人 Mads Torgersen 一起参加了会议和用户组活动。我带着基于我在 Noda Time 使用经验的一系列功能请求和问题清单,他的回应让我对功能的未来充满信心。

C# 团队已经意识到目前可用的预览版还不是主流采用的准备状态。有几件事情需要更多的工作,但预览版允许团队收集早期反馈。这里列出的更改不会是唯一的,但它们是我最感兴趣的。

为编译器提供更多语义信息

当我在 第 15.1.5 节 介绍叹号操作符时,我展示了编译器并不理解 string.IsNullOrEmpty 的语义。(编译器不会推断如果方法返回 false,则输入不可能是 null。)这不是唯一一个输入和输出之间关系应该能够帮助编译器的情况。以下有三个感觉上应该没有警告(包括 string.IsNullOrEmpty 以确保完整性)的例子:

string? a = ...;
if (!string.IsNullOrEmpty(a))
{
    Console.WriteLine(a.Length);
}

object b = ...;
if (!ReferenceEquals(b, null))
{
    Console.WriteLine(b.GetHashCode());
}

XElement c = ...;
string d = (string) c;

在每种情况下,你调用的代码的语义都很重要。对于这些示例,编译器需要了解以下内容:

  • 如果 string.IsNullOrEmpty 的结果是 false,则输入不能为空。

  • 如果 ReferenceEquals 的结果是 false,并且其中一个输入已知是空引用,则另一个输入不能为空。

  • 如果 XElementstring 转换操作符的输入非空,输出也将非空。

这些都是输入和输出之间关系的事例,而这些关系目前无法表达。我怀疑如果编译器理解了这些关系,那么预览构建中大多数使用叹号操作符的情况都可以避免。编译器如何获取这些额外信息?

对于这些特定示例,一个可能的工作方法是让编译器将信息硬编码。这对 C# 设计团队来说很容易,但在其他方面可能不太令人满意。这会让框架库与第三方库处于不同的地位,这会让人感到烦恼。例如,我可能想在 Noda Time 中表达这种关系,这将使其使用起来更加愉快。

很可能,C# 团队将设计一种全新的迷你语言,可以通过属性来表示,从而为编译器提供额外的语义信息,使其能够更智能地确定某个特定值是否应该被认为是“肯定不是 null”。这将需要大量的设计和实施工作,但将提供一个更加完整的解决方案。

对泛型的深入思考

泛型在可空性设计上提出了有趣的挑战。我在实现 IEqualityComparer<T> 时提到了一个例子,但这个问题远远超出了那个范围。考虑以下在 C# 7 中已经有效的简单类:

public class Wrapper<T>
{
    public T Value { get; set; }
}

如果这是有效的,那它意味着什么?特别是,如果没有设置 Value 属性,构造该实例的结果是什么?

  • 对于 Wrapper<int>Value 的默认值将是 0。

  • 对于 Wrapper<int?>Value 的默认值将是 int? 的空值。

  • 对于 Wrapper<string>Value 的值默认将是一个空引用。这很糟糕,因为它与 Value 的非空字符串类型相矛盾。

  • 对于 Wrapper<string?>Value 的值默认将是一个空引用。这是可以接受的,因为 Value 的类型是可空字符串类型。

当你考虑到在执行时,Wrapper<int>Wrapper<int?> 将是不同的 CLR 类型,但 Wrapper<string>Wrapper<string?> 将是相同的 CLR 类型时,事情会变得更加混乱。

我不知道 C# 8 中这种混淆将如何解决,但团队已经注意到了这个问题。我很高兴是他们而不是我来弄清楚这个问题,因为这仅仅想到它就让我头疼。

那个例子只使用了在 C# 7 中有效的语法,并且根本没有明确提到可空类型。如果你尝试在泛型类型或方法中使用 T? 会怎样?

在 C# 7 中,如果你有一个类型参数 T,只有当 T 被约束为非可空值类型时,才能使用 T?,此时它意味着 Nullable<T>。这相当简单,但对于可空引用类型你能做什么呢?看起来你将需要一个非可空引用类型的新泛型约束,此时 T? 可以在 T 被约束为非可空值类型或被约束为非可空引用类型时使用。我不期望一个单一的约束来表示“某些非可空类型”,因为相应的可空类型在值类型和引用类型之间的表示方式非常不同。

自愿参数验证

目前实施的所有更改都是在编译时进行的。编译器生成的 IL 代码没有变化,你仍然需要执行参数验证来保护代码不受忽略编译器警告、使用感叹号运算符或针对 C# 的早期版本编译的影响。

这是有意义的,但验证感觉像是样板代码。空合并运算符、nameof 运算符和 throw 表达式都是有助于在某些情况下改进验证所需代码的功能,但它们仍然令人烦恼且容易忘记。

正在讨论的一个特性是允许在参数名后跟一个感叹号,以指示编译器在方法开始时生成空值验证。考虑一个可能目前这样编写的方法:

static void PrintLength(string text)
{
    string validated =                                            
        text ?? throw new ArgumentNullException(nameof(text));    
    Console.WriteLine(validated.Length);
}

你可以改写为:

static void PrintLength(string text!)     *1*
{
    Console.WriteLine(text.Length);
}
  • 1 自动空值验证

可能属性也可以以相同的方式自动验证。

启用空值检查

在我使用的预览构建中,空值检查默认是开启的。虽然你可以像往常一样抑制警告,但 C# 8 编译器在发布之前可能会提供更细致的设置。有许多不同的场景需要考虑。

当开发者升级到 C# 8 编译器时,他们很可能会希望在看不到任何新警告的情况下进行此操作。如果项目设置将警告视为错误,这一点尤为重要。我怀疑这意味着 nullability 检查默认情况下会被关闭,至少对于现有项目来说是这样。

并非所有类库都会同时拥抱 C# 8。对于使用 C# 8 且已开启 nullability 检查的代码来说,能够消费尚未迁移的库非常重要。这很可能是为了尽可能减少错误报告。例如,编译器可能会将库的所有输入视为可空,但所有输出视为不可空。此外,还需要有一种方式让库能够表明它已经迁移。

当开发者决定将项目迁移到使用可空引用类型时,他们可能会在几个更改的过程中这样做。他们的项目可能包含无法轻易修改以表达 nullability 的生成代码。这表明能够在每个类型的基础上表达“此代码表达 nullability”的概念是有用的。

这些考虑对于 C#来说是新的。我们从未有过一个对兼容性影响如此广泛的语言特性。我预计团队在 C# 8 最终发布之前会在此方面迭代多次。

可空引用类型很可能会成为 C# 8 中最大的特性,但其他特性也已经在预览版本中可用。我最喜欢的是 switch 表达式。

15.2. Switch 表达式

switch 语句从 C#一开始就可用,在这段时间里,它唯一的变化是允许在 C# 7 中实现模式匹配。它仍然是一个命令式控制结构:如果这个情况匹配,就做这个;如果那个情况匹配,就做那个。尽管如此,switch 语句的许多用法都是函数式的,每个情况都计算一个结果:如果这个情况匹配,结果是 X;如果那个情况匹配,结果是 Y。这在许多函数式编程语言中是一个常见的结构,其中许多函数完全用模式匹配来表示。

表达式成员的引入使得这一点显得格外突出。许多方法可以用单个表达式实现,但如果你想使用 switch/case 结构,就必须使用块体。这通常只是不方便,但仍然是一个摩擦点。

C# 8 引入了switch 表达式作为 switch 语句的替代方案。它与 switch 语句的语法略有不同,因此值得比较。在第十二章中,当我介绍模式匹配时,你看到了一个用于计算不同形状周长的 switch 语句示例。以下是第十二章中使用的代码:

static double Perimeter(Shape shape)
{
    switch (shape)
    {
        case null:
            throw new ArgumentNullException(nameof(shape));
        case Rectangle rect:
            return 2 * (rect.Height + rect.Width);
        case Circle circle:
            return 2 * PI * circle.Radius;
        case Triangle triangle:
            return triangle.SideA + triangle.SideB + triangle.SideC;
        default:
            throw new ArgumentException(
                $"Shape type {shape.GetType()} perimeter unknown",
                nameof(shape));
    }
}

下面的列表显示了使用 switch 表达式代替的等效代码,但仍然使用常规的块体方法。

列表 15.9. 将 switch 语句转换为 switch 表达式
static double Perimeter(Shape shape)
{
    return shape switch
    {
        null => throw new ArgumentNullException(nameof(shape)),
        Rectangle rect => 2 * (rect.Height + rect.Width),
        Circle circle => 2 * PI * circle.Radius,
        Triangle triangle =>
            triangle.SideA + triangle.SideB + triangle.SideC,
        _ => throw new ArgumentException(
            $"Shape type {shape.GetType()} perimeter unknown",
            nameof(shape))
    };
}

这里有很多需要注意的地方,所以我并没有试图将它们全部作为注释放入代码中。以下是switch语句和switch表达式之间的所有差异:

  • switch (value)的引入语法是value switch

  • 如果匹配到模式,则模式与返回结果之间使用一个粗箭头=>。在switch语句中,则使用冒号代替。

  • switch表达式中根本不使用case关键字。=>的左侧只是一个带有可选的when关键字保护子句的模式。

  • =>的右侧只是一个表达式。不需要使用return关键字,因为每个模式都会产生一个值或抛出异常。同样,也永远不会出现break语句。

  • 模式是逗号分隔的。如果您正在将switch语句转换为switch表达式,这通常意味着将分号改为逗号。

  • 没有使用default情况。相反,使用丢弃的_(下划线)来匹配任何尚未匹配的内容。

我的经验主要是编写直接返回switch表达式结果的方法,但您也可以像使用任何其他表达式一样使用它。例如,您可以编写如下:

double circumference = shape switch
{
          *1*
};
  • 1 switch 表达式的主体与之前相同

这是可以的,但如我之前提到的,switch 表达式最令人愉快的一个方面是用于表达式主体方法。以下列表显示了列表 15.9 演变成表达式主体方法的过程。

列表 15.10. 使用 switch 表达式实现表达式主体方法
static double Perimeter(Shape shape) =>
    shape switch
    {
        null => throw new ArgumentNullException(nameof(shape)),
        Rectangle rect => 2 * (rect.Height + rect.Width),
        Circle circle => 2 * PI * circle.Radius,
        Triangle triangle =>
            triangle.SideA + triangle.SideB + triangle.SideC,
        _ => throw new ArgumentException(
            $"Shape type {shape.GetType()} perimeter unknown",
            nameof(shape))
    };

您可以按自己的喜好格式化,比如将形状切换移到第一行,或者将花括号缩进到与方法声明相同的级别。

switch语句和switch表达式之间的重要区别是,switch表达式必须始终有一个结果(可能是异常)。不允许switch表达式什么都不做并产生没有值。您可以使用_丢弃来确保这一点,但可能编写一个不是详尽无遗switch表达式——换句话说,一个可能不会总是匹配的表达式。在我使用的预览构建中,这会产生编译器警告,然后编译器生成无效的 IL。这可能会成为编译时错误,或者编译器可能会注入代码来抛出异常(可能是InvalidOperationException),以表明代码遇到了它没有预料到的情况。

目前我对 switch 表达式有一个问题,那就是没有方法来表达多个应该评估为相同结果的模式。在switch语句中,您可以指定多个 case 标签,但在 switch 表达式中还没有等效的选项。C#团队已经注意到了这种需求,所以希望它能在 C# 8 发布之前被包含进去。

在 C# 8 中,模式的使用不仅仅是通过 switch 表达式得到改进。模式本身也在扩展其范围。

15.3.递归模式匹配

作为提醒,C# 7 中引入的模式如下:

  • 类型模式(expression is Type t

  • 常量模式(expression is 10expression is null等)

  • var模式(expression is var v

C# 8 将引入递归模式(模式可以嵌套在更大的模式中)以及解构模式。解释递归模式的最简单方法就是展示它们的作用。我们将回到解构模式。

15.3.1.在模式中匹配属性

要在整体模式内匹配具有附加模式的属性,你使用包含逗号分隔的模式列表的括号来匹配属性。属性模式使用任何正常的模式类型将属性值与嵌套模式匹配。作为一个例子,让我们再次看看我们用来计算矩形、圆形和三角形面积的三个模式,这些模式取自列表 15.10:

Rectangle rect => 2 * (rect.Height + rect.Width),
Circle circle => 2 * PI * circle.Radius,
Triangle triangle => triangle.SideA + triangle.SideB + triangle.SideC,

在每种情况下,你不需要形状本身;你只需要它的属性。你可以使用嵌套var模式来匹配这些属性与任何值,并为每个你需要属性的属性提取模式变量。以下列表显示了包含嵌套模式的完整方法。

列表 15.11.匹配嵌套模式
static double Perimeter(Shape shape) => shape switch
{
    null => throw new ArgumentNullException(nameof(shape)),
 Rectangle { Height: var h, Width: var w } => 2 * (h + w),
 Circle { Radius: var r } => 2 * PI * r,
 Triangle { SideA: var a, SideB: var b, SideC: var c } => a + b + c,
    _ => throw new ArgumentException(
        $"Shape type {shape.GetType()} perimeter unknown", nameof(shape))
};

这比之前的代码更清晰吗?我不确定。我已经用它作为从上一个例子中顺利跟随的例子,但我可能很容易地坚持使用列表 15.10 中的代码。你将在稍后的一个更复杂的例子中看到,这个特性将变得更加引人注目,但可能更难立即理解。

注意,尽管在这里你已经停止使用它们各自的模式变量(在rectcircletriangle之前)来捕获RectangleCircleTriangle,但这仅仅是因为你不需要它们用于任何事情。以这种方式引入模式变量仍然是有效的。例如,如果你在描述形状,你可能有一个模式来描述一个高度为零的平面矩形:

Rectangle { Height: 0 } rect => $"Flat rectangle of width {rect.Width}"

当你有很多属性但只是对其中几个进行模式测试时,这很有用。接下来,我们将探讨解构模式

15.3.2.解构模式

你在第 12.1 节中看到了元组的解构,在第 12.2 节中通过Deconstruct方法进行了解构。C# 8 中的模式将被扩展,以允许使用嵌套模式进行解构。作为一个有点牵强的例子,你可能会决定将Triangle解构为其所有三个边是自然的:

public void Deconstruct
    (out double sideA, out double sideB, out double sideC) =>
    (sideA, sideB, sideC) = (SideA, SideB, SideC);

然后,你可以简化我们的周长计算,将其解构为三个变量而不是指定每个属性名称。所以,在我们的 switch 表达式中,而不是这个情况

Triangle { SideA: var a, SideB: var b, SideC: var c } => a + b + c

你可以这样:

Triangle (var a, var b, var c) => a + b + c

再次,这比仅匹配类型更易读吗?也许吧。随着时间的推移,我怀疑每个开发者都会在自己的偏好中找到关于模式匹配的方法,并且理想情况下,也会在他们的代码库中形成一种约定。

15.3.3. 从模式中省略类型

查看对象内部的能力使得模式在您不测试值类型时仍然有用。在那个点上,将类型作为模式的一部分指定似乎显得多余。对于这个例子,让我们回到之前用于可空引用类型的客户和地址示例。您将回到第一个数据模型:所有可变的,所有可空的:

public class Customer
{
    public string Name { get; set; }
    public Address Address { get; set; }
}

public class Address
{
    public string Country { get; set; }
}

现在假设您想根据地址中的国家以不同的方式问候客户。您的输入可能是 Customer 类型,因此您不想在模式中重复它。当您在模式中匹配客户的 Address 时,它始终是 Address 类型,因此您也不需要指定该类型。

下面的列表显示了匹配不同类型客户的多个模式。它还演示了 { } 模式,这是没有属性要匹配的属性模式的特例。该模式匹配任何非空值。

列表 15.12. 简洁地匹配多个模式以匹配客户
static void Greet(Customer customer)
{
    string greeting = customer switch
    {
        { Address: { Country: "UK" } } =>                      *1*
             "Welcome, customer from the United Kingdom!",      
        { Address: { Country: "USA" } } =>                     *2*
             "Welcome, customer from the USA!",                 
        { Address: { Country: string country } } =>            *3*
             $"Welcome, customer from {country}!",              
        { Address: { } } =>                                    *4*
             "Welcome, customer whose address has no country!",
        { } =>                                                 *5*
            "Welcome, customer of an unknown address!",      
        _ =>                                                   *6*
            "Welcome, nullness my old friend!"                 
    };
    Console.WriteLine(greeting);
}
  • 1 匹配国家为 UK

  • 2 匹配国家为 USA

  • 3 匹配任何国家,但必须存在

  • 4 匹配任何地址

  • 5 匹配任何客户,即使地址为空

  • 6 匹配任何内容,即使客户引用为空

这里的顺序很重要。例如,一个地址国家为美国的客户可能除了第一个模式外,都匹配。您可以使模式更具有选择性(例如,使用常量空模式来匹配具有空 Address 属性值的客户),但依赖顺序会更简单。

C# 8 中对模式匹配的增强将允许它们在更多需要 if 语句的情况下使用。切换表达式也增加了这种灵活性。我预计越来越多的代码将使用模式编写。一如既往,避免过度使用很重要;并非所有代码使用模式编写都比我们之前使用的控制结构更简单。然而,C# 进化的这个领域确实有很大的潜力。我们的下一个特性实际上是由两种新的框架类型启用的两个特性。

15.4. 索引和范围

与可空引用类型和改进的模式处理相比,索引和范围感觉像是一个小特性,即使结合起来也是如此。但我怀疑随着时间的推移,我们会 wonder 为什么它们出现得这么晚。下面的列表在您查看详细信息之前提供了一个小小的预览。

列表 15.13. 使用范围从字符串中裁剪第一个和最后一个字符
string quotedText = "'This text was in quotes'";
Console.WriteLine(quotedText);
Console.WriteLine(quotedText.Substring(1..¹));       *1*
  • 1 使用范围文字从字符串中提取子字符串

输出如下:

'This text was in quotes'
This text was in quotes

这里高亮的1..¹表达式是这里有趣的部分。要理解这段代码,你需要了解两种新的类型。

15.4.1. 索引和范围类型和字面量

这个想法很简单。IndexRange是框架中将提供的两个结构体,但目前需要在你的代码中定义:

  • Index是从可索引事物的开始或结束的一个整数。索引的值永远不会是负数。

  • Range是一对索引:一个用于范围的开始,一个用于范围的结束。

然后有三个重要的语法元素:

  • int到创建“从开始”Index的常规隐式转换。

  • 一个新的单目运算符(^),可以与int一起使用来创建“从末尾”的Index。在这里,0 的值表示刚好在末尾的元素,而 1 的值表示最后一个元素.^([3])

    ³

    当使用带有索引器的Index时,这有点反直觉,但与具有排他性上界的范围相比,它更有意义。一个上界为的范围实际上是指“到序列的末尾”,这可能是你预期的。

  • 一个新的类似二元的运算符(..),具有可选的起始和结束操作数来创建Range

..运算符是类似二元的,因为可以有零个、一个或两个操作数。以下列表显示了所有这些示例。你并不是将索引或范围应用于任何东西;你只是在创建值。

列表 15.14. 索引和范围字面量
Index start = 2;
Index end = ²;
Range all = ..;
Range startOnly = start..;
Range endOnly = ..end;
Range startAndEnd = start..end;
Range implicitIndexes = 1..5;

需要注意的一点是,范围的起始点和结束点可以是任何索引。例如,你可以有一个⁵..10的范围,表示从末尾第五个元素到从开始第十个元素。这可能是非典型的,但却是有效的。

这就是索引和范围直接语言支持的总量。当它们也有框架支持时,它们才变得有用。

15.4.2. 应用索引和范围

本节中的所有示例都需要 C# 8 预览版支持的扩展方法和扩展运算符。确切的 API 可能会更改,预览版中提供的扩展只与有限的一组类型一起工作;这足以展示其优势。在列表 15.13 中,我展示了如何使用RangeSubstring方法一起使用。索引和范围都将应用,并且通常应用于表示某种形式的序列的类型,例如

  • 数组

  • 范围

  • 字符串(作为 UTF-16 代码单元的序列)

这些都支持两种操作:

  • 获取单个元素

  • 创建一个切片来表示序列的一部分

单元素检索操作已经有一个使用接受 int 参数的索引器的常见表示,但这使得以统一的方式检索最后一个元素变得困难。Index 类型通过其从起始或从末尾的特性解决了这个问题。切片操作以前根据涉及类型的不同而采取不同的形式。例如,Span<T> 有一个 Slice 方法,而 String 有一个 Substring 方法。

通过添加接受 IndexRange 值的索引重载,你可以使用一致且方便的语法在所有相关类型上执行这两种操作。以下列表显示了类似调用在字符串和 Span<int> 上工作。

列表 15.15. 在字符串和 span 中使用索引重载来访问索引和范围
string text = "hello world";
Console.WriteLine(text[2]);                                  *1*
Console.WriteLine(text[³]);                                 *2*
Console.WriteLine(text[2..7])                                *3*

Span<int> span = stackalloc int[] { 5, 2, 7, 8, 2, 4, 3 };
Console.WriteLine(span[2]);                                  *4*
Console.WriteLine(span[³]);                                 *5*
Span<int> slice = span[2..7];                                *6*
Console.WriteLine(string.Join(", ", slice.ToArray()));
  • 1 从起始索引访问单个字符

  • 2 从末尾索引访问单个字符

  • 3 使用范围获取子字符串

  • 4 从起始索引访问单个元素

  • 5 从末尾索引访问单个元素

  • 6 使用范围创建切片

输出如下:

l
r
llo w
7
2
7, 8, 2, 4, 3

接受 Range 的字符串和 span 索引器都将范围的上界视为排他性:范围 [2..7] 返回索引为 2、3、4、5 和 6 的元素。

在 列表 15.15 中,包含的范围包括起始和结束索引,并且这两个索引值都是从起始计算的。只要索引对它们应用的序列有效,你就可以使用任何范围与索引器一起使用。例如,使用 text[⁵..] 与 列表 15.15 中的代码将返回 text 的最后五个字符 world

同样,你可以写 text[¹⁰..5],这将返回 ello。在长度为 11 的字符串(hello world)的上下文中,索引 ¹⁰ 等同于索引 1,因此 text[¹⁰..5] 在这个情况下(这确实取决于 text 的长度)等同于 text[1..5],返回第一个字符之后的四个字符。接下来,我们将探讨对异步性的语言支持增强。

15.5. 更多的异步集成

当在 C# 5 中引入 async/await 时,它彻底改变了许多 C# 开发者的异步性。但到目前为止,一些语言特性仍然保持同步,这使得全面采用异步性变得困难。在本节中,我们将探讨以下内容:

  • 异步处置

  • 异步迭代 (foreach)

  • 异步迭代器 (yield return)

这些需要框架支持和语言支持。编译器通过在另一个线程上执行同步代码来近似异步性是不合适的。让我们从异步处置开始,这是三个特性中最简单的。

15.5.1. 使用 using await 进行异步资源处置

带有单个 Dispose 方法的 IDisposable 接口自然是同步的。如果该方法需要执行 I/O,例如刷新流,那么它可能会因为所有正常原因而阻塞。

将引入一个新的接口,用于支持异步释放的类:

public interface IAsyncDisposable
{
    Task DisposeAsync();
}

实现 IAsyncDisposable 的类型不需要也实现 IDisposable,尽管我怀疑许多类型会这样做。

然后以 using await 语句的形式提供相应的语言支持,它按预期工作,自动调用 DisposeAsync 并等待结果任务。以下列表显示了实现接口并使用它的示例。

列表 15.16. 实现 IAsyncDisposal 并使用 using await 调用它
class AsyncResource : IAsyncDisposable
{
    public async Task DisposeAsync()
    {
        Console.WriteLine("Disposing asynchronously...");
        await Task.Delay(2000);
        Console.WriteLine("... done");
    }

    public async Task PerformWorkAsync()
    {
        Console.WriteLine("Performing work asynchronously...");
        await Task.Delay(2000);
        Console.WriteLine("... done");
    }
}
async static Task Main()
{
    using await (var resource = new AsyncResource())
    {
        await resource.PerformWorkAsync();
    }
    Console.WriteLine("After the using await statement");
}

输出显示了资源释放:

Performing work asynchronously...
... done
Disposing asynchronously...
... done
After the using await statement

这很简单,但它隐藏了需要解决的两个复杂性的方面:

  • 库通常使用 ConfigureAwait(false) 等待任务。应用程序通常在没有这个的情况下等待任务。如果编译器正在自动等待,用户如何配置这个?

  • 对于释放来说,自然应该有取消操作。这在该接口和调用点中如何定位?

C# 团队已经注意到了这两个问题,我期望它们在发布前以某种形式得到解决。C# 8 中其他异步特性也存在相同的问题,我希望它们都能以类似的方式得到解决。现在让我们看看下一个特性:使用 foreach 的异步迭代。

15.5.2. 使用 foreach await 的异步迭代

提示:在我们达到本节中的语言特性之前,这里有很多文本。这是为了正确解释它所必需的,但结果是,像这样的代码将是有效的,其中 asyncSequence 需要异步操作来检索项目:

foreach await (var item in asyncSequence)
{
       *1*
}
  • 1 使用项目

引入的异步迭代接口并不像释放接口那样简单直接。有两个接口,在一定程度上反映了 IEnumerable<T>IEnumerator<T>,但并不那么明显:

public interface IAsyncEnumerable<out T>
{
    IAsyncEnumerator<T> GetAsyncEnumerator();
}

public interface IAsyncEnumerator<out T>
{
    Task<bool> WaitForNextAsync();
    T TryGetNext(out bool success);
}

IAsyncEnumerable<T> 可能比您预期的更接近 IEnumerable<T>;它里面没有异步操作。它不是 GetEnumerator(),而是 GetAsyncEnumerator(),它返回一个 IAsyncEnumerator<T>,但它是以同步方式完成的。对于某些实现,这可能会成为问题,但我预计对于大多数异步序列来说,这将是自然的方法。任何希望在设置过程中执行异步操作的实现可能都需要将这项工作推迟到调用者开始迭代结果时。

IAsyncEnumerator<T> 接口与 IEnumerator<T> 相差甚远,反映了现实世界实现中的常见模式。当涉及 I/O 时,如通过网络检索结果,通常使用异步操作。这通常自然地导致序列以块的形式检索;您可能执行一个查询并一次性检索前 10 个结果,然后是下一个 7 个,然后被告知这是完整的结果集。

当你在缓冲的结果集中迭代时,不需要异步操作。尽管异步操作相当高效,但它并不完全免费,所以如果你能避免,那就值得避免。相反,你可以同步迭代,只要你有一种方法来确定你是否已经到达了当前结果集的末尾。在那个点上,你可以异步获取下一个结果,并再次同步迭代。

IAsyncEnumerator<T> 接口通过其两个方法公开此模式:

  • WaitForNextAsync 是异步的,返回一个任务,指示是否检索到更多结果或是否已到达序列的末尾。

  • TryGetNext 是同步的,返回下一个项目。out 参数用于指示是否返回了下一个项目。⁴ 当这是 false 时,这并不意味着你必然到达了序列的末尾;它只是意味着你需要再次调用 WaitForNextAsync

    这与大多数 TryXyz 方法不一致,这些方法返回 bool 并使用 out 参数作为值。这可能在发布前改变。

这可能听起来很复杂,但好消息是,你不太可能需要自己执行任何这些操作;新的 foreach await 语句为你处理了一切。

让我们看看一个例子,这个例子大量借鉴了我与 Google Cloud Platform API 一起工作的经验。许多 API 都有列表操作,例如在地址簿中列出联系人或在集群中列出虚拟机。返回的结果可能太多,无法在一个 RPC 响应中返回,所以我们有一个基于页面的模式:每个响应都包含一个“下一页令牌”,客户端在后续请求中提供该令牌以检索更多数据。对于第一个请求,客户端不提供页码,最后的响应也不包含页码。API 的简化视图可能如下所示。

列表 15.17. 列出城市的简化 RPC 服务
public interface IGeoService
{
    Task<ListCitiesResponse> ListCitiesAsync(ListCitiesRequest request);
}

public class ListCitiesRequest
{
    public string PageToken { get; }
    public ListCitiesRequest(string pageToken) =>
        PageToken = pageToken;
}

public class ListCitiesResponse
{
    public string NextPageToken { get; }
    public List<string> Cities { get; }

    public ListCitiesResponse(string nextPageToken, List<string> cities) =>
        (NextPageToken, Cities) = (nextPageToken, cities);
}

这直接使用起来不方便,但它可以很容易地被包装在一个客户端中,该客户端公开此 API,如下一个列表所示。

列表 15.18. RPC 服务的包装,以提供更简单的 API
public class GeoClient
{
    public GeoClient(IGeoService service) { ... }               *1*
    public IAsyncEnumerable<string> ListCitiesAsync() { ... }   *2*
}
  • 1 构建带有 RPC 服务的 GeoClient

  • 2 提供一个简单的城市异步序列

GeoClient 就位后,你最终可以使用 foreach await,如下所示。

列表 15.19. 使用 foreach awaitGeoClient
var client = new GeoClient(service);

foreach await (var city in client.ListCitiesAsync())
{
    Console.WriteLine(city);
}

这里最终的代码比之前向你展示的设置示例的代码要简单得多,甚至没有查看 GeoClient 的实现。但这是一件好事;它显示了该功能的好处。你以相对简单和高效的方式使用 foreach await 消费了 IGeoServiceIAsyncEnumerable<T> 中的相对复杂的定义。

注意

可下载的源代码包含一个完整的示例,其中包含内存中的模拟服务实现。

你可能会惊讶的一件事是 IAsyncEnumerator<T> 并没有实现 IAsyncDisposable。这可能在发布前改变,但即使它没有改变,我也预计编译器会在执行时销毁一个枚举器,如果它实现了 IAsyncDisposable

就像同步的 foreach 语句一样,foreach await 不会要求实现 IAsyncEnumerable<T>IAsyncEnumerator<T> 接口。它将基于模式,因此任何提供 GetAsyncEnumerator() 方法且返回的类型又提供了适当的 WaitForNextAsyncTryGetNext 方法的类型都将得到支持。这可能会允许一些优化,但我预计接口将在大多数情况下被使用。

到目前为止,你已经看到了如何消费异步序列。那么,如何产生它们呢?

15.5.3. 异步迭代器

C# 2 引入了 yield returnyield break 语句,使得编写返回 IEnumerable<T>IEnumerator<T> 的方法变得容易。C# 8 将为异步序列提供相同的功能。这个功能在预览版中不可用,但下面的列表显示了我是如何期望它工作的。

列表 15.20. 使用迭代器实现 ListCitiesAsync
public async IAsyncEnumerable<string> ListCitiesAsync()
{
    string pageToken = null;
    do
    {
        var request = new ListCitiesRequest(pageToken);
        var response = await service.ListCitiesAsync(request);
        foreach (var city in response.Cities)
        {
            yield return city;
        }
        pageToken = response.NextPageToken;
    } while (pageToken != null);
}

异步迭代器方法与 IAsyncEnumerator<T> 接口之间的映射,其中包含异步和同步部分的混合,将非常复杂来实现。每次你在异步方法中继续执行代码时,它可以通过几种方式完成那个特定的调用:

  • 它可能等待一个不完整的异步操作。

  • 它可能达到一个 yield return 语句。

  • 它可能达到一个 yield break 语句。

  • 它可能达到方法的末尾。

  • 它可能抛出一个异常。

这些功能的处理方式将取决于调用者是否正在执行 WaitForNextAsync()TryGetNext()。为了提高效率,生成的代码应该能够有效地在同步模式(如果你在无中间等待的情况下产生值)和异步模式(如果你正在等待异步操作)之间切换。我可以大致想象出这可能如何实现,但我很高兴我不是那个需要实现它的人。

还有其他一些功能在 C# 8 预览版中尚未提供。我们将简要地探讨这些功能。

15.6. 尚未在预览中提供的功能

如果 C# 8 只包含我之前列出的功能,它仍然是一个大事件。在某些方面,我希望我们能够发布一个只包含可空引用类型的版本,等待一年左右,让大多数代码库更新到它,然后继续添加更多功能。但 C# 8 很可能将包含比我之前展示的更多功能。

本节讨论了我认为最有可能包含在 C# 8 中的特性。还有更多特性被 C#团队或外部开发者提出。C#团队使用 GitHub 来跟踪语言提案,这使得查看正在进行的事情并自行贡献变得容易;请参阅github.com/dotnet/csharplang。我们将从一个受 Java 启发的特性开始。

15.6.1. 默认接口方法

虽然 C#为 LINQ 引入了扩展方法,但 Java 采取了不同的方法来支持其功能,这涵盖了与 LINQ 相同的大量用例。在 Java 8 中,Oracle 在 Java 接口中引入了默认方法:接口可以声明一个方法及其默认实现,然后可以在具体实现中覆盖它。默认实现不能声明任何以字段形式存在的状态;它必须以接口的其他成员的形式表达。

这两个特性在某些方面是相似的:它们都允许逻辑以这样的方式表达,即接口的消费者可以在不需要每个接口实现直接了解或实现它的情况下调用方法。每种方法都有其优缺点:

  • 扩展方法可以由任何人引入,而不仅仅是接口的作者。你不能向一个你无法控制的接口添加默认方法。(当然,扩展方法也可以应用于类和结构体。)

  • 默认方法可以通过实现类来覆盖,通常是为了优化。扩展方法不能被覆盖;它们只是带有语法糖的静态方法,使得调用它们看起来更像常规实例方法。

第二点可以通过使用 LINQ 的Enumerable.Count()方法作为例子来轻松理解。默认情况下,它通过调用GetEnumerator()来计算序列中的元素数量,然后统计该枚举器上MoveNext()方法返回true的调用次数。

许多IEnumerable<T>的实现有更有效的方法来确定元素的数量。Enumerable.Count()特别针对某些实现进行了优化,例如ICollectionICollection<T>实现。但对于不想实现这些接口但仍想以低成本提供Count功能的集合怎么办?它就陷入了困境;它没有方法将信息传达给Enumerable.Count(),表明它可以更高效地实现 LINQ 的这一部分。然而,如果Count()IEnumerable<T>中的一个具有默认实现的方法,那么我们的新集合只需覆盖该方法即可。

下面是一个使用 C# 8 默认接口方法声明IEnumerable<T>的例子:

public interface IEnumerable<T>
{
    IEnumerator<T> GetEnumerator();

    int Count()
    {
        using (var iterator = GetEnumerator())
        {
            int count = 0;
            while (iterator.MoveNext())
            {
                count++;
            }
        }
    }
    return count;
}

默认接口方法还允许接口以更版本友好的方式随着时间的推移进行扩展。可以通过默认实现添加新方法,该实现要么使用现有成员实现新功能,要么可能抛出NotSupportedException。这样,旧实现仍然可以构建,即使新方法无法可靠地调用。版本控制至少是一个棘手的话题,但拥有另一个工具箱选项是受欢迎的。在许多情况下,这会使我维护的代码更简单。

默认接口方法正在证明是一个有争议的功能。它们需要 CLR 支持,这使得在完全承诺之前进行实验更困难。如果该功能被包含在内,将很有趣地看到其采用率。它可能直到支持它的运行时版本被广泛采用之前都很少使用。接下来,我们将探讨一个被讨论了很长时间甚至已经原型化的功能。

15.6.2. 记录类型

记录类型的先驱是一个名为主构造函数的功能,最初旨在 C# 6 中存在。语言团队对原始设计中的某些粗糙边缘不满意,因此他们决定推迟其引入,直到可以改进。

记录类型旨在使创建具有给定属性集的不可变类或结构体变得容易。我倾向于从匿名类型开始,但添加所有种类的功能。它们可以声明得非常简单。例如,以下是一个完整的类声明:

public class Point(int X, int Y, int Z);

这会为你生成一些成员,尽管你仍然可以引入自己的行为。生成的成员包括构造函数、属性、相等方法、一个用于解构的Deconstruct方法,以及一个类似这样的With方法:

public Point With(int X = this.X, int Y = this.Y, int Z = this.Z) =>
    new Point(X, Y, Z);

目前这不是可选参数默认值的有效语法,而且不清楚是否可以明确地写出这样的代码,但至少显示了该方法行为意图。

With方法旨在与以with 表达式形式出现的新语法进行互操作。想法是方法和语法都使得创建一个与现有实例相同但一个或多个属性已更改的新不可变类型变得容易。在不可变类型中已经常见WithFoo方法(其中Foo是该类型中属性的名称),但它们通常一次只处理一个属性。例如,对于具有XYZ属性的不可变 Point 类,你可能使用以下代码创建一个新的点,它具有与先前点相同的Z值,但新的XY值:

var newPoint = oldPoint.WithX(10).WithY(20);

每个 WithFoo 方法都会调用一个构造函数,传递除了方法中命名的属性之外的所有现有属性,其中参数中指定的新值被用来使用。这些方法编写起来很繁琐,并且也有性能影响:要“更改”N 个属性,你需要进行 N 次方法调用,每次调用都会创建一个新的对象。

记录类型的 With 方法不同:它为类型的每个属性有一个参数,如果未指定该参数,则具有用于默认参数值的语法,表示应从当前对象中获取该值。例如,考虑我们 Point 类型的 With 方法。你可以直接调用它

var newPoint = oldPoint.With(X: 10, Y: 20);

或者使用新的 with 表达式 语法,它看起来更像是一个对象初始化器:

var newPoint = oldPoint with { X = 10, Y = 20 };

这两个会编译成相同的 IL。这样,就只构造了一个新的对象。

这只是一个简单的例子。当你有一个复杂类型并且只想修改一个叶节点时,事情会变得复杂。例如,如果你有一个具有Address属性的Contact类型,你可能想创建一个新的联系信息,它与旧的联系信息相同,但Address属性的一部分不同。在 C# 8 中,这仍然可能很棘手,但with 表达式语法可能会随着时间的推移得到增强,使其变得更简单,就像模式匹配的语法一样。

我对这里的可能性感到兴奋。在 C# 中,不可变类型长期以来一直难以创建和使用。虽然 C# 7 的元组填补了匿名类型留下的一个空缺,但记录类型填补了另一个空缺。我一直喜欢匿名类型,因为编译器为你做了很多工作,包括等价性、构造函数和属性代码。只是遗憾的是,我们无法给它们命名或稍后添加更多功能。记录类型解决了所有这些问题,还有更多。最后,我想强调一些需要更多创新思维的功能。

15.6.3. 简要介绍更多功能

虽然一些小功能更有可能被纳入 C# 8,但它们不如我这里讨论的有趣。记住,你总是可以检查 GitHub 来了解可能包含的内容及其最新状态。

类型类(也称为概念、形状或结构化泛型约束)

虽然泛型在许多情况下都很出色,但它们也有局限性。有些数据类型“形状”无法用泛型表达,例如运算符和构造函数。虽然你可以要求类型参数有一个无参构造函数,但你不能要求它有一个具有特定参数列表的构造函数。此外,有时类型可以在某些有用的方式下具有相同的形状,但除了 System.Object 之外,没有实现任何公共接口或具有任何公共基类。类型类 将是一种新的类型,用于解决这些问题。它们会有一点像接口,但实现类不需要了解它们。你将能够通过类型类来约束泛型类型参数。

这有可能非常强大但有些令人困惑;我自己对此也有两种看法。它可能需要运行时更改才能高效执行。它可能需要 C# 开发者(至少是我)一段时间才能弄清楚何时有用,何时只是令人困惑。在语言发展的这个阶段添加整个新的类型感觉像是一个巨大的步骤。尽管有所有这些警告,这个特性确实填补了一个空白:当你需要这种功能时,当前的工具没有提供任何干净的解决方案。

扩展一切

在撰写本文时,这在 GitHub 上的里程碑为 X.0,但我不会对它被提升到优先级列表感到过分惊讶。这个名字很好地解释了该特性:扩展方法的概念将被应用于其他成员类型,例如属性、构造函数和运算符。它还可能允许引入静态扩展成员——它们看起来像是扩展类型上的静态方法。(例如,你可以在 StringExtensions 中编写一个方法,可以像 string.IsNullOrTabs 一样调用,作为 string.IsNullOrWhiteSpace 的更具体版本。)

用于扩展方法的语法不适合其他成员类型,因此很可能将使用全新的语法。这可能是纯粹为了创建多个扩展成员而存在于一个特定扩展类型上的扩展类型。

扩展类型仍然无法引入新的状态。任何扩展属性都可能呈现现有属性的不同视图。例如,你可以在 DateTime 上有一个扩展属性名为 FinancialQuarter,它了解你公司的财务报告日期,并使用现有的 Year/Month/Day 属性来计算适当的季度。

目标类型新

使用 var 的隐式类型可以用于在涉及长类型名时减少混乱。然而,对于字段来说,它并没有帮助,因为它们不能隐式类型化。我们最终还是得到了这样的代码:

Dictionary<string, List<DateTime>> entryTimesByName =
    new Dictionary<string, List<DateTime>>();

目标类型的新特性 不会影响你可以在哪里使用 var。相反,它会缩短声明右侧的部分:

Dictionary<string, List<DateTime>> entryTimesByName = new();

任何时候编译器可以告诉你调用构造函数时你很可能指的是哪种类型,你就可以完全省略类型名。这给成员调用引入了有趣的复杂性。例如,Method(new()) 会从方法参数中获取目标类型,这在 Method 不是泛型或重载的情况下是可以的。

我对这个特性提案又爱又恨,大约是相等的程度。如果过度使用,它确实可能会使代码难以阅读,但几乎任何特性都可能被误用。另一方面,我喜欢去除长字段初始化重复的可能性。

我预计这会比默认接口方法更具争议性。我们将看看会发生什么,你也可以成为对话的一部分。

15.7. 参与其中

C# 的设计流程比以往任何时候都更加开放。尽管在微软办公室的编程语言设计会议(LDMs)背后有很多工作,但社区参与的空间也很大。github.com/dotnet/csharplang 的 GitHub 仓库是开始的地方。它包含 LDMs 的笔记、提案、讨论和规范。你可以在以下任何级别参与:

  • 尝试预览版构建以查看新功能与现有代码的兼容性

  • 讨论当前提出的特性

  • 提出新特性

  • 在 Roslyn 中原型设计新功能

  • 帮助起草新功能的规范

  • 在现有规范中找出错误(这种情况是会发生的!)

你可能觉得等待带有完整文档和精炼实现的完整发布版本是更好的时间利用方式。这也是完全可以接受的。任何时候都可以轻松地尝试一下,即使只是为了查看特定里程碑的提案功能集。

这种开放的设计流程相对较新,我预计它会在未来得到进一步的优化。如果团队真的回到了更加封闭的流程,我会感到惊讶。尽管这种社区参与在时间上可能很昂贵,但确保新功能是开发者真正需要的功能所带来的巨大好处是值得的。

结论

本章中文字比代码多得多,主要是因为我不想展示太多在 C# 8 发布时可能已经错误的代码。我怀疑我描述的所有功能都不会出现在 C# 8 中,但我认为至少有一些功能可能会出现。如果可空引用类型或与模式相关的功能没有进入 C# 8,我会感到惊讶。

那么,接下来会是什么?嗯,C# 8 线的次要版本,然后是 C# 9。C# 9 的一些特性可能已经在 GitHub 上作为提案出现,但我怀疑还有一些尚未讨论过。我预计随着计算环境的改变,C# 将继续进化以满足开发者的需求。

语言特性按版本

本书主要按版本组织,但很难一眼看出每个版本引入的特性。这对于在 C# 7 的次要版本中引入的特性尤其如此,这些特性通常改进了 C# 7.0 中引入的特性。

此外,了解一个语言特性是否需要运行时或框架支持,或者它是否是纯编译器魔法,可能很有用。本附录旨在尽可能简单地提供所有这些信息。

我还没有提到的一个方面是泛型类型推断在各个版本中的演变。它已经改变了很多次,通常是以太复杂,无法用几句话来概括。我建议你把它当作一个既定事实,即每当引入一个新版本时,泛型类型推断可能已经得到了改进。

特性 备注和要求 章节
C# 2
泛型 需要运行时和框架支持。 2.1
可空值类型 需要运行时和框架支持。 2.2
方法组转换 2.3.1
匿名方法 2.3.2
委托的可变性 2.3.3
迭代器(yield return) 2.4
部分类型 2.5.1
静态类 2.5.2
属性的单独获取/设置访问 2.5.3
命名空间别名限定符 :: 语法 2.5.4
全局命名空间别名 2.5.4
外部别名 2.5.5
固定大小缓冲区 2.5.6
InternalsVisibleToAttribute 支持 需要运行时和框架支持。 2.5.7
C# 3
部分方法 2.5.1
自动实现属性 3.1
隐式类型局部变量(var) 3.2.2
隐式类型数组(new[]) 3.2.3
对象初始化器 3.3.2
集合初始化器 3.3.3
匿名类型 3.4
Lambda 表达式(委托) 3.5
Lambda 表达式(表达式树) 需要框架支持(表达式树类型)。 3.5.3
扩展方法 需要框架支持(属性)。 3.6
查询表达式 3.7
C# 4
动态类型 需要框架支持(称为 Dynamic Language Runtime 但不是运行时的一部分)。 4.1
可选参数 4.2
命名参数 4.2
链接的主要互操作程序集 需要运行时和框架支持。 4.3.1
COM 中可选参数的特殊规则 4.3.2
访问命名的索引器(仅 COM) 4.3.3
接口和委托的泛型方差 对现有接口和委托的框架更改。(运行时支持已经存在。) 4.4
锁语句的实现更改 需要框架支持:Monitor.Enter(object, ref bool)。 第三版,第 13.4.1 节
类似字段的事件的实现更改 第三版,第 13.4.2 节
在声明类内部使用类似字段的事件访问 第三版,第 13.4.2 节
C# 5
Async/await 需要框架支持(编译器使用的任务类型和附加基础设施)。 第五章 和 第六章
foreach 迭代变量捕获的变化 行为变化,但仅针对几乎肯定在先前版本中已损坏的代码。 7.1
调用者信息属性 需要框架支持(属性本身)。 7.2
C# 6
只读自动实现属性 8.2.1
自动实现属性的初始化器 8.2.2
从包含自动实现属性的结构的构造函数中删除调用 this 的要求 8.2.3
表达式成员 8.3
嵌套字符串文字 当 FormattableString 类和 FormattableStringFactory 可用时,提供对 FormattableString 的额外支持。 9.2, 9.3
nameof 运算符 9.5
using static 指令 10.1
使用索引器的对象初始化器 10.2.1
使用扩展 Add 方法的集合初始化器 10.2.2
null 条件?.运算符 10.3
异常过滤器 10.4
移除了在 try/catch、try/finally 和 try/catch 语句中等待的限制 5.4.2
C# 7.0
元组 需要框架支持(ValueTuple 类型)。 11.2–11.4
通过 Deconstruct 方法进行解构 直到 C# 7.2 编译器需要存在值元组类型,但不是 C# 7.2 语言功能。(实现更改,实际上。) 12.1, 12.2
初始模式:常量模式、类型模式、var 模式 12.4
使用 is 运算符的模式 12.5
在 switch 语句中使用模式,包括守卫子句(when) 12.6
Ref 局部变量 13.2.1
Ref 返回 13.2.2
二进制整数文字 14.3.1
数字文字中的下划线分隔符 14.3.2
从异步方法返回自定义任务类型 需要框架支持(属性)。 5.8
更多类型的表达式成员 8.3.3
C# 7.1
默认文字 14.5
改进类型模式以匹配泛型值 12.4.2
异步入口点(async Task Main) 5.9
推断元组元素名称 11.2.2
C# 7.2
允许条件?:运算符与 ref 一起工作 13.2.3
ref readonly 局部变量和返回类型 返回 ref readonly 的方法只能由理解它们的编译器调用。此外,InAttribute 在编译时是必需的,但自.NET 1.1 和.NET Standard 1.1 以来一直存在。 13.2.4
in 参数 需要 IsReadOnlyAttribute,但如果目标框架中缺少它,则包含在输出中。 13.3
只读结构 如前所述,需要 IsReadOnlyAttribute。 13.4
具有 ref/in 参数的扩展方法 13.5
类似于 ref 的结构 如前所述,需要 IsReadOnlyAttribute。此外,类似于 ref 的结构应用了具有特定消息的 ObsoleteAttribute。类似于 ref-struct 的编译器版本将忽略此属性,但早期编译器将阻止使用该类型。 13.6
对 Span的 stackalloc 支持 需要框架支持。 13.6.2
非尾随命名参数 14.6
私有受保护访问修饰符 14.7
在 0x 或 0b 基数指定符后面的数字字面量中的下划线分隔符 14.3.2
C# 7.3
通过字段访问固定大小的缓冲区,而不使用固定语句 2.5.6
元组的==和!=运算符 元组可用,但没有新的要求。 11.3.6
在字段、属性和构造函数初始化器中使用模式和 out 变量 14.2.2
ref 局部变量的重新赋值 13.2.1
stackalloc 语句中的初始化器 13.6.2
使用 GetPinnableReference 的基于模式的固定语句 13.6.2
现在允许在 Enum 和 Delegate 上使用泛型类型约束 14.8.1
新的未托管类型约束 具有未托管约束的类型和方法只能由足够新版本的编译器使用,以理解它。还需要 UnmanagedType 枚举,自.NET 1.1 和.NET Standard 1.1 以来可用。 14.8.1
自动实现属性的字段属性的放置 14.8.3

^a

这指的是从一个具有兼容但非相同签名的函数构造委托。这与 C# 4 中引入的泛型方差不同。

posted @ 2025-11-23 09:26  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报