C# 8.0 添加和增強的功能【基礎篇】

.NET Core 3.x.NET Standard 2.1支援C# 8.0

一、Readonly 成員

可將 readonly 修飾符應用於結構的成員,來限制成員為不可修改狀態。這比在C# 7.2中將 readonly 修飾符僅可應用於 struct 聲明更精細。

public struct Point
{
    public double X { get; set; }
    public double Y { get; set; }
    public double Distance => Math.Sqrt(X * X + Y * Y);
    public override string ToString() =>
        $"({X}, {Y}) is {Distance} from the origin";
}

與大多數結構一樣,ToString() 方法不會修改狀態。 可以通過將 readonly 修飾符添加到 ToString() 的聲明來對此進行限制:

public readonly override string ToString() =>// 編譯器警告,因為 ToString 訪問未標記為 readonly 的 Distance 屬性
    $"({X}, {Y}) is {Distance} from the origin";
// Distance 屬性不會更改狀態,因此可以通過將 readonly 修飾符添加到聲明來修復此警告
public readonly double Distance => Math.Sqrt(X * X + Y * Y);

注意:readonly 修飾符對於只讀屬性是必需的。

編譯器會假設 get 訪問器可以修改狀態;必須顯式聲明 readonly

自動實現的屬性是一個例外;編譯器會將所有自動實現的 Getter 視為 readonly,因此,此處無需向 X 和 Y 屬性添加 readonly 修飾符。

通過此功能,可以指定設計意圖,使編譯器可以強制執行該意圖,並基於該意圖進行優化。

二、默認介面方法

.NET Core 3.0 上的 C# 8.0 開始,可以在聲明介面成員時定義實現。 最常見的方案是,可以將成員添加到已經由無數客戶端發布並使用的介面。示例:

// 先聲明兩個介面
// 客戶介面
public interface ICustomer
{
    IEnumerable<IOrder> PreviousOrders { get; }
    DateTime DateJoined { get; }
    DateTime? LastOrder { get; }
    string Name { get; }
    IDictionary<DateTime, string> Reminders { get; }
    
    // 在客戶介面中加入新的方法實現
    public decimal ComputeLoyaltyDiscount()
    {
        DateTime TwoYearsAgo = DateTime.Now.AddYears(-2);
        if ((DateJoined < TwoYearsAgo) && (PreviousOrders.Count() > 10))
        {
            return 0.10m;
        }
        return 0;
    }
}
// 訂單介面
public interface IOrder
{
    DateTime Purchased { get; }
    decimal Cost { get; }
}

// 測試程式碼
// SampleCustomer:介面 ICustomer 的實現,可不實現方法 ComputeLoyaltyDiscount
// SampleOrder:介面 IOrder 的實現
SampleCustomer c = new SampleCustomer("customer one", new DateTime(2010, 5, 31))
{
    Reminders =
    {
        { new DateTime(2010, 08, 12), "childs's birthday" },
        { new DateTime(1012, 11, 15), "anniversary" }
    }
};

SampleOrder o = new SampleOrder(new DateTime(2012, 6, 1), 5m);
c.AddOrder(o);//添加訂單
o = new SampleOrder(new DateTime(2103, 7, 4), 25m);
c.AddOrder(o);

// 驗證新增的介面方法
ICustomer theCustomer = c; // 從 SampleCustomer 到 ICustomer 的強制轉換
Console.WriteLine($"Current discount: {theCustomer.ComputeLoyaltyDiscount()}");
// 若要調用在介面中聲明和實現的任何方法,該變數的類型必須是介面類型,即:theCustomer

三、模式匹配的增強功能

C# 8.0擴展了C# 7.0中的辭彙表(isswitch),這樣就可以在程式碼中的更多位置使用更多模式表達式。

3.1 switch 表達式

區別與 switch 語句:

  變數位於 switch 關鍵字之前;

  將 case 和 : 元素替換為 =>,更簡潔、直觀;

  將 default 事例替換為 _ 棄元;

  實際語句是表達式,比語句更加簡潔。

