简化 模式匹配

模式匹配(Pattern Matching)是在 C# 7.0 引入的,是对 switch 语句的增强,可以支持实现复杂的条件匹配。下面我先用一个示例来展示一下模式匹配的一般的用法。

假如现在我们要计算各种车辆在某高速的通行费,比如有下面四种车辆,分别定义为以下四个类,各个类中定义了和通行费计算相关的属性:

public class Car
{
    public int Passengers { get; set; }
}

public class DeliveryTruck
{
    public int GrossWeightClass { get; set; }
}

public class Taxi
{
    public int Fares { get; set; }
}

public class Bus
{
    public int Capacity { get; set; }
    public int Riders { get; set; }
}

下面用用模式匹配的方式来实现一个计算通行费的方法:

public decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car { Passengers: 0}        => 2.00m + 0.50m,
    Car { Passengers: 1}        => 2.0m,
    Car { Passengers: 2}        => 2.0m - 0.50m,
    Car c                       => 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 _ => 10.00m,

    { } => throw new ArgumentException(message: "Not a known vehicle type", paramName: nameof(vehicle)),
    null => throw new ArgumentNullException(nameof(vehicle))
};

实现这个业务逻辑,若在 C# 7.0 之前,需要用一堆的 if/else 来实现。有了模式匹配后,变得方便了很多,而且使用上很灵活,代码结构也更优美。

对我来说,模式匹配是个极好的特性!但这还不够,C# 9.0 对模式匹配的写法做了进一步的简化!

以上面代码为例,模式匹配可以分为三种:简单模式、关系模式和逻辑模式。下面分别说说 C# 9.0 对三种模式的简化。

简单模式

以上面 CalculateToll 方法示例代码为例,简单模式对应的代码片段是这样的:

vehicle switch
{
    ...
    Car c => 2.00m - 1.0m
}

我们其实可以发现,上面的变量 c 声明了却没用被使用,现在 C# 9.0 中可以把它省略了:

vehicle switch
{
    ...
    Car => 2.00m - 1.0m
}

关系模式

以上面 CalculateToll 方法示例代码为例,关系模式是通过比较(大小)关系来匹配的,对应的代码片段如下:

DeliveryTruck t when (t.GrossWeightClass > 5000) => 10.00m + 5.00m,
DeliveryTruck t when (t.GrossWeightClass < 3000) => 10.00m - 2.00m,

现在 C# 9.0 可以简写成:

DeliveryTruck t when t.GrossWeightClass switch
{
    > 5000 => 10.00m + 5.00m,
    < 3000 => 10.00m - 2.00m,
}

逻辑模式

在 C# 9.0 中,你可以通过逻辑操作符 and、or 和 not 对模式进行组合,下面是一些示例:

DeliveryTruck t when t.GrossWeightClass switch
{
    < 3000 => 10.00m - 2.00m,
    >= 3000 and <= 5000 => 10.00m,
    > 5000 => 10.00m + 5.00m,
}

not null => throw new ArgumentException($"Not a known vehicle type: {vehicle}", nameof(vehicle)),
null => throw new ArgumentNullException(nameof(vehicle))

另外,not 关键字还可以用来替代 if 条件判断中的逻辑非(!),比如:

// 原来的写法
if (!(e is Customer)) { ... }

// 新的写法(易读性更好)
if (e is not Customer) { ... }

简化 参数非空检查

参数非空检查是编写类库很常见的操作,在一个方法中要求参数不能为空,否则抛出相应的异常。比如:

public static string HashPassword(string password)
{
    if(password is null)
    {
        throw new ArgumentNullException(nameof(password));
    }
    ...
}

当异常发生时,调用者很容易知道是什么问题。如果不加这个检查,可能就会由系统抛出未将对象引用为实例之类的错误,这不利于调用者诊断错误。

由于这个场景太常见了,于是我经常在我的项目中通过一个辅助类来做此类检查。这个类用来检查方法参数,所以命名为 Guard,主要代码如下:

