Partager via


C# 7.0 新功能介紹

c7-0-01

這篇文章介紹了 C# 7.0 的新語法。這也是在 2017/03/07 發表的 Visual Studio 2017 中眾多新功能之一。

在 C# 7.0 新增了許多支援的語法,重點擺在改善效能、精簡程式碼、以及資料取用幾個部分。其中最主要的功能之一是 Tuples, 能讓你更容易的一次傳回多筆結果,另外 Pattern Match 新語法則簡化了撰寫針對特定資料型態與條件的程式碼。除此之外,C# 7.0 也包含了其他重要的各種新語法支援。希望所有的這些改變都能讓你更愉快的寫出有效率,簡潔的程式碼,同時也更有生產力。

如果你很好奇我們如何導引出這些功能的設計過程,可以查閱 C# Language design GitHub 網站,在那邊可以找到設計說明文件,設計提案,與大量的討論內容。

如果你覺得這篇文章內容很熟悉,也許是你曾經看過去年八月份 (2016/08) 發表過的版本。在 C# 7.0 最終定案的版本中有少數的異動,這些
異動都來自先前版本的眾多優良的回饋意見。

希望你喜歡 C# 7.0, 盡情享受它, Happy Hacking !!

Mads Torgersen, C# Language PM


譯註:

為了更清楚的表達這篇文章的內容,翻譯時我採用意譯,而非逐句翻譯。我也會適時補充字句,讓文章要表達的意義更清楚完整。

太多專有名詞,翻成中文反而對閱讀沒有幫助,因此這部分我保留原文,但是我會在譯註的部分額外補充說明。
期望這樣能更清楚的讓讀者了解內容。


Microsoft-logo 本篇文章,帶您看到以下 C# 7.0 新功能


Out 變數 (out variables)

在先前版本的 C# 中,out 參數的使用並不如我們期望的那麼的流暢。呼叫帶有 out 參數的 method 之前,你必須先宣告變數
並且將它當作 out 的參數傳遞才行。通常你不會 (也不需要) 先初始化這變數 (變數的內容會在被呼叫的 method 內覆寫),同時你也不能使用 var 的方式來宣告它, 你必須很明確的指定這變數的完整型別:

 public void PrintCoordinates(Point p)
{
    int x, y; // have to "predeclare"
    p.GetCoordinates(out x, out y);
    WriteLine($"({x}, {y})");
}

在 C# 7.0,新增了 out 變數,可以在傳遞 out 參數時同時宣告這個變數:

 public void PrintCoordinates(Point p)
{
    p.GetCoordinates(out int x, out int y);
    WriteLine($"({x}, {y})");
}

請留意,這個變數在包含它本身的 { } 封閉區塊範圍內,所以接續宣告後面的程式碼可以直接使用這些變數。
多數類似型態的語法沒有指定可視範圍,該變數可視範圍就等同於宣告他的區塊範圍。

通常 out 變數都會直接被宣告為傳遞的參數,編譯器通常能直接判定參數的型別為何 (除非 method 包含數個互相衝突
overloads 而無法判定),因此可以直接使用 var 的方式來宣告它:

 p.GetCoordinates(out var x, out var y);

一般來說,我們常常在 Try... 這類的使用模式中用到 out 參數,它會傳回 true 或是 false 來代表執行成功與否,同時藉著 out 參數來傳回成功執行後的結果:

 public void PrintStars(string s)
{
    if (int.TryParse(s, out var i)) { WriteLine(new string('*', i)); }
    else { WriteLine("Cloudy - no stars tonight!"); }
}

如果你不在意某個 out 參數的傳回結果,可以使用 _ 代表忽略它:

 p.GetCoordinates(out var x, out _); // I only care about x

Pattern Matching (模式匹配)

C# 7.0 開始引入了 patterns (模式) 的概念。抽象的來說,他是可以判定資料是否具備一定 "形狀"(Shape) 的語法元素,並從該數值之中提取需要的資訊。

