Essential .NET - C# 7.0:细说元组

发布时间 2023-10-09 19:33:51作者: 漫思

Essential .NET - C# 7.0:细说元组

作者 Mark Michaelis

Mark Michaelis在去年 11 月的 Connect(); 专题 (msdn.microsoft.com/magazine/mt790178) 中,我概述了 C# 7.0,并介绍了元组。在本文中,我将重新深入探究元组,并全方位地介绍语法选项。

首先,让我们来想想下面这个问题:为什么要使用元组? 有时,可能会发现,将数据元素组合在一起非常有用。例如,假设要处理国家/地区相关信息,如 2017 年世界上最贫穷的国家/地区马拉维。它的首都是利隆圭,人均国内生产总值 (GDP) 为 226.50 美元。显然,可以为此类数据声明一个类,但它并不是真正典型的名词/对象。它似乎更像是一组相关数据,而不是对象。当然,若要设置 Country 对象(举个例子),所含数据远不止 Name、Capital 和 GDP per capita 这些属性。也可以将每个数据元素存储在各个变量中,但这样做的结果是,数据元素相互没有任何关联。除了变量名称可能共用后缀或前缀之外,226.50 美元与马拉维将无任何关联。另一种方法是,将所有数据组合到一个字符串中。不过,这样做的缺点是,必须先分析各个数据元素,然后才能分别处理这些元素。最后一种方法是创建匿名类型,但这样做同样有局限性。其实,元组足以完全替代匿名类型。我将把这个主题留到本文最后。

最佳做法是使用 C# 7.0 元组。简而言之,使用元组语法,可以在一个语句中分配多个不同类型的变量:

C#
(string country, string capital, double gdpPerCapita) = 
  ("Malawi", "Lilongwe", 226.50);

在此示例中,我不仅要分配多个变量,还要声明它们。

不过,元组还提供其他多种语法选项,分别如图 1 所示。

图 1 - 元组声明和分配示例代码

示例 说明 示例代码
1. 将元组分配到各个已声明的变量中。 (string country, string capital, double gdpPerCapita) =
("Malawi", "Lilongwe", 226.50);
System.Console.WriteLine(
$@"The poorest country in the world in 2017 was {
country}, {capital}: {gdpPerCapita}");
2. 将元组分配到各个已预声明的变量中。 string country;
string capital;
double gdpPerCapita;
(country, capital, gdpPerCapita) =
("Malawi", "Lilongwe", 226.50);
System.Console.WriteLine(
$@"The poorest country in the world in 2017 was {
country}, {capital}: {gdpPerCapita}");
3. 将元组分配到各个已声明的隐式类型化变量中。 (var country, var capital, var gdpPerCapita) =
("Malawi", "Lilongwe", 226.50);
System.Console.WriteLine(
$@"The poorest country in the world in 2017 was {
country}, {capital}: {gdpPerCapita}");
4. 将元组分配到各个已声明且使用分布式语法进行隐式类型化的变量中。 var (country, capital, gdpPerCapita) =
("Malawi", "Lilongwe", 226.50);
System.Console.WriteLine(
$@"The poorest country in the world in 2017 was {
country}, {capital}: {gdpPerCapita}");
5. 声明命名项元组并为其分配元组值,再按名称访问元组项。 (string Name, string Capital, double GdpPerCapita) countryInfo =
("Malawi", "Lilongwe", 226.50);
System.Console.WriteLine(
$@"The poorest country in the world in 2017 was {
countryInfo.Name}, {countryInfo.Capital}: {
countryInfo.GdpPerCapita}");
6. 将命名项元组分配到一个隐式类型化变量中,再按名称访问元组项。 var countryInfo =
(Name:"Malawi", Capital:"Lilongwe", GdpPerCapita:226.50);
System.Console.WriteLine(
$@"The poorest country in the world in 2017 was {
countryInfo.Name}, {countryInfo.Capital}: {
countryInfo.GdpPerCapita}");
7. 将未命名的元组分配到一个隐式类型化变量中,再按 Item-number 属性访问元组元素。 var countryInfo =
("Malawi", "Lilongwe", 226.50);
System.Console.WriteLine(
$@"The poorest country in the world in 2017 was {
countryInfo.Item1}, {countryInfo.Item2}: {
countryInfo.Item3}");
8. 将命名项元组分配到一个隐式类型化变量中,再按 Item-number 属性访问元组元素。 var countryInfo =
(Name:"Malawi", Capital:"Lilongwe", GdpPerCapita:226.50);
System.Console.WriteLine(
$@"The poorest country in the world in 2017 was {
countryInfo.Item1}, {countryInfo.Item2}: {
countryInfo.Item3}");
9. 使用下划线放弃相应的元组部分。 (string name, _, double gdpPerCapita) countryInfo =
("Malawi", "Lilongwe", 226.50);

