教學課程:使用模式比對組建類型驅動和資料驅動演算法
您可以撰寫功能,使其行為如同您擴充可能在其他庫中的型別。 模式的另一個用途是建立應用程式需要的功能,但該功能不是要擴充之型別的基本功能。
在本教學課程中,您將了解如何:
- 辨識應該使用模式比對的情況。
- 使用模式比對運算式根據類型和屬性值實作行為。
- 結合模式比對與其他技術,建立完整的演算法。
必要條件
- 最新 .NET SDK
- Visual Studio Code 編輯器
- C# 開發套件
安裝指示
在 Windows 上,使用此 WinGet 組態檔 來安裝所有必要條件。 如果您已安裝某些專案,WinGet 將會略過此步驟。
- 下載檔案,然後按兩下以執行它。
- 閱讀許可協議,輸入 y,然後在系統提示接受時選取 [輸入]。
- 如果您在任務欄中收到閃爍的用戶帳戶控制 (UAC) 提示,請允許安裝繼續。
在其他平臺上,您必須個別安裝這些元件。
- 從 .NET SDK 下載頁面下載建議的安裝程式,然後按兩下以執行它。 下載頁面會偵測您的平臺,並建議您平臺的最新安裝程式。
- 從 Visual Studio Code 首頁下載最新的安裝程式,然後按兩下以執行它。 該頁面還會偵測您的平臺,並且應該提供適合您系統的正確連結。
- 按兩下 C# DevKit 擴充功能頁面上的 [安裝] 按鈕。 這樣會開啟 Visual Studio 程式代碼,並詢問您是否要安裝或啟用延伸模組。 選取 [安裝]。
本教學課程假設您已熟悉 C# 和 .NET,包括 Visual Studio 或 .NET CLI。
模式比對的案例
新式開發通常包括整合來自多個來源的資料,並在單一且一致的應用程式內呈現來自該資料的資訊和見解。 您和您的小組不會有代表傳入資料之所有型別的控制權和存取權。
傳統物件導向設計要求您在您的應用程式中建立型別,以代表來自多個資料來源的每個資料類型。 然後,您的應用程式會使用這些新型別、建立繼承階層、建立虛擬方法,以及實作抽象概念。 那些技術可以運作,而且有時候它們是最好的工具。 其他時候,您可以撰寫少一點程式碼。 透過使用將資料與操作該資料之作業分離的技術,您可以撰寫更清楚的程式碼。
在此教學課程中,您會建立並探索在單一情況下,接受來自數個外部來源之資料的應用程式。 您會了解模式比對如何提供有效率的方法,以不屬於原始系統的方式來取用及處理該資料。
請考慮使用通行費和尖峰時段計費來管理交通的主要都會區。 您要撰寫根據車輛類型計算其通行費的應用程式。 之後的改進將整合根據車內乘客數量來計費的機制。 進一步的改進會新增根據時間和星期幾的計費。
從這個簡短描述,您可能已經快速地勾勒出用來建模這個系統的物件階層。 不過,您的資料來自多個來源,如其他車輛註冊管理系統。 這些系統提供建構資料模型的不同類別,而且您沒有任何可用的單一物件模型。 在此教學課程中,您將使用這些簡化的類別,從來自外部系統的這些車輛資料建構模型,如下列程式碼所示:
namespace ConsumerVehicleRegistration
{
public class Car
{
public int Passengers { get; set; }
}
}
namespace CommercialRegistration
{
public class DeliveryTruck
{
public int GrossWeightClass { get; set; }
}
}
namespace LiveryRegistration
{
public class Taxi
{
public int Fares { get; set; }
}
public class Bus
{
public int Capacity { get; set; }
public int Riders { get; set; }
}
}
您可以從 dotnet/samples GitHub 存放庫下載起始程式碼。 您可以看到車輛類別是來自不同的系統,且位於不同的命名空間中。 除了可用的 System.Object
之外,沒有可使用的基底類別。
模式比對設計
本教學課程所使用案例會醒目提示適合以模式比對來解決的問題類型:
- 您要處理的物件不在符合您目標的物件階層中。 您可能會處理屬於不相關系統的類別。
- 您要新增的功能不屬於這些類別的核心抽象概念。 車輛的通行費會因車輛類型而改變,但通行費並不是車輛的核心功能。
當資料的圖形與資料上的作業不是一起描述時,C# 中的模式比對功能可讓它變得更容易使用。
實作基本通行費計算
最基本的通行費計算僅依賴車輛類型:
-
Car
售價2.00美元。 -
Taxi
是 $3.50。 -
Bus
是 $5.00。 -
DeliveryTruck
的價格是 10.00 美元
建立新的 TollCalculator
類別,並在車輛類型上實作模式比對來取得通行費金額。 下列程式碼示範 TollCalculator
的初始實作。
using System;
using CommercialRegistration;
using ConsumerVehicleRegistration;
using LiveryRegistration;
namespace Calculators;
public class TollCalculator
{
public decimal CalculateToll(object vehicle) =>
vehicle switch
{
Car c => 2.00m,
Taxi t => 3.50m,
Bus b => 5.00m,
DeliveryTruck t => 10.00m,
{ } => throw new ArgumentException(message: "Not a known vehicle type", paramName: nameof(vehicle)),
null => throw new ArgumentNullException(nameof(vehicle))
};
}
上述程式碼使用switch
運算式(與switch
陳述式不同)來測試宣告模式。
switch 運算式以變數開始(在上述程式碼中為 vehicle
),接著是 switch
關鍵字。 然後所有的 switch 臂都在大括號內。
switch
運算式會對括住 switch
陳述式的語法進行其他細分。
case
關鍵字已省略,且每個分支的結果都是運算式。 最後兩個臂顯示新的語言功能。
{ }
案例符合任何未符合先前分支的非 null 物件。 此臂會攔截傳遞到此方法的任何不正確型別。
{ }
案例必須遵循每種車輛類型的案例。 順序如已顛倒,則 { }
案例會優先。 最後,null
常數模式會偵測 null
什麼時候傳遞至這個方法。 由於其他模式只匹配正確類型的非 null 物件,因此 null
可以放在最後。
您可以使用 Program.cs
中的下列程式碼來測試此程式碼:
using System;
using CommercialRegistration;
using ConsumerVehicleRegistration;
using LiveryRegistration;
using toll_calculator;
var tollCalc = new TollCalculator();
var car = new Car();
var taxi = new Taxi();
var bus = new Bus();
var truck = new DeliveryTruck();
Console.WriteLine($"The toll for a car is {tollCalc.CalculateToll(car)}");
Console.WriteLine($"The toll for a taxi is {tollCalc.CalculateToll(taxi)}");
Console.WriteLine($"The toll for a bus is {tollCalc.CalculateToll(bus)}");
Console.WriteLine($"The toll for a truck is {tollCalc.CalculateToll(truck)}");
try
{
tollCalc.CalculateToll("this will fail");
}
catch (ArgumentException e)
{
Console.WriteLine("Caught an argument exception when using the wrong type");
}
try
{
tollCalc.CalculateToll(null!);
}
catch (ArgumentNullException e)
{
Console.WriteLine("Caught an argument exception when using null");
}
該程式碼已包含在這個入門專案中,但已經被註解掉。移除註解後,你就可以測試自己撰寫的程式碼。
您已經開始了解模式能如何協助您在程式碼和資料分離的情況下建立演算法。
switch
運算式會測試型別,並根據結果產生不同的值。 這只是個開頭。
新增佔用率計費
通行費主管機關想要鼓勵車輛在行駛時達到最大承載。 他們決定要對乘客較少的車輛收更多費用,並透過提供較低的費用來鼓勵車輛載滿乘客:
- 沒有乘客的汽車和計程車要付額外的 $0.50。
- 有兩名乘客的計程車和汽車可獲得 $0.50 的折扣。
- 有三個或更多乘客的汽車和計程車可折價 $1.00。
- 小於 50% 載滿的巴士要付額外的 $2.00。
- 大於 90% 載滿的巴士可折價 $1.00。
在相同的switch運算式中使用屬性模式可以實作這些規則。 屬性模式會比較屬性值與常數值。 一旦判斷出型別,屬性模式就會檢查物件的屬性。 單一的 Car
案例展開為四個不同案例:
vehicle switch
{
Car {Passengers: 0} => 2.00m + 0.50m,
Car {Passengers: 1} => 2.0m,
Car {Passengers: 2} => 2.0m - 0.50m,
Car => 2.00m - 1.0m,
// ...
};
前三個案例測試型別是否為 Car
,然後檢查 Passengers
屬性的值。 如果兩個都符合,系統就會評估該運算式並傳回。
您也可以用類似的方式來擴展計程車的情境:
vehicle switch
{
// ...
Taxi {Fares: 0} => 3.50m + 1.00m,
Taxi {Fares: 1} => 3.50m,
Taxi {Fares: 2} => 3.50m - 0.50m,
Taxi => 3.50m - 1.00m,
// ...
};
接下來,通過擴展公交車的情境來實施承載率規則,如下列範例所示:
vehicle switch
{
// ...
Bus b when ((double)b.Riders / (double)b.Capacity) < 0.50 => 5.00m + 2.00m,
Bus b when ((double)b.Riders / (double)b.Capacity) > 0.90 => 5.00m - 1.00m,
Bus => 5.00m,
// ...
};
通行費主管機關不在意貨車中的乘客數目。 它們會根據卡車的重量類別調整通行費,如下所示:
- 超過 5000 磅的卡車要付額外的 $5.00。
- 未滿 3000 磅的輕型卡車有美金 $2.00 元折扣。
該規則使用下列程式碼來實作:
vehicle switch
{
// ...
DeliveryTruck t when (t.GrossWeightClass > 5000) => 10.00m + 5.00m,
DeliveryTruck t when (t.GrossWeightClass < 3000) => 10.00m - 2.00m,
DeliveryTruck => 10.00m,
};
前一個程式碼顯示 switch 分支的 when
子句。 您使用 when
子句來測試屬性是否符合條件,除了相等之外。 當您完成後,您將擁有一個看起來像下面程式碼的方法:
vehicle switch
{
Car {Passengers: 0} => 2.00m + 0.50m,
Car {Passengers: 1} => 2.0m,
Car {Passengers: 2} => 2.0m - 0.50m,
Car => 2.00m - 1.0m,
Taxi {Fares: 0} => 3.50m + 1.00m,
Taxi {Fares: 1} => 3.50m,
Taxi {Fares: 2} => 3.50m - 0.50m,
Taxi => 3.50m - 1.00m,
Bus b when ((double)b.Riders / (double)b.Capacity) < 0.50 => 5.00m + 2.00m,
Bus b when ((double)b.Riders / (double)b.Capacity) > 0.90 => 5.00m - 1.00m,
Bus => 5.00m,
DeliveryTruck t when (t.GrossWeightClass > 5000) => 10.00m + 5.00m,
DeliveryTruck t when (t.GrossWeightClass < 3000) => 10.00m - 2.00m,
DeliveryTruck => 10.00m,
{ } => throw new ArgumentException(message: "Not a known vehicle type", paramName: nameof(vehicle)),
null => throw new ArgumentNullException(nameof(vehicle))
};
這些 switch 臂許多都是遞迴模式的範例。 例如,Car { Passengers: 1}
顯示屬性模式內的常數模式。
您可以使用巢狀開關讓這段程式碼減少重複性。 在上述範例中,Car
和 Taxi
都有四個不同的臂。 在這兩種情況下,您可以建立饋入常數模式的宣告模式。 下列程式碼中顯示此技術:
public decimal CalculateToll(object vehicle) =>
vehicle switch
{
Car c => c.Passengers switch
{
0 => 2.00m + 0.5m,
1 => 2.0m,
2 => 2.0m - 0.5m,
_ => 2.00m - 1.0m
},
Taxi t => t.Fares switch
{
0 => 3.50m + 1.00m,
1 => 3.50m,
2 => 3.50m - 0.50m,
_ => 3.50m - 1.00m
},
Bus b when ((double)b.Riders / (double)b.Capacity) < 0.50 => 5.00m + 2.00m,
Bus b when ((double)b.Riders / (double)b.Capacity) > 0.90 => 5.00m - 1.00m,
Bus b => 5.00m,
DeliveryTruck t when (t.GrossWeightClass > 5000) => 10.00m + 5.00m,
DeliveryTruck t when (t.GrossWeightClass < 3000) => 10.00m - 2.00m,
DeliveryTruck t => 10.00m,
{ } => throw new ArgumentException(message: "Not a known vehicle type", paramName: nameof(vehicle)),
null => throw new ArgumentNullException(nameof(vehicle))
};
在上述範例中,使用遞迴運算式意味著您不重複那些包含測試屬性值的子臂的 Car
和 Taxi
臂。 此技術未用於 Bus
和 DeliveryTruck
臂,因為這些臂是測試屬性的範圍 (不是離散值)。
新增尖峰時段計費
針對最後一個功能,通行費主管機關想要新增有時間性的尖峰時段計費。 在早上和晚上尖峰時段,通行費會加倍。 該規則只影響單向的交通:早上尖峰時段進入城市,以及晚上尖峰時段離開城市。 在工作日的其他時間,通行費增加 50%。 在半夜和清晨,通行費減少 25%。 在週末,無論時間皆為一般費率。 您可以使用一系列的 if
和 else
陳述式,透過下列程式碼表示:
public decimal PeakTimePremiumIfElse(DateTime timeOfToll, bool inbound)
{
if ((timeOfToll.DayOfWeek == DayOfWeek.Saturday) ||
(timeOfToll.DayOfWeek == DayOfWeek.Sunday))
{
return 1.0m;
}
else
{
int hour = timeOfToll.Hour;
if (hour < 6)
{
return 0.75m;
}
else if (hour < 10)
{
if (inbound)
{
return 2.0m;
}
else
{
return 1.0m;
}
}
else if (hour < 16)
{
return 1.5m;
}
else if (hour < 20)
{
if (inbound)
{
return 1.0m;
}
else
{
return 2.0m;
}
}
else // Overnight
{
return 0.75m;
}
}
}
上述程式碼可正常執行,但無法讀取。 您必須串聯所有輸入案例和巢狀 if
陳述式以理解程式碼。 相反,您會為此功能使用模式比對,但您會將它與其他技術整合。 您可以建置單一模式比對運算式,納入所有方向、星期幾和時間的組合。 結果會是一個複雜的運算式, 而它會難以閱讀及理解。 這樣會讓確認其正確性變得困難。 相反地,結合那些方法來構建一個包含值的元組,精簡地描述所有那些狀態。 然後使用模式比對來計算通行費的乘數。 元組包含三個獨立的條件:
- 一天可以是工作日或週末。
- 收取通行費時的時段。
- 方向是進入城市或離開城市
下表顯示輸入值和尖峰時段計費乘數的組合:
日 | 時間 | 方向 | 高級 |
---|---|---|---|
平日 | 早上尖峰時段 | 進入 | x 2.00 |
平日 | 晨間高峰期 | 出境 | x 1.00 |
平日 | 日間 | 進入 | x 1.50 |
平日 | 白天 | 出境 | x 1.50 |
平日 | 晚上尖峰時段 | 來向 | x 1.00 |
平日 | 晚間交通高峰 | 出境 | x 2.00 |
平日 | 夜間 | 入境 | x 0.75 |
平日 | 夜間 | 離開 | x 0.75 |
週末 | 早上尖峰時段 | 進入 | x 1.00 |
週末 | 早上尖峰時段 | 出境 | x 1.00 |
週末 | 日間 | 入境 | x 1.00 |
週末 | 日間 | 離開 | x 1.00 |
週末 | 晚上尖峰時段 | 入境 | x 1.00 |
週末 | 晚上高峰時段 | 離開 | x 1.00 |
週末 | 一夜之間 | 入境 | x 1.00 |
週末 | 夜間 | 外向 | x 1.00 |
三個變數的 16 個不同組合。 透過結合一些條件,您將會簡化最終的 switch 運算式。
針對何時收取通行費,收取通行費的系統會使用 DateTime 結構。 建置從上述表格建立變數的成員方法。 下列函式會使用模式比對 switch 運算式,表達 DateTime 代表週末或工作日:
private static bool IsWeekDay(DateTime timeOfToll) =>
timeOfToll.DayOfWeek switch
{
DayOfWeek.Monday => true,
DayOfWeek.Tuesday => true,
DayOfWeek.Wednesday => true,
DayOfWeek.Thursday => true,
DayOfWeek.Friday => true,
DayOfWeek.Saturday => false,
DayOfWeek.Sunday => false
};
該方法是正確的,但具重複性。 您可以簡化它,如下列程式碼所示:
private static bool IsWeekDay(DateTime timeOfToll) =>
timeOfToll.DayOfWeek switch
{
DayOfWeek.Saturday => false,
DayOfWeek.Sunday => false,
_ => true
};
接下來,新增類似的函式來將時間分類為區塊:
private enum TimeBand
{
MorningRush,
Daytime,
EveningRush,
Overnight
}
private static TimeBand GetTimeBand(DateTime timeOfToll) =>
timeOfToll.Hour switch
{
< 6 or > 19 => TimeBand.Overnight,
< 10 => TimeBand.MorningRush,
< 16 => TimeBand.Daytime,
_ => TimeBand.EveningRush,
};
您會新增自訂的 enum
,將每個時間範圍轉換成離散值。 然後,GetTimeBand
方法會使用關聯式模式和連結 or
模式。 關聯式模式可讓您使用 <
、>
、<=
,或 >=
測試數值。
or
模式會測試運算式是否符合一或多個模式。 您也可以使用 and
模式確保運算式符合兩個不同的模式,並使用 not
模式測試運算式是否不符合模式。
建立這些方法之後,您可以使用另一個 switch
表達式,結合元組模式來估算價格溢價。 您可以建立具有 16 個分支的 switch
運算式:
public decimal PeakTimePremiumFull(DateTime timeOfToll, bool inbound) =>
(IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
{
(true, TimeBand.MorningRush, true) => 2.00m,
(true, TimeBand.MorningRush, false) => 1.00m,
(true, TimeBand.Daytime, true) => 1.50m,
(true, TimeBand.Daytime, false) => 1.50m,
(true, TimeBand.EveningRush, true) => 1.00m,
(true, TimeBand.EveningRush, false) => 2.00m,
(true, TimeBand.Overnight, true) => 0.75m,
(true, TimeBand.Overnight, false) => 0.75m,
(false, TimeBand.MorningRush, true) => 1.00m,
(false, TimeBand.MorningRush, false) => 1.00m,
(false, TimeBand.Daytime, true) => 1.00m,
(false, TimeBand.Daytime, false) => 1.00m,
(false, TimeBand.EveningRush, true) => 1.00m,
(false, TimeBand.EveningRush, false) => 1.00m,
(false, TimeBand.Overnight, true) => 1.00m,
(false, TimeBand.Overnight, false) => 1.00m,
};
上面的程式碼可以運作,但它可以簡化。 週末的八種組合的通行費率都相同。 您可以用下列一行取代所有八個:
(false, _, _) => 1.0m,
平日白天和夜間,進出流量擁有相同的倍數係數。 這四個交換臂可以替換成以下兩行:
(true, TimeBand.Overnight, _) => 0.75m,
(true, TimeBand.Daytime, _) => 1.5m,
經過這兩個變更之後,程式碼應該看起來像下列程式碼:
public decimal PeakTimePremium(DateTime timeOfToll, bool inbound) =>
(IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
{
(true, TimeBand.MorningRush, true) => 2.00m,
(true, TimeBand.MorningRush, false) => 1.00m,
(true, TimeBand.Daytime, _) => 1.50m,
(true, TimeBand.EveningRush, true) => 1.00m,
(true, TimeBand.EveningRush, false) => 2.00m,
(true, TimeBand.Overnight, _) => 0.75m,
(false, _, _) => 1.00m,
};
最後,您可以移除收取一般價格的兩個尖峰時段。 移除那些分支之後,您可以在最後一個 switch 分支中用捨棄 (_
) 取代 false
。 您會有下列已完成的方法:
public decimal PeakTimePremium(DateTime timeOfToll, bool inbound) =>
(IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
{
(true, TimeBand.Overnight, _) => 0.75m,
(true, TimeBand.Daytime, _) => 1.5m,
(true, TimeBand.MorningRush, true) => 2.0m,
(true, TimeBand.EveningRush, false) => 2.0m,
_ => 1.0m,
};
此範例會醒目提示模式比對的優點之一:依序評估模式分支。 如果您重新排列它們,讓較早的分支處理其中一個較晚的情況,編譯器會提出有關無法到達的程式碼的警告。 那些語言規則讓您可以放心地進行上述簡化,而不需擔心程式碼會變更。
模式比對讓某些類型的程式碼更容易讀取,並在您無法將程式碼新增至類別時,提供物件導向技術的替代方式。 雲端導致資料和功能彼此分離。 資料的「形狀」與其上的「操作」不一定要一起描述。 在此教學課程中,您以完全不同於現有資料原始功能的方式使用它們。 模式比對讓您能夠撰寫功能來覆蓋這些類型,即使您無法擴充它們。
下一步
您可以從 dotnet/samples GitHub 存放庫下載已完成的程式碼。 自行探索模式,並將此技術新增到您平常撰寫程式碼的活動中。 學習這些技巧提供您處理問題並建立新功能的另一種方式。