第12章 分解与模式匹配

第12章 分解与模式匹配

12.1 分解元组

分解元组,即将元组的元素分解、复制给其他变量。下面是一个元组分解的示例:

var tuple = (10, "text");   // 创建一个元组类型 (int, string)

var (a, b) = tuple;     // 隐式地将元组分解到两个新变量 a 和 b

(int c, string d) = tuple;  // 显式地将元组分解到两个新变量 c 和 d

int e;              //
string f;           // 分解到现有变量
(e, f) = tuple;     //

Console.WriteLine($"a: {a}; b: {b}");   //
Console.WriteLine($"c: {c}; d: {d}");   // 证明分解成功
Console.WriteLine($"e: {e}; f: {f}");   //

Notice

比较如下两行代码,哪句是在声明元组示例?哪句是在分解元组?

(int c, string d) = tuple;
(int c, string d) x = tuple;

1 条语句是在分解、第 2 条语句是在声明。编码时请注意这些细节。

12.1.1 分解成新变量

分解这一特性的出现让变量的赋值简化了很多。以如下代码为例:

var tmp = MethodReturningTuple();
int a = tmp.x;
int b = tmp.y;
string name = tmp.text;

static (int x, int y, string text) MethodReturningTuple() => (1, 2, "t");

可以看到它需要借助临时变量完成赋值,且语句繁琐。再看借助分解特性的赋值,只需一行代码便可以完成:

(int a, int b, string name) = MethodReturningTuple();

static (int x, int y, string text) MethodReturningTuple() => (1, 2, "t");

关于变量类型,C# 还支持隐式类型和显式类型混用、全部进行隐式推断:

(int a, int b, var name) = MethodReturningTuple();
(long a, var b, XNamespace name) = MethodReturningTuple();
var (a, b, name) = MethodReturningTuple();

对于不需要的变量,还可以通过 _ ​ 符号进行丢弃:

(int a, _, string name) = MethodReturningTuple();

12.1.2 通过分解操作为已有变量或者属性赋值

利用分解操作,还可以对已有变量/字段/属性/索引器/其他对象进行赋值:

int a = 20;
int b = 30;
string name = "before";

(a, b, name) = MethodReturningTuple();

Error

利用分解操作,可以声明并初始化变量,或者执行一系列赋值,但是不能将二者混用。例如以下代码非法:

int x;
(x, int y) = (1, 2);

这种赋值方式也存在一些陷阱。要搞清楚这个陷阱,我们先要知道赋值分解的执行步骤:

  1. 赋值目标 进行运算;
  2. 赋值操作右半部分 进行运算;
  3. 完成赋值。

这种执行顺序可能造成非预期的执行结果。以如下代码为例,它在分解中同时对 StringBuilder​、StringBuilder.Length​ 进行了赋值,输出内容为: 12367890

StringBuilder builder = new StringBuilder("12345");
StringBuilder original = builder;

(builder, builder.Length) = (new StringBuilder("67890"), 3);

Console.WriteLine(original);
Console.WriteLine(builder);

它等价于如下代码:

StringBuilder builder = new StringBuilder("12345");
StringBuilder original = builder;

StringBuilder targetForLength = builder;
(StringBuilder, int) tuple = (new StringBuilder("67890"), 3);
builder = tuple.Item1;
targetForLength.Length = tuple.Item2;

Console.WriteLine(original);
Console.WriteLine(builder);

12.1.3 元组字面量分解的细节

类似于11.3 元组类型及其转换,元组的分解也有完全相同的机制。如下代码合法:

// 分解
(string text, Func<int, int> func) = (null, x => x * 2);
// 赋值
(text, func) = ("text", x => x * 3);
// 隐式转换
(byte x, byte y) = (5, 10);

12.2 非元组类型的分解操作

非元组类型的分解,是一种基于模式的方式(类似于 async/await 和f oreach)。任何具有合适的 Deconstruct() ​ 方法或扩展方法的类型,都可以使用与元组相同的语法完成分解。

12.2.1 实例分解方法

分解操作所用的 Deconstruct()​ 实例方法需要满足以下几条规则:

  • 分解方法对于执行分解操作的代码必须是可 访问 的。(如果所有代码都位于同一个程序集中,那么可以使用 internal 来修饰 Deconstruct()​ 方法。)
  • 该方法的返回值必须是 void
  • 参数个数不少于 2 (因为不可能分解一个值)。
  • 该方法 不能 是泛型方法。

为什么 Deconstruct()​ 方法要通过 out 参数,而非返回元组?

通过 out 参数可以进行重载,以分解不同的多组值。通过返回元组则无法实现该功能。

下面是一个 Deconstruct()​ 实现的简单示例:

public void Deconstruct(out double x, out double y)
{
    x = X;
    y = Y;
}

还可以通过表达式体的方式实现:

public void Deconstruct(out double x, out double y) => (x, y) = (X, Y);

12.2.2 扩展分解方法与重载

