C# 13 でのコレクション式 - 制限の緩和の話

C# 12 でコレクション式が入ったわけですが、 スケジュールの都合で「C# 12 後に改めて検討する」ということになった機能がたくさんあります。 C# 12 リリース(2023/11)直後から再検討が始まっていて、先月にはある程度まとまった計画が出ています。

量が多いのでちょっとずつ取り上げ…

  • ディクショナリ式
  • 自然な型
  • インラインなコレクション式
  • コレクションに対する拡張メソッド
  • 現状でコレクション式に対応してない型
  • 非ジェネリックなコレクションのサポート
  • 制限の緩和 ← 今日はこれ

制限の緩和

今、コレクション式の要素の型は IEnumerable<T>T で判定しています。

using System.Collections;

foreach (var x in new A()) ; // この x は int

// Add(int) だけあればよさそうに見えるのに、
// 実際には IEnumerable<int> をみて「int のコレクション」と判断してる。
A a = [1];

// foreach すると int を列挙する型。
class A : IEnumerable<int>
{
    IEnumerator<int> IEnumerable<int>.GetEnumerator() => throw new NotImplementedException();
    IEnumerator IEnumerable.GetEnumerator() => throw new NotImplementedException();
    public void Add(int x) { }
}
// foreach はインターフェイスがなくても GetEnumerator っていう名前のメソッドさえ持っていれば OK なのに。
foreach (var x in new A()) { }

// これはダメになる。
A a = [1];

// インターフェイスを削るとコレクション式で使えなくなる。
class A
{
    public IEnumerator<int> GetEnumerator() => throw new NotImplementedException();
    public void Add(int x) { }
}
using System.Collections;

// foreach なんとか OK。
// non-generic な GetEnumerator が呼ばれてるので object を介してるけど…
foreach (int x in new A()) { }

// 旧来のコレクション初期化子は使えるのに…
A a1 = new() { 1 };

// コレクション式はダメになる。
A a2 = [1];

// non-generic インターフェイスに変えると?
class A : IEnumerable
{
    public IEnumerator GetEnumerator() => throw new NotImplementedException();
    public void Add(int x) { }
}

ちなみに、この「IEnumerable<T>T」以外は受け付けなかったりします。 これも、コレクション初期化子時代はできたこと。

using System.Collections;

// 旧来のコレクション初期化子は string を受け付けるのに…
A a1 = new() { 1, "2" };

// コレクション式はダメになる。
A a2 = [1, "2"];

// Add だけは string 受付。
class A : IEnumerable<int>
{
    public void Add(int x) { }
    public void Add(string x) { }

    IEnumerator<int> IEnumerable<int>.GetEnumerator() => throw new NotImplementedException();
    IEnumerator IEnumerable.GetEnumerator() => throw new NotImplementedException();
}

これが、非ジェネリックな IEnumerable を使うと object のみ受け付けるようになるみたいです。 しかもこれ、 Visual Studio 17.10 以前であれば受け付けていたコードがコンパイル エラーになるというひと悶着あり。

using System.Collections;

// 旧来のコレクション初期化子は string を受け付けるのに…
A a1 = new() { 1, "2" };

// これ、ちょっと前まで受け付けていたらしい。
// Visual Studio 17.10 Preview 1 だとエラー。
A a2 = [1, "2"];

// non-generic なインターフェイスを実装。
class A : IEnumerable
{
    public void Add(int x) { }
    public void Add(string x) { }

    IEnumerator IEnumerable.GetEnumerator() => throw new NotImplementedException();
}

意図した破壊的変更 (たぶん、1/8 の LDM での決定)だそうですが、 本当にこの変更をしてよかったのかどうか。 こういう非ジェネリック IEnumerable だけ実装して、Add でちゃんとした型を指定しているクラス、 WPF とか WinForms には結構あって、それが突然コンパイルできなくなったものでちょっとした混乱が起きています。

ちなみに、この変更の理由は、こうしておかないと params コレクションを使った時のオーバーロード解決のコストが高くなるからだそうです。 制限を緩めるとして、もしかしたら「コレクション式では使えるけども params コレクションでは使えない」みたいな状況が増えるかもしれません。

一方、そもそもとして IEnumerable 実装は必要なのかという問題が。 何せ、コレクションを作る時点では GetEnumerator は要らず、CollectionBuilder 属性で指定した Create メソッドだけあれば事足ります。 例えば、型によっては「別のコレクションを作るための足掛かりにするもので、直接列挙はしない」みたいなものがあります。 (実際、Roslyn チーム自身が1件そういう問題を踏んだりしています: CSharpTestSourceSyntaxTree[] を作るために使っていて、この型自体からの列挙はしない)。

ということで、CollectionBuilder 属性指定のコレクション型の場合、 Create メソッドの引数の ReadOnlySpan<T> から要素の型を決めようという提案が出ています。