こっこのぶろぐ

kockoにまつわる何かを書いていく

FacadeパターンがUEで有効であることの考察【UE5】

プログラミングにおけるデザインパターンの一つに、Facadeパターンという設計がある。

簡単に説明すると、一つの目的に対して複雑で高度な処理を行う必要になる場合、それをAPIの内部で全て処理し、利用者には極めてシンプルな状態でインターフェースを提供するというもの。


例えば、DataTableに登録されている数値AとBについて、2つをAbbした結果を返す関数を実装する場合。

手順としては、

①DataTableのインスタンスを取得する
 (取得できたかどうかのnullチェック等のバリデーションが必要)
②取得したインスタンスからAとBを取得する
 (そもそもAとBが存在するのかチェックする必要がある)
③取得したAとBをAddして返り値として返す

という感じになるだろう。

やり方としては、DataTableを継承したクラス(仮にDataTableDerivedと呼ぶ)にそういう事が出来る関数を実装し、必要な所で呼び出してもらうというのがあり得ると思う。

ただし、これだとDataTableDerivedのインスタンスが必要になってしまう。

つまり、DataTableのインスタンスを取得したBP側でキャストしてようやく使えるようになるのだ。

そもそも、DataTableのインスタンスを取得するのがBPだと手間がかかるので、これはあまり良い作りとは言えないと思う。

C++的に考えたら普通に有り得る作りだと思うが、UE環境でBPの事を考えると途端に話が変わってくる。

継承によって振る舞いに幅を持たせる事が、インスタンスやキャストが必要であることを考えると、その過程における手間のせいでBPが見づらくなったりなど、必ずしも有効に働くとは限らない。


また、GetDataTableRowNamesを駆使してBP側でAとBをAddする方法もある。

しかしこれは、一時的に挙動を見たいなどの暫定的な処置以外では、僕の中では論外だ。

何故なら、BPでそういう複雑な事をしたくないからだ!

僕は、BPでそういうインタンスを取得だのNullチェックだの、プログラミングみたいな事をしたくないというのが根底にある。

でなければC++は使ってないし、あまつさえデザインパターンなんて考えもしない。

ということで、そういう思想を持ってる時点でBPでこの機能を実装することはしない。

(と言いつつ、BPで処理するのが良い場合も往々にしてあるので全部C++で実装しないとヤダ!というわけではない)


ではどうするかというと、ここでFacadeパターンの登場である。

現時点での要件をまとめると、
・手順①②③を処理する
・BPにロジックを実装しない
・①②③をロジックとして実装するのは全てC++
・BPは、ここで実装した関数を呼び出す程度の仕事に抑え、C++との結合度を抑える


とりあえず、今回のテーマであるFacadeパターンの考え方は調べれば分かると思うので割愛する。


C++の具体的な実装として、要はBP側の要望としては「DetaTableに登録されているAとBをAddした結果が欲しい」というだけに過ぎない。

DataTableのインスタンスの取得やRowの存在チェック等の、複雑な事をあえてBPでやる理由は無いのだ。

そういう、Nullチェックやデータの存在チェックみたいな細々した事をやり始めると、BPが複雑になったりC++との結合度が異様に上昇することになりやすい。

なので、まずはC++側で、戻り値がAとBをAddした結果であるStaticな関数を一つ宣言する。

この関数が、BPが欲しい実行結果を取得するための窓口(Facade)となる。

次に実装だが、先ほど宣言した関数の中で①②③を全て処理して値を返す。

そして最後に、BP側でこの関数を呼び出し、AとBの返り値を受け取れば完了だ。


これくらいの実装はC++において、特段珍しいことはない。

ただ、UEにおいては、このFacadeパターンが持つポテンシャル以上の利益をエンジニアにもたらしてくれると思っている。


まず前提として、C++で実装したAPIをBPに公開する上でのインターフェースというのは、可能な限り変更したくないというのがある。

C++側の都合で関数宣言の戻り値や引数、関数名などのインターフェースが変わった時に、都度BPを修正しないといけなくなるからだ。

これは、関数を呼び出しているBPの場所を特定して適宜修正すればいいという話では済まない。

C++APIが呼び出せなくなったことがBP側で発覚するのは、APIを呼び出しているBPがコンパイルされた時だけ。

よって、例えば機能検証や機能実装の為に準備している普段遣いしないレベルで呼び出されている場合、そのレベルを実行しない限り見逃される可能性がある。

後々、検証、実装の為にレベルを開いた時に、そのレベルが置いてけぼりにされてるのに気付いても後の祭り。

修正から始めないといけないというのがいかに苦しいかは想像に難くない。

また、BPはファイルとしてはバイナリデータになっているので、バージョン管理が非常に難しい。

なので、単独のBP自身が持つ機能の修正程度ならまだしも、汎用的な関数を修正するという影響範囲のデカい修正を行うと、影響箇所全ての作業を止めないといけなくなる。

バイナリファイルだと、同時に編集した人が複数人いた場合、変更内容をマージすることが不可能だからだ。

そう考えるとやはり、C++で作成したAPIのインターフェースというのは可能な限り変わらない方が良いというのが理想だと思う。

よって、Facadeパターンが持つ「利用者側から見た時のシンプルさ」というのがここで有効に働く。

