95
73

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

[C#] 配列やList<T>を直接公開する代わりにするべきこと

Last updated at Posted at 2018-09-19

概要

この投稿は、カプセル化を保ったまま、値の集合を外部へ公開する方法を述べる。
特に、外部から値の変更を許してはいけない場合、どうやって値を外部からの修正から保護するかについてまとめている。
経験があさい方にもわかりやすいよう書くようにつとめた。

前書き

Qiitaで以下のようなコードを推奨しているコメントを見る機会があった。
(そっくり同じコードではないが、そのままだとそもそもコンパイルを通らなかった)

Ng.cs
class NGSample
{
    public static readonly string Message1 = "message1";
    public static readonly string Message2 = "message2";
    public static readonly string Message3 = "message3";

    // 列挙用配列。しかし、この配列の公開はNG
    public static readonly string[] Messages =
    {
        Message1,
        Message2,
        Message3
    };
}

こういうコードを書いてはいけない。

内部で使用している配列をそのまま公開してはいけない理由

この配列、Messagesの各要素は外部から容易に入れ換えられてしまう。

NGSample.Messages[1] = "This message is spoiled.";

foreach (var s in NGSample.Messages)
    Console.WriteLine(s);

このコードの実行結果は以下のようになる。

message1
This message is spoiled.
message3

message2と表示されるべき部分が変化してしまった。
「すべてのメッセージを列挙する」という機能を外部から破壊できてしまうのだ。
守るべき配列を直接公開してはいけないのはこうした理由だ。

ところで、どうして書き換えできたのだろう。Messagesにreadonlyとつけたではないか。と考えた人はreadonlyの効果を正しく理解できていない。

参照型のフィールドをreadonlyとして宣言した場合、その変数が参照しているオブジェクトを変更することはできない
しかし、そのオブジェクトが持つ値を変更することはできるのだ。
だから、readonlyによって、以下のコードからデータを守ることはできる。

// コンパイルエラーになる
NGSample.Messages = new [] { "Compile Error!" };

しかし、配列の要素の変更からはデータを守れないのだ。

// 実際に値が入れかえられる
NGSample.Messages[1] = "This message is spoiled.";

変更可能なコレクションを直接IEnumerable<T>として公開してもいけない

ときおり、以下のようなコードを見ることがある。

Ng2.cs
    public static readonly IEnumerable<string> Messages =
        new []
        {
            Message1,
            Message2,
            Message3
        };

IEnumerable<T>を使用することで値の変更から身を守ろうとしているのだが、残念ながら、以下のようなコードからデータを守ることはできない。

(NGSample.Messages as string[])[1] = "This message is spoiled.";

foreach (var s in NGSample.Messages)
    Console.WriteLine(s);

このコードの実行結果は以下のようになる。

message1
This message is spoiled.
message3

ずるいではないか。外部からしかクラスを参照できないユーザが、どうして内部データを配列で持っていることがわかるのか。
という人がいるかもしれないので、もうひとつの方法を紹介しておこう。

(NGSample.Messages as IList<string>)[1] = "This message is spoiled.";

foreach (var s in NGSample.Messages)
    Console.WriteLine(s);

このコードも先程と同じ結果になる。

Messagesが指すデータ構造がIList<string>を実装している場合、このコードで書き換え可能1だ。string[]IList<string>を実装するデータ構造のひとつだし、List<string>Collection<string>も同様なので、よく使用される変更可能なコレクションはこのキャストで値を書き換えられる。

守るべき変更可能なコレクションを直接公開してはいけない。たとえ、書き換え不可能なインタフェース経由だったとしても。

安全にコレクションを公開するには

では、どうやって値の集合を公開したらいいのだろうか。
安全にコレクションを公開する方法を3つあげる。

IEnumerable<T>型の公開用プロパティからyield returnする

今回のサンプルコードの場合、必要なことは外部から値の列挙をする手段を提供することだけだった。
だから、IEnumerable<T>を返しさえすれば要件は完全に満たすことができる。これ以上の機能の追加もないことが予想できる。

先程は直接IEnumerable<T>型として参照を返してはいけないということを述べたばかりではあるが、この型のデータを公開しても問題が発生しないやりかたももちろんある。

NoProblem.cs
class NoProblemSample
{
    public static readonly string Message1 = "message1";
    public static readonly string Message2 = "message2";
    public static readonly string Message3 = "message3";

    // 列挙用配列
    static readonly string[] _Messages =
    {
        Message1,
        Message2,
        Message3
    };

    // これは外部から変更不可能
    public static IEnumerable<string> Messages
    {
        get
        {
            foreach (var e in _Messages)
                yield return e;
        }
    }
}

このMessagesIList<string>にキャストできない。以下の式はnullを返してくれる。

  • NoProblemSample.Messages as string[]
  • NoProblemSample.Messages as IList<string>

ところが、このMessagesを以下のようにすると途端に変更可能になってしまう。

NG3.cs
    // 注意!外部から変更可能!
    public static IEnumerable<string> Messages
    {
        get
        {
            return _Messages.AsEnumerable();
        }
    }

このコードの場合、

  • NoProblemSample.Messages as string[]
  • NoProblemSample.Messages as IList<string>

でキャストできてしまう。
これは、配列のAsEnumerable()メソッド2は自分自身を返すだけだからだ。
編集可能な型へのキャストを許可しないという目的ではAsEnumerable()メソッドは使用できない。

変更不可能な型でデータを公開する

コレクションを外部からの編集から守るために、ReadOnlyCollection<T>というクラスを利用できる。

ReadOnly.cs
class OKSample
{
    public static readonly string Message1 = "message1";
    public static readonly string Message2 = "message2";
    public static readonly string Message3 = "message3";

    public static ReadOnlyCollection<string> Messages { get; }
        = Array.AsReadOnly(new []
            {
                Message1,
                Message2,
                Message3
            });
}

あるいは、以下のようにも書ける。

    public static ReadOnlyCollection<string> Messages { get; }
        = new []
            {
                Message1,
                Message2,
                Message3
            }.ToList().AsReadOnly();

Array.AsReadOnly<T>(T[])メソッドは、その配列をラップするReadOnlyCollection<T>クラスのオブジェクトを返す。
List<T>クラスのAsReadOnly<T>()メソッドも同様である。

    public static ReadOnlyCollection<string> Messages { get; }
        = new ReadOnlyCollection<string>(
            new []
            {
                Message1,
                Message2,
                Message3
            });

とも書ける。

このクラスからは値の閲覧のみが可能であり、もはや、以下のようなコードで値を入れかえることはできない。

// コンパイルエラーになる
OKSample.Messages[1] = "Compile Error!"
// NotSupportedExceptionをスローする
(OKSample.Messages as IList<string>)[1] = "Throw an exception";

このクラスのオブジェクトはインデクサ(OKSample.Messages[1]という表現)が使用できるので、クラスの外部から便利に使える。列挙以外のつかいみちがありそうであれば、この型で返すことをおすすめする。

ReadOnlyCollection<T>の注意点

ReadOnlyCollection<T>型のオブジェクトは、コンストラクタで指定されたIList<T>型の引数のオブジェクトを参照しつづける。そして、そのオブジェクトのビューとして動作する。
だから、最初に内部で扱うコレクションを引数で指定してReadOnlyCollection<T>オブジェクトを生成しておけば、何度も生成しなおさなくてよい。

class ReadWriteSample
{
    IList<int> List { get; }
    public ReadOnlyCollection<int> Collection { get; }

    public ReadWriteSample()
    {
        List = new List<int>();
        Collection = new ReadOnlyCollection<int>(List);
    }

    public void Add(int value)
    {
        List.Add(value);
    }
}

上記のコードでは、Collectionプロパティをいつ取得しても、最後にAddされた最新のListの内容を取得することができる。コンストラクタで指定されたオブジェクトのビューとして働くとはこういうことだ。

これを少しだけ変更した下記のコードには問題がある。
おわかりだろうか。

class ReadWriteNGSample
{
    IList<int> List { get; set; }
    public ReadOnlyCollection<int> Collection { get; private set; }

    public ReadWriteNGSample()
    {
        List = new List<int>();
        Collection = new ReadOnlyCollection<int>(List);
    }

    public void Add(int value)
    {
        List.Add(value);
    }

    public void Clear()
    {
        List = new List<int>();
        Collection = new ReadOnlyCollection<int>(List);
    }
}

おそらくは、このコードを組みながら作成したテストコードはすべて通過するだろう。
そして、いつの日か以下のようなバグレポートを受け取ることになる。

var sample = new ReadWriteNGSample();
var collection = sample.Collection;

sample.Add(1);
sample.Add(2);
sample.Clear();

foreach(int value in collection)
    Console.WriteLine(value);
// 実行結果は以下の通り。Clear()したのに!
// 1
// 2

いちいちプロパティの値を変数に保存しないでくれ、と言いたくもなるが、ReadOnlyCollection<T>型のプロパティを返す場合、そのオブジェクトが変数に保存されることを意識する必要がある。
ReadOnlyCollection<T>IList<T>のビューとして働く」ということを知っているプログラマからしてみれば、Clear()すれば以前取得したCollectionプロパティが返したオブジェクトが返す値もクリアされるはずである、と想定してしまうからだ。

実データであるListが参照するオブジェクトが置きかわらないよう、readonlyをつけたフィールドとして定義するか、読み取り専用の自動プロパティとして定義するとよい。
以下が修正したコードだ。

class ReadWriteOKSample
{
    IList<int> List { get; }
    public ReadOnlyCollection<int> Collection { get; }

    public ReadWriteOKSample()
    {
        List = new List<int>();
        Collection = new ReadOnlyCollection<int>(List);
    }

    public void Add(int value)
    {
        List.Add(value);
    }

    public void Clear()
    {
        List.Clear();
    }
}

変更不可能な型を継承した独自の型でデータを公開する

ReadOnlyCollection<T>そのものを返すのではなく、それを継承した型でデータを公開する方法がある。

OKReadOnlyEx.cs
class OKSampleEx
{
    public static readonly string Message1 = "message1";
    public static readonly string Message2 = "message2";
    public static readonly string Message3 = "message3";

    public static MessageCollection Messages { get; }
        = new MessageCollection(
            new []
            {
                Message1,
                Message2,
                Message3
            });
}

class MessageCollection : ReadOnlyCollection<string>
{
    public MessageCollection(IList<string> list)
        : base(list) { }
}

この方法は、

  • データを公開するコレクションに何か機能を追加したい場合
  • 将来的にコレクションに機能を追加する要件が予想される上に、その拡張の前後でクラスのインタフェースを保たなければならない場合

に使用する。そうではない場合は、KISSの原則を思い出そう。

IEnumerable<T>と具象クラスの中間の型でデータを公開する

ReadOnlyCollection<T>が実装しているインタフェースの型、例えばIReadOnlyList<T>型としてオブジェクトを公開するやりかたもあるだろう。
あまりいいやり方とは思わないが、積極的に反対する理由もない。3

OKReadOnlyEx2.cs
class OKSampleDash
{
    public static readonly string Message1 = "message1";
    public static readonly string Message2 = "message2";
    public static readonly string Message3 = "message3";

    // 問題なし。IReadOnlyCollection<string>型として公開するのはおすすめしない
    public static IReadOnlyList<string> Messages { get; }
        = Array.AsReadOnly(new []
            {
                Message1,
                Message2,
                Message3
            });
}

ただ、C#5.0(.NET4.5)以降のIReadOnlyList<T>インタフェースが定義された環境であれば、互換性以外の理由でIReadOnlyCollection<T>型を使うのはやめよう。
この型はあつかいづらい。実行時のコストはともかく、プログラミングのコストについてはIEnumerable<T>で返されるのとあまり変わりない。

ただし、繰り返しになるが、配列を直接IReadOnlyList<T>IReadOnlyCollection<T>にキャストして返してはいけない。
配列(List<T>も)はIReadOnlyList<T>IReadOnlyCollection<T>を実装しているのでこうしたことができてしまうのだ。
逆に言えば、プロパティの戻り値の型がIReadOnlyList<T>だからと言って、実際に返されるオブジェクトがReadOnlyCollection<T>やその派生クラスとは限らないということだ。

値のコピーを返す

配列を求められる度にコピーして返すやりかたもある。

OKCopy.cs
class CopySample
{
    public static readonly string Message1 = "message1";
    public static readonly string Message2 = "message2";
    public static readonly string Message3 = "message3";

    public static string[] Messages
    {
        get
        {
            return _Messages.ToArray();
        }
    }

    static string[] _Messages { get; }
        = new []
            {
                Message1,
                Message2,
                Message3
            };
}

以下のようなコードで書き換えが可能に見える。

CopySample.Messages[1] = "This message is unbreakable."

foreach (var s in CopySample.Messages)
    Console.WriteLine(s);

しかし、元のデータである_Messagesは書き換えられないので、値は正しく保たれる。

message1
message2
message3

このやりかたは、速度の面でも、メモリの面でもReadOnlyCollection<T>を使う方法よりも不利だ。
不恰好におもうかもしれないが、

  • 値の列挙をしながら、その集合に新たに値を追加しなければならない場合
  • 値の参照と編集との並列化に対応しなければならない場合

には有効に働く。
そうでない場合だったとしても、データの保護が不完全なコードよりはずっといい。

ただし、大量のデータをコピーしなければならないのであれば、プロパティではなくメソッドの形で値を公開すべきだろう。

読み取り専用の自動プロパティを使用する

もし、あなたがC#6.0(.NET4.6)以降を使っているのであれば、公開しているフィールドをreadonlyで定義する代わりに、読み取り専用の自動プロパティを利用しよう。
コンパイラが、裏で自動的に宣言されるフィールドに対しreadonlyとして定義してくれるから、readonlyと同じ効果が得られる。つまり、コンストラクタより後では値の再代入は許可されない。
もう、readonlyとタイプする必要はないし、そのまま値を公開するよりも拡張性に優れている。

たとえば、配列をそのまま返してしまうプロパティを含むプログラムをリリースしてしまったとする。

bugrelease.cs
// 問題のある値の公開。外部から値を変更できてしまう!
public static string[] MessagesProperty { get; } =
    {
        Message1,
        Message2,
        Message3
    };

このようにプロパティとして公開していれば、以下のように修正することができる。

bugfix.cs
static readonly string[] _Messages =
    {
        Message1,
        Message2,
        Message3
    };

// バイナリ互換性を保ったまま、値を変更できないように修正。
public static string[] MessagesProperty
{
    get
    {
        return _Messages.ToArray();
    }
}

これは、直接フィールドを公開していてはできない芸当だ。
というのは、フィールドをプロパティにすると外部からのバイナリ互換性は失われ、そのライブラリを使用しているプログラムの再コンパイルが必要になってしまうのだ。

まとめ

配列やList<T>といった変更可能なコレクションをそのまま公開してしまうと、外部からデータを破壊できてしまう。
その代わりの手段として、

  • IEnumerable<T>型の公開用プロパティで値をyield returnする
  • 変更不可能な型、たとえばReadOnlyCollection<T>を活用する
  • 公開用プロパティやメソッドでコレクションのコピーを返す

という3つの方法を述べた。
また、読み取り専用の自動プロパティは、readonlyフィールドをそのまま公開するのとほとんど同じタイプ量でより高い拡張性が得られる。C#6.0以降で使用できる。

発展

ここでは配列やList<T>に焦点をしぼって記述してきたが、他のコレクションについても応用できるだろう。4

また、こうしたデータの保護が必要なのは、コレクションだけではない。変更可能な参照型オブジェクトについてはどれも同じことが言える。
こちらの問題の場合は、

  • 値型のデータを公開する
  • 型を変更不可能(イミュータブル)にする
  • 変更可能な型を外部から隠蔽し、変更不可能な型やインタフェースで公開する
  • 読み取り専用のラッパ経由で公開する
  • 値そのものではなくコピーした値を返す

といった手段で対応するのだが、後者のふたつはここで述べた内容そのものだ。

ところで

System.Runtime.CompilerServices名前空間にReadOnlyCollectionBuilder<T>というクラスがある。
もちろんこれは、ReadOnlyCollection<T>クラスのオブジェクトを生成するために存在する。
しかし、どういったときに便利なのかまったく思いつかない。
ご存知の方はコメントでこっそり教えてください。

  1. ただし、後述するが、そのオブジェクトがデータの変更を許可しない場合は書き換えできず、実行時に例外をスローする。また、これはIEnumerable<T>に限った話ではない。IReadOnlyCollection<T>IReadOnlyList<T>だったとしてもまったく同じことが言える。

  2. 正確にはIEnumerable<T>の拡張メソッド

  3. ReadOnlyCollection<T>型でのデータの公開は、内部データをIList<T>で管理しているという点に強く依存してしまっているので、これを嫌うというケースはあるかもしれない。

  4. そしてHashSet<T>型のときに読み取り専用のラッパクラスをさがし、ReadOnlySet<T>クラスが用意されていないことに気付いて首をかしげることだろう。

95
73
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
95
73

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?