[周譯見] C# 7 中的模範和實踐

原文地址:https://www.infoq.com/articles/Patterns-Practices-CSharp-7

關鍵點

  • 遵循 .NET Framework 設計指南,時至今日,仍像十年前首次出版一樣適用。
  • API 設計至關重要,設計不當的API大大增加錯誤,同時降低可重用性。
  • 始終保持"成功之道":只做正確的事,避免犯錯。
  • 去除 "line noise" 和 "boilerplate" 類型的代碼以保持關注業務邏輯
  • 在爲了性能犧牲而可讀性之前請保持清醒

C# 7 一個主要的更新是帶來了大量有趣的新特性。雖然已經有很多文章介紹了 C# 7 可以做哪些事,但關於如何用好 C# 7 的文章還是很少。遵循 .NET Framework設計指南中 的原則,我們首先通過下面的策略,獲取這些新特性的最佳做法。

元組返回結果

在 C# 以往的編程中,從一個函數中返回多個結果可是相當的乏味。Output 關鍵詞是一種方法,但如果對於異步方法不適用。Tuple<T>(元組) 儘管囉嗦,又要分配內存,同時對於其字段又不能有描述性名稱。自定義的結構優於元組,但在一次性代碼中濫用會產生垃圾代碼。最後,匿名類型和動態類型(dynamic) 的組合非常慢,又缺乏靜態類型檢查。
所有的這一切問題,在新的元組返回語法中得到了解決。下面是舊語法的例子:

public (string, string) LookupName(long id) // tuple return type
{
    return ("John", "Doe"); // tuple literal
}
var names = LookupName(0);
var firstName = names.Item1;
var lastName = names.Item2;

 

這個函數實際的返回類型是 ValueTuple<string, string>。顧名思義,這是類似 Tuple<T> 類的輕量級結構。這解決了類型膨脹的問題,但和 Tuple<T> 同樣缺失了描述性名稱。

public (string First, string Last) LookupName(long id) 
var names = LookupName(0);
var firstName = names.First;
var lastName = names.Last;

返回的類型仍然是 ValueTuple<string, string>,但現在編譯器爲函數添加了TupleElementNames 屬性,允許代碼使用描述性名稱而不是 Item1/Item2。

警告:TupleElementNames 屬性只能被編譯器使用。如果在返回類型上使用反射,則只能看到 ValueTuple<T> 結構。因爲這些屬性在函數返回結果的時候纔會出現,相關的信息是不存在的。

編譯器盡所能地爲這些臨時的類型維持一種幻覺。例如,考慮下面這些聲明:

var a = LookupName(0);  
(string First, string Last) b = LookupName(0); 
ValueTuple<string, string> c = LookupName(0); 
(string make, string model) d = LookupName(0);

從編譯器來看,a 是一種像 b 的 (string First, string Last) 類型。 由於 c 明確聲明爲 ValueTuple<string, string>類型,所以沒有 c.First 的屬性。
d 說明了這種設計帶來的破壞,導致失去類型安全。很容易不小心重命名字段,會將一個元組分配給一個恰好具有相同形狀的元組。重申一下,這是因爲編譯器不會認爲 (string First, string Last) 和 (string make, string model) 是不同的類型。

ValueTuple 是可變的

關於 ValueTuple 的一個有趣的看法:它是可變的。Mads Torgersen 解釋了原因:

下面的原因解釋了可變結構爲何經常是壞的設計,請不要用於元組。
如果您以常規方式封裝可變結構體,使用私有、公共的訪問器,那麼您將遇到一些意外驚嚇。原因是儘管這些結構體被保存在只讀變量中,訪問器將悄悄在結構體的副本中生效!

然而,元組只有公共的、可變的字段。由於這種設計沒有訪問器,因此不會有上述現象帶來的風險。

再且因爲它們是結構體,當它們被傳遞時會被複制。線程之間不直接共享,也不會有 “共享可變狀態” 的風險。這與 System.Tuple 系列的類型相反,爲了線程安全需要保證其不可變。

[譯者]:Mutator的翻譯參考https://en.wikipedia.org/wiki/Mutator_method#C.23_example爲 C# 中的訪問器

注意他說的是“字段”,而不是“屬性”。這可能會導致基於反射的庫會有問題,這將對返回元組結果的方法造成毀滅。

元組返回結果指南

✔ 當返回結果的列表字段很小且永不會改變時,考慮使用元組返回結果而不是 out 參數。
✔ 在元組返回結果中使用帕斯卡(PascalCase)來命名描述性字段。這使得元組字段看起來像普通類和結構體上的屬性。
✔ 在讀取元組返回值時不要使用var來解構(deconstructing) ,避免意外搞錯字段。
✘ 期望的返回值中用到反射的避免使用元組。
✘ 在公開的 APIs 中請不要使用元組返回結果,如果在將來的版本中需要返回其他字段,將字段添加到元組返回結果具有破壞性。