public static class Guard  
{  
    public static void NotNull(object param, string paramName)  
    {  
        if (param is null)  
        {  
            throw new ArgumentNullException(paramName);  
        }  
    }  
  
    public static void NotNullOrEmpty(string param, string paramName)  
    {  
        NotNull(param, paramName);  
        if (param == string.Empty)  
        {  
            throw new ArgumentException($"The string can not be empty.", paramName);  
        }  
    }  
  
    public static void NotNullOrEmpty&lt;T&gt;(IEnumerable&lt;T&gt; param, string paramName)  
    {  
        NotNull(param, paramName);  
        if (param.Count() == 0)  
        {  
            throw new ArgumentException("The collection can not be empty.", paramName);  
        }  
    }  
    ...  
}

这个类包含了三个常见的非空检查,包括 null、空字符串、空集合的检查。使用示例:

public static string HashPassword(string password)  
{  
    Guard.NotNull(password, nameof(password));  
    ...  
}  
  
public static IEnumerable&lt;TSource&gt; DistinctBy&lt;TSource, TKey&gt;(  
    this IEnumerable&lt;TSource&gt; source,  
    Func&lt;TSource, TKey&gt; keySelector)  
{  
    Guard.NotNullOrEmpty(source, nameof(source));  
    ...  
}

介于这种非空检查极其常见,C# 9.0 对此做了简化,增加了操作符‘!’,放在参数名后面,表示此参数不接受 null 值。使用方式如下:

public static string HashPassword(string password!)  
{  
    ...  
}

简化了很多有木有。这个提案已经纳入 C# 9.0 的特性中,但目前(2020-06-13)还没有完成开发。

这个特性只支持非 null 检查,其它参数检查场景还是不够用的,我还是会通过辅助类来进行像空字符串、空集合的检查。

这个特性在写公共类库的时候很有用,但我想大多数人在写业务逻辑代码的时候可能用不到这个特性,一般会封自己的参数检查机制。比如,我在项目中,对于上层 API 开发,我通过封装一个辅助类(ApiGuard)来对对参数进行检查,如果参数不通过,则抛出相应的业务异常,而不是 ArgumentNullException。比如下面的一段截取自我的 GeekGist 小项目的代码:

public static class ApiGuard  
{  
    public static void EnsureNotNull(object param, string paramName)  
    {  
        if (param == null) throw new BadRequestException($"{paramName} can not be null.");  
    }  
  
    public static void EnsureNotEmpty&lt;T&gt;(IEnumerable&lt;T&gt; collection, string paramName)  
    {  
        if (collection == null || collection.Count() == 0)  
            throw new BadRequestException($"{paramName} can not be null or empty.");  
    }  
  
    public static void EnsureExist(object value, string message = "Not found")  
    {  
        if (value == null) throw new BadRequestException(message);  
    }  
  
    public static void EnsureNotExist(object value, string message = "Already existed")  
    {  
        if (value != null) throw new BadRequestException(message);  
    }  
    ...  
}

使用示例:


public async Task UpdateAsync(long id, BookUpdateDto dto)  
{  
    ApiGuard.EnsureNotNull(dto, nameof(dto));  
    ApiGuard.EnsureNotEmpty(dto.TagValues, nameof(dto.TagValues));  
  
    var book = await DbSet  
        .Include(x => x.BookTags)  
        .FirstOrDefaultAsync(x => x.Id == id);  
    ApiGuard.EnsureExist(book);  
  
    Mapper.Map(dto, book);  
  
    ...  
}

ApiGuard 的好处是,当 API 接口接到不合要求的参数时,可以自定义响应返回内容。比如,增加一个 Filter 或中间件用来全局捕获业务代码异常,根据不同的异常返回给前端不同的状态码和消息提示:

private Task HandleExceptionAsync(HttpContext context, Exception exception)  
{  
    ApiResult result;  
    if (exception is BadRequestException)  
    {  
        result = ApiResult.Error(exception.Message, 400);  
    }  
    else if (exception is NotFoundException)  
    {  
        message = string.IsNullOrEmpty(message) ? "Not Found" : message;  
        result = ApiResult.Error(message, 404);  
    }  
    else if (exception is UnauthorizedAccessException)  
    {  
        message = string.IsNullOrEmpty(message) ? "Unauthorized" : message;  
        result = ApiResult.Error(message, 401);  
    }  
    ...  
}

