System.ValueTuple和System.Tuple有什么区别?

180

19
如果您知道元组是什么,就不需要示例代码来理解问题。问题本身很简单:ValueTuple是什么,它与Tuple有何区别? - BoltClock
1
@BoltClock: 这就是我没有回答那个问题的原因。我知道在c#中,有一个Tuple类,我经常使用它,有时也在powershell中调用同一个类。它是一个引用类型。现在看到其他答案,我明白了还有一种值类型,即Valuetuple。如果有示例,我想知道它的用法。 - Ranadip Dutta
2
为什么你要反编译这些代码,当 Roslyn 的源代码在 Github 上是可用的呢? - Zein Makki
1
@user3185569 可能是因为 F12 自动反编译功能比跳转到 GitHub 更方便。 - John Zabroski
@John Zabroski:"F12自动反编译"?!我没有遇到过。你是如何指示你的系统这样做的? - Jens Mander
显示剩余4条评论
6个回答

266

什么是ValueTuples,为什么不用Tuple呢?

一个ValueTuple是一个反映元组的结构体,与原始的System.Tuple类相同。

Tuple和ValueTuple的主要区别是:

  • System.ValueTuple 是一个值类型(结构体),而 System.Tuple 是一个引用类型(类)。在谈论分配和垃圾回收压力时,这是有意义的。
  • System.ValueTuple 不仅仅是一个 struct,它是一个可变的,因此在使用它们时必须小心。想象一下当一个类将 System.ValueTuple 作为字段时会发生什么。
  • System.ValueTuple 通过字段而不是属性公开其项。

直到 C# 7,使用元组并不是很方便。它们的字段名为 Item1Item2 等,并且语言没有像大多数其他语言(如 Python、Scala)那样为它们提供语法糖。

当 .NET 语言设计团队决定在语言级别上合并元组并为它们添加语法糖时,性能是一个重要因素。由于 ValueTuple 是一个值类型,使用它们时可以避免垃圾回收压力,因为(作为实现细节)它们将在堆栈上分配。

此外,结构体通过运行时获得自动(浅层)相等语义,而类则不具备此特性。尽管设计团队确保元组的相等性能更加优化,因此为其实现了自定义相等性。
以下是来自《元组》设计笔记的一段文字:

结构体或类:

如前所述,我建议将元组类型定义为结构体而不是,以避免与它们相关的分配开销。它们应该尽可能轻量级。

可以说,结构体可能会更加昂贵,因为赋值会复制一个较大的值。因此,如果它们被赋值的次数远远超过创建的次数,那么选择结构体就是一个糟糕的选择。

然而,在它们的动机上,元组是短暂的。当部分比整体更重要时,您会使用它们。因此,常见的模式是构造、返回和立即解构它们。在这种情况下,结构体显然更可取。

结构体还有许多其他好处,接下来将变得明显。


示例:

很容易看出,使用System.Tuple很快变得模糊不清。例如,假设我们有一个方法用于计算List<Int>的总和和数量:

public Tuple<int, int> DoStuff(IEnumerable<int> values)
{
    var sum = 0;
    var count = 0;
    
    foreach (var value in values) { sum += value; count++; }
   
    return new Tuple(sum, count);
}

在接收端,我们最终得到:
Tuple<int, int> result = DoStuff(Enumerable.Range(0, 10));

// What is Item1 and what is Item2?
// Which one is the sum and which is the count?
Console.WriteLine(result.Item1);
Console.WriteLine(result.Item2);

你可以将值元组拆解为命名参数的方式是这个特性的真正力量。
public (int sum, int count) DoStuff(IEnumerable<int> values) 
{
    var res = (sum: 0, count: 0);
    foreach (var value in values) { res.sum += value; res.count++; }
    return res;
}

而在接收端:

var result = DoStuff(Enumerable.Range(0, 10));
Console.WriteLine($"Sum: {result.sum}, Count: {result.count}");

或者:

var (sum, count) = DoStuff(Enumerable.Range(0, 10));
Console.WriteLine($"Sum: {sum}, Count: {count}");

编译器好处:

如果我们深入研究之前的示例,我们可以看到当我们要求编译器解构ValueTuple时,它具体是如何解释的。

[return: TupleElementNames(new string[] {
    "sum",
    "count"
})]
public ValueTuple<int, int> DoStuff(IEnumerable<int> values)
{
    ValueTuple<int, int> result;
    result..ctor(0, 0);
    foreach (int current in values)
    {
        result.Item1 += current;
        result.Item2++;
    }
    return result;
}

public void Foo()
{
    ValueTuple<int, int> expr_0E = this.DoStuff(Enumerable.Range(0, 10));
    int item = expr_0E.Item1;
    int arg_1A_0 = expr_0E.Item2;
}

在内部,编译后的代码使用Item1Item2,但由于我们使用的是分解的元组,所有这些都被抽象化了。带有命名参数的元组会被注释为TupleElementNamesAttribute。如果我们使用一个单独的新变量而不是分解,我们会得到:
public void Foo()
{
    ValueTuple<int, int> valueTuple = this.DoStuff(Enumerable.Range(0, 10));
    Console.WriteLine(string.Format("Sum: {0}, Count: {1})", valueTuple.Item1, valueTuple.Item2));
}

请注意,当我们调试我们的应用程序时,编译器仍然需要进行一些魔术操作(通过属性),因为看到 Item1Item2 是很奇怪的。

2
请注意,您还可以使用更简单(并且在我看来更可取)的语法var (sum, count) = DoStuff(Enumerable.Range(0, 10)); - Abion47
3
一些问题: "它们将被分配在堆栈上" -- 这只针对局部变量而言是正确的。毫无疑问,您知道这一点,但不幸的是,您表达的方式可能会让人误以为值类型总是存在于堆栈中,进而延续这个谬误。 - Peter Duniho
@PeterDuniho 谢谢你的回复。我最近一直在做Scala,那里编译器实际上是为记录类型生成自定义相等比较的(所以我已经被它宠坏了)。根据评论更新了答案。 - Yuval Itzchakov
Tuple2是你自己的结构元组类型吗?看起来每个人都需要在他们的个人utils库中拥有一个结构元组。 - usr
@usr 打错字了 :p。已经修复。 - Yuval Itzchakov
显示剩余4条评论

32
TupleValueTuple的区别在于Tuple是引用类型,而ValueTuple是值类型。后者更可取,因为在C# 7中对元组的语言支持更多,但每个元组都分配一个新的对象到堆上将产生性能问题,特别是在不必要时。
然而,在C# 7中,由于添加了元组使用的语法糖,你永远不用显式地使用任何一种类型。例如,在C# 6中,如果你想使用元组返回一个值,你需要执行以下操作:
public Tuple<string, int> GetValues()
{
    // ...
    return new Tuple(stringVal, intVal);
}

var value = GetValues();
string s = value.Item1; 

然而,在C# 7中,您可以使用以下代码:

public (string, int) GetValues()
{
    // ...
    return (stringVal, intVal);
}

var value = GetValues();
string s = value.Item1; 
你甚至可以更进一步,为值赋予名称:
public (string S, int I) GetValues()
{
    // ...
    return (stringVal, intVal);
}

var value = GetValues();
string s = value.S; 

...或者完全拆解元组:

public (string S, int I) GetValues()
{
    // ...
    return (stringVal, intVal);
}

var (S, I) = GetValues();
string s = S;

在 C# 7 之前,元组很少使用,因为它们很笨重冗长,只有在构建数据类/结构体仅用于单个工作实例的情况下才会被使用,这往往不划算。但在 C# 7 中,元组现在具有语言级别的支持,因此使用它们更加清晰和有用。


如果你返回的是(string S, int I),那么它就不再是Tuple类型了,而是ValueTuple类型,这是两种不同的东西。如果你返回一个类的私有字段,那么从性能角度考虑,使用引用类型的Tuple可能更好? - joe
@joe 严格来说,也许是这样,但你可以使用相同的逻辑来永远不使用值类型,最多只是微小的优化。你失去了对ValueTuple提供的语言支持和有用工具的支持,换取了返回时间上的几个纳秒。另一方面,每当需要取消引用(即解箱)值时,您也会失去至少那么多时间。最后,如果您将元组存储为私有类值,则我认为这是代码异味。如果您将数据包作为类数据存储,请使用类/结构,而不是元组。 - Abion47
当你返回一个“稳定”的ValueTuple<structA, structB>(可能是堆上类的字段)时,我感觉到了代码的异味,但每次分配时都必须复制它(请参见已接受的答案)。在权衡方面,你可能是正确的。 - joe
@joe 如果我们谈论像intbool甚至在某种程度上是string这样的原始值类型,那么复制返回值比将其包装在引用类型中并稍后解包更快。当您拥有一个getter函数或属性返回基本类型的类字段时,您就会这样做。返回ValueType也属于性能相同的类别,特别是如果元组的字段本身是原始类型。 - Abion47

15
我查看了TupleValueTuple的源码。区别在于Tuple是一个class,而ValueTuple是一个实现了IEquatablestruct
这意味着如果它们不是同一实例,则Tuple == Tuple将返回false,但如果它们是相同类型并且包含的每个值的Equals返回true,则ValueTuple == ValueTuple将返回true

这不仅仅是那样。 - BoltClock
4
如果您能详细阐述,那么@BoltClock的评论将是有建设性的。 - Peter Morris
3
值类型未必一定存储在堆栈上。区别在于,无论该变量被存储在哪里,它们语义上代表值而非引用。 - Servy

8
除了上面的评论之外,ValueTuple 的一个不幸的问题是,作为值类型,在编译为 IL 时,命名参数会被擦除,因此它们在运行时不可用于序列化。
也就是说,当通过 Json.NET 等方式进行序列化时,您甜美的命名参数仍将变为“Item1”、“Item2”等。

4
所以从技术上讲,这是相似而不是不同之处;) - JAD

6
其他回答忘记了重要的点。我将引用来自source code的XML文档,而不是改写它:
ValueTuple类型(从arity 0到8)包括在C#中的元组和F#中的结构化元组下面的运行时实现。
除了通过语言语法创建外,它们最容易通过ValueTuple.Create工厂方法创建。 System.ValueTuple类型与System.Tuple类型的不同之处在于:
- 它们是结构体而不是类, - 它们是可变的而不是只读的,并且 - 它们的成员(例如Item1、Item2等)是字段而不是属性。
随着这种类型和C# 7.0编译器的引入,您可以轻松编写
(int, string) idAndName = (1, "John");

从一个方法中返回两个值:

private (int, string) GetIdAndName()
{
   //.....
   return (id, name);
}

System.Tuple 相反,你可以更新其成员(Mutable),因为它们是公共读写字段,可以赋予有意义的名称。
(int id, string name) idAndName = (1, "John");
idAndName.name = "New Name";

1
"Arity 0到8"。啊,我喜欢它们包括一个0元组的事实。它可以用作一种空类型,并且在泛型中允许使用,当某些类型参数不需要时,例如class MyNonGenericType:MyGenericType <string,ValueTuple,int>等。 - Jeppe Stig Nielsen

3

针对这两个常识问题,我想补充一点说明:

  • 它们是结构体,而不是类
  • 它们是可变的,而不是只读的

人们可能会认为批量更改值元组很简单:

Original Answer翻译成"最初的回答"

 foreach (var x in listOfValueTuples) { x.Foo = 103; } // wont even compile because x is a value (struct) not a variable

 var d = listOfValueTuples[0].Foo;

有人可能会试图像这样解决:

最初的回答

 // initially *.Foo = 10 for all items
 listOfValueTuples.Select(x => x.Foo = 103);

 var d = listOfValueTuples[0].Foo; // 'd' should be 103 right? wrong! it is '10'

最初的回答:这种奇怪行为的原因是值元组恰好是基于值的(结构体),因此.Select(...)调用在克隆的结构体而不是原始结构体上工作。要解决这个问题,我们必须采取以下措施:
这种奇怪的行为是由于值元组是基于值的结构体,因此.Select(...)方法操作的是克隆的结构体而不是原始结构体。要解决这个问题,我们需要采取以下措施:
 // initially *.Foo = 10 for all items
 listOfValueTuples = listOfValueTuples
     .Select(x => {
         x.Foo = 103;
         return x;
     })
     .ToList();

 var d = listOfValueTuples[0].Foo; // 'd' is now 103 indeed

当然,也可以尝试直接的方法:

最初的回答

   for (var i = 0; i < listOfValueTuples.Length; i++) {
        listOfValueTuples[i].Foo = 103; //this works just fine

        // another alternative approach:
        //
        // var x = listOfValueTuples[i];
        // x.Foo = 103;
        // listOfValueTuples[i] = x; //<-- vital for this alternative approach to work   if you omit this changes wont be saved to the original list
   }

   var d = listOfValueTuples[0].Foo; // 'd' is now 103 indeed

希望以下内容能帮助那些在处理列表中的值元组时感到困惑的人: "最初的回答"

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接