Menu

C# 元组:命名元组、返回多个值与解构

C# 元组无需声明类型就能把几个值组合成一个。学习值元组的语法、命名元素、从方法返回多个值、用 var (a, b) 解构、在字典中用元组作键,以及 ValueTuple 与旧的 System.Tuple 有何不同。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

元组把少量、固定数目的值打包成一个值,而无需为此声明一个类。("Ana", 31) 是一个由 string 和 int 组成的元组。从方法返回两三样东西,或者在方法内部保存临时的数据对,元组都是地道的写法。

创建元组

类型同样用括号来写:(string, int) 是一个元组类型,第一个元素是 string,第二个是 int。

输出:

Ana is 31
(3, 4)
179.70
(Ana, 32)

没有名称时,元素就是 Item1、Item2、Item3 等等。这些元组是 System.ValueTuple 结构体,所以把一个元组赋给另一个变量会复制其中的值,修改副本不会影响原来的元组。

命名元素

Item1 说明不了这个值是什么。给元素起个名字,可以写在类型中,也可以写在字面量中:

输出:

Ana, 31
Desk lamp costs 24.99
Ana
Ana

名称只是编译时的便利。在运行时,上面两个元组都是普通的 ValueTuple<string, int> 值,所以把 person 赋给一个名称不同的元组也能通过编译:只有元素的类型和顺序必须一致。

C# 7.1 及更高版本还会从字面量中的变量推断名称,所以 var t = (name, age); 就能让你使用 t.name 和 t.age,而不必把名称写两遍。在 C# 7.0 中,这些元素只能叫 Item1 和 Item2。

从方法返回多个值

这是语言加入元组的主要原因。在此之前,可选的办法是 out 参数、一个一次性的类,或者带着匿名 Item1 的 System.Tuple。

输出:

Lowest 60, highest 95
Average 78.75

返回类型中的元素名称,就是调用方看到的名称。对比一下用 out 参数写的同一个方法:

static void Stats(int[] scores, out int min, out int max, out double average) { ... }

Stats(scores, out int min, out int max, out double avg);

对于 TryParse 这种形式,即用一个 bool 表示是否成功、同时把值一起带回来,out 参数仍然是惯例。如果是“计算几样东西并把它们返回”,元组读起来更好。请参阅 ref 和 out。

解构

解构用一条语句把元组拆成多个独立的变量:

输出:

Lima 24.5
Lima
24.5
2 1

var (city, temp) 中的变量名由你决定;它们不必和元组的元素名称一致,因为解构是按位置进行的。交换写法 (a, b) = (b, a) 会先用旧值构造一个元组,然后再赋值,所以两个值都不会丢失。

你自己的类可以通过声明一个带 out 参数的 Deconstruct 方法来支持解构。从 C# 7 搭配 .NET Core 2.0 或更高版本开始,KeyValuePair 也有这个方法,所以遍历字典时可以直接在循环头里拆开每个条目:

class Point
{
    public int X { get; }
    public int Y { get; }
    public Point(int x, int y) { X = x; Y = y; }
    public void Deconstruct(out int x, out int y) { x = X; y = Y; }
}

var (x, y) = new Point(3, 4);

foreach (var (name, score) in scoresByName)   // Dictionary<string, int>
{
    Console.WriteLine($"{name}: {score}");
}

同样的 foreach (var (a, b) in listOfTuples) 写法也适用于元组列表。把整个元组作为一个循环变量,再读取它的命名元素,就像下一节那样,效果也一样好。

列表、LINQ 和字典中的元组

命名元组的列表,是在方法内部保存多行数据的一种轻量方式:

输出:

Chloe 95
Ana 88
Ben 72
(Ana, B), (Chloe, A)
Ben
False

ValueTuple 按元素实现了 Equals 和 GetHashCode,所以在两个不同地方构造的 (2, 7) 会找到同一个字典条目。这让元组成为最简单的复合键:不需要类,不需要手写哈希函数,也不需要 row + ":" + col 这样的字符串拼接。

元组的相等比较

Equals 逐个元素进行比较,从元组被引入时就可以使用:

输出:

True
False

