教程:使用模式匹配构建算法

模式匹配使你可以为在其他库中定义的类型添加功能,而无需修改这些类型。 模式的另一个用途是,创建应用程序需要的功能,但此功能不是要扩展的类型的基本功能。

本教程中,您将学习如何:

  • 识别应使用模式匹配的情况。
  • 使用模式匹配表达式来实现基于类型和属性值的行为。
  • 结合使用模式匹配和其他方法来创建完整算法。

Prerequisites

安装说明

在 Windows 上,可以使用这个 WinGet 配置文件来安装所有必备组件。 如果已安装某些内容,WinGet 将跳过此步骤。

  1. 下载该文件,然后双击以运行它。
  2. 阅读许可协议,键入 y,并在系统提示接受时按下 Enter。
  3. 如果在任务栏中收到闪烁的用户帐户控制(UAC)提示,请允许安装继续。

在其他平台上,需要单独安装其中每个组件。

  1. 从 .NET SDK 下载页下载建议的安装程序然后双击以运行它。 下载页可检测平台,并推荐平台的最新安装程序。
  2. 从 Visual Studio Code 主页下载最新的安装程序,然后双击以运行它。 该页还会检测平台,并且链接应该适合你的系统。
  3. 单击 C# DevKit 扩展页上的“安装”按钮。 这将打开 Visual Studio 代码,并询问是否要安装或启用扩展。 选择“安装”。

模式匹配场景

新式开发通常包括从多个源集成数据,并在一个整体应用程序中呈现以相应数据为依据的信息和见解。 你和你的团队可能无法控制表示传入数据的类型。

经典面向对象的设计方法要求在应用程序中创建表示来自这些多个数据源的每个数据类型的类型。 然后,应用程序使用这些新类型、生成继承层次结构、创建虚拟方法并实现抽象。 这些技术起作用,而且有时候是最佳工具。 不过,在其他时候,你可以编写更少的代码。 在某些情况下,将数据与使用它的操作区分开,可以使代码更易于读取。

在本教程中,你将创建和探索一个应用程序,该应用程序在单个场景中接收来自多个外部源的传入数据。 你将看到模式匹配如何提供一种有效的方式来使用和处理这些数据,而这些方式不是原始系统的一部分。

设想一个利用通行费和高峰时段定价来管理交通的主要大都市区。 你编写的应用程序根据车辆类型来计算车辆通行费。 后续增强功能包括,定价因车内乘客数而异。 进一步增强功能包括,定价因时间和周几而异。

从该简短说明中,你可以快速绘制对象层次结构来为此系统建模。 但是,数据来自多个来源,如其他车辆注册管理系统。 这些系统提供不同的类来为该数据建模,并且这些系统不共享单个对象模型。 在本教程中,你将使用这些简化的类来为来自这些外部系统的车辆数据建模,如以下代码所示:

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 美元折扣。

可以在同一开关表达式中使用 属性模式 来实现这些规则。 属性模式将属性值与常数值进行比较。 确定类型后,属性模式将检查对象的属性。 Car {Passengers: 0} 本身是 递归模式:在 Car 上的外部属性模式包含测试 Passengers 值的内部常量模式。 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 臂是递归模式,在属性模式内嵌套常量模式。

可使用嵌套的 switch 来减少代码的重复性。 在上面的示例中,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 语句,才能对代码进行推理。 相反,您将对该功能使用模式匹配,但会将其与其他技术集成。 可以生成一个模式匹配表达式,用于考虑方向、星期几和时间的所有组合。 生成的结果是一个复杂的表达式。 它既难读取,也难理解。 这就加大了确保正确性的难度。 请改为将这些方法合并为生成值元组,用于简要描述所有这些状态。 然后,使用模式匹配来计算通行费乘数。 元组包含以下三个离散条件:

  • 这一天是工作日,还是周末。
  • 收取通行费时所处的时间带区。
  • 方向是进入城市或出城。

下表展示了输入值和高峰时段定价乘数的组合:

日 时间 Direction 高级
工作日 早高峰 入站 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 种不同的组合。 通过组合某些条件,可以简化最终开关表达式。

通行费收取系统在收取通行费时对时间使用 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 模式测试表达式是否匹配一个或多个模式,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,
    };

最后,删除按常规价格收费的两个高峰时段。 移除这些分支后,在最后一个开关分支中用丢弃(_)替换 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 存储库。 请自行探索模式,并将此技术纳入你的常规编码活动。 学些这些技术,你可以通过其他方式来处理问题和新建功能。

另请参阅