IList とかを IReadOnlyList とかから派生させたい
.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;
}
ということでインターフェイスを独立。
これなら「再コンパイルするまでは C は IReadOnlyCollection にはならない」というだけなので、
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 でこの作業をやろうという検討に入ったみたいです。
- API レビューをやった報告コメント
- 実験を試みる準備が整った
- .NET チームのプロダクト マネージャーのコメント
- 指摘されている破壊的変更はレアケースで、考えを変えるものではないと思う
- どの程度の破壊的変更になるか(許容できる範囲かどうか)、.NET 9 の初期に実験してみるのは十分妥当
- 修正 Pull Request
