再帰パターン

Ver. 8.0

C# 7.0 の範囲で使えるものは、「パターン」と呼ぶのが仰々しいくらい単純なものでした。 C# 8.0 で、再帰的に使えるパターンが追加されて、ようやくパターン マッチングらしくなりました。

例えば以下のような感じです。

public class Point
{
    public int X { get; set; }
    public int Y { get; set; }
    public Point(int x = 0, int y = 0) => (X, Y) = (x, y);
    public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
}
 
class Program
{
    static int M(object obj)
        => obj switch
    {
        0 => 1,
        int i => 2,
        Point (1, _) => 4, // new!
        Point { X: 2, Y: var y } => y, // new!
        _ => 0
    };
}

位置パターン

位置パターン (positional pattern)は、 分解と同じ要領で再帰的なマッチングを行うパターンです。

分解と同様、Deconstructメソッドを呼んでメンバーを取り出した上で、 それぞれのメンバーの値に対してマッチングを行います。 例えば、先ほど例として使ったPointクラスを引き続き使うとして、以下のように書けます。

static int M(Point p)
    => p switch
{
    (1, 2) => 0,
    (var x, _) when x > 0 => x,
    _ => -1
};

このコードは概ね以下のような意味になります。

p.Deconstruct(out var x, out var y);
if (x is 1 && y is 2) return 0;
if (x > 0) return x;
return -1;

サブパターンの順序に意味があるため「位置」パターンという呼び名になっています。

上記の例では元々型がPointだとわかっているので型名を省略していますが、 型の明示もできます。

static int M(object obj)
    => obj switch
{
    int i => i,
    string s => s.Length,
    Point(var x, var y) => 0,
    _ => -1
};

また、後述しますが、プロパティ パターンとの混在や、 型パターンのように変数を付け足すこともできます。

obj switch
{
    Point (var x, _) { Y: var y } p => x * y
};

位置パターンとか言いつつ、名前付き引数のノリで、名前付きなパターン マッチングもできます。

static int NamedPattern(Point p)
    => p switch
{
    (x: 1, y: 2) => 0,
    (x: var x, y: _) when x > 0 => x,
    _ => -1
};

補足: コンストラクター呼び出しの逆

位置パターンは、コンストラクター呼び出し(new)の逆に当たる構文です。 書き方も、コンストラクターと対になっています。

// 位置指定で構築できるんなら、位置指定でマッチングできるべき
var p1 = new Point(1, 2);
var r1 = p1 is Point (1, 2);
 
// 名前指定で構築できるんなら、名前指定でマッチングできるべき
var p2 = new Point(x: 1, y: 2);
var r2 = p2 is Point (x: 1, y: 2);
 
// 型推論が効く場合に new の後ろの型名は省略可能(になる予定)なら
// 型が既知なら型名を省略してマッチングできるべき
Point p3 = new (1, 2);
var r3 = p3 is (1, 2);
 
// 階層的に new できるんなら、階層的にマッチングできるべき
var line = new Line(new Point(1, 2), new Point(3, 4));
var r4 = line is ((1, 2), (3, 4));

分解方法

