Build Insiderオピニオン:岩永信之(5)
次期C# 7: パターンマッチングの内部的な仕組み
C#の次バージョンに追加される「パターンマッチング」の挙動(パターンマッチングがどう展開されるのかや、その実現方法)について説明する。
前回はC# 7のパターンマッチングについて、必要とされる背景や書き方について説明した。後編となる今回は、パターンマッチングがどう展開されるかなど、内部的な仕組みについて説明する。
前編同様、リスト1に示す型を使って説明していく。
abstract class Node { }
class Var : Node { }
class Const : Node
{
public int Value { get; }
public Const(int value) { Value = value; }
}
class Add : Node
{
public Node X { get; set; }
public Node Y { get; set; }
public Add(Node x, Node y) { X = x; Y = y; }
}
class Mul : Node
{
public Node X { get; set; }
public Node Y { get; set; }
public Mul(Node x, Node y) { X = x; Y = y; }
}
|
それでは、パターンマッチングの展開結果がどうなるかを説明していこう。位置指定パターンを除けば、パターンの展開結果はかなり簡単なものだ。
パターンの展開結果
まずは簡単なものから説明していく。型パターンとプロパティパターンを例に、パターンの展開結果を説明する。
前編の型パターンの説明で「これまでas
演算子とnull
チェックを通して行っていた処理を簡素に書けるようになる」と書いたが、展開結果もまさにこの通りになる。例えばリスト2に示すパターンの例を見てみよう。
public static string ToString(this Node n)
{
switch (n)
{
case Var _: return "x";
case Add { X is var x, Y is var y }: return ToString(x) + "+" + ToString(y);
// Const、Mulの行はAddの行とほぼ同じなので省略
case *: throw new InvalidOperationException();
}
}
|
これは、リスト3のような処理に展開される。
public static string ToString(this Node n)
{
var _ = n as Var;
if (_ != null) return "x";
var a = n as Add;
if (a != null)
{
var x = a.X;
var y = a.Y;
return ToString(x) + "+" + ToString(y);
}
// Const、Mulの行はAddの行とほぼ同じなので省略
throw new InvalidOperationException();
}
|
この例を見ての通り、パターンマッチングは、as
演算子以外は完全に静的な(=コンパイル時に型が確定している)処理である。as
演算子のコストも十分小さく、実行性能的な心配は不要だろう。
実行性能面で多少不利になる点を挙げると、case
句が上から順に判定されることである。リスト3を見ての通り、1つ1つのcase
句がif
ステートメントに置き換えられる。その結果、後ろに書いたcase
句ほど、そこにたどり着くまでの負担が大きくなる。
また、静的な処理なので、Visual StudioなどのIDE上で、コード補完やハイライト(キーワードや型名の色付け表示)もかかる。
位置指定パターンの実現方法
位置指定パターンを実現するには一工夫挟む必要がある。現状、以下の3つの案が出ていて、確定はしていない。
- ユーザー定義の
is
演算子 - 引数の位置とプロパティ名を対応付ける
- 2-1. タプル型を返す
GetValue
メソッドによる対応 - 2-2. 名前による自動対応
- 2-1. タプル型を返す
必ずしもどれか1つに決まるという様子でもなく、複数に対応するかもしれない(=まずユーザー定義のis
演算子があるかを調べて、あればそれを使い、なければ名前による自動対応を使うという可能性もある)。
ユーザー定義のis演算子
1つ目の案は位置指定パターンのために、ユーザー定義のis
演算子を導入するというものだ。is
演算子の例として、Const
クラスに対する実装例をリスト4に示す。
class Const : Node
{
public int Value { get; }
public Const(int value) { Value = value; }
public void operator is(Const c, out int value)
{
value = c.Value;
}
}
|
要は、位置指定パターンを実現するために新構文導入を導入する形だ。位置指定パターンは、このis
演算子の呼び出しに展開される。
この方法の場合、1つの型に対して複数のis
演算子オーバーロードを実装することで、複数の分解方法を提供できる。
class Const : Node
{
// リスト4に書かれている分は省略
// 2つ目のオーバーロード
public void operator is(Const c, out double value)
{
value = c.Value;
}
}
static class Byte
{
// 「Const c」に対して、
// 「c is Byte(var b)」というようなパターンが書ける
public void operator is(Const c, out byte value)
{
value = (byte)c.Value;
}
}
|
問題は、実装の面倒くささだろう。ただでさえ、1つのプロパティValue
に対して、大文字小文字だけが違う同名のコンストラクター引数value
、プロパティへの代入Value = value
などを書く必要があって煩雑だというのに、さらに、is
演算子の引数に再びvalue
、その引数への代入value = Value
が必要になる。合計7回もvalue
(もしくはValue
)が出てくる。
そこで、次回以降で説明するレコード型というものを使って、このis
演算子も自動生成する予定である。例えば、リスト6のように書くだけで、リスト4のようなプロパティ、コンストラクター、is
演算子が自動的に作られる。
class Const(int Value) : Node;
|
この仕組みが入った場合、リスト5のように複数のオーバーロードを追加したい場合にだけis
演算子を定義することになるだろう。
位置とプロパティの対応付け
先ほどの例に表れたように、プロパティには同名のコンストラクター引数などを大量に必要とする場合がある。図1に示すように、コンストラクター引数とプロパティが1対1に対応する型を書く場面は多い。
この場合、構築(コンストラクター呼び出し、オブジェクト初期化子)と分解(パターンマッチング)でも、引数の位置とプロパティ名に1対1の対応が付くことになる(図2)。
このような位置とプロパティの対応付け(position-property match)ができれば、位置指定パターンをはじめ、いろいろなものが簡素に書け、便利になる。
この対応付けにも2つの実装案がある。
タプル型を返すGetValueメソッド
1つ目は、明示的に対応付けを行うためのメソッドを書く方法である。第3回で説明したタプル型は、位置と名前の対応関係を最初から持っている。そこで、タプル型を返すようなGetValue
というメソッド(拡張メソッドでも可)を1つ用意することで、位置とプロパティを対応付けようというものである。例えばリスト7のような実装になる。
class Add : Node
{
public Node X { get; set; }
public Node Y { get; set; }
public Add(Node x, Node y) { X = x; Y = y; }
public (Node X, Node Y) GetValue() => (X, Y);
}
|
位置指定パターンは、リスト8に示すようなコードに展開される。
// 位置指定パターン
if (n is Add(var x, var y)) { }
// as演算子とGetValueメソッドの組み合わせに展開
var a = n as Add;
if (a != null)
{
let (var x, var y) = a.GetValue();
}
|
拡張メソッドも認めることで、自分以外の誰かが作った既存の型に対してGetValue
メソッドを追加し、位置指定パターンなどの新機能に対応させることができる。
一方でこの方法には、is
演算子の場合と同様、プロパティ名と同じものを何度も書かざるを得ない面倒さという問題がある。恐らくis
演算子同様、レコード型の場合は自動的に実装されることになるだろう。
また、もう1つ、タプル型という別の新機能に依存するという問題がある。異なる機能の間の強い依存関係にはリスクがあり、可能ならば避けたいものである。しかも、それが検討途中の機能同士の間の依存関係となると、より一層のリスクとなるだろう。
名前による対応
ユーザー定義のis
演算子でもタプル型を返すメソッドでも、面倒さに加えて、レコード型導入以前の既存の型に対して位置指定パターンを使えない(もしくは使えるようにするのに手間がかかる)という問題がある。
実際のところ、図1に示したような、引数とプロパティが1対1に対応する型を書くことは非常に多い。リスト1に含まれる型も全てそうなっている。そこで、リスト9に示すように、「Const
クラスのValue
プロパティは第1引数value
と対応している」というような、名前を見ての対応付けを自動的に行ってしまおうという発想が出てくる。
// 位置指定パターン
if (n is Const(1)) { }
// 位置と対応するプロパティに変換
// コンストラクター引数valueと同名のプロパティを探して使う
if (n is Const { Value is 1 }) { }
// 最終的な展開結果
var c = n as Const;
if (c != null && c.Value == 1) { }
|
C#では、このように名前の一致を見て異なるものを同一視する機能を他に持っていない。そういう意味ではかなり異色な機能になってしまいそうである。しかし、既存の型に対して追加の作業なしで位置とプロパティの対応付けを行うには他に現実的な方法がなさそうだ。
おまけ: immutableな型
位置指定パターン以外にも、位置とプロパティの対応付けによって実現できる機能がある。その代表例は以下の2つ機能で、いずれもimmutableな型を便利にするためのものである。
- immutableな型に対するオブジェクト初期化子
with
式
immutableな型というのは、フィールドが全て読み取り専用(readonly
修飾子付きのフィールドや、get
-onlyなプロパティ)で、コンストラクター以外では値の書き換えを行わない型だ。リスト1のクラスは全てそうなっている。immutableな型は、C# 6までのC#では、何かと不便な点があった。
immutableな型に対するオブジェクト初期化子
immutableな型は、コンストラクター引数で値を渡して初期化する必要がある。後からのプロパティ書き換えはできない。そして、オブジェクト初期化子は単なるプロパティの書き換えに展開されるため、immutableな型に対しては使えなかった。
これに対して、位置とプロパティの対応付けができると、リスト10に例を示すように、オブジェクト初期化子を引数付きのコンストラクター呼び出しに変換することで、immutableな型に対しても使えるようになる。
// オブジェクト初期化子
var c = new Const { Value = 1 };
// C# 6までの解釈
var c = new Const();
c.Value = 1; // Valueプロパティの値を書き換えられないのでコンパイルエラーに
// 位置とプロパティの対応付けができると
// 引数付きのコンストラクター呼び出しに変換できる
var c = new Const(1);
|
with式
値を書き換えられないということは、一部分だけを書き換えたい場合でもいちいち新しいインスタンスを作る必要がある。この際、書き換えない値についてもコンストラクターに渡しなおす必要があり、プロパティ数が増えてくるとかなりの手間となる。
そこで、リスト11に示すように、with
式という新しい構文を追加して、書き換えたい値だけを新しい値に置き換え、その他の値はそのまま新しいインスタンスに渡せるようにする。
// Yプロパティの値だけを書き換え
// プロパティ数が増えてくるとかなり大変
var x = new Add(new Var(), new Const(1));
var y = new Add(x.X, new Const(2));
// with式を使って書き換えたいところだけを記述
// Xプロパティの値はx.Xがそのまま使われる
var y = x with { Y = new Const(2) };
// 展開結果
var y = x.With(x.X, new Const(2));
|
展開結果にあるWith
メソッドについては、次回以降、レコード型と一緒に説明する。ここでも、どのプロパティがWith
メソッドのどの引数に対応するのかを決定する手段が必要になる。
まとめ
今回はパターンマッチングの展開結果など、内部的な仕組みについて説明した。型パターンやプロパティパターンは単なるas
演算子への展開で、それほど複雑なものではない。
一方、位置指定パターンには(コンストラクター引数などの)位置とプロパティとの間の対応関係を知る必要がある。位置指定パターンの実現にはいくつかの案が出ているが、大まかにいうと、レコード型という新しい型定義のための構文にサポートしてもらうか、「同名の引数とプロパティを同一視する」というような名前での対応付けを必要とする。
レコード型は次回以降で説明するが、これも便利で面白い機能だ。名前による対応付けはこれまでのC#ではあまり採用されてこなかった方針となる。しかし、C# 7で導入したいいくつかの構文を、レコード型導入以前の既存の型に対して実現するためには名前による対応付けが必要かもしれない。
岩永 信之(いわなが のぶゆき)
※以下では、本稿の前後を合わせて5回分(第3回~第7回)のみ表示しています。
連載の全タイトルを参照するには、[この記事の連載 INDEX]を参照してください。
3. 次期C# 7: 複数データをまとめるための言語機能、タプル型
メソッドが複数の値を戻す場合など、複数のデータを緩くまとめて、扱いたい場合はよくある。C#の次バージョンではこれを簡潔に記述するための機構として「タプル型」が導入される。
4. 次期C# 7: 型に応じた分岐や型の分解機能、パターンマッチング
オブジェクトの型を階層的に調べて分岐処理を行いたい場合がある。そのための機能として、C#の次バージョンでは「パターンマッチング」が追加される。
5. 【現在、表示中】≫ 次期C# 7: パターンマッチングの内部的な仕組み
C#の次バージョンに追加される「パターンマッチング」の挙動(パターンマッチングがどう展開されるのかや、その実現方法)について説明する。
6. 次期C#とパフォーマンス向上(前編)―― 必要となった背景と改善内容
機能や構文ばかりが注目されるが、プログラミング言語ではパフォーマンスも重要だ。パフォーマンス向上に対する要求が高まってきた背景と、向上のための改善方法を説明。C# 7以降で追加が検討されている新機能にも言及する。
7. 次期C#とパフォーマンス向上(後編)―― 予定・検討中の5つの新機構
前編に続き、次期C#のパフォーマンス向上について解説。C# 7以降での採用が予定もしくは検討されているパフォーマンス向上関連の新機能の内容を具体的に見ていこう。