zzzkan.me

C#でコレクションを読み取り専用で公開するには結局どうしたらいい?

  • 10min
車の模型のコレクション
Credit: Karen Vardazaryan

C#でコレクションをプロパティなどで公開するとき List<T>や配列のまま公開すると、中身が変更できてしまうため外部から意図しない変更が加えられる可能性があります。読み取り専用で公開するにはどうやるのがいいんですかね~という話です。

readonly なプロパティじゃだめ?

たとえば get-only プロパティを使えば readonly なプロパティを宣言できます。ただ、List<T>や配列のような参照型の場合、あくまで別のインスタンスで置き換えることができなくなるだけでコレクションの中身は相変わらず変更できてしまいます。

public class Sample
{
    public List<int> Collection { get; }
    public Sample()
    {
        Collection = new List<int> { 1, 2, 3 };
    }
}
 
public void Test()
{
    var sample = new Sample();
    sample.Collection = new List<int> { 4, 5, 6 }; // これはダメ
    sample.Collection.Add(4); // これはOK
}

ということでコレクションを読み取り専用で公開する場合は単にプロパティを readonly にするだけでなく、List<T>や配列とは別の型を使おうという話になります。

変更できないコレクションを使う

.NET には変更できないコレクションとしてたとえば以下があります。

  • ReadOnlyCollectionなどの ReadOnly 系のコレクション
  • ImmutableListなどの Immutable 系のコレクション

ReadOnly 系のコレクション

ReadOnlyCollection<T>IList<T>をラップしてAdd()などの書き込み処理をできないようにしたコレクションです。

var readOnlyCollection = (new List<int> { 1, 2, 3 }).AsReadOnly();
readOnlyCollection.Contains(1); // これはOK
readOnlyCollection.Add(4); // これはダメ

名前空間としてはSystem.Collections.ObjectModelにあって、似たようなものとしてはReadOnlyObservableCollection<T>とかReadOnlyDictionary<TKey, TValue>があります。

余談ですが、ReadOnlyCollection<T>ICollection<T>を実装しているので実はたとえばAdd()はあります。ただ明示的なインターフェイスの実装とうい方法で通常は見えないようになっていて、ICollection<T>などのインターフェイスを介すことではじめて呼び出すことができます。もっとも呼び出しても NotSupportedException がスローされるだけなので中身が変更されることはないです。

((ICollection<int>)readOnlyCollection).Add(4); // コンパイルエラーではないが実行時にNotSupportedException

ということで、ReadOnlyCollection<T>としてコレクションを公開すると次のようになります。

public class Sample
{
    public ReadOnlyCollection<int> Collection { get; }
    public Sample()
    {
        var list = new List<int> { 1, 2, 3 };
        Collection = new ReadOnlyCollection<int>(list);
    }
}
 
public void Test() 
{
    var sample = new Sample();
    sample.Collection = new List<int> { 4, 5, 6 }; // これはダメ
    sample.Collection.Add(4); // これもダメ
}

しっかりコレクションの中身は変更できなくなっていて書き込み系の処理も(基本的には)コンパイルエラーになるので良いです。ちなみに、「.NET のクラスライブラリ設計」という.NET の開発者の方々が書かれた本を覗てみると次の用に書かれていたりします。

読み取り専用のコレクションを表すプロパティまたは戻り値には、ReadOnlyCollection<T>もしくはそのサブクラス、または、滅多にないケースだが、IEnumerable<T>を使用する。一般的には、ReadOnlyCollection<T>を推奨します。

とのことでここではReadOnlyCollection<T>が推奨されてるみたいです。

Immutable 系のコレクション

変更できないコレクションとしてもう 1 つ代表的なのがImmutableList<T>などの Immutable 系のコレクションだと思います。これは名前の通り「不変(イミュータブル)」なコレクションのことで「読み取り専用」とは少し違います。どういうことかというと、たとえば普通にAdd()が呼び出せます。

var immutableList1 = ImmutableList.Create(1, 2, 3);
var immutableList2 = immutableList1.Add(4); // 普通にAddできる、ただしimmutableList1は変更されない