Deconstruct()​ 扩展方法的使用规则,自然和实例方法保持一致:

  • 需要对于调用代码可
  • 除了第一个参数(扩展方法的目标类型),其他参数必须是 out 参数;
  • out 参数的个数不少于 2
  • 方法 可以 是泛型,但只有扩展方法的调用目标(第一个参数)才能参与类型推断。

下面是一个简单的示例:

static void Deconstruct(this DateTime dateTime, out int year, out int month, out int day) =>
    (year, month, day) = (dateTime.Year, dateTime.Month, dateTime.Day);

12.2.3 编译器对于 Deconstruct​ 调用的处理

以如下代码为例,其中 target 不是元组实例,编译器会将其转为右侧代码:

(int x, string y) = target;
target.Deconstruct(out var tmpX, out var tmpY);
int x = tmpX;
string y = tmpY;

之后编译器按照常规方法的调用规则查找合适的方法。

这里的重点是:源码中声明的两个变量类型其实并没有用于 Deconstruct()​ 方法调用,即这两个变量并没有参与类型推断。这一事实解释了如下 3 个问题:

  • Deconstruct​ 实例方法 不能 是泛型方法,因为泛型方法无法为类型推断提供有效信息。
  • Deconstruct​ 扩展方法 可以 是泛型方法,因为编译器可能根据 target 这个类型实参做出类型推断,而且 target 也是类型推断的唯一信息来源。
  • Deconstruct()​ 方法进行重载时, out 参数的个数 是决定性因素,而非 参数类型 。如果再添加一个和已有方法 out 参数个数相同的重载方法,编译器会因为无法决定应该调用哪个方法而终止编译。

12.4 C#7.0 可用的模式

12.4.1 常量模式

常量模式由编译时常量表达式组成。输入类型有 2 种:

  • 整型表达式:使用 ==​ 进行比较
  • 其他类型:使用 object.Equals()静态 方法进行比较。

以如下代码为例,因 Match()​ 方法接受 object​ 类型的变量,传入整数值时发生了 装箱 !因此这些匹配实际使用的是 object.Equals()方法 进行比较。故 Match(10L)​ 输出的内容是“ Input didn't match hello, long 5 or int 10 ”:

Match("hello");
Match(5L);
Match(7);
Match(10);
Match(10L);

static void Match(object input)
{
    if (input is "hello")
        Console.WriteLine("Input is string hello");
    else if (input is 5L)
        Console.WriteLine("Input is long 5");
    else if (input is 10)
        Console.WriteLine("Input is int 10");
    else
        Console.WriteLine("Input didn't match hello, long 5 or int 10");
}

12.4.2 类型模式

类型模式由一个类型和一个标识符组成。

类型匹配成功后会引入一个新的模式变量,该变量的类型为匹配成功的类型,变量值会被初始化为输入的值。

下面是类型模式的一个简单示例:

static double Perimeter(Shape shape)
{
    if (shape == null)
        throw new ArgumentNullException(nameof(shape));
    if (shape is Rectangle rect)
        return 2 * (rect.Height + rect.Width);
    if (shape is Circle circle)
        return 2 * PI * circle.Radius;
    if (shape is Triangle triangle)
        return triangle.SideA + triangle.SideB + triangle.SideC;

    throw new ArgumentException($"Shape type {shape.GetType()} perimeter unknown", nameof(shape));
}

类型模式中,所指定的类型不能是可空值类型,以下代码 法:

static void CheckType(object value)
{
    if (value is int? num)
    {
    }
}

但是所指定的类型可以是 类型形参 ,且 类型形参 在执行时可以是可空值类型:

CheckType<int?>(null);
CheckType<int?>(5);
CheckType<int?>("text");
CheckType<string>(null);
CheckType<string>(5);
CheckType<string>("text");

static void CheckType<T>(object value)
{
    if (value is T t)
    {
        Console.WriteLine($"Yes! {t} is a {typeof(T)}");
    }
    else
    {
        Console.WriteLine($"No! {value ?? "null"} is not a {typeof(T)}");
    }
}

12.4.3 var 模式

var 模式看起来与类型模式类似,区别是把 var 作为类型。其基本语法如下:

\[someExpression\ is\ var\ x \]

var 模式有如下特点:

  • 它不检查任何内容,总能匹配成功
  • 总会引入一个新的变量,其类型、值与输入完全相同
  • 即便输入是 null 也能匹配成功

var 模式的最大用处是和 switch + 哨兵 语句搭配使用。下面是一个简单的示例:

static double Perimeter(Shape shape)
{
    switch (shape ?? CreateRandomShape())
    {
        case Rectangle rect:
            return 2 * (rect.Height + rect.Width);
        case Circle circle:
            return 2 * PI * circle.Radius;
        case Triangle triangle:
            return triangle.SideA + triangle.SideB + triangle.SideC;
        case var actualShape:
            throw new InvalidOperationException($"Shape type {actualShape.GetType()} perimeter unknown");
    }
}

12.5 模式匹配与 is 运算符的搭配使用

