c# 元組


C# 元組是使用輕量語法定義的類型。 其優點包括:更簡單的語法,基於元素數量(稱為“基數”)和元素類型的轉換規則,以及一致的副本、相等測試和賦值規則。 但另一方面,元組不支持一些與繼承相關的面向對象的語法。 C# 7.0 中的新增功能文章中的“元組”一節對其進行了概述。

在本文中,你將了解用於控制 C# 7.0 及更高版本中的元組的語言規則、這些規則的各種用法,以及有關如何使用元組的初步指導。

 備注

新的元組功能需要 ValueTuple 類型。 為在不包括該類型的平台上使用它,必須添加 NuGet 包 System.ValueTuple

這類似於依賴框架提供的類型的其他語言功能。 例如,依賴 INotifyCompletion 接口的 async 和 await,依賴 IEnumerable<T> 的 LINQ。 但是,隨着 .NET 越來越不依賴平台,交付機制也在發生改變。 .NET Framework 交付頻率可能不會與語言編譯器的始終相同。 新語言功能依賴於新類型時,這些類型將在交付語言功能時以 NuGet 包的形式提供。 這些新類型添加到 .NET 標准 API 並作為框架的一部分交付后,將刪除 NuGet 包要求。

我們先解釋一下為什么要添加新的元組支持。 方法返回單個對象。 借助元組,可以更輕松地對該單個對象中的多個值打包。

.NET Framework 已具有泛型 Tuple 類。 但這些類有兩個主要限制。 其一,Tuple 類將其屬性命名為 Item1Item2 等。 這些名稱未承載任何語義信息。 使用這些 Tuple 類型無法表達各屬性的含義。 通過新的語言功能,可對元組中的各元素進行聲明並為其賦予有意義的語義名稱。

Tuple 類因其引用類型會導致更多性能問題。 使用任一 Tuple 類型即意味着分配對象。 在熱路徑中,分配許多小型對象可能會對應用程序性能產生明顯的影響。 因此,元組的語言支持使用新的 ValueTuple 結構。

為避免這些缺陷,可創建 class 或 struct 來承載多個元素。 但這樣做不僅加大了工作量,還掩蓋了你的設計意圖。 創建 struct或 class 意味着定義一個具有數據和行為的類型。 很多時候,你其實只是想存儲單個對象中的多個值而已。

這些語言功能和 ValueTuple 泛型結構共同實施以下規則:不能向這些元組類型添加任何行為(方法)。 所有 ValueTuple 類型都是可變結構。 每個成員字段都是公共字段。 這使它們變得非常輕量。 但是,這意味着在要求永久性的場合無法使用元組。

元組是比 class 和 struct 類型更為簡單靈活的數據容器。 我們來探討一下它們之間的差異。

命名元組和未命名元組

ValueTuple 結構具有名為 Item1Item2Item3 等的字段,與現有 Tuple 類型中定義的屬性類似。 這些名稱是可用於未命名元組的唯一名稱。 如果不為元組提供任何備用字段名稱,即表示創建了一個未命名元組:

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

上例中的元組已使用文本常量進行初始化,並且不會有 C# 7.1 中使用“元組字段名稱投影”創建的元素名稱。

但是,在初始化元組時,可以使用新語言功能為每個字段提供更好的名稱。 如此便創建了命名元組。 命名元組仍將元素命名為 Item1Item2Item3 等。 不過,它們還會為這些已命名的元素提供同義詞。 通過為每個元素指定名稱即可創建命名元組。 其中一種方式是在元組初始化過程中指定名稱:

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

這些同義詞由編譯器和語言處理,因此,你可以高效地使用命名元組。 IDE 和編輯器可以使用 Roslyn API 讀取這些語義名稱。 可以在同一程序集中的任何位置通過這些語義名稱引用命名元組的元素。 編譯器在生成已編譯的輸出時,會將已定義的名稱替換為 Item*等效項。 已編譯的 Microsoft 中間語言 (MSIL) 不包括為這些元素賦予的名稱。

