動機
List<T>
は配列と違い、動的に要素を追加したり削除したりできることは誰でも知るところだろう。筆者のようなC#初心者は、恥ずかしながら今までのその仕組みを考えたことは一度もなく、ただ何となく便利なコレクションクラスとして使っていたわけだが、あるときList<T>
についてちゃんと調べようと思って、Microsoft.Docsを読んでいたら、以外と単純な仕組みで実装されていることを知ったので、簡単な検証を行ってみた。
前提
Microsoft.Docsを読めばクラスの全機能が分かるし、ご丁寧にコードサンプルまで書いてあるので非常にありがたいのだが、如何せん書き方が小難しくて敵わないし、表現に統一性がないのはどうにかならないのだろうか。まずは下記プロパティを見てほしい。
List<T>.Capacity プロパティ
C#publicintCapacity{get;set;}内部データ構造体がサイズ変更せずに格納できる要素の合計数を取得または設定します。
Capacity
は、サイズ変更が必要になる前にList<T>
が格納できる要素の数であり、Count
は実際にList<T>
内にある要素の数です。Capacity
は、常にCount
以上です。要素の追加中にCount
がCapacity
を超えた場合は、古い要素をコピーして新しい要素を追加する前に、内部配列が自動的に再割り当てされ、容量が増加します。
すなわち、List<T>
は内部的に要素数の固定された配列をラップしていて、アイテムの追加で限界値Capacity
を超えそうになったら、自動的に要素数の大きい配列を作り直して元データをコピーしているということらしい。
さらに、下記メソッドの注釈部分を見てみる。
List<T>.Add(T) メソッド
C#publicvoidAdd(Titem);
Count
が既にCapacity
に等しい場合は、内部配列が自動的に再割り当てされ、既存の要素が新しい配列にコピーされてから、新しい要素が追加されるまで、List<T>
の容量が増加します。Count
がCapacity
より小さい場合、このメソッドは $O(1)$ 操作になります。 新しい要素に対応するために容量を増やす必要がある場合、このメソッドは $O(n)$ 操作になります。ここで、$n$ はCount
です。
つまり、通常の値の追加であれば、既に確保されたメモリへの書き込みなので $O(1)$ 操作で済むが、Capacity
が足りなくなったら配列の作成とコピーの手間が発生するので、$O(n)$ 操作になるということらしい。
ここで、実際どのようにサイズ変更が行われているのか検証してみたいと思う。
観測
List<T>
に対してAdd
メソッドを繰り返したときに、Capacity
がどのように変更されるかを観測するため、以下のメソッドを用意した。
/// <summary>/// List<T> の Capacity がどのように変動するか観測します。/// </summary>/// <param name="list">List<T>。</param>/// <param name="length">探索する長さ。</param>privatestaticvoidViewCapacity<T>(List<T>list,intlength){varresult=newList<(intStart,intLast,intCapacity)>();(intCount,intCapacity)current=(list.Count,list.Capacity);for(inti=0;i<length;i++){list.Add(default(T));if(list.Capacity>current.Capacity){// Capacityに変化があった場合は記録result.Add((current.Count,list.Count-1,current.Capacity));current=(list.Count,list.Capacity);}}result.Add((current.Count,list.Count,list.Capacity));Console.WriteLine(" Count | Capacity");result.ForEach(r=>Console.WriteLine($"{r.Start.ToString().PadLeft(4)} - {r.Last.ToString().PadLeft(4)} | {r.Capacity.ToString().PadLeft(4)}"));}
List<T>
には下記のコンストラクタが用意されているので、各パターンでCapacity
の変化を観測する。
パターン | コンストラクタ | 説明 |
---|---|---|
1. | List() | 空で、既定の初期量を備えた、List<T> クラスの新しいインスタンスを初期化します。 |
2. | List(IEnumerable<T> collection) | 指定したコレクションからコピーした要素を格納し、コピーされる要素の数を格納できるだけの容量を備えた、List<T> クラスの新しいインスタンスを初期化します。 |
3. | List(int capacity) | 空で、指定した初期量を備えた、List<T> クラスの新しいインスタンスを初期化します。 |
パターン1. List()
- 特に何も考えず初期化した場合を観測してみる。
ViewCapacity(newList<int>(),1000);
Count|Capacity0-0|01-4|45-8|89-16|1617-32|3233-64|6465-128|128129-256|256257-512|512513-1000|1024
- 観測メモ
- 初期化された状態のとき、
Capacity
は0
になっている。 - 初めて項目を追加したときに、
Capacity
は4
が設定される。 - 以降、
Count
がCapacity
を超えようとすると、Capacity
は2倍になる。
- 初期化された状態のとき、
パターン2. List(IEnumerable<T> collection)
- 初期化時に要素数を与えた場合を観測してみる。
ViewCapacity(newList<string>(Enumerable.Repeat(string.Empty,100)),1000-100);
Count|Capacity100-128|128129-256|256257-512|512513-1000|1024
ViewCapacity(newList<string>(Enumerable.Repeat(string.Empty,128)),1000-128);
Count|Capacity128-128|128129-256|256257-512|512513-1000|1024
ViewCapacity(newList<string>(Enumerable.Repeat(string.Empty,129)),1000-129);
Count|Capacity129-256|256257-512|512513-1000|1024
- 観測メモ
- 初期化された状態のとき、
Count
に応じてCapacity
が自動でセットされる。 Capacity
の値と増加方法は、空のコンストラクタで初期化した場合と同じ。
- 初期化された状態のとき、
パターン3. List(int capacity)
- 最後に、初期化時に
Capacity
を指定するとどうなるのか試してみる。
ViewCapacity(newList<int>(3),1000);
Count|Capacity0-3|34-6|67-12|1213-24|2425-48|4849-96|9697-192|192193-384|384385-768|768769-1000|1536
ViewCapacity(newList<int>(100),1000);
Count|Capacity0-100|100101-200|200201-400|400401-800|800801-1000|1600
- 観測メモ
- 初期化時に与えられた
Capacity
が設定される。 - 以降、
Count
がCapacity
を超えようとすると、Capacity
は2倍になる。
- 初期化時に与えられた
以上の観測から、サイズが最初から判明している場合は、明示的にCapacity
を指定した方が内部的再定義の時間を短縮できるのでは、という推測が浮かんでくる。
計測
通常の初期化と明示的にCapacity
を指定した初期化で、その後Add
メソッドを繰り返した時の処理速度がどのくらい変わるか比較するため、下記のメソッドを用意した。
/// <summary>/// List<T> と List<T>(capacity) で Add() メソッドを繰り返したときの処理速度を比較します。/// </summary>privatestaticvoidCompareTime(){Console.WriteLine("Count | WithOut[ms] | With[ms] | percentage[%]");for(vari=0;i<9;i++){// 要素数は1, 10, 100, ..., 100000000 のパターンを試すvarlength=(int)Math.Pow(10,i);doubleTest(List<int>list){varwatch=newStopwatch();watch.Start();for(intk=0;k<length;k++)list.Add(0);watch.Stop();returnwatch.Elapsed.TotalMilliseconds;}vartimeSpans=(newList<double>(),newList<double>());for(intj=0;j<10;j++)// 試行回数:10回{timeSpans.Item1.Add(Test(newList<int>()));timeSpans.Item2.Add(Test(newList<int>(length)));}// それぞれの平均処理速度と速度改善率を計算varave1=timeSpans.Item1.Average();varave2=timeSpans.Item2.Average();Console.WriteLine($"10^{i} | {ave1:000.0000000} | {ave2:000.0000000} | {((ave2*100)/ave1):00.00}");}}
Count|WithOut[ms]|With[ms]|percentage[%]10^0|000.0003400|000.0000900|26.4710^1|000.0002200|000.0000700|31.8210^2|000.0021600|000.0002900|13.4310^3|000.0174000|000.0025900|14.8910^4|000.0923000|000.0220400|23.8810^5|000.4015200|000.2589700|64.5010^6|005.6044200|003.2822300|58.5710^7|064.1446900|033.7847300|52.6710^8|544.1132700|300.4292300|55.21
Count|WithOut[ms]|With[ms]|percentage[%]10^0|000.0003300|000.0000800|24.2410^1|000.0002000|000.0001100|55.0010^2|000.0016800|000.0003000|17.8610^3|000.0052400|000.0022200|42.3710^4|000.0699700|000.0215700|30.8310^5|000.3693000|000.2578000|69.8110^6|005.8541300|003.1537400|53.8710^7|067.5346200|031.0078600|45.9110^8|542.6710200|300.1461600|55.31
今回の計測だけで言えば、やはり明示的にCapacity
を指定した方が処理速度は速くなることが分かった。
終わりに
闇に包まれていたList<T>
の真の姿が見えてきて、少し親近感が湧いた気がする、、
処理速度計測はPCのスペックにもよるところなので、各々試されたい。
また他の言語、例えばc++でいうVector
の内部構造はどのようになっているのかも興味深いところである。