模式匹配引入的模式变量有如下特点:

  • 在 is 表达式中声明的模式变量,其作用域为 整个闭合块
  • 如果某处使用模式变量的代码发生了编译错误,则说明在此处编译器还无法确定该变量是否已经被 赋值

关于作用域,以如下代码为例,text 的作用域二者相同:

string text = input as string;
if (text != null)
{
    Console.WriteLine(text);
}
if (input is string text)
{
    Console.WriteLine(text);
}

关于确定赋值,仅在模式变量确认已赋值的情况下,该模式变量才可用。以如下两段代码为例,第二段代码不确认是 x​ 还是 y​ 进行了赋值,因此 无法通过编译

if (input is int x && x > 100)
{
    Console.WriteLine($"Input was a large integer: {x}");
}
void DoSomething(object input)
{
    if ((input is int x && x > 100) || (input is long y && y > 100))
    {
        Console.WriteLine($"Input was a large integer of some kind: {x}, {y}");
    }
}

12.6 在 switch 语句中使用模式

switch 模式的一个简单示例如下:

static double Perimeter(Shape shape)
{
    switch (shape)
    {
        case null:
            throw new ArgumentNullException(nameof(shape));
        case Rectangle rect:
            return 2 * (rect.Height + rect.Width);
        case Circle circle:
            return 2 * PI * circle.Radius;
        case Triangle tri:
            return tri.SideA + tri.SideB + tri.SideC;
        default:
            throw new ArgumentException(...);
    }
}

12.6.1 哨兵语句

每个 case 标签都可以有一条哨兵语句,该语句由一个表达式组成:

\[case\ pattern\ when\ expression: \]

该表达式最终要计算出一个 布尔 值。case 标签下的语句,只有在该表达式计算为 true 时才会执行。此处的“when”便是哨兵。

在使用哨兵语句时,因其有可能返回 false,同一模式出现多次是常事。下面的代码演示了同一模式出现多次:

private string GetUid(TypeReference type, bool useTypeArgumentNames)
{
    switch (type)
    {
        case ByReferenceType brt:
            return $"{GetUid(brt.ElementType, useTypeArgumentNames)}@";
        case GenericParameter gp when useTypeArgumentNames:
            return gp.Name;
        case GenericParameter gp when gp.DeclaringType != null:
            return $"`{gp.Position}";
        case GenericParameter gp when gp.DeclaringMethod != null:
            return $"``{gp.Position}";
        case GenericParameter gp:
            throw new InvalidOperationException(
            "Unhandled generic parameter");
        case GenericInstanceType git:
            return "(This part of the real code is long and irrelevant)";
        default:
            return type.FullName.Replace('/', '.');
    }
}

12.6.2 case 标签中的模式变量的作用域

switch-case 中的变量可以分为两种:

  • 在 case 体中定义的变量:作用域是 整个 switch 语句

  • 在 case 标签中声明的变量:作用域 仅限当前 case 体

    该情况下的变量主要有三种:

    • 模式引入的变量
    • 哨兵语句中声明的变量
    • out 变量(见14.2 out 变量)

但这又迎来一个新的问题:基于模式的 switch 语句应当和普通 switch 语句一样,允许多个 case 标签共享同一个 case 体。此时 case 标签中声明的变量不能 重名 。相应的,因为编译器不确定会匹配到哪个标签、哪个模式变量会完成赋值,因此模式变量只能在 哨兵语句 中使用,而不能在 case 体 中使用。

下面是一个简单的示例:

static void CheckBounds(object input)
{
    switch (input)
    {
        case int x when x > 1000:
        case long y when y > 10000L:
            Console.WriteLine("Value is too large");
            break;
        case int x when x < -1000:
        case long y when y < -10000L:
            Console.WriteLine("Value is too low");
            break;
        default:
            Console.WriteLine("Value is in range");
            break;
    }
}

12.6.3 基于模式的 switch 语句的运算顺序

绝大部分情况下,基于常量的 switch 语句中的 case 标签 可以 任意排列,而 不会 影响代码的执行行为。

基于模式则完全不同,它的逻辑运算顺序可以概括为:

  • 每个 case 标签都按照 源码 的顺序进行运算;
  • 只有 当所有 case 标签都 经过运算后,default 标签的代码才会被执行,与 default 标签在 switch 语句中的 位置 无关。

以如下代码为例,即使 default 块处于开头,仍在最后匹配:

CheckBounds(1);

static void CheckBounds(object input)
{
    switch (input)
    {
        default:
            Console.WriteLine("default 块");
            break;
        case int x when PrintMsg("第一个 case 标签"):
            Console.WriteLine("第一个 case 块");
            break;
        case int y when PrintMsg("第二个 case 标签"):
            Console.WriteLine("第二个 case 块");
            break;
    }
}

static bool PrintMsg(string message)
{
    Console.WriteLine(message);
    return true;
}
posted @ 2025-04-13 23:02  hihaojie  阅读(11)  评论(0)    收藏  举报