C# 7.3 为元组新增了 == 和 !=,它们会编译成同样的逐元素比较,同样忽略名称:

if ((order.Status, order.Paid) == ("shipped", true)) { ... }
Console.WriteLine((1, "x") == (1, "x"));   // True

System.Tuple 与 ValueTuple

.NET 4 引入了 System.Tuple,它是一个用 Tuple.Create 创建的类。它仍然存在于框架中,也仍然出现在较老的代码和 API 里,所以搜索“C# tuple”时常常会搜到它。

ValueTuple(C# 7)System.Tuple(.NET 4)
语法(1, "a")、(int, string)Tuple.Create(1, "a")、Tuple<int, string>
种类结构体(值类型)类(引用类型)
元素名称支持不支持,只有 Item1、Item2
可变是,元素是字段否,是只读属性
解构支持支持,通过扩展方法

输出:

Ben 25
(Ben, 25)

ToValueTuple() 和 ToTuple() 可以在两者之间转换。新代码请优先使用值元组:它们不在堆上分配任何东西,而且可以带名称。

什么时候改用类或 record

当这种组合是局部的、一目了然的时候,元组最合适:一个方法返回两个数字,一个 LINQ 查询把一对值传给下一步,一个字典需要由两部分组成的键。出现以下情况时,请改用有名称的类型:

  • 同样的结构出现在好几个公共方法签名中。五个地方都出现 (string, string, int),说明有一个类还没写出来。
  • 这些值需要行为、验证,或者元素超过三四个。
  • 数据要跨越边界,比如序列化。名称在运行时并不存在,所以 Newtonsoft.Json 会写出 Item1 和 Item2,而 System.Text.Json 会写出 {},因为元组元素是字段,它默认会跳过字段。

C# 9 的 record 能给你元组的大部分便利,同时还有一个真正的名称。record Score(string Name, int Points); 自带值相等、可读的 ToString 和解构。请参阅 record。

常见错误

  • 以为名称在运行时还存在。 反射和序列化器看到的是 Item1、Item2(或者什么都看不到)。
  • 修改的是副本。 元组是结构体:var t2 = t1; t2.Item1 = 5; 不会改变 t1。
  • 元素很多的元组。 超过三四个时,有名称的类型读起来更好。
  • 搞错解构的顺序。 解构是按位置进行的:对于一个 (Name, Age) 元组,var (age, name) = person; 会把名字放进 age,而且由于 var 会推断类型,没有任何警告提醒你。

常见问题

C# 中如何从方法返回多个值?

返回一个元组:把返回类型声明为 (int Min, int Max),然后 return (lowest, highest);。调用方读取 result.Min 和 result.Max,或者用 var (min, max) = MinMax(data); 解构。其他选择是 out 参数(适合 TryParse 模式),或者在结果本身有独立含义时,使用一个小型的类或 record。

C# 中的命名元组是什么?

就是元素带有名称、而不是 Item1、Item2 的元组:(string Name, int Age) person = ("Ana", 31); 让你可以写 person.Name。这些名称只存在于编译时;底层它仍然是一个 ValueTuple<string, int>,Item1 依然可用。

C# 中 Tuple 和 ValueTuple 有什么区别?

System.Tuple(来自 .NET 4)是一个类:在堆上分配、不可变,元素永远只能叫 Item1、Item2。System.ValueTuple(C# 7)是一个带有可变字段的结构体,支持元素名称和 (a, b) 语法,所有元组字面量创建的都是它。新代码请使用值元组。

C# 中的元组解构是如何工作的?

var (name, age) = person; 声明两个变量,并按顺序把元组的元素赋给它们。你也可以用 (a, b) = (b, a) 给已有的变量赋值,这会交换它们;还可以用弃元 _ 跳过某些元素:var (_, age) = person;。

C# 中可以用 == 比较元组吗?

从 C# 7.3 开始,== 和 != 会逐个元素比较元组:(1, "a") == (1, "a") 的结果是 true。名称会被忽略,只有位置和值才重要。在 C# 7.3 之前,请使用 Equals,值元组对它的实现方式相同。

Coddy 编程语言插图

和 Coddy 一起学编程

开始