C#のArrayList・Hashtable・Queue・Stack:旧式コレクションとボックス化の実測
C#の非ジェネリックコレクションArrayList・Hashtable・Queue・Stackを.NET 10で動かし直し。実行ごとに変わるHashtableの順序、List<int>と比べたボックス化のコスト実測、安全な移行手順まで。
2026年11月改訂: この記事は2021年に韓国語で書いたコレクション入門を、日本語向けに書き直したものです。4つの例を .NET 10.0.14(C# 14)で動かし直したところ、どれも警告なしでコンパイルできましたが、Hashtable の例は実行するたびに出力順が変わりました。2021年版はそれを決まった結果のように載せていました。また、この4クラスが新規コードでは使わない旧式(レガシー)であることにも触れていませんでした。実際の出力、ボックス化のコスト実測、ジェネリックコレクションへの移行手順を加えています。
古い C# のコードや書籍、昔の Q&A サイトの回答で ArrayList、Hashtable、Queue、Stack に出会った人向けの記事です。先に結論を書くと、この4つ(System.Collections 名前空間)はジェネリックがなかった .NET 1.x 時代のコレクションで、今はそれぞれ List<T>、Dictionary<TKey, TValue>、Queue<T>、Stack<T> に置き換えるのが正解です。この記事が扱うのは旧式4クラスの動き、遅い理由、移行のしかたまでです。今のコレクションからどれを選ぶかは「C#コレクションの選び方」、List<T> の内部は「List
コレクションとは、そしてなぜこの4つが旧式なのか
コレクションは、複数の値を入れて、探して、取り出すためのデータ構造クラスです。配列は作った時点で長さが固定されますが(「C#の配列」参照)、コレクションは必要に応じて伸びます。2021年版で紹介した4クラスは、どれも要素を object として保存します。何でも入れられる代わりに、代償が2つあります。
intやdoubleなどの値型を入れるたびに、ヒープに小さな箱を作るボックス化が起きる。- 取り出すときは元の型にキャストが必要で、型が違えば実行時に
InvalidCastExceptionになる。
C# 2.0(2005年)でジェネリックが入り、System.Collections.Generic に型を指定できる後継クラスができました。ジェネリックの仕組みは「C#ジェネリック」で扱っています。旧クラスは .NET 10 でも削除されておらず [Obsolete] も付いていないので、以下の4つの例は警告0件でコンパイルされました。コンパイラは止めてくれないので、知っている人が避けるしかありません。
ArrayList:2021年の例を .NET 10 で実行
2021年の例(0〜9を入れて Remove(5) のあと Insert(5, 20))はそのまま動きました。元の出力の最後にあった「続行するには何かキーを押してください」に当たる行は Visual Studio が付けていたもので、dotnet run では出ません。
ArrayList alist = new ArrayList();
for (int i = 0; i < 10; i++) alist.Add(i);
alist.Remove(5); // 値が 5 の要素を探して削除
alist.Insert(5, 20); // インデックス 5 に 20 を挿入
alist:
0 1 2 3 4 5 6 7 8 9
0 1 2 3 4 20 6 7 8 9
2021年版は「Remove(5) と書くと6番目の要素が消える」と説明していましたが、正しくは値が 5 の最初の要素を消します。この例では値とインデックスがたまたま一致していたので結果が同じだっただけです。位置で消すなら RemoveAt(5) です。List<T> でも Remove(値) と RemoveAt(位置) は分かれているので、移行後もこの区別は大事です。
ArrayList の本当の問題は型チェックがないことです。古い API が点数の一覧を ArrayList で返すとします。
static ArrayList LegacyScores() => new ArrayList { 90, 75, "n/a", 88 };
int total = 0;
foreach (int s in LegacyScores()) total += s; // コンパイルは通る
foreach(int): InvalidCastException
コンパイラは何も言わず、3番目の "n/a" で実行時に落ちます。同じミスを List<int> ですると、ビルドの時点で止まります。
Program.cs(2,10): error CS1503: 引数 1: は 'string' から 'int' へ変換することはできません
Hashtable:列挙の順番は保証されない
2021年の Hashtable の例は、3つのキー(韓国語で「りんご」「トマト」「じゃがいも」)を入れて Keys で回して出力するもので、記事には1通りの出力順が載っていました。.NET 10 で同じ実行ファイルを5回動かし、出てきた値を順に並べるとこうなりました。
potato tomato apple
apple tomato potato
potato tomato apple
potato apple tomato
potato apple tomato
1つのバイナリから3通りの順番が出ています。Hashtable はキーのハッシュ値で格納位置を決めますが、.NET Core 以降の string.GetHashCode() はハッシュ衝突攻撃を防ぐためプロセスごとにランダムなシードを使います。2021年版の出力は、その日たまたま出た順番の1つだったわけです。Dictionary や HashSet も含め、ハッシュベースのコレクションでは列挙順に頼ってはいけません。
移行でいちばん引っかかるのは、存在しないキーを読んだときの違いです。
var ht = new Hashtable { ["Apple"] = "apple" };
Console.WriteLine(ht["Kiwi"] is null ? "null" : "value"); // null
var dict = new Dictionary<string, string> { ["Apple"] = "apple" };
_ = dict["Kiwi"]; // 例外
Console.WriteLine(dict.TryGetValue("Kiwi", out var v)); // False
Hashtable missing -> null
Dictionary missing -> KeyNotFoundException
TryGetValue -> False / null
Hashtable null key -> ArgumentNullException
if (ht[key] == null) で存在確認していたコードを機械的に Dictionary に置き換えると、その行で例外になります。最後の行のとおり、Hashtable もキーに null は受け付けません。
Queue と Stack:FIFO と LIFO
キューは先に入れたものから出る先入れ先出し(FIFO)、スタックは最後に入れたものから出る後入れ先出し(LIFO)です。2021年の例はどちらもそのまま動きました。キューの例の出力です。
Dequeue: one, Count: 3
Dequeue: two, Count: 2
Dequeue: three, Count: 1
Dequeue: four, Count: 0
Count が3から始まるのは、引数が左から順に評価されるからです。先に queue.Dequeue() が実行されて1つ減り、そのあと queue.Count が読まれます。スタックの例は元記事では実在する書籍のタイトル4つを積んでいましたが、ここでは中立的な名前に変えました。動作は同じです。2021年版にあった行列に並ぶ人の画像と本棚の写真は出どころがはっきりしない外部の画像だったため外し、上の自作の図に置き換えています。
ジェネリック版に移してもメソッド名は同じで、空のときに例外ではなく false を返す TryDequeue、TryPop、TryPeek(.NET Core 2.0 以降)が使えます。
Queue<string> q = new(["one", "two", "three"]);
Stack<string> st = new(["vol.1", "vol.2", "vol.3"]);
Console.WriteLine($"Dequeue={q.Dequeue()} Peek={q.Peek()} | Pop={st.Pop()} Peek={st.Peek()}");
Console.WriteLine($"TryPop on empty: {new Stack<int>().TryPop(out _)}");
Dequeue=one Peek=two | Pop=vol.3 Peek=vol.2
TryPop on empty: False
ボックス化のコスト実測:ArrayList と List
「ボックス化は遅い」を数字で確かめました。旧コードによくある形のまま、初期容量なしで Add して foreach で合計します。割り当て量は GC.GetAllocatedBytesForCurrentThread() の前後差、gen0 はその間に起きた第0世代 GC の回数です。
var list = new ArrayList();
for (int i = 0; i < n; i++) list.Add(i); // ボックス化
long sum = 0;
foreach (object o in list) sum += (int)o; // ボックス化解除
// List<int> 版、Hashtable(t[i] = i)/ Dictionary<int,int> 版も同じ形
.NET 10.0.14、Release ビルド、Apple Silicon Mac、ウォームアップ後に3回実行しました。
| 対象 | 1回目 | 2回目 | 3回目 | 割り当て | gen0 GC |
|---|---|---|---|---|---|
ArrayList(int 1,000万) |
603.5 ms | 531.2 ms | 568.0 ms | 484.9 MB | 42回 |
List<int>(int 1,000万) |
33.0 ms | 26.9 ms | 29.8 ms | 128.0 MB | 3回 |
Hashtable(int キー100万) |
118.4 ms | 117.0 ms | 114.3 ms | 130.3 MB | 15回 |
Dictionary<int,int>(100万) |
19.4 ms | 17.4 ms | 17.2 ms | 51.4 MB | 2回 |
リストで約19倍、ハッシュテーブルで約6倍の差でした。ArrayList の484.9MBは、ボックス化された int(1個24バイト)1,000万個と、伸びるたびに作り直される object[](参照1つ8バイト)の合計です。時間差の大部分は小さなオブジェクトを1,000万個作り、GC がそれを追跡するコストで、gen0 GC の回数(42回対3回)にそのまま表れています。容量を先に確保した場合の数値は「C#ジェネリック」に、List<int> の128.0MBの内訳は「List
ボックス化を「スタックの値をヒープにコピーすること」と説明する図をよく見かけますが、C# で「値型はスタックにある」は常には成り立ちません。配列や List<int> の中の int は最初からヒープ(配列オブジェクトの中)にあります。ボックス化の本質は場所ではなく、値1つごとに別のオブジェクトができることで、上の表の割り当て量と GC 回数がそのコストです。
旧コードを移行する手順
実務で ArrayList・Hashtable を取り除くときの順番です。
- 要素の型を確認する。 1種類だけなら
List<int>のようにそのまま置き換えます。複数の型が混ざっているならList<object>にせず、そのデータを表すrecordを作るほうが後で楽です。 - 変更できない外部 API が
ArrayListを返すなら、境界で1回だけ変換します。OfType<int>()は型の合わない要素を飛ばし、Cast<int>()は見つけた時点で例外を投げます。
List<int> scores = raw.OfType<int>().ToList();
Console.WriteLine($"OfType<int>: {string.Join(",", scores)} sum={scores.Sum()}");
var all = raw.Cast<int>().ToList(); // "n/a" で例外
OfType<int>: 90,75,88 sum=253
Cast<int>: InvalidCastException
Hashtableのnull比較をTryGetValueに置き換える。 1か所でも漏れるとコンパイルは通り、本番でKeyNotFoundExceptionになります。- 列挙順に頼っている箇所を探す。 帳票やファイル出力が「いつも同じ順番」だったなら、偶然だった可能性があります。順番が必要なら
SortedDictionaryやOrderByで明示します。 - 終わったと思ったら
using System.Collections;を消してみます。コンパイルエラーになる箇所が、まだ旧クラスを使っている場所です。
どのコレクションに移すか迷ったら、「C#コレクションの選び方」の判断図と検索速度の実測が次の手がかりになります。
確認環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14(net10.0 の既定値)、macOS(Apple Silicon Mac)。
よくある質問
ArrayList や Hashtable は今も使っていい?
.NET 10 でも警告なしでコンパイルできますが、新しいコードには使いません。Microsoft Learn の ArrayList のページも、新規開発には List<T> を推奨しています。int を1,000万個追加して合計する処理で、ArrayList は平均568ms・割り当て484.9MB、List<int> は30ms・128.0MBでした(.NET 10、Release、Apple Silicon Mac)。
Hashtable を foreach で回すと順番が毎回違うのはなぜ?
Hashtable はキーのハッシュ値で格納位置を決め、.NET Core 以降の string のハッシュ値はプロセスごとにランダム化されています。同じ実行ファイルを5回動かしたところ、同じ3つのキーが3通りの順番で出力されました。順番が必要なら SortedDictionary を使うか、キーを並べ替えてから回します。
Hashtable を Dictionary に置き換えるとき一番気をつける点は?
存在しないキーを読んだときの挙動です。Hashtable は null を返しますが、Dictionary<TKey, TValue> は KeyNotFoundException を投げます。ht[key] == null で存在確認していたコードは TryGetValue に書き換える必要があります。