(譯者:deconstructing 的翻譯參考 https://zhuanlan.zhihu.com/p/25844861 中對deconstructing的翻譯,下面的部分名詞也是如此)

解構多值返回結果

回到 LookupName 的示例, 創建一個名稱變量似乎有點惱人,只能在被局部變量單獨替換之前立即使用它。C#7 也使用所謂的 “解構” 來解決這個問題。語法有幾種變形:

(string first, string last) = LookupName(0);
(var first, var last) = LookupName(0);
var (first, last) = LookupName(0);
(first, last) = LookupName(0);

在上面示例的最後一行,假定變量 first 和 last 已經事先被聲明瞭。

解構器

儘管名字很像 “析構(destructor)”,但解構器與對象銷燬無關。正如構造函數將獨立的值組合成一個對象一樣,解構器同樣是組合和分解對象。解構器允許任何類提供上述的解構語法。讓我們來分析一下 Rectangle 類,它有這樣的構造函數:

public Rectangle(int x, int y, int width, int height)

當你在一個新的實例中調用 ToString 時,你會得到"{X=0,Y=0,Width=0,Height=0}"。結合這兩個事實,我們知道了在自定義的解構函數中對字段排序。

public void Deconstruct(out int x, out int y, out int width, out int height)
{
    x = X;
    y = Y;
    width = Width;
    height = Height;
} 

var (x, y, width, height) = myRectangle;
Console.WriteLine(x);
Console.WriteLine(y);
Console.WriteLine(width);
Console.WriteLine(height);

你可能會好奇爲什麼使用 output 參數,而不是元組。一部分原因是性能,這樣就減少了需要複製的數量。但最主要的原因是微軟還爲重載打開了一道門。
繼續我們的研究,注意到 Rectangle 還有第二個構造函數:

public Rectangle(Point location, Size size);

我們同樣爲它匹配一個解構方法:

public void Deconstruct(out Point location, out Size size);
var (location, size) = myRectangle;

有多少個不同數量的構造參數就有多少個解構函數。即使你顯式地指出類型,編譯器也無法確定有哪些解構方法可以使用。
在 API 設計中,結構通常能從解構中受益。類,特別是模型或者DTOs,如 Customer 和 Employee 可能不應該有解構方法,它們沒有方法解決諸如:"應該是 (firstName, lastName, phoneNumber, email)" 還是 " (firstName, lastName, email, phoneNumber)" 的問題。某種程度來說,大家都應該開心。

解構器指南

✔ 考慮在讀取元組返回值時使用解構,但要注意避免搞錯標籤。
✔ 爲結構提供自定義的解構方法。
✔ 記得匹配類的構造函數中字段的順序,重寫 ToString 。
✔ 如果結構具有多個構造函數,考慮提供對應的解構方法。
✔ 考慮立即解構大值元組。大值元組的總大小超過16個字節,這可能帶來多次複製的昂貴代價。請注意,引用類型的變量在32位操作系統中的大小總是4字節,而在64位操作系統是8字節。
✘ 當不知道在類中字段應以何種方式排序時,請不要使用解構方法。
✘ 不要聲明多個具有同等數量參數的解構方法。

Out 變量

C# 7 爲 帶有 "out" 變量的調用函數提供了兩種新的語法選擇。現在可以在函數調用中這樣聲明變量。

if (int.TryParse(s, out var i))
{
    Console.WriteLine(i);
}

另一種選擇是完全使用"下劃線",忽略out 變量。

if (int.TryParse(s, out _))
{
    Console.WriteLine("success");
}

如果你使用過 C# 7 預覽版,可能會注意到一點:對被忽略的參數使用星號(*)已被更改爲用下劃線。這樣做的部分原因是在函數式編程中通常出於同樣的目的使用了下劃線。其他類似的選擇包括諸如"void" 或者 "ignore" 的關鍵字。
使用下劃線很方便,同時意味着 API中的設計缺陷。在大多數情況中,更好的方法是對忽視的 out 參數簡單地提供一個方法重載。

Out 變量指南

✔ 考慮用元組返回值替代 out參數。
✘ 儘量避免使用 out 或者 ref 參數。[詳情見 框架設計指南 ]
✔ 考慮對忽視的 out 參數提供重載,這樣就不需要用下劃線了。

局部方法和迭代器

局部方法是一個有趣的概念。乍一看,就像是創建匿名方法的一種更易讀的語法。下面看看他們的不同。

public DateTime Max_Anonymous_Function(IList<DateTime> values)
{
    Func<DateTime, DateTime, DateTime> MaxDate = (left, right) =>
    {
        return (left > right) ? left : right;
    };

    var result = values.First();
    foreach (var item in values.Skip(1))
        result = MaxDate(result, item);
    return result;
}

public DateTime Max_Local_Function(IList<DateTime> values)
{
    DateTime MaxDate(DateTime left, DateTime right)
    {
        return (left > right) ? left : right;
    }

    var result = values.First();
    foreach (var item in values.Skip(1))
        result = MaxDate(result, item);
    return result;
}

然而,一旦你開始深入瞭解,一些有趣的內容將會浮現。

匿名方法 vs. 局部方法

當你創建一個普通的匿名方法時,總是會創建一個對應的隱藏類來存儲該匿名方法。該隱藏類的實例將被創建並存儲在該類的靜態字段中。因此,一旦創建,沒有額外的開銷。
反觀局部方法,不需要隱藏類。相反,局部方法表現爲其靜態父方法。

閉包

如果您的匿名方法或局部方法引用了外部變量,則產生"閉包"。下面是示例:

public DateTime Max_Local_Function(IList<DateTime> values)
{
    int callCount = 0;

    DateTime MaxDate(DateTime left, DateTime right)
    {
        callCount++; <--The variable callCount is being closed over.
        return (left > right) ? left : right;
    }

    var result = values.First();
    foreach (var item in values.Skip(1))
        result = MaxDate(result, item);
    return result;
}

對於匿名方法來說,隱藏類每次創建新實例時都要求外部父方法被調用。這確保每次調用時,會在父方法和匿名方法共享數據副本。
這種設計的缺點是每次調用匿名方法需要實例化一個新對象。這就帶來了昂貴的使用成本,同時加重垃圾回收的壓力。
反觀局部方法,使用隱藏結構取代了隱藏類。這就允許繼續存儲上一次調用的數據,避免了每次都要實例化對象。與匿名方法一樣,局部方法實際存儲在隱藏結構中。

委託

創建匿名方法或局部方法時,通常會將其封裝到委託,以便在事件處理程序或者 LINQ 表達式中調用。
根據定義,匿名方法是匿名的。所以爲了使用它,往往需要當成委託存儲在一個變量或參數。
委託不可以指向結構(除非他們被裝箱了,那就是奇怪的語義)。所以如果你創建了一個委託並指向一個局部方法,編譯器將會創建一個隱藏類代替隱藏結構。如果該局部方法是一個閉包,那麼每次調用父方法時都會創建一個隱藏類的新實例。

迭代器

在C#中,使用 yield 返回的 IEnumerable<T> 不能立即驗證其參數。相反,直到在匿名枚舉器中調用 MoveNext,纔可以對其參數進行驗證。
這在 VB 中不是問題,因爲它支持 匿名迭代器。下面有一個來自MSDN的示例:

Public Function GetSequence(low As Integer, high As Integer) _
As IEnumerable
    ' Validate the arguments.
    If low < 1 Then Throw New ArgumentException("low is too low")
    If high > 140 Then Throw New ArgumentException("high is too high")

    ' Return an anonymous iterator function.
    Dim iterateSequence = Iterator Function() As IEnumerable
                              For index = low To high
                                  Yield index
                              Next
                          End Function
    Return iterateSequence()
End Function

在當前的 C# 版本中,GetSequence的迭代器需要完全獨立的方法。而在 C# 7中,可以使用局部方法實現。

public IEnumerable<int> GetSequence(int low, int high)
{
    if (low < 1)
        throw new ArgumentException("low is too low");
    if (high > 140)
        throw new ArgumentException("high is too high");

    IEnumerable<int> Iterator()
    {
        for (int i = low; i <= high; i++)
            yield return i;
    }

    return Iterator();
}

迭代器需要構建一個狀態機,所以它們的行爲就像在隱藏類中作爲委託返回閉包。

匿名方法和局部方法指南

✔ 當不需要委託時,使用局部方法代替匿名方法,尤其是涉及到閉包。
✔ 當返回一個需要驗證參數的 IEnumerator 時,使用局部迭代器。
✔ 考慮將局部方法放到方法的開頭或結尾處,以便與父方法區分來。
✘ 避免在性能敏感的代碼中使用帶委託的閉包,這適用於匿名方法和局部方法。

引用返回、局部引用以及引用屬性

結構具有一些有趣的性能特性。由於他們與其父數據結構一起存儲,沒有普通類的頭開銷。這意味着你可以非常密集地存儲在數組中,很少或不浪費空間。除了減少內存總體開銷外,還帶來了極大的優勢,使 CPU 緩存更高效。這就是爲什麼構建高性能應用程序的人喜歡結構。
但是如果結構太大的話,需要避免不必要的複製。微軟的指南建議爲16個字節,足夠存儲2個 doubles 或者 4 個 integers。這不是很多,儘管有時可以使用位域 (bit-fields)來擴展。

局部引用

這樣做的一個方法是使用智能指針,所以你永遠不需要複製。這裏有一些我仍然使用的ORM性能敏感代碼。

for (var i = 0; i < m_Entries.Length; i++)
{
    if (string.Equals(m_Entries[i].Details.ClrName, item.Key, StringComparison.OrdinalIgnoreCase)
        || string.Equals(m_Entries[i].Details.SqlName, item.Key, StringComparison.OrdinalIgnoreCase))
    {
        var value = item.Value ?? DBNull.Value;

        if (value == DBNull.Value)
        {
            if (!ignoreNullProperties)
                parts.Add($"{m_Entries[i].Details.QuotedSqlName} IS NULL");
        }
        else
        {
            m_Entries[i].ParameterValue = value;
            m_Entries[i].UseParameter = true;
            parts.Add($"{m_Entries[i].Details.QuotedSqlName} = {m_Entries[i].Details.SqlVariableName}");
        }

        found = true;
        keyFound = true;
        break;
    }
}

你會注意到的第一件事是沒有使用 for-each。爲了避免複製,仍然使用舊式的 for 循環。即使如此,所有的讀和寫操作都是直接在 m_Entries 數組中操作。
使用 C# 7 的局部引用,明顯地減少混亂而不改變語義。

for (var i = 0; i < m_Entries.Length; i++)
{
    ref Entry entry = ref m_Entries[i]; //create a reference
    if (string.Equals(entry.Details.ClrName, item.Key, StringComparison.OrdinalIgnoreCase)
        || string.Equals(entry.Details.SqlName, item.Key, StringComparison.OrdinalIgnoreCase))
    {
        var value = item.Value ?? DBNull.Value;

        if (value == DBNull.Value)
        {
            if (!ignoreNullProperties)
                parts.Add($"{entry.Details.QuotedSqlName} IS NULL");
        }
        else
        {
            entry.ParameterValue = value;
            entry.UseParameter = true;
            parts.Add($"{entry.Details.QuotedSqlName} = {entry.Details.SqlVariableName}");
        }

        found = true;
        keyFound = true;
        break;
    }
}

這是因爲 "局部引用" 真的是一個安全的指針。我們之所以說它 “安全” ,是因爲編譯器指向不允許任何臨時變量,諸如普通方法的結果。
如果你很想知道 " ref var entry = ref m_Entries[i];" 是不是有效的語法(是的),無論如何也不能這麼做,會造成混亂。 ref 既是用於聲明,又不會被用到。(譯者:這裏應該是指 entry 的 ref 修飾吧)

引用返回

引用返回豐富了本地方法,允許創建無副本的方法。
繼續之前的示例,我們可以將搜索結果輸出推到其靜態方法。

static ref Entry FindColumn(Entry[] entries, string searchKey)
{
    for (var i = 0; i < entries.Length; i++)
    {
        ref Entry entry = ref entries[i]; //create a reference
        if (string.Equals(entry.Details.ClrName, searchKey, StringComparison.OrdinalIgnoreCase)
            || string.Equals(entry.Details.SqlName, searchKey, StringComparison.OrdinalIgnoreCase))
        {
            return ref entry;
        }
    }
    throw new Exception("Column not found");
}

在這個例子中,我們返回了一個數組元素的引用。你也可以返回對象中字段的引用,使用引用屬性(見下文)和引用參數。

ref int Echo(ref int input)
{
    return ref input;
}
ref int Echo2(ref Foo input)
{
    return ref Foo.Field;
}

引用返回的一個有趣的功能是調用者可以選擇是否使用它。下面兩行代碼同樣有效:

Entry copy = FindColumn(m_Entries, "FirstName");
ref Entry reference = ref FindColumn(m_Entries, "FirstName");

引用返回和引用屬性

你可以創建一個引用返回風格的屬性,但只能用於該屬性只讀的情況下。例如:

public ref int Test { get { return ref m_Test; } }

對於不可變結構來說,這種模式似乎毫不傷腦。調用者不需要花費額外的功夫,就可以將其視爲引用值或普通值。
對於可變的結構,事情變得有趣起來。首先,這修復了一不小心就會通過修改屬性而改變結構返回值的老問題,只與值變化共進退。
考慮以下的類:

public class Shape
{
    Rectangle m_Size;
    public Rectangle Size { get { return m_Size; } }
}
var s = new Shape();
s.Size.Width = 5;

在 C# 1中,size 將保持不變。在 C# 6中,將觸發一個編譯器錯誤。在 C# 7 中,我們只是加了個 ref 修飾,卻能跑起來。

public ref Rectangle Size { get { return ref m_Size; } }

乍一看就像你一旦想覆蓋 size 的值就會被阻止。但事實證明,仍然可以編寫如下代碼:

var rect = new Rectangle(0, 0, 10, 20);
s.Size = rect;

即使該屬性是“只讀”,也將如期執行。這個對象清楚自己不會返回一個 Rectangle對象,而是保留指向 Rectangle對象所在位置的指針。
現在有了新的問題,不可變結構不再是永恆的。即使單個字段不能被更改,值卻被引用屬性替換了。C# 將通過拒絕執行該語法來警告你:

readonly int m_LineThickness;
public ref int LineThickness { get { return ref m_LineThickness; } }

引用返回和索引器

對於引用返回和局部引用最大的限制可能就是需要一個固定的指針。
考慮這行代碼:

ref int x = ref myList[0];

這樣的代碼無效,因爲列表不像數組,在讀取其值時會創建一個副本結構。下面是對 List<T> 實現 引用的源碼

public T this[int index] {
    get {
        // Following trick can reduce the range check by one
        if ((uint) index >= (uint)_size) {
            ThrowHelper.ThrowArgumentOutOfRangeException();
        }
        Contract.EndContractBlock();
        return _items[index]; <-- return makes a copy
    }

這同樣適用於 ImmutableArray<T> 和 訪問 IList<T> 接口的普通數組。但是,您可以實現自己的List<T>,將其索引定義爲引用返回。

public ref T this[int index] {
    get {
        // Following trick can reduce the range check by one
        if ((uint) index >= (uint)_size) {
            ThrowHelper.ThrowArgumentOutOfRangeException();
        }
        Contract.EndContractBlock();
        return ref _items[index]; <-- return ref makes a reference
    }

如果你這麼做,需要明確實現 IList<T> 和 IReadOnlyList<T> 接口。這是因爲引用返回具有與普通返回值不同的簽名,因此不能滿足接口的要求。
由於索引器實際上只是專用屬性,它們與引用屬性具有相同的限制; 這意味着您無法顯式定義 setter,而索引器卻是可寫的。

引用返回、局部引用和引用屬性指南

✔ 在使用數組的方法中,考慮使用引用返回而不是索引值
✔ 在擁有結構的自定義集合類中,對索引器考慮使用引用返回代替一般的返回結果。
✔ 將包含可變結構體的屬性暴露爲引用屬性。
✘ 不要將包含不可變結構的屬性暴露爲引用屬性。
✘ 不要在不可變或只讀類上暴露引用屬性。
✘ 不要在不可變或只讀集合類上暴露引用索引器。

ValueTask 和通用異步返回類型

Task類被創建時,它的主要角色是簡化多線程編程。它創建一種將長時間運行的操作推入線程池的通道,並在 UI線程上推遲讀取結果。而當你使用 fork-join 模式併發時,效果顯著。
隨着.NET 4.5中引入了 async/await ,一些缺陷也開始顯現。正如我們在2011年的反饋(詳見 Task Parallel Library Improvements in .NET 4.5),創建一個 Task對象所花費的時間比可接受的時間長,因此必須重寫其內部,結果是創建Task<Int32> 所需的時間縮短了49%至55%,並在大小上減小了52%。
這是很好的一步,但 Task 仍然分配了內存。所以當你在緊湊循環中使用它,如下所示將產生大量的垃圾。

while (await stream.ReadAsync(buffer, offset, count) != 0)
{
    //process buffer
}

而且如前所述, C# 高性能代碼的關鍵在於減少內存分配和隨後的GC循環。微軟的Joe Duffy在 Asynchronous Everything 的文章中寫到:

首先,請記住,Midori 被整個操作系統用於內存垃圾回收。我們必須學到了一些必要的經驗教訓,以便充分發揮作用。但我想說的主要是避免不必要的分配,分配越多麻煩越多,特別是短命對象。早期 .NET世界中流傳着一句口頭禪:Gen0 集合是無代價的。不幸的是,這形成了很多.NET的庫代碼濫用。Gen0 集合存在着中斷、弄髒緩存以及在高併發的系統中有高頻問題。

這裏的真正解決方案是創建一個基於結構的 task,而不是使用堆分配的版本。這實際上是以System.Threading.Tasks.Extensions 中的 ValueTask<T>創建。並且因爲 await 已經任何暴露的方法中工作了,所以你可以使用它。

手動暴露ValueTask<T>

ValueTask<T>的基本用例是預期結果在大部分時間是同步的,並且想要消除不必要的內存分配。首先,假設你有一個傳統的基於 task 的異步方法。

public async Task<Customer> ReadFromDBAsync(string key)

然後我們將其封裝到一個緩存方法中:

public ValueTask<Customer> ReadFromCacheAsync(string key)
{
    Customer result;
    if (_Cache.TryGetValue(key, out result))
        return new ValueTask<Customer>(result); //no allocation

    else
        return new ValueTask<Customer>(ReadFromCacheAsync_Inner(key));
}

並添加一個輔助方法來構建異步狀態機。

async Task<Customer> ReadFromCacheAsync_Inner(string key)
{
    var result = await ReadFromDBAsync(key);
    _Cache[key] = result;
    return result;
}

有了這一點,調用者可以使用與 ReadFromDBAsync 完全相同的語法來調用ReadFromCacheAsync;

async Task Test()
{
    var a = await ReadFromCacheAsync("aaa");
    var b = await ReadFromCacheAsync("bbb");
}

通用異步

雖然上述模式並不困難,但實施起來相當乏味。而且我們知道,編寫代碼越繁瑣,出現簡單的錯誤就越有可能。所以目前 C# 7 的提議是提供通用異步返回結果。
根據目前的設計,你只能使用異步關鍵字,並且方法返回 Task、Task<T>或者 void。一旦實現,通用異步返回結果將會擴展到任何 tasklike 方法上去。一些人認爲 tasklike 需要有一個 AsyncBuilder 屬性。這表明輔助類被用於創建 tasklike 對象。
在這個設計的注意事項中,微軟估計大概有五種人實際上會創建 tasklike 類,從而被普遍接受。其他人都很可能也像這五分之一。這是我們上面使用新語法的例子:

public async ValueTask<Customer> ReadFromCacheAsync(string key)
{
    Customer result;
    if (_Cache.TryGetValue(key, out result))
    {
        return result; //no allocation
    }
    else
    {
        result = await ReadFromDBAsync(key);
        _Cache[key] = result;
        return result;
    }
}

如您所見,我們已經去除了輔助方法,除了返回類型,它看起來像任何其他異步方法一樣。

何時使用 ValueTask<T>

所以應該使用 ValueTask<T> 代替 Task<T>? 完全不必要,這可能有點難以理解,所以我們將引用相關文檔:

方法可能會返回一個該值類型的實例,當它們的操作可以同時執行,同時被頻繁喚起(invoked)。這時,對於Task<TResult>,每一次調用都是昂貴的成本,應該被禁止。

使用 ValueTask<TResult> 代替 Task<TResult> 需要權衡利弊。例如,雖然 ValueTask<TResult> 可以避免分配,並且成功返回結果是可以同步返回的。然而它需要兩個字段,而 Task<TResult> 作爲引用類型只是一個字段。這意味着調用方法最終返回的是兩個數據而不是一個數據,這就會有更多的數據被複制。同時意味着如果在異步方法中需要等待時,只返回其中一個,這會導致該異步方法的狀態機變得更大。因爲要存儲兩個字段的結構而不是一個引用。

再進一步,使用者通過 await 來獲取異步操作的結果,ValueTask<TResult> 可能會導致更復雜的模型,實際上就會導致分配更多的內存。例如,考慮到一個方法可能返回一個普通的已緩存 task 的結果Task<TResult>,或者是一個 ValueTask<TResult>。如果調用者的預期結果是 Task<TResult>,可以被諸如 Task.WhenAll 和 Task.WhenAny 的方法調用,那麼 ValueTask<TResult> 首先需要使用 ValueTask<TResult>.AsTask 將其自身轉換爲 Task<TResult> ,如果 Task<TResult> 在第一次使用沒有被緩存了,將導致分配。

因此,Task的任何異步方法的默認選擇應該是返回一個 Task 或Task<TResult>。除非性能分析證明使用 ValueTask<TResult> 優於Task<TResult>。Task.CompletedTask 屬性可能被單獨用於傳遞任務成功執行的狀態, ValueTask<TResult> 並不提供泛型版本。

這是一段相當長的段落,所以我們在下面的指南中總結了這一點。

ValueTask <T>指南

✔ 當結果經常被同步返回時,請考慮在性能敏感代碼中使用 ValueTask<T>。
✔ 當內存壓力是個問題,且 Tasks 不能被緩存時,考慮使用 ValueTask<T>。
✘ 避免在公共API中暴露 ValueTask<T>,除非有顯著的性能影響。
✘ 不要在調用 Task.WhenAll 或 WhenAny 中調用 ValueTask<T>。

表達式體成員

表達式體成員允許消除簡單函數的括號。這通常是將一個四行函數減少到一行。例如:

public override string ToString()
{
    return FirstName + " " + LastName;
}
public override string ToString() => FirstName + " " + LastName;

必須注意不要過分。例如,假設當 FirstName 爲空時,您需要避免產生空格。你可能會這麼寫:

public override string ToString() => !string.IsNullOrEmpty(FirstName) ? FirstName + " " + LastName : LastName;

但是,你可能會遇到 last name 同時爲空。

public override string ToString() => !string.IsNullOrEmpty(FirstName) ? FirstName + " " + LastName : (!string.IsNullOrEmpty(LastName) ? LastName : "No Name");

如您所見,很容易得意忘形地使用這個功能。所以當你遇到有多分支條件或者 null合併操作時,請剋制使用。

表達式體屬性

表達式體屬性是 C# 6 的新特性。在使用 Get/Set 方法處理 MVVM風格的模型之類時,非常有用。
這是C#6代碼:

public string FirstName
{
    get { return Get<string>(); }
    set { Set(value); }
}

還有 C# 7的替代方案:

public string FirstName
{
    get => Get<string>();                      
    set => Set(value);              
}

雖然沒有減少代碼行數,但大部分 line-noise 代碼已經消失了。而且每個屬性都能這麼做,積少成多。
有關 Get/Set 在這些示例中的工作原理的更多信息,請參閱 C#, VB.NET To Get Windows Runtime Support, Asynchronous Methods

表達式體構造函數

表達式體構造函數是C# 7 的新特性。下面有一個例子:

class Person
{
    public Person(string name) => Name = name;
    public string Name { get; }
}

這裏的用法非常有限。它只有在零個或者一個參數的情況下才有效。一旦需要將其他參數分配給字段/屬性時,則必須用回傳統的構造函數。同時也無法初始化其他字段,解析事件處理程序等(參數驗證是可能的,請參見下面的“拋出表達式”。)
所以我們的建議是簡單地忽略這個功能。它只是將單參數構造函數看起來與一般的構造函數不同而已,同時讓代碼大小減少而已。

析構表達式

爲了使 C# 更加一致,析構被允許寫成和表達式的成員一樣,就像用在方法和構造函數一樣。
對於那些忘記析構的人來說,C# 中的析構是在 Finalize 方法上重寫System.Object。雖然 C# 不這樣表達:

~UnmanagedResource()
{
    ReleaseResources();
}

這種語法的一個問題是它看起來很像一個構造函數,因此可以很容易地被忽略。另一個問題是它模仿 C ++中的析構語法,卻是完全不同的語義。但是已經被使用了這麼久,所以我們只好轉向新的語法:

~UnmanagedResource() => ReleaseResources();

現在我們有一行孤立的、容易忽略的代碼,用於終結對象生命週期。這不是一個簡單的 屬性 或 ToString 方法,而是很重大的操作,需要顯眼一些。所以我建議不要使用它。

表達式體成員指南

✔ 爲簡單的屬性使用表達式體成員。
✔ 爲方法重載使用表達式體成員。
✔ 簡單的方法考慮使用表達式體成員。
✘ 不要在表達式體成員使用多分支條件(a?b:c)或 null 合併運算符(x ?? y)。
✘ 不要爲 構造函數 和 析構函數 中使用表達式成員。

拋出表達式

表面上,編程語言一般可以分爲兩種:

  • 一切都是表達式
  • 語句、聲明和表達式都是獨立的概念

Ruby是前者的一個實例,甚至其聲明也是表達式。相比之下,Visual Basic代表後者,語句和表達式之間有很強的區別。例如,對於 "if" 而言,當它獨立存在時,以及作爲表達式中的一部分時,是完全不同的語法。
C#主要是第二陣營,但存在着 C語言的遺產,允許你處理語句,當成表達式一樣。可以編寫如下代碼:

while ((current = stream.ReadByte()) != -1)
{
    //do work;
}

首先,C#7 允許使用非賦值語句作爲表達式。現在可以在表達式的任何地方放置 “throw” 語句,不用對語法做任何更改。以下是Mads Torgersen 新聞稿中的一些例子:

class Person
{
    public string Name { get; }

    public Person(string name) => Name = name ?? throw new ArgumentNullException("name");

    public string GetFirstName()
    {
        var parts = Name.Split(' ');
        return (parts.Length > 0) ? parts[0] : throw new InvalidOperationException("No name!");
    }

    public string GetLastName() => throw new NotImplementedException();
}

在這些例子中,很容易看出會發生什麼情況。但是如果我們移動拋出表達式的位置呢?

return (parts.Length == 0) ? throw new InvalidOperationException("No name!") : parts[0];

這樣看來就不夠易讀了。而左右的語句是相關的,中間的語句與他們無關。從第一個版本看,左邊是預期分支,右邊是錯誤分支。第二個版本的錯誤分支將預期分支分成兩半,打破整條流程。

我們來看另一個例子。這裏我們摻入一個函數調用。

void Save(IList<Customer> customers, User currentUser)
{
    if (customers == null || customers.Count == 0) throw new ArgumentException("No customers to save");

    _Database.SaveEach("dbo.Customer", customers, currentUser);
}

void Save(IList<Customer> customers, User currentUser)
{
    _Database.SaveEach("dbo.Customer", (customers == null || customers.Count == 0) ? customers : throw new ArgumentException("No customers to save"), currentUser);
}

我們已經可以看到,寫到一塊是有問題的,儘管它的LINQ並不難看。但是爲了更好地閱讀代碼,我們使用橙色標記條件,藍色標記函數調用,黃色標記函數參數,紅色標記錯誤分支。


這樣可以看到隨着參數改變位置,上下文如何變化。

拋出表達式指南

✔ 在分支/返回語句中,考慮將拋出表達式放在條件(a?b:c)和 null 合併運算符(x ?? y)的右側。
✘ 避免將拋出表達式放到條件運算的中間位置。
✘ 不要將拋出表達式放在方法的參數列表中。
有關異常如何影響 API設計的更多信息,請參閱 Designing with Exceptions in .NET

模式匹配 和 加強 Switch 語句

模式匹配(加強了 Switch 語句)對API設計沒有任何影響。所以雖然可以使異構集合的處理變得更加容易,但最好的情況還是儘可能地使用共享接口和多態性。
也就是說,有些細節還是要注意的。考慮這個八月份發佈的例子:

switch(shape)
{
    case Circle c:
        WriteLine($"circle with radius {c.Radius}");
        break;
    case Rectangle s when (s.Width == s.Height):
        WriteLine($"{s.Width} x {s.Height} square");
        break;
    case Rectangle r:
        WriteLine($"{r.Width} x {r.Height} rectangle");
        break;
    default:
        WriteLine("<unknown shape>");
        break;
    case null:
        throw new ArgumentNullException(nameof(shape));
}

以前,case的順序並不重要。在 C# 7 中,像 Visual Basic一樣,switch語句幾乎嚴格按順序執行。對於 when 表達式同樣適用。
實際上,您希望最常見的情況是 switch 語句中的第一種情況,就像在一系列 if-else-if 語句塊中一樣。同樣,如果任何檢查特別昂貴,那麼它應該越靠近底部,只在必要時才執行。
順序規則的例外是默認情況。它總是被最後處理,不管它的實際順序是什麼。這會使代碼更難理解,所以我建議將默認情況放在最後。

模式匹配表達式

雖然 switch 語句可能是 C# 中最常用的模式匹配; 但並不是唯一的方式。在運行時求值的任何布爾表達式都可以包含模式匹配表達式。
下面有一個例子,它判斷變量 'o' 是否是一個字符串,如果是這樣,則嘗試將其解析爲一個整數。

if (o is string s && int.TryParse(s, out var i))
{
    Console.WriteLine(i);
}

注意如何在模式匹配中創建一個名爲's'的新變量,然後再用於TryParse。這種方法可以鏈式組合,構建更復雜的表達式:

if ((o is int i) || (o is string s && int.TryParse(s, out i)))
{
    Console.WriteLine(i);
}

爲了方便比較, 將上述代碼重寫成 C# 6 風格:

if (o is int)
{
    Console.WriteLine((int)o);
}
else if (o is string && int.TryParse((string) o, out i))
{
    Console.WriteLine(i);
}

現在還不知道新的模式匹配代碼是否比以前的方式更有效,但它可能會消除一些冗餘的類型檢查。

一起維護這個在線文檔

C# 7 的新特性仍然很新鮮,而且關於它們在現實世界中如何運行,還需要多多瞭解。所以如果你看到一些你不同意的東西,或者這些指南中沒有的話,請讓我們知道。

關於作者

喬納森·艾倫(Jonathan Allen)在90年代末期開始從事衛生診所的MIS項目,從 Access 和 Excel 到企業解決方案。在爲金融部門編寫自動化交易系統五年之後,他成爲各種項目的顧問,包括機器人倉庫的UI,癌症研究軟件的中間層以及房地產保險公司的大數據需求。在空閒時間,他學習和書寫16世紀以來的武術知識。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章