コレクションの保護
クラスの不十分なカプセル化としてよく見られるのがコレクションです。 以下の例は、カプセル化されていないコレクションの例です。
public static class Database { public static Person[] People { get; private set; } } public class Person { public int Age { get; private set; } public string Name { get; private set; } }
問題があるのは、Database
クラスのPeople
プロパティです。
一見、setterはprivate
であるため、クラス外部からの変更は不可能なように見えますが、これはコレクションにありがちなミスです。
コレクションそのもののインスタンスは書き換え不可ですが、以下のように各要素については書き換えができてしまいます。
// これはsetterがprivateなので不可 Database.Person = new Person[5]; // これは可能 Database.Person[3] = new Person();
外部に対してforeach
でPerson
を回すことだけを想定している場合、外部からの要素の書き換えは危険です。これはArray
に限らずList<T>
である場合も当然同様です。
public static class Database { public static List<Person> People { get; private set; } } // ----------------------------------------------- // 想定外の要素の追加が可能 Database.People.Add(new Person());
foreach
による列挙を許可したい場合、適切な実装は以下のようにIEnumerable<T>
を使うのが正しいです。
public static class Database { public static IEnumerable<Person> People { get; private set; } }
これで、外部からはイテレート以外の操作は受け付けません。
しかし、この場合、内部からの操作もIEnumerable<T>
でしか操作できず、大変不便です。
以下のように実装すると内部では柔軟な操作が可能になります。
public static class Database { private static readonly List<Person> _people = new List<Person>(); public static IEnumerable<Person> People => _people; }
これで外部からはイテレートしかできず、内部からは自由な操作が可能になります。
しかし、IEnumerable<T>
は意外と不便で、index操作が使えない、要素数が取れない等の不便さが生じます。インデクサを定義しているのはIList
、要素数を定義しているのはICollection
だからです。(IEnumerable<T>
から要素数取得やインデックス指定可能な方法はありますが後述。)
なおIEnumerable
、ICollection
、IList
およびそのジェネリック版の関係は以下の図の通りです。
そこで、コレクションのカプセル化を実現しつつ、インデクサと要素数を利用できるのがSystem.Collections.ObjectModel.ReadOnlyCollection<T>
です。
[2020/06/21 追記]
.net standard 2.0 以降の環境では、保護したいものが配列の場合はReadOnlyMemory<T>
やReadOnlySpan<T>
を使った方が色々とパフォーマンスが良いです。ListReadOnlyCollection<T>
を使わざるを得ませんが。
public static class Database { private static readonly List<Person> _people = new List<Person>(); public static ReadOnlyCollection<Person> People { get; } = _people.AsReadOnly(); } // -------------------------------------------------------- // インデクサのsetterはないためこれは不可 Database.People[3] = new Person(); // getは可能 var person = Database.People[3]; // 要素数も取得可能 var count = Database.People.Count;
これで、インデクサ操作や要素数の要件を満たしつつ、適切なコレクションのカプセル化ができました。
インデクサ操作や要素数の取得が不要な場合、IEnumerable<T>
でもReadOnlyCollection<T>
でも外部から保護されている点では十分なのでどちらを用いてもよいでしょう。
補足1
ReadOnlyCollection<T>
を用いた実装例で、
public static ReadOnlyCollection<Person> People => _people.AsReadOnly();
ではなく、
public static ReadOnlyCollection<Person> People { get; } = _people.AsReadOnly();
としています。2つの違いは、前者の実装ではgetterが呼ばれるたびにReadOnlyCollection<Person>
のインスタンスが生成されますが、後者は最初の1回インスタンスが生成されるのみです。当然、後者の方がメモリ的には良いでしょう。(微々たる差ではありますが)
が、これは一見不思議な実装です。次の例を見てください。
public static class Database { private static readonly List<Person> _people = new List<Person>(); public static ReadOnlyCollection<Person> People { get; } = _people.AsReadOnly(); public static Add(Person person) => _people.Add(person); } // -------------------------------------------------------- // ここで0が出力されるとする Console.WriteLine(Database.People.Count); // 要素追加 Database.Add(new Person()); // ここでは何が出力される? Console.WriteLine(Database.People.Count);
さて問題です。最後の出力では何が出力されるでしょう?
ReadOnlyCollection<Person>
のインスタンスが生成されるのがDatabase
クラス初期化時のみなので、一見0が出力されるように思えます。
が、実際は1がちゃんと出力されます。アラ不思議。
これはReadOnlyCollection<T>
がList<T>
のラッパーであるため、内部で元になったList<T>
のインスタンスを持っているために起こります。参照型万歳。
補足2
IEnumerable<T>
を使った隠蔽実装では要素数およびインデクサ操作ができないと前述しました。
しかし、実際はLINQのIEnumerable<T>.Count()
やIEnumerable<T>.ElementAt(int)
を使うことで可能です。
しかし、IEnumerable<T>
は列挙可能であることを定義するだけのインターフェースであるため、Count()
は先頭から順に全要素の数え上げを行いO(n)かかります。ElementAt(int)
も同様です。
が、実はMicrosoftの公開しているLINQの実装を見てみると、Count()
は実際のインスタンスがICollection
である場合はICollection
にキャスト後、ICollection.Count
を返しています。同様に、ElementAt(int)
もIList
である場合はキャスト後にIList[int]
を返しています。
本文で上げたIEnumerable<T>
を用いた隠蔽実装の実体はList<T>
であるため、ともにO(1)で返ります。つまり、要素数もインデクサ操作も実は何の不都合も存在せず使用可能なのです。
しかし、ここでもう一度否定します。
例で挙げたDatabase
クラスを外部から見ると、内部の実態がList<T>
であることなど知る由もありません。IEnumerable<T>.Count()
と使えばO(n)だと考えるのが自然です。実際はO(1)で返るのだとしても、そのような利用法を外部に公開するのはフレンドリーとはあまり言えないでしょう。
よって、要素数やインデクサを使わせたいのであれば素直にReadOnlyCollection<T>
で公開すべきでしょう。