SOLID原則を慣れ親しんだ用語で読み解く

私もプログラミングに関しては不勉強なとこがあり、SOLID原則は曖昧にしか認知していませんでした。

で、詳しく調べてみたところどうやら昔からある手法をまとめて言い直したものと見受けたので、昔学んだものと紐付けて自分用に整理してみました。

古い用語で覚えた人には参考になるかも知れません。

また、理想を言えばSOLID原則厳守が良いんですが、個人でのゲーム開発ならある程度無視した方が楽だったりします。
今回はその点にも触れます。

コードをAssetとして売り出すとか、多人数開発をするならSOLID原則厳守がイイですけどね。

SOLID原則とは

こちらのサイトが詳しいので、こちらを引用し、基点とします。

コード規約において重要な頑健性(バグの発生しづらさ)、保守性(メンテナンスしやすさ)…などを高める上で有効な考え方といったところです。

S:SRP、単一責任の原則

類似する名前[1]:「1関数1目的、1クラス1目的」の原則

で、例に挙がっているものは

類似する名前[2]:「3層アーキテクチャ」

あるいは

類似する名前[3]:「MVCアーキテクチャ」

と言い換えることが出来る。

厳密には[1]がSRPであり、[2][3]は最低限これくらいは分離しましょう、という話になる([1]は関数の、[2][3]はクラスの分け方)

3層アーキテクチャ

クラス設計時に、「プレゼンテーション層」「ファンクション層」「データ層」で分離することで、互いの関係が密にならないようにする設計手法。

こちらはプレゼンテーション<->ファンクション<->データでのみやりとりをし、プレゼンテーションから直接データへはアクセスしない。

MVCアーキテクチャ

Model View Controllerの略。

意味合いは上記のものと似ているが、V(表示関連)<->M(データ管理)のアクセスも許容しており、直線ではなく三角形の形で関係を結ぶ。

それぞれやる内容は決まっていて、DBなどから取ってきたり保持するのがModelで、Controllerが処理をしてViewに表示あるいは出力する。

Viewが直接DBにアクセスしたり、Controllerにデータを持たせたりすると管理しきれなくなる、という話。

個人で開発していて、若い人なら全て把握出来るので気にしなくて良い…ように思えるが、正直作って数日経つと分からなくなるので、誰が持つか、誰が表示するかは決めた方が良いだろう。

よく言われる「なんでもかんでも○○Managerにするのは良くない」ということの理由の一つがこれで、ManagerがControllerなのかModelなのかが分からないのだ。(表示まですることがある)

少なくとも、データ管理役、処理役、生成役(Factory)、表示役…などには分けられる。

(ただし、これが個人開発ゲームなどだと、1つにまとめたところで問題は少なかったりする。 データ持ちと生成Factoryは分けるなど、適宜使い分ければよろしい)

O:OCP、解放閉鎖の原則

これは種別で分岐して本体内でそれぞれの処理を書くのではなく、クラスで分けて継承させることで、同一関数を呼べばそれぞれの動作を得ることができる仕組みにせよ、という話。

少し用語がややこしいので、先に整理する。

スーパークラス:上位クラス、基底クラスとも言う。他にアッパークラスとか、まあなんか色々言われる。厳密に互いに違うとは言いづらいので気にしなくてイイ。
このうち、仮想関数を持つスーパークラスは抽象クラスと言う。Javaならabstractを付けるのでアブストラクトクラスとも言うが、C++では"abstract"という語は使わない(!?)。
そしてC++/CLI(要するに.NET)にはabstractという予約語があって普通に使われる(!!?!?)

インターフェース(I/F):本来Javaの機能で、interfaceを付けて定義されたメソッド群を示す(厳密には変数を定義したりもできる。implementsで複数利用出来る。implementsされたら、そのI/Fが持つメソッドを全て実装しなくてはいけない)。
ちなみに、JavaにおいてabstractクラスはC++の抽象クラスと一緒で、メンバ変数を持っても良いし、実体をメンバ関数を一部持ってもいい(インターフェースは全てのメソッドが抽象メソッドである必要がある)

転じてC++においても、Abstractクラス=抽象クラス(仮想関数を持ったクラス)のうち、全ての関数が純粋仮想関数(virtual void func() =0;)で書かれたものをインターフェースと呼ぶ文化がある。(正式名称ではない(!))

まとめると、インターフェースは「どういう機能/関数を実装すれば良いのか」を示すガイドのようなものとして使われる。(can-do関係と言える)

一方、抽象クラスと通常クラスとの関係は、Animalに対するLionなど、is-a関係のものに使われる。

といったところで、OCPは「変数とかで中身の処理を分岐/切り替えるのではなく、クラスを継承して同じ関数で中身を追加すれば、本体の処理は修正しなくて済む」といった手法。

その一手法としてインターフェースや抽象クラス、仮想関数を駆使すると良いよという話。

近い話:ポリモーフィズム

ポリモーフィズム

ポリモーフィズムとは、オーバーロードとオーバーライドのコトを言う。

この格好いい単語には、もっと一般的で小難しい定義はあるが、プログラミングではこの2つだけ覚えておけばよろしい。

オーバーロード

同じ名前のメンバ関数(Java名:メンバメソッド)で、引数が違う関数にすると、それぞれ別の関数として実装出来る。

オーバーライド

継承先で同じ関数(関数名、引数、返値が同じ関数)を定義した場合、継承先の関数だけ呼ばれる。

class Super1 {
public:
	int func() { return 1; }
};
class Sub1 :public Super1{
public:
	virtual int func() { return 2; }
};
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
	LPSTR lpCmdLine, int nCmdShow)
{
	{
		Super1 super1;
		Sub1 sub1;
		Super1* lpSuper1 = &sub1;
		tstring str=HPLString::chrToStr("super1=%d", super1.func());
		str = HPLString::chrToStr("sub1=%d", sub1.func());
		str=HPLString::chrToStr("lpSuper1=%d", lpSuper1->func());
		int a = 0;
	}

上位クラスの関数の処理を行いたい場合は上記例ならSuper1::func()と呼ぶ必要がある。

ちなみにデストラクタは特殊で、

virtual ~Sub1(){}

と定義した場合は~Sub1()→~Super1()の順番で処理が走るが、

~Sub1(){}

と定義した場合は~Sub1()のみが走る(なので、Super1側でメモリ確保している場合リークするので注意。

ちなみに上記処理の結果は下記。

super1=1
sub1=2
lpSuper1=1

注意なのが3番目で、親のポインタで継承オブジェクトを呼ぶと、親の関数処理が走る、というもの。

ちなみにSuper1::funcを仮想関数にすると、ポインタであってもサブクラスの関数(Sub1::func)が呼び出される。

つまり、virtualがあり、継承先に同じ関数があれば、そっちを優先します、という意味(Sub1::funcにvirtualがある意味は現状ないが、Sub1を継承したSubSub1を作ったときに、同じことが起きる)

これはクラスのメモリ確保が[親[継承1[継承2...]]]]と言った具合に包括的に確保するため、より上位のクラスのポインタとして利用することが出来る機能の話でもある。

ちなみに、クラスが複雑になるとオーバーヘッドがシャレにならなくなる。

今はどうか分からないが、最適化の問題で処理が長くなったりもする。
組み込みやコンソール移植ではよりプリミティブなもの、あるいは構造体などの方が最適化という意味では優れている。

最適化は保守性とは真逆の作業なので、最後の方でやるのがベターだろう。

L:LSP、リスコフの置換原則

これも例としてはOCPと同じだが、話としては微妙に違う。

OCP:要素を追加する場合に、既存コードを修正する必要が無い方が良い。

LSP:[1]上位クラスから派生したクラスは、上位クラスの同一関数より出来ることが増えてはいけない。

つまり継承先のクラスであっても、処理の範囲は変えないということ。Super1はOKだけどSub1を入れると意味を成さない、というのはNG。その場合切り分けが必要になって、OCPにも違反する。

→関数定義としては、インターフェースが行う内容が異なっても、行う範囲は固定する
(言い換えるとAPIやI/Fの役割は上位クラス側で確定し、その内容の範囲だけで実装せよ、と言う話。 新しい関数やAPIを継承先で用意して、それをクラス判別で…とかやるのはよせ、という話。)

→関数実装としては、上位クラスが想定していない処理を付け加えていけないと言う話。表示関数なのに、中でデータ変更処理をするよう追加処理するのはアカン。たとえば、変数を入れるだけの処理が、派生先では変数にかけ算をして入れる処理になると、期待される動作から逸脱していると言える。
メンバ変数だけで処理していたのに、派生クラスがDBに依存したりすると置き換えが出来なくなる。そういう構造的な違いもNGになる。

同時に;

LSP:[2]出力される情報は、基底クラスや派生クラスとして期待されるもので、それより出来ることが少なくなってもいけない。

まあこれも同じ。空メソッドでオーバーライドしたり、勝手に処理を絞ったり。

もちろん上位側が想定する範囲内ならいいが、想定しない範囲の数値やNULLが返ってきたりすれば置換できなくなる。

文字にすると分かりづらいが、言ってしまえばis-a関係として完全かの話。そのため、プログラマならばクラス設計で必ずぶち当たる問題といえる。

包括関係だからといって継承したあと、でもここでこの処理が必要だとか、この処理はここで入らないから削る、などしていると、結局はクラスの種類を見て処理を把握する必要が出てきてしまう。

それはそもそも系統が別になるのではないか?あるいはインスタンスをもう一個用意すれば良いのではないか?など、先に考えることは結構ある。

インスタンスの型をチェックするのは、void*とかを経由したときの復号作業くらいで十分だと思う(少なくとも個人開発のゲームプログラミング(RPG,ACT,STG,SLG)では不要だった)

I:ISP、インタフェース分離の原則

万能インターフェース(I/F)は存在せず、不要なAPIを用意すべきではない。

インターフェースの設計は実は非常に難しく、後で欲しい機能、要らなくなった機能が出たりすると、再設計が必要になることすらある。

個人開発では足かせにもなるが、多人数プログラミングでは方針をそろえるためには必須の要素だ。

目的別にインターフェースを用意してもイイし、汎用的なものを一つ用意して、中身は継承先に任せるのでも良い。(引数の設計が厄介だが)

D:DIP、依存性逆転の原則

高水準モジュールは低水準モジュールに依存してはいけない。両者は抽象化に依存すべき。

高水準というのは、より概念的な、と言う意味で、低水準というのはより低レイヤー(既存/標準ライブラリやOS、プロトコル、HW側)の、と言う意味。

高水準、高レイヤー、高レベルは全部同じ。低レベルといっても、程度が低いとかいう意味は無い(紛らわしい)

たとえば、高水準クラスの中で、実際のDBアクセス処理を書くのは良くない。扱うDBやSDKの変化にAPI(関数、引数、返値、クラス設計)レベルで対応しなくてはならないため。

そこで、DIPを満たす上では、高水準クラスは基本的に概念のみをAPIとして持たせる。

そのインターフェースや基底クラスを継承して、より実際的なモジュール(クラス群)を作れば良い。

たとえばDBからゲットしてくるインターフェースを用意したら、それを継承(またはimplementsして)SQLを飛ばす具体的なメソッドを持った、各DBやSDKに応じてクラスを用意すれば良い。

これはMVCアーキテクチャにも似ている。

とはいえ…

他人に見せるプログラムならともかく、クラスやI/Fを必要に応じて設計&作成して、かつ最終的にぶれないように開発していくというのは非常に手間が掛かる上に、省略出来る箇所がかなり多い。

これを個人のゲームプログラムにまで適用するというのは結構大変なので、時短出来る要素であればある程度目をつぶっても良いと個人的には思っている(どうせ他人には見せないので)

特にOCPを無視してタイプによって処理を変えるというのは、アルゴリズムが似通った敵が多く出るゲームや、ある程度ファイルデータから読み込んだ設定値で制御できる状況なら、意外と選択肢に入る。

まとめ

SOLIDということで、言っていることはそれぞれ独立しているが、状況としてはお互いに重複している…というか、要するにクラス設計する上で気をつけるべき点であり、オブジェクト指向とかデザインパターン(の一部、あるいは概念)で言われてきたことを改めてまとめ直した、というのが個人的な印象。

おまけ:デザインパターンについて

デザインパターン:オブジェクト指向でプログラミングする場合、データの扱いやオブジェクトのクラス設計をする上でおきまりのパターンというものがあり、その用例集を集めた者。

…なのだが、正直これを座学で学ぶというのは茨の道で、1から大規模システムを組みます、と言う場合以外では無用の長物要素がかなりある(最終的には一通り網羅するのだが、気を回さなくてイイ所まで気を回す必要が結果的に出てくる)

たとえばシングルトンはおきまりのやり方(コンストラクタをprivateにしてstaticで用意したインスタンスをgetInstance()で取得する)でやるのだが、やっていることはインスタンスをグローバル領域に置いてるのと何ら変わらないので、個人のプログラムなら、適当にexternインスタンスで用意しちゃってもいい(なぜならデバッグでいちいちgetInstance()を掘らなくて良いのでちょっと楽なのだ)

なぜシングルトンがこんな回りくどい方法をしているかと言えば、他人が間違って唯一のインスタンスをもう一個作ってしまう可能性を危惧して、防ごうとしているためなので、個人でやるなら別にインスタンス直置きでも良い(みっともないはみっともないが)

私はデザインパターンをあまり座学で学んでいないが、作ったゲームはデザインパターンのそれぞれの要素に当てはまるものが結果的に出来ている。

実践から入った方が、無駄なくデザインパターンに適用出来るのではないかなと、わりと思っている(当然、教えるなら自分のプログラムをデザインパターンと比較しながら説明することになるが)

有料範囲に文章はありません。(投げ銭扱いです)

ここから先は

0字

¥ 100

この記事が気に入ったらサポートをしてみませんか?