はじめに
やりたいことはタイトルの通りです。
List<T>.AddRangeというリストに複数の要素を一括して追加できるメソッドがありますが,実はこれはList<T>にしかないらしいです。
IList<T>とかも持っていませんが,あったら嬉しい場面はたまにあります。
Collection<T>の派生クラスに大量の要素を追加したい場面がありAddRangeがないことに気づき,困りながら実装を見ていたところ中身はIList<T>のラッパーであることが判明しました。
そして空のコンストラクタから作った場合には中身はList<T>になるようなので,だったらこれを触ればいいのではないかということになりました。
private readonly IList<T> itemsという感じでフィールドを1個だけ持っていて,これを触るのに古き良き方法ではリフレクションを使うのでしょうが,それでは結局パフォーマンス上かなり不利になってしまうので,危険な方法に頼ることにしました。
AddRangeって速いの?
場合によります。実装の概略は以下の通りです。
(今回の話題と関係ない部分は端折っています。コメントは筆者が追記した物です)
public void AddRange(IEnumerable<T> collection)
{
if (collection is ICollection<T> c)
{
// ICollection<T>だったらまとめて追加する
int count = c.Count;
if (count > 0) // 追加する要素が0個なら何もしなくていい
{
if (_items.Length - _size < count)
Grow(checked(_size + count)); // 内部で持っている配列のサイズを大きくする
c.CopyTo(_items, _size); // 配列の後ろにまとめてコピー
_size += count;
_version++;
}
}
else
{
// ICollection<T>ではないなら1つずつ追加する
using (IEnumerator<T> en = collection.GetEnumerator())
{
while (en.MoveNext())
Add(en.Current);
}
}
}
コードからわかるように,追加するコレクションがICollection<T>であるか否かでやっていることが全然違います。
ICollection<T>である場合には文字通り一括での追加になりますが,そうでなければ結局は1つずつ追加することになります。
要素を追加する際には内部で持っている配列の長さが十分であることを確認し,足りない場合には長さを2倍に伸ばして既存の配列の中身をすべてコピーします。
ここでアロケーション+コピーのコストが発生し,パフォーマンスを悪化させます。
一括追加では最初に必要な長さまで配列を伸ばすため,要素が多い場合にはこちらの方が多少マシになります。
追加したいコレクションがICollection<T>でない場合には普通にAddを複数回呼ぶのと変わらないので,今回紹介するかなり危険な方法を使うメリットはないと言えるでしょう。
あるいは,List<T>にはEnsureCapacityという内部配列の長さを指定した値まで伸ばすメソッドが用意されているので,追加したい長さが既知であれば事前に呼んでおくことで伸長のコストを抑えられるかもしれません。
いずれにしても,この記事では怪しい方法を紹介しているので,実行時に落ちるかもしれないリスクとパフォーマンス上の利益をよく考慮した上でご利用ください。
やってみよう
unsafeは使いませんが普通に危険です。危険なC#に慣れていない方は諦めて素直にAddを何回も叩いてください。
using System.Collections.ObjectModel;
using System.Runtime.CompilerServices;
namespace Qiita;
internal static class CollectionHelper
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void AddRange<T>(this Collection<T> @this, IEnumerable<T> collection)
{
var wrapper = Unsafe.As<CollectionWrapper<T>>(@this);
wrapper.AddRange(collection);
}
private class CollectionWrapper<T>
{
private readonly IList<T> items = null!;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal void AddRange(IEnumerable<T> collection)
{
var list = Unsafe.As<List<T>>(this.items);
list.AddRange(collection);
}
}
}
var collection = new Collection<int>();
collection.AddRange([3, 3, 4]);
List<T>以外のコレクションをコンストラクタに渡して初期化した場合落ちます。
より安全なバージョンが下にあります。
やっていること
そんなに難しいことはしていません。
プライベートフィールドに触るために,Collection<T>と同じフィールドを持つ適当なクラスを作ります。
Unsafe.As<T>とかいうとんでもなく危険なメソッドを使って,Collection<T>を自前のクラスと見なします。
当然ですがCollection<T>の実装が変わったら詰みます。
さらに,触れるようになったIList<T> itemsの実装がList<T>であると信じて無理矢理List<T>にした上で(再びUnsafe.As<T>),AddRangeを呼んであげます。
繰り返しになりますが違う実体が入っていたら詰みます。
Unsafe.Asって何だよ
ソースコード上は
[Intrinsic]
[NonVersionable]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[return: NotNullIfNotNull(nameof(o))]
public static T As<T>(object? o) where T : class?
{
throw new PlatformNotSupportedException();
// ldarg.0
// ret
}
となっておりましてそのままだと例外を投げるだけですが,Intrinsic属性が付いていることによりJIT時に特殊対応が入り別のコードに置き換わります。1
書き換える先はおそらくコメントで書いてあるILなのでしょうが,これは「引数をスタックに積む,スタックの先頭を返す」ということで,要するに素通しです。
キャストとかそんな高尚なことは考えず,もらったものをそのまま横流しにするだけです。
それだけのことですが(普通の)C#では表現できません。
逆に考えると,型安全性はC#コンパイラが頑張って保証してくれていただけで,.NETの仕組みだけでは意外と適当と捉えることもできるでしょう。
安全バージョン
List<T>以外の実体がフィールドに入っている場合には,以下のようにすると安全になり落ちなくなります。
(List<T>じゃなかったら素直に一つずつ足すようにフォールバック)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal void AddRange(IEnumerable<T> collection)
{
if (this.items is List<T> list) list.AddRange(collection);
else AddRangeForEach(collection);
}
private void AddRangeForEach(IEnumerable<T> collection)
{
foreach (var item in collection)
this.items.Add(item);
}
ただし,C#としての丁寧な型チェックが入るためパフォーマンスは多少落ちます(そんなに気にするほどの差にはならないと思いますが)。
elseの中でforeachを呼ぶのではなく別メソッドに切り出すことでAddRangeのインライン化が期待できます(反復処理はインライン化を阻害する)。
感想
Unsafeクラスやばい。
こんな黒魔術がunsafeコンテキストの外でできてしまうなんて…
繰り返しになりますが,今回紹介したコードは使い方を間違えると飛びます。
用法に注意してご利用ください。
-
throwはインライン化を阻害しますが,Intrinsicで書き換えられたメソッドに対してはインライン化のチャンスがあるのでしょうか? (AggressiveInliningの意味はあるのか?) 詳しい方教えてほしいです。 ↩