はじめに
C#のパターンマッチングについて@toRisouPさんの以下の記事を拝見して、この機会にプロパティによるパターンマッチングについて、何番煎じかを恐れず個人的に確認したことを書いてみます。なお、環境はVisual Studio 2022 (17.0.4)で実行したものです。
基礎
パターンマッチングの中でも、型によるパターンなどは直感的に理解できますが、プロパティによるパターン(property pattern)は記法がちょっと人工的で、とっつきにくい気がしていました。慣れるとなかなか便利なのですが。
基本的な記法としては { プロパティ名: プロパティ値のパターン } の組み合わせで指定します。例えば、従来の記法で以下のように書いたとします。
var p = new Point(10, 10);
if (p.X == 10 && p.Y == 10)
{
// 何か処理する。
}
これをパターンマッチングで書くと、以下のようになります。
if (p is { X: 10, Y: 10 })
{
// 何か処理する。
}
変数のpを何回も書く必要がなくなり、少し見通しがよくなります。なお、複数のプロパティのマッチングはショートサーキットになるので、&&と同じ意味になります。
また、上記はXとYのプロパティ値のパターンとして定数によるパターンを使った例ですが、他のパターンも当然使えるので、Yの条件を変えて以下のような書き方もできます。
if (p is { X: 10, Y: > 0 })
_ とか { } とか
さて、スイッチ式(switch expression)でお馴染みの、何にでも当てはまるアンダーバー「_」(正しくはdiscode patternという)がプロパティ値のパターンでも使えます。上の例でYの値は何でもよいとすると、以下のように書けます。
if (p is { X: 10, Y: _ })
でも、これってYは評価しないのと同じなので、単にYを書かなくても同じ意味になります。
if (p is { X: 10 })
さらに、Xの値も何でもよいとすると、同じことをXにも適用する、つまり { } の中に何も書かないこともできてしまいます。
if (p is { })
これだとXとYの値は何でもよいので、条件として何の意味もないように見えます。それは値型の場合には正しいですが、参照型やNullableの値型では意味があって、これはnullではないことを意味しています。nullではなく存在はするが、中身は何でもよいというか。あるいは、元からプロパティのパターンでは暗黙的にnullチェックが行われていて、{ } だけになるとnullチェックだけが残る、とも捉えられるかなと。
これは意味としては以下のように書いても同じです。
if (p is not null)
この方が分かりやすいのは確かですが、プロパティのパターンに慣れると { } でも意味は通るのと、コンパクトに書けて、他のプロパティのパターンと書き方を揃えやすいので、まあありかなと思います。
switch文、switch式の中では
実のところ、if文よりswitch文、switch式の方がパターンマッチングに慣れるには早道な気がします。というのも、Visual Studioがコードを自動解析して厳しく指摘してくるので、慣れざるを得ないというか。
テスト用のクラスを以下のように定義するとします。
public class TestClass1
{
public bool? A { get; }
public bool B { get; }
public TestClass1(bool? a, bool b) => (A, B) = (a, b);
}
これを使ったswitch式のパターンは、上記の _ や { } を使うと、例えば以下のように書けます。
private static string TestMethod1(TestClass1 t)
{
return t switch
{
// AがtrueでBもtrueの場合
{ A: true, B: true } => "Hit1",
// AがtrueでBは何でもよい場合
// (BがtrueのときはHit1でヒットするので、Bがfalseの場合)
{ A: true, B: _ } => "Hit2",
// AがfalseでBは何でもよい場合
{ A: false } => "Hit3",
// tがnullではなく、AもBも何でもよい場合
// (Aがtrueの場合はHit2で全てヒットし、Aがfalseの場合はHit3で全てヒットするので、
// AがnullでBは何でもよい場合)
{ } => "Hit4",
// その他の場合
// (tがnullでない場合はHit4で全てヒットするので、tがnullの場合)
_ => "Hit5"
};
}
この中で、Hit1の下にBがfalseの場合(Hit1b)を挿入すると、Hit1とHit1bでHit2に該当し得る場合を網羅してしまい、Hit2には到達できなくなるので、エラーになります。
private static string TestMethod2(TestClass1 t)
{
return t switch
{
// AがtrueでBもtrueの場合
{ A: true, B: true } => "Hit1",
// AがtrueでBはfalseの場合
{ A: true, B: false } => "Hit1b",
// AがtrueでBは何でもよい場合
// (Bがtrueの場合はHit1でヒットし、Bがfalseの場合はHit1bでヒットするので、
// 到達できないことになる)
{ A: true, B: _ } => "Hit2", // エラー(CS8510)
_ => "Hit5"
};
}
また、Hit3の下にAがnullの場合(Hit3b)を挿入すると、Hit1とHit3とHit3bでHit4に該当し得る場合を網羅してしまい、Hit4には到達できなくなるので、エラーになります。
private static string TestMethod3(TestClass1 t)
{
return t switch
{
// AがtrueでBは何でもよい場合
{ A: true, B: _ } => "Hit2",
// AがfalseでBは何でもよい場合
{ A: false } => "Hit3",
// AがnullでBは何でもよい場合
{ A: null } => "Hit3b",
// tがnullではなく、AもBも何でもよい場合
// (AとBが何でもあってもHit2とHit3とHit3bで全てヒットするので、
// 到達できないことになる)
{ } => "Hit4", // エラー(CS8510)
_ => "Hit5"
};
}
次に構造体の場合として、プロパティの同じテスト用の構造体を以下のように定義するとします。
public struct TestStruct1
{
public bool? A { get; }
public bool B { get; }
public TestStruct1(bool? a, bool b) => (A, B) = (a, b);
}
これで同じようにパターンを書いてみると、構造体は当然nullにならないので、Hit4で全て網羅され、Hit5には到達できなくなるので、Hit5がエラーになります。もっとも、この場合はHit4が余分なのかもしれませんが。
private static string TestMethod4(TestStruct1 t)
{
return t switch
{
// AがtrueでBもtrueの場合
{ A: true, B: true } => "Hit1",
// AがtrueでBは何でもよい場合
{ A: true, B: _ } => "Hit2",
// AがfalseでBは何でもよい場合
{ A: false } => "Hit3",
// tがnullではなく、AもBも何でもよい場合
{ } => "Hit4",
// その他の場合
// (tがnullでない場合はHit4で全てヒットし、tはnullにならないので、
// 到達できないことになる)
_ => "Hit5" // エラー(CS8510)
};
}
また別の点として、プロパティのパターンで、プロパティのgetterへのアクセスはパターンごとに行われるのか気になったので、getterへのアクセス回数を数えるためのクラスを定義してみます。
public class TestClass2
{
public bool? A
{
get
{
CountA++;
return _a;
}
}
private readonly bool? _a;
public int CountA { get; private set; } = 0;
public bool B
{
get
{
CountB++;
return _b;
}
}
private readonly bool _b;
public int CountB { get; private set; } = 0;
public TestClass2(bool? a, bool b) => (_a, _b) = (a, b);
public void Reset() => (CountA, CountB) = (0, 0);
}
このクラスで以下のようなパターンを試してみました。
private static string TestMethod5(TestClass2 t)
{
t?.Reset();
return t switch
{
{ A: true, B: true } => "Hit1",
{ A: true, B: false } => "Hit2",
{ A: false } => "Hit3",
{ } => "Hit4",
_ => "Hit5"
} + $" CountA:{t?.CountA}, CountB:{t?.CountB}";
}
結果だけ言うと、CountAの値は常に1、CountBの値は0~1でした。つまり、getterへのアクセス回数は0~1回だったわけですが、CountBの0回というのはAの評価だけで終わった場合と考えるとして、1回というのは、これがどう展開されるかまでは追究していませんが、評価が1回で済むような構文に展開されるのかなと思います。そういう意味では、単純にプロパティを参照する書き方より優秀と言えそうです(プロパティにアクセスするコストが気になるような設計がいいかは別問題として)。
アーリーリターンに使うとき
if文でのパターンマッチングは込み入った条件をすっきり書けるのがよいところですが、アーリーリターンのために条件を逆にしたいときはどうすればいいか、という疑問が出てきます。
例えば、先ほどのクラスを使う以下のようなメソッドがあるとします。
private static string TestMethod7a(TestClass1 t)
{
if (t is { A: true })
{
// 正常ルート
return "Good 👍";
}
return "Bad 👎";
}
これをアーリーリターンに書き換える場合、Aの型はbool?なので、とりあえずAを反転させてみます。
private static string TestMethod7b(TestClass1 t)
{
if (t is { A: null or false })
{
return "Bad 👎";
}
// 正常ルート(tがnullの場合にも来てしまう)
return "Good 👍";
}
一見よさそうに見えますが、元のメソッドではnullは正常ルートから弾いていたのが、正常ルートに来てしまいます。これはプロパティのパターンによる暗黙のnullチェックを考慮していなかったためです。したがって、この点を補うなら、以下のようになります。
private static string TestMethod7c(TestClass1 t)
{
if (t is null or { A: null or false })
{
return "Bad 👎";
}
// 正常ルート(tがnullの場合には来ない)
return "Good 👍";
}
これで条件は満たせますが、いかにも複雑化してしまっています。ではどうするかというと、元の条件全体をnotで反転させてしまえば簡単にできます。
private static string TestMethod7d(TestClass1 t)
{
if (t is not { A: true })
{
return "Bad 👎";
}
// 正常ルート(tがnullの場合には来ない)
return "Good 👍";
}
この方法はもっと複雑な条件の場合でも使えます。
private static string TestMethod8a(object o)
{
if (o is TestClass1 t and { A: true, B: true })
{
// 正常ルート
return $"Good 👍 A:{t.A} B:{t.B}";
}
return "Bad 👎";
}
これをnotでさくっと反転。
private static string TestMethod8b(object o)
{
if (o is not (TestClass1 t and { A: true, B: true }))
{
return "Bad 👎";
}
// 正常ルート
return $"Good 👍 A:{t.A} B:{t.B}";
}
これなら別に難しくはないと思います。たいていの場合、正常ルートの条件の方がストレートに考えることができるので、それを固めてから反転させる方が早いのではないかと。
まとめ
以上で、プロパティのパターンを使う上で個人的に気になっていたことは大体確認できました。
プロパティのパターンは他のパターンと融通無碍に組み合わせることができるので、このパターンマッチングを使って条件をなるべく宣言的に書くことによって、見通しをよくすることができるのではないかなと思います。