【C#】コレクション(リスト・配列)を安全に外部公開する

この記事は、あるクラスの中で管理しているコレクション(リスト・配列)をクラス外に渡す時に安全な渡し方の考え方や実装の紹介です。

あるクラス内で管理しているリストをクラス外に公開する場合に具体的にどういった危険性があるのか、どうすれば安全なのかを考えていきます。

はじめに

例えば、以下の例のようにあるクラス内でリストを持っていてそれを外部に公開するケースです。

// リストを持っているクラス
public class ListContainer
{
    List<Item> _list = new List<Item>();

    public IReadOnlyCollection<Item> Items => _list; // リストを外部に公開してる

    public ListContainer()
    {
        _list.Add(new() { No = 0 });
        _list.Add(new() { No = 1 });
        _list.Add(new() { No = 2 });
        _list.Add(new() { No = 3 });
    }
}

// リストの要素
public class Item
{
    public int No { get; set; } // 誰でも変更できる
}

IReadOnlyCollection で変更できない旨を外部に表明してはいますが、要素の内容までは保護されない & 以下のようなコードを書くと割と簡単に変更できてしまいます。

ListContainer container = new ();

foreach (var item in container.Items)
{
    item.No = 1; // (1)mutableな参照型の要素だとIReadOnlyCollectionでも変更できる
}

if (container.Items is List<Item> list)
{
    list.Add(new() { No = 4 }); // (2)元の型にアクセスしやすいとキャストして自由に追加削除できる
}

// (3)クラス外で勝手に変更されてクラス内のリストも変わってしまう
foreach (var item in container.Items)
{
    Console.WriteLine(item.No);
    // > 1 // 中身が書き換わっている
    // > 1
    // > 1
    // > 1
    // > 4 // 追加されている
}
// ★想定しない状態になってしまう

こうなると自分のクラス内で管理してるリストが外で変更されて自分のクラス内の処理が想定外の結果になって処理の失敗が発生してしまいます。

リストを外部に渡す時の要件

従って、特に複数人で作業していた場合は自分の作成したコンポーネントは割と想定と違う使われ方をされることが多く、そういった場合に他人の操作で自分のコンポーネントの中身が壊れるようなことは避けたい所です。その場合コレクションを外部公開する時の要件は概ね以下になります。

  • (1) コンポーネント内のコレクションが変更されない
  • (2) コレクションの各要素を変更してほしくない
  • (3) もしクラス外で変更されてもその変更はクラス内に伝わらない

解決方法

先述のそれぞれの項目の解決方法は以下の通りです。

  • (1) コレクションを変更してほしくない
    • リストを複製した上で IEnumerable を使用する
  • (2) コレクションの各要素を変更してほしくない
    • 可能であれば要素クラスを immutable に変更する、そうでなければ完全なコピーを返す
  • (3) もしクラス外で変更されてもその変更はクラス内に伝わらない
    • (1)と(2)が守られていれば自動的に達成可能

少し長くなるので(2)の要素クラスをimmutableにする方法を先に紹介します。基本的に「一度作成したオブジェクトは変更できない」です。

要素クラスのimmutable化

色々実装方法があるのでいくつか紹介します。

// 元の定義
public class Item
{
    public int No { get; set; }
}

// ↓↓↓↓

public class Item
{
    public int No { get; private set; }
    public Item(int no) => No = no;
}

// もしくは

public class Item
{
    public readonly int No;
    public Item(int no) => No = no;
}

// 以下でもほぼ同じ

public readonly struct Item
{
    public readonly int No;
    public Item(int no) => No = no;
}

上記の方法の他に、外部にはインターフェースだけ公開、クラス内では内部してクラスを用いて安全を確保する方法もあります。

public class ListContainer
{
    List<Item> _list = new();

    // ★インターフェースの要素を返すようにする
    public IEnumerable<IItem> Items => _list.ConvertAll(item => item as IItem);

    public ListContainer()
    {
      // ...
    }

    // ★内部で使用するクラス
    private class Item : IItem
    {
        public int No { get; set; }
    }
}

// 外部に公開するインターフェース
public interface IItem
{
    int No { get; }
}

immurable なクラスの場合 struct と大差ないのですが、struct だとオブジェクトのメンバーとして持つケースでコンポーネント内の通常の操作が面倒になるため使い方を考慮しつつ適切な宣言を行います。インターフェースを返す方法は最初のコードからは形が大きく違うので実装が少し大きくなります。これも状況と規模によってどうするか決めましょう。

実装コード

最終的な実装方法は以下の通りです。

要素クラスにコピーを実装しないで yield return するときに新しいオブジェクトを返す事もできます。

public class ListContainer
{
    List<Item> _list = new();

    // ★IEnumerable を返すように修正する
    public IEnumerable<Item> Items
    {
        get
        {
            foreach (var item in _list)
            {
                yield return item.Clone(); // ★全ての要素はコピーを返す
            }
        }
    }

    public ListContainer() { /* ... */ }
}

public class Item
{
    public int No { get; set; }

    // ★オブジェクトの完全なコピーを返すメソッドを追加
    public Item Clone()
    {
        return new() { No = this.No };
    }
}

要素のインターフェースだけを外部公開する場合の実装は以下の通りです。

public class ListContainer
{
    List<Item> _list = new();

    // ★外部公開するのはインターフェース、ConvertAllで新しいリストを作って返す
    public IEnumerable<IItem> Items => _list.ConvertAll(item => item as IItem);

    public ListContainer() { /* ... */ }

    // ★実際に使用する要素クラス(内部クラスで宣言)
    public class Item
    {
        public int No { get; set; }
    }
}

// ★外部に公開する要素クラスのインターフェース
public interface IItem
{
    int No { get; }
}

この場合、以下のようにキャストすれば追加削除できますが ConvertAll を挟んでいるので内部のリストではなく新しい別のリストとして渡しているためクラス内部まで影響はありません。

if (container.Items is List<IItem> list)
{
    list.RemoveAt(0); // こうすると削除できるがクラス内の要素には影響しない
}

独自に IItem のサブクラスを作って追加することもできますが、これもクラスの内部には影響しません。

オブジェクトのクローンの実装はメンバーを含め完全なコピーを作成します。ただしこのコピー処理(深いコピー or ディープコピー等と言います)の実装は割と不具合を作りやすいため、新規実装する場合は特にインターフェースの実装の方が簡単かもしれません。余談ですが、記事中ではコード例を提示していませんがインデクサーなどでリストの個別の要素にアクセスする場合、クラス外にはオブジェクトのコピーを返すのが定石です。

またリフレクションを使うと要素を書き換えられると値が変わってしまうのですが、それを言い始めるとクラス内部のリストを直接操作も可能なのでここでは考慮しないで良いと思います。

最後に内部管理のコレクションが配列の場合「Array」クラスに「ConvertAll」が同様に存在するため以下の通りほぼ同じ実装ができます。

// コレクションが配列の場合以下のように実装できる
Item[] _array = new Item[5];
public IEnumerable<IItem> Items => Array.ConvertAll(_array, item => item as IItem);

Unity の場合は「[SerializeField] Item[] itemList」のような宣言をよく利用するためその場合こちらを使用します。

以上です。