public static RGBColor FromRainbow(Rainbow colorBand) =>
    colorBand switch
    {
        Rainbow.Red    => new RGBColor(0xFF, 0x00, 0x00),
        Rainbow.Orange => new RGBColor(0xFF, 0x7F, 0x00),
        Rainbow.Yellow => new RGBColor(0xFF, 0xFF, 0x00),
        Rainbow.Green  => new RGBColor(0x00, 0xFF, 0x00),
        Rainbow.Blue   => new RGBColor(0x00, 0x00, 0xFF),
        Rainbow.Indigo => new RGBColor(0x4B, 0x00, 0x82),
        Rainbow.Violet => new RGBColor(0x94, 0x00, 0xD3),
        _              => throw new ArgumentException(message: "invalid enum value", paramName: nameof(colorBand)),
    };

3.2 屬性模式

藉助屬性模式,可以匹配所檢查的對象的屬性。

如下電子商務網站的示例,該網站必須根據買家地址(Address 對象的 State 屬性)計算銷售稅。

// Address:地址對象;salePrice:售價
public static decimal ComputeSalesTax(Address location, decimal salePrice) =>
    location switch
    {
        { State: "WA" } => salePrice * 0.06M,
        { State: "MN" } => salePrice * 0.075M,
        { State: "MI" } => salePrice * 0.05M,
        // other cases removed for brevity...
        _ => 0M
    };

此寫法,使得整個語法更為簡潔。

3.3 元組模式

日常開發中,存在演算法依賴於多個輸入。使用元組模式,可根據 表示為元組 的多個值進行切換。

// 遊戲「rock, paper, scissors(石頭剪刀布)」的切換表達式
public static string RockPaperScissors(string first, string second)
    => (first, second) switch
    {
        ("rock", "paper") => "rock is covered by paper. Paper wins.",
        ("rock", "scissors") => "rock breaks scissors. Rock wins.",
        ("paper", "rock") => "paper covers rock. Paper wins.",
        ("paper", "scissors") => "paper is cut by scissors. Scissors wins.",
        ("scissors", "rock") => "scissors is broken by rock. Rock wins.",
        ("scissors", "paper") => "scissors cuts paper. Scissors wins.",
        (_, _) => "tie" // 此處棄元 表示平局(石頭剪刀布遊戲)的三種組合或其他文本輸入
    };

3.4 位置模式

某些類型包含 Deconstruct 方法,該方法將其屬性解構為離散變數。 如果可以訪問 Deconstruct 方法,就可以使用位置模式檢查對象的屬性並將這些屬性用於模式。

// 位於象限中的 點對象
public class Point
{
    public int X { get; }
    public int Y { get; }
    public Point(int x, int y) => (X, Y) = (x, y);
    public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
}
public enum Quadrant// 象限
{
    Unknown, Origin, One, Two, Three, Four, OnBorder
}
// 下面的方法使用位置模式來提取 x 和 y 的值。 然後,它使用 when 子句來確定該點的 Quadrant
static Quadrant GetQuadrant(Point point) => point switch
{
    (0, 0) => Quadrant.Origin,
    var (x, y) when x > 0 && y > 0 => Quadrant.One,
    var (x, y) when x < 0 && y > 0 => Quadrant.Two,
    var (x, y) when x < 0 && y < 0 => Quadrant.Three,
    var (x, y) when x > 0 && y < 0 => Quadrant.Four,
    var (_, _) => Quadrant.OnBorder,// 當 x 或 y 為 0(但不是兩者同時為 0)時,前一個開關中的棄元模式匹配
    _ => Quadrant.Unknown
};

如果沒有在 switch 表達式中涵蓋所有可能的情況,編譯器將生成一個警告。

四、using 聲明

using 聲明是前面帶 using 關鍵字的變數聲明。它指示編譯器聲明的變數應在封閉範圍的末尾進行處理。

static int WriteLinesToFile(IEnumerable<string> lines)
{
    using var file = new System.IO.StreamWriter("WriteLines2.txt");
    int skippedLines = 0;
    foreach (string line in lines)
    {
        if (!line.Contains("Second"))
            file.WriteLine(line);
        else
            skippedLines++;
    }
    return skippedLines;
    // 當程式碼運行到此位置時,file 被銷毀
    // 相當於 using (var file = new System.IO.StreamWriter("WriteLines2.txt")){ ... }
}

 如果 using 語句中的表達式不可用,編譯器將生成一個錯誤。

五、靜態本地函數

