自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
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表達式中的使用情況,最后介紹了為什么使用模式匹配的原因。
如對您有價值,請推薦,您的鼓勵是我繼續的動力,在此萬分感謝。關注本人公眾號“碼客風雲”,享第一時間閱讀最新文章。