從 C# 7.1 開始,元組的字段名稱可能會通過用於初始化此元組的變量提供。 這稱為元組投影初始值設定項。 以下代碼用於創建名為 accumulation 的元組,包含元素 count(整數)和 sum(雙精度)。

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

編譯器必須傳達為從公共方法或屬性返回的元組創建的這些名稱。 在這種情況下,編譯器會在方法上添加 TupleElementNamesAttribute 特性。 此特性包含一個 TransformNames 列表屬性,該屬性包含為元組中的每個元素賦予的名稱。

 備注

Visual Studio 等開發工具還讀取其元數據,並提供 IntelliSense 和其他使用元數據字段名稱的功能。

請務必理解新元組和 ValueTuple 類型的這些基礎知識,這樣才能理解將命名元組賦給彼此的規則。

元組投影初始值設定項

一般情況下,元組投影初始值設定項使用元組初始化語句右側的變量或字段名稱。 如果未提供顯式名稱,上述名稱將優先於任何投影的名稱。 例如,在以下初始值設定項中,元素為 explicitFieldOne 和 explicitFieldTwo,而非 localVariableOne 和 localVariableTwo

var localVariableOne = 5; var localVariableTwo = "some text"; var tuple = (explicitFieldOne: localVariableOne, explicitFieldTwo: localVariableTwo); 

對於任何未提供顯式名稱的字段,將投影適用的隱式名稱。 不要求提供顯式或隱式語義名稱。 以下初始化表達式具有字段名稱 Item1其值為 42和 stringContent(其值為“The answer to everything”):

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

在以下兩種情況下,不會將候選字段名稱投影到元組字段:

    1. 候選名稱是保留元組名稱時。 示例包括 Item3ToString、 或 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 編寫的代碼的一項重大改變。

相等和元組

從 C# 7.3 開始,元組類型支持 == 和 != 運算符。 這些運算符按順序將左邊參數的每個成員與右邊參數的每個成員進行比較。 這些比較將發生短路。 只要有一對不相等,== 運算符即停止計算成員。 只要有一對相等,!= 運算符即停止計算成員。 以下代碼示例使用 ==,但比較規則均適用於 !=。 以下代碼示例演示兩對整數的相等比較:

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 

元組成員名稱不參與相等測試。 但是,如果其中一個操作數是含有顯式名稱的元組文本,則當這些名稱與其他操作數的名稱不匹配時,編譯器將生成警告 CS8383。 在兩個操作數都為元組文本的情況下,警告位於右側操作數,如以下示例中所述:

 
(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 和 anonymous)沒有為元素提供語義名稱。 字段名稱為 Item1 和 Item2。 后兩個變量(named 和 differentName)為元素提供了語義名稱。 這兩個元組具有不同的元素名稱。

這四個元組具有相同數量的元素(稱為“基數”),這些元素的類型也完全一樣。 因此可進行以下賦值:

 
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; } 

 備注

這些示例計算得出未修正的樣本標准差。 與 Average 擴展方法一樣,修正后的樣本標准差公式將與平均數之差的平方的總和除以 (N-1),而不是 N。 有關這些標准差公式之間的區別的更多詳細信息,請查看統計信息文本。

前面的代碼采用教科書上的標准差公式。 它會生成正確的答案,但實施起來非常低效。 此方法對數列進行兩次計算:一次生成平均數,一次生成與平均數之差的平方的平均數。 (請記住,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); } 

在 Visual Studio 的重構支持下,可以輕松地將核心統計信息的功能提取到私有方法中。 從而得到一個 private static 方法,該方法返回具有 SumSumOfSquares 和 Count 這三個值的元組類型:

 
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; } 

如果你想手動進行一些快速編輯,該語言可提供更多選項供你使用。 首先,可以使用 var 聲明來初始化 ComputeSumAndSumOfSquares方法調用的元組結果。 此外,還可以在 ComputeSumAndSumOfSquares 方法內創建三個離散變量。 下面的代碼演示了最終版本:

 
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); } 

此元組的字段被命名為 Item1Item2 和 Item3。 建議為從方法返回的元組的元素提供語義名稱。

