元组把少量、固定数目的值打包成一个值,而无需为此声明一个类。("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,值元组对它的实现方式相同。