在前四个示例中,虽然右侧表示元组,但左侧仍表示各个变量,这些变量通过 *元组语法。*分配到一起。即包含两个或多个元素,这些元素用逗号隔开,并用括号组合到一起。(我之所以使用“元组语法”这个词语是因为,从技术角度来讲,编译器在左侧生成的基础数据类型不是元组。) 结果是,虽然开始是右侧组合为元组的值,但将元组分配到左侧后,元组析构为自己的组成部分。在示例 2 中,元组分配到左侧已预声明的变量。但在示例 1、3 和 4 中,变量是在元组语法中进行声明。由于我只要声明变量,因此命名和大小写格式约定将遵循公认的框架设计准则(例如,“务必对局部变量名称使用驼峰式大小写”)。

请注意,虽然可以在元组语法内跨每个变量声明分布隐式类型(变量)(如示例 4 所示),但不能对显式类型(如字符串)这样做。在此示例中,实际上是要声明元组类型,而不只是要使用元组语法,因此需要添加对 System.ValueType NuGet 包(至少为 .NET Standard 2.0)的引用。由于各个元组项的数据类型可以不同,因此跨所有元素分布显式类型名称不一定可行,除非所有项的数据类型都完全相同(但即便如此,编译器也不允许这样做)。

在示例 5 中,我在左侧声明了元组,并在右侧分配了此元组。请注意,此元组包含的是命名项,稍后可以引用这些名称,将项值重新从此元组中检索出来。正因为此,可以在 System.Console.WriteLine 语句中使用 countryInfo.Name、countryInfo.Capital 和 countryInfo.GdpPerCapita 语法。在左侧声明元组的结果是,将这些变量组合到一个变量 (countryInfo) 中,以便可以通过这个变量访问各个组成部分。这一点很有用,因为稍后可以将这个变量传递到其他方法中,并且这些方法也可以访问元组内的各个项。

如前面所述,使用元组语法定义的变量使用驼峰式大小写。不过,元组项名称约定的定义并不完善。建议在元组的行为类似参数(如在元组语法使用 out 参数前返回多个值)时,使用参数命名约定。也可以使用 Pascal 命名法,遵循公共字段和属性的命名约定。强烈建议根据标识符大写规则 (itl.tc/caprfi) 使用后一种方法。元组项名称呈现为元组成员,所有(公共)成员(可使用点运算符访问)遵循的命名约定是 Pascal 命名法。

示例 6 的功能与示例 5 相同,区别在于前者在右侧使用命名元组项值,并在左侧使用隐式类型声明。虽然项名称保留到隐式类型化变量中,但仍可用于 WriteLine 语句。当然,这样就可以为左侧项命名与右侧不同的名称。虽然 C# 编译器允许这样做,但会发出警告,指明由于左侧的名称优先级更高,因此将忽略右侧的项名称。

