C# 3.0 から拡張メソッドが使えるわけですが、 もうちょっといろんな「拡張」をしたいという話が前々からあります。 例えば以下のような要求。
- 既存の型に静的メンバーも足したい
- プロパティや演算子も足したい
- インターフェイスの後付けもしたい
今では Extensions とか呼ばれていまして、以下の issue でトラッキング中。
ここからさかのぼって、かつては Extension everything とか呼ばれていたり、 個別に「インターフェイスを実装したい」「演算子を拡張したい」など個別の issue がありました。
2015年(Roslyn が GitHub での公開に切り替わった年)にはすでにそんな話が出ています。
結構大きな機能なのでしり込みしていたみたいですが、 去年くらいから Working Group (この機能の追加を推進するメンバーを割り当てて、定期的にミーティング)を設けて作業を始めました。
うちのブログでも去年、1度取り上げています。
もう9年も経ってしまい、C# 12 でも入らなかったわけですが、 ついに今年、C# 13 には一部入りそう(インターフェイスの後付けだけは無理そう)な雰囲気になっています。
最近の話題のうちいくつかを取り上げると、以下のような話が出ています。
-
段階的に実装していく
- 静的メンバー → インスタンス メンバー → 継承のサポート (C# 13 でやれそうなのはここまで) → インターフェイスの後付け
-
普通の構造体でラッパー型を作って、利用時に Unsafe.As で変換してメンバーを呼ぶ
- 型消去な実装
- インターフェイス実装は大変そう
-
メンバーのルックアップ
- クラスの継承時の挙動に準ずる
- 旧拡張メソッドと新 Extensions は優先度をつけない(どちらにも同名メソッドがあった場合、コンパイル エラーにする)案が今のところ優勢
extension 構文
ということで、改めて Extensions の話を。 今、以下のような構文を足そうとしています。
// 拡張の構文例。 implicit extension SomeExtension for SomeClass : IEquatable<SomeExtension> { // 追加したいメンバーを書く。 // 1. 静的メンバーも書ける。 public static int Y => X * X; // 2. メソッド以外も書ける。 public int Property { get => GetValue(); set => SetValue(value); } public int this[int index] => GetValue(index); // 3. インターフェイスの実装を持てる。 public bool Equals(SomeExtension? other) => Property == other?.Property; } // 拡張の対象の例。 class SomeClass { // (中身は適当。) public static int X = 123; private int _value; public int GetValue() => _value; public void SetValue(int value) => _value = value; public int GetValue(int index) => _value * index; }
ちなみに、「インターフェイスの実装を持つ」には少し難題があって、 C# 13 時点では入らない可能性がかなり高いです。
普通の構造体 + Unsafe.As
拡張はラッパー構造体を使った実装になりそうです。 一時期は以下のような ref struct を使った実装になりそうだったんですが、 この案は結局没になりました。
var value = new SomeStruct(); var extension = new SomeExtension(ref value); // 拡張プロパティを呼び出す。 extension.Property = 123; // ちゃんと元インスタンスに値が反映。 Console.WriteLine(value.GetValue()); ref struct SomeExtension(ref SomeStruct @this) { ref SomeStruct @this = ref @this; public int Property { get => @this.GetValue(); // ref で持ってるので、引数でもらった構造体に書き換えが反映される。 set => @this.SetValue(value); } } // デモ用に構造体に変更。 struct SomeStruct { private int _value; public int GetValue() => _value; public void SetValue(int value) => _value = value; public int GetValue(int index) => _value * index; }
この案に変わって、普通の構造体 + Unsafe.As を使う路線で考えているそうです。
using System.Runtime.CompilerServices; var value = new SomeStruct(); // Unsafe.As を使って、value 値が入っているの場所を無理やり SomeExtension で解釈。 ref var extension = ref Unsafe.As<SomeStruct, SomeExtension>(ref value); // 拡張プロパティを呼び出す。 extension.Property = 123; // extension の参照先が value なので、ちゃんと value が書き変わる。 Console.WriteLine(value.GetValue()); // 普通の構造体。 struct SomeExtension { private SomeStruct @this; public int Property { get => @this.GetValue(); set => @this.SetValue(value); } } // SomeStruct は先ほどと同じ。
型消去
Extensions は普通の型と同じように使えたりします。
(特に、explicit
を付けた Extensions はむしろ「型を明示しないと使えない」状態になります。)
なのでこれを拡張型(extension types)と呼んだりもします。
で、前節の通り Extensions のコンパイル結果はラッパー構造体だったりするわけですが、 このラッパー構造体への変換(Unsafe.As)はあくまでメンバー参照のタイミングで行われます。 メソッドの引数などに拡張型を書くと、実際には「元の型 + 属性」(いわゆる「型消去」方式)になる予定です。 例えば、以下のようなメソッドを書いたとして、
static int Sum(SomeExtension a, List<SomeExtension> b) { var sum = a.Property; foreach (var x in b) sum += x.Property; return sum; }
以下のような類のコードに置き換わる予定です。
static int Sum( // SomeExtension は属性の中にしか残らない。 // 元の、 SomeStruct に置き換わる。 [Extension(typeof(SomeExtension))] SomeStruct a, [Extension(typeof(SomeExtension))] List<SomeStruct> b) { // メンバーアクセスするところで Unsafe.As var sum = Unsafe.As<SomeStruct, SomeExtension>(ref a).Property; foreach (var x in b) sum += Unsafe.As<SomeStruct, SomeExtension>(ref Unsafe.AsRef(in x)).Property; return sum; }
変性を持っていない List<T>
で、
List<SomeStruct>
を List<SomeExtension>
に変換する手段は通常全くありません。
型消去で List<SomeExtension>
が List<SomeStruct>
に置き換わることで、
List<SomeStruct>
型の変数を List<SomeExtension>
型の引数に渡せるようになっています。
メンバーのルックアップ(継承)
拡張型は元となる型との間には、クラスの継承関係と似た関係が成り立ちます。
なので、メンバーのルックアップのルールも「クラスの継承に準ずる」で行きたいそうです。
例えば、派生クラスから基底クラスのメンバーを何の修飾もなしで(this.
とか base.
が必須ではなく)参照できるように、
拡張型から元となる型のメンバーも修飾なしで参照できます。
おさらい的に、「継承があるときのルックアップ」の例をいくつか紹介しておきます。 (拡張型中で元となる型と同名のメンバーを書くとこれに準ずることになると思われます。)
近い側優先:
class Base { public void M(int x) { } } class Derived : Base { public new void M(int x) { } public void M() { // 近い側優先なので、Derived.M が呼ばれる。 M(1); } }
もうちょっとわかりにくい例:
class Base { public void M(int x) { } } class Derived : Base { public new void M(object x) { } public void M() { // わかりにくいけども、Derived.M(object) の方が呼ばれる。 // 引数の型を考えると Base.M(int) が呼ばれそうに見えるけども、そうはならない。 // (「元々はなかったけど後から Base の方に M(int) が追加された」みたいな状況で破壊的変更にならないようにするため。) M(1); } }
メンバーのルックアップ(拡張同士)
あと、既存の拡張メソッドには以下のような優先度があります。
namespace Ex1 { static class AExtension { public static void M(this App1.A _) => Console.WriteLine("Extension in Ex1"); } } namespace App1 { class A { public void M() => Console.WriteLine("Instance"); } class Program { public static void Main() { // インスタンス メソッド優先。 new A().M(); // Instance } } }
namespace Ex1 { static class AExtension { public static void M(this App1.A _) => Console.WriteLine("Extension in Ex1"); } } namespace App1 { class A; static class AExtension { public static void M(this A _) => Console.WriteLine("Extension in App1"); } class Program { public static void Main() { // 同じ名前空間内の拡張メソッド優先。 new A().M(); // in App1 } } }
using Ex1; namespace Ex1 { static class AExtension { public static void M(this App1.A _) => Console.WriteLine("Extension in Ex1"); } } namespace Ex2 { static class AExtension { public static void M(this App1.A _) => Console.WriteLine("Extension in Ex1"); } } namespace App1 { using Ex2; class A; class Program { public static void Main() { // 内側で using した方優先。 new A().M(); // in Ex2 } } }
namespace Ex1 { static class AExtension { public static void M(this App1.A _) => Console.WriteLine("Extension in Ex1"); } } namespace Ex2 { static class AExtension { public static void M(this App1.A _) => Console.WriteLine("Extension in Ex1"); } } namespace App1 { using Ex1; using Ex2; class A; class Program { public static void Main() { // 優劣がない場合はコンパイル エラー。 new A().M(); } } }
新しい拡張型でも同様のルールになると思われます。
一方で、旧「拡張メソッド」と新「拡張型」に優劣をつけるかという議題もありますが、 現状は「優劣つけない」という方向で検討されています。 というか、新旧混在した時点でコンパイル エラーにしようかという話もあるみたいです。
namespace Ex1 { static class AExtension { public static void M(this App1.A _) => Console.WriteLine("old extension method"); } } namespace Ex2 { implicit extension AExtension for A { public void M() => Console.WriteLine("new extension type"); } } namespace App1 { using Ex1; // これが外にあってもエラーにする案もあり using Ex2; class A; class Program { public static void Main() { // 優劣を付けない(コンパイル エラーになる)。 // 何なら新旧混在している時点でコンパイル エラーにする可能性濃厚。 new A().M(); } } }
インターフェイス実装
ここまでの話は割かし C# 13 で入りそうな話なんですが、 最後に1つ、13では入らなさそうなのがインターフェイス実装の後付けです。
これまでの話どおり、ラッパー構造体を作る方針で少し考えてみましょう。
インターフェイス実装に関する部分だけ残して、以下のようにしたとします。
var value = new SomeClass { Value = 1 }; SomeExtension extension = value; extension.Equals(new SomeClass { Value = 1 }); explicit extension SomeExtension for SomeClass : IEquatable<SomeExtension> { public bool Equals(SomeExtension? other) => Value == other?.Value; } class SomeClass { public int Value; }
ラッパー構造体で展開するとしたら以下のようになります。
using System.Runtime.CompilerServices; var value = new SomeClass { Value = 1 }; ref var extension = ref Unsafe.As<SomeClass, SomeExtension>(ref value); var temp = new SomeClass { Value = 1 }; // こういう風に直接インターフェイス メンバーを呼ぶ分には特に問題なさげ。 extension.Equals(Unsafe.As<SomeClass, SomeExtension>(ref temp)); struct SomeExtension : IEquatable<SomeExtension> { private SomeClass Value; public bool Equals(SomeExtension other) => Value.Value == other.Value?.Value; } class SomeClass { public int Value; }
この例はインターフェイス実装しているといっても、そもそもメンバーを直接呼んでいるので問題がないだけです。 問題は以下の状況。
- インターフェイス型や
object
型の変数で受けてボックス化する場合 - ジェネリック メソッドに渡す場合
まず、インターフェイス型の変数で受けてみましょう。
ReferenceEquals
や is
判定であまり期待通りとは言えない挙動を起こします。
using System.Runtime.CompilerServices; var value = new SomeClass { Value = 1 }; ref var extension = ref Unsafe.As<SomeClass, SomeExtension>(ref value); // インターフェイスに渡そうとすると、この実装だとボックス化が発生。 IEquatable<SomeExtension> boxedExtension = extension; // インスタンスが一致しなくなる。 Console.WriteLine(ReferenceEquals(value, boxedExtension)); // false // ダウンキャストが失敗する。 Console.WriteLine(boxedExtension is SomeClass); // false
ジェネリク メソッドでは、以下のように、元の型と拡張型の両方の型情報を使う必要がでてきます。
var value = new SomeClass { Value = 1 }; List<SomeClass> list = [new() { Value = 2 }, new() { Value = 1 }, new() { Value = 0 }]; // SomeClass のままだと IEquatable 制約を満たさなくて呼べない。 var i1 = IndexOf<SomeClass>>(list, value); // これなら呼べるようになるはず。 // ただ、list は List<SomeClass> なので、やっぱり型消去が必要。 // 型引数が暗黙的に SomeClass と SomeExtension の2つに増えるような処理が必要。 var i2 = IndexOf<SomeExtension>(list, value); static int IndexOf<T>(List<T> list, T value) where T : IEquatable<T> { // 今の型システムだと T が通常の型か拡張型かを知るすべはなく、Unsafe.As 展開ができない。 for (int i = 0; i < list.Count; i++) if (list[i].Equals(value)) return i; return -1; }
いずれも、C# コンパイラー上のトリックでは問題を解消できなさそうで、 .NET ランタイムの型システムに手を入れる必要が出てきそうです。 型システムに手を入れるとなると結構大ごとなので、C# 13 で実現する見込みは残念ながらほぼありません。