.NET が長らく抱えている「なぜ IList<T>IReadOnlyList<T> ではないのか」問題、 .NET 9 で解消するかもしれないみたい。

ちなみに、問題を抱えるに至った原因は IReadOnlyList<T> が後付けということです。 1から作り直すのであれば、誰がどう考えても IList<T>IReadOnlyList<T> から派生させるのが自然です。 それがかえって、IReadOnlyList<T> 導入以降に .NET 利用を始めた人に混乱を招いているというのが現状になります。

当初設計: インターフェイスは増やしすぎない

インターフェイスを増やすというのは、 型情報で DLL サイズが増えるとか、 実行時にインターフェイスを検索するコストが増えるとか、 多少なりともコストを生じます。

一方で、.NET Framework の最初のβ版が出たのは2000年ごろ、正式版で2002年なわけですが、 この頃は read-only であることの重要性が過小評価されていたと思います。 なので、重要でない(と当時は思われていた)ものにコストは掛けたくないという話に。

(この辺りのことは「.NETのクラスライブラリ設計」で触れられていたりします。 ちなみにこの本、要は「.NET の初期設計に関する懺悔本」です。)

そこで当時の設計としては「read-only / writeable なインターフェイスを1個用意して、IsReadOnly プロパティで書き込み出来るかどうかを調べる」という作りでした。

namespace System.Collections;

public interface IList : ICollection, IEnumerable
{
    object this[int index] { get; set; }
    bool IsReadOnly { get; } // ← これ
    void Add(object value);
    // 以下略
}

.NET Framework 2.0 (2005年)にジェネリクスが導入されてもまだこの思想は引き継がれます。 まあ、旧来インターフェイスとジェネリック インターフェイスで思想が違うのも混乱しそうですし。

namespace System.Collections.Generic;

public interface ICollection<T> : IEnumerable<T>, IEnumerable
{
    int Count { get; }
    bool IsReadOnly { get; } // ← これ
    void Add(T value);
    // 以下略
}

問題になり始めたのは C# 4.0 (2010年)で共変性を得てからでして。 読み書き両方できてしまう IList<T>ICollection<T> では、以下のような共変な代入ができません。

IList<string> str = new List<string>();
IList<object> obj = str; // ダメ。

// そりゃ、こういうコード書かれたらまずいので当然。
obj.Add(1);

そこで .NET Framework 4.5 (2012年)では read-only 系のインターフェイスが導入されます。

IReadOnlyList<string> str = new List<string> { "abc" };
IReadOnlyList<object> obj = str; // read-only なら共変。

// obj.Add(1); とか書かれる心配がない。
// 読むだけなら安全。
Console.WriteLine(obj[0]);

インターフェイスへの親インターフェイスの追加・メンバー移動は破壊的変更

2012年に追加された read-only 系インターフェイスですが、元々あったインターフェイスとは独立しています。 残念ながら「IList<T>IReadOnlyList<T> ではない」という状態。

namespace System.Collections.Generic;

public interface IReadOnlyCollection<out T> : IEnumerable<T>
{
    int Count { get; }
}

public interface ICollection<T> : IEnumerable<T>
{
    // IReadOnlyCollection とは独立に Count を持つ。
    int Count { get; }
    // 以下略
}

public interface IReadOnlyList<out T> : IReadOnlyCollection<T>
{
    T this[int index] { get; }
}

public interface IList<T> : ICollection<T>
{
    // IReadOnlyList とは独立に this[int] を持つ。
    T this[int index] { get; set; }
    // 以下略
}

普通に考えて、1から作るのであれば以下のようにします。

namespace System.Collections.Generic;

public interface IReadOnlyCollection<out T> : IEnumerable<T>
{
    int Count { get; }
}

public interface ICollection<T> : IReadOnlyCollection<T> 
{
    // 以下略
}

ところが、後付けでこういうことをするのは破壊的変更になります。

例えば以下のようなコードがあったとします。

// バージョン1

// corelib.dll
interface ICollection
{
    int Count { get; }
}

// corelib とは別のプロジェクトで、別の開発者が保守
// mylib.dll
class C : ICollection
{
    public int Count => 0;
}

ここに IReadOnlyCollection を「理想的な状態」で導入したくて Count を移動させると mylib を壊します。

// バージョン2

// corelib.dll
interface IReadOnlyCollection
{
    int Count { get; }
}

interface ICollection : IReadOnlyCollection
{
    // Count は IReadOnlyCollection に移した。
}