只是一个参数非空检查,在实际开发中却有不少的学问,所以学好了理论还要多实践才能更透彻的理解它。

目标类型推导 new 表达式

关于类型推导想必大家都很熟悉,它是在 var 关键字引入的时候引入 C# 的。

var i = 10;  
var u = new User();

编译器会通过右边的字面量自动推导左边变量的类型,这种推导方式可以归纳为:从上下文右边推导出左边的类型。我们不防把它称为源类型推导(Source-typed inferring,参考 Target-typed 自创的术语)。

相应的,有源类型推导就有目标类型推导 (Target-typed inferring),它是指从上下文左边推导出右边的类型。比如数组的初始化和 Lambda 表达式常常是目标类型推导的表达式。举个例子:

// 没有使用类型推导  
string\[\] s = new string\[\] { "a", "b" };  
// 目标类型推导(左推右)  
string\[\] s = new { "a", "b" };  
string\[\] s = new \[\] { "a", "b" };  
  
// 没有使用类型推导  
Users.FirstOrDefault&lt;User&gt;(u => u.id = 123);  
// 目标类型推导(左推右)  
Users.FirstOrDefault(u => u.id = 123);

这次在 C# 9 中,增加了用户定义类型 new 表达式的目标类型推导,即通过上下文左边自动推导 new 表达式的类型,从而在使用 new 构造时省略类型的指定,请看示例:

// C# 9 之前  
Point p = new Point(3, 5);  
  
// C# 9  
Point p = new (3, 5);

除此之外,C# 9 也增加了操作符 ?? 和 ?: 的目标类型推导支持。之前这两个操作符必须要求两边的操作对象都是相同的类型,否则会编译报错。而在 C# 9 中,只要目标类型是操作对象共同的基类就不再会编译报错了,比如:

// Student 和 Customer 拥有共同的父类 Person  
Person person = (Person)(student ?? customer); // C# 9 之前  
Person person = student ?? customer; // C# 9  
  
// 可空类型,0 和 null 都可以隐式转换为 int? 类型  
int? result = b ? 0 : (int?)null; // C# 9 之前  
int? result = b ? 0 : null; // C# 9

其实本文的核心就一句代码:

Point p = new (3, 5);

只读属性和记录

熟悉函数式编程的童鞋一定对“只读”这个词不陌生。为了保证代码块自身的“纯洁”,函数式编程是不能随便“弄脏”外来事物(参数、变量等)的,所以“只读”对函数式编程非常重要。

为了丰富 C# 对函数式编程支持,较新的 C# 版本引入了一些很有用的新特性。比如 C# 8 中就对 struct 类型的方法增加了 readonly 修饰符支持,被 readonly 修饰的方法是不能修改该方法所在类的属性的。举个例子:

public struct FooValue  
{  
    private int A { get; set; }  
    public readonly int IncreaseA()  
    {  
        A = A + 1; // 报错  
        return A;  
    }  
}

而 C# 9 又进一步增加了对“只读”的支持,此次增加了 init-only 属性和 record 相关特性,下面一一介绍。

Init-only 属性

我们知道类的属性有 set 和 get 两种访问器,现在 C# 9 增加一种属性访问器:init。init 是 set 访问器的变体,它的作用是使属性只能在对象初始化的时候对其赋值,之后该属性就是只读的,因此叫 init-only 属性。使用方式如下:

public class Foo  
{  
    public string PropA { get; init; }  
    public string PropB { get; init; }  
}

赋值操作:

var foo = new Foo {  PropA = "A", PropB = "B" };  
foo.PropA = "AA"; // 报错,PropA 此时是只读的!

由于 init 是在初始化阶段赋值,所以它可以在类内部修改 readonly 修饰的字段。比如:


public class Foo  
{  
    private readonly string propA;  
    private readonly string propB;  
  
    public string PropA  
    {  
        get =\> propA;  
        init => propA = (value ?? throw new ArgumentNullException(nameof(propA)));  
    }  
    public string PropA  
    {  
        get =\> propB;  
        init => propB = (value ?? throw new ArgumentNullException(nameof(propB)));  
    }  
}

如果你知道在构造函数中可以对只读字段/属性赋值就自然也理解这一点。

记录 (Record)

做过财务系统的人都知道交易记录一旦入账是不能修改的,如果录入错误,就要新录入一笔负的记录把之前的红冲掉,再录入正确的记录。应对类似这种只读记录的场景,C# 9 引入了 Record(记录,下文均使用中文的“记录”)的概念,它用来支持整个对象的只读特性(即实例化后为只读)。使用方式如下:

public data class Foo  
{  
    public string PropA { get; init; }  
    public string PropB { get; init; }  
}

这里用了一个 data 关键字,表示该类的对象只是纯粹的记录值,它不是可修改的状态(在函数式编程中,所有的数据修改都是状态在发生变化)。

上面的太麻烦了,可以这样简写:

public data class Foo  
{  
    string PropA;  
    string PropB;  
}

默认属性都是 public 的,如果实在要改为 private,可以在属性定义前面加上 private 修饰符。

定位记录 (Positional Record)

有时候为了初始化更方便,可以定义构造函数来给属性赋值,初始化时只需要把属性值按顺序传给构造函数即可,这个操作称为定位构造(Positional Construction)。同样,也可以使用解构函数(Deconstructor)来实现属性的解构,即按照解构函数的参数顺序从对象中提取属性的值,被称为定位解构(Positional Deconstructor)。实现了定位构造或定位解构的记录称为定位记录(Positional Record)。下面是一个定位记录的实现:

public data class Foo  
{  
    string PropA;  
    string PropB;  
    public Foo(string propA, string propB)  
      =\> (PropA, PropB) = (propA, propB);  
    public void Deconstruct(out string propA, out string propB)  
      =\>  (propA, propB) = (PropA, PropB);  
}

这个写法太麻烦了,可以直接简写为:

public data class Foo(string PropA, string PropB);

这样简短一句代码,其内部默认实现了 init-only 自动属性,且同时为所有属性定义了构造函数和解构函数。

使用示例:


var foo = new Foo("AA", "BB");  // 构造定位  
var (a, b) = foo;               // 解构定位

可以想象,记录的大部分使用场景,以上简写的写法能满足需求。若有特殊场景,就不能简单,需要进行自定义修改其默认行为。

with 表达式

当处理不可变数据时,若要生成不同的状态,一个常见的场景是在一条旧记录基础上拷贝一条新的记录。比如我们要修改 Foo 对象的 PropA 属性,我们就要拷贝该对象生成一个新的对象。这个操作在函数式编程中被称为“非破坏性修改 (non-destructive mutation)”。为了支持记录的这个操作,C# 9 引入了 with 表达式,它可以很方便在一条原有记录基础上创建一条新记录。示例:

var other = foo with { PropA = "AA" };

with 表达式内部其实是通过一个默认的 protected 构造函数来实现的,大致如下:


protected Foo(Foo original)  
{  
    // 拷贝 original 的所有字段  
}

如果默认实现的字段拷贝不符合你的需求,你也可以手动实现这个构造函数。

Lambda 弃元参数

弃元(Discards) 是在 C# 7.0 的时候开始支持的,它是一种人为丢弃不使用的临时虚拟变量。语法上它是用来赋值的,但它却不被分配存储空间,即没有值,所以不能从中读取值。弃元用 _ (下划线) 表示,下划线是一个关键字,只能赋值,不能读取,例如:

图片

在 C# 7.0 中,弃元的使用场景主要有下面四种:

  • 元组和对象的解构

  • 使用 is 和 switch 的模式匹配

  • 对具有 out 参数的方法的调用

  • 作用域内独立使用场景

