はじめに
先日、下記記事を公開しました。
【C#】インターフェイスの利点が理解できない人は「インターフェイスには3つのタイプがある」ことを理解しよう
https://qiita.com/yutorisan/items/d28386f168f2f3ab166d
この記事でIReadOnly系インターフェイスの役割と重要性について簡単に説明し、
Listをそのまま渡すのではなく、IReadOnlyListとして渡すだけで、渡した先で勝手に書き換えられる危険性が皆無になります
と記述しましたが、これに対して「IReadOnlyListではIListにキャストされてしまうので、変更される危険性が皆無ではない。本当に皆無にしたければImmutableListで公開すべき」とのご意見をいただきました。
ImmutableListのことは正直あまり知らなかったので、調べてみると「不変なコレクション」とのこと。
ReadOnlyCollectionとどう違うんだ…?と思ったので、それぞれ違いを整理しました。
IReadOnlyList<T>インターフェイス
IReadOnlyList<T>は、ListのReadOnlyなインターフェイスです。
定義は次のようになっています。
public interface IReadOnlyList<out T> : IEnumerable<T>, IEnumerable, IReadOnlyCollection<T>
{
T this [int index] {
get;
}
}
IReadOnlyList<T>が継承1しているIReadOnlyCollection<T>の定義はこうです。
public interface IReadOnlyCollection<out T> : IEnumerable<T>, IEnumerable
{
int Count {
get;
}
}
つまり、インデクサによる値の取得 と 要素数の取得 、それから IEnumerator<T>の取得(foreachで回す) だけができるコレクションというわけですね。
List<T>をIReadOnlyList<T>で渡すと、コレクション変更系メソッドが呼べなくなるので安全ですよ、というインターフェイスです。
問題点:キャストされるとReadOnlyじゃなくなる
さて、ここからが本題で、冒頭で記述した「キャストされたら普通に変更されちゃう問題」についてです。
IReadOnlyList<T>はList<T>のインターフェイス2なので、 List<T>にキャストされてしまえば普通に変更できちゃう という問題です。
以下の例をご覧ください。
class Program
{
static void Main(string[] args)
{
IReadOnlyList<int> ireadonlylist = new List<int>() { 1, 2, 3 };
ReadOnlyBreaker.Break(ireadonlylist);
foreach (var item in ireadonlylist)
{
Console.WriteLine(item);
}
}
}
class ReadOnlyBreaker
{
//IReadOnlyListで受け取っても…
public static void Break(IReadOnlyList<int> rolist)
{
//キャストしてしまえばAddできる
(rolist as IList<int>).Add(100);
}
}
1,2,3でList<int>を初期化して、IReadOnlyList<int>にして渡しても、受け取った側が勝手にIList<int>にキャストすればAddできてしまうという例です。
実行結果がこちら。
1
2
3
100
IReadOnlyList<int>で渡したのに、100がAddされてしまっています。
これは、IReadOnlyList<T>がインターフェイスであるがゆえ、必ず具象クラスが存在するので、もともとの具象クラス側で変更を許可しているならば無理やり変更できるという、悲しい問題です。
しかし、普通のプログラマーであれば「IReadOnlyList」と書いてあれば、「あ、このコレクションは変更してはいけないんだな」と理解できる ため、わざわざキャストしてまで無理やり変更されることは少ないと思います。
したがって、決して IReadOnlyList<T>では危険だ!と主張しているわけではありません のでご留意ください。
しかしながら、汎用的なライブラリとして公開するなど、どんな使い方をされるかわからない場合や、キャストによる変更される危険もなくしたいと言った場合は、次に紹介するReadOnlyCollection<T>クラスまたはImmutableList<T>クラスを使用すれば、この問題を解決することができます。
ReadOnlyCollection<T>クラス
ReadOnlyCollection<T>クラスは、List<T>をラップして読み取り専用なメンバのみ外部に公開するクラスです。
このクラスを生成するにはAsReadOnly()メソッドを利用するか、IList<T>を引数にとったコンストラクタを利用します。
List<int> list = new List<int>() { 1, 2, 3 };
//AsReadOnly()による生成
ReadOnlyCollection<int> readonlyCollection = list.AsReadOnly();
//コンストラクタによる生成
ReadOnlyCollection<int> readonlyCollection = new ReadOnlyCollection<int>(list);
IReadOnlyList<T>との違い
IReadOnlyList<T>はインターフェイスで、 実体としてはあくまでも具象クラスを参照している ため、具象クラスにキャストされればList<T>が変更される危険があります。
ところが、この ReadOnlyCollecion<T>はインターフェイスではなく、元のList<T>とはまた別のクラス なので、キャストされる心配がありません。
具体的には、元となるList<T>の参照を内部で持ち、読み取り専用のメンバのみを外部に公開しています。
そのため、内部のList<T>は完全にプロテクトされており、変更される危険がないのです。
図による解説
ちょっと分かりづらいかと思いますので図を用意しました。
IReadOnlyList<T>の場合は次のようになります。
IReadOnlyList<T>はList<T>のうち読み取り専用のメンバのみを提供するインターフェイスです。
しかし、あくまでもList<T>に実装されたインターフェイスなので、キャストされればList<T>に直接アクセスされます。
対して、ReadOnlyCollection<T>は次のようになります。

