はじめに
はじめましてluckinといいます。
今回はプロダクト開発中にZLinqに移行する場合
- そもそも移行できるのか?どれくらいコストがかかるのか?
- 移行するとどういうメリット・デメリットがあるのか?
にくわえてZLinqがどういう仕組みで0アロケーションを実現しているのか
ざっくり解説していきたいと思います。
そもそもなんでZLinqを導入しようと思ったの?
ZLinqはC#の神ことneueccさん開発されている(いつもお世話になっております。
ゼロアロケーションLinqライブラリとなっています。
https://github.com/Cysharp/ZLinq
Unityでリアルタイム3Dレンダリングを行う際などは、アロケーションの削減が欠かせません。
しかし0アロケーションを目指す場合はLinqの禁止、もしくは適切な箇所のみでのLinqの使用といった
過激もしくは複雑なルールを設定せざるをえません。
そこを解決できる可能性を秘めているのが今回のZLinqとなります。
Linqが0アロケーションで実装できると聞くと夢のような話に聞こえてきますよね?
今すぐ標準ライブラリに組み込むべきだと思うかもしれません。
ただZLinqには当然デメリットも存在します。
次の項では実際のLinqの実装とZLinqの実装を簡易的に実装してみて
実際にどのような形で0アロケーションが実現されているのか確認してみましょう。
(いいからZLinqが使えるのかどうかだけ結論を教えてくれって方は「ZLinqへの移行について」まで読み飛ばしてok
ざっくりどういう仕組みで0アロケーションを実現してるの?
長々と解説してしまったので畳んで起きます...
掻い摘んで解説している部分が多いですので
こちらに本項目で紹介したスクリプトのサンプルコードを貼っておきます。
また、本項目はC#のイテレータの仕組みの把握を前提しております。
イテレータの仕組みがわからないよ!って方は生成AIに聞くといい感じ回答が返ってくると思います。
ZLinqがどうやって0アロケーションを実現しているかを見る前に
そもそもなぜLinqはアロケーションが必須になってしまうIEnumerableというインターフェースを返すのでしょうか?
それはシンプルに様々なイテレーション処理(SelectやWhere)でIEnumerableをベースに抽象化しているからです。
ではそれをstructな型に置き換えられないかという疑問が当然出てきます。
実際System.Linq.SelectのSelectイテレータの簡易版を実装してみましょう。
public class Select<TSource, TResult> : IEnumerator<TResult>, IEnumerable<TResult>
{
private Func<TSource, TResult> _selector;
private IEnumerator<TSource> _enumerator;
object IEnumerator.Current => Current;
public TResult Current { get; private set; }
public Select(IEnumerable<TSource> source, Func<TSource, TResult> selector)
{
_enumerator = source.GetEnumerator();
_selector = selector;
}
public bool MoveNext()
{
if (_enumerator.MoveNext())
{
Current = _selector(_enumerator.Current);
return true;
}
Dispose();
return false;
}
public IEnumerator<TResult> GetEnumerator() => this;
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public void Dispose() => _enumerator?.Dispose();
public void Reset() => throw new NotSupportedException();
}
だいたいこんな感じになるかと思います。Linqはこれを拡張メソッド経由で生成しています。
// こんな感じの拡張メソッドとSelectイテレータでLinqは構成されている
public static Select<TSource, TResult> Select<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, TResult> selector)
=> new Select<TSource, TResult>(source, selector);
ではこれを実際にstructに置き換えてみます。
インターフェースで抽象化された実装をboxingせずに様々な実装のstruct(SelectやWhere)に置き換えるには
我らが悪友、Genericsを酷使すると良さそうです。
public struct StructSelect<TSource, TSourceEnumerable, TSourceEnumerator, TResult>
: IValueEnumerable<TResult, StructSelect<TSource, TSourceEnumerable, TSourceEnumerator, TResult>>
, IValueEnumerator<TResult>
where TSourceEnumerable : IValueEnumerable<TSource, TSourceEnumerator>
where TSourceEnumerator : IValueEnumerator<TSource>
{
private Func<TSource, TResult> _selector;
private TSourceEnumerator _enumerator;
public TResult Current { get; private set; }
public StructSelect(TSourceEnumerable source, Func<TSource, TResult> selector)
{
_enumerator = source.GetEnumerator();
_selector = selector;
}
public StructSelect<TSource, TSourceEnumerable, TSourceEnumerator, TResult> GetEnumerator() => this;
public bool MoveNext()
{
if (_enumerator.MoveNext())
{
Current = _selector(_enumerator.Current);
return true;
}
Dispose();
return false;
}
public void Reset() => throw new NotSupportedException();
public void Dispose() => _enumerator.Dispose();
}
こんな感じになりました。なんか割と簡単にいけそうですね?
ではこれのWhere版も作って拡張メソッドで実際に呼び出して見ましょう。
// AsEnumerableはIEnumerableをstructイテレータに変換するメソッド(詳細は最後の付録に添付するソース参照)
var enumerable = Enumerable.Range(0, 10).AsValueEnumerable()
.Select<int, FromEnumerable<int, IEnumerable<int>>, FromEnumerable<int, IEnumerable<int>>, int>(static x => x * 2)
.Where<int, StructSelect<int, FromEnumerable<int, IEnumerable<int>>, FromEnumerable<int, IEnumerable<int>>, int>, StructSelect<int, FromEnumerable<int, IEnumerable<int>>, FromEnumerable<int, IEnumerable<int>>, int>>(static y => y % 5 == 0);
我らが悪友Genericsの型宣言が爆発してしまいました...
このGenericsの型宣言を推論して省略できるようにしないと使い物にならなさそうです
この時点でSelect拡張メソッドは以下のように定義しています
public static StructSelect<TSource, TSourceEnumerable, TSourceEnumerator, TResult> Select<TSource, TSourceEnumerable, TSourceEnumerator, TResult>(this TSourceEnumerable source, Func<TSource, TResult> selector)
where TSourceEnumerable : IValueEnumerable<TSource, TSourceEnumerator>
where TSourceEnumerator : IValueEnumerator<TSource>
=> new StructSelect<TSource, TSourceEnumerable, TSourceEnumerator, TResult>(source, selector);
引数には型制約で必要な型を指定したTSourceEnumerableを渡していますので
一見すると推論でGenerics部分を省略できそうな気もしますが
残念ながらC#は型制約をヒントにした型推論は行ってくれません。
(https://github.com/dotnet/csharplang/discussions/6930
var list = new List<int>();
// Listはintと宣言しており、TestメソッドのTListはIList<TValue>の型制約を持っているので
// 一見するとTListとTValueを推論してくれそうだが
// C#では型制約をヒントにGenericsの型推論は行われないため以下のInvokeはコンパイルエラーになる
Test2(list);
// 明示的にGenericsを宣言すればコンパイルを通すことができるが
// これをStructLinqに適用すると前述のように型宣言が爆発してしまう...
Test2<List<int>, int>(list);
static void Test2<TList, TValue>(TList list) where TList : IEnumerable<TValue>
{
}
なので型を推論してもらうためには型制約をヒントに解決するのではなく
引数の型に型宣言に必要な型を全て含める必要がありそうです。
そこで以下のようにGenericsに必要な型全て宣言したWrapperクラスを用意し
その中にイテレータを格納します。
// Wrapperクラスを用意して、WrapperのGenerics型引数から型推論を行うようにすればGenerics型宣言を省略できる
var listInWrapper = new Wrapper<List<int>, int>(new List<int>());
Test3(listInWrapper);
static void Test3<TList, TValue>(Wrapper<TList, TValue> listInWrapper) where TList : IEnumerable<TValue>
{
}
class Wrapper<TEnumerable, T> where TEnumerable : IEnumerable<T>
{
public Wrapper(TEnumerable value)
{
}
}
ということでStructSelectとその拡張メソッドを以下のように改良します。
public static ValueEnumerable<TResult, StructSelect2<TSource, TSourceEnumerable, TSourceEnumerator, TResult>, StructSelect2<TSource, TSourceEnumerable, TSourceEnumerator, TResult>> Select2<TSource, TSourceEnumerable, TSourceEnumerator, TResult>(this ValueEnumerable<TSource, TSourceEnumerable, TSourceEnumerator> source, Func<TSource, TResult> selector)
where TSourceEnumerable : IValueEnumerable<TSource, TSourceEnumerator>
where TSourceEnumerator : IValueEnumerator<TSource>
=> new ValueEnumerable<TResult, StructSelect2<TSource, TSourceEnumerable, TSourceEnumerator, TResult>, StructSelect2<TSource, TSourceEnumerable, TSourceEnumerator, TResult>>(new StructSelect2<TSource, TSourceEnumerable, TSourceEnumerator, TResult>(source, selector));
public struct StructSelect2<TSource, TSourceEnumerable, TSourceEnumerator, TResult>
: IValueEnumerable<TResult, StructSelect2<TSource, TSourceEnumerable, TSourceEnumerator, TResult>>
, IValueEnumerator<TResult>
where TSourceEnumerable : IValueEnumerable<TSource, TSourceEnumerator>
where TSourceEnumerator : IValueEnumerator<TSource>
{
private Func<TSource, TResult> _selector;
private ValueEnumerable<TSource, TSourceEnumerable, TSourceEnumerator> _enumerator;
public TResult Current { get; private set; }
public StructSelect2(ValueEnumerable<TSource, TSourceEnumerable, TSourceEnumerator> source, Func<TSource, TResult> selector)
{
_enumerator = source.GetEnumerator();
_selector = selector;
}
public StructSelect2<TSource, TSourceEnumerable, TSourceEnumerator, TResult> GetEnumerator() => this;
public bool MoveNext()
{
if (_enumerator.MoveNext())
{
Current = _selector(_enumerator.Current);
return true;
}
Dispose();
return false;
}
public void Reset() => throw new NotSupportedException();
public void Dispose() => _enumerator.Dispose();
}
public struct ValueEnumerable<TSource, TSourceEnumerable, TSourceEnumerator>
: IValueEnumerable<TSource, ValueEnumerable<TSource, TSourceEnumerable, TSourceEnumerator>>
, IValueEnumerator<TSource>
where TSourceEnumerable : IValueEnumerable<TSource, TSourceEnumerator>
where TSourceEnumerator : IValueEnumerator<TSource>
{
private TSourceEnumerator _enumerator;
public TSource Current => _enumerator.Current;
public ValueEnumerable(TSourceEnumerable source)
{
_enumerator = source.GetEnumerator();
}
public ValueEnumerable<TSource, TSourceEnumerable, TSourceEnumerator> GetEnumerator() => this;
public bool MoveNext() => _enumerator.MoveNext();
public void Reset() => _enumerator.Reset();
public void Dispose() => _enumerator.Dispose();
}
ここでいうValueEnumerableが先程の型推論の例で紹介したWrapperに相当する構造体となります。
これを先程のように呼び出してみるとGenericsの型推論が無事行われるようになりました。
var enumerable = Enumerable.Range(0, 10).AsValueEnumerable2()
.Select2(static x => x * 2)
.Where2(static y => y % 5 == 0);
これがZLinqがstructで実現できている大まかな仕組みとなっています。
※今回は既存のLinqと比較するためにあえて冗長な実装となっています。ZLinqでも確かにメソッドチェーン毎に型宣言が徐々長くなっていきますが今回の事例ほど極端には増えません。
ZLinqへの移行について
まずZLinqに移行するにあたってどういうメリデメがあるのか見ていきましょう。
メリット
- 0アロケーション最高!
- オペレーターの内容によってはSIMDが適用されて爆速!
- DropInGeneratorによってコード修正無しでLinq実装がそのままでZLinqに置き換えられるように
- Spanも対応!
- その他様々な箇所での最適化
デメリット
- 0アロケーションだからといって全ての箇所でLinqの速度を上回る訳ではない
- ZLinqはstructをこれでもかというほど扱っているのでその分だけコピーのコストも発生している
- とはいえまぁ一般的には無視できるレベルだとは思う
- 型宣言が爆発する(移行時の問題点にて後述)
簡単に移行できるの?
メリットにも記載しましたが、比較的簡単に移行できます。
DropInGeneratorを使うことで現在Linqが使われている箇所を全てZLinqにコード変更無しで置き換えることができます。
ただし、このDropInGeneratorを使えばコンパイルまで一発で通るということではなく
後述する問題への対処が必要です。まずは移行手順からご紹介
ZLinq移行手順
今回はDomainアセンブリにのみZLinqの置き換えを適用するケースで導入手順をご紹介します。
- GitHubのドキュメントに従ってZLinqをインポートします
-
csc.rspファイルを作成して今回ZLinqに置き換えたいアセンブリのasmdefが配置されている同階層に配置- 中身
-langVersion:10 - 後述する
global usingのために必要
- 中身
-
CjProjModifierをインポートします
- IDE上で
global usingがエラーにならないようにcsprojを書き換える
- IDE上で
-
LangVersion.propsをファイルを作成してUnityのプロジェクトルートに配置します- 中身
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <PropertyGroup> <LangVersion>10.0</LangVersion> </PropertyGroup> </Project>
- 中身
- CsProjModifierを以下のように設定
-
AssemblyAttributes.csファイルを作成して今回ZLinqに置き換えたいアセンブリのasmdefが配置されている同階層に配置- 中身
global using ZLinq; [assembly: ZLinqDropIn("", DropInGenerateTypes.Everything)]
- 中身
- 最終的なディレクトリ構成
. |-- Assets | `-- Product | `-- Scripts | `-- Domain | |-- AssemblyAttributes.cs | |-- Domain.asmdef | `-- csc.rsp `-- LangVersion.props - ここまででうまく行っていればSourceGeneratorが働き、以下のようなスクリプトがIDE上で表示され、全てZLinqに置き換わっているはずです。
移行時の問題点
ZLinqの大まかな仕組みを読んで頂いた方はすでに気づいている方がいらっしゃると思いますが
ZLinqの0アロケーションはstructをGenericsによって数珠繋ぎにすることによって実現しています。
仕組み解説のところではメソッド呼び出し時のGenerics型宣言を推論で回避することで巨大な型宣言を隠蔽してました。
そして変数の格納にはvarを使っていました。
// 実際のZLinqでの書き方
var enumerable = Enumerable.Range( 0, 10 )
.AsValueEnumerable() // DropInGeneratorを使っている場合はこの行は省略
.Select( x => x * 2 )
.Where( y => y % 5 == 0 );
ここのvarの定義は
ValueEnumerable<SelectWhere<FromEnumerable<int>, int, int>, int>
となっています。前述の仕組み解説のところほどやばくはないですが
これを明示的に宣言しなきゃいけない(メンバ変数等)のは可読性やコーディングの手間の観点からかなり厳しい印象を受けます。
なのでZLinqを常に0アロケーションで扱いたい場合
メンバ変数やメソッド返り値を用いた遅延評価は可読性の観点からほぼ使えない可能性が高いです。
public class User
{
// メンバ変数に宣言すると定義が長くなってしまい記述するのも読むのも厳しい...
public ValueEnumerable<Select<ListSelectWhere<int, Character>, Character, Equipment>, Equipment> EquipmentsOnCharacter
=> _characterIds.AsValueEnumerable()
.Select(id => _characterRepository.Characters[id])
.Where(character => character.IsPartyMember)
.Select(character => character.Equipment);
}
そのため現実的には遅延評価を行う場合はアロケーション覚悟でIEnumerableに変換するのが良いかと思います。
IEnumerableに変換したとしても最適な実装を行えれば
アロケーションのサイズやアロケーション回数は通常のLinqと比較して少なくなるはずです。
しかし、残念ながらIEnumerableに変換する拡張メソッドはZLinq内で提供されていません。
そのため独自で実装する必要があります。
public class User
{
// こんな感じで少しのコストに目を瞑ってIEnumerableを使った方が健全
public IEnumerable<Equipment> EquipmentsOnCharacter
=> _characterIds.AsValueEnumerable()
.Select(id => _characterRepository.Characters[id])
.Where(character => character.IsPartyMember)
.Select(character => character.Equipment)
.AsIE(); // ここの中身は最後の付録コーナーで添付しておきます
}
最後に
いかがでしたでしょうか?
デメリットとしてAsIEといったやや苦しいケースはありますが
それ以外には即時評価時は0アロケーション、SIMDやSpan対応といった個人でメリットが大きいので個人的には移行して損はないのかなと思っております。
最後まで読んで頂きありがとうございました!
明日のカバー株式会社AdventCalendar2025は@sugar_king777さんとなります!
お楽しみにー