Robert C. Martin氏の名著、「CleanCode」では「クラスは小さくすべきだ」と繰り返し強調される。
しかし実務ではこう感じたことはないだろうか。
「クラスを分割しすぎると、逆に全体の流れが追えなくなるのでは?」
実際、自分も現場で同じ指摘を受けたことがある。
そしてその違和感は、決して間違っていない。
本記事では、CleanCodeの第10章〜12章をベースにしながら、
なぜクラスは小さくすべきなのか
小さくしすぎると何が問題になるのか
その矛盾をどう設計で解消するのか
を、「単一責務の原則」と「構築と使用の分離」という観点から紐解いていく。
「単一責務の原則」はオブジェクト指向設計の概念で最も有名であり、理解が容易で守るのが簡単でありながら、最も無視されがちな設計原則であると本書では書かれている。
「単一責務の原則」を無視してはいけない理由と、どこまで適用すべきなのかを解明していく。
参考までに、Clean Codeの前半部分については別途記事 を作成しているので、こちらも興味があれば是非読んでみて欲しい。
そもそも何故クラス小さくしなければならないのか?
本書によると、クラスの規則の筆頭は「小さくすること」、第二の規則は「小さくすること」とある。
クラスにおける「小さい」とはどういうことか?行数か?メンバ変数の数?メソッドの数?
本書ではクラスを「責務の数」で測定するべきであるとある。
では一つのクラスに責務の数はいくつまで持って良いのか?
⇒「単一責務の原則」とあるように、責務は一つであるべきである。
単一責務の原則(あるいは単一責任の原則)とは
本書の著者 Robert C. Martin(通称 Uncle Bob)が提唱したとされているSOLID原則のSに該当するのがこの「単一責務の原則」である。
(余談だが、同じRobert C. Martin氏の著書の中でも
CleanCodeの中では「単一責務の原則」となっているが、Clean Architectureの中では「単一責任の原則」とされている。ニュアンス的には”責任”の方がより正確なのかも知れないが、本記事はCleanCodeの深掘りであるので、"責務"で統一する。)
単一責務の原則(Single Responsibility Principle、SRP*3)によれば、クラス、モジュールは変更の原因となるものが1つでなければなりません。この原則は、責務の定義とクラスサイズの指標の両方を提供します。クラスは、ただ1つの責務、つまり変更の原因となるものを持たなければなりません。
よく勘違いされやすいのだが、"ひとつのことだけをすべき"ということではない。
クラスは変更の原因となるもの一つだけ持つべき
ということである。
また、Robert C. Martin氏の別の著書ではこうも言われている
モジュール(クラス)はたったひとつのアクターに対して責務を負うべきである。
アクターとは、クラスメソッドを呼び出すひとたちをひとまとめにしたグループのことである。
例えば映像販売サイトであれば、以下のアクターが考えられる
- 購入者(Purchaser):映像作品を購入したい
- 制作者(Author):映像作品をアップロードしたい、説明文を書きたい
- 管理者(Admin):不適切な動画を削除したい、製作者、購入者、視聴者を管理したい
つまり「その機能を欲しがっている責任者(アクター)」を複数抱え込むのがダメだとこの原則は言っている。
なぜクラスは変更の原因を複数持つべきではないのか?
では何故クラスが複数の変更の原因を持ってはいけないのだろうか
なぜ複数のアクターに対して責務を負うべきではないのだろうか?
例えば映像作品のアップロード方法と、映像作品の購入方法の二つの機能(つまりは変更の原因)を1つのクラスに持っていた場合、
映像作品の購入方法の機能の変更が発生したとする。
当然このクラスに手を入れる必要があるのだが、その時に”映像作品のアップロード方法”という機能への影響にも気を配らなければならない。テストでリグレッションが発生していないことを入念にチェックしなければならない。
変更の理由が複数あるということは、『全く異なる事情で動く人々(アクター)』を、コードという同じボートに乗せている状態にある。
購入担当者の指示でボートを改造している最中に、アップロード担当者が溺れるような設計は、システムの柔軟性を奪い、リグレッションテストのコストを際限なく膨らませてしまう。
また、購入機能だけを他のプロジェクトで再利用したいといった場合でも、アップロード機能がくっついてきてしまい、切り離せないという再利用性の低下も招いてしまう。
つまり責任が混在したクラスはシステムを**「脆く、硬く」**してしまう。
小さなクラスが溢れかえると、全体の流れが追いづらくならないか?
ではここからが本題となる。
「単一責務の原則」はもちろん多くの開発者が知っている。真意を理解せずとも原則の名前くらいは多くのソフトウェア開発者は聞いたことあるだろう。
ではなぜこの原則は無視されがちなのか?
多くの開発者は、1つの責務のみを持った小さなクラスが溢れかえることで、全体の絵(ビッグピクチャー)が分かりづらくなってしまうのではないか、処理の流れを追うのが困難になり、それが読みづらいコードになってしまうのではないかと思い、結果として「単一責務の原則」を無視してクラスに多くの責務を持たせがちになる。
その気持ち、痛いほど分かる。
実際コードレビューでもそのように言い返されることは少なくない。
では本当にそうなのだろうか?
数個の大きな工具箱か、適切にラベル付けされた引き出しか
例えば必要なコードを工具とし、その工具を探す身になって考えてみると腑に落ちる。
多くの責務を持つ巨大なクラスとは、つまりは大きな工具箱に無造作に道具が投げ込まれている状態。ここから必要なものを探すとなると、本来欲しいものとは関係ないものをかき分けなければならない。間違ったものを取り出してしまうかも知れないし、関係ないものを壊してしまうかも知れない。
適切にラベル付けされた小さな引き出しだとどうだろうか。引き出し自体の数は多くなっているが、ラベル付けが効いてきて、必要な引き出しを探し出すことは容易である。引き出しの中は、一つの責務を果たすことだけを目的とした道具だけが入っているのでほしいものだけを取り出すのはより容易である。
クラスが多いと全体の流れを追うのは難しくなるのか?
ではクラスが多いと全体の流れを追うのが難しくなるというのはどうだろうか、それは本当だろうか?
そうではない。
関心事と抽象化レベルの分離が行われいれば、処理の全体の流れを把握するという責務は一つのクラスに集約される。
重要なのは
システムを使うことと、構築することを分離する
である。
システムの構築、つまりクラスの生成があらゆるクラスに散らばってしまっているから、全体の流れを追うのが困難になってしまう。
だからこそ、システムを使うことと、構築することを分離することが重要なのである。
システムの使用と構築を分離する方法
前述したように、クラスの生成があらゆるクラスに散らばってしまっていることが、全体の流れを追うのが困難になる原因であると書いた。
これは例えるなら、コーヒーを淹れるという責務を持つ「バリスタクラス」が、コーヒーメーカーの組み立てを始めてしまっていることにある。
それが依頼側(ここだとmain)からは分からないので、中を順に追っていかないと全体の絵が見えない
#include <iostream>
#include <string>
// コーヒーメーカーのクラス
class EspressoMachine {
public:
void setBoilerTemperature(int temp) { /* 温度設定 */ }
void setWaterPressure(double bar) { /* 圧力設定 */ }
void brew() { std::cout << "エスプレッソを抽出中...\n"; }
};
//コーヒーを淹れるという責務を持つバリスタクラスだが、機械の組み立ての責務も負っている。
class Barista {
public:
// バリスタが「コーヒーを淹れる」という仕事(使用)をしようとしたとき、
// 同時に「機械の組み立て・設定(構築)」まで自分で行っている
void makeCoffee() {
// 構築ロジックが使用ロジックに混入している
EspressoMachine* machine = new EspressoMachine();
machine->setBoilerTemperature(92);
machine->setWaterPressure(9.0);
// 本来の責務(使用)
machine->brew();
delete machine;
}
};
int main() {
Barista barista;
barista.makeCoffee(); // 実行するたびに組み立てが発生し、機械も固定されている
return 0;
}
そこで、「コーヒーメーカーの組み立て」という構築の処理はmainあるいはmainに準ずる上位レイヤに任せる
構築に関するすべての局面を、mainあるいはmainと名付けられたモジュールへと移動し、システム内の残りの部分は、すべてのオブジェクトが適切に生成され、関連付けられているという前提のもとに設計する
#include <iostream>
#include <memory>
#include <string>
// ==========================================
// 1. アプリケーション・パーティション (Application Partition)
// ==========================================
// この領域のコードは「構築」については一切関知せず、
// すべてが適切に生成されているという前提で動作する
class CoffeeMaker {
public:
virtual ~CoffeeMaker() = default;
virtual void brew() = 0;
};
//バリスタはコンストラクタで受け取ったコーヒーメーカーを使ってコーヒーを淹れるという"単一責務"を持つ
class Barista {
private:
const std::shared_ptr<CoffeeMaker> machine;
public:
// コンストラクタ注入:
// 「適切なCoffeeMakerが渡される」という前提に立っている
explicit Barista(std::shared_ptr<CoffeeMaker> m) : machine(std::move(m)) {
if (!machine) throw std::invalid_argument("CoffeeMaker is required");
}
void makeCoffee() {
std::cout << "バリスタ:コーヒーを淹れる仕事を開始します。\n";
machine->brew();
}
};
// ==========================================
// 2. インフラ/具象パーティション
// ==========================================
class EspressoMachine : public CoffeeMaker {
public:
void brew() override {
std::cout << "(エスプレッソマシン:高圧抽出中...)\n";
}
};
// ==========================================
// 3. 構築パーティション (Main Partition)
// ==========================================
//構築の処理はmainに集中しているので、クラスが増えても処理の流れのような全体の絵(ビッグピクチャー)はmainを見ればすべて把握できる。
int main() {
// --- 構築の局面 (Construction Phase) ---
// 1. 具象クラス(道具)の生成
//コーヒーメーカーの種類は
auto machine = std::make_shared<EspressoMachine>();
// 2. アプリケーションオブジェクト(役者)の生成と配線(Dependency Injection)
// バリスタに「どの機械を使うか」を教え込む
Barista barista(machine);
// --- 実行の局面 (Execution Phase) ---
// 3. システムの起動
// アプリケーションの世界に入ると、もう「構築」のコードは出てこない
barista.makeCoffee();
return 0;
}
こうすることで、全体の絵はmainを見るだけで分かるようになる。
もちろんこれは単純なパターンではあるが、複雑なシステムであっても考え方は変わらない。
繰り返しになるが
システムを使うことと、構築することを分離する
という前提をしっかりやれば、クラスを機能ごとに細かく分割しても全体の流れはむしろ上位レイヤに集中するので追いやすくなる。
さらに、このような形にすることで、必要に応じてコーヒーメーカークラスをモックに差し替えることが可能となり、バリスタクラスのユニットテストがより容易になるというメリットもある。
必要な分離を行ったうえで、クラスとメソッドの数を最低限に抑える
ここで本書の大いなる矛盾にぶつかる
CleanCodeの12章では、シンプルな設計にする為の4つのルールとして
- 全テストをパスする
- 重複を排除する(DRY)
- 意図を表現する(Expressive / Clarity)
- クラスとメソッドの数を最小限にする
を上げられている。
「クラスの数を細かく分割しろと言ったり、クラスの数を最小限にしろといったり、矛盾しているじゃないか!」
と思わずにはいられない、恐らく多くの読者がぶつかる本書の大いなる矛盾である。
第四のルール「クラスとメソッドの数を最小限にする」は他のルールを達成した後に適用される
重要なのはこのルールの優先順位である。
これらのルールは1から順に重要な順に並んでいるとある。
つまり一番最後に出てくるこの第四のルールは他のルールをすべて達成したうえで適用される。
「DRYや意図の表現のために増えるクラス」は善であるが、それらの目的がないのに、ただルールとして「細かくすべきだから」という理由だけで増やすのは悪であるとある。
あくまでもクラスは変更の原因を複数持つべきではないが、教条的思考で、何も考えずにクラスを小さくしてクラスの数を増やし続けることは、システムを複雑にする要因になってしまう。
あくまでもクラスは変更の原因を複数持つべきではないが、教条的思考で、何も考えずにクラスを小さくしてクラスの数を増やし続けることは、システムを複雑にする要因になってしまう。
我々の目的は、システム全体を小さくしつつ、関数とクラスも小さくするということです。ただし、この規則の優先順位は、単純な設計への規則の中で最も低いということを覚えておきましょう。クラスと関数の数を少なくすることは重要ではありますが、テストを用意すること、重複を排除すること、コードに自分の意図を表現することのほうがより重要なのです。
クラスを単一責務にさせ続けることは非常に難しい
クラスを単一責務にすることの重要性と、クラスを細かくしても全体の絵が見失われないようにする考え方や設計方法はなんとなく理解できたと思うが、それでもなお、クラスを単一責務にさせておくことは難しい。
丁度、仕事ができる人にあらゆる仕事が集中してしまうように、あるいはリーダーがあらゆる仕事を自分でやってしまうように、機能追加があるとついつい既存のクラスにメソッドを追加させてしまう方に思考が働くことが多い。
しかし、新たに人を雇うのはコストがかかるが、新たにクラスを用意するコストは微々たるものだ。むしろ、その後のメンテナンスコストを考えたら十分に元が取れる。
今後クラスを作成する際や、クラスに機能を追加する際は、著者のこの言葉を思い出してみて欲しい
クラスは小さくしなければならない!
クラスの規則の筆頭は、小さくするということです。第二の規則は、さらに小さくするということです。