ReadOnlyCollection<T>は、インターフェイスではなく実体があるクラスで、内部に元となるList<T>の参照を持っています。
ReadOnlyCollection<T>はList<T>のメンバのうち読み取り専用メンバのみを外部に公開するため、List<T>自体は安全が保たれます。
さらに、外部からアクセスされるReadOnlyCollection<T>は内部のList<T>とは独立した存在なので、キャストによって内部のList<T>が変更される危険はありません。
ImmutableList<T>クラス
ImmutableList<T>クラスも、ReadOnlyCollection<T>と同様に、元となるList<T>を内部にラップします。
このクラスを生成するには、ToImmutable()メソッドを利用するか、ImmutableList.CreateRange(IEnumerable<T>)ファクトリメソッドを利用します。コンストラクタによる生成はできません。
List<int> list = new List<int>() { 1, 2, 3 };
//ToImmutable()による生成
ImmutableList<int> immutableList = list.ToImmutable();
//ImmutableList.CreateRangeによる生成
ImmutableList <int> immutableList = ImmutableList.CreateRange(list);
参照ではなくコピーをラップする
ReadOnlyCollection<T>との違いは、 参照ではなくコピーをラップする ことです。
参照ではなくコピーをラップするため、ImmutableList<T>を生成したあとに元となるコレクションに変更を加えても、ImmutableList<T>の読み出し値は変わりません。
以下のコードで、ReadOnlyCollection<T>とImmutableList<T>を両方生成したあとに、元のコレクションに変更を加えて、動作の違いを確認してみます。
static void Main(string[] args)
{
List<int> list = new List<int>() { 1, 2, 3 };
ReadOnlyCollection<int> readonlyCollection = list.AsReadOnly();
ImmutableList<int> immutableList = list.ToImmutableList();
//ReadOnlyCollection, ImmutableListを生成したあとに元となるコレクションに変更を加える
list.Add(100);
Console.WriteLine("ReadOnlyCollection:");
foreach (var item in readonlyCollection)
{
Console.WriteLine(item);
}
Console.WriteLine("ImmutableList:");
foreach (var item in immutableList)
{
Console.WriteLine(item);
}
}
ReadOnlyCollection:
1
2
3
100
ImmutableList:
1
2
3
ReadOnlyCollection<T>のほうは、インスタンス生成後に元となるコレクションに変更が加えられた場合でも、その変更内容が反映されています。元となるコレクションの参照を内部にラップしているからですね。
対して、ImmutableList<T>のほうは、インスタンス生成後の変更内容が反映されていません。これは、元となるコレクションのコピーを内部にラップしているためです。
言うならば、 コレクションのスナップショットを撮影しているような感覚 だと思います。
変更系メソッドが利用可能
ImmutableList<T>の場合は 変更系メソッドが呼び出し可能 です。
変更系メソッドを呼び出した場合、内部にラップされた元となるList<T>のコピーがまた生成され、そのコピーに対して変更内容が適用されて、メソッドの戻り値として返されます。
したがって、変更系メソッドを呼び出した場合でも、元となるコレクションはもちろん、内部にラップしたコレクションにも変更が加えられることはありません。
図による解説
ImmutableList<T>の挙動を図で表すと次のようになります。

ReadOnlyCollection<T>と同様、キャストによる変更の危険性はなくなっています。
まとめ
IReadOnlyList<T>, ReadOnlyCollection<T>, ImmutableList<T>の違いをまとめると次のようになります。
IReadOnlyList<T> |
ReadOnlyCollection<T> |
ImmutableList<T> |
|
|---|---|---|---|
| 種類 | インターフェイス | クラス | クラス |
| 利用方法 | そのまま渡せばOK |
AsReadOnly()または new ReadOnlyCollection(IList<T>)
|
ToImmutable()または ImmutableList.CreateRange(IEnumerable<T>)
|
| 元コレクション | 直接参照している | 参照がラップされる | コピーがラップされる |
| 変更系メソッド |
利用不能 (ただしキャストで利用可能) |
利用不能 |
利用可能 ただしコピーに適用され、ソースには影響しない |
| 利用場面 | 「このListは変更してほしくない」という意思を伝えたい |
Listを絶対に変更できない形で渡したい |
コレクションのスナップショットを渡したい |
メソッド名から伝わるニュアンス
AsReadOnly()とToImmutable()のメソッド名をこうやって比べてみると、確かにAsの方は「オブジェクト参照はそのままで、型を変化させる」といったニュアンスが伝わりますし、Toの方は「オブジェクトを元にして別のインスンタンスを生成する」といったニュアンスが伝わります。細かなメソッド名の違いも、その挙動を正確に表現しているようです。メソッド名はきちんと考えて付けなければならないと感じました。
おまけ:IList<T>インターフェイスの実装について
実は、ReadOnlyCollection<T>クラスとImmutableList<T>クラスは、共にIList<T>インターフェイスを実装しています。
IList<T>インターフェイスを実装しているということは、AddやRemoveができるはずですよね?
すごく疑問に思い調べてみると、 インターフェイスの明示的な実装 というものを使っているらしいです。
インターフェイスの明示的な実装
詳しくはこのサイトを参考にしてほしいのですが、インターフェイスのメンバにあるけども、publicにしたくないメンバを隠すために使われることがあるようです。
インターフェイスの明示的な実装を行うには下記のよう、インターフェイス名.メンバ名でインターフェイスメンバを実装します。
public interface ITestInterface
{
void MethodA();
void MethodB();
}
public class TestClass : ITestInterface
{
//普通のインターフェイス実装
public void MethodA()
{
}
//明示的なインターフェイスの実装
void ITestInterface.MethodB()
{
}
}
このようにすると、MethodBはTestClassクラスのインスタンスから呼ぶことはできなくなり、メンバを隠すことができます。
しかし、インターフェイスを実装している以上、メンバは呼べなければなりません。
ではどうすれば呼べるかと言うと、インターフェイス名.メンバ名で呼ぶことができます。
実装しているクラスのインスタンスから直接呼び出すことはできないけども、インターフェイス経由ならば呼び出せるようです。
「インターフェイスの明示的な実装」、使う場面にはあまり出くわしたことがないですが、まだ知らないことがたくさんあると勉強になりました。
呼び出すと必ずNotSupportedExceptionがスローされる
では、ReadOnlyCollection<T>やImmutableList<T>でも、IList<T>インターフェイス経由であればAddやRemoveができるのかといえば、そうではないようです。
両者のクラスは、 IList<T>インターフェイス経由で変更系メソッドを呼び出すと、必ずNotSupportedExceptionがスローされる ようです。
この実装って正しいんですかね…?そこまでしてIList<T>インターフェイスを実装する理由は…?
まぁ、マイクロソフトのやることなので正しいのでしょう(適当)
誰かReadOnlyCollection<T>やImmutableList<T>がIList<T>を実装してる理由知っていたら教えて下さい。
以上、長くなってしまいましたがIReadOnlyList<T>とReadOnlyCollection<T>とImmutableList<T>の違いでした。