C# 8.0中可以向本地函數添加 static 修飾符,以確保本地函數不會從封閉範圍捕獲(引用)任何變數。 若引用了就會生成報錯:CS8421-「靜態本地函數不能包含對 <variable> 的引用」。

// 本地方法 LocalFunction 訪問了方法 M() 這個封閉空間的變數 y
// 因此,不能用 static 修飾符來聲明
int M()
{
    int y;
    LocalFunction();
    return y;
    void LocalFunction() => y = 0;
}
// Add 方法可以是靜態的,因為它不訪問封閉範圍內的任何變數
int M()
{
    int y = 5;
    int x = 7;
    return Add(x, y);
    static int Add(int left, int right) => left + right;
}

六、可處置的 ref 結構

用 ref 修飾符聲明的 struct 可能無法實現任何介面,也包括介面 IDisposable

class Program
{
   static void Main(string[] args)
   {
      using (var book = new Book())
         Console.WriteLine("Hello World!");
   }
}
// 錯誤寫法
// Error CS8343 'Book': ref structs cannot implement interfaces
ref struct Book : IDisposable
{
   public void Dispose()
   {   }
}
// 正確寫法
class Program
{
   static void Main(string[] args)
   {
      // 根據 using 新特性,簡潔的寫法,默認在當前程式碼塊結束前銷毀對象 book
      using var book = new Book();
      // ...
    }
}
ref struct Book
{
   public void Dispose()
   {
   }
}

因此,若要能夠處理 ref struct,就必須有一個可訪問的 void Dispose() 方法。

此功能同樣適用於 readonly ref struct 聲明。

七、可為空引用類型

若要指示一個變數可能為 null,必須在類型名稱後面附加 ?,以將該變數聲明為可為空引用類型。否則都被視為不可為空引用類型。

對於不可為空引用類型,編譯器使用流分析來確保在聲明時將本地變數初始化為非 Null 值。 欄位必須在構造過程中初始化。 如果沒有通過調用任何可用的構造函數或通過初始化表達式來設置變數,編譯器將生成警告。

此外,不能向不可為空引用類型分配一個可以為 Null 的值。

編譯器使用流分析,來確保可為空引用類型的任何變數,在被訪問或分配給不可為空引用類型之前,都會對其 Null 性進行檢查。

八、非同步流

非同步流,可針對流式處理數據源建模 。 數據流經常非同步檢索或生成元素,因此它們為非同步流式處理數據源提供了自然編程模型。

// 非同步枚舉,核心對象是:IAsyncEnumerable
[HttpGet("syncsale")]
public async IAsyncEnumerable<Product> GetOnSaleProducts()
{
    var products = _repository.GetProducts();
    await foreach (var product in products) // 消費非同步枚舉,順序取決於 IAsyncEnumerator 演算法
    {
        if (product.IsOnSale)
            yield return product;// 持續非同步逐個返回,不用等全部完成
    }
}

另一個實例:模擬非同步抓取 html 數據

// 這是一個【相互獨立的長耗時行為的集合(假設分別耗時 5,4,3,2,1s)】
static async Task Main(string[] args)
{
      Console.WriteLine(DateTime.Now + $"\tThreadId:{Thread.CurrentThread.ManagedThreadId}\r\n");
      await foreach (var html in FetchAllHtml()) // 默認按照任務加入的順序輸出
      {
           Console.WriteLine(DateTime.Now + $"\tThreadId:{Thread.CurrentThread.ManagedThreadId}\t" + $"\toutput:{html}");
      }
      Console.WriteLine("\r\n" + DateTime.Now + $"\tThreadId:{Thread.CurrentThread.ManagedThreadId}\t");
      Console.ReadKey();
 }
 // 這裡已經默認實現了一個 IEnumerator 枚舉器: 以 for 循環加入非同步任務的順序
 static async IAsyncEnumerable<string> FetchAllHtml()
 {
    for (int i = 5; i >= 1; i--)
    {
        var html = await Task.Delay(i* 1000).ContinueWith((t,i)=> $"html{i}",i); // 模擬長耗時
        yield return html;
    }
 }

  

接著,其實五個操作是分別開始執行的,那麼當耗時短的任務處理好後,能否直接輸出呢?這樣的話交互體驗就更好了!