譯註:
Shape, 代表資料的 "形狀", 精確的來說包含資料本身型別包含哪些成員? 這些成員的數值是否落在預期的範圍?
patterns 可以讓判斷資料 "形狀" 的程式碼更為簡潔明確。

舉例來說,C# 7.0 支援的 patterns 有這幾種:

  • Constant Patterns (常數模式, 以 c 表示,c 是 C# 的常數表達式), 測試輸入的數值是否與 c 相等。
  • Type Patterns (類型模式, 以 T x 表示,T 代表型別,而 x 是識別名稱), 測試輸入的數值是否屬於類別 T? 如果是的話就把輸入的數值放到類型為 T 的變數 x 中。
  • Var Patterns (變數模式, 以 var x 表示, x 是識別名稱), 這種模式下永遠會匹配成功,此時 x 的型別與輸入的數值相同,這模式下只是簡單的把輸入的數值放到 x 之中。

這些只是計畫中的第一步 - pattern (模式) 是 C# 新型態的語法元素,我們期望未來能繼續新增更多的功能。

在 C# 7.0 我們用 pattern 來增強兩種既有的語法結構:

  • is expression (is 表達式) 現在可以在右方加上 pattern,在之前則只能定義型別。
  • switch 陳述式中的 case 子句,現在可以比對模式是否符合,過去則只支援常數數值

在未來的 C# 我們會增加更多適用 pattern 的語法。

使用 pattern 的 is 表達式

來看看使用 constant patternstype patternsis expression 使用範例:

 public void PrintStars(object o)
{
    if (o is null) return;     // constant pattern "null"
    if (!(o is int i)) return; // type pattern "int i"
    WriteLine(new string('*', i));
}

如所見,pattern 變數 - 由 pattern 引入的變數,跟前面介紹的 out 變數非常相似,你可以宣告在表達式之中,而且可以直接就近在同可是範圍內直接使用他。

out 變數很相似的地方是,模式變數是可變動的,我們常將 out 變數與 pattern 變數,統稱為 expression 變數。

Patterns 常與 Try... method 一起使用:

 if (o is int i || (o is string s && int.TryParse(s, out i)) { /* use i */ }

使用 patterns 的 switch 陳述式

在 C# 7.0,我們也擴大了 switch 陳述式的應用範圍:

  • switch 陳述式現在可以運用在所有型別 (不再只限於基本類型)
  • patterns 可以用在 case 子句
  • case 子句可以附加條件判斷式

這邊有對應的範例程式碼:

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

這裡有幾個 switch 陳述式新增的擴充功能:

  • case 子句的順序是重要的:
    就如同 catch 子句一樣,多個 case 子句之間不再是沒有順序關聯的,而第一個符合條件的 case 子句會被選中。這點非常重要,拿上一個範例程式碼來說,代表正方形的這個 case 子句 (譯註: case Rectangle s when (s.Length == s.Height):) 應該要排在代表長方形的 case 子句 (case Rectangle r:) 前面,結果才會正確。另外,就像 catch 子句一樣,編譯器可以標示出永遠無法執行到的程式碼來協助你。在這之前,你無法也不需要指定多個 case 之間的評估順序,所以這並不是個破壞性的改變 (breaking change)。
  • default 子句永遠會最後才評估:
    即使在上述的例子中,null case 子句被擺在最後,他仍然會在 default 子句之前被檢查。這樣的設計是為了與現有的 switch 陳述句保持相容。然而,好的做法通常會明確的將 default 子句擺在最後面。
  • 擺在最後面的 null case 子句並不會無法被被執行到:
    因為 type patterns (類型模式) 依循 is expression 的例子,不會與 null 子句匹配。這可以確保 null 子句不會不小心被任何的 type patterns (類型模式) 給搶走,你必須更清楚該如何處理這種狀況 (或是把它留給 default 子句來處理)

由 case ... 引進的 pattern 變數 ,他的可視範圍只限於對應的 switch 區段。

Tuples

想要從一個 method 傳回一個以上的傳回值是蠻常見的狀況。但是目前 C# 版本對這需求能提供的作法都不夠好。現有的作法有:

  • out 參數:
    使用上很累贅 (即使在前面的部分已經介紹了改良的語法),而且這方式也無法搭配 async method 一起使用。
  • 使用 System.Tuple<...> 型別來傳回值:
    需要手動配置一個 tuple 物件,同時也需要寫些冗長的 code 才能辦到。
  • 替每個 method 都自訂專屬的傳回值型別:
    得額外寫大量的 code 來完成這件事,但是目的只是暫時將多個數值組合起來而已。
  • 使用 dynamic 來傳回匿名的型別 (anonymous types):
    無法做到靜態型別檢查,同時將會付出很高的效能代價。

為了讓這件事做得更好,C# 7.0 新增了 tuple typestuple literals 的語法:

 (string, string, string) LookupName(long id) // tuple return type
{
    ... // retrieve first, middle and last from data storage
    return (first, middle, last); // tuple literal
}

method 現在能更有效率的傳回三個字串型別的傳回值了,這範例將三個字串包成一個 tuple
呼叫這 method 的程式碼將會收到回傳的 tuple 物件,且能透過 tuple 物件個別存取這些封裝在內的資料:

 var names = LookupName(id);
WriteLine($"found {names.Item1} {names.Item3}.");

其中 Item1 等等,為 tuple 內的元素預設的名稱,這方法能正常運作, 但是這命名方式終究不能很能清楚表達用途。所以你願意的話可以明確的替它們指定更適合的名稱:

 (string first, string middle, string last) LookupName(long id) // tuple elements have names

現在這個 tuple 的元素能用更貼切的名稱來存取之內的元素了:

 var names = LookupName(id);
WriteLine($"found {names.first} {names.last}.");

你也可以直接在 tuple literals 內指定元素的名稱:

     return (first: first, middle: middle, last: last); // named tuple elements in a literal

一般來說,你可以互相指派 tuple 的變數,而不用管他的名稱為何: 只要個別的元素都可以被指派,tuple 型別可以自由轉換為其他的 tuple 型別。

Tuplesvalue types, 而且它包含的元素都很單純的被標示為 public, 都是可異動的欄位 (mutable fields)。它們是 "數值相等" (value equality) 的,
意思是只要兩個 tuples 的所有對應的元素都是相等的 (而且 hash code 也必須相同),那這兩個 tuples 就是相等的 (hash code 也會相同) 。

除了傳回多個傳回值的情況之外,在其他地方 tuples 也很有用。例如,如果你需要一個包含多個 Key 的 Dictionary,你只需要拿 tuple 當作 Dictionary 的 Key 就可以了。如果你需要在 List 內的一個元素放置多個不同的數值,只要使用 tuple 型別並且搜尋這個 List。在這些情況中,tuple 都能正常運作。

Tuples 的實作必須依靠底層的泛型結構型別 (generic struct types): ValueTuple<...>。如果你使用的 target framework 版本還未包含它,你只需要透過 NuGet 取得他們即可:

  • 在 "方案總管" 內的 "專案" 上按右鍵,選擇 "管理 NuGet 套件..."
  • 選擇 "瀏覽" 頁籤,同時在 "套件來源" 項目中選擇 "nuget.org"
  • 搜尋 "System.ValueTuple" 並安裝

Desconstruction (解構 )

另一個使用 tuples 的方式是將他們 deconstruct (解構)。Deconstructing declaration (解構宣告) 是用來將 tuple (或是其他值) 裡面的部分拆解並個別指派到其他新的變數用的語法:

 (string first, string middle, string last) = LookupName(id1); // deconstructing declaration
WriteLine($"found {first} {last}.");

deconstructing declaration (解構宣告) 中,可以在個別的變數上使用 var:

 (var first, var middle, var last) = LookupName(id1); // var inside

甚至你可以在括號外面只用單一一個 var:

 var (first, middle, last) = LookupName(id1); // var outside

你也可以透過 deconstructing assignment (解構指派) 將 tuple 解構後指派到一個既有的變數:

 (first, middle, last) = LookupName(id2); // deconstructing assignment

Deconstruction 不只適用於 tuple,任何型別只要它包含 deconstructor (解構式, 無論是定義
在 instance method 或是 extension method 都可以) ,就可以被解構:

 public void Deconstruct(out T1 x1, ..., out Tn xn) { ... }

在這個 deconstructor 裡定義的所有 out 參數,就是該型別物件解構後的所有項目。
(為何在這邊我們使用 out 參數,而不直接傳回 tuple ? 因為這樣就可以讓你為不同數量的
變數,分別定義多個 overloads (多載))

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

    public Point(int x, int y) { X = x; Y = y; }
    public void Deconstruct(out int x, out int y) { x = X; y = Y; }
}

(var myX, var myY) = GetPoint(); // calls Deconstruct(out myX, out myY);

你可以用這樣常見的模式,讓 constructordeconstructor 的參數對稱排列。
就如同 out 變數的語法,我們允許你在 deconstructor 中 "忽略" 你不在意的 out 參數:

 (var myX, _) = GetPoint(); // I only care about myX

譯註: 請勿將這裡介紹的 deconstructor 與一般物件導向語言 (如: C++, C# 都有) 常見的 descructor 搞混了。
這個段落介紹的 C# 解構式 (deconstructor), 是定義物件如何 "拆解" 為多個獨立的變數。拆解後原物件仍然存在。
而 C# 與 constructor (建構式) 作用相反的 descructor (解構函式), 則是定義物件要被銷毀前必須執行的動作。
兩者的中文譯名都同樣是 "解構" 請特別留意
對於 C# descructor 的說明,可以參考: https://msdn.microsoft.com/en-us/library/66x5fx1b.aspx

Local functions (區域函式)

有時,輔助函式只有在使用他的函式內才有意義。現在這種情況下,你可以在其他函式內宣告 local functions (區域函式):

 public int Fibonacci(int x)
{
    if (x < 0) throw new ArgumentException("Less negativity please!", nameof(x));
    return Fib(x).current;

    (int current, int previous) Fib(int i)
    {
        if (i == 0) return (1, 0);
        var (p, pp) = Fib(i - 1);
        return (p + pp, p);
    }
}

local function (區域函式) 內,可以直接使用封閉區塊內的 parameters (參數) 與 local variables (區域變數),用法及規則就跟 lambda 運算式 的用法一樣。

舉例來說,iterator method 通常外面都需要包覆另一個 non-iterator method ,用來在呼叫時做參數檢查 (iteraotr 在這時並不會執行,而是在 MoveNext() 被呼叫時才會啟動)。這時 local function 就非常適合在這裡使用:

 public IEnumerable<T> Filter<T>(IEnumerable<T> source, Func<T, bool> filter)
{
    if (source == null) throw new ArgumentNullException(nameof(source));
    if (filter == null) throw new ArgumentNullException(nameof(filter));

    return Iterator();

    IEnumerable<T> Iterator()
    {
        foreach (var element in source) 
        {
            if (filter(element)) { yield return element; }
        }
    }
}

同樣的例子,不用 local function 的話,就必須把該 method 定義在 Filter 後面,將 iterator 宣告為 private method。這樣會導致封裝性被破壞: 其他成員可能意外的使用它 (而且參數檢查會被略過)。同時,所有原本 local function 需要取用的區域變數與參數,都必須做一樣的處理 (變成 private members)

改良的 Literal

C# 7.0 允許在 number literal (數字常數) 中,用 _ 當作 digit separator (數字分隔器):

 var d = 123_456;
var x = 0xAB_CD_EF;

你可以將 _ 放在數字中的任何位置,來提高程式碼的可讀性,完全不會對數值本身有任何影響。

此外,C# 7.0 也引入二進位的常數表示方式,你現在可以直接用二進位的方式來取代過去十六進位 (例: 0x001234) 的表示方式。例如:

 var b = 0b1010_1011_1100_1101_1110_1111;

Ref returns 與 ref locals

如同你可以在 C# 用參考的方式傳遞參數 (使用 ref 修飾詞),你現在也可以用同樣的方式將區域變數的數值用參考的方式傳回。

 public ref int Find(int number, int[] numbers)
{
    for (int i = 0; i < numbers.Length; i++)
    {
        if (numbers[i] == number) 
        {
            return ref numbers[i]; // return the storage location, not the value
        }
    }
    throw new IndexOutOfRangeException($"{nameof(number)} not found");
}

int[] array = { 1, 15, -39, 0, 7, 14, -12 };
ref int place = ref Find(7, array); // aliases 7's place in the array
place = 9; // replaces 7 with 9 in the array
WriteLine(array[4]); // prints 9

這在回傳大型資料結構時相當有用。舉例來說,遊戲程式可能會預先配置龐大的陣列來存放結構資料 (這樣是為了避免執行過程中發生 garbage collect,
導致遊戲暫停)。現在 method 可以直接用參考的方式傳回結構的資料,呼叫端可以直接讀取與修改它的內容。

同時,有些搭配限制來確保這樣做是安全的:

  • 你只能傳回 "能夠安全傳回" 的參考: 一個是外界傳遞給你的參考,另一個是指向目前物件的 fields (欄位) 的參考。
  • ref locals 在初始化時會被指向某個儲存位置,一旦指派之後無法再更改。

非同步的傳回型別

到目前為止,C# 的非同步 method 限定必須傳回 void, Task 或是 Task<T> 這幾種型別。C# 7.0 開始,也允許你用同樣的方式,從非同步方法傳回你定義的其他型別。

舉例來說,我們現在可以定義 ValueTask<T> 這個 struct 型別當作傳回值。
這可以避免當非同步執行的結果已經可用,但是卻因為要進行 Task<T> 的配置,而導致非同步執行的結果還在等待中 (awaiting 狀態)。許多涉及 buffering(緩衝) 的非同步操作時,這做法可以明顯地降低配置的次數,同時能帶來明顯的效能提升。

譯註: 例如非同步 I/O 的操作,我們會用非同步的方式將檔案的內容讀到 buffer 內,完成後再不斷重複同樣動作,直到檔案讀取完畢為止,這個動作也許會被重複上千萬次。此時由 Task<T> 替換為 ValueTask<T> 可能可以帶來明顯的效能提升。

也有很多其他的情況下,你可以想像自訂 "task-like" 的應用類型會很有用。要正確地建立它們並不是那麼的直觀,所以我們也不期待大部分的人能正確的使用它們。但是它們可能開始會出現在其他的框架或是 API,而呼叫者可以像過去使用 Task 一樣的使用他,傳回值與 await 等待結果

更廣泛的 expression bodies 成員

在 C# 6.0 以前,expression bodied methods, properties(屬性) 等功能大受歡迎,但不是所有的成員都可以
使用。在 C# 7.0 中,accessors (存取子), constructor (建構式) 與 finalizers (終結器) 都已加到可以使用 expression bodies 的清單中:

 class Person
{
    private static ConcurrentDictionary<int, string> names = new ConcurrentDictionary<int, string>();
    private int id = GetId();

    public Person(string name) => names.TryAdd(id, name); // constructors
    ~Person() => names.TryRemove(id, out *);              // destructors
    public string Name
    {
        get => names[id];                                 // getters
        set => names[id] = value;                         // setters
    }
}

這個新語法的範例程式並非來自 Microsoft C# 編譯器的團隊,而是由社群成員貢獻的。太棒了! Open source!

Throw 運算式

要在運算式之中丟出一個例外 (exception) 是很容易的,只要呼叫 method (在 method 內擲出 exception) 就可以了。但是在 C# 7.0 我們允許在運算式之中直接就地丟出 exception:

 class Person
{
    public string Name { get; }
    public Person(string name) => Name = name ?? throw new ArgumentNullException(nameof(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();
}

🌟 更多最新文章及資訊 >> MSDN 台灣粉絲專頁 & MSDN 台灣部落格

🌟 更多熱門課程影片 >> Channel 9 免費課程平台