最近、C++とDxライブラリを利用してブロック崩しを作っているんですが、プログラムを分かりやすくしとかないと後から見て何をやってるのかがまったく分かんなくなっちゃうんですな。
そのおかげで、僕の作っているブロック崩しは一度0から作り直すハメになっちゃいました。
ゲームに限らず、プログラムを組まれる方であれば、深夜テンションでプログラムを書きあげて、次の日に「なんだこれ?」と思ってしまった経験のある方も居るのではないでしょうか。
その原因のほとんどは、プログラムが分かりづらくなっちゃってることにあるのではないかと思います。そして、プログラムを分かりやすくすることの目的は、後から簡単に変更できるようにすることだと思います。
もし分かりやすいプログラムを書けていても、構造的にその変更をやりづらくなっていては意味がありません(例えば、継承しまくってるとか)。
というわけで今回は、こうすれば分かりやすく書けて、かつ変更もやりやすくなりますよという技術紹介になります。
※今回の話はC++のものになります。他の言語でも適用できる方法なのかどうかは分かりません。
ラムダ式関数とは?
まず、ラムダ式関数についてざっくりとした説明をしときます。
ラムダ式関数とは「関数の中にも定義できる関数」みたいなものです。定義の仕方は
auto function = [](){};
っていう感じになります。もっと詳しい定義方法とかはこちらを参照してください。
兎にも角にも、こういう関数の宣言と定義を関数の中でもできるのがラムダ式の優位性になります。
ただ、これだけ聞いても、「だから何?どんなメリットがあるの?」って感じですが(少なくとも、ブロック崩しを作り始めるまで僕はそう思ってました)、これが意外と使えるやつなんです。
ラムダ式の使い所の一つとして、Factory Methodパターンへの応用というものがあります。
ラムダ式関数とFactory Methodパターン
仮に、ブロック崩しのように、いくつかのオブジェクト(ブロックとかボールとか)を作る必要がある場合、Factory Methodパターンを利用することになるかと思います(当然ながら、Abstract Factoryパターンでも成り立ちます)。
ただし、Factory Methodパターンを利用する場合、クラスを大量に作る必要が出てきてしまいます。というのも、GoFのFactory Methodパターンでは、作りたいクラス一つに対して、もう一つのクラス(クラスをインスタンス化するためのクラス)を作る必要があったからです。
つまり、生成用の関数を実装しただけのクラスを新しく作る必要があるわけです。
それだけでも嫌な感じがしますよね。クラスを作るとなると、クラス自体の宣言、定義を書かなければなりません。それだけでもソースコードの変更が大変になってしまいます。
さらに、大量にクラスを作ってしまうと冗長になって、エラー修正がまた大変になってしまいます。
「じゃあクラスを作らなければいいじゃない!」ということで、ラムダ式関数の出番です(それと、unordered_mapも)。
class CObject
{
public:
virtual ~CObject() = 0;
virtual display() = 0;
}
class CBlock : public CObject
{
private:
pos_[2];
public:
~CBlock();
display();
}
class CBall : public CObject
{
private:
pos_[2];
public:
~CBall();
display();
}
CObject* Creator(int object_num)
{
using create = std::function<CObject*(int)>;
std::unordered_map<int, create> creation_rule =
{
{0, [](){ return new CBlock; } }
{1, [](){ return new CBall; } }
};
return creation_rule.at(object_num)();
}
Creatorという関数でCBlockまたはCBallというクラスのインスタンスを作るという例です。Creatorの引数で、どのクラスを作るのかを指定しています。0ならCBlockを、1ならCBallを作ります。
CBlockもCBallもCObjectを継承しているので、CBlockとCBallのポインタは、CObjectのポインタとしても扱えます。ポリモーフィズムってやつですな。
ここで、インスタンス化する必要のあるクラスとして、CBlockとCBallだけでなく、さらにCBarとかも作る必要が出てきた場合、クラスを利用したFactory Methodパターンとの違いがはっきりと出てきます。
3つのクラス(CBlock、CBall、CBar)をインスタンス化する必要が出てきたとしましょう。クラスを使った場合、それら3つのクラスに加えて、生成用のクラスも3つ必要になります。当然、それ相応のコード数を実際に書かなければなりません。
クラスをヘッダファイルで宣言して、生成用の関数をその内部で宣言する。その上で、ソースファイルで宣言した関数を定義する。変更の手順はざっくりと3つあることになります。
もし後で必要のなくなったクラスがあれば、それら3つを消さなければなりません。少し変更するだけでも一苦労です。
それに対してラムダ式を使った場合、生成用のクラスを3つ作る代わりに、一つのunordered_map、そして、その要素として3つのペアを定義するだけで済みます。
変更する場合も、キーとラムダ式を定義するだけです。そのラムダ式の内部に書くことも、return文だけととてもシンプルです。後で必要なくなっても、その1行を削除するだけで済みます。
大きいプログラムになると、このわずかな差で開発速度に大きな違いが出てくることになります。
ラムダ式の場合、実際に利用する場合も、そのunordered_mapのキーを検索するだけです(上の例でやったように)。
もしインスタンス化しなければならないクラスが10、100と増えていった場合、クラスベースのプログラムと関数ベースのプログラムの違いはより顕著になります。
ラムダ式と列挙型
unordered_mapとラムダ式を利用すればプログラムを小さくできて、変更も簡単にできるようになると説明しました。
さらにラムダ式Factory Methodパターンを分かりやすくする方法として、列挙型を利用する方法が挙げられます。キーを列挙型にすれば、漏れを起こしにくくなります。
さらに、キーに名前を付けることが出来る(例えば、BLOCKというような感じで)ので、より分かりやすくなります。
この分かりやすさと網羅性というのが結構大事で、クラスベースで作っていた場合、実はまだ作っていなかったというようなことがあるんですな。
それに対して、ラムダ式を使って密集させて書いていると、どの生成処理をまだ書いていなくて、もう書いた生成処理が何なのかが分かりやすくなります。どうしても、画面をスクロールすると、何が書いてあったかを忘れちゃうこともありますから。
まとめ
というわけで、ラムダ式関数とstd::unordered_map、列挙型を使うことで、クラスを大量に作らずに済む、その結果として、プログラムを変更しやすくなったり、コーディングの段階から欠陥に気付きやすくなったりするよーという話でした。
その方法としては、まずstd::unordered_map型として、あらかじめキーに対してstd::function(ラムダ式関数)を定義したmapを用意しておきました。
そのmapの要素として、キーが列挙型で、値がラムダ式(std::function)であるようなペアを格納しました。実際に使うときはそのmapを呼び出すだけです。
あまりインスタンス化されないクラスの場合(レアな条件を満たさないとインスタンス化されないとかの理由で)、テストをしてみても、クラスを作ってないことに気付きにくかったり、気づくのが遅れたりするんですが、ラムダ式を使えば、その可能性をコーディングの段階から小さくできるので便利です。
それに何より、後からの変更がとても楽です。個人的には、この変更が楽って所が気に入ってます。
今回の記事は、こちらのスライドを読んで感銘を受けて書きました。ラムダ式関数の優位性がめちゃくちゃ分かりやすく解説されているのでおすすめです。
そのスライド内ではAbstract Factoryパターンが例として使われていましたが、今回はラムダ式の使い所とその魅力をお伝えする(そして、分かりやすいスライドがあったから読んでみてほしいとおすすめする)のが目的だったんで、より単純なFactory Methodパターンで解説しました。