スポンサーリンク
Unity C#・スクリプト実装最適化・パフォーマンス

【初心者向け】Unityでラムダ式&LINQを活用するメリットと注意点

Unity C#・スクリプト実装

「ラムダ式」や「LINQ」という言葉、Unityのスクリプトを書いているとよく目にするけれど、なんとなく難しそうで避けている方も多いのではないでしょうか。

ネットで調べてみると「LINQは便利」という声もあれば「Unityでは重いから使わない方がいい」という声もあって、結局どっちなの?と迷ってしまいますよね。

この記事では、ラムダ式とLINQの基本的な書き方から、Unity開発の中で実際にどう使い分ければいいのかまで、順番に整理していきます。読み終える頃には、自分のコードのどの部分にLINQを使ってよくて、どの部分は避けるべきか、判断できるようになっているはずです。


UnityでLINQ・ラムダ式は使っていい?

結論からお伝えすると、LINQやラムダ式は「使っていい場面」と「できれば避けたい場面」がはっきり分かれています。全部使ってOKでも、全部NGでもないんです。

判断のポイントになるのは、そのコードが「どれくらいの頻度で実行されるか」という点です。

  • ゲーム起動時の初期化処理や、シーンを読み込むときの一度きりの処理
  • エディタ拡張やビルド前の処理など、プレイ中には動かない部分

こういった場所であれば、LINQを使ってコードをすっきり書いても、パフォーマンスへの影響はほとんど気にしなくて大丈夫です。むしろ読みやすさが上がって、後から見返したときに助かります。

逆に気をつけたいのが、次のような場所です。

  • Update関数やFixedUpdate関数の中で、毎フレーム呼ばれる処理
  • 敵キャラクターや弾など、大量のオブジェクトを扱う処理

こうした「頻繁に、しかもたくさん実行される」場所では、LINQを多用すると処理が重くなったり、ゲームがカクついたりする原因になることがあります。なぜ重くなることがあるのか、そしてどこで線引きすればいいのかは、このあと詳しく見ていきますね。

まずは、LINQやラムダ式そのものの書き方に不安がある方のために、最低限おさえておきたい基本から確認していきましょう。




何から書けばいい?基本の書き方と使うべき4メソッド

ラムダ式の最小限の書き方

ラムダ式と聞くと身構えてしまいますが、正体は「名前を付けない、その場限りの関数」です。難しく考えず、まずは形だけ覚えてしまいましょう。

基本の書き方はこうなります。

(引数) => 処理内容

たとえば、数値を受け取って2倍にする処理をラムダ式で書くと、次のようになります。

Func<int, int> doubleNum = x => x * 2;
int result = doubleNum(5); // result は 10

ここで出てきた「Func」は、ラムダ式を入れておくための箱のようなものです。C#にはこの箱の種類がいくつかあり、代表的なものは次の2つです。

  • Action:返り値がない処理を入れる箱
  • Func:返り値がある処理を入れる箱(最後の型が返り値の型になります)

ここで一つ、誤解しやすいポイントがあります。ラムダ式は「LINQ専用の書き方」だと思われがちですが、実はそうではありません。ラムダ式はAction やFuncに代入できる関数全般に使える、汎用的な書き方です。LINQは、そのラムダ式の使い道のひとつに過ぎないんです。

この関係がわかると、次に紹介するLINQのメソッドがなぜラムダ式とセットで使われるのか、すんなり理解できるようになります。

デリゲートやイベント処理についてもっと体系的に学びたい方は、書籍で一通り整理しておくと理解が早くなります。

独習C# 第5版
✅ Amazonでチェックする✅ 楽天でチェックする

まずこの4つだけ覚えればいい

LINQには数多くのメソッドがありますが、最初から全部覚える必要はありません。Unity開発の現場でよく使うのは、実はほんの一握りです。まずは次の4つを押さえておけば、日々のスクリプトの大半はカバーできます。