// corelib とは別のプロジェクトで、別の開発者が保守
// mylib.dll
class C : ICollection
{
    // 再コンパイルするなら平気。
    // ただ、古い dll のまま使うと「IReadOnlyCollection.Count がない」と怒られる。
    // 再コンパイルするまでは C が持ってるのは ICollection.Count。
    public int Count => 0;
}

ということでインターフェイスを独立。 これなら「再コンパイルするまでは CIReadOnlyCollection にはならない」というだけなので、 DLL のロードに失敗したりはしません。

// corelib.dll
interface IReadOnlyCollection
{
    int Count { get; }
}

interface ICollection
{
    int Count { get; } // IReadOnlyCollection と機能がダブってるけど許して
}

// corelib とは別のプロジェクトで、別の開発者が保守
// mylib.dll
class C : ICollection, IReadOnlyCollection // 2個とも実装
{
    public int Count => 0;
}

これが .NET のコレクション系インターフェイスの現状になります。

インターフェイス メソッドのデフォルト実装

.NET Core 3.0 (2019年)にデフォルト実装というものが導入されて、インターフェイスへのメンバー追加での破壊的変更を避けれるようになりました。

この機能を使えば先ほどの「既存クラスが IReadOnlyCollection.Count を実装していない」問題は解消できます。 (親インターフェイスの追加は、「メンバー追加」の一種なのでデフォルト実装で対処できます。)

// corelib.dll
interface IReadOnlyCollection
{
    int Count { get; }
}

interface ICollection : IReadOnlyCollection
{
    new int Count { get; } // IReadOnlyCollection.Count とは別の Count にはなっちゃう。

    // IReadOnlyCollection のことを知らない既存クラスのために、
    // 既存クラスに代わって ICollection 内で IReadOnlyCollection.Count を実装。
    int IReadOnlyCollection.Count => Count;
}

// corelib とは別のプロジェクトで、別の開発者が保守
// mylib.dll
class C : ICollection
{
    // 再コンパイルするまではあくまで ICollection.Count。
    // それでも、ICollection 側で IReadOnlyCollection.Count を実装してくれているので平気。
    //
    // ちなみに、再コンパイルするとこの Count をもって
    // ICollection.Count と IReadOnlyCollection.Count の両方を実装。
    public int Count => 0;
}

ということで、インターフェイスのデフォルト実装の導入後、 ついに ICollection<T>IReadOnlyCollection<T> 派生に、 IList<T>IReadOnlyList<T> 派生にできるのではないかと多くの期待が寄せられています。 実際、2019年に提案あり:

ただ、厳密にはこれも破壊的変更を起こす可能性はあったりします。 というのも、デフォルト実装には「ダイアモンド継承」問題というものがあります。 以下のような感じで、「分かれ道からの合流がある継承」をやると問題を起こすことがあります。

interface IA
{
    int M();
}

interface IB : IA
{
    int IA.M() => 1; // デフォルト実装持ち
}

interface IC : IA
{
    int IA.M() => 2; // デフォルト実装持ち
}

// IA.M の実装をデフォルト実装に頼るとして、
// IB の実装と IC の実装のどちらを使えばいいか不明瞭。
class C : IB, IC
{
}

まあ、前述の ICollection に「分かれ道」はないので誰しもがこの問題を踏むわけではないんですが。 1段自作のインターフェイスとかを挟んでいると問題を踏む可能性が出てきます。 例えば以下のような感じ。

// corelib とは別のプロジェクトで、別の開発者が保守
// anotherlib.dll
interface ICustomReadonlyList : IReadOnlyCollection
{
    // 何らかのデフォルト実装持ち
    int IReadOnlyCollection.Count => 0;
}

// corelib とも anotherlib とも別のプロジェクトで、別の開発者が保守
// mylib.dll
class C : ICollection, ICustomReadonlyList
{
    // ICollection 更新前: 
    //   ICollection.Count は明示的に実装
    //   IReadOnlyCollection.Count は ICustomReadonlyList 側のデフォルト実装を使用
    //
    // ICollection 更新後: 
    //   ICollection.Count は明示してるから平気
    //   IReadOnlyCollection.Count は ICustomReadonlyList と ICollection のどちらのデフォルト実装を使えばいいかわからない
    //   (ソースコードも修正しないと再コンパイルも失敗)
    int ICollection.Count => 1;
}

この辺りの懸念もあって、しばらく塩漬けが続きます。

ついに動きが

そして時は流れること4年、ついに動きが。 .NET 9 でこの作業をやろうという検討に入ったみたいです。