Help us understand the problem. What is going on with this article?

【C#】IReadOnlyList<T>とReadOnlyCollection<T>とImmutableList<T>の違い

はじめに

先日、下記記事を公開しました。

【C#】インターフェイスの利点が理解できない人は「インターフェイスには3つのタイプがある」ことを理解しよう
https://qiita.com/yutorisan/items/d28386f168f2f3ab166d

この記事でIReadOnly系インターフェイスの役割と重要性について簡単に説明し、

Listをそのまま渡すのではなく、IReadOnlyListとして渡すだけで、渡した先で勝手に書き換えられる危険性が皆無になります

と記述しましたが、これに対して「IReadOnlyListではIListにキャストされてしまうので、変更される危険性が皆無ではない。本当に皆無にしたければImuutableListで公開すべき」とのご意見をいただきました。

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.png

IReadOnlyList<T>List<T>のうち読み取り専用のメンバのみを提供するインターフェイスです。
しかし、あくまでもList<T>に実装されたインターフェイスなので、キャストされればList<T>に直接アクセスされます。

対して、ReadOnlyCollection<T>は次のようになります。
ReadOnlyCollection.png

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>の挙動を図で表すと次のようになります。
ImmutableList.png

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>インターフェイスを実装しているということは、AddRemoveができるはずですよね?

すごく疑問に思い調べてみると、インターフェイスの明示的な実装というものを使っているらしいです。

インターフェイスの明示的な実装

詳しくはこのサイトを参考にしてほしいのですが、インターフェイスのメンバにあるけども、publicにしたくないメンバを隠すために使われることがあるようです。

インターフェイスの明示的な実装を行うには下記のよう、インターフェイス名.メンバ名でインターフェイスメンバを実装します。

public interface ITestInterface 
{
    void MethodA();
    void MethodB();
}

public class TestClass : ITestInterface 
{
    //普通のインターフェイス実装
    public void MethodA()
    {

    }

    //明示的なインターフェイスの実装
    void ITestInterface.MethodB()
    {

    }    
}

このようにすると、MethodBTestClassクラスのインスタンスから呼ぶことはできなくなり、メンバを隠すことができます。

しかし、インターフェイスを実装している以上、メンバは呼べなければなりません。
ではどうすれば呼べるかと言うと、インターフェイス名.メンバ名で呼ぶことができます。
実装しているクラスのインスタンスから直接呼び出すことはできないけども、インターフェイス経由ならば呼び出せるようです。

「インターフェイスの明示的な実装」、使う場面にはあまり出くわしたことがないですが、まだ知らないことがたくさんあると勉強になりました。

呼び出すと必ずNotSupportedExceptionがスローされる

では、ReadOnlyCollection<T>ImmutableList<T>でも、IList<T>インターフェイス経由であればAddRemoveができるのかといえば、そうではないようです。

両者のクラスは、IList<T>インターフェイス経由で変更系メソッドを呼び出すと、必ずNotSupportedExceptionがスローされるようです。

この実装って正しいんですかね…?そこまでしてIList<T>インターフェイスを実装する理由は…?
まぁ、マイクロソフトのやることなので正しいのでしょう(適当)

誰かReadOnlyCollection<T>ImmutableList<T>IList<T>を実装してる理由知っていたら教えて下さい。

以上、長くなってしまいましたがIReadOnlyList<T>ReadOnlyCollection<T>ImmutableList<T>の違いでした。


  1. ちなみにインターフェイスがインターフェイスを「継承」しているっていうんですかね?それとも「実装」?意味合い的には「継承」が正しい気もしますが。 

  2. もちろんList<T>だけじゃなくて色々なクラスに実装されていますが代表として挙げました 

yutorisan
技術系記事はQiitaに書いてみることにしました。現在ブログ→Qiitaに記事移行中。
https://yutori-techblog.com/
unity-game-dev-guild
趣味・仕事問わずUnityでゲームを作っている開発者のみで構成されるオンラインコミュニティです。Unityでゲームを開発・運用するにあたって必要なあらゆる知見を共有することを目的とします。
https://unity-game-dev-guild.github.io/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした