public class PathInfo{ public string DirectoryName { get; } public string FileName { get; } public string Extension { get; } public string Path { get { return System.IO.Path.Combine( DirectoryName, FileName, Extension); } } public PathInfo(string path) { DirectoryName = System.IO.Path.GetDirectoryName(path); FileName = System.IO.Path.GetFileNameWithoutExtension(path); Extension = System.IO.Path.GetExtension(path); } public void Deconstruct( out string directoryName, out string fileName, out string extension) { directoryName = DirectoryName; fileName = FileName; extension = Extension; } // ...}顯然,可以和在 C# 1.0 一樣調用 Deconstruct 方法。但是,C# 7.0 提供了可以顯著簡化調用的語法糖。如果存在解構函數的聲明,則可以使用新的 C# 7.0“類似元組”的語法調用它(參見圖 2)。圖 2 解構函數調用和賦值PathInfo pathInfo = new PathInfo(@"//test/unc/path/to/something.ext");{ // Example 1: Deconstructing declaration and assignment. (string directoryName, string fileName, string extension) = pathInfo; VerifyExpectedValue(directoryName, fileName, extension);}{ string directoryName, fileName, extension = null; // Example 2: Deconstructing assignment. (directoryName, fileName, extension) = pathInfo; VerifyExpectedValue(directoryName, fileName, extension);}{ // Example 3: Deconstructing declaration and assignment with var. var (directoryName, fileName, extension) = pathInfo; VerifyExpectedValue(directoryName, fileName, extension);}請注意,C# 第一次如何允許同時向不同值的多個變量賦值。這與將所有變量都初始化為同一值 (null) 的空賦值聲明不同:string directoryName, filename, extension = null;通過新的類似元組的語法,賦予每個變量一個不同的值,該值與其名稱不對應,但與它出現在聲明和解構語句中的順序相對應。正如你所期望的,out 參數的類型必須與被分配的變量類型相匹配,并且允許使用 var,因為此類型可以從 Deconstruct 參數類型中推斷出來。但是,請注意,雖然可以在圓括號外面放置一個 var(如圖 2 中的示例 3 所示),但此時即使所有變量的類型均相同,也不能拉出字符串。請注意,此時 C# 7.0 類似元組的語法要求圓括號內至少出現兩個變量。例如,即使存在類似如下的解構函數,也不允許使用 (FileInfo path) = pathInfo;:public void Deconstruct(out FileInfo file)換句話說,不能對僅有一個 out 參數的 Deconstruct 方法使用 C# 7.0 解構函數。使用元組
正如我所說過的,前面的每個示例都利用了 C# 7.0 類似元組的語法。此類語法的特點就是用圓括號括住分配的多個變量(或屬性)。我之所以使用術語“類似元組的”,是因為所有這些解構函數示例實際上在內部均未使用任何元組類型。(實際上,由于已分配的對象是表示封裝的組成部分的實例,因此,不允許通過解構函數語法分配元組,也可以說這樣做不太必要。)借助 C# 7.0,現在有了一種特別簡化的語法,可以使用元組,如圖 3 所示。只要允許使用類型說明符,就可以使用這種語法,其中包括聲明、強制轉換運算符和類型參數。圖 3 聲明、實例化并使用 C# 7.0 元組語法[TestMethod]public void Constructor_CreateTuple(){ (string DirectoryName, string FileName, string Extension) pathData = (DirectoryName: @"//test/unc/path/to", FileName: "something", Extension: ".ext"); Assert.AreEqual<string>( @"//test/unc/path/to", pathData.DirectoryName); Assert.AreEqual<string>( "something", pathData.FileName); Assert.AreEqual<string>( ".ext", pathData.Extension); Assert.AreEqual<(string DirectoryName, string FileName, string Extension)>( (DirectoryName: @"//test/unc/path/to", FileName: "something", Extension: ".ext"), (pathData)); Assert.AreEqual<(string DirectoryName, string FileName, string Extension)>( (@"//test/unc/path/to", "something", ".ext"), (pathData)); Assert.AreEqual<(string, string, string)>( (@"//test/unc/path/to", "something", ".ext"), (pathData)); Assert.AreEqual<Type>( typeof(ValueTuple<string, string, string>), pathData.GetType());}[TestMethod]public void ValueTuple_GivenNamedTuple_ItemXHasSameValuesAsNames(){ var normalizedPath = (DirectoryName: @"//test/unc/path/to", FileName: "something", Extension: ".ext"); Assert.AreEqual<string>(normalizedPath.Item1, normalizedPath.DirectoryName); Assert.AreEqual<string>(normalizedPath.Item2, normalizedPath.FileName); Assert.AreEqual<string>(normalizedPath.Item3, normalizedPath.Extension);}static public (string DirectoryName, string FileName, string Extension) SplitPath(string path){ // See http://bit.ly/2dmJIMm Normalize method for full implementation. return ( System.IO.Path.GetDirectoryName(path), System.IO.Path.GetFileNameWithoutExtension(path), System.IO.Path.GetExtension(path) );}如果你不太熟悉元組,可以在輕量級語法中將多個類型組合成一個包含類型,然后在對其進行實例化的方法外面使用。之所以說是輕量級,是因為和定義類/結構不同,元組可通過內聯和動態方式“聲明”。但是,與也支持內聯聲明和實例化的動態類型不同,元組可以從其包含成員的外部訪問,它們實際上可以包含在 API 中。雖然外部 API 支持,但元組沒有兼容版本的擴展(除非類型參數本身正好支持推導),因此,在公共 API 中應謹慎使用。因此,更好的辦法是對公共 API 中的返回內容使用標準類。在 C# 7.0 之前,該框架已有元組類 System.Tuple<…>(在 Microsoft .NET Framework 4 中引入)。但 C# 7.0 與之前的解決方案不同,因為它將語義意圖嵌入到聲明中并引入一個元組值類型: System.ValueTuple<…>。我們現在來看看語義意圖。請注意,在圖 3 中,C# 7.0 元組語法可讓你為元組包含的每個 ItemX 元素聲明別名。例如,圖 3 中的 pathData 元組實例已定義強類型 DirectoryName: string、FileName: string 和 Extension: string 屬性,因此,可以調用(例如)pathData.DirectoryName。這是一項重大改進,因為在 C# 7.0 之前,唯一可用的名稱是 ItemX 名稱,其中 X 將針對每個元素增加。現在,雖然 C# 7.0 元組的元素屬于強類型,但這些名稱本身在類型定義中并未區分。因此,可以分配兩個使用不同別名的元組,你將得到一條警告,通知你將忽略右邊的名稱:// Warning: The tuple element name 'AltDirectoryName1' is ignored// because a different name is specified by the target type...(string DirectoryName, string FileName, string Extension) pathData = (AltDirectoryName1: @"//test/unc/path/to", FileName: "something", Extension: ".ext");同樣,可以將元組分配到尚未定義部分別名元素名稱的其他元組:// Warning: The tuple element name 'directoryName', 'FileNAme' and 'Extension'// are ignored because a different name is specified by the target type...(string, string, string) pathData = (DirectoryName: @"//test/unc/path/to", FileName: "something", Extension: ".ext");必須確定,每個元素的類型和順序都定義類型兼容性。僅忽略元素名稱。然而,即使在名稱不同時被忽略,它們仍然在 IDE 中提供 IntelliSense。請注意,無論是否定義元素名稱的別名,所有元組均有 ItemX 名稱,其中 X 對應于元素的數量。ItemX 名稱很重要,因為它們是元組從 C# 6.0 開始起可用,即使沒有別名元素的名稱也是如此。需要注意的另一點就是,基礎 C# 7.0 元組類型是 System.ValueTuple。如果正針對其進行編譯的框架中未提供此類型,可以通過 NuGet 包訪問它。有關元組內部元素的詳細信息,請參閱 intellitect.com/csharp7tupleiinternals。具有 Is 表達式的模式匹配
有時會存在基類(例如 Storage),以及一系列的派生類、DVD、UsbKey、HardDrive、FloppyDrive 等。要對每個類實施 Eject 方法,請使用以下多個選項:As 運算符使用 As 運算符轉換并賦值檢查結果是否為 null執行 eject 操作Is 運算符使用 Is 運算符檢查類型轉換類型并為其賦值執行 eject 操作Cast顯式轉換并賦值捕獲可能的異常執行操作看起來不怎么樣啊!還有第四種、效果更好的方法,即使用你通過虛擬函數分派的多形性。但是,僅在具有 Storage 類的源代碼并且可以添加 Eject 方法時,才可以使用這種方法。我假設的選項不適用于這個討論,因此需要模式匹配。上述這些方法存在的問題都是語法相當冗長,總是要求為需要轉換的每個類提供多個語句。C# 7.0 提供模式匹配,用作一種將測試和賦值合并為單個操作的方法。因此,圖 4 中的代碼簡化為如圖 5 中所示的代碼。圖 4 無模式匹配的類型轉換// Eject without pattern matching.public void Eject(Storage storage){ if (storage == null) { throw new ArgumentNullException(); } if (storage is UsbKey) { UsbKey usbKey = (UsbKey)storage; if (usbKey.IsPluggedIn) { usbKey.Unload(); Console.WriteLine("USB Drive Unloaded."); } else throw new NotImplementedException(); } else if(storage is DVD) // ... else throw new NotImplementedException();}圖 5 有模式匹配的類型轉換// Eject with pattern matching.public void Eject(Storage storage){ if (storage is null) { throw new ArgumentNullException(); } if ((storage is UsbKey usbDrive) && usbDrive.IsPluggedIn) { usbDrive.Unload(); Console.WriteLine("USB Drive Unloaded."); } else if (storage is DVD dvd && dvd.IsInserted) // ... else throw new NotImplementedException(); // Default}這兩種轉換方式的區別并不重要,但如果要經常執行(例如,針對每個派生類型),則前一種語法存在一種繁瑣的 C# 特性。C# 7.0 的改進之處是將類型測試、聲明和賦值組合為一個操作,呈現早期的語法,但不推薦使用。在前一種語法中,檢查類型而不分配標識符會導致失敗而恢復“默認設置”,否則會很麻煩。相比之下,除了類型檢查,分配還考慮到其他條件。請注意,圖 5 中的代碼開始模式匹配 is 運算符,也支持 null 比較運算符:if (storage is null) { ... }使用 Switch 語句的模式匹配
雖然支持使用 is 運算符的模式匹配實現了改進,但 switch 語句的模式匹配支持無疑更重要,至少在有多個可轉換的兼容類型時如此。這是因為 C# 7.0 包括 case 語句和模式匹配,此外,如果滿足 case 語句中的類型模式,就可以在 case 語句中提供、分配和訪問標識符。圖 6 提供了一個示例。圖 6 Switch 語句中的模式匹配public void Eject(Storage storage){ switch(storage) { case UsbKey usbKey when usbKey.IsPluggedIn: usbKey.Unload(); Console.WriteLine("USB Drive Unloaded."); break; case DVD dvd when dvd.IsInserted: dvd.Eject(); break; case HardDrive hardDrive: throw new InvalidOperationException(); case null: default: throw new ArgumentNullException(); }}在該示例中,請注意如何在 case 語句中自動聲明和分配如 usbKey 和 dvd 的局部變量。正如你所期望的,范圍僅限于 case 語句中。但也許與變量聲明和賦值一樣重要的是附加條件,可以用一個 when 子句附加到 case 語句。結果是 case 語句完全可以篩選無效的方案,無需在 case 語句內部使用額外的篩選器。這帶來額外的好處是:如果事實上沒有完全滿足前一個 case 語句,也允許計算下一個 case 語句。這也意味著 case 語句不再僅限于常量,此外,switch 表達式可以是任何類型,不再僅限于 bool、char、string、integral 和 enum。新的 C# 7.0 模式匹配 switch 語句功能引入的另一個重要特征就是,case 語句順序很重要并在編譯時驗證。(這與該語言的早期版本形成對比,早期版本中沒有模式匹配,case 語句順序也不重要。) 例如,如果我在派生自 Storage 的模式匹配 case 語句之前引入了 Storage 的 case 語句(UsbKey、DVD 和 HardDrive),則 case Storage 會隱藏所有其他的類型模式匹配(派生自 Storage)。如果 case 語句來自隱藏計算結果中的其他派生類型 case 語句的基類,將導致隱藏的 case 語句中出現編譯錯誤。這樣,case 語句順序要求就類似于 catch 語句。讀者將會記得 null 值中的 is 運算符返回 false。因此,對于值 null 的 switch 表達式,類型模式匹配 case 語句不匹配。為此,null case 語句的順序無關緊要;此行為在模式匹配之前與 switch 語句匹配。此外,為了支持與 C# 7.0 之前的 switch 語句的兼容性,默認總是最后評估 case,而不考慮它出現在 case 語句順序中的位置。(也就是說,由于 case 總是在最后評估,可讀性通常也會將它放在最后。) 此外,goto case 語句仍僅適用于常量 case 標簽,不適用于模式匹配。本地函數
雖然已經可以聲明委托并為其分配一個表達式,但是 C# 7.0 通過允許在另一個成員內部完全聲明本地函數,做出了進一步改進。請考慮圖 7 中的 IsPalindrome 函數。圖 7 本地函數示例bool IsPalindrome(string text){ if (string.IsNullOrWhiteSpace(text)) return false; bool LocalIsPalindrome(string target) { target = target.Trim(); // Start by removing any surrounding whitespace. if (target.Length <= 1) return true; else { return char.ToLower(target[0]) == char.ToLower(target[target.Length - 1]) && LocalIsPalindrome( target.Substring(1, target.Length - 2)); } } return LocalIsPalindrome(text);}在該實現中,我先檢查傳遞到 IsPalindrome 的參數不是 null 或僅為空格。(我已使用模式匹配與 “text is null” 進行 null 檢查。) 接下來,我聲明函數 LocalIsPalindrome,其中,我以遞歸方式將第一個和最后一個字符進行比較。這種方法的好處是,我不在可能會錯誤調用的類范圍內聲明 LocalIsPalindrome,進而繞過 IsNullOrWhiteSpace 檢查。換句話說,本地函數提供其他的范圍限制,但僅在周圍函數內部。圖 7 中的參數驗證方案是一種通用的本地函數用例。我經常遇到的另一個方案發生在單元測試內,例如在測試 IsPalindrome 函數時(參見圖 8)。圖 8 單元測試通常使用本地函數[TestMethod]public void IsPalindrome_GivenPalindrome_ReturnsTrue(){ void AssertIsPalindrome(string text) { Assert.IsTrue(IsPalindrome(text), $"'{text}' was not a Palindrome."); } AssertIsPalindrome("7"); AssertIsPalindrome("4X4"); AssertIsPalindrome(" tnt"); AssertIsPalindrome("Was it a car or a cat I saw"); AssertIsPalindrome("Never odd or even");}返回 IEnumerable<T> 的 Iterator 函數以及 yield 返回元素是另一種通用的本地函數用例。作為對該主題的總結,以下列出了大家需要注意的有關本地函數的幾個要點:本地函數不允許使用可訪問性修飾符(public、PRivate、protected)。本地函數不支持重載。即使簽名未重疊,也不能在名稱相同的同一種方法中使用兩個本地函數。編譯器將針對永不調用的本地函數發出警告。本地函數可以訪問封閉范圍內的所有變量,包括局部變量。此行為與本地定義的 lambda 表達式相同,除了本地函數不分配表示結束的對象外,其他方面都與本地定義的 lambda 表達式相同。本地函數存在于整個方法的范圍內,而不考慮是在聲明之前還是之后調用它們。通過引用返回
從 C# 1.0 開始,可以通過引用 (ref) 將參數傳遞給函數。結果就是對參數本身的任何改變都將傳回給調用方。請考慮以下 Swap 功能:static void Swap(ref string x, ref string y)在這種情況下,被調用方法可以用新值更新原始調用方的變量,從而交換第一和第二參數中存儲的內容。從 C# 7.0 開始,除了 ref 參數,還可以通過函數返回傳回一個引用。例如,考慮返回圖像中與紅眼相關聯的第一像素的函數,如圖 9 所示。圖 9 Ref 返回和 Ref 局部聲明public ref byte FindFirstRedEyePixel(byte[] image){ //// Do fancy image detection perhaps with machine learning. for (int counter = 0; counter < image.Length; counter++) { if(image[counter] == (byte)ConsoleColor.Red) { return ref image[counter]; } } throw new InvalidOperationException("No pixels are red.");}[TestMethod]public void FindFirstRedEyePixel_GivenRedPixels_ReturnFirst(){ byte[] image; // Load image. // ... // Obtain a reference to the first red pixel. ref byte redPixel = ref FindFirstRedEyePixel(image); // Update it to be Black. redPixel = (byte)ConsoleColor.Black; Assert.AreEqual<byte>((byte)ConsoleColor.Black, image[redItems[0]]);}通過返回圖像引用,調用方然后能夠將像素更新為不同的顏色。通過數組檢查更新時發現,該值現在為 black。使用 by reference 參數的替代方法如下所示,有人可能會說這種方法不太明顯、可讀性較低:public bool FindFirstRedEyePixel(ref byte pixel);通過引用返回有兩個重要的限制,并且這兩個限制都由對象生命周期造成。對象引用不應被視為垃圾收集,因為對象仍然被引用,當它們不再有任何引用時,不應消耗內存。首先,只能返回以下內容的引用:字段、其他引用返回屬性或函數,或作為參數傳遞到引用返回函數的對象。例如,FindFirst-RedEyePixel 返回對圖像數組中項目的引用,它是函數的參數。同樣,如果圖像存儲為類中的字段,則可以通過引用返回該字段:byte[] _Image;public ref byte[] Image { get { return ref _Image; } }其次,ref 局部變量初始化為內存中的某個存儲位置,且不能修改為指向不同的位置。(不能具有指向一個引用的指針和修改引用 - 對于那些有 C++ 背景的人,是指向指針的指針。)以下是需要了解的幾個按引用返回特征: 如果你要返回一個引用,則顯然必須返回。因此,這意味著在圖 9 的示例中,即使沒有紅眼像素存在,仍需要返回 ref 字節。唯一的解決方法是引發一個異常。相比之下,by reference 參數方法可讓你保持參數不變,并返回一個指示成功的布爾值。在許多情況下,這種方法可能更可取。聲明一個引用局部變量時,需要初始化。這涉及到為它分配從函數或引用返回到變量的 ref:ref string text; // Error雖然可以在 C# 7.0 中聲明引用局部變量,但不允許聲明 ref 類型的字段:class Thing { ref string _Text; /* Error */ }不能為自動實施的屬性聲明 by reference 類型:class Thing { ref string Text { get;set; } /* Error */ }允許使用返回引用的屬性:class Thing { string _Text = "Inigo Montoya"; ref string Text { get { return ref _Text; } } }不能使用值(如 null 或常量)初始化引用局部變量。它必須通過返回成員或局部變量/字段的 by reference 分配: ref int number = null; ref int number = 42; // ERROR輸出變量
從 C# 的第一個版本開始,調用包含輸出參數的方法時,始終要求在調用方法之前預先聲明輸出參數標識符。但 C# 7.0 刪除了這個特性,并且允許以內聯方式聲明輸出參數以及方法調用。圖 10 顯示了一個例子。圖 10 輸出參數的內聯聲明public long DivideWithRemainder( long numerator, long denominator, out long remainder){ remainder = numerator % denominator; return (numerator / denominator);}[TestMethod]public void DivideTest(){ Assert.AreEqual<long>(21, DivideWithRemainder(42, 2, out long remainder)); Assert.AreEqual<long>(0, remainder);}請注意,在 DivideTest 方法中,從測試中對 DivideWithRemainder 的調用如何在 out 修飾符之后包含一個類型說明符。此外,了解剩余部分如何自動繼續包含在方法的范圍內,如第二個 Assert.AreEqual 調用證明。很好!文本改進
與以前的版本不同,C# 7.0 包含數字二進制文本格式,如下例所示:long LargestSquareNumberUsingAllDigits = 0b0010_0100_1000_1111_0110_1101_1100_0010_0100; // 9,814,072,356long MaxInt64 { get; } = 9_223_372_036_854_775_807; // Equivalent to long.MaxValue還要注意對下劃線 “_” 用作數字分隔符的支持。它只是用來提高可讀性,可以放在數字位數(二進制、十進制或十六進制數字)之間的任何位置。通用的異步返回類型
有時在實施異步方法時,能夠同步返回結果,縮短一個長時間運行的操作,因為結果幾乎是瞬時的,甚至是已知的。例如,考慮一個異步方法,用于確定目錄 (bit.ly/2dExeDG) 中文件的總大小。事實上,如果該目錄中沒有文件,則該方法可以立即返回,而不執行長時間運行的操作。直到 C# 7.0,異步語法的要求規定此類方法的返回結果應當是 Task<long>,因此,即使不需要這樣的 Task 實例,也要實例化 Task。(要實現這一點,通用模式是從 Task.FromResult<T> 返回結果。)在 C# 7.0 中,編譯器不再限制異步方法返回到 void、Task 或 Task<T>。現在可以定義自定義類型,例如 .NET Core Framework 提供的 System.Threading.Tasks.ValueTask<T> struct,它們與異步方法返回值兼容。有關更多信息,請參閱 itl.tc/GeneralizedAsyncReturnTypes。更多的 Expression-Bodied 成員
C# 6.0 引入了函數和屬性的 expression-bodied 成員,從而簡化了實現瑣碎的方法和屬性的語法。在 C# 7.0 中,將 expression-bodied 實現添加到了構造函數、訪問器(get 和 set 屬性實現),甚至終結器中(請參見圖 11)。圖 11 在訪問器和構造函數中使用 Expression-Bodied 成員class TemporaryFile // Full IDisposible implementation // left off for elucidation.{ public TemporaryFile(string fileName) => File = new FileInfo(fileName); ~TemporaryFile() => Dispose(); Fileinfo _File; public FileInfo File { get => _File; private set => _File = value; } void Dispose() => File?.Delete();}我希望使用 expression-bodied 成員,這對于終結器特別常見,因為最常見的實現是調用 Dispose 方法,如上圖所示。我很高興地在此說明,對 expression-bodied 成員的額外支持是由 C# 社區實施的,而不是 Microsoft C# 團隊。而且還是開源,耶!警告: 此功能在 Visual Studio 2017 RC 中尚未實現。Throw 表達式:
圖 11 中的臨時類可以得到增強,在 expression-bodied 成員內包括參數驗證;因此,我可以將構造函數更新為:public TemporaryFile(string fileName) => File = new FileInfo(filename ?? throw new ArgumentNullException());如果沒有 throw 表達式,C# 對 expression-bodied 成員的支持就不能進行任何參數驗證。但是,通過 C# 7.0 支持 throw 作為一個表達式,而不僅僅是一個語句,因此,可以在更大的包含表達式中報告錯誤內聯。警告: 此功能在 Visual Studio 2017 RC 中尚未實現。總結
我承認,當我開始寫這篇文章,以為它會短得多。然而,由于我花了更多的時間編程和測試這些功能,因此,我發現有更多的方式實現 C# 7.0,而不僅僅是通過閱讀功能標題和遵照語言開發。在許多情況下,聲明變量、二進制文本、throw表達式等等,沒有太多地涉及理解和使用功能。但有幾種情況(例如,按引用返回、解構函數和元組)需要比最初所預期的更多地了解功能。在后一種情況下,不僅要了解語法,還要知道功能何時是相關的。C# 7.0 繼續在快速減少的特性列表(預先聲明的輸出標識符和缺少 throw 表達式)中削弱,而與此同時進行擴展,以包括對之前在語言級別看不到的功能的支持(元組和模式匹配)。希望這個介紹可以幫助你快速進入 C# 7.0 編程領域。有關本文內容之后的 C# 7.0 開發的更多信息,請查看我在 intellitect.com/csharp7 上的博客,以及我的《Essential C# 7.0》一書的更新(預計將在 Visual Studio 2017 投入生產后不久面世)。
Mark Michaelis 是 IntelliTect 的創始人,擔任首席技術架構師和培訓師。在近二十年的時間里,他一直是 Microsoft MVP,并且自 2007 年以來一直擔任 Microsoft 區域總監。Michaelis 還是多個 Microsoft 軟件設計評審團隊(包括 C#、Microsoft Azure、SharePoint 和 Visual Studio ALM)的成員。他在開發者會議上發表了演講,并撰寫了大量書籍,包括最新的“必備 C# 6.0(第 5 版)”(itl.tc/EssentialCSharp)。可通過他的 Facebook facebook.com/Mark.Michaelis、博客IntelliTect.com/Mark、Twitter @markmichaelis 或電子郵件 mark@IntelliTect.com 與他取得聯系。
新聞熱點
疑難解答