c# 元组

C# 7.0 中的新增功能文章中的“元组”一节对其进行了概述。

在本文中,你将了解用于控制 C# 7.0 及更高版本中的元组的语言规则、这些规则的各种用法,以及有关如何使用元组的初步指导。

备注

System.ValueTuple

这些新类型添加到 .NET 标准 API 并作为框架的一部分交付后,将删除 NuGet 包要求。

借助元组,可以更轻松地对该单个对象中的多个值打包。

通过新的语言功能,可对元组中的各元素进行声明并为其赋予有意义的语义名称。

因此,元组的语言支持使用新的 ValueTuple 结构。

很多时候,你其实只是想存储单个对象中的多个值而已。

但是,这意味着在要求永久性的场合无法使用元组。

我们来探讨一下它们之间的差异。

命名元组和未命名元组

如果不为元组提供任何备用字段名称,即表示创建了一个未命名元组:

var unnamed = ("one", "two");

上例中的元组已使用文本常量进行初始化,并且不会有 C# 7.1 中使用“元组字段名称投影”创建的元素名称。

其中一种方式是在元组初始化过程中指定名称:

var named = (first: "one", second: "two");

已编译的 Microsoft 中间语言 (MSIL) 不包括为这些元素赋予的名称。

以下代码用于创建名为 accumulation 的元组,包含元素 count(整数)和 sum(双精度)。

var sum = 12.5;
var count = 5;
var accumulation = (count, sum);

TransformNames 列表属性,该属性包含为元组中的每个元素赋予的名称。

备注

Visual Studio 等开发工具还读取其元数据,并提供 IntelliSense 和其他使用元数据字段名称的功能。

请务必理解新元组和 ValueTuple 类型的这些基础知识,这样才能理解将命名元组赋给彼此的规则。

元组投影初始值设定项

例如,在以下初始值设定项中,元素为 explicitFieldOneexplicitFieldTwo,而非 localVariableOnelocalVariableTwo

var localVariableOne = 5;
var localVariableTwo = "some text";

var tuple = (explicitFieldOne: localVariableOne, explicitFieldTwo: localVariableTwo);

以下初始化表达式具有字段名称 Item1其值为 42stringContent(其值为“The answer to everything”):

var stringContent = "The answer to everything";
var mixedTuple = (42, stringContent);

在以下两种情况下,不会将候选字段名称投影到元组字段:

    1. Rest
    2. 候选名称重复了另一元组的显式或隐式字段名称时。

以下示例说明了这两个条件:

var ToString = "This is some text";
var one = 1;
var Item1 = 5;
var projections = (ToString, one, Item1);
// Accessing the first field:
Console.WriteLine(projections.Item1);
// There is no semantic name 'ToString'
// Accessing the second field:
Console.WriteLine(projections.one);
Console.WriteLine(projections.Item2);
// Accessing the third field:
Console.WriteLine(projections.Item3);
// There is no semantic name 'Item1`.

var pt1 = (X: 3, Y: 0);
var pt2 = (X: 3, Y: 4);

var xCoords = (pt1.X, pt2.X);
// There are no semantic names for the fields
// of xCoords. 

// Accessing the first field:
Console.WriteLine(xCoords.Item1);
// Accessing the second field:
Console.WriteLine(xCoords.Item2);

这些情况不会导致编译器错误,因为当元组字段名称投影不可用时,它将成为使用 C# 7.0 编写的代码的一项重大改变。

相等和元组

以下代码示例演示两对整数的相等比较:

var left = (a: 5, b: 10);
var right = (a: 5, b: 10);
Console.WriteLine(left == right); // displays 'true'

提升转换,如以下代码中所示:

var left = (a: 5, b: 10);
var right = (a: 5, b: 10);
(int a, int b)? nullableTuple = right;
Console.WriteLine(left == nullableTuple); // Also true

以下示例演示整数 2 元组可以与较长的 2 元组进行比较,因为进行了从整数元组到较长元组的隐式转换:

// lifted conversions
var left = (a: 5, b: 10);
(int? a, int? b) nullableMembers = (5, 10);
Console.WriteLine(left == nullableMembers); // Also true

// converted type of left is (long, long)
(long a, long b) longTuple = (5, 10);
Console.WriteLine(left == longTuple); // Also true

// comparisons performed on (long, long) tuples
(long a, int b) longFirst = (5, 10);
(int a, long b) longSecond = (5, 10);
Console.WriteLine(longFirst == longSecond); // Also true

在两个操作数都为元组文本的情况下,警告位于右侧操作数,如以下示例中所述:

(int a, string b) pair = (1, "Hello");
(int z, string y) another = (1, "Hello");
Console.WriteLine(pair == another); // true. Member names don't participate.
Console.WriteLine(pair == (z: 1, y: "Hello")); // warning: literal contains different member names

元组相等通过嵌套元组比较每个操作数的“形状”,如以下示例中所示:

(int, (int, int)) nestedTuple = (1, (2, 3));
Console.WriteLine(nestedTuple == (1, (2, 3)) );

赋值和元组

让我们看一下元组类型之间允许的赋值类型。

注意以下示例中使用的这些变量:

// The 'arity' and 'shape' of all these tuples are compatible. 
// The only difference is the field names being used.
var unnamed = (42, "The meaning of life");
var anonymous = (16, "a perfect square");
var named = (Answer: 42, Message: "The meaning of life");
var differentNamed = (SecretConstant: 42, Label: "The meaning of life");

这两个元组具有不同的元素名称。

因此可进行以下赋值:

unnamed = named;

named = unnamed;
// 'named' still has fields that can be referred to
// as 'answer', and 'message':
Console.WriteLine($"{named.Answer}, {named.Message}");

// unnamed to unnamed:
anonymous = unnamed;

// named tuples.
named = differentNamed;
// The field names are not assigned. 'named' still has 
// fields that can be referred to as 'answer' and 'message':
Console.WriteLine($"{named.Answer}, {named.Message}");

// With implicit conversions:
// int can be implicitly converted to long
(long, string) conversion = named;

元素的赋值顺序遵循元素在元组中的顺序。

元素类型或数量不同的元组不可赋值:

// Does not compile.
// CS0029: Cannot assign Tuple(int,int,int) to Tuple(int, string)
var differentShape = (1, 2, 3);
named = differentShape;

作为方法返回值的元组

以下面的方法为例,该方法计算一个数列的标准差:

public static double StandardDeviation(IEnumerable<double> sequence)
{
    // Step 1: Compute the Mean:
    var mean = sequence.Average();

    // Step 2: Compute the square of the differences between each number 
    // and the mean:
    var squaredMeanDifferences = from n in sequence
                                 select (n - mean) * (n - mean);
    // Step 3: Find the mean of those squared differences:
    var meanOfSquaredDifferences = squaredMeanDifferences.Average();

    // Step 4: Standard Deviation is the square root of that mean:
    var standardDeviation = Math.Sqrt(meanOfSquaredDifferences);
    return standardDeviation;
}

备注

有关这些标准差公式之间的区别的更多详细信息,请查看统计信息文本。

(请记住,LINQ 查询进行迟缓计算,因此,在计算与平均数的差以及这些差的平均数时只需计算一次。)

此计算公式在计算数列时生成两个值:数列中所有项的总和,以及每个平方值的总和:

public static double StandardDeviation(IEnumerable<double> sequence)
{
    double sum = 0;
    double sumOfSquares = 0;
    double count = 0;

    foreach (var item in sequence)
    {
        count++;
        sum += item;
        sumOfSquares += item * item;
    }

    var variance = sumOfSquares - sum * sum / count;
    return Math.Sqrt(variance / count);
}

所有这三个值都可以作为一个元组返回。

以下是更新后的版本:

public static double StandardDeviation(IEnumerable<double> sequence)
{
    var computation = (Count: 0, Sum: 0.0, SumOfSquares: 0.0);

    foreach (var item in sequence)
    {
        computation.Count++;
        computation.Sum += item;
        computation.SumOfSquares += item * item;
    }

    var variance = computation.SumOfSquares - computation.Sum * computation.Sum / computation.Count;
    return Math.Sqrt(variance / computation.Count);
}

从而得到一个 private static 方法,该方法返回具有 SumSumOfSquaresCount 这三个值的元组类型:

public static double StandardDeviation(IEnumerable<double> sequence)
{
    (int Count, double Sum, double SumOfSquares) computation = ComputeSumsAnSumOfSquares(sequence);

    var variance = computation.SumOfSquares - computation.Sum * computation.Sum / computation.Count;
    return Math.Sqrt(variance / computation.Count);
}

private static (int Count, double Sum, double SumOfSquares) ComputeSumsAnSumOfSquares(IEnumerable<double> sequence)
{
    var computation = (count: 0, sum: 0.0, sumOfSquares: 0.0);

    foreach (var item in sequence)
    {
        computation.count++;
        computation.sum += item;
        computation.sumOfSquares += item * item;
    }

    return computation;
}

下面的代码演示了最终版本:

public static double StandardDeviation(IEnumerable<double> sequence)
{
    var computation = ComputeSumAndSumOfSquares(sequence);

    var variance = computation.SumOfSquares - computation.Sum * computation.Sum / computation.Count;
    return Math.Sqrt(variance / computation.Count);
}

private static (int Count, double Sum, double SumOfSquares) ComputeSumAndSumOfSquares(IEnumerable<double> sequence)
{
    double sum = 0;
    double sumOfSquares = 0;
    int count = 0;

    foreach (var item in sequence)
    {
        count++;
        sum += item;
        sumOfSquares += item * item;
    }

    return (count, sum, sumOfSquares);
}

这个最终版本可用于任何需要这三个值或其任意子集的方法。

该语言支持其他用于管理这些元组返回方法中的元素名称的选项。

可以删除返回值声明中的字段名称,返回一个未命名元组:

private static (double, double, int) ComputeSumAndSumOfSquares(IEnumerable<double> sequence)
{
    double sum = 0;
    double sumOfSquares = 0;
    int count = 0;

    foreach (var item in sequence)
    {
        count++;
        sum += item;
        sumOfSquares += item * item;
    }

    return (sum, sumOfSquares, count);
}

建议为从方法返回的元组的元素提供语义名称。

最终投影的结果通常包含被选中的对象的某些(而不是全部)属性。

也可以将 objectdynamic 用作结果类型,但这种备用方法会产生高昂的性能成本。

可以定义一个与下面类似的类,以表示待办事项列表中的某一项:

public class ToDoItem
{
    public int ID { get; set; }
    public bool IsDone { get; set; }
    public DateTime DueDate { get; set; }
    public string Title { get; set; }
    public string Notes { get; set; }    
}

返回一个元组序列的方法很好地表达了该设计:

internal IEnumerable<(int ID, string Title)> GetCurrentItemsMobileList()
{
    return from item in AllItems
           where !item.IsDone
           orderby item.DueDate
           select (item.ID, item.Title);
}

备注

在以上代码中,查询投影中的 select 语句将创建具有元素 IDTitle 的元组。

命名元组还承载了静态类型信息,因此无需使用高成本的运行时功能(如反射或动态绑定)来处理结果。

析构

首先,可在括号内显式声明每个字段的类型,为元组中的每个元素创建离散变量:

public static double StandardDeviation(IEnumerable<double> sequence)
{
    (int count, double sum, double sumOfSquares) = ComputeSumAndSumOfSquares(sequence);

    var variance = sumOfSquares - sum * sum / count;
    return Math.Sqrt(variance / count);
}

也可以通过在括号外使用 var 关键字,隐式声明元组中每个字段的类型化变量:

public static double StandardDeviation(IEnumerable<double> sequence)
{
    var (sum, sumOfSquares, count) = ComputeSumAndSumOfSquares(sequence);

    var variance = sumOfSquares - sum * sum / count;
    return Math.Sqrt(variance / count);
}

还可以在括号内将 var 关键字与任意或全部变量声明结合使用。

(double sum, var sumOfSquares, var count) = ComputeSumAndSumOfSquares(sequence);

即使元组中的每个字段都具有相同的类型,也不能在括号外使用特定类型。

也可以使用现有声明析构元组:

public class Point
{
    public int X { get; set; }
    public int Y { get; set; }

    public Point(int x, int y) => (X, Y) = (x, y);
}

警告

这将产生错误 CS8184,因为 x 在括号内声明,且 y 以前在其他位置声明。

析构用户定义类型

也可以对任何用户定义的类型(类、结构甚至接口)轻松启用析构。

例如,以下 Person 类型定义 Deconstruct 方法,该方法将 person 对象析构成表示名字和姓氏的元素:

public class Person
{
    public string FirstName { get; }
    public string LastName { get; }

    public Person(string first, string last)
    {
        FirstName = first;
        LastName = last;
    }

    public void Deconstruct(out string firstName, out string lastName)
    {
        firstName = FirstName;
        lastName = LastName;
    }
}

该析构方法支持从 Person 赋值给两个表示 FirstNameLastName 属性的字符串:

var p = new Person("Althea", "Goodwin");
var (first, last) = p;

以下示例显示从 Person 类型派生的 Student 类型,以及将 Student 析构成三个变量(表示 FirstNameLastNameGPA)的扩展方法:

public class Student : Person
{
    public double GPA { get; }
    public Student(string first, string last, double gpa) :
        base(first, last)
    {
        GPA = gpa;
    }
}

public static class Extensions
{
    public static void Deconstruct(this Student s, out string first, out string last, out double gpa)
    {
        first = s.FirstName;
        last = s.LastName;
        gpa = s.GPA;
    }
}

如果为 student 分配两个变量,则仅返回名字和姓氏。

var s1 = new Student("Cary", "Totten", 4.5);
var (fName, lName, gpa) = s1;

调用方可能无法轻松调用所需的 Deconstruct 方法。

在此示例中,发生有歧义的调用的几率很小,因为用于 PersonDeconstruct 方法有两个输出参数,而用于 StudentDeconstruct 方法有三个输出参数。

下面的示例生成编译器错误 CS0019:

Person p = new Person("Althea", "Goodwin");
if (("Althea", "Goodwin") == p)
    Console.WriteLine(p);

Deconstruct 方法无法将 Person 对象 p 转换为包含两个字符串的元组,但它在相等测试上下文中不适用。

结束语

即便如此,元组还是对 privateinternal 这样的实用方法最有