[BP]Blueprintアンチパターン その2 -Pure関数+ForEachLoop-

今回は、ゲーム制作を通して実際に遭遇したBlueprintのアンチパターンについて紹介したいと思います。

Pure関数をForEachLoopに繋ぐ

これはよく言われる話ですが、実にUE4を始めて1年近く気づかなかったものです。図1を見てください。
図1 Pure関数をForeachループに繋ぐ
HeavyPureFuncノードの結果をForEachLoopに繋いでいるだけです。一見すると問題なさそうに見えます。しかし、これでは駄目です。ForEachLoopをダブルクリックして実装を見てみると、図2のようになっています。
図2 ForEachLoopの実装
Inputの引数Arrayを辿ってみると、LengthとGetノードで使われているのがわかります。これを見るとForEachLoopは特別なことをしている訳ではなく、与えられたArrayの要素を順に取り出しているだけなのです。なので、ここにPure関数を繋いでしまうとLengthとGetが評価される度にPure関数も再評価してしまうことになります。
実際に、HeavyPureFunc関数に適当なPrintStringを繋いで確かめてみましょう。ReturnValueが返す配列の要素数は3としました。
図3 Pure関数が複数回呼ばれていることを確認するためのグラフ
図4 図3を実行した結果
要素数に対し、LengthとGetノードの評価回数だけHeavyPureFunc関数が呼び出されていることが確認できます。もしPure関数の計算量が非常に大きい場合は、再評価が重なってボトルネックとなり得ます。
また、ボトルネックだけなら最適化の段階で気づきますが、もう一つ別の問題があります。それは『Pure関数が返す配列の要素が変わってもそのままイテレーションし続ける』という点です。
例を1つ挙げます。この例を実行すると、配列の範囲外にアクセスしてしまいます。
図5 配列の範囲外にアクセスするForEachLoop
図6 図5の実行結果
OutRangePureFunc関数は、ForEachLoop内のLengthノードが評価される瞬間には要素数3の配列を返し、Getノードが評価される瞬間には要素数2の配列を返します。よって、図8を実行すると、3回目のループ時に配列3番目の要素にアクセスできずIsValidの結果がFalseになります。UE4における配列のGetノードは範囲外にアクセスしてもNullを返すだけなのでエラーが起きることはありませんが、ForEachLoopが最初にきたときの配列と別物の配列がイテレーションされる可能性があるというのは結構怖いことだと思います。
もう一つ例を挙げます。これを実行すると、無限ループに陥ります。
図7 無限ループになるForEachLoop
InfLoopPureFunc関数は呼び出されたときにカウンタを増やし、カウンタ分だけ要素をもつ配列を返します。お察しの通り、これを実行するとForEachLoop内のArrayIndexがLength以上になることがなく、ForEachLoopは停止しません。
これらの例はやや強引な方法で危険性を訴えていますが、ForEachLoopにPure関数を与えると再評価されるというのは、こういう問題が起こり得るのです。では、これらを回避するためにはどうしたらいいでしょうか。いくつか方法があります。

  • ForEachLoop前に配列を変数にキャッシュする
図8のように、Pure関数の結果を変数に格納したうえでForEachLoopに与えます。
図8 変数にキャッシュする
とても簡単です。私はこういうシチュエーションに遭遇したら、まずPure関数をImpureに戻すことを検討した後、どうしても戻せないなら図8の方法を取る、という方針にしています。

  • StandardMacroを変更し、最初にキャッシュする
図8のやり方で十分なのですが、ど忘れやケアレスミスというのは無くならないものです。なので今回は、変数へのキャッシュをForEachLoop側で強制するようStandardMacroを変更する方法を紹介します。ForEachLoopマクロをダブルクリックして、冒頭部の実装を次のように変更します。
図9 StandardMacroを変更する
まず、「Localワイルドカードの配列」と「割り当てる」を作成し、「Localワイルドカードの配列」を「割り当てる」のVariableに繋ぎます。次に「割り当てる」のValueにインプットのArrayを繋ぎ、最後に「Localワイルドカードの配列」をLengthノードとGetノードに繋ぎます。これで、配列をマクロの冒頭でキャッシュするようにできました。
この方式はデメリットがあり、変数、純粋・非純粋関数の区別を行わず、キャッシュしなくてもいい場合でも、必ずキャッシュしてしまう問題があります。配列のコピーコストは、場合によっては洒落にならない計算量になるときもあるので、その点は気をつけたいところです。

この記事は次のバージョンで作成されました。
Unreal Editor(4.14.3-3249277+++UE4+Release-4.14)

コメント