概要
C# の言語機能のいくつか(というか結構多くのもの)は、「所定のパターンを満たしている任意の型に使える」というものになっています。 そういう構文を指して「パターン ベース」(pattern-based)な構文と言ったりします。
本項では、パターン ベースにすることのメリットや、 実際にパターン ベースになっている構文について紹介します。
パターン ベース
例えば C# 3.0 のクエリ式がパターン ベースな構文の代表例です。 以下のような書き方をした場合、
from x in source
where x < 10
select x * x;
C# コンパイラーが以下のようなメソッド呼び出しに展開します。
source
.Where(x => x < 10)
.Select(x => x * x);
C# コンパイラーは「select
句を見たらSelect
メソッドに置き換える」というルールだけを提供していて、
Select
メソッドをどう実装するかは自由にできます。
クエリ式に関しては本当にかなり自由度が高く、以下のように、だいぶ緩い条件で使えます。
- (
Select
メソッドなどを定義した)インターフェイス不要 - 戻り値の型には何の制約もない
- インスタンス メソッドでも拡張メソッドでもいい
要するに、 「所定のパターンを満たしている任意の型に使える」ということです。 このこと、特に1番目の「インターフェイス不要」を指して、「パターン ベース」(pattern-based)と言います。
(同じ単語が入っているのでちょっと紛らわしいですが、 パターン マッチングとは無関係です。)
パターン ベースな構文の対極
逆に、インターフェイスの実装が必須の構文が1つだけあって、using
ステートメントがそうです。
(ただし、C# 8.0 で、ref struct
に対してだけは緩和されています。)
using System;
struct Disposable
// インターフェイス実装が必須。
// 以下の行をコメントアウトするとコンパイル エラーになる。
: IDisposable
{
public void Dispose() { }
}
class Program
{
static void Main()
{
using (var d = new Disposable()) ;
}
}
また、パターン ベースの逆という意味では、 C# コンパイラーだけではできない言語機能もあります。 ジェネリクスやインターフェイスのデフォルト実装がそうで、 これらは新しい .NET ランタイム(ジェネリクスは .NET Framework 2.0 以降、インターフェイスのデフォルト実装は .NET Core 3.0 以降)が必要になります。
ただ、パターン ベースでは実現不可能でも、「.NET ランタイム的には昔から機能を持っていて、C# 上認められていなかっただけ」というものあります。 例えば C# 4.0 でオプション引数とジェネリクスの共変・反変性という機能が入りましたが、 ランタイム側はもっと昔から(それぞれ .NET Framework 1.0、2.0 時点で)対応していました。 なので、C# コンパイラーだけの更新で実現可能でした。
パターン ベースの利点
新しい構文をパターン ベースに実装するのには2つの利点があります。
- C# コンパイラーだけでできる/古い .NET ランタイム上でも動く
- 仮想呼び出しが挟まらない
C# コンパイラーだけでできる
パターン ベースな置き換えは C# コンパイラーだけでできる仕事になります。
「.NET プログラム」で説明しているように、 C# は、
- C# コンパイラーは、C# ソースコードを中間言語(IL)と呼ばれる汎用的な命令に翻訳する
- .NET ランタイムが IL を CPU 依存で高速な命令に置き換える
という2段階に分けてプログラムを実行しています。 C# コンパイラーがしている仕事の方が比較的楽で、 C# コンパイラーだけの修正で済むなら実装コストがだいぶ低いです。
実装コストの問題だけでなく、 古いランタイムでも動くというメリットもあります。 例えば、2017年リリースの C# 7.0 の機能(例えば分解)を使ったプログラムが、 2002年 リリースの .NET Framework 1.0 上でも動かせたりします。
(.NET Framework 1.0 は 2017年時点ですでにサポートも切れています。 サポート外のランタイム上ですら、新しい言語機能を使えたりします。)
仮想呼び出しを避ける
詳細は「[雑記] 仮想関数テーブル」で説明していますが、
virtual
なメソッドには、一段階テーブルをはさむコストが発生します。
また、付随して以下のようなコストがかかる場合があります。
例えば以下のような型があったとします。
interface IDeconstructibleTo2Ints
{
void Deconstruct(out int x, out int y);
}
struct Point : IDeconstructibleTo2Ints
{
public int X { get; }
public int Y { get; }
public Point(int x, int y) => (X, Y) = (x, y);
public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
}
この型は分解構文を使えるように作ってあります。
(分解は Deconstruct
メソッドの呼び出しに展開されます。)
分解もパターン ベースなので、インターフェイスは必須ではありません。
この型に対して、以下のような2つのメソッドを考えます。
どちらも Deconstruct
メソッドに展開されますが、
Sum1
はパターン ベース(Point
構造体のDeconstruct
が直接呼ばれる)、
Sum2
はインターフェイスを介しています。
// Point を直接分解。
// 最終的にインライン展開が働いて、単なる p.X + p.Y に展開される(ものすごく高速)。
static int Sum1(Point p)
{
var (x, y) = p;
return x + y;
}
// インターフェイスを介して分解。
// インライン展開が効かず、ボックス化も起きてるので遅い。
static int Sum2(IDeconstructibleTo2Ints p)
{
var (x, y) = p;
return x + y;
}
ベンチマークを取ってみればわかるんですが、
Sum1
は最適化によってほとんど消えることすらあって、計測できない(誤差しか残らない)くらい高速です。
一方、Sum2
はボックス化で24バイトのゴミが発生しますし、実行に数ミリ秒要します。
パターン ベースである(インターフェイスを要求しない)ことで、このくらいの速度差が生じます。
パターンの自由度
ということで、C# の構文の多くがパターン ベースな実装になっています。 ただ、実装された時期によってどのくらい自由が利くかに差があったりします。 (基本的には新しいものほど自由が利く。ただ、新しいものでも、他の構文との兼ね合いで制限が掛かる場合がある。)
一番自由が利くのはクエリ式です。
例えば、クエリ式(のwhere
とselect
)を使える最低限のコードを書くと以下のようになります。
(意味のあることはしていません。単に、クエリ式で使えるというだけです。)
using System;
struct Queryable
{
public Queryable Where(Func<int, bool> f) => this;
public Queryable Select(Func<int, int> f) => this;
}
class Program
{
static void Main()
{
var q =
from x in new Queryable()
where x < 10
select x * x;
}
}
これに対して、融通が利くポイントが2つあります。
例えば、上記のコードは以下のように書き換えてもコンパイルできます。
using System;
struct Queryable
{
public Queryable Where(Func<int, bool> f, params int[] dummy) => this;
}
static class QueryableExtensions
{
public static Queryable Select(this Queryable q, Func<int, int> f, int dummy = 0) => q;
}
class Program
{
static void Main()
{
var q =
from x in new Queryable()
where x < 10
select x * x;
}
}
パターン ベースな構文一覧
以下に、パターン ベースになっている構文の一覧を示します。
構文 | 拡張メソッド可 | オプション引数可 |
---|---|---|
クエリ式 | 〇 | 〇 |
コレクション初期化子 | 〇※1 | 〇 |
分解 | 〇 | × |
await | 〇 | × |
await foreach | 〇※4 | 〇 |
await using | × | 〇 |
foreach※2 | 〇※4 | × |
fixed | × | × |
using※3 | × | × |
※2 ループの最後に Dispose
が呼ばれるためにはインターフェイスの実装、もしくは、ref struct
である必要があります。
※3 ref struct
限定でパターン ベース。クラスや通常の構造体の場合はインターフェイスの実装が必須。
foreach
と await foreach
、using
と await using
の差は実装時期の差によるものです。
非同期版(await
付き)の方が C# 8.0 での実装と新しく、制限が緩和されています。
C# 8.0 の計画段階では既存の(同期の) foreach
や using
の制限緩和も検討されましたが、
破壊的変更になりそうとのことで断念されました。
ref struct
に対してだけは、
そもそも ref struct
自体が新しくて破壊的変更の影響が少ないことと、
ないと困るという理由で制限が緩和されています。