解構函數
從 C# 1.0 開始,就能調用函數,就是將參數組合起來並封裝到一個類中的構造函數。但是,從來沒有一種簡便的方式可將對象解構回其各個組成部分。例如,假設有一個 PathInfo 類,它採用文件名的每個元素(目錄名、文件名、擴展名),並將它們組合成一個對象,然後支持操作對象的不同元素。現在,假設你需要將該對象提取(解構)回其各個組成部分。
在 C# 7.0 中,通過解構函數完成這項任務將變得輕而易舉,解構函數可返回對象的具體確定組件。注意,不要將解構函數 (deconstructor) 與析構函數 (destructor)(確定性對象解除分配和清除)或終結器 (itl.tc/CSharpFinalizers) 混淆。
我們來看看圖 1 中的 PathInfo 類。
圖 1 具有解構函數的 PathInfo 類及相關測試
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 運算符
- 檢查結果是否爲 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 的 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);
通過引用返回有兩個重要的限制,並且這兩個限制都由對象生命週期造成。對象引用不應被視爲垃圾收集,因爲對象仍然被引用,當它們不再有任何引用時,不應消耗內存。首先,只能返回以下內容的引用:字段、其他引用返回屬性或函數,或作爲參數傳遞到引用返回函數的對象。例如,FindFirstRedEyePixel 返回對圖像數組中項目的引用,它是函數的參數。同樣,如果圖像存儲爲類中的字段,則可以通過引用返回該字段:
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,356
long MaxInt64 { get; } =
9_223_372_036_854_775_807; // Equivalent to long.MaxValue
還要注意對下劃線 “_” 用作數字分隔符的支持。它只是用來提高可讀性,可以放在數字位數(二進制、十進制或十六進制數字)之間的任何位置。
通用的異步返回類型
有時在實施異步方法時,能夠同步返回結果,縮短一個長時間運行的操作,因爲結果幾乎是瞬時的,甚至是已知的。例如,考慮一個異步方法,用於確定目錄 (bit.ly/2dExeDG) 中文件的總大小。事實上,如果該目錄中沒有文件,則該方法可以立即返回,而不執行長時間運行的操作。直到 C# 7.0,異步語法的要求規定此類方法的返回結果應當是 Task,因此,即使不需要這樣的 Task 實例,也要實例化 Task。(要實現這一點,通用模式是從 Task.FromResult 返回結果。)
在 C# 7.0 中,編譯器不再限制異步方法返回到 void、Task 或 Task。現在可以定義自定義類型,例如 .NET Core Framework 提供的 System.Threading.Tasks.ValueTask 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 中尚未實現。