自分用ZLinq辞書
この記事はZLinq v1.5.6を前提にしている。
基本形
using ZLinq;
var result = source
.AsValueEnumerable()
.Where(x => x.IsActive)
.Select(x => x.Score);
foreach (var score in result)
{
Console.WriteLine(score);
}
元データ → AsValueEnumerable() → 加工 → 列挙、という順序で読むと理解しやすい。
ZLinqは、標準LINQに近い書き味を保ちながら、メソッドチェーンで発生するアロケーションを抑えるためのLINQ実装だ。配列やList<T>だけでなく、Span、UnityのGameObject階層、ファイルシステム、JSONなども扱える。
ただし、「ZLinqに変えれば何をしても完全にゼロアロケーション」という意味ではない。ToArray()で新しい配列を作れば、その配列は当然アロケーションされる。ラムダ式が外側の変数をキャプチャすれば、クロージャが生成される場合もある。
ZLinqが主に消してくれるのは、LINQの列挙器やメソッドチェーンの途中で発生する余計なアロケーションだ。
索引
基本
| 項目 | 説明 |
|---|---|
AsValueEnumerable |
通常のコレクションをZLinqの列挙へ変換する。 |
Where |
条件に一致する要素だけを通す。 |
Select |
要素を別の値へ変換する。 |
foreach |
遅延実行されたクエリを列挙する。 |
First / Any / Count |
必要な結果だけを直接取得する。 |
ToArray / ToList |
結果を新しいコレクションとして確定する。 |
CopyTo |
既存の領域へ結果を書き込む。 |
ToArrayPool |
ArrayPoolから一時配列を借りて結果を保持する。 |
JoinToString |
列挙結果を文字列として連結する。 |
生成・集計
| 項目 | 説明 |
|---|---|
ValueEnumerable.Range |
連続値をZLinqの列挙として生成する。 |
Distinct |
重複を除外する。 |
OrderBy |
要素を並び替える。 |
GroupBy |
キーごとに要素をまとめる。 |
Sum / SumUnchecked |
合計を求める。オーバーフロー検査の有無を選べる。 |
拡張機能
| 項目 | 説明 |
|---|---|
| Drop-in replacement |
AsValueEnumerable()を書かずにZLinqを適用する。 |
| LINQ to Span |
Span<T>やReadOnlySpan<T>へLINQを適用する。 |
| LINQ to Tree | 木構造を親・子・子孫・兄弟方向へ探索する。 |
| Unity拡張 | GameObject、Transform、UI Toolkitの階層を探索する。 |
| LINQ to SIMD | 対応する処理をSIMDで高速化する。 |
注意点
| 項目 | 説明 |
|---|---|
| ゼロアロケーションの範囲 | 消えるアロケーションと、消えないアロケーションを区別する。 |
IEnumerable<T>との違い |
既存APIへそのまま渡せない場合がある。 |
awaitとyield |
.NET 9以降では列挙をまたいで保持できない。 |
| 同じ変数への再代入 | チェーンごとに型が変わるため再代入できない。 |
| 長すぎるチェーン | 小さな列挙ではstructのコピーコストが勝つ場合がある。 |
導入
通常の.NETプロジェクトではNuGetから導入する。
dotnet add package ZLinq
コード側では名前空間を追加する。
using ZLinq;
Unityでは、まずNuGetForUnityからZLinqを導入し、その後Package Managerの「Install package from git URL...」からUnity拡張を追加する。
https://github.com/Cysharp/ZLinq.git?path=src/ZLinq.Unity/Assets/ZLinq.Unity
Unity拡張を入れると、通常のZLinqに加えてGameObject、Transform、UI ToolkitのVisualElement階層を探索できる。
AsValueEnumerable
通常のコレクションをZLinqのValueEnumerableへ変換する。ZLinqを明示的に使う場合の入口になる。
int[] values = { 1, 2, 3, 4, 5 };
var query = values
.AsValueEnumerable()
.Where(x => x % 2 == 0)
.Select(x => x * 10);
配列だけでなく、List<T>、Dictionary<TKey, TValue>、HashSet<T>、Queue<T>、Stack<T>など、多くの標準コレクションに対応している。
List<Player> players = GetPlayers();
var alivePlayers = players
.AsValueEnumerable()
.Where(player => player.Hp > 0);
変数の型がIEnumerable<T>でも、実体が配列やList<T>なら、ZLinqがそれを判定して効率的に列挙できる。
void PrintAll(IEnumerable<int> source)
{
foreach (var value in source.AsValueEnumerable())
{
Console.WriteLine(value);
}
}
ただし、実体が独自のIEnumerable<T>なら、その列挙器の性質までは消せない。AsValueEnumerable()は魔法の最適化命令ではなく、ZLinqの列挙モデルへ接続する入口だ。
Where
条件に一致する要素だけを通す。標準LINQと同じ発想で使える。
var enemies = characters
.AsValueEnumerable()
.Where(x => x.Team == Team.Enemy);
条件を複数書くこともできる。
var targets = characters
.AsValueEnumerable()
.Where(x => x.Team == Team.Enemy)
.Where(x => x.Hp > 0)
.Where(x => x.Distance <= attackRange);
ただし、外側の変数を参照するとラムダ式がキャプチャを持つ場合がある。
float attackRange = 10f;
var targets = characters
.AsValueEnumerable()
.Where(x => x.Distance <= attackRange);
attackRangeを参照しているため、クエリ本体がゼロアロケーションでも、ラムダ式側でクロージャが生成される可能性がある。
キャプチャが不要ならstaticラムダにできる。
var alive = characters
.AsValueEnumerable()
.Where(static x => x.Hp > 0);
性能が重要な場所では、ZLinqだけを見るのではなく、ラムダ式が何を捕まえているかも確認したほうがいい。
Select
要素を別の値へ変換する。
var names = players
.AsValueEnumerable()
.Select(player => player.Name);
ゲーム内の表示用データへ変換する例。
var labels = players
.AsValueEnumerable()
.Where(player => player.Hp > 0)
.Select(player => $"{player.Name}: {player.Hp}");
ここでは補間文字列によって新しい文字列が作られる。ZLinqの列挙部分がゼロアロケーションでも、stringの生成は別の話だ。
「ZLinqを使ったのにGC Allocがある」ときは、Selectの中でオブジェクト、配列、文字列、匿名型を作っていないか確認すること。
foreach
ZLinqのクエリは基本的に遅延実行される。WhereやSelectを書いた時点では、まだ全要素の処理は終わっていない。foreachで列挙した時点で処理される。
var query = players
.AsValueEnumerable()
.Where(static player => player.Hp > 0)
.Select(static player => player.Name);
// ここで実際に列挙される
foreach (var name in query)
{
Console.WriteLine(name);
}
同じクエリを二回foreachすれば、元データも二回列挙される。
foreach (var item in query)
{
Use(item);
}
foreach (var item in query)
{
UseAgain(item);
}
処理が重い、元データが変化する、同じ結果を何度も使う、といった場合は、ToArray()やToArrayPool()で一度確定したほうがよい。
「遅延実行は速い」ではない。不要な中間コレクションを作らずに済む一方、再列挙すれば処理も再実行される。
First / Any / Count
全結果を配列にする必要がないなら、終端演算子で直接結果を取る。
bool hasBoss = enemies
.AsValueEnumerable()
.Any(static enemy => enemy.IsBoss);
Enemy nearest = enemies
.AsValueEnumerable()
.OrderBy(static enemy => enemy.Distance)
.First();
int aliveCount = players
.AsValueEnumerable()
.Count(static player => player.Hp > 0);
存在確認のためにCount() > 0と書くより、Any()のほうが意図が明確で、最初の一致で終了できる。
// 避けたい
bool exists = players
.AsValueEnumerable()
.Count(static x => x.Hp > 0) > 0;
// こちらのほうがよい
bool exists = players
.AsValueEnumerable()
.Any(static x => x.Hp > 0);
必要なのが一件だけなら、全件を実体化しないこと。これはZLinq以前にLINQ全般の基本だ。
ToArray / ToList
クエリ結果を新しい配列やリストとして確定する。
Player[] alivePlayers = players
.AsValueEnumerable()
.Where(static player => player.Hp > 0)
.ToArray();
List<string> names = players
.AsValueEnumerable()
.Select(static player => player.Name)
.ToList();
ここで作られる配列やList<T>は新しいオブジェクトなので、アロケーションされる。
ZLinqの「ゼロアロケーション」は、ToArray()まで無から配列を生み出すという意味ではない。新しい結果コレクションが必要なら、そのメモリは必要になる。
毎フレーム呼ぶ処理でToArray()を使うなら、本当に配列が必要かを考えるべきだ。単に順番に処理したいだけならforeach、既存の領域を再利用できるならCopyToが向いている。
CopyTo
結果を既存のSpan<T>やList<T>へ書き込む。結果用コレクションを再利用したい場合に使う。
Span<int> destination = stackalloc int[32];
int written = values
.AsValueEnumerable()
.Where(static x => x > 0)
.CopyTo(destination);
for (int i = 0; i < written; i++)
{
Console.WriteLine(destination[i]);
}
既存のList<T>へコピーすることもできる。
private readonly List<Player> buffer = new();
void CollectAlivePlayers(List<Player> players)
{
players
.AsValueEnumerable()
.Where(static player => player.Hp > 0)
.CopyTo(buffer);
}
List<T>版は対象のリストをクリアしてから結果を詰める。容量が足りなければList<T>側で拡張が発生するため、繰り返し使うなら必要な容量をあらかじめ確保しておくとよい。
ToArrayPool
一時的に結果を保持したいが、毎回新しい配列を作りたくない場合に使う。
using var pooled = players
.AsValueEnumerable()
.Where(static player => player.Hp > 0)
.ToArrayPool();
ReadOnlySpan<Player> span = pooled.Span;
foreach (var player in span)
{
Console.WriteLine(player.Name);
}
返されるPooledArray<T>はArrayPool<T>.Sharedから配列を借りる。usingを抜けると配列がプールへ返却される。
using var pooled = ValueEnumerable
.Range(1, 1000)
.ToArrayPool();
var memory = pooled.Memory;
var segment = pooled.ArraySegment;
var enumerable = pooled.AsEnumerable();
長期間保持したり、フィールドへ保存したりする用途には向かない。PooledArray<T>はstructなので、不用意にコピーすると同じ配列を複数回返却する危険もある。
基本は「using varで受け、そのスコープ内だけで使う」と考えること。
JoinToString
ZLinqの列挙結果を区切り文字付きの文字列へ変換する。
string names = players
.AsValueEnumerable()
.Select(static player => player.Name)
.JoinToString(", ");
ValueEnumerableはIEnumerable<T>ではないため、String.Joinへそのまま渡すと、意図しないオーバーロードが選ばれる場合がある。
// ZLinqではこちらを使う
string text = values
.AsValueEnumerable()
.JoinToString(",");
最終的な文字列そのものは生成されるため、当然アロケーションは発生する。ここで避けられるのは、連結前に余計な配列へ変換する処理だ。
ValueEnumerable.Range
連続値をZLinqの列挙として生成する。
var numbers = ValueEnumerable.Range(0, 10);
foreach (var number in numbers)
{
Console.WriteLine(number);
}
Enumerable.Range(...).AsValueEnumerable()とするより、最初からValueEnumerable.Range()を使ったほうがZLinq向けに扱いやすい。
ステップ付きのRangeや、DateTimeのRangeも用意されている。
var days = ValueEnumerable.Range(
new DateTime(2026, 6, 1),
7,
TimeSpan.FromDays(1));
foreach (var day in days)
{
Console.WriteLine(day);
}
SequenceやInfiniteSequenceもある。無限列挙を使う場合は、Takeなどで終了条件を付けること。
var firstTen = ValueEnumerable
.InfiniteSequence(0, 2)
.Take(10);
終了条件のない無限列挙をToArray()へ渡すのは、当然ながら終わらない。
Distinct
重複する要素を除外する。
var uniqueIds = players
.AsValueEnumerable()
.Select(static player => player.TeamId)
.Distinct();
Distinctは重複判定のために内部状態を必要とする。ZLinqはプーリングなどを使ってアロケーションを抑えるが、重複判定そのものの計算量やメモリ使用量が消えるわけではない。
要素数が少ないからと何も考えずに使うのではなく、元データ側で重複を持たない設計にできるなら、そのほうが単純な場合もある。
OrderBy
要素を並び替える。
var ranking = players
.AsValueEnumerable()
.OrderByDescending(static player => player.Score)
.ThenBy(static player => player.Name);
並び替えには全体の比較と一時的な保持が必要になる。Whereのように一要素ずつ流すだけの処理とは性質が違う。
毎フレームランキングを全件ソートするより、スコアが変わったときだけ更新する、上位数件だけ必要なら別のデータ構造を使う、といった設計のほうが効果的な場合もある。
ZLinqに置き換えることと、アルゴリズム自体を見直すことは別問題だ。
GroupBy
キーごとに要素をまとめる。
var teams = players
.AsValueEnumerable()
.GroupBy(static player => player.TeamId);
foreach (var team in teams)
{
Console.WriteLine($"Team: {team.Key}");
foreach (var player in team)
{
Console.WriteLine(player.Name);
}
}
GroupByも内部バッファを必要とする処理だ。ZLinqは実装上のアロケーションを抑えるが、グループを構築するための仕事まで無料になるわけではない。
単純に件数だけ欲しい場合は、環境によってはCountByのほうが意図を表しやすい。
var counts = players
.AsValueEnumerable()
.CountBy(static player => player.TeamId);
Sum / SumUnchecked
数値の合計を求める。
int totalScore = players
.AsValueEnumerable()
.Select(static player => player.Score)
.Sum();
Sum()はオーバーフローを検査する。検査が不要で、オーバーフロー時のラップアラウンドを許容できるなら、.NET 8以降ではSumUnchecked()を使える。
int total = values
.AsValueEnumerable()
.SumUnchecked();
速そうだからという理由だけでSumUnchecked()へ変えるのはよくない。合計が型の範囲を超える可能性があるなら、結果が壊れても例外が出ない。
「オーバーフローしないことを入力条件として保証できる」場合にだけ使うべきだ。
また、doubleのSumやAverageはSIMDによって加算順序が変わり、標準的な逐次加算と丸め誤差が異なる場合がある。浮動小数点数で完全一致を前提にしてはいけない。
Drop-in replacement
ZLinq.DropInGeneratorを導入すると、AsValueEnumerable()を書かなくてもZLinqの演算子を選べる。
dotnet add package ZLinq.DropInGenerator
アセンブリ属性で対象を指定する。
using ZLinq;
[assembly: ZLinqDropIn(
"MyGame",
DropInGenerateTypes.Collection)]
Collectionは配列、Span、Memory、Listを対象にする。Everythingを指定するとIEnumerable<T>まで含めて広範囲に置き換える。
[assembly: ZLinqDropIn(
"MyGame",
DropInGenerateTypes.Everything)]
これは便利だが、最初からEverythingを使うのは勧めにくい。どのLINQがZLinqへ置き換わったのか分かりにくくなり、IEnumerable<T>を要求する既存APIとの境界で問題が出やすい。
まずは明示的なAsValueEnumerable()で動作と制約を理解し、その後Collectionから段階的に導入するほうが安全だ。
UnityでDrop-in Generatorを使う場合、asmdefごとにアセンブリ属性を置く必要がある。
// AssemblyAttributes.cs
using ZLinq;
[assembly: ZLinqDropIn(
"MyGame",
DropInGenerateTypes.Array |
DropInGenerateTypes.List)]
Drop-in GeneratorをUnityで使う場合は、Incremental Source Generatorを扱えるUnity 2022.3.12f1以上が必要になる。
LINQ to Span
.NET 9とC# 13以降では、Span<T>やReadOnlySpan<T>へZLinqの演算子を適用できる。
Span<int> values = stackalloc int[] { 1, 2, 3, 4, 5 };
var query = values
.AsValueEnumerable()
.Where(static x => x % 2 == 1)
.Select(static x => x * x);
foreach (var value in query)
{
Console.WriteLine(value);
}
Span<T>はスタック上の領域や連続メモリを安全に扱うための型だ。通常のIEnumerable<T>へ変換せず、そのままLINQ風に処理できるのがZLinqの強みになる。
ただし、Spanを使えば必ず速くなるわけではない。要素数が少ない処理や一度しか呼ばれない処理では、可読性の差のほうが重要なこともある。
LINQ to Tree
木構造を、親、子、子孫、前後の兄弟という方向で探索する。
主な軸は、Ancestors、Children、Descendants、BeforeSelf、AfterSelf。それぞれに自身を含む形式も用意されている。
ファイルシステムでは、ディレクトリ以下を再帰的に探索できる。
using ZLinq;
var root = new DirectoryInfo(@"C:\GameData");
var jsonFiles = root
.Descendants()
.OfType<FileInfo>()
.Where(static file => file.Extension == ".json");
foreach (var file in jsonFiles)
{
Console.WriteLine(file.FullName);
}
JSON拡張ではSystem.Text.Json.Nodes.JsonNodeを対象に木構造を探索できる。JsonDocumentやJsonElementではなく、JsonNode向けの機能である点に注意すること。
dotnet add package ZLinq.Json
木構造を自前の再帰処理で毎回たどる代わりに、探索方向と絞り込みをチェーンで表現できる。
ただし、深い階層をDescendants()で全探索すれば、当然その分だけ処理は増える。LINQ風に書けることと、探索範囲が小さくなることは同じではない。
Unity拡張
Unity拡張では、GameObjectやTransformの階層をLINQ to Treeとして探索できる。
using UnityEngine;
using ZLinq;
public sealed class EnemyFinder : MonoBehaviour
{
[SerializeField] private Transform root;
private void Start()
{
var enemies = root
.Descendants()
.Where(static transform =>
transform.CompareTag("Enemy"));
foreach (var enemy in enemies)
{
Debug.Log(enemy.name);
}
}
}
特定のComponentだけを取得する場合はOfComponent<T>()を使える。
var damageables = root
.ChildrenAndSelf()
.OfComponent<Damageable>();
foreach (var damageable in damageables)
{
damageable.ResetHp();
}
UI ToolkitのVisualElement階層も探索できる。
var emptyButtons = document
.rootVisualElement
.Descendants()
.OfType<UnityEngine.UIElements.Button>()
.Where(static button =>
string.IsNullOrEmpty(button.text));
階層探索が簡潔になる一方、毎フレームDescendants()でシーン全体を探索する設計は重い。必要な参照を初期化時に収集する、生成・破棄時に管理する、キャッシュを更新する、といった設計も比較すべきだ。
なお、Unityでは.NET Standard 2.1を参照するため、ZLinqのSIMD最適化は利用されない。
LINQ to SIMD
.NET 8以降では、元データからSpanを取得できる場合、一部の集計や検索処理でSIMDが使われる。
対象にはSum、Average、Min、Max、Contains、SequenceEqualなどがある。
明示的にSIMD向け処理を書く場合はZLinq.Simdを使う。
using ZLinq.Simd;
int[] values = { 1, 2, 3, 4, 5 };
values.VectorizedUpdate(
static vector => vector * 10,
static value => value * 10);
一つ目のラムダがSIMD幅でまとめて処理する部分、二つ目が端数要素を処理する部分になる。
SIMDは常に有効とは限らない。型が対応していない、ハードウェアアクセラレーションが使えない、処理内容がベクトル化に向かない場合は通常のループになる。
また、SIMD化によって浮動小数点数の演算順序が変わることがある。数値計算では速度だけでなく、誤差の許容範囲も確認すること。
ゼロアロケーションの範囲
ZLinqの説明で最も誤解しやすい部分だ。
次のコードでは、列挙チェーン自体は余計な列挙器オブジェクトを作らずに処理できる。
foreach (var score in players
.AsValueEnumerable()
.Where(static player => player.Hp > 0)
.Select(static player => player.Score))
{
Consume(score);
}
一方、次の処理は結果オブジェクトを作る。
var array = players
.AsValueEnumerable()
.Where(static player => player.Hp > 0)
.ToArray();
次のSelectも、要素ごとに新しいオブジェクトを生成する。
var views = players
.AsValueEnumerable()
.Select(static player => new PlayerViewModel(player));
次のラムダは外側の変数を参照する。
int minimumScore = 100;
var highScorePlayers = players
.AsValueEnumerable()
.Where(player => player.Score >= minimumScore);
つまり、確認すべき場所は三つある。列挙器、終端処理、ラムダ式の中身だ。
ZLinqは列挙器とチェーンのコストを大きく減らす。しかし、ユーザーコードが明示的に作ったオブジェクトまでは消さない。
IEnumerable<T>との違い
ValueEnumerableはIEnumerable<T>ではない。したがって、IEnumerable<T>を引数に取る既存メソッドへそのまま渡せない場合がある。
void Process(IEnumerable<int> values)
{
}
var query = source
.AsValueEnumerable()
.Where(static x => x > 0);
// Process(query); // そのままでは渡せない
単純な解決策はToArray()だが、新しい配列が作られる。
Process(query.ToArray());
一時的な受け渡しならToArrayPool()も使える。
using var pooled = query.ToArrayPool();
Process(pooled.AsEnumerable());
ただし、pooled.AsEnumerable()をusingの外へ持ち出してはいけない。スコープを抜けた時点で内部配列はプールへ返却され、別の処理に再利用される可能性がある。
ZLinqを導入するときは、アプリケーション全体を一気に置き換えるより、IEnumerable<T>との境界が少ないホットパスから始めるほうが扱いやすい。
awaitとyield
.NET 9以降のValueEnumerableはref structになる。そのため、awaitやyieldをまたいで保持できない。
var query = source
.AsValueEnumerable()
.Where(static x => x > 0);
await Task.Delay(100);
// .NET 9以降ではqueryをawaitの前後へまたがせられない
foreachの途中でawaitする書き方もできない。
// .NET 9以降では不可
foreach (var item in query)
{
await ProcessAsync(item);
}
必要なら、awaitの前にToArray()やToArrayPool()で結果を確定する。
int[] items = query.ToArray();
foreach (var item in items)
{
await ProcessAsync(item);
}
ただし、非同期処理のために全件を配列化することが最善とは限らない。大量データなら、そもそもIAsyncEnumerable<T>やチャネル、バッチ処理を使う設計も検討したほうがよい。
同じ変数への再代入
ZLinqは演算子を一つ追加するたびに、異なる具体型を返す。
そのため、標準LINQで見かける次のような再代入はできない。
var query = source.AsValueEnumerable();
if (onlyActive)
{
// query = query.Where(...); // 型が変わるため代入できない
}
条件ごとにクエリを組み立てたい場合は、分岐ごとに最後まで処理するほうが単純だ。
if (onlyActive)
{
foreach (var item in source
.AsValueEnumerable()
.Where(static x => x.IsActive))
{
Use(item);
}
}
else
{
foreach (var item in source.AsValueEnumerable())
{
Use(item);
}
}
重複が気になるなら、条件を述語の中へ含める方法もある。
var query = source
.AsValueEnumerable()
.Where(x => !onlyActive || x.IsActive);
ただし、ここではonlyActiveをキャプチャする可能性がある。可読性、分岐、アロケーションのどれを優先するかは、実測して決めるべきだ。
長すぎるチェーン
ValueEnumerableはstructであり、演算子を追加するたびに列挙状態を型として内包する。チェーンが長くなるとstruct自体も少しずつ大きくなる。
var query = source
.AsValueEnumerable()
.Where(...)
.Select(...)
.Where(...)
.Select(...)
.Skip(...)
.Take(...)
.Distinct()
.OrderBy(...);
大量の要素を処理するなら、標準LINQより有利になることが多い。一方、数要素しかないコレクションへ極端に長いチェーンを適用すると、structのコピーコストが相対的に大きくなり、標準LINQより遅くなる場合がある。
だからといって、チェーンを見た瞬間に手動ループへ書き換える必要はない。まずは読みやすく書き、ProfilerやBenchmarkDotNetで問題がある箇所だけ測る。
「ゼロアロケーションだから必ず最速」ではない。アロケーションと実行時間は別の指標だ。
実戦的な組み合わせ
Unityで、アクティブな敵から最も近い一体を探す例。
using UnityEngine;
using ZLinq;
public sealed class TargetFinder : MonoBehaviour
{
[SerializeField] private Transform enemyRoot;
[SerializeField] private Transform player;
public Enemy FindNearestEnemy()
{
Vector3 playerPosition = player.position;
return enemyRoot
.Descendants()
.OfComponent<Enemy>()
.Where(static enemy =>
enemy.isActiveAndEnabled &&
enemy.Hp > 0)
.OrderBy(enemy =>
(enemy.transform.position - playerPosition).sqrMagnitude)
.FirstOrDefault();
}
}
このコードでは、DescendantsがTransform階層を探索し、OfComponent<Enemy>が対象Componentだけを取り出し、Whereが攻撃可能な敵へ絞り、OrderByが距離順に並べ、FirstOrDefaultが先頭の一体を返す。
ただし、この処理を毎フレーム呼ぶ設計が最善とは限らない。階層全体の探索と全件ソートが毎回発生するため、敵の登録リストを持つ、一定間隔で更新する、最小値だけを一回の走査で探す、といった方法のほうが速い可能性が高い。
たとえば最小値だけが必要なら、全件をOrderByする必要はない。利用できる環境ではMinByを使うほうが意図に合う。
return enemyRoot
.Descendants()
.OfComponent<Enemy>()
.Where(static enemy =>
enemy.isActiveAndEnabled &&
enemy.Hp > 0)
.MinBy(enemy =>
(enemy.transform.position - playerPosition).sqrMagnitude);
ZLinqは、非効率なアルゴリズムを高速化する免罪符ではない。読みやすいクエリを書けることと、必要な処理量を減らすことの両方が重要だ。
最初に覚えるもの
最初は、AsValueEnumerable、Where、Select、foreach、Any、FirstOrDefault、ToArray、CopyToだけで十分だ。
Unityなら、そこへDescendantsとOfComponent<T>を加えると、コレクション処理とTransform階層探索の多くを扱える。
ToArrayPool、Drop-in Generator、Span、SIMDは強力だが、必要になる前から使うものではない。まず明示的なAsValueEnumerable()で境界を見えるようにし、Profilerで効果を確認してから導入範囲を広げたほうが安全だ。
ZLinqの価値は、単に「LINQより速い」ことではない。標準LINQに近い表現力を保ちながら、ゲームや高頻度処理で問題になりやすい列挙コストを制御しやすくする点にある。
参考資料
この記事は、Cysharp/ZLinqの公式README、公式リリース情報、開発者によるアーキテクチャ解説を基に整理した。