元組另一個常見的用途是在你創作 LINQ 查詢時。 最終投影的結果通常包含被選中的對象的某些(而不是全部)屬性。

傳統做法是將查詢結果投影成一個匿名類型的對象序列。 這種做法存在很多限制,主要是因為匿名類型無法在方法的返回類型中方便地命名。 也可以將 object 或 dynamic 用作結果類型,但這種備用方法會產生高昂的性能成本。

返回一個元組類型序列非常簡單,並且不管是在編譯時還是通過 IDE 工具,都可以獲取其元素的名稱和類型。 以 ToDo 應用程序為例。 可以定義一個與下面類似的類,以表示待辦事項列表中的某一項:

 
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; } } 

移動應用程序可能支持當前待辦事項的簡潔版,即僅顯示標題。 該 LINQ 查詢生成一個僅包含 ID 和標題的投影。 返回一個元組序列的方法很好地表達了該設計:

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

 備注

在 C# 7.1 中,通過元組投影可使用元素,以類似於在匿名類型中命名屬性的方式創建命名元組。 在以上代碼中,查詢投影中的 select 語句將創建具有元素 ID 和 Title 的元組。

命名元組可以是簽名的一部分。 它讓編譯器和 IDE 工具提供靜態檢查,看結果的用法是否正確。 命名元組還承載了靜態類型信息,因此無需使用高成本的運行時功能(如反射或動態綁定)來處理結果。

析構

通過對方法返回的元組進行析構,可以解封元組中的所有項。 有三種元組析構方法。 首先,可在括號內顯式聲明每個字段的類型,為元組中的每個元素創建離散變量:

 
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); } 

 警告

不能混合現有聲明和括號內的聲明。 例如,不允許以下內容:(var x, y) = MyMethod();。 這將產生錯誤 CS8184,因為 x 在括號內聲明,且 y 以前在其他位置聲明。

析構用戶定義類型

如上所示,可以析構任何元組類型。 也可以對任何用戶定義的類型(類、結構甚至接口)輕松啟用析構。

類型作者可定義一個或多個賦值給任意數量的 out 變量的 Deconstruct 方法,這類變量表示構成該類型的數據元素。 例如,以下 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 賦值給兩個表示 FirstName 和 LastName 屬性的字符串:

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

即使對未創作的類型,也可以啟用析構。 Deconstruct 方法可以是一種擴展方法,用於解封對象的可訪問數據成員。 以下示例顯示從 Person 類型派生的 Student 類型,以及將 Student 析構成三個變量(表示 FirstNameLastName 和 GPA)的擴展方法:

 
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 對象現在有兩個可訪問的 Deconstruct 方法:為 Student 類型聲明的擴展方法,以及 Person 類型的成員。 兩者都可用,可將 Student 析構為兩個或三個變量。 如果為 student 分配三個變量,則返回名字、姓氏和 GPA。 如果為 student 分配兩個變量,則僅返回名字和姓氏。

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

在某個類或類層次結構中定義多個 Deconstruct 方法時應非常小心。 具有相同數量的 out 參數的多個 Deconstruct 方法很快就會產生歧義。 調用方可能無法輕松調用所需的 Deconstruct 方法。

在此示例中,發生有歧義的調用的幾率很小,因為用於 Person 的 Deconstruct 方法有兩個輸出參數,而用於 Student 的 Deconstruct 方法有三個輸出參數。

析構運算符不參與測試相等。 下面的示例生成編譯器錯誤 CS0019:

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

Deconstruct 方法無法將 Person 對象 p 轉換為包含兩個字符串的元組,但它在相等測試上下文中不適用。

結束語

命名元組的新語言和庫支持簡化了設計工作:與類和結構一樣,使用數據結構存儲多個元素,但不定義行為。 對這些類型使用元組非常簡單明了。 既可以獲得靜態類型檢查的所有好處,又不需要使用更復雜的 class 或 struct 語法來創作類型。 即便如此,元組還是對 private 或 internal 這樣的實用方法最有


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM