C#でreadonlyなListを作る方法|ReadOnlyCollection・IReadOnlyListの違いも解説

はじめに

C#で開発していると、「Listの中身を外部から変更されたくない」「readonlyなListとして公開したい」という場面がよくあります。

しかし、C#のreadonly修飾子をList<T>に付けても、リストの要素追加や削除まで禁止されるわけではありません。

C#
private readonly List<string> _names = new List<string>();

public void AddName(string name)
{
_names.Add(name); // 実行できる
}

このように、readonly List<T>は「変数の再代入を禁止する」だけであり、「Listの中身を変更不可にする」ものではありません。

C#でreadonlyなListを作りたい場合は、目的に応じてReadOnlyCollection<T>IReadOnlyList<T>ImmutableList<T>などを使い分ける必要があります。

この記事では、C#でreadonlyなListを作る方法を、ReadOnlyCollection<T>IReadOnlyList<T>の違いを中心に解説します。

1. C#でreadonlyなListを作りたいときに知るべき前提

C#でreadonlyなListを扱う前に、まず理解しておきたいのが「readonly修飾子」と「読み取り専用コレクション」は別物だという点です。

1-1. readonly修飾子だけではListの中身の変更を防げない

C#のreadonly修飾子は、フィールドに対して使うことで、そのフィールドへの再代入を制限できます。

C#
private readonly List<int> _numbers = new List<int>();

この場合、_numbersに別のList<int>を代入することはできません。

C#
_numbers = new List<int>(); // コンパイルエラー

しかし、_numbersが参照しているListの中身を変更することはできます。

C#
_numbers.Add(1);    // OK
_numbers.Remove(1); // OK
_numbers.Clear(); // OK

つまり、readonlyはListそのものの変更を禁止するものではありません。

1-2. List<T>をreadonlyにしてもAdd・Removeは実行できる

List<T>には、要素を追加するAdd、削除するRemove、全削除するClearなどのメソッドがあります。

フィールドがreadonlyであっても、参照先のオブジェクトが持つメソッド呼び出しは制限されません。

C#
public class Sample
{
private readonly List<string> _items = new List<string>();

public void Execute()
{
_items.Add("A");
_items.Add("B");
_items.Remove("A");
}
}

このコードは問題なくコンパイルされます。

readonly List<T>という書き方だけを見ると「読み取り専用のList」に見えますが、実際にはListの中身は変更可能です。

1-3. 「変数の再代入不可」と「コレクションの変更不可」の違い

C#でreadonlyなListを考えるときは、次の2つを分けて考える必要があります。

readonly修飾子が制限するのは、フィールドの再代入です。

C#
private readonly List<string> _items = new List<string>();

_items = new List<string>(); // NG

一方で、コレクションの変更不可とは、要素の追加・削除・差し替えができない状態を指します。

C#
_items.Add("A");    // これを禁止したい
_items.Remove("A"); // これを禁止したい

C#で「readonlyなListを作りたい」と言う場合、多くは後者の「コレクションの中身を変更されたくない」という意味で使われます。

そのため、readonly List<T>ではなく、ReadOnlyCollection<T>IReadOnlyList<T>を使うのが一般的です。

2. C#でreadonlyなListを作る主な方法

C#でListを読み取り専用として扱う方法はいくつかあります。

代表的な方法は次のとおりです。

2-1. ReadOnlyCollection<T>を使って読み取り専用にする

ReadOnlyCollection<T>は、要素の追加・削除・変更を直接行えない読み取り専用コレクションです。

C#
using System.Collections.ObjectModel;

List<string> list = new List<string> { "A", "B", "C" };
ReadOnlyCollection<string> readOnlyList = list.AsReadOnly();

ReadOnlyCollection<T>にはAddRemoveが用意されていないため、利用側から要素を変更できません。

C#
// readOnlyList.Add("D"); // コンパイルエラー

ただし、元になったList<T>を変更すると、ReadOnlyCollection<T>にもその変更が反映される点には注意が必要です。

2-2. IReadOnlyList<T>として公開する

IReadOnlyList<T>は、読み取り専用のListとして扱うためのインターフェイスです。

C#
private readonly List<string> _items = new List<string>();

public IReadOnlyList<string> Items => _items;

このように公開すると、外部からはItems[0]のように要素を参照できますが、AddRemoveは呼び出せません。

C#
var item = sample.Items[0];

// sample.Items.Add("D"); // コンパイルエラー

APIやクラス設計では、内部ではList<T>を使い、外部にはIReadOnlyList<T>として公開するパターンがよく使われます。

2-3. AsReadOnly()でList<T>からReadOnlyCollection<T>を作る

List<T>にはAsReadOnly()メソッドがあります。

これを使うと、既存のList<T>からReadOnlyCollection<T>を作成できます。

C#
List<int> numbers = new List<int> { 1, 2, 3 };

ReadOnlyCollection<int> readOnlyNumbers = numbers.AsReadOnly();

AsReadOnly()は、Listの内容をコピーして新しいコレクションを作るのではなく、元のListをラップした読み取り専用ビューを返します。

そのため、元のListを変更すると、読み取り専用コレクション側の内容も変わります。

C#
numbers.Add(4);

Console.WriteLine(readOnlyNumbers.Count); // 4

2-4. 配列やImmutableList<T>を使う選択肢もある

読み取り専用のデータを扱いたい場合、配列やImmutableList<T>を使う選択肢もあります。

配列は固定長なので、要素数の追加や削除はできません。

C#
string[] names = { "Alice", "Bob" };

ただし、配列の各要素は変更できます。

C#
names[0] = "Carol"; // 変更できる

完全に不変なコレクションを使いたい場合は、System.Collections.ImmutableImmutableList<T>が候補になります。

C#
using System.Collections.Immutable;

ImmutableList<string> names = ImmutableList.Create("Alice", "Bob");
ImmutableList<string> newNames = names.Add("Carol");

ImmutableList<T>では、要素を追加しても元のインスタンスは変更されず、新しいインスタンスが返されます。

3. ReadOnlyCollection<T>を使ったreadonlyなListの作り方

ReadOnlyCollection<T>は、C#でreadonlyなListを作りたいときに使いやすいクラスです。

特に、既存のList<T>を外部から変更されたくない形で公開したい場合に便利です。

3-1. List<T>.AsReadOnly()の基本的な使い方

List<T>.AsReadOnly()を使うと、List<T>からReadOnlyCollection<T>を簡単に作成できます。

C#
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;

List<string> fruits = new List<string>
{
"Apple",
"Banana",
"Orange"
};

ReadOnlyCollection<string> readOnlyFruits = fruits.AsReadOnly();

foreach (string fruit in readOnlyFruits)
{
Console.WriteLine(fruit);
}

readOnlyFruitsは読み取り専用として扱えるため、利用側は要素の参照だけを行えます。

C#
Console.WriteLine(readOnlyFruits[0]); // Apple
Console.WriteLine(readOnlyFruits.Count); // 3

3-2. ReadOnlyCollection<T>のサンプルコード

クラスのプロパティとしてReadOnlyCollection<T>を公開する例を見てみましょう。

C#
using System.Collections.Generic;
using System.Collections.ObjectModel;

public class Menu
{
private readonly List<string> _items = new List<string>();

public ReadOnlyCollection<string> Items => _items.AsReadOnly();

public void AddItem(string item)
{
_items.Add(item);
}
}

このクラスでは、内部的にはList<string>を使って要素を管理しています。

一方で、外部にはReadOnlyCollection<string>として公開しているため、呼び出し側は直接AddRemoveを実行できません。

C#
var menu = new Menu();

menu.AddItem("Home");
menu.AddItem("About");

Console.WriteLine(menu.Items[0]); // Home

// menu.Items.Add("Contact"); // コンパイルエラー

このように、内部では変更可能、外部からは読み取り専用という設計ができます。

3-3. ReadOnlyCollection<T>ではAdd・Removeができない理由

ReadOnlyCollection<T>は、名前のとおり読み取り専用のコレクションです。

List<T>のようにAddRemoveClearといった変更用メソッドを公開していません。

C#
ReadOnlyCollection<string> items = new List<string>
{
"A",
"B"
}.AsReadOnly();

// items.Add("C"); // コンパイルエラー
// items.Remove("A"); // コンパイルエラー

そのため、ReadOnlyCollection<T>型として受け取った側は、コレクションの中身を直接変更できません。

ただし、これはあくまでReadOnlyCollection<T>経由では変更できないという意味です。

元のList<T>を持っている側は、引き続き中身を変更できます。

3-4. 元のList<T>を変更するとReadOnlyCollection<T>にも反映される点に注意

ReadOnlyCollection<T>を使うときに特に注意したいのが、元のList<T>との関係です。

AsReadOnly()で作成したReadOnlyCollection<T>は、元のListをラップしているだけです。

C#
List<string> list = new List<string> { "A", "B" };
ReadOnlyCollection<string> readOnlyList = list.AsReadOnly();

list.Add("C");

Console.WriteLine(readOnlyList.Count); // 3
Console.WriteLine(readOnlyList[2]); // C

readOnlyList自体からは追加できませんが、元のlistを変更すると、その変更がreadOnlyListにも見えます。

外部に元のList<T>も渡してしまうと、読み取り専用にした意味が薄れてしまいます。

C#
public List<string> Items { get; } = new List<string>(); // 外部から変更できる

安全に扱いたい場合は、元のList<T>privateにして、外部にはReadOnlyCollection<T>IReadOnlyList<T>だけを公開する設計にしましょう。

4. IReadOnlyList<T>を使ったreadonlyなListの作り方

IReadOnlyList<T>は、C#で読み取り専用のリストを表現するためによく使われるインターフェイスです。

特に、クラスのプロパティやメソッドの戻り値として「変更できないListとして扱ってほしい」と伝える場合に向いています。

4-1. IReadOnlyList<T>とは何か

IReadOnlyList<T>は、インデックスによる要素アクセスと要素数の取得ができる読み取り専用のインターフェイスです。

主に次のような操作ができます。

C#
IReadOnlyList<string> items = new List<string> { "A", "B", "C" };

Console.WriteLine(items[0]); // A
Console.WriteLine(items.Count); // 3

一方で、AddRemoveのような変更用メソッドはありません。

C#
// items.Add("D"); // コンパイルエラー

つまり、IReadOnlyList<T>として扱うことで、利用側に対して「このリストは読み取り専用として使ってください」という意図を明確にできます。

4-2. List<T>をIReadOnlyList<T>として公開するサンプルコード

内部ではList<T>を使い、外部にはIReadOnlyList<T>として公開する例です。

C#
using System.Collections.Generic;

public class Order
{
private readonly List<string> _products = new List<string>();

public IReadOnlyList<string> Products => _products;

public void AddProduct(string product)
{
_products.Add(product);
}
}

利用側はProductsを読み取ることはできます。

C#
var order = new Order();

order.AddProduct("Keyboard");
order.AddProduct("Mouse");

Console.WriteLine(order.Products[0]); // Keyboard
Console.WriteLine(order.Products.Count); // 2

しかし、ProductsIReadOnlyList<string>として公開されているため、直接追加はできません。

C#
// order.Products.Add("Monitor"); // コンパイルエラー

4-3. 外部からの変更を防ぐプロパティ設計

外部からの変更を防ぐためには、List<T>をそのまま公開しないことが重要です。

避けたい例は次のような設計です。

C#
public class BadSample
{
public List<string> Items { get; } = new List<string>();
}

この場合、呼び出し側から自由に変更できます。

C#
var sample = new BadSample();

sample.Items.Add("A");
sample.Items.Clear();

外部から変更されたくない場合は、内部のListをprivateにして、公開する型をIReadOnlyList<T>にします。

C#
public class GoodSample
{
private readonly List<string> _items = new List<string>();

public IReadOnlyList<string> Items => _items;

public void AddItem(string item)
{
_items.Add(item);
}
}

この設計にすると、クラスの外側からは読み取り専用として扱えます。

4-4. IReadOnlyList<T>でも内部のList<T>は変更できる点に注意

IReadOnlyList<T>として公開しても、内部で保持しているList<T>は変更できます。

C#
public class Sample
{
private readonly List<int> _numbers = new List<int>();

public IReadOnlyList<int> Numbers => _numbers;

public void AddNumber(int number)
{
_numbers.Add(number);
}
}

この場合、外部からはNumbers.Add()を呼べません。

しかし、クラス内部では_numbers.Add()を呼べます。

C#
var sample = new Sample();

sample.AddNumber(1);
sample.AddNumber(2);

Console.WriteLine(sample.Numbers.Count); // 2

これは便利な設計ですが、「完全に不変」という意味ではありません。

あくまで「外部から変更しにくいように公開している」だけです。

完全に変更不可にしたい場合は、後述するImmutableList<T>を検討しましょう。

5. ReadOnlyCollection<T>とIReadOnlyList<T>の違い

C#でreadonlyなListを作るときに迷いやすいのが、ReadOnlyCollection<T>IReadOnlyList<T>の違いです。

どちらも読み取り専用のListを表現できますが、性質と使いどころが異なります。

5-1. ReadOnlyCollection<T>はクラス、IReadOnlyList<T>はインターフェイス

ReadOnlyCollection<T>はクラスです。

C#
ReadOnlyCollection<string> items;

一方、IReadOnlyList<T>はインターフェイスです。

C#
IReadOnlyList<string> items;

ReadOnlyCollection<T>は実体を持つコレクション型であり、List<T>をラップして読み取り専用として扱います。

一方で、IReadOnlyList<T>は「読み取り専用リストとして扱える」という契約を表す型です。

そのため、List<T>、配列、ReadOnlyCollection<T>など、さまざまな型をIReadOnlyList<T>として扱えます。

C#
IReadOnlyList<string> list1 = new List<string> { "A", "B" };
IReadOnlyList<string> list2 = new string[] { "A", "B" };
IReadOnlyList<string> list3 = new List<string> { "A", "B" }.AsReadOnly();

5-2. 使い方・用途・戻り値としての違い

ReadOnlyCollection<T>は、実際に読み取り専用のラッパーを作りたい場合に使います。

C#
ReadOnlyCollection<string> readOnlyItems = list.AsReadOnly();

一方で、IReadOnlyList<T>は、メソッドの戻り値やプロパティの型としてよく使われます。

C#
public IReadOnlyList<string> GetItems()
{
return _items;
}

API設計では、実装の詳細を隠すために、具体的なクラスであるReadOnlyCollection<T>よりも、インターフェイスであるIReadOnlyList<T>を返すことが多いです。

C#
public IReadOnlyList<string> Items => _items;

このようにしておけば、将来的に内部実装をList<T>から配列やImmutableList<T>に変えても、呼び出し側への影響を抑えやすくなります。

5-3. API設計ではIReadOnlyList<T>が向いているケース

APIやクラスのプロパティとして公開する場合は、IReadOnlyList<T>が向いています。

理由は、呼び出し側に対して必要最小限の機能だけを公開できるからです。

C#
public IReadOnlyList<string> Users => _users;

このプロパティを見た利用者は、「この一覧は読み取り専用として扱うものだ」と理解できます。

また、IReadOnlyList<T>はインターフェイスなので、内部実装の自由度も高くなります。

C#
private readonly List<string> _users = new List<string>();

public IReadOnlyList<string> Users => _users;

後から内部実装を変更しても、公開している型が変わらなければ、利用側のコードを大きく変更せずに済みます。

5-4. 実体として読み取り専用コレクションを返したい場合はReadOnlyCollection<T>

一方で、「実体として読み取り専用コレクションを返したい」場合は、ReadOnlyCollection<T>が向いています。

C#
public ReadOnlyCollection<string> Items => _items.AsReadOnly();

ReadOnlyCollection<T>は、List<T>を読み取り専用のビューとしてラップします。

そのため、戻り値として明確にReadOnlyCollection<T>を返したい場合に使えます。

ただし、毎回AsReadOnly()を呼ぶと、そのたびにラッパーオブジェクトが作られる点には注意が必要です。

必要に応じて、読み取り専用コレクションをフィールドとして保持する方法もあります。

C#
public class Sample
{
private readonly List<string> _items;
private readonly ReadOnlyCollection<string> _readOnlyItems;

public Sample()
{
_items = new List<string>();
_readOnlyItems = _items.AsReadOnly();
}

public ReadOnlyCollection<string> Items => _readOnlyItems;
}

6. readonlyなListを安全に扱うための実装パターン

C#でreadonlyなListを安全に扱うには、単にreadonlyIReadOnlyList<T>を使うだけでは不十分な場合があります。

重要なのは、外部から変更できる経路を作らないことです。

6-1. privateなList<T>とpublicなIReadOnlyList<T>を組み合わせる

最もよく使われる実装パターンは、内部ではprivate List<T>を持ち、外部にはpublic IReadOnlyList<T>として公開する方法です。

C#
public class Team
{
private readonly List<string> _members = new List<string>();

public IReadOnlyList<string> Members => _members;

public void AddMember(string member)
{
_members.Add(member);
}
}

この設計では、クラス内部では自由にListを操作できます。

一方で、クラス外部からは読み取り専用として扱われます。

C#
var team = new Team();

team.AddMember("Alice");
team.AddMember("Bob");

foreach (var member in team.Members)
{
Console.WriteLine(member);
}

// team.Members.Add("Carol"); // コンパイルエラー

外部にList<T>を直接公開しないことがポイントです。

6-2. コンストラクタでリストを受け取りコピーして保持する

コンストラクタで外部からListを受け取る場合は、そのまま保持しないようにしましょう。

悪い例です。

C#
public class UserGroup
{
private readonly List<string> _users;

public IReadOnlyList<string> Users => _users;

public UserGroup(List<string> users)
{
_users = users;
}
}

この場合、呼び出し元が持っているusersを変更すると、UserGroup内部の状態も変わってしまいます。

C#
var users = new List<string> { "Alice", "Bob" };
var group = new UserGroup(users);

users.Add("Carol");

Console.WriteLine(group.Users.Count); // 3

安全にしたい場合は、コンストラクタでコピーして保持します。

C#
public class UserGroup
{
private readonly List<string> _users;

public IReadOnlyList<string> Users => _users;

public UserGroup(IEnumerable<string> users)
{
_users = users.ToList();
}
}

こうすることで、呼び出し元のListが変更されても、クラス内部のListには影響しません。

6-3. ToList()やToArray()で防御的コピーを作る

外部から渡されたコレクションや、外部へ返すコレクションは、防御的コピーを作ることで安全性を高められます。

C#
public class Settings
{
private readonly List<string> _values;

public Settings(IEnumerable<string> values)
{
_values = values.ToList();
}

public IReadOnlyList<string> Values => _values.ToArray();
}

ToList()ToArray()を使うと、元のコレクションとは別のインスタンスを作れます。

ただし、プロパティのたびにToArray()ToList()を実行すると、呼び出し回数が多い場合にパフォーマンスへ影響することがあります。

必要に応じて、コンストラクタでコピーしておき、外部にはIReadOnlyList<T>として公開する設計がよく使われます。

C#
public class Settings
{
private readonly List<string> _values;

public IReadOnlyList<string> Values => _values;

public Settings(IEnumerable<string> values)
{
_values = values.ToList();
}
}

この形であれば、少なくとも外部から渡された元コレクションの変更には影響されません。

6-4. 外部に元のList<T>を渡さない設計にする

readonlyなListを安全に扱ううえで、最も重要なのは、元のList<T>を外部に渡さないことです。

次のような設計は避けましょう。

C#
public class BadSample
{
public List<string> Items { get; } = new List<string>();
}

また、メソッドで内部のListをそのまま返すのも危険です。

C#
public List<string> GetItems()
{
return _items;
}

この場合、呼び出し側が内部状態を直接変更できてしまいます。

C#
sample.GetItems().Clear();

外部に公開する場合は、IReadOnlyList<T>ReadOnlyCollection<T>を使います。

C#
public IReadOnlyList<string> GetItems()
{
return _items;
}

より安全にしたい場合は、防御的コピーを返します。

C#
public IReadOnlyList<string> GetItems()
{
return _items.ToArray();
}

どの方法を選ぶかは、「外部からの変更を防ぎたいだけなのか」「元リストの変更も反映させたくないのか」「完全に不変にしたいのか」によって変わります。

7. 完全に変更不可にしたい場合はImmutableList<T>を使う

ReadOnlyCollection<T>IReadOnlyList<T>は、読み取り専用として公開するには便利です。

しかし、元のListを変更できる場合、その変更は読み取り側にも影響します。

完全に変更不可なコレクションを使いたい場合は、ImmutableList<T>を検討しましょう。

7-1. ImmutableList<T>とは何か

ImmutableList<T>は、不変なListを扱うためのコレクションです。

System.Collections.Immutable名前空間に含まれています。

C#
using System.Collections.Immutable;

通常のList<T>は、AddRemoveを呼ぶと自身の中身が変更されます。

C#
var list = new List<string> { "A", "B" };
list.Add("C"); // list自体が変更される

一方、ImmutableList<T>では、追加や削除を行っても元のインスタンスは変更されません。

C#
var list = ImmutableList.Create("A", "B");
var newList = list.Add("C");

Console.WriteLine(list.Count); // 2
Console.WriteLine(newList.Count); // 3

7-2. ReadOnlyCollection<T>との違い

ReadOnlyCollection<T>は、元のListを読み取り専用として見せるためのラッパーです。

C#
var list = new List<string> { "A", "B" };
var readOnlyList = list.AsReadOnly();

list.Add("C");

Console.WriteLine(readOnlyList.Count); // 3

元のListが変更されると、ReadOnlyCollection<T>にも変更が反映されます。

一方、ImmutableList<T>は、コレクション自体が不変です。

C#
var list = ImmutableList.Create("A", "B");
var newList = list.Add("C");

Console.WriteLine(list.Count); // 2
Console.WriteLine(newList.Count); // 3

元のlistは変更されず、追加後の結果はnewListとして返されます。

つまり、ReadOnlyCollection<T>は「読み取り専用ビュー」、ImmutableList<T>は「不変コレクション」と考えるとわかりやすいです。

7-3. ImmutableList<T>の基本的な使い方

ImmutableList<T>を使うには、System.Collections.Immutableを参照します。

C#
using System.Collections.Immutable;

ImmutableList<string> names = ImmutableList.Create("Alice", "Bob");

空のImmutableList<T>を作ることもできます。

C#
ImmutableList<string> names = ImmutableList<string>.Empty;

要素を追加する場合は、戻り値を受け取ります。

C#
names = names.Add("Carol");

複数の要素を追加することもできます。

C#
names = names.AddRange(new[] { "Dave", "Ellen" });

削除する場合も、新しいインスタンスが返されます。

C#
names = names.Remove("Alice");

7-4. 値を追加・削除すると新しいインスタンスが返る仕組み

ImmutableList<T>の重要な特徴は、変更操作をしても元のインスタンスを変更しないことです。

C#
var original = ImmutableList.Create("A", "B");

var added = original.Add("C");
var removed = added.Remove("A");

Console.WriteLine(original.Count); // 2
Console.WriteLine(added.Count); // 3
Console.WriteLine(removed.Count); // 2

このように、追加や削除のたびに新しいインスタンスが返ります。

そのため、複数の場所で同じリストを参照していても、意図しない変更が起きにくくなります。

ただし、通常のList<T>よりも使い方が少し異なり、操作のたびに戻り値を受け取る必要があります。

C#
names.Add("Alice"); // 戻り値を受け取らないと反映されない

names = names.Add("Alice"); // 正しい

完全な不変性が必要な設計では、ImmutableList<T>が有力な選択肢です。

8. readonlyなListのよくある間違い

C#でreadonlyなListを扱うときには、いくつかのよくある誤解があります。

特に、readonly List<T>IReadOnlyList<T>ReadOnlyCollection<T>の違いを曖昧にしたまま使うと、意図せず外部から変更される設計になりやすいです。

8-1. readonly List<T>で中身も変更不可になると誤解する

最も多い間違いは、readonly List<T>にすればListの中身も変更不可になると思ってしまうことです。

C#
private readonly List<string> _items = new List<string>();

このコードでは、_itemsに別のListを代入することはできません。

しかし、要素の追加や削除はできます。

C#
_items.Add("A");
_items.Remove("A");
_items.Clear();

readonlyは参照の再代入を防ぐだけであり、参照先のオブジェクトの状態変更までは防ぎません。

外部からListの中身を変更されたくない場合は、IReadOnlyList<T>ReadOnlyCollection<T>として公開する必要があります。

8-2. IReadOnlyList<T>にキャストすれば完全に安全だと思い込む

IReadOnlyList<T>として公開すれば、呼び出し側はAddRemoveを呼べません。

しかし、それだけで完全に安全になるわけではありません。

C#
List<string> list = new List<string> { "A", "B" };
IReadOnlyList<string> readOnlyList = list;

list.Add("C");

Console.WriteLine(readOnlyList.Count); // 3

元のList<T>を持っているコードが変更すれば、IReadOnlyList<T>側にも変更が見えます。

また、呼び出し側が実体がList<T>だと知っていてキャストできる場合、変更される可能性もあります。

C#
IReadOnlyList<string> readOnlyList = new List<string> { "A", "B" };

if (readOnlyList is List<string> list)
{
list.Add("C");
}

このようなリスクを避けたい場合は、元のListを外部に渡さない、防御的コピーを返す、ImmutableList<T>を使うといった対策が必要です。

8-3. 元のList<T>を外部に公開してしまう

内部のListをprivateにしていても、どこかで元のListを外部に渡してしまうと、外部から変更される可能性があります。

C#
public class Sample
{
private readonly List<string> _items = new List<string>();

public IReadOnlyList<string> Items => _items;

public List<string> GetRawItems()
{
return _items;
}
}

この場合、Itemsは読み取り専用でも、GetRawItems()から内部Listを取得できてしまいます。

C#
sample.GetRawItems().Clear();

readonlyなListとして安全に扱いたいなら、内部のList<T>を外部へ直接渡してはいけません。

必要な場合はコピーを返しましょう。

C#
public IReadOnlyList<string> GetItems()
{
return _items.ToArray();
}

8-4. 参照型要素のプロパティ変更までは防げない

ReadOnlyCollection<T>IReadOnlyList<T>は、コレクション自体の追加・削除を防ぐためのものです。

ただし、要素が参照型の場合、その要素のプロパティ変更までは防げません。

C#
public class User
{
public string Name { get; set; } = "";
}

var users = new List<User>
{
new User { Name = "Alice" }
};

IReadOnlyList<User> readOnlyUsers = users;

readOnlyUsers[0].Name = "Bob";

Console.WriteLine(readOnlyUsers[0].Name); // Bob

この例では、readOnlyUsersに対して要素の追加や削除はできません。

しかし、要素であるUserオブジェクトのNameプロパティは変更できてしまいます。

要素まで不変にしたい場合は、要素の型も不変に設計する必要があります。

C#
public record User(string Name);

または、プロパティのsetを外部に公開しない設計にします。

C#
public class User
{
public string Name { get; }

public User(string name)
{
Name = name;
}
}

コレクションを読み取り専用にすることと、要素オブジェクトを不変にすることは別の問題です。

9. 目的別のおすすめ実装方法

C#でreadonlyなListを作る方法は複数あります。

どれを選ぶべきかは、目的によって変わります。

9-1. 外部に読み取り専用で公開したい場合

クラス内部ではListを変更したいが、外部からは読み取り専用にしたい場合は、private List<T>public IReadOnlyList<T>の組み合わせがおすすめです。

C#
public class Article
{
private readonly List<string> _tags = new List<string>();

public IReadOnlyList<string> Tags => _tags;

public void AddTag(string tag)
{
_tags.Add(tag);
}
}

この方法はシンプルで、API設計としても自然です。

外部からはAddRemoveを呼べないため、内部状態をある程度守れます。

C#
var article = new Article();

article.AddTag("C#");
article.AddTag("readonly");

Console.WriteLine(article.Tags[0]);

// article.Tags.Add("List"); // コンパイルエラー

一般的なアプリケーション開発では、このパターンで十分なケースが多いです。

9-2. 元リストの変更も反映させたくない場合

元のListが変更されても影響を受けたくない場合は、防御的コピーを作ります。

C#
public class Article
{
private readonly List<string> _tags;

public IReadOnlyList<string> Tags => _tags;

public Article(IEnumerable<string> tags)
{
_tags = tags.ToList();
}
}

このようにコンストラクタでToList()しておけば、呼び出し元のコレクションとは別のListを保持できます。

C#
var tags = new List<string> { "C#", "readonly" };
var article = new Article(tags);

tags.Add("List");

Console.WriteLine(article.Tags.Count); // 2

さらに、外部へ返すたびにコピーを返す方法もあります。

C#
public IReadOnlyList<string> Tags => _tags.ToArray();

ただし、毎回コピーが発生するため、呼び出し頻度が高い場合はパフォーマンスに注意しましょう。

9-3. 完全な不変コレクションにしたい場合

完全に変更不可なコレクションを使いたい場合は、ImmutableList<T>がおすすめです。

C#
using System.Collections.Immutable;

public class Article
{
public ImmutableList<string> Tags { get; }

public Article(IEnumerable<string> tags)
{
Tags = tags.ToImmutableList();
}
}

ImmutableList<T>は、追加や削除をしても元のインスタンスが変更されません。

C#
var tags = ImmutableList.Create("C#", "readonly");

var newTags = tags.Add("List");

Console.WriteLine(tags.Count); // 2
Console.WriteLine(newTags.Count); // 3

複数の場所で同じコレクションを共有する場合や、状態を安全に扱いたい場合に向いています。

9-4. パフォーマンスを重視する場合

パフォーマンスを重視する場合は、不要なコピーを避けることが重要です。

例えば、毎回ToList()ToArray()を返すプロパティは、呼び出されるたびに新しいコレクションを作ります。

C#
public IReadOnlyList<string> Items => _items.ToArray();

データ量が少ない場合は問題になりにくいですが、大量データや高頻度アクセスではコストになります。

その場合は、内部のListをprivateで保持し、外部にはIReadOnlyList<T>として公開する方法が現実的です。

C#
private readonly List<string> _items = new List<string>();

public IReadOnlyList<string> Items => _items;

ただし、完全な不変性よりもパフォーマンスを優先する設計です。

安全性を高めたい場合は、初期化時にコピーして保持する、元のListを外部に公開しない、要素型も不変にするなどの対策を組み合わせましょう。

10. C#のreadonlyなListに関するFAQ

C#でreadonlyなListを扱うときによくある疑問をまとめます。

10-1. readonly List<T>とIReadOnlyList<T>は何が違う?

readonly List<T>は、フィールドへの再代入を禁止する書き方です。

C#
private readonly List<string> _items = new List<string>();

この場合、_itemsに別のListを代入することはできません。

しかし、AddRemoveは実行できます。

C#
_items.Add("A"); // OK

一方、IReadOnlyList<T>は、読み取り専用のリストとして扱うためのインターフェイスです。

C#
public IReadOnlyList<string> Items => _items;

IReadOnlyList<T>にはAddRemoveがないため、外部からは要素を追加・削除できません。