メソッドできること
Where条件に合う要素だけを絞り込む
Select各要素を別の形に変換する
OrderBy要素を並び替える
FirstOrDefault条件に合う最初の要素を取得する(なければ既定値)

たとえば、敵キャラクターのリストから体力が残っている敵だけを取り出したいとき、for文で書くと数行かかりますが、LINQならこう書けます。

var aliveEnemies = enemyList.Where(e => e.hp > 0).ToList();

体力が低い順に並び替えて、一番弱っている敵を取得したい場合は、こんな書き方もできます。

var weakestEnemy = enemyList.OrderBy(e => e.hp).FirstOrDefault();

一行でやりたいことがそのまま読み取れるのが、LINQの魅力です。for文で同じ処理を書くよりも、あとから見返したときに「何をしているコードか」がすぐにわかります。

LINQが操作しているのは、そもそもリストや配列といったコレクションです。この土台となるデータの持ち方について理解が曖昧な方は、先にこちらを確認しておくとスムーズです。

この4つのメソッドさえ使いこなせれば、LINQの実用場面はほぼ網羅できます。次は、気になる「重さ」の正体について、for文との違いを踏まえながら見ていきましょう。




for文とLINQ、どちらで書くべきか

なぜLINQは重くなることがあるのか

「LINQは重い」という話、耳にしたことがある方も多いと思います。これは根拠のない噂ではなく、LINQの仕組みに理由があります。

LINQのメソッドは、呼び出されるたびに内部で新しいオブジェクトを作る仕組みになっています。この「新しく作る」という動作が、メモリを確保する処理(ガベージコレクション、通称GC)につながります。for文や普通のforeach文では基本的に発生しない処理が、LINQでは裏側で毎回走っているイメージです。

問題は、このメモリ確保が一度や二度なら気にならなくても、毎フレーム・大量に発生すると、処理落ちやカクつきの原因になってしまうことです。GCの仕組みそのものについては、こちらの記事でより詳しく解説しています。

ここで大事なのは「LINQ=絶対ダメ」ではなく、「メモリ確保が積み重なる状況かどうか」を見極めることです。次で、具体的な判断の仕方を見ていきましょう。

判断フロー:Update内か、それ以外か

実際に手を動かすときは、次のチェックリストで判断してみてください。

  • Update・FixedUpdateの中で毎フレーム呼ばれる処理 → for文・foreach文を優先
  • 敵や弾など、大量のオブジェクトを繰り返し処理する場所 → for文・foreach文を優先
  • ゲーム起動時の初期化、シーンロード時の一度きりの処理 → LINQでOK
  • エディタ拡張やビルド処理など、プレイ中に動かない処理 → LINQでOK

実際にあった失敗例を紹介します。Update関数の中で、毎フレーム次のようなコードを書いていたケースです。

void Update()
{
    var target = enemyList.Where(e => e.hp > 0).FirstOrDefault();
    // targetを使った処理
}

一行で書けて便利に見えますが、これが毎フレーム呼ばれることで、少しずつメモリ確保が積み重なり、あるタイミングでガクッとフレームレートが落ちる、いわゆるGCスパイクが発生してしまいました。この場合は、同じ処理をfor文に書き換えることで解決できます。

void Update()
{
    Enemy target = null;
    for (int i = 0; i < enemyList.Count; i++)
    {
        if (enemyList[i].hp > 0)
        {
            target = enemyList[i];
            break;
        }
    }
    // targetを使った処理
}

コードは少し長くなりますが、メモリ確保が発生しないぶん、頻繁に呼ばれる処理では安心して使えます。「読みやすさを取るか、実行頻度を優先するか」で書き方を選ぶ、というのが実務での基準になります。




ToList()とクロージャで陥りやすい失敗

LINQの使い分けを理解しても、書き方によっては思わぬ落とし穴にはまることがあります。ここでは、初心者がつまずきやすい2つのポイントを見ていきましょう。

ToList()を付ければ安心、というわけではない

「LINQは重いから、結果をToList()で確定させておけば安心」と考える方がいますが、これは半分正解で半分誤解です。

