C#9.0新特性詳解系列之六:增強的模式匹配

自C#7.0以來,模式匹配就作為C#的一項重要的新特性在不斷地演化,這個借鑒於其小弟F#的函數式編程的概念,使得C#的本領越來越多,C#9.0就對模式匹配這一功能做了進一步的增強。

為了更為深入和全面的了解模式匹配,在介紹C#9.0對模式匹配增強部分之前,我對模式匹配整體做一個回顧。

1 模式匹配介紹

1.1 什麼是模式匹配?

在特定的上下文中,模式匹配是用於檢查所給對象及屬性是否滿足所需模式(即是否符合一定標準)並從輸入中提取資訊的行為。它是一種新的程式碼流程式控制方式,它能使程式碼流可讀性更強。這裡說到的標準有「是不是指定類型的實例」、「是不是為空」、「是否與給定值相等」、「實例的屬性的值是否在指定範圍內」等。

模式匹配常結合is表達式用在if語句中,也可用在switch語句在switch表達式中,並且可以用when語句來給模式指定附加的過濾條件。它非常善於用來探測複雜對象,例如:外部Api返回的對象在不同情況下返回的類型不一致,如何確定對象類型?

1.2 模式匹配種類

從C#的7.0版本到現在9.0版本,總共有如下十三種模式:

  • 常量模式(C#7.0)
  • Null模式(C#7.0)
  • 類型模式(C#7.0)
  • 屬性模式(C#8.0)
  • var模式(C#8.0)
  • 棄元模式 (C#8.0)
  • 元組模式(C#8.0)
  • 位置模式(C#8.0)
  • 關係模式(C#9.0)
  • 邏輯模式(C#9.0)
    • 否定模式(C#9.0)
    • 合取模式(C#9.0)
    • 析取模式(C#9.0)
  • 括弧模式(C#9.0)

後面內容,我們就以上這些模式以下面幾個類型為基礎進行寫示例進行說明。

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

public abstract record Shape():IName
{
    public string Name =>this.GetType().Name;
}

public record Circle(int Radius) : Shape,ICenter
{
    public Point Center { get; init; }
}

public record Square(int Side) : Shape;

public record Rectangle(int Length, int Height) : Shape;

public record Triangle(int Base, int Height) : Shape
{
    public void Deconstruct(out int @base, out int height) => (@base, height) = (Base, Height);
}

interface IName
{
    string Name { get; }
}

interface ICenter
{
    Point Center { get; init; }
}

2 各模式介紹與示例

2.1 常量模式

常量模式是用來檢查輸入表達式的結果是否與指定的常量相等,這就像C#6.0之前switch語句支援的常量模式一樣,自C#7.0開始,也支援is語句。

expr is constant

這裡expr是輸入表達式,constant是字面常量、枚舉常量或者const定義常量變數這三者之一。如果expr和constant都是整型類型,那麼實質上是用expr == constant來決定兩者是否相等;否則,表達式的值通過靜態函數Object.Equals(expr, constant)來決定。

var circle = new Circle(4);

if (circle.Radius is 0)
{
    Console.WriteLine("This is a dot not a circle.");
}
else
{
    Console.WriteLine($"This is a circle which radius is {circle.Radius}.");
}

2.2 null模式

null模式是個特殊的常量模式,它用於檢查一個對象是否為空。

expr is null

這裡,如果輸入表達式expr是引用類型時,expr is null表達式使用(object)expr == null來決定其結果;如果是可空值類型時,使用Nullable.HasValue來決定其結果.

Shape shape = null;

if (shape is null)
{
    Console.WriteLine("shape does not have a value");
}
else
{
    Console.WriteLine($"shape is {shape}");
}

2.3 類型模式

類型模式用於檢測一個輸入表達式能否轉換成指定的類型,如果能,把轉換好的值存放在指定類型定義的變數里。 在is表達式中形式如下:

expr is type variable

其中expr表示輸入表達式,type是類型或類型參數名字,variable是類型type定義的新本地變數。如果expr不為空,通過引用、裝箱或者拆箱能轉化為type或者滿足下面任何一個條件,則整個表達式返回值為true,並且expr的轉換結果被賦給變數variable。

  • expr是和type一樣類型的實例
  • expr是從type派生的類型的實例
  • expr的編譯時類型是type的基類,並且expr有一個運行時類型,這個運行時類型是type或者type的派生類。編譯時類型是指聲明變數是使用的類型,也叫靜態類型;運行時類型是定義的變數中具體實例的類型。
  • expr是實現了type介面的類型的實例

如果expr是true並且is表達式被用在if語句中,那麼variable本地變數僅在if語句內被分配空間進行賦值,本地變數的作用域是從is表達式到封閉包含if語句的塊的結束位置。

需要注意的是:聲明本地變數的時候,type不能是可空值類型。

Shape shape = new Square(5);
if (shape is Circle circle)
{
    Console.WriteLine($"This shape is a {circle.Name} with radius equal to {circle.Radius}");
}
else
{
    Console.WriteLine(circle.Radius);//錯誤,使用了未賦值的本地變數
    circle = new Circle(6);
    Console.WriteLine($"A new {circle.Name} with radius equal to {circle.Radius} is created now.");
}

//circle變數還處於其作用域內,除非到了封閉if語句的程式碼塊結束的位置。
if (circle is not null && circle.Radius is 0)
{
    Console.WriteLine("This is a dot not a circle.");
}
else
{
    Console.WriteLine($"This is a circle which radius is {circle.Radius}.");
}

上面的包含類型模式的if語句塊部分:

if (shape is Circle circle)
{
    Console.WriteLine($"This shape is a {circle.Name} with radius equal to {circle.Radius}");
}

與下面程式碼是等效的。

var circle = shape as Circle;

if (circle != null)
{
    Console.WriteLine($"This shape is a {circle.Name} with radius equal to {circle.Radius}");
}

從上面可以看出,應用類型模式匹配,使得程式程式碼更為緊湊簡潔。

2.4 屬性模式

屬性模式使你能訪問對象實例的屬性或者欄位來檢查輸入表達式是否滿足指定標準。與is表達式結合使用的基本形式如下:

expr is type {prop1:value1,prop2:value2,...} variable

該模式先檢查expr的運行時類型是否能轉化成類型type,如果不能,這個模式表達式返回false;如果能,則開始檢查其中屬性或欄位的值匹配,如果有一個不相符,整個匹配結果就為false;如果都匹配,則將expr的對象實例賦給定義的類型為type的本地變數variable。
其中,

  • type可以省略,如果省略,則type使用expr的靜態類型;
  • 屬性中的value可以為常量、var模式、關係模式或者組合模式。

下面例子用於檢查shape是否是為高和寬相等的長方形,如果是,將其值賦給用Rectangle定義的本地變數rect中:

if (shape is Rectangle { Length: var l,Height:var w } rect && l == w)
{
    Console.WriteLine($"This is a square");
}

屬性模式是可以嵌套的,如下檢查圓心坐標是否在原點位置,並且半徑為100:

if (shape is Circle {Radius:100, Center: {X:0,Y:0} c })
{
    Console.WriteLine("This is a circle which center is at (0,0)");
}

上面示例與下面程式碼是等效的,但是採用模式匹配方式寫的條件程式碼量更少,特別是有更多屬性需要進行條件檢查時,程式碼量節省更明顯;而且上面程式碼還是原子操作,不像下面程式碼要對條件進行4次檢查:

if (shape is Circle circle &&
    circle.Radius == 100
    && circle.Center.X == 0
    && circle.Center.Y == 0)
{
    Console.WriteLine("This is a circle which center is at (0,0)");
}

2.5 var模式

將類型模式表達形式的type改為var關鍵字,就成了var模式的表達形式。var模式不管什麼情況下,甚至是expr電腦結果為null,它都是返回true。其最大的作用就是捕獲expr表達式的值,就是expr表達式的值會被賦給var後的局部變數名。局部變數的類型就是表達式的靜態類型,這個變數可以在匹配的模式外部被訪問使用。var模式沒有null檢查,因此在你使用局部變數之前必須手工對其進行null檢查。

if (shape is var sh && sh is not null)
{
    Console.WriteLine($"This shape's name is {sh.Name}.");
}

將var模式和屬性模式相結合,捕獲屬性的值。示例如下所示。

if (shape is Square { Side: var side } && side > 0 && side < 100)
{
    Console.WriteLine($"This is a square which side is {side} and between 0 and 100.");
}

2.6 棄元模式

棄元模式是任何表達式都可以匹配的模式。棄元不能當作常量或者類型直接用於is表達式,它一般用於元組、switch語句或表達式。例子參見2.7和4.3相關的例子。

var isShape = shape is _; //錯誤

2.7 元組模式

元組模式將多個值表示為一個元組,用來解決一些演算法有多個輸入組合這種情況。如下面的例子結合switch表達式,根據命令和參數值來創建指定圖形:

Shape Create(int cmd, int value1, int value2) => (cmd,value1,value2) switch {
    (0,var v,_)=>new Circle(v),
    (1,var v,_)=>new Square(v),
    (2,var l,var h)=>new Rectangle(l,h),
    (3,var b,var h)=>new Triangle(b,h),
    (_,_,_)=>throw new NotSupportedException()
};

下面是將元組模式用於is表達式的例子。

(Shape shape1, Shape shape2) shapeTuple = (new Circle(100),new Square(50));
if (shapeTuple is (Circle circle, _))
{
    Console.WriteLine($"This shape is a {circle.Name} with radius equal to {circle.Radius}");
}

2.8 位置模式

位置模式是指通過添加解構函數將類型對象的屬性解構成以元組方式組織的離散型變數,以便你可以使用這些屬性作為一個模式進行檢查。

例如我們給Point結構中添加解構函數Deconstruct,程式碼如下:

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

這樣,我就可以將Point結構成不同的變數。

var point = new Point(10,20);
var (x, y) = point;
Console.WriteLine($"x = {x}, y = {y}");

解構函數使對象具有了位置模式的功能,使用的時候,看起來像元組模式。例如我用在is語句中例子如下:

if (point is (10,_))
{
    Console.WriteLine($"This point is (10,{point.Y})");
}

由於位置型record類型,默認已經帶有解構函數Deconstruct,因此可以直接使用位置模式。如果是class和struct類型,則需要自己添加解構函數Deconstruct。我們也可以用擴展方法給一些類型添加解構函數Deconstruct。

2.9 關係模式

關係模式用於檢查輸入是否滿足與常量進行比較的關係約束。形式如: op constant
其中

  • op表示操作符,關係模式支援二元操作符:<,<=,>,>=
  • constant是常量,其類型只要是能支援上述二元關係操作符的內置類型都可以,包括sbyte, byte, short, ushort, int, uint, long, ulong, char, float, double, decimal, nint和 nuint。
  • op的左操作數將做為輸入,其類型與常量類型相同,或者能夠通過拆箱或者顯式可空類型轉換為常量類型。如果不存在轉換,則編譯時會報錯;如果存在轉換,但是轉換失敗,則模式不匹配;如果相同或者能轉換成功,則其值或轉換的值與常量開始進行關係操作運算,該運算結果就是關係模式匹配的結果。由此可見,左操作數可以為dynamic,object,可空值類型,var類型及和constant相同的基本類型等。
  • 常量不能是null;
  • double.NaN或float.NaN雖是常量,但不是數字,是不受支援的。
  • 該模式可用在is,which語句和which表達式中。
int? num1 = null;
const int low = 0;
if (num1 is >low)
{
}

關係模式與邏輯模式進行結合,功能就會更加強大,幫助我們處理更多的問題。

int? num1 = null;
const int low = 0;
double num2 = double.PositiveInfinity;
if (num1 is >low and <int.MaxValue && num2 is <double.PositiveInfinity)
{
}

2.10 邏輯模式

邏輯模式用於處理多個模式間邏輯關係,就像邏輯運算符!、&&和||一樣,優先順序順序也是相似的。為了避免與表達式邏輯操作符引起混淆,模式操作符採用單詞來表示。他們分別為not、and和or。邏輯模式為多個基本模式進行組合提供了更多可能。

2.10.1 否定模式

否定模式類似於!操作符,用來檢查與指定的模式不匹配的情況。它的關鍵字是not。例如null模式的否定模式就是檢查輸入表達式不為null.

if (shape is not null)
{
    // 當shape不為null時的程式碼邏輯
    Console.WriteLine($"shape is {shape}.");
}

上面這段程式碼我們將否定模式與null模式組合了起來,實現了與下面程式碼等效的功能,但是易讀性更好。

if (!(shape is null))
{
    // 當shape不為null時的程式碼邏輯
    Console.WriteLine($"shape is {shape}.");
}

我們可以將否定模式與類型模式、屬性模式、常量模式等結合使用,用於更多的場景。例如下面例子就將類型模式、屬性模式、否定模式和常量模式四種組合起來檢查一個圖形是否是一個半徑不為零的圓。

if (shape is Circle { Radius: not 0 })
{
    Console.WriteLine("shape is not a dot but a Circle");
}

下面示例判斷一個shape如果不是Circle時執行一段邏輯。

if (shape is not Circle circle)
{
    Console.WriteLine("shape is not a Circle");
}

注意:上面這段程式碼,如果if判斷條件為true的話,那麼circle的值為null,不能在if語句塊中使用,但為false時,circle不為null,即使在if語句塊中得到了使用,但也得不到執行,只能在if語句後面使用。

2.10.2 合取模式

類似於邏輯操作符&&,合取模式就是用and關鍵詞連接兩個模式,要求他們都同時匹配。
以前,我們檢查一個對象是否是邊長位於(0,100)之間的正方形時,會有如下程式碼:

if (shape is Square)
{
    var square = shape as Square;

    if (square.Side > 0 && square.Side < 100)
    {
        Console.WriteLine($"This shape is a square with a side {square.Side}");
    }
}

現在,我們可以用模式匹配將上述邏輯描述為:

if (shape is Square { Side: > 0 and < 100 } square)
{
    Console.WriteLine($"This shape is a square with a side {square.Side}");
}

這裡,我們將一個類型模式、一個屬性模式、一個合取模式、兩個關係模式和兩個常量模式進行組合。兩段同樣效果的程式碼,明顯模式匹配程式碼量更少,沒了square.Side的重複出現,更為簡潔易懂。

注意事項:

  • and要用於兩個類型模式之間,則兩個類型必須有一個是介面,或者都是介面
shape is Square and Circle // 編譯錯誤
shape is Square and IName // Ok
shape is IName and ICenter // OK
  • and不能用在一個沒有關係模式的屬性模式中,
shape is Circle { Radius: 0 and 10 } // 編譯錯誤
  • and不能用在兩個屬性模式之間,因為這已經隱式實現了
shape is Triangle { Base: 10 and Height: 20 } // 編譯錯誤
shape is Triangle { Base: 10 , Height: 20} // OK,是上一句要實現的效果

2.10.3 析取模式

類似於邏輯操作符||,析取模式就是用or關鍵詞連接兩個模式,要求兩個模式中有一個能匹配就算匹配成功。

例如下面程式碼用來檢查一個圖形是否是邊長小於20或者大於60的有效的正方形:

if (shape is Square { Side: >0 and < 20 or > 60 } square)
{
    Console.WriteLine($"This shape is a square with a side {square.Side}");
}

這裡,我們組合運用了類型模式、屬性模式、合取模式、析取模式、關係模式和常量模式這六個模式來完成條件判斷。看起來很簡潔,這個如果用C#9.0之前的程式碼實現如下,繁瑣很多,並且square.Side有重複出現:

if (shape is Square)
{
    var square = shape as Square;

    if (square.Side > 0 && square.Side < 20 || square.Side>60)
    {
        Console.WriteLine($"This shape is a square with a side {square.Side}");
    }
}

注意事項:

  • or 可以放在兩個類型之間,但是不支援捕捉輸入表達式的值存到定義的局部變數里;
shape is Square or Circle // OK
shape is Square or Circle smt // 編譯錯誤,不支援捕捉
  • or 可以放在一個沒有關係模式的屬性模式中,同時支援捕捉輸入表達式的值存到定義的局部變數里
shape is Square { Side: 0 or 1 } sq // OK
  • or 不能用於同一對象的兩個屬性之間
shape is Rectangle { Height: 0 or Length: 0 } // 編譯錯誤
shape is Rectangle { Height: 0 } or Rectangle { Length: 0 } // OK,實現了上一句想實現的目標

2.11 括弧模式

有了以上各種模式及其組合後,就牽扯到一個模式執行優先順序順序的問題,括弧模式就是用來改變模式優先順序順序的,這與我們表達式中括弧的使用是一樣的。

if (shape is Square { Side: >0 and (< 20 or > 60) } square)
{
    Console.WriteLine($"This shape is a square with a side {square.Side}");
}

3 其他

有了模式匹配,對於是否為null的判斷檢查,就顯得豐富多了。下面這些都可以用於判斷不為null的程式碼:

if (shape != null)...
if (!(shape is null))...
if (shape is not null)...
if (shape is {})...
if (shape is {} s)...
if (shape is object)...
if (shape is object s)...
if (shape is Shape s)...

4 switch語句與表達式中的模式匹配

說到模式匹配,就不得不提與其緊密關聯的switch語句、switch表達式和when關鍵字。

4.1 when關鍵字

when關鍵字是在上下文中用來進一步指定過濾條件。只有當過濾條件為真時,後面語句才得以執行。

被用到的上下文環境有:

  • 常用在try-catch或者try-catch-finally語句塊的catch語句中
  • 用在switch語句的case標籤中
  • 用在switch表達式中

這裡,我們重點介紹後面兩者情況,有關在catch中的應用,如有不清楚的可以查閱相關資料。

在switch語句的when的使用語法如下:

case (expr) when (condition):

這裡,expr是常量或者類型模式,condition是when的過濾條件,可以是任何的布爾表達式。具體示例見後面switch語句中的例子。

在switch表達式中when的應用與switch類似,只不過case和冒號被用=>替代而已。具體示例見switch語句表達式。

4.2 switch語句

自C#7.0之後,switch語句被改造且功能更為強大。變化有:

  • 支援任何類型
  • case可以用表達式,不再局限於常量
  • 支援匹配模式
  • 支援when關鍵字進一步限定case標籤中的表達式
  • case之間不再相互排斥,因而case的順序很重要,執行匹配了第一個分支,後面分支都會被跳過。

下面方法用於計算指定圖形的面積。

static int ComputeArea(Shape shape)
{
    switch (shape)
    {
        case null:
            throw new ArgumentNullException(nameof(shape));

        case Square { Side: 0 }:
        case Circle { Radius: 0 }:
        case Rectangle rect when rect is { Length: 0 } or { Height: 0 }:
        case Triangle { Base: 0 } or Triangle { Height: 0 }:
            return 0;

        case Square { Side:var side}:
            return side * side;
        case Circle c:
            return (int)(c.Radius * c.Radius * Math.PI);
        case Rectangle { Length:var l,Height:var h}:
            return l * h;
        case Triangle (var b,var h):
            return b * h / 2;

        default:
            throw new ArgumentException("shape is not a recognized shape",nameof(shape));
    }
}

上面該方法僅用於展示模式匹配多種不同可能的用法,其中計算面積為0的那一部分其實是沒有必要的。

4.3 switch表達式

switch表達式是為在一個表達式的上下文中可以支援像switch語句那樣的功能而添加的表達式。

我們將4.1中的switch語句改為表達式,如下所示:

static int ComputeArea(Shape shape) => shape switch 
{
    null=> throw new ArgumentNullException(nameof(shape)),
    Square { Side: 0 } => 0,
    Rectangle rect when rect is { Length: 0 } or { Height: 0 } => 0,
    Triangle { Base: 0 } or Triangle { Height: 0 } => 0,
    Square { Side: var side } => side*side,
    Circle c => (int)(c.Radius * c.Radius * Math.PI),
    Rectangle { Length: var l, Height: var h } => l * h,
    Triangle (var b, var h) => b * h / 2,
    _=> throw new ArgumentException("shape is not a recognized shape",nameof(shape))
};

由上例子可以看出,switch表達式與switch語句有以下不同:

  • 輸入參數位於switch關鍵字前面
  • case和:被用=>替換,顯得更加簡練和直觀
  • default被棄元符號_替代
  • 語句體是表達式不是語句

switch表達式的每個分支=>標記後面的表達式們的最佳公共類型如果存在,並且每個分支的表達式都可以隱式轉換為這個類型,那麼這個類型就是switch表達式的類型。

在運行情況下,switch表達式的結果是輸入參數第一個匹配到的模式的分支中表達式的值。如果沒有匹配到的情況,就會拋出SwitchExpressionException異常。

switch表達式的各個分支情況要全面覆蓋輸入參數的各種值的情況,否則會報錯。這也是棄元在switch表達式中用於代表不可知情況的原因。

如果switch表達式中一些前面分支總是得到匹配,不能到達後面的分支話,就會出錯。這就是棄元模式要放在最後分支的原因。

5 為什麼用模式匹配?

從前面很多例子可以看出,模式匹配的很多功能都可以用傳統方法實現,那麼為什麼還要用模式匹配呢?

首先,就是我們前面提到的模式匹配程式碼量少,簡潔易懂,減少程式碼重複。

再者,就是模式常量表達式在運算時是原子的,只有匹配或者不匹配兩種相斥的情況。而多個連接起來的條件比較運算,要多次進行不同的比較檢查。這樣,模式匹配就避免了在多執行緒場景中的一些問題。

總的來說,如果可能的話,請使用模式匹配,這才是最佳實踐。

6 總結

這裡我們回顧了所有的模式匹配,也介紹了模式匹配在switch語句和switch表達式中的使用情況,最後介紹了為什麼使用模式匹配的原因。

如對您有價值,請推薦,您的鼓勵是我繼續的動力,在此萬分感謝。關注本人公眾號「碼客風雲」,享第一時間閱讀最新文章。

碼客風雲