位置パターンは 分解と同じ要領でメンバーの値を取り出します。 分解もそうなんですが、タプル(C# のタプル構文を使って作る ValueTuple 構造体の値)の場合とそうでない場合で内部的な挙動が少し変わります。

まず、タプルの場合、コンパイラーの最適化によって、タプルのフィールドを直接参照するようなコードが生成されます。 例えば以下のようなコードを書いた場合、

public bool TupleSyntax((int a, int b) x) => x is (1, 2);

以下のようなコードと同じような挙動をします。

// ValueTuple の場合は直接フィールドを参照する。
public bool TupleSyntax((int a, int b) x)
{
    return x.a == 1 && x.b == 2;
}

そうでない場合、まずはコンパイル時に Deconstruct メソッドを探します。 見つかった場合は、それを使うコードが生成されます。 例として以下のようなクラスを用意します。

using System.Runtime.CompilerServices;

class X : ITuple
{
    public object this[int index] => index;
    public int Length => 2;
    public void Deconstruct(out int a, out int b) => (a, b) = (0, 1);
}

この型に対して以下のようなコードを書いた場合、

public bool Deconstruct(X x) => x is (1, 2);

以下のようなコードと同じような挙動をします。

// コンパイル時に Deconstruct メソッドが見つかる場合はそれを使って分解。
public bool Deconstruct(X x)
{
    x.Deconstruct(out var a, out var b);
    return a == 1 && b == 2;
}

分解代入や分解変数宣言とは違って、位置パターンの場合はコンパイル時に Deconstruct メソッドが見つからない場合があります。 この場合、ITupleインターフェイス(System.Runtime.CompilerServices名前空間)を使って分解を試みます。 例えば以下のようにobjectで値を渡すコードを書いた場合、

public bool Object(object x) => x is (1, 2);

以下のようなコードと同じような挙動をします。

// コンパイル時の解決ができない場合、ITuple を実装しているかどうかを見る。
// Length とインデクサーを使ってマッチング。
public bool Object(object x)
{
    return x is ITuple t
        && t.Length == 2
        && t[0] is int a && a == 1
        && t[1] is int b && b == 1
        ;
}

タプル switch

位置パターンに伴って、switchステートメントの () の中に、複数の値を , 区切りで書けるようになりました。

int Compare(int? a, int? b)
{
    switch (a, b)
    {
        case (null, null): return 0;
        case (int _, null): return -1;
        case (null, int _): return -1;
        case (int a1, int b1): return a1.CompareTo(b1);
    }
}

このコードは、まず (a, b) というタプルを作って、それを switch ステートメントに掛ける挙動になります。case の後ろに書かれているのは位置パターンです。

要するに、意味としては switch ((a, b)) と書くのと同じです。 なので実体としては「複数の値に対するswitch」というより、「タプルに限り、() を一段省略できる」という機能です。

0、1要素の分解

タプル構築や分解代入・分解宣言では0、1要素のもの( ()(x)) は認められていませんが、 位置パターンでは認められるようになりました。 それぞれ、0、1引数のDeconstructメソッドが調べられます。

using System;
 
class X
{
    public void Deconstruct() { }
    public void Deconstruct(out int a) => a = 0;
}
 
class Program
{
    static void Main() => M(new X());
 
    static void M(X x)
    {
        // 0 引数の位置パターン。
        // Deconstruct() を持っていることが使える条件。
        if (x is ()) Console.WriteLine("Deconstruct()");
 
        // 1 引数の位置パターン。
        // Deconstruct(out T) を持っていることが使える条件。
        // ただ、キャストの () との区別が難しいらしく、素直に x is (int a) とは書けない。
        // 前後に余計な var や _ を付ける必要あり。
        if (x is (int a) _) Console.WriteLine($"Deconstruct({a})");
    }
}

0引数のものは単に () で OK です。

一方で、1引数のものは、キャストの () との区別が難しいそうで、 素直に (constant) とか (T variable) とかは書けません。 var (subpattern) とか (subpattern) _ とか、前後に余計なものを付けることでキャストと区別します。

最適化での Deconstruct 削除

位置パターンでは、コンパイラーの最適化によって Deconstruct メソッドの呼び出しが消えることがあります。 以下のように、すべて _ で値を破棄してしまう場合には Deconstruct メソッドを呼び出す必要がなく、 実際、呼び出しが消えてなくなります。

using System;
 
class X
{
    // Deconstruct に副作用を持たせる
    public void Deconstruct() => Console.WriteLine("Deconstruct()");
    public void Deconstruct(out int a)
    {
        Console.WriteLine("Deconstruct(out int a)");
        a = 0;
    }
    public void Deconstruct(out int a, out int b)
    {
        Console.WriteLine("Deconstruct(out int a, out int b)");
        (a, b) = (0, 0);
    }
}
 
class Program
{
    static void Main()
    {
        var x = new X();
 
        // Deconstruct() がないとコンパイル エラーになるけど、
        // Deconstruct() は呼ばれない。
        Console.WriteLine(x is ());
 
        // Deconstruct(out int) がないとコンパイル エラーになるけど、
        // Deconstruct(out int) は呼ばれない。
        Console.WriteLine(x is var (_));
 
        // Deconstruct(out int, out int) がないとコンパイル エラーになるけど、
        // Deconstruct(out int, out int) は呼ばれない。
        Console.WriteLine(x is (_, _));
    }
}

また、引数の数が同じ位置パターンをいくつか並べた際にも、Deconstruct メソッドの呼び出しは1回にまとめられます。

class X
{
    public int Value { get; }
    public X(int value) => Value = value;
    public void Deconstruct(out int value) => value = Value;
}
 
class Program
{
    static int M(X x)
        => x switch
    {
        // 引数の数が同じ位置パターンを3回。
        // この場合、Deconstruct(out int) の呼び出しは1回にまとめられる。
        (0) _ => 1,
        (1) _ => 2,
        (2) _ => 0,
        _ => x.Value
    };
}

ちなみに、仕様上は「必ず消える」という保証もないです(「消えることがある」という仕様)。 なので、Deconstruct メソッドは副作用を起こさないように作ることが推奨されます。

プロパティ パターン

プロパティ パターン(property pattern)は、プロパティに対して再帰的なマッチングを行うパターンです。 (プロパティ パターンという名前に反して、フィールドも使えます。)

書き方は、{ PropertyName: SubPattern, ... } というように、 プロパティ名と、そのプロパティに対して掛けたいパターンを : でつなぎます。 複数のプロパティに対して使う場合はそれぞれを , で区切ります。 位置パターンとは違って、名前の省略はできません。

再び Point クラス(int 型の2つのプロパティ XY を持つ)を例に挙げます。 以下のような書き方ができます。

static int M(Point p)
    => p switch
{
    { X: 1, Y: 2 } => 0,
    { X: var x, Y: _ } when x > 0 => x,
    _ => -1
};

このコードは概ね以下のような意味になります。

var x = p.X;
var y = p.Y;
if (x is 1 && y is 2) return 0;
if (x > 0) return x;
return -1;

位置パターンと同様、型の明示もできます。

static int M(object obj)
    => obj switch
{
    int i => i,
    string s => s.Length,
    Point { X: 0, Y: 0 } => 0,
    Point (_, _) => 1,
    _ => -1
};

ちなみに、プロパティ パターンと言いつつ、フィールドも参照できます。

using System;
 
class X
{
    // (外から見て) get-only なプロパティ
    public int GetOnly { get; private set; }
 
    // get/set 可能なプロパティ
    public int GetSet { get; set; }
 
    // フィールド
    public int Field;
 
    // set-only なプロパティ
    public int SetOnly { set => GetOnly = value; }
}
 
class Program
{
    public static void Main()
    {
        // オブジェクト初期化子では、set が public なプロパティか readonly ではないフィールドを指定可能
        var x = new X { GetSet = 1, Field = 2, SetOnly = 3 };
 
        // プロパティ パターンでは、get が public なプロパティかフィールドを指定可能
        Console.WriteLine(x is { GetOnly: 3, GetSet: 1, Field: 2 });
    }
}

オブジェクト初期化子の逆

「位置パターンはコンストラクター呼び出しの逆」という話をしましたが、 同様に、プロパティ パターンはオブジェクト初期化子と対になるものです。

// 初期化子でプロパティ指定できるんなら、プロパティ指定でマッチングできるべき
var p1 = new Point { X = 1, Y = 2 };
var r1 = p1 is { X: 1, Y: 2 };
 
// 混在で構築できるんなら、混在でマッチングできるべき
var p2 = new Point(x: 1) { Y = 2 };
var r2 = p2 is (1, _) { Y: 2 };

ただ、= は代入の意味なのでパターンとしては使えず、代わりに : になっています。 : を使っているのは、位置パターンと構文を共通化できて実装が楽だからだそうです。

位置パターンとプロパティ パターンの順序

位置パターンとプロパティ パターンを混在して使う場合、 Deconstructメソッドとプロパティのアクセサーの呼び出し順序には保証がないそうです。

残念ながら、以下のようなコードには動作保証がないそうです。

using System;
 
enum Type { A, B }
 
class X
{
    public Type Type { get; }
    public X(Type type) => Type = type;
 
    // それぞれ Type が一致しているときだけ値を取り出せ、そうでなければ例外
    public int A => Type == Type.A ? 1 : throw new InvalidOperationException();
    public int B => Type == Type.B ? 2 : throw new InvalidOperationException();
 
    // 分解でタイプ判定
    public void Deconstruct(out Type t) => t = Type;
}
 
class Program
{
    static void Main()
    {
        Console.WriteLine(M(new X(Type.A)));
        Console.WriteLine(M(new X(Type.B)));
    }
 
    // 以下のコードはたまたま動く可能性はあるものの、C# の言語使用としては保証がない。
    // Deconstruct よりも先にプロパティのアクセスがあると例外が出ることがある。
    static int M(X x) => x switch
    {
        (Type.A) { A: var a } => a,
        (Type.B) { B: var b } => b,
        _ => 0
    };
}

非 null マッチング

プロパティ パターンは、暗黙的にnullチェックが挟まって、非 null であることが保証されます。 しかも、x is { } というように、中身が空っぽであっても null チェックだけは挿入されるので、 x is { }を「xはnullではない」の意味で使えます。

C# 7.0 までのパターンだと、null チェックを楽に書く手段がなかったです。

struct LongLongNamedStruct { }
 
void M1(LongLongNamedStruct? x)
{
    // こういう書き方だと null チェックになる。
    if (x is LongLongNamedStruct nonNull)
    {
        // obj が null じゃない時だけここが実行される。
        // でも、x の型が既知なのに、長いクラス名をわざわざ書くのはしんどい…
    }
}
 
void M2(LongLongNamedStruct? x)
{
    // が、var パターンは null にもマッチしちゃう。
    // (var は「何にでもマッチ」。null でも true になっちゃう。)
    if (x is var nullable)
    {
        // obj が null でもここが実行される。
    }
}

単に null チェックだけなら !(x is null) とか x.HasValue だけでいいんですけども、 値を使いたければその後ろで var nonNull = x.GetValueOrDefault(); とかが必要で、何を使うにしても微妙に長くなりがちでした。

そこで先ほどの x is { } を使います。 以下のような書き方で、null 許容型の null チェックをしつつ、値を変数に受け取れます。

void M3(LongLongNamedStruct? x)
{
    // (C# 8.0) プロパティ パターンであれば、null チェックを含む。
    if (x is { } nonNull)
    {
        // obj が null じゃない時だけここが実行される。
    }
}

プロパティ パターンの拡張(入れ子のメンバー参照)

Ver. 10

C# 10.0 で、以下のように、入れ子のプロパティ・フィールド参照でプロパティ パターンを書けるようになりました。

m(null);
m(new X { Name = "" });
m(new X { Name = "a" });
m(new X { Name = "abc" });

static void m(X? x)
{
    if (x is { Name.Length: 1 })
    {
        Console.WriteLine("single-char Name");
    }
}

class X
{
    public string? Name { get; set; }
}

この例でいう { Name.Length: 1 } の部分は、{ Name: { Length: 1 } } と全く同じ意味になります。

ここで注意点というか、1つ、一瞬迷いそうな点として、 Name.Length と言う書き方でも Name の null チェックを含んでいます。 { Name: { Length: 1 } } をさらに展開すると、以下のようなコードとほぼ同じ意味になります。

    if (x is not null)
    {
        var name = x.Name;
        if (name is not null)
        {
            var length = name.Length;
            if (length == 1)
            {
                Console.WriteLine("single-char Name");
            }
        }
    }

リスト パターン

Ver. 11

C# 11で、[] を使ってリスト(配列や List<T> など)に対するパターン マッチングができるようになりました。 例えば以下のような switch を書けます。

var array = new[] { 1, 2 };

Console.WriteLine(array switch
{
    [] => "空の配列",
    [1] => "長さ1で、1要素目が1",
    [_] => "長さ1の配列",
    [1, 2] => "長さ2で、1要素目が1、2要素目が2",
    [1, _] => "長さ2で、1要素目が1",
});

このような [] を使ったパターンをリスト パターン(list pattern)と言います。

注意: 角カッコ

C# で新文法を追加する際には、既存の文法と比べて違和感がないような選択をすることが多いです。

そういう意味ではリスト パターンの [] は珍しくちょっと見慣れない感じの選択でした。 これまで [] を使う文法というと、配列作成の new T[N] か、インデクサーの x[i] な分けですが、 これらはの場合 [] の内側には「個数」や「何番目か」の数値が入ります。 リスト パターンの [] の中に入るのは「要素に対するパターン」で、ちょっと方針が異なります。

初期案では、配列初期化子 new[] { a, b, c } からの類推ができるよう、リスト パターンには {} を使おうかという話もありました。 ただ、is {} だとプロパティ パターンとの弁別が難しかったようです。

これに対して、(C# 11 では入らなかったんですが、将来) 「コレクション リテラル」みたいな文法で [] を使う事を考えたりもしているようです。

// (C# 11 時点で提案段階)
using System.Collections.Immutable;

int[] array = [ 1, 2 ];
Span<int> span = [ 1, 2 ];
ReadOnlySpan<int> ros = [ 1, 2 ];
List<int> list = [ 1, 2 ];
ImmutableArray<int> immutable = [1, 2];

これが入れば、初期化・生成側と、パターン マッチ・分解側の間の違和感が緩和されるかと思います。

.. (スライス パターン)

パターンに対して [a, b] と書く場合、2要素ピッタリのリスト出ないとマッチしません。

var array = new[] { 1, 2 };

Console.WriteLine(array is [1, 2]); // true
Console.WriteLine(array is [1]);    // false。部分一致ではダメ。

部分一致させたい場合、余る部分に .. を置けばマッチさせることができます。 例えば、以下のようなコードで、「1, 2 で始まって、長さ2以上のリスト」にマッチできます。

var array = new[] { 1, 2 };

match(new[] { 1 }); // false
match(new[] { 1, 2 }); // true (ちょうどでもOK)
match(new[] { 1, 2, 3 }); // true (過剰でもOK)
match(new[] { 1, 2, 3, 4, 5 }); // true

static void match(int[] array)
    => Console.WriteLine(array is [1, 2, ..]);

このような ..スライス パターン(slice pattern)と言います。

ちなみに、スライス パターンはリスト パターンの [] の内側にだけ書けます。 例えば array is .. みたいな書き方は認められていません。

.. は先頭や中間にも書けます。

var a1 = new[] { 1, 2 };
var a2 = new[] { 1, 2, 2 };
var a3 = new[] { 1, 2, 1, 2 };

// 1で始まって2で終わる(長さは任意)。
Console.WriteLine(a1 is [1, .., 2]); // true
Console.WriteLine(a2 is [1, .., 2]); // true
Console.WriteLine(a3 is [1, .., 2]); // true

// 末尾が 1, 2で終わる(長さは任意)。
Console.WriteLine(a1 is [.., 1, 2]); // true
Console.WriteLine(a2 is [.., 1, 2]); // false
Console.WriteLine(a3 is [.., 1, 2]); // true

ちなみに、2か所以上に .. を置いてしまうとコンパイル エラーになります。

var array = new[] { 1, 2 };

Console.WriteLine(array is [.., ..]);

リスト パターンの再帰

リスト パターンはカテゴライズするなら再帰パターンの一種です。 [] の中の各要素には任意のパターンを書くことができます。

using System.Numerics;

static bool match1(int[] array)
    => array is [0, _, > 0, < 0, var x, ..] && (x % 2) == 1;
// 前から順に、
// 0 だけにマッチ(定数パターン)
// 任意 (破棄パターン)
// 0 より大きい(関係演算パターン)
// 0 より小さい(関係演算パターン)
// 任意 (var パターン)
// 残り読み飛ばし (スライス パターン)

static bool match2((int x, int y)[] points)
    => points is [(1, 2), (x: 3, y: 4), { x: 5, y: 6 }];
// 前から順に
// 位置パターン
// 位置パターン(名前付き)
// プロパティ パターン

また、スライス パターンも、.. の後ろに続けてパターンを書くことができます。

static bool match1(ReadOnlySpan<int> span) => span switch
{
    [> 0, .. var rest] => match1(rest), // 先頭が正の数で、残りを再帰的に判定
    [] => true,
    _ => false,
};

static bool match2(int[] array)
    => array is [1, ..[2, 3]]; // あまり意味はなくて、[1, 2, 3] と同じ結果にしかならない

よく使いそうな例でいうと、「先頭数バイトが特定のパターンの時に読み飛ばし」みたいなことができます。

var utf8 = File.ReadAllBytes("a.txt");

foreach (var b in removeBom(utf8))
{
    Console.WriteLine($"{b:X}");
}

static ReadOnlySpan<byte> removeBom(ReadOnlySpan<byte> utf8)
    => utf8 is [0xEF, 0xBB, 0xBF, .. var noBom] ? noBom : utf8;

リスト パターンの展開結果

リスト パターンやスライス パターンは、 割かしべたに長さ (Length もしくは Count プロパティ)、インデックス (a[i]) やスライス (a[..]) に展開されます。 例えば以下のようなリスト パターンを書いた場合、

Console.WriteLine(palindrome(new int[0]));              // true
Console.WriteLine(palindrome(new[] { 1 }));             // true
Console.WriteLine(palindrome(new[] { 1, 2 }));          // false
Console.WriteLine(palindrome(new[] { 1, 2, 2 }));       // false
Console.WriteLine(palindrome(new[] { 1, 2, 1 }));       // true
Console.WriteLine(palindrome(new[] { 1, 2, 1, 2, 1 })); // true
Console.WriteLine(palindrome(new[] { 1, 2, 1, 2, 2 })); // false

static bool palindrome(ReadOnlySpan<int> list) => list switch
{
    [] or [_] => true,
    [var first, .. var rest, var last] => first == last && palindrome(rest),
};

以下のようなコードとほぼ同じ意味になります。

static bool palindrome(ReadOnlySpan<int> list) => list.Length switch
{
    0 or 1 => true,
    >= 2 => list[0] == list[^1] && palindrome(list[1..^1]),
};

a[^i]a[i..j] が使えることが、そのままリスト パターンを使える条件になります。 (詳しい条件に付いては「インデックス/範囲」を参照。)

また、list is [_, .. var rest, _] みたいなものが list[1..^1] に展開される都合上、 list[i..j] がパフォーマンス的にいまいちなコードになっている場合、 リスト パターンも非効率になります。

static void m1(int[] array)
{
    // 配列に対するスライスは新しい配列を作っちゃう(= 遅い)。
    var slice = array[1..^1];

    // その影響で、以下のコードも新しい配列がいちいち作られて遅い。
    // (string でも同じようなことが起きる)。
    Console.WriteLine(array is [_, ..var rest, _]);
}

static void m2(ReadOnlySpan<int> span)
{
    // Span の場合はそんな非効率な事は起きないので、
    var slice = span[1..^1];

    // 以下のコードも遅くはならない。
    // (string に対しては ReadOnlySpan<char> にすると速い)。
    Console.WriteLine(span is [_, .. var rest, _]);
}

再帰パターンの利用例

型スイッチの用途」と同じ題材で、再帰パターンの利用例も挙げておきます。

使った題材は、数式を扱うようなクラスです。 要するに、例えば、「x×x+1」というような式を、以下のようなクラスで表します。

public abstract class Node
{
    public static readonly Node X = new Var();
    public static implicit operator Node(int value) => new Const(value);
    public static Node operator +(Node left, Node right) => new Add(left, right);
    public static Node operator *(Node left, Node right) => new Mul(left, right);
}
 
public class Var : Node { public override string ToString() => "x"; }
 
public class Const : Node
{
    public int Value { get; }
    public Const(int value) { Value = value; }
    public void Deconstruct(out int value) => value = Value;
    public override string ToString() => Value.ToString();
}
 
public class Add : Node
{
    public Node Left { get; }
    public Node Right { get; }
    public Add(Node left, Node right) => (Left, Right) = (left, right);
    public void Deconstruct(out Node left, out Node right) => (left, right) = (Left, Right);
    public override string ToString() => $"({Left.ToString()} + {Right.ToString()})";
}
 
public class Mul : Node
{
    public Node Left { get; }
    public Node Right { get; }
    public Mul(Node left, Node right) => (Left, Right) = (left, right);
    public void Deconstruct(out Node left, out Node right) => (left, right) = (Left, Right);
    public override string ToString() => $"{Left.ToString()} * {Right.ToString()}";
}

こいつに対して「式の簡約化」をやってみます。 要は、 「x+0xに、 x×1xに、 x×00に直す」みたいなやつ。

こういう処理は、switch式と位置パターンを使って以下のように書けます。 (コード全体: Expressions/Program.cs)

public static Node Simplify(this Node n)
    => n switch
{
    Add (var l, var r) => (l.Simplify(), r.Simplify()) switch
    {
        // 0 を足しても変わらない
        (Const(0), var r1) => r1,
        (var l1, Const(0)) => l1,
        // 他
        (var l1, var r1) => new Add(l1, r1)
    },
    Mul (var l, var r) => (l.Simplify(), r.Simplify()) switch
    {
        // 0 を掛けたら 0
        (Const(0) c, _) => c,
        (_, Const(0) c) => c,
        // 1 を掛けても変わらない
        (Const(1), var r1) => r1,
        (var l1, Const(1)) => l1,
        // 他
        (var l1, var r1) => new Mul(l1, r1)
    },
    _ => n
};

C# 7.3 までだと、この処理は以下のように書くことになります。

public static Node ClassicSimplify(this Node n)
{
    if (n is Add a)
    {
        var (l, r) = a;
        var l1 = l.Simplify();
        var r1 = r.Simplify();
 
        { if (l1 is Const c && c.Value == 0) return r1; }
        { if (r1 is Const c && c.Value == 0) return l1; }
        return new Add(l1, r1);
    }
    if (n is Mul m)
    {
        var (l, r) = m;
        var l1 = l.Simplify();
        var r1 = r.Simplify();
 
        {
            if (l1 is Const c)
            {
                if (c.Value == 0) return c;
                if (c.Value == 1) return r1;
            }
        }
        {
            if (r1 is Const c)
            {
                if (c.Value == 0) return c;
                if (c.Value == 1) return l1;
            }
        }
        return new Mul(l1, r1);
    }
    return n;
}

更新履歴

ブログ