static async IAsyncEnumerable<string> FetchAllHtml()
{  
    var tasklist= new List<Task<string>>();
    for (int i = 5; i >= 1; i--)
    {
       var t= Task.Delay(i* 1000).ContinueWith((t,i)=>$"html{i}",i);// 模擬長耗時任務
       tasklist.Add(t);
    }
    while(tasklist.Any())  // 監控已完成的操作,立即處理
    {
      var tFinlish = await Task.WhenAny(tasklist);
      tasklist.Remove(tFinlish); 
      yield return await tFinlish; // 完成即輸出
    }
}  

以上總耗時取決於 耗時最長的那個非同步任務5s。

  

  參考自:C# 8.0 寶藏好物 Async streams

九、非同步可釋放(IAsyncDisposable)

IAsyncDisposable 介面,提供一種用於非同步釋放非託管資源的機制。與之對應的就是提供同步釋放非託管資源機制的介面 IDisposable

提供此類及時釋放機制,可使用戶執行資源密集型釋放操作,從而無需長時間佔用 GUI 應用程式的主執行緒。

同時更好的完善.NET非同步編程的體驗,IAsyncDisposable誕生了。它的用法與IDisposable非常的類似:

public class ExampleClass : IAsyncDisposable
{
	private Stream _memoryStream = new MemoryStream();
	public ExampleClass()
	{	}
	public async ValueTask DisposeAsync()
	{
		await _memoryStream.DisposeAsync();
	}
}
// using 語法糖
await using var s = new ExampleClass()
{
	// doing
};
// 優化 同樣是對象 s 只存在於當前程式碼塊
await using var s = new ExampleClass();
// doing

  參考於:熟悉而陌生的新朋友——IAsyncDisposable

十、索引和範圍

索引和範圍,為訪問序列中的單個元素或範圍,提供了簡潔的語法。

新增了兩個類型(System.Index & System.Range)和運算符(末尾運算符”^” & 範圍運算符「..」)。

用例子說話吧:

var words = new string[]
{
                // index from start    index from end
    "The",      // 0                   ^9
    "quick",    // 1                   ^8
    "brown",    // 2                   ^7
    "fox",      // 3                   ^6
    "jumped",   // 4                   ^5
    
    "over",     // 5                   ^4
    "the",      // 6                   ^3
    "lazy",     // 7                   ^2
    "dog"       // 8                   ^1
    
};              // 9 (or words.Length) ^0

運算實例:

Console.WriteLine($"The last word is {words[^1]}");
// 「dog」 // 使用 ^1 索引檢索最後一個詞

var quickBrownFox = words[1..4];
//「quick」、「brown」、「fox」 子範圍

var lazyDog = words[^2..^0];
// 「lazy」、「dog」 子範圍

var allWords = words[..];     
// 「The」、「dog」子範圍
var firstPhrase = words[..4]; 
// 「The」、「fox」子範圍
var lastPhrase = words[6..];  
// 「the」、「lazy」、「dog」子範圍

另外可將範圍聲明為變數:

Range phrase = 1..4;
var text = words[phrase];

十一、 Null 合併賦值

Null 合併賦值運算符:??=

僅當左操作數計算為 null 時,才能使用運算符 ??= 將其右操作數的值分配給左操作數。

List<int> numbers = null;
int? i = null;
numbers ??= new List<int>();
numbers.Add(i ??= 17);
numbers.Add(i ??= 20);
Console.WriteLine(string.Join(" ", numbers));  // output: 17 17
Console.WriteLine(i);  // output: 17

十二、非託管構造類型

在 C# 7.3 及更低版本中,構造類型(包含至少一個類型參數的類型)不能為非託管類型。 從 C# 8.0 開始,如果構造的值類型僅包含非託管類型的欄位,則該類型不受管理。

public struct Coords<T>
{
    public T X;
    public T Y;
}
// Coords<int> 類型為 C# 8.0 及更高版本中的非託管類型
// 與任何非託管類型一樣,可以創建指向此類型的變數的指針,或針對此類型的實例在堆棧上分配記憶體塊
Span<Coords<int>> coordinates = stackalloc[]
{
    new Coords<int> { X = 0, Y = 0 },
    new Coords<int> { X = 0, Y = 3 },
    new Coords<int> { X = 4, Y = 0 }
};