このときimmutableList1Add()を呼び出してもimmutableList1は変更されず、Add()された結果は別インスタンスとして返されます。このように作成後にその状態が変わらない性質のことをイミュータブルと呼びます。名前空間としてはSystem.Collections.Immutableにあって、他にもImmutableArray<T>とかImmutableDictionary<TKey, TValue>などいろいろあります。

このImmutableList<T>としてコレクションを公開するとたとえば次のようになります。

public class Sample
{
    public ImmutableList<int> Collection { get; }
    public Sample()
    {
        Collection = ImmutableList.Create(1, 2, 3);
    }
}
 
public void Test() 
{
    var sample = new Sample();
    sample.Collection = ImmutableList.Create(4, 5, 6); // これはダメ
    var newCollection = sample.Collection.Add(4); // これはOKだがample.Collectionは変更されない
}

当然コレクションの中身は変更できないんですが、前述したように普通にAdd()などの書き込み系の処理を呼び出せるので「読み取り専用」というと少し違う気がします。

ReadOnly 系のインターフェイスを介して公開する

ReadOnly 系のインターフェイスを介して公開するという方法もあります。たとえば、IEnumerable<T>は LINQ などで出てくるのでよく知られていると思いますが、コレクションが列挙可能なことのみを定義するインターフェイスです。当然、Add()をはじめとした書き込み系の処理は定義されていないので、IEnumerable<T>を介せばそのコレクションは読み取り専用に見えます。

で.NET には (.NET Framework 4.5 あたりから)コレクションに対する読み取り処理のみを定義したインターフェイスが以下のように用意されています。

ReadOnly 系のインターフェイスの図

IEnumerable<T>は前述の通り列挙できること、IReadOnlyCollection<T>はこれに加えて要素数が、IReadOnlyList<T>はさらに要素順が確定します。IReadOnlyList<T>が最も具体的な ReadOnly なコレクションという感じがしますね。Countで要素数も取れるし[]での各要素へのアクセスもできるし。

このIReadOnlyList<T>としてコレクションを公開するとたとえば次のようになります。

public class Sample
{
    public IReadOnlyList<int> Collection { get; }
    public Sample()
    {
        Collection = new List<int> { 1, 2, 3 };
    }
}
 
public void Test()
{
    var sample = new Sample();
    sample.Collection = new List<int> { 4, 5, 6 }; // これはダメ
    sample.Collection.Add(4); // これもダメ
}

List<T>IReadOnlyList<T>として公開することで中身を変更できなくなりました。ただ、あくまでIReadOnlyList<T>を介した状態では変更できないだけで、実体としてはList<T>のままでたとえば以下のようにすれば中身は変更できます。

((ICollection<int>)sample.Collection).Add(4); // これは大丈夫、中身を変更できてしまう

とは言ってもIReadOnlyList<T>を実装した具象クラスが必ずICollection<T>を実装している保証は(基本的に)ないわけで、これは普通はやっちゃいけない類の操作になると思います。なので、こういうケースについてはそこまで神経質にならなくてもいいのかなと個人的には思います。結局、リフレクションを使えば何でもできてしまうわけだし。

結局どうすれば…?

じゃあよりベターなのは何かというのを考えたいんですが…まあなんでもいいんじゃないんですか(投げやり)。基本的には以下のような選択肢になると思うので状況に合わせて都度選択するしかない…のか?

  • ReadOnlyCollection<T>として公開
  • IReadOnlyList<T>として公開
    • 実体をReadOnlyCollection<T>にもできる
  • 自作の読み取り専用コレクションを作ってもいいのかもしれない

パフォーマンスが気になる場合

たとえばインターフェイスを介すると一般的にパフォーマンスが落ちます。これは仮想呼び出しのオーバーヘッドがあるからであったりインライン化できないであったりまあいろいろあると思います。

ReadOnlyCollection は?

そうすると具象クラスであるReadOnlyCollection<T>で公開した方が良いのではという話が出てきそうです。ただ、実はパフォーマンスはよくならなかったりします。

MethodMeanErrorStdDev
ReadOnlyCollectionBenchmark71.51 ns0.768 ns0.719 ns
IReadOnlyListBenchmark72.62 ns0.732 ns0.684 ns
public class Sample
{
    public int[] Array { get; }
    public ReadOnlyCollection<int> ReadOnlyCollection { get; }
    public IReadOnlyList<int> IReadOnlyList { get; }
    public Sample()
    {
        var N = 10^6;
        Array = Enumerable.Range(1, N).ToArray();
        ReadOnlyCollection = new ReadOnlyCollection<int>(Array);
        IReadOnlyList = Array;
    }
}
 
[Benchmark]
public int ReadOnlyCollectionBenchmark()
{
    var sample = new Sample();
    int sum = 0;
    foreach (var item in sample.ReadOnlyCollection)
    {
        sum += item;
    }
    return sum;
}
[Benchmark]
public int IReadOnlyListBenchmark()
{
    var sample = new Sample();
    int sum = 0;
    foreach (var item in sample.IReadOnlyList)
    {
        sum += item;
    }
    return sum;
}

これはReadOnlyCollection<T>の内部実装がIList<T>を介した呼び出しになっているのが大きな原因です。ReadOnlyCollection のリファレンスを見てみると次のようになっています。

public class ReadOnlyCollection<T>: IList<T>, IList, IReadOnlyList<T>
{
    IList<T> list; // IList<T>として保持している
...
    public IEnumerator<T> GetEnumerator() {
        return list.GetEnumerator(); //IListを介した呼び出し
    }
...
}

IReadOnlyList<T>を介していてもいなくても、内部的には結局IList<T>を介した呼び出しになるため最適化がかからずパフォーマンスはそこまで変わりません。パフォーマンスを気にする場合、ReadOnlyCollection<T>が遅いのは覚えておいて損はないと思います。

こういった面もあってReadOnlyCollection<T>ってなんか使いづらいなあと思っています。

ReadOnlySpan を使おう

で、パフォーマンスを気にする局面ではReadOnlySpanが良いです。

MethodMeanErrorStdDev
ArrayBenchmark27.68 ns0.571 ns0.680 ns
ReadOnlySpanBenchmark27.47 ns0.453 ns0.423 ns
public class Sample
{
    public int[] Array { get; }
    public ReadOnlySpan<int> ReadOnlySpan => Array;
    public Sample()
    {
        var N = 10^6;
        Array = Enumerable.Range(1, N).ToArray();
    }
}
 
[Benchmark]
public int ArrayBenchmark()
{
    var sample = new Sample();
    int sum = 0;
    foreach(var item in sample.Array)
    {
        sum += item;
    }
    return sum;
}
[Benchmark]
public int ReadOnlySpanBenchmark()
{
    var sample = new Sample();
    int sum = 0;
    foreach (var item in sample.ReadOnlySpan)
    {
        sum += item;
    }
    return sum;
}
 

配列を直接公開した場合と比較してもほとんど変わらないパフォーマンスが出ます。

このReadOnlySpan<T>Span<T>の読み取り専用版で、Span<T>は配列のような連続したメモリ領域を表す構造体です。もはやコレクションではない…ので LINQ のような便利な操作もできないです。でも、速いは正義。

結論

ReadOnlySpan<T>は読み取り専用で速い!

終わりに

(本題とはあんまり関係ないんですが)引用した「.NET のクラスライブラリ設計」に以下のようなことも書いてありました。

Count プロパティにアクセスするという目的だけで、パラメーターを ICollection<T>または ICollection にすることを避ける。代わりに、 IEnumerable<T> または IEnumerable を使用して、そのオブジェクトが ICollection<T> または ICollection を実装しているかどうかを動的にチェックすることを検討する。

これってIReadOnlyCollection<T>で解決するような気がします。新装版なので.NET Framework 4.5 が出てから更新されていると思うのですが、この辺更新されない理由って何かあったりするんでしょうか…。

参考

zzzkan
zzzkan

アルフォートは水色派です。

© 2023 zzzkan, Built with Gatsby