若未指定任何项名称,仍可通过分配的元组变量获取各个元素。不过,名称将是 Item1、Item2 等,如示例 7 所示。实际上,始终可以对元组使用 ItemX 名称,即使指定自定义名称(见示例 8),也不例外。不过,使用支持 C# 7.0 的各类最新版 Visual Studio 等 IDE 工具时,IntelliSense 下拉列表中不显示 ItemX 属性。这是件好事,因为指定的名称很可能更可取。

如示例 9 所示,可以使用下划线排除元组分配部分;这称为“放弃”。

元组是用于将数据封装到一个对象的轻型解决方案,就像用袋子将从商店购买的杂项物品装在一起一样。与数组不同,元组包含的项数据类型可以不同,几乎不受任何限制(尽管不允许使用指针),但需要由代码标识,且不能在运行时更改,这两点限制除外。此外,不同于数组的是,编译时也会对元组内的项数量进行硬编码。最后,无法向元组添加自定义行为(扩展方法除外)。如果需要添加与已封装数据相关的行为,首选方法是利用面向对象的编程并定义类。

System.ValueTuple<…> 类型

C# 编译器生成依赖一组泛型值类型(结构)(如 System.ValueTuple<T1, T2, T3>)的代码,作为图 1 示例中的右侧所有元组实例的元组语法基础实现代码。同样,从示例 5 开始,同一组 System.ValueTuple<...> 泛型值类型也用于左侧数据类型。与元组类型的限制一样,只能添加与比较和等同性相关的方法。不过,或许想不到的是,没有 ItemX 属性,取而代之的是读写字段(似乎违背了 itl.tc/CS7TuplesBreaksGuidelines 中介绍的最基本 .NET 编程准则)。

除了不符合编程准则外,还出现了另一个行为问题。鉴于 System.ValueTuple<...> 定义中不包含自定义项名称及其类型,那么各个自定义项名称怎么可能貌似成为 System.ValueTuple<...> 类型的成员,又怎么可能作为此类型的成员获得访问呢?

令人惊讶的是(尤其对于熟悉匿名类型实现的人来说),编译器没有为与自定义名称对应的成员生成基础公共中间语言 (CIL) 代码。不过,即使没有使用自定义名称的基础成员,但从 C# 视角来看,(似乎)有这样的成员。

对于所有命名元组局部变量示例,例如:

C#
var countryInfo = (Name: "Malawi", Capital: "Lilongwe", GdpPerCapita: 226.50)

编译器显然可以知道元组剩余作用域对应的名称,因为此作用域包括在声明它的成员之中。其实,编译器(和 IDE)相当依赖此作用域,以便可以按名称访问各项。也就是说,编译器在元组声明内查找项名称,并利用它们允许代码在相应作用域内使用这些名称。也是出于这个原因,IDE IntelliSense 中没有显示 ItemX 方法作为元组成员(IDE 直接忽略它们,并将它们替换为命名项)。

对于编译器而言,在成员内确定项名称是没有问题的,但如果元组在成员外公开(如来自不同程序集中方法的参数或返回值,因此可能没有可用的源代码),会发生些什么? 对于属于 API(无论是公共 API 还是专用 API)的所有元组,编译器均以属性的形式将项名称添加到成员的元数据中。例如,下面的代码:

C#
[return: System.Runtime.CompilerServices.TupleElementNames(
  new string[] {"First", "Second"})]
public System.ValueTuple<string, string> ParseNames(string fullName)
{
  // ...
}

是编译器为下列代码生成的 C# 等效代码:

C#
public (string First, string Second) ParseNames(string fullName)

另一相关问题是,使用显式 System.ValueTuple<…> 数据类型时,C# 7.0 禁用自定义项名称。因此,如果替换图 1 ****中示例 8 的变量,最后会看到警告,指明将忽略各个项名称。

关于 System.ValueTuple<…>,还请注意以下几点:

  • 总共有 8 个泛型 System.ValueTuple 结构,对应于支持最多包含 7 个项的元组。对于第 8 个元组,最后一个类型参数 System.ValueTuple<T1, T2, T3, T4, T5, T6, T7, TRest> 允许指定其他值元组,从而可以支持 n 个项。例如,如果指定包含 8 个参数的元组,编译器会自动生成 System.ValueTuple<T1, T2, T3, T4, T5, T6, T7, System.ValueTuple<TSub1>> 作为基础实现类型。(为了保持完整性,可以有 System.Value<T1>,但实际上只会将它作为类型直接使用。编译器绝不会直接使用它,因为 C# 元组语法最少需要两个项。)
  • 可使用非泛型 System.ValueTuple 作为元组工厂,其中包含对应于各个值元组实参的 Create 方法。至少对于 C# 7.0(或更高版本)程序员而言,使用元组文本(如 var t1 = (“Inigo Montoya”, 42))的便捷性取代了 Create 方法。
  • 从一切实用角度来看,C# 开发者基本上可以忽略 System.ValueTuple 和 System.ValueTuple<T>。

.NET Framework 4.5 曾随附另一种元组类型,即 System.Tuple<…>。当时,此类型预计会成为今后的核心元组实现类型。不过,在 C# 支持元组语法后,人们便发现,值类型的性能通常更高,因此引入了 System.ValueTuple<…>,并在所有情况下有效取代了 System.Tuple<…>(例外情况是,要保持与依赖 System.Tuple<…> 的现有 API 的后向兼容性)。

总结

最初引入时,许多人都没有发现,新增的 C# 7.0 元组几乎可以取代匿名类型,并能提供其他功能。例如,可从方法返回元组,并且项名称保留到 API 中,以便能够使用有意义的名称来取代 ItemX 类型命名。此外,与匿名类型一样,元组甚至可以表示复杂的分层结构,如更复杂的 LINQ 查询结构(尽管如此,与匿名类型一样,开发者应谨慎执行此操作)。不过,这可能导致元组值类型超过 128 个字节,因此可能成为使用匿名类型的极端情况,因为它是引用类型。除了这些极端情况(又例如,通过典型反射进行访问)外,几乎没有理由在使用 C# 7.0 或更高版本编程时使用匿名类型。

使用元组类型对象进行编程由来已久(如前所述,.NET Framework 4 引入了元组类 System.Tuple<…>,但在此之前,它已用于 Silverlight)。不过,此类解决方案一直没有随附 C# 语法,只随附了 .NET API 而已。C# 7.0 引入了一流的元组语法,支持 var tuple = (42, “Inigo Montoya”) 等文本、隐式类型化、强类型化、使用公共 API,并集成了对命名 ItemX 数据的 IDE 支持等。得承认,可能不会每个 C# 文件都要用到,但在有需要时,可能会感激它的存在。并且,与备用的 out 参数或匿名类型相比,将会更青睐元组语法。

本文的大部分内容都来自我的《Essential C#》一书 (IntelliTect.com/EssentialCSharp),目前我正在撰写此书的最新版本《Essential C# 7.0》。 有关本主题的详细信息,请参阅此书的第 3 章。

元组项命名准则

务必对使用元组语法声明的所有变量使用驼峰式大小写。

****考虑对所有元组项名称使用 Pascal 命名法。


Mark Michaelis 是 IntelliTect 的创始人,担任首席技术架构师和培训师。在近二十年的时间里,他一直是 Microsoft MVP,并且自 2007 年以来一直担任 Microsoft 区域总监。Michaelis 还是多个 Microsoft 软件设计评审团队(包括 C#、Microsoft Azure、SharePoint 和 Visual Studio ALM)的成员。他在开发者会议上发表过演讲,并撰写了大量书籍,包括最新版《Essential C# 6.0(第 5 版)》(itl.tc/EssentialCSharp)。可通过他的 Facebook facebook.com/Mark.Michaelis、博客 IntelliTect.com/Mark、Twitter @markmichaelis 或电子邮件 mark@IntelliTect.com 与他取得联系。

衷心感谢以下 Microsoft 技术专家对本文的审阅:Mads Torgersen