内部的にどんな処理をしようと、実装内容にどういう変更が加わろうと、利用者側は戻って来る値が「AとBをAddした結果」であれば十分なのである。


まとめると、BPでDataTableを呼び出す事も、AとBを取得する事も、Addという計算処理をする事も、決して難しいことではない。

しかし、時に可読性や実行速度の問題でC++で実装した方が良いことは往々にしてある。

その時に、C++から細かく機能を提供してBPに好きに組み合わせて使ってもらうというのもいいが、一つの目的に対して必要な作業や関数の呼び出しが多く発生する場合、Facadeパターンを意識して実装してみるとBPの可読性やメンテナンス性を高い水準で保てると思う。

また、UEではFacadeパターンの持つ有効性がより存分に発揮される環境だとも思っている。

そもそもの話、今回でいうDataTableを始めとして、ゲームを実装する際に必要なUEのエンジンが提供する機能にStaticなものが多いので、今更Staticな関数を避ける理由はあまりない。

設計というのは、理想的な構造であるというのも大事だが、インターフェースや振る舞いの一貫性というのはそれ以上に大事だと思ってる。

なので、UEの環境ならばそれに適したC++の設計というのがある。

処理の順序性というのはC++では重要な概念であり、それに乗っ取ると消去法的にStaticな関数は忌避されがちになっていくものだが、BP上で愚直にそれを守ると逆に処理を追いづらくなる。

よって、そういう意味でもFacadeパターンという考え方はUEではよりよいものとなると思っている。


ちなみに蛇足だが、C++だけで書ける環境の場合、恐らくFacadeパターンを好んで使う人はあまりいないと思う。

Facadeパターンというのは、要は便利クラス、便利関数を作ると言っても過言ではない。

基本的に便利関数というのは、可読性やメンテナンス性の低下を招いたり、各機能の依存や結合度が高まりやすい傾向にあり、機能修正時の影響範囲が大きくなりやすい。

普通ならば、高度な処理を必要とする側が実装の流れを組むべきであり、提供側は複雑なことはしないというのがC++の原則にある。

よって、今回のように実装されたAPIというのは、内部的に色々やりすぎていて、作り次第では重度の神関数となる可能性があるので設計思想としては言うほど良いものではない。

あくまでも、BPがバイナリファイルであることや、細かい機能の呼び出しによってノードが肥大化すると著しく可読性が低下する性質に対して、C++の持つ実行速度や堅牢性、安全性を組み合わせる際に、結局便利関数が必要になるならFacadeパターンを意識しようよ、ということである。



P.S

そういえば、FacadeパターンがUEで有効と言っても、実装した関数が神になりやすい要因は予め理解しておく必要はある。

Facadeパターンというのは、言ってしまえば便利関数を作りましょうということだ。

その便利関数の具体性や粒度をしっかり考えておかないと、関数一つの重要性や影響度が極端に上がってしまい、プロジェクトにおける神を生み出しやすい構造になってしまう。

神を生む要因の一つとして、例えばインターフェースの抽象性というのがある。

今回で言うDataTableのAとBをAddする関数だが、名付けるとしたらAddAandBFromDataTableみたいな極めて具体的な名前をつけるべきだと思う。

「どうせAとBをAddするだけなら、いっそもっと便利にCでもDでもEでも対応できる汎用関数、AddRowsにしよう」

とやり始めると、神生誕の兆しが見える。

それというのも、その処理の結果に期待したBPがあらゆる箇所で利用し、一つの関数の出現頻度が極めて高くなってしまうからだ。

そうなると、「AddRowsするにしてもAとBの時だけ一つ条件を加えたい」という仕様変更があった場合に、内部的に処理を変更した時の影響が凄まじい事になってしまう。

やりようによっては、そこで始めてAddAandBFromDataTableを定義する事もできるが、じゃあAddRowsとの違いは何?僕プロジェクトに参加して間もないんだけどどう使い分けるの?となった際に、BPを修正する人がいちいちC++側の仕様を理解しないといけなくなる。

もしかすると、「うーん、AとB足したいんだけど・・・お、AddRowsあるじゃーん」となるかもしれない。

即ち、便利関数というのは、便利であればあるほど利用者を馬鹿にするものなのだ。

使う側はその関数がプロジェクトにとっていかに重たくなっているかを考えたりはしない。

本来、提供する側がその関数の将来性やメンテナンス性、影響度を考えて実装するものなのだ。

まぁ、今回の実装例は比較的有り得るというか、神関数の爆誕について脅すにはちょっと現実的すぎる所があるものの、言いたいのは「便利な関数ではあっても便利に使える関数であってはいけない」とでも言うのだろうか。

結局、C++がやるべきなのはBPが苦手とするNullチェックなどのバリデーションやループ処理なので、それをまとめたFacadeパターンの関数を実装するとしても、便利というのはあくまでも「そういう細々したことを内部的に全部やってくれる」という意味であって、「汎用的に使えて何でもやってくれる」ということではない。

この関数でいいじゃん、となり始めたら唯一神が生まれてしまう可能性があるため、そうならないように予め用途を具体的にしておくことや、複雑な処理をするにしても粒度を可能な限り低くするというのが重要だと思う。


(ΦωΦ)