Build Insiderオピニオン:岩永信之(4)
次期C# 7: 型に応じた分岐や型の分解機能、パターンマッチング
オブジェクトの型を階層的に調べて分岐処理を行いたい場合がある。そのための機能として、C#の次バージョンでは「パターンマッチング」が追加される。
前回に引き続き、C#に対する機能追加における大きなテーマである「データ処理」に沿った機能を紹介していこう。今回~次回で紹介する機能はパターンマッチングである。今回は前編として、パターンマッチングの構文の紹介や、具体的な用途、オブジェクト指向的な機能とのすみ分けなどについて説明していく。
パターンマッチング
パターンマッチングは、オブジェクトの型を階層的に調べて分岐処理を行うための構文である。is
演算子とswitch
ステートメントの拡張として、これらの中にいくつかの「パターン」を書くことができる。
最初に簡単な例を挙げておこう。リスト1およびリスト2に示すような構文になる。詳細は後々説明していくことになるが、この構文でできることは「型による分岐」と「型の分解」である。
if (n is Const c) Console.WriteLine($"Constant: {c.Value}");
|
string ToString(Node n) => n switch (
case Var(): "x",
case Const c: c.Value.ToString(),
case Add(var x, var y): ToString(x) + " + " + ToString(y),
case Mul(var x, var y): ToString(x) + " * " + ToString(y),
case *: new InvalidOperationException()
);
|
is
やcase
キーワードの直後の部分が「パターン」である。リスト1は最も単純なパターンで、既存のis
演算子と同様、オブジェクトの型を調べるだけのパターン、つまり型による分岐だ。ただし、パターンに合致した(=もしn
の型がConst
型だった)場合、変数c
に型変換した結果が代入されるようになっている。
リスト2は新たに導入されるswitch
式を使ったパターンマッチングとなる。最初の2つのパターン(Var()
とConst c
)は、リスト1と同様の単純なパターンである。3つ目と4つ目のAdd(var x, var y)
とMul(var x, var y)
というパターンでは、変数n
の型を調べるだけでなく、そのメンバーを抽出して使える。このパターンが型の分解に当たる。コンストラクター(構築: construction)の対になる書き方でメンバーを抽出(分解: deconstruction)している。どの引数位置からどのメンバーを抽出するかを決める仕組みについては後編で説明する。
このような構文は、F#やScalaなど、関数型言語ではよく採用されている構文だ。だからと言って、関数型言語でないと実現できない/役に立たないものではない。これまでオブジェクト指向言語であまりパターンマッチングが見られなかったのは、オブジェクト指向的にはis
演算子やキャストを使って型を調べるよりも、仮想関数(C#でいうとvirtual
修飾子やabstract
修飾子が付いたものや、インターフェースで定義されたメソッドやプロパティ)を使った分岐が好まれるからだろう。
今後、恐らく両極端な意見、すなわち、「仮想関数があるのでパターンマッチングは要らない」と「オブジェクト指向的発想はダメだった。これからはパターンマッチング」というような意見も出てくるだろう。しかし、仮想関数にもパターンマッチングにも良い面があり、使い分けていくべきだろう。
サンプルコード中で使う型
本題に入る前に、サンプルコード中でたびたび使うであろうクラスを提示しておこう。リスト3に示す。この型は、図1に示すように、「3 * x + 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; }
}
|
ちなみに、同じくC#の次バージョンでの導入が検討されている「レコード型」と呼ばれる構文を使えば、リスト3のようなコードはかなりすっきりと書けるようになる。レコード型の詳細については次回以降で説明するが、リスト3のコードを、この構文を使って書き直した結果だけ紹介しておく。リスト4のようになる。
abstract sealed class Node();
class Var() : Node;
class Const(int Value) : Node;
class Add(Node X, Node Y) : Node;
class Mul(Node X, Node Y) : Node;
|
パターンの種類
パターンには表1に示すようなものがある。
種類 | 例 | 説明 |
---|---|---|
型パターン | n is Const c | 変数の型を調べる |
位置指定パターン | n is Add(Node x, Node y) | 引数の位置を見て型を分解する |
プロパティ パターン | n is Add { X is Node x, Y is Node y } | プロパティ名を見て型を分解する |
定数パターン | x is Const(1) | 値が一致するかを調べる |
varパターン | x is Const(var value) | 位置指定やプロパティパターンで、型は調べず(=任意の型に合致)、分解だけを行う |
ワイルドカード | x is Add(*, var y) | 位置指定パターンで、一部分の引数を無視する(=任意の型に合致するし、分解結果も受け取らない) |
それぞれのパターンについて説明していこう。
型パターン
型パターン(type pattern。例: n is Const c
)は、最初の例でも挙げた「最も単純なパターン」だ。既存のis
演算子に近いが、型が合致した場合、その型に変換した結果を変数に受けられる。リスト5に示すように、これまでas
演算子とnullチェックを通して行っていた処理を簡素に書けるようになる。
// これまでの書き方
var c = n as Const;
if (c != null) { ... }
// 型パターンを使った書き方
if (n is Const c) { ... }
|
また、リスト6に示すように、null許容型のnullチェックを簡素化する目的でも使える。
// これまでの書き方
var x = s?.Length;
if (x.HasValue) { var len = x.Value; ... }
// 型パターンを使った書き方
if (s?.Length is int len) { ... }
|
型の分解(位置指定パターンとプロパティ パターン)
位置指定パターン(positional pattern。例: n is Add(Node x, Node y)
)とプロパティパターン(property pattern。例: n is Add { X is Node x, Y is Node y }
)は、型を分解して階層的に構造を調べるパターンとなる。型の分解(deconstruction)は、コンストラクターやオブジェクト初期化子などによる型の構築(construction)の逆操作と考えられる。表2に示すように、コンストラクターと位置指定パターン、オブジェクト初期化子とプロパティパターンはそれぞれ逆の関係になる。
構築(construction) | 分解(deconstruction) | |
---|---|---|
位置指定 | コンストラクター var n = new Add( new Var(), new Const(1)); |
位置指定パターン n is Add(Var(), Const(1)) |
プロパティ | オブジェクト初期化子 var n = new Add { X = new Var(), Y = new Const { Value = 1 } }; |
プロパティパターン n is Add { X is Var(), Y is Const { Value is 1 } } |
ちなみに、リスト4で紹介したレコード型という構文を使って作った型の場合、コンストラクター呼び出し前のnew
キーワードを省略可能にしたいという案も出ている(既存の型に対して認めないのは、後方互換性の維持のため)。これを使うと、表2のコンストラクター呼び出しの項は、リスト7のように書き換えられ、より一層、位置指定パターンとの対比がきれいになる。
var n = Add(Var(), Const(1));
|
その他のパターン
残りの定数パターン(constant pettern。例: x is Const(1)
)、varパターン(“var” pattern。例: x is Const(var value)
)、ワイルドカード(wildcard pattern。例: x is Add(*, var y)
)は、型の分解とともに使うことになるだろう。階層的なパターンの一部分を、それぞれ、定数との比較、型に依らない分解結果の変数での受け取り、単に無視(任意のパターンを受け入れ、変数にも受ける必要がない)に使う。
分かりやすい例は式の簡約化だろう。例えば「1を掛けても元と変わらない」「0を足しても元と変わらない」「0には何を掛けても0」といったルールをリスト8のように書ける。
// 1を掛けても元と変わらない
if (n is Mul(Const(1), var x)) return x;
// 0を足しても元と変わらない
if (n is Add(Const(0), var x)) return x;
// 0には何を掛けても0
if (n is Mul(Const(0), *)) return new Const(0);
|
パターンマッチングの用途
これまでの説明でも出てきているが、パターンマッチングには「型による分岐」という側面と、「型の分解」という側面がある。
型による分岐
前述の通り、パターンマッチングのような「型を調べて分岐」という機能は、オブジェクト指向言語の感覚には合わない面もある。「ダウンキャストは悪手」「型を調べたら負け」という感覚の開発者もいることだろう。一般に、型に応じて処理を変えたい場合、オブジェクト指向言語では仮想関数を使う。
例えば、サンプルとして提示した「式」を表すNode
クラスに、変数の値を与えた結果を計算するCalculate
メソッドを追加してみよう。仮想関数での実装ではリスト9、パターンマッチングを使った実装ではリスト10に示すようなコードになる。
abstract class Node
{
public abstract int Calculate(int v);
}
class Var : Node
{
……省略……
public override int Calculate(int v) => v;
}
class Const : Node
{
……省略……
public override int Calculate(int v) => Value;
}
class Add : Node
{
……省略……
public override int Calculate(int v) => X.Calculate(v) + Y.Calculate(v);
}
class Mul : Node
{
……省略……
public override int Calculate(int v) => X.Calculate(v) * Y.Calculate(v);
}
|
必要な部分だけ記載。省略している部分は、リスト3を参照してほしい(Calculateメソッドに渡した引数は、メソッド呼び出しが連鎖する中でVarクラスのインスタンスではその値が戻り値となり、他のクラスでは無視されるようになっている。例えば、new Add(new Var(), new Var).Calculate(1)
呼び出しは、結果として「1+1」を計算する)。
public static int Calculate(this Node n, int v)
{
switch (n)
{
case Var(): return v;
case Const(var value): return value;
case Add(var x, var y): return x.Calculate(v) + y.Calculate(v);
case Mul(var x, var y): return x.Calculate(v) * y.Calculate(v);
default: throw new InvalidOperationException();
}
}
|
それぞれに利点があり、どちらがいいかは「状況による」ということになる。その説明のためにこれら二者の特徴について説明していこう。それぞれの特徴を表3に示す。
仮想関数 | パターン マッチング | |
---|---|---|
実装がある場所 | 型の中 | 型の外 |
分岐の条件 | 1つの型だけを見る | 複雑な条件を書ける |
実行性能 | 若干よい | 若干不利 |
型の中に閉じていてよく、型だけを見た単純な分岐しかしない場合、仮想関数を使う方が実行性能面でも保守しやすさの面でもいいだろう。先ほどのCalculate
メソッドの例はこの条件を満たしているため、筆者の意見としては、仮想関数を使ったリスト9の書き方がよいと考える。
では、逆に、パターンマッチングの方が好ましいと思う例を挙げていこう。1つは、型の外に実装を書く方が好ましい場合だ。サンプルとして挙げているNode
クラスは、いわばモデル(=アプリの種類によらず共通して使えるロジック)に当たる型である。結果として、特定の種類のアプリでしか使わないような機能を書く場所としては不適切となる。例えば、式を文字列化する際、アプリによって望む形式が異なるだろう。この場合はNode
クラスの中よりも、特定の形式を使いたいアプリ側のどこかに実装があった方が好ましい。そして、型の中に書けないとなると、パターンマッチングの出番となる。一例として、MathML形式で文字列化するコードをリスト11に示す。
public static int ToMathML(this Node n, int v)
{
switch (n)
{
case Var(): return "<mi>x</mi>";
case Const(var value): return $"<mn>{value}</mn";
case Add(var x, var y): return $"{x.ToMathML(v)}<mo>+</mo>{y.ToMathML(v)}";
case Mul(var x, var y): return $"{x.ToMathML(v)}<mo>×</mo>{y.ToMathML(v)}";
default: throw new InvalidOperationException();
}
}
|
型の中に実装を持ちたくない場合はパターンマッチングの出番となる。
もう1つは、条件が複雑な場合だ。リスト8でも挙げたような「式の簡約化」がいい例だろう。リスト8の完全版という位置付けになるが、式の簡約化はリスト12のように書ける。条件がこのくらい複雑になると、仮想関数的な発想で分岐を書くのは困難だろう。
Node Simplify(Node n)
{
switch (n)
{
case Mul(Const(0), *): return new Const(0);
case Mul(*, Const(0)): return new Const(0);
case Mul(Const(1), var x): return x;
case Mul(var x, Const(1)): return x;
case Mul(Const(var l), Const(var r)): return new Const(l * r);
case Add(Const(0), var x): return x;
case Add(var x, Const(0)): return x;
case Add(Const(var l), Const(var r)): return new Const(l + r);
default: return n;
}
}
|
型の分解
パターンマッチングのもう1つの側面は型の分解である。この用途に限って言うと、is
演算子やswitch
ステートメント/式のような条件分岐は必要ない場合もある。型はコンパイル時点で分かっていて、単に位置指定パターンやプロパティパターンを使ったメンバーの抽出だけを行いたい場合だ。そしてこの場合、常に条件を満たすことが分かっているのにわざわざis
演算子を使うのも煩雑すぎるだろう。
そこで、パターンマッチングと合わせて、型の分解用にlet
ステートメントというものが導入される。let
ステートメントの最大の用途は前回説明したタプル型の分解になるだろう。例として、前回の説明で使ったTally
メソッド(戻り値が(int sum, int count)
というタプル型)の結果を、リスト13に示す。
// var パターンでの分解
let (var sum, var count) = Tally(items);
// 型パターンやワイルドカードでの分解
let (int sum, *) = Tally(items);
|
let
キーワードの直後には、「パターンの種類」で説明した各種パターンを書ける。パターンに合致しているかどうかはコンパイル時に確認され、合致していなければコンパイルエラーとなる。
【補足】クエリ式のlet句と読み取り専用なローカル変数
ちなみに、let
キーワードはクエリ式のlet
句ですでに使われているキーワードだ。このクエリ式のlet
句も、ここで説明したlet
ステートメントに合わせて、パターンを受け付けられるように拡張する提案も出ている。
一方で、現在のlet
句は、クエリ式中で使える変数を導入する構文で、通常の代入ステートメントに近い。ただ、代入ステートメントと違って、let
句で導入した変数は書き換えられない(読み取り専用)。let
ステートメントも、リスト14に示すように、この挙動に合うようにする予定である。
// var パターン(let var x)の省略形という意味で、以下の構文を許す
let x = 10;
// let ステートメントで導入した変数は書き換えられない
x = 20; // コンパイル エラー
|
C#では、ローカル変数を読み取り専用にできるようにしてほしいという要望はずっとあった。readonly
キーワードを使った構文(readonly var x = 10
)なども検討されたが、最終的には、let
句との一貫性や、パターンを使った分解との兼ね合いで、リスト14の構文を使うことになりそうである。
まとめ
今回はパターンマッチングの構文と用途について説明した。パターンマッチングはいわゆる関数型言語でよく見られる構文で、オブジェクト指向言語ではこれまであまり見られなかった。だからといってオブジェクト指向言語になじまないわけでも、パターンマッチングの方が新しく一方的に優れた構文というわけでもなく、既存の構文と使い分けていくことになるだろう。
パターンマッチングでは以下のようなことができる。
- 型による分岐: 仮想関数ではできないような複雑な条件での分岐や、型の外での実装ができる
- 型の分解: 構築(コンストラクターやオブジェクト初期化子)の逆操作ができる
次回は、パターンがどう評価されるかといった実装寄りの話や、複合型(クラスや構造体)以外(配列やタプル型など)に対するパターンなどについて説明する予定である。
岩永 信之(いわなが のぶゆき)
※以下では、本稿の前後を合わせて5回分(第1回~第5回)のみ表示しています。
連載の全タイトルを参照するには、[この記事の連載 INDEX]を参照してください。
1. オープンソースのC#/Roslynプロジェクトで見たこと、感じた教訓
日本を代表する「C#(でぐぐれ)」の人、岩永信之氏によるコラムが遂に登場。今回はオープンソースで開発が行われているC#と開発者の関わり方について。
2. 次期C# 7: 式の新機能 ― throw式&never型/式形式のswitch(match式)/宣言式/シーケンス式
C# 6が出てまだ間もないが、すでに次バージョン「C# 7」についての議論が進んでいる。その中で提案されている「式」に関する新機能を取り上げる。
3. 次期C# 7: 複数データをまとめるための言語機能、タプル型
メソッドが複数の値を戻す場合など、複数のデータを緩くまとめて、扱いたい場合はよくある。C#の次バージョンではこれを簡潔に記述するための機構として「タプル型」が導入される。