Span 簡介

  在定義中,Span 就是一個簡單的值類型。它真正的價值,在於允許我們與任何類型的連續記憶體一起工作。

  在使用中,Span 確保了記憶體和數據安全,而且幾乎沒有開銷。

  要使用 Span,需要設置開發語言為 C# 7.2 以上,並引用System.Memory到項目。

  Span 使用時,最簡單的,可以把它想像成一個數組,有一個Length屬性和一個允許讀寫的index

// 常用的一些定義、屬性和方法
Span(T[] array);
Span(T[] array, int startIndex);
Span(T[] array, int startIndex, int length);
unsafe Span(void* memory, int length);
int Length { get; }
ref T this[int index] { get; set; }
Span<T> Slice(int start);
Span<T> Slice(int start, int length);
void Clear();
void Fill(T value);
void CopyTo(Span<T> destination);
bool TryCopyTo(Span<T> destination);
// 從 T[] 到 Span 的隱式轉換
char[] array = new char[] { 'i', 'm', 'p', 'l', 'i', 'c', 'i', 't' };
Span<char> fromArray = array;
// 複製記憶體
int Parse(ReadOnlySpan<char> anyMemory);
int Copy<T>(ReadOnlySpan<T> source, Span<T> destination);

  Span 參考: 關於C# Span的一些實踐

十三、嵌套表達式中的 stackalloc

從 C# 8.0 開始,如果 stackalloc 表達式的結果為 System.Span<T> 或 System.ReadOnlySpan<T> 類型,則可以在其他表達式中使用 stackalloc 表達式:

Span<int> numbers = stackalloc[] { 1, 2, 3, 4, 5, 6 };
var ind = numbers.IndexOfAny(stackalloc[] { 2, 4, 6, 8 });
Console.WriteLine(ind);  // output: 1

stackalloc 表達式簡介:

  stackalloc 關鍵字用於不安全的程式碼上下文中,以便在堆棧上分配記憶體塊。

// 關鍵字僅在局部變數的初始值中有效,正確寫法:
int* block = stackalloc int[100];
// 錯誤寫法:
int* block;
block = stackalloc int[100];

  由於涉及指針類型,因此 stackalloc 要求不安全上下文。 

  以下程式碼示例計算並演示 Fibonacci 序列中的前 20 個數字。 每個數字是先前兩個數字的和。 在程式碼中,大小足夠容納 20 個 int 類型元素的記憶體塊是在堆棧上分配的,而不是在堆上分配的。
  該塊的地址存儲在 fib 指針中。 此記憶體不受垃圾回收的制約,因此不必將其釘住(通過使用 fixed)。 記憶體塊的生存期受限於定義它的方法的生存期。 不能在方法返回之前釋放記憶體。

class Test
{
    static unsafe void Main()
    {
        const int arraySize = 20;
        int* fib = stackalloc int[arraySize];
        int* p = fib;
        *p++ = *p++ = 1;// The sequence begins with 1, 1.
        for (int i = 2; i < arraySize; ++i, ++p)
            *p = p[-1] + p[-2];// Sum the previous two numbers.
        for (int i = 0; i < arraySize; ++i)
            Console.WriteLine(fib[i]);
        // Keep the console window open in debug mode.
        System.Console.WriteLine("Press any key to exit.");
        System.Console.ReadKey();
    }
}
/*
Output
1
1
2
3
5
8
13
21
34
55
89
144
233
377
610
987
1597
2584
4181
6765
*/

  不安全程式碼的安全性低於安全替代程式碼。 但是,通過使用 stackalloc 可以自動啟用公共語言運行時 (CLR) 中的緩衝區溢出檢測功能。 如果檢測到緩衝區溢出,進程將儘快終止,以最大限度地減小執行惡意程式碼的機會。

  stackalloc 表達式參考: C#不安全程式碼和stackalloc

十四、內插逐字字元串的增強功能

內插逐字字元串中 $ 和 @ 標記的順序可以任意安排:$@”…” 和 @$”…” 均為有效的內插逐字字元串。

在早期 C# 版本中,$ 標記必須出現在 @ 標記之前。

 

註:暫時整理這些,歡迎指正和補充。