针对这几个场景,用下面的几段代码演示一下。

场景一:元组/对象的解构

var tuple = (1, 2, 3, 4, 5);  
(_, _, _, _, var fifth) = tuple;

场景二:使用 is/switch 的模式匹配

var obj = CultureInfo.CurrentCulture.DateTimeFormat;  
  
switch (obj)  
{  
    case IFormatProvider fmt:  
        Console.WriteLine($"{fmt} object");  
        break;  
    case null:  
        Console.Write("A null object reference");  
        break;  
    case object _:  
        Console.WriteLine("Some object type without format information");  
        break;  
}  
  
if (obj is object _)  
{  
    ...  
}

场景三:对具有 out 参数的方法的调用

var point = new Point(10, 10);  
// 只要 x, 不关心 y  
point.GetCoordinates(out int x, out _);

场景四:作用域内独立使用场景

void Test(Dto dto)  
{  
    _ = dto ?? throw new ArgumentNullException(nameof(dto));  
}

理解了弃元,也了解了弃元的四种使用场景,那么对下面这个 C# 9.0 新支持的弃元使用场景就容易理解了。

C# 9.0 对弃元增加了一种场景支持:Lambda 参数,也包括匿名方法参数。示例:

// C# 9 之前  
Func<int, int, int\> zero = (a, b) => 0;  
Func<int, int, int\> func = delegate (int a, int b) { return 0; };  
  
// C# 9  
Func<int, int, int\> zero = (_, _) => 0;  
Func<int, int, int\> func = delegate (int _, int _) { return 0; };

在 C# 9 之前,即便不使用的 Lambda 参数也需要给它命名。C# 9 支持弃元参数一方面简化了命名,另一方面也节省了内存分配。更重要的是它使得编程的意图更明确,让人一看就知道这个参数是不用的,增强了代码的可读性和可维护性。

使用新语法提升 if 语句美感

C# 语言一贯秉承简洁优美的宗旨,每次升级都会带来一些语法糖,让我们可以使代码变得更简洁。本文分享两个使用 C# 9.0 提升 if 语句美感的技巧示例。

使用属性模式代替 IsNullOrEmpty

在任何你使用 IsNullOrEmpty 的时候,可以考虑这样替换:

string? hello = "hello world";  
hello = null;  
  
// 旧的方式  
if (!string.IsNullOrEmpty(hello))  
{  
    Console.WriteLine($"{hello} has {hello.Length} letters.");  
}  
  
// 新的方式  
if (hello is { Length: >0 })  
{  
    Console.WriteLine($"{hello} has {hello.Length} letters.");  
}

属性模式相当灵活,你还可以把它用在数组上,对数组进行各种判断。比如判断可空字符串数组中的字符串元素是否为空或空白:

string?\[\]? greetings = new string\[2\];  
greetings\[0\] = "Hello world";  
greetings = null;  
  
// 旧的方式  
if (greetings != null && !string.IsNullOrEmpty(greetings\[0\]))  
{  
    Console.WriteLine($"{greetings\[0\]} has {greetings\[0\].Length} letters.");  
}  
  
// 新的方式  
if (greetings?\[0\] is {Length: > 0} hi)  
{  
    Console.WriteLine($"{hi} has {hi.Length} letters.");  
}

刚开始你可能会觉得阅读体验不太好,但用多了看多了,这种简洁的方法更有利于阅读。

使用逻辑模式简化多重判断

对于同一个值,把它与其它多个值进行比较判断,可以用 or 、and 逻辑模式简化,示例:

ConsoleKeyInfo userInput = Console.ReadKey();  
  
// 旧的方式  
if (userInput.KeyChar == 'Y' || userInput.KeyChar == 'y')  
{  
    Console.WriteLine("Do something.");  
}  
  
// 新的方式  
if (userInput.KeyChar is 'Y' or 'y')  
{  
    Console.WriteLine("Do something.");  
}

之前很多人不解 C# 9.0 为什么要引入 or 、and 逻辑关键字,通过这个示例就一目了然了。

Q.E.D.