確かにToList()には意味があります。同じLINQクエリに対してCount()やFirstOrDefault()を別々に呼び出すと、その都度処理が再実行されてしまうことがあり、ToList()で一度結果を確定させておけば、この無駄な再計算を防げます。

// 良くない例:2回アクセスすると2回評価される
var query = enemyList.Where(e => e.hp > 0);
int count = query.Count();
var first = query.FirstOrDefault();

// 改善例:先に確定させておく
var aliveList = enemyList.Where(e => e.hp > 0).ToList();
int count = aliveList.Count;
var first = aliveList.FirstOrDefault();

ただし、ToList()自体もメモリを確保する処理です。「とりあえずToList()を付けておけば速くなる」というものではなく、あくまで「同じ結果に何度もアクセスする場合に、無駄な再計算を防ぐための手段」だと理解しておくとよいです。

ラムダ式が変数を巻き込むと起きること

もう一つ見落としがちなのが、ラムダ式の中で外側の変数を使ったときの挙動です。

int threshold = 50;
var strongEnemies = enemyList.Where(e => e.hp > threshold).ToList();

このコードでは、ラムダ式の中で外側のthresholdという変数を参照しています。これを「クロージャ(変数のキャプチャ)」と呼びますが、実はこの動作の裏側でも、変数を保持するための小さなオブジェクトが作られていて、メモリ確保が発生しています。

普段の初期化処理などで一度きり使う分には、まったく気にする必要はありません。ただし、これがUpdate関数の中で毎フレーム実行されるとなると、話は別です。外側の変数を参照するたびに、見えないところでメモリが積み重なっていってしまいます。

ここで、前のセクションで触れた「単純な判定なら軽いはず」という考え方にも触れておきたいのですが、実はAny()やFirst()のような一見シンプルな判定であっても、LINQを経由する以上は同じようにメモリ確保が発生します。「判定だけだから大丈夫」と思い込まず、実行される頻度で判断する、という基準はここでも変わりません。

頻繁に呼ばれる処理では、外側の変数に依存しない書き方や、そもそもfor文に書き換えることを検討してみてください。




よくある質問(FAQ)

Q
LINQを使うとビルドサイズや実機パフォーマンスに影響しますか?
A

LINQ自体を使ったからといって、ビルドサイズが大きく膨らむということはほとんどありません。System.Linqの機能はすでに.NETの標準ライブラリに含まれているため、追加の容量を大きく圧迫するようなものではないからです。

実機でのパフォーマンスについても、これまで説明してきた通り「実行頻度」が影響の大きさを左右します。IL2CPPでビルドした場合でも、LINQのメモリ確保という仕組み自体が消えるわけではないので、Update内での多用は避けるという判断基準は、実機でも変わらず当てはまります。気になる場合は、実際に実機でUnity Profilerを使って、GC Allocの発生状況を確認してみるのが確実です。

Q
C#やUnityの新しいバージョンでLINQのパフォーマンスは改善されていますか?
A

C#やUnityのバージョンが上がるにつれて、内部的な最適化が加えられている部分はあります。ただし、LINQが「呼び出すたびに新しいオブジェクトを作る」という基本的な仕組み自体は大きく変わっていません。

そのため「新しいバージョンだから何も気にせず使っていい」とは言い切れないのが現状です。バージョンによる細かな差はあっても、この記事で紹介した「実行頻度で使い分ける」という考え方は、今後もベースの判断基準として持っておくと安心です。最新の挙動が気になる場合は、実際に自分のプロジェクトの環境で計測してみることをおすすめします。

※当サイトはアフィリエイト広告を利用しています。リンクを経由して商品を購入された場合、当サイトに報酬が発生することがあります。

※本記事に記載しているAmazon商品情報(価格、在庫状況、割引、配送条件など)は、執筆時点のAmazon.co.jp上の情報に基づいています。
最新の価格・在庫・配送条件などの詳細は、Amazonの商品ページをご確認ください。

スポンサーリンク