つまり、readonly List<T>は「参照の再代入を防ぐもの」、IReadOnlyList<T>は「読み取り専用のリストとして公開するもの」です。

10-2. ReadOnlyCollection<T>は本当に変更できない?

ReadOnlyCollection<T>経由では、要素の追加や削除はできません。

C#
var list = new List<string> { "A", "B" };
var readOnlyList = list.AsReadOnly();

// readOnlyList.Add("C"); // コンパイルエラー

ただし、元のList<T>を変更すると、ReadOnlyCollection<T>にも反映されます。

C#
list.Add("C");

Console.WriteLine(readOnlyList.Count); // 3

そのため、ReadOnlyCollection<T>は「完全に不変」ではなく、「そのコレクション経由では変更できない読み取り専用ビュー」と考えるのが正確です。

10-3. IReadOnlyList<T>にAddメソッドがないのはなぜ?

IReadOnlyList<T>は、読み取り専用のリストを表すインターフェイスだからです。

そのため、要素の参照や件数取得はできます。

C#
IReadOnlyList<string> items = new List<string> { "A", "B" };

Console.WriteLine(items[0]);
Console.WriteLine(items.Count);

しかし、要素を追加・削除するためのAddRemoveは定義されていません。

C#
// items.Add("C"); // コンパイルエラー

変更操作をしたい場合は、List<T>などの変更可能なコレクション型として扱う必要があります。

10-4. List<T>を完全に変更不可にするには?

List<T>自体を完全に変更不可にすることはできません。

List<T>は変更可能なコレクションとして設計されているため、参照を持っているコードはAddRemoveを実行できます。

完全に変更不可なコレクションを使いたい場合は、ImmutableList<T>を使います。

C#
using System.Collections.Immutable;

var list = ImmutableList.Create("A", "B");

var newList = list.Add("C");

Console.WriteLine(list.Count); // 2
Console.WriteLine(newList.Count); // 3

また、外部からの変更だけを防ぎたい場合は、IReadOnlyList<T>ReadOnlyCollection<T>として公開する方法でも十分なことがあります。

10-5. 配列とreadonlyなListはどちらを使うべき?

配列は要素数が固定のコレクションです。

C#
string[] names = { "Alice", "Bob" };

要素の追加や削除はできませんが、要素の差し替えはできます。

C#
names[0] = "Carol"; // 変更できる

一方、IReadOnlyList<T>ReadOnlyCollection<T>は、読み取り専用のリストとして公開したい場合に向いています。

C#
public IReadOnlyList<string> Names => _names;

単純に固定されたデータを扱うだけなら配列でも問題ありません。

ただし、APIやクラス設計で「読み取り専用のリスト」として意図を明確にしたい場合は、IReadOnlyList<T>を使うのがおすすめです。

完全に不変なコレクションが必要なら、ImmutableList<T>を検討しましょう。

まとめ

C#でreadonlyなListを作りたい場合、readonly List<T>だけでは不十分です。

readonly修飾子は、フィールドの再代入を防ぐだけであり、Listの中身の追加・削除までは防ぎません。

C#
private readonly List<string> _items = new List<string>();

_items.Add("A"); // 実行できる

外部からListを変更されたくない場合は、IReadOnlyList<T>ReadOnlyCollection<T>として公開するのが基本です。

C#
private readonly List<string> _items = new List<string>();

public IReadOnlyList<string> Items => _items;

既存のList<T>から読み取り専用のビューを作りたい場合は、AsReadOnly()を使ってReadOnlyCollection<T>を作成できます。

C#
ReadOnlyCollection<string> readOnlyItems = _items.AsReadOnly();

ただし、ReadOnlyCollection<T>IReadOnlyList<T>は、元のListが変更されるとその影響を受ける場合があります。

元リストの変更も反映させたくない場合は、ToList()ToArray()で防御的コピーを作ることが大切です。

C#
_items = sourceItems.ToList();

完全に変更不可なコレクションが必要な場合は、ImmutableList<T>を使いましょう。

C#
ImmutableList<string> items = ImmutableList.Create("A", "B");

C#でreadonlyなListを設計するときは、次のように目的別に選ぶとわかりやすいです。

外部に読み取り専用で公開したいなら、IReadOnlyList<T>

既存のListを読み取り専用ビューとして見せたいなら、ReadOnlyCollection<T>

元リストの変更を反映させたくないなら、防御的コピー。

完全な不変コレクションが必要なら、ImmutableList<T>

readonly List<T>という書き方に頼るのではなく、「何を変更不可にしたいのか」を明確にして、適切な型と設計を選ぶことが重要です。