3.4 元组
有时需要合并数据元素。例如,2019年全球最贫穷的国家是首都位于Juba(朱巴)的南苏丹,人均GDP为275.18美元。利用目前讲过的编程构造,可将上述每个数据元素存储到单独的变量中,但它们相互无关联。换言之,看不出275.18和南苏丹有什么联系。为解决该问题,第一个方案是在变量名中使用统一的后缀或前缀,第二个方案是将所有数据合并到一个字符串中,但缺点是需要解析字符串才能处理单独的数据元素。
C# 7.0提供了第三个方案:元组(tuple)。元组允许在一条语句中完成对所有变量的赋值,如下所示:
表3.1总结了元组的其他语法形式。
表3.1 元组声明和赋值的示例代码
前四个例子虽然右侧是元组,但左侧仍然是单独的变量,只是用元组语法一起赋值。在这种语法中,两个或更多元素以逗号分隔,放到一对圆括号中进行组合。(我使用“元组语法”一词是因为编译器为左侧生成的基础数据类型技术上说并非元组。)结果是虽然右侧的值合并成元组,但在向左侧赋值的过程中,元组已被解构为它的组成部分。例2左边被赋值的变量是事先声明好的,但例1、3和4的变量是在元组语法中声明的。由于只是声明变量,所以命名和大小写应遵循第1章的设计规范,例如有一条是“要为局部变量使用camelCase风格命名。”
虽然隐式类型(var)在例4中用元组语法平均分配给每个变量声明,但这里的var绝不可以替换成显式类型(如string)。元组宗旨是允许每一项都有不同数据类型,所以为每一项都指定同一个显式类型名称跟这个宗旨冲突(即使类型真的一样,编译器也不允许指定显式类型)。
例5在左侧声明一个元组,将右侧的元组赋给它。注意元组含具名项,随后可引用这些名称来获取右侧元组中的值。这正是能在System.Console.WriteLine语句中使用countryInfo.Name、countryInfo.Capital和countryInfo.GdpPerCapita语法的原因。在左侧声明元组造成多个变量组合到单个元组变量(countryInfo)中。然后可利用元组变量来访问其组成部分。如第4章所述,这样的设计允许将该元组变量传给其他方法。那些方法能轻松访问元组中的项。
前面说过,用元组语法定义的变量应遵守camelCase大小写规则。但该规则并未得到彻底贯彻。有人提倡当元组的行为和参数相似时(类似于元组语法出现之前用于返回多个值的out参数),这些名称应使用参数命名规则。另一个方案是PascalCase大小写,这是类型成员(属性、函数和公共字段,参见第5章和第6章的讨论)的命名规范。个人强烈推荐PascalCase规范,从而和C#/.NET成员标识符的大小写规范一致。但由于这并不是被广泛接受的规范,所以我在设计规范“考虑为所有元组项名称使用PascalCase大小写风格”中使用“考虑”而非“要”一词,
设计规范
·要为元组语法的变量声明使用camelCase大小写规范。
·考虑为所有元组项名称使用PascalCase大小写风格。
例6提供和例5一样的功能,只是右侧元组使用了具名元组项,左侧使用了隐式类型声明。但元组项名称会传入隐式类型变量,所以WriteLine语句仍可使用它们。当然,左侧可使用和右侧不同的元组项名称。C#编译器允许这样做但会显示警告,指出右侧元组项名称会被忽略,因为此时左侧的优先。
不指定元组项名称,被赋值的元组变量中的单独元素仍可访问,只是名称是Item1、Item2、......,如例7所示。事实上,即便提供了自定义名称,ItemX名称始终都能使用,如例8所示。但在使用Visual Studio这样的IDE工具时,ItemX属性不会出现在“智能感知”的下拉列表中。这是好事,因为自己提供的名称理论上应该更好。如例9所示,可用下划线丢弃部分元组项的赋值,这称为弃元(discard)。
例10展示的元组项名称推断功能是自C# 7.1引入的。如本例所示,元组项名称可根据变量名(甚至属性名)来推断。
元组是在对象中封装数据的轻量级方案,有点像你用来装杂货的购物袋。和稍后讨论的数组不同,元组项的数据类型可以不一样,没有限制[1],只是它们由编译器决定,不能在运行时改变。另外,元组项数量也是在编译时硬编码好的。最后,不能为元组添加自定义行为(扩展方法不在此列)。如果需要和封装数据关联的行为,则应使用面向对象编程并定义一个类,具体在第6章讲述。
高级主题:System.ValueTuple<...>类型
在表3.1的示例中,C#为赋值操作符右侧的所有元组实例生成的代码都基于一组泛型值类型(结构),例如System.ValueTuple<T1, T2, T3>。类似地,同一组System.ValueTuple<...>泛型值类型用于从例5开始的左侧数据类型。元组类型唯一包含的方法是跟比较和相等性测试有关的方法,这符合预期。
既然自定义元组项名称及其类型没有包含在System.ValueTuple<...>定义中,为什么每个自定义元组项名称都好像是System.ValueTuple<...>类型的成员,并能以成员的形式访问呢?让人(尤其是那些熟悉匿名类型实现的人)惊讶的是,编译器根本没有为那些和自定义名称对应的“成员”生成底层CIL代码,但从C#的角度看,又似乎存在这样的成员。
对于表3.1的所有具名元组例子,编译器在元组剩下的作用域中显然知道那些名称。事实上,编译器(和IDE)正是依赖该作用域通过项的名称来访问它们。换言之,编译器查找元组声明中的项名称,并允许代码访问还在作用域中的项。也正是因为这一点,IDE的“智能感知”不显示底层的ItemX成员。它们会被忽略,替换成显式命名的项。
编译器能判断作用域中的元组项名称,这一点还好理解,但如果元组要对外公开,比如作为另一个程序集中的一个方法的参数或返回值使用(另一个程序集可能看不到你的源代码),那么会发生什么?其实对于作为API(公共或私有)一部分的所有元组,编译器都会以“属性”(attribute)的形式将元组项名称添加到成员元数据中。例如,代码清单3.6展示了编译器为以下方法生成的CIL代码的C#形式:
代码清单3.6 编译器为返回ValueTuple的方法生成的CIL代码
另外要注意,如显式使用System.ValueTuple<...>类型,C# 7.0就不允许使用自定义的元组项名称。所以表3.1的例8如果将var替换成该类型,编译器会警告所有项的名称将被忽略。
下面总结了和System.ValueTuple<...>有关的其他注意事项:
·共有8个泛型System.ValueTuple<...>,前7个最大支持七元组。第8个是System.ValueTuple<T1, T2, T3, T4, T5, T6, T7, TRest>,可为最后一个类型参数指定另一个ValueTuple,从而支持n元组。例如,编译器自动为8个参数的元组生成System.ValueTuple<T1, T2, T3, T4, T5, T6, T7, System.ValueTuple<TSub1>>作为底层实现类型。System.Value<T1>的存在只是为了补全,很少使用,因为C#元组语法要求至少两项。
·有一个非泛型System.ValueTuple类型作为元组工厂使用,提供了和所有ValueTuple元数[2]对应的Create()方法。C# 7.0以后基本用不着Create()方法,因为像var t1=("Inigo Montoya", 42)这样的元组字面值实在太好用了。
·C#程序员实际编程时完全可以忽略System.ValueTuple和System.ValueTuple<T>。
还有一个元组类型是Microsoft .NET Framework 4.5引入的System.Tuple<...>。当时是想把它打造成核心元组实现。但在C#中引入元组语法时才意识到值类型性能更佳,所以量身定制了System.ValueTuple<...>,它在所有情况下都代替了System.Tuple<...>(除非要向后兼容依赖System.Tuple<...>的遗留API)。
[1] 数据类型不可以为指针。我们将在第23章详细介绍指针。
[2] 元数的英文是arity,源自像unary(arity=1)、binary(arity=2)、ternary(arity=2)这样的单词。——译者注