この記事は クラウドワークスアドベントカレンダー2020 8日目の記事です。
概要
こんにちは、クソコードを爆殺リファクタリングするのが大好きなミノ駆動です。
今回は単一責任原則の話です。
単一責任原則はSOLID原則のひとつとして有名で、2020年のオブジェクト指向カンファレンスのアンケートでも、SOLID原則の中で最も人気がありました。
皆さんは単一責任原則を遵守した設計をしていますか。
どんな構造が単一責任設計で、一方どんな構造が単一責任でない設計か、明確に意識していますか。説明できますでしょうか。
ところで「単一責任原則とはなんぞや」について、少なくとも私の観測範囲では、概念的な話にとどまっているものが多く、コードレベルで具体的に説明しているものは少ないように感じます。
そうした状況からか、単一責任原則の解釈が人によって違っていたりしているように感じます。
本記事は、今一度単一責任原則について整理し、コードレベルで具体的に理解を促すことを目的とします。
この記事で伝えたいこと
- 単一責任でない構造
- 単一責任でないことにより生じる悪影響
- 単一責任原則を遵守するための設計方法
- ビジネスへの関心が薄く理解が浅いと単一責任設計が困難 (←ココ重要)
よくあるダメな例
単一責任原則が遵守されていないと何が大変なのかについて、ECサイトに機能追加する架空のシチュエーションを例に説明していきます。
あるECサイトで割引サービスが追加されることになりました。
商品1点につき300円割引される仕様 です。
この割引を通常割引と呼ぶことにします。
ある担当者は通常割引のロジックを、次のように実装しました。
class DiscountManager {
int totalPrice;
List<Product> discountProducts;
// 中略
/**
* 割引価格を取得する
* @param price 商品価格
* @return 割引価格
*/
static int getDiscountPrice(int price) {
int discountPrice = price - 300;
if (discountPrice < 0) {
discountPrice = 0;
}
return discountPrice;
}
}
// これはクソコードです。
// 良い子は絶対に真似してはいけません。
その後、夏季限定割引の仕様が追加されることになりました。
この割引は通常割引とはいろいろ仕様が異なるものの、 商品1点につき300円割引される仕様 は通常割引と同じでした。
通常割引ロジックの実装者とは別の人が実装担当することになりました。
この担当者は「DiscountManagerに既に300円割り引くロジックが実装されてるじゃないか。これを 流用しよう。DRYになるし。 」と判断し、夏季限定割引を管理する SummerDiscountManager
に DiscountManager
を組み込みました。
class SummerDiscountManager {
DiscountManager discountManager;
/**
* 商品を追加する
* @param product 商品
* @return 追加に成功した場合true
*/
boolean add(Product product) {
// 商品の割引価格を加算し、
// 上限30,000円を超えていなければ商品を追加する。
int tmp = this.discountManager.totalPrice +
DiscountManager.getDiscountPrice(product.price);
if (tmp < 30000) {
this.discountManager.totalPrice = tmp;
this.discountManager.discountProducts.add(product);
return true;
}
else {
return false;
}
}
}
}
// これはクソコードです。
// 良い子は絶対に真似してはいけません。
さらにその後、通常割引の仕様が変更されることになりました。
商品1点につき4%割り引く仕様 になりました。
通常割引の担当者は、 DiscountManager.getDiscountPrice
メソッドを次のように変更し、リリースしました。
夏季限定割引で流用されていることも知らずに。
class DiscountManager {
int totalPrice;
List<Product> discountProducts;
// 中略
/**
* 割引価格を取得する
* @param price 商品価格
* @return 割引価格
*/
static int getDiscountPrice(int price) {
return (int)(price * (1.00 - 0.04));
}
}
// これはクソコードです。
// 良い子は絶対に真似してはいけません。
何が起こったのでしょうか。
夏季限定割引が仕様と違うインシデントが発生してしまったのです…。
以上が架空ではありますが、単一責任原則が遵守されていない事例です。
責任とは何か
単一責任原則とは何でしょうか。
まずは「責任」の意味から考えていきます。
家計における責任
私たちの日常生活に当てはめて考えてみます。
例えば家計。
毎月ちゃんと生活していくためには、お金を使いすぎないように計画的に使うことが求められます。
家計を維持していくのは、その人自身の責任です。
金を使いすぎて借金生活に陥っても、それは使いすぎたその人自身の責任ですね。
他の誰の責任でもありません。
このように責任は、 誰がその責任を負うべきか適用範囲とセットになります。
ソフトウェアにおける責任
この考えをソフトウェアに当てはめてみます。
ソフトウェアは、表示、金額計算、DBなど、様々な関心事を扱います。
関心事とは「気にかける特定の事柄」です。
言い換えると、ソフトウェアは表示処理、金額計算処理、DB処理…など、様々な関心事の処理の塊です。
ここで、画面表示にバグがあったとします。
バグ修正のためにDB処理を修正しようとするでしょうか?
違いますよね。表示にバグがあるなら、表示を担う表示処理を修正するのが普通です。
家計の維持がそれぞれ個人の責任であるのと同様に、正常に動作するよう制御するのは、制御を担う処理の責任であるわけです。
つまりソフトウェアにおける責任とは、「 ある関心事について、不正な動作にならないよう、正常に動作するよう制御する責任 」と考えることができます。
単一責任原則
「クラスが担う責任は、たったひとつに限定すべき」とする設計原則が単一責任原則です。
先程の「責任」の考え方併せると、「 不正な動作にならないよう、正常に動作するよう制御する責任は、たったひとつに限定すべき 」とも言い替えることができます。
そして、この単一責任原則の観点から先の割引サービスのソースコードを見ると、今まで見えなかった設計のバグが見えてきます。
何がまずかったのか
DiscountManager.getDiscountPrice
メソッドは、通常割引の価格計算において正常動作を保証するために実装されたロジックのはずです。
後から登場した夏季割引価格を保証する責任を負うように作られていません。
しかし夏季割引価格の計算に流用されたために、無理矢理 責任を二重に負うことになってしまったのです。
単一責任原則は「クラスを変更する理由は1つのみ」とも言われています。
しかし DiscountManager.getDiscountPrice
が流用されている構造では、通常割引と夏季限定割引、どちらの仕様変更でも影響を受けてしまいます。
単一責任になるよう設計する
以上を踏まえると、単一責任原則を遵守するには、
「 単一の関心事 について、不正に陥らないよう、 正常動作させる責任 を持つこと」
これを満たすことが求められます。
では先程の割引に関して、あるべき設計を考えていきます。
定価クラス
まず割引の元となる定価を考えます。
何も考えずに実装すると、単なるint型変数として定義されるだけになりがちではないでしょうか。
そして以下のように容易に不正値を代入できてしまいます。
これでは正常動作に責任を持てませんね。
int price = -100;
正常動作に責任を持つクラスの設計方法
オブジェクト指向におけるクラスは、
- インスタンス変数
- インスタンス変数 を正常に制御するメソッド (←ココ重要)
から構成されるのが基本です(※一部例外はあるが割愛)。
インスタンス変数だけだと、そのインスタンス変数を制御するロジックが別のクラスに実装されることになります。
また、やり方によっては他のクラスのインスタンス変数を変更するメソッドを作ることもできます。
しかし、いずれにしても クラス単体で正常動作を完結できる構造ではない ですね。
単一責任原則が守られません。
定価に関して正常動作の責任を負うクラス構造
定価に関して正常動作を保証するには、以下のようにValueObjectパターンで設計します。
ValueObjectパターンの設計方法についての詳細は、過去に私が執筆した 設計要件をギッチギチに詰めたValueObjectで低凝集クラスを爆殺する をご覧下さい。
// 定価を表現するクラス
class RegularPrice {
private static final int MIN_AMOUNT = 0;
final int amount;
RegularPrice(int amount) {
if (amount < MIN_AMOUNT) {
throw new IllegalArgumentException();
}
this.amount = amount;
}
RegularPrice add(RegularPrice price) {
return new RegularPrice(this.amount + price.amount);
}
}
コンストラクタは完全コンストラクタパターンで作られており、負の価格、即ち不正値を持った RegularPrice
インスタンスが存在できない構造になっています。
インスタンス変数 amount
は final
宣言され、後から不正値を混入できない仕組みです。
定価の演算は必要なものだけメソッドとして用意します。
例えば加算のみ必要であれば add
メソッドとして公開します。
これにより「定価 x 定価」といったありえない演算を抑止できます。
また、引数の型は同じ RegularPrice
であり、「定価 + 定価」のみを許可しています。
「定価 + 年齢」といったありえない加算を抑止できます。
ここまで設計して、初めて定価について正常動作を保証可能な頑強な構造になります。
繰り返しになりますが、このValueObjectパターンの設計についての詳しい意図は 設計要件をギッチギチに詰めたValueObjectで低凝集クラスを爆殺する をご覧下さいませ。
通常割引価格クラス
定価クラスと同様にValueObjectパターンを用い、通常割引価格について正常動作を保証する設計をします。
// 通常割引を表現するクラス
class RegularDiscountedPrice {
private static final float DISCOUNT_RATE = 0.04;
final int amount;
RegularDiscountedPrice(RegularPrice price) {
this.amount = (int)(price.amount * (1.00 - DISCOUNT_RATE));
}
}
コンストラクタで定価クラス RegularPrice
を渡す仕組みになっているので、定価以外の例えば商品個数や商品IDが渡される心配がありません。
また、「通常割引価格とは何か、どんな仕様であるか」がこのクラスのコードを見ただけで理解が完結するのもポイントです。
夏季限定割引価格クラス
同様に夏季限定割引価格について正常動作を保証する設計をします。
// 夏季限定割引価格を表現するクラス
class SummerDiscountedPrice {
private static final int MIN_AMOUNT = 0;
private static final int DISCOUNT_AMOUNT = 300;
final int amount;
SummerDiscountedPrice(RegularPrice price) {
int discountedAmount = price.amount - DISCOUNT_AMOUNT;
if (discountedAmount < MIN_AMOUNT) {
discountedAmount = MIN_AMOUNT;
}
this.amount = discountedAmount;
}
}
これで定価、通常割引価格、夏季限定割引価格、それぞれ 単一の関心事について正常動作に責任を負ったクラス を作り上げることができました。
通常割引、夏季限定割引、どちらに仕様変更が生じても互いに影響せず、安全に変更できる構造です。
詳しくは後述しますが、 下手にロジックを流用せず、各概念ごとに丁寧にクラス化していくことがポイントです。
なお、クラス図で表現すると以下のような関係になります。
単一責任設計のバリエーション
上で挙げた割引の例は、単一の値に対してValueObjectパターンを適用し、正常動作を保証する設計でした。
他の構造も例に単一責任原則を考えてみます。
コレクション型の例
次に示すのはECサイトの「買い物かご」を表すクラスであり、通常割引商品を入れる専用の買い物かごです。
// 通常割引商品が入る買い物かご
class RegularDiscountShoppingCart {
private static final int MAX_PRODUCTS_COUNT = 10;
private final List<Product> products;
// 商品を追加する。
boolean add(Product product) {
if (this.products.size() < MAX_PRODUCTS_COUNT) {
this.products.add(product);
return true;
}
return false;
}
// 商品を削除する。
boolean remove(Product product) {
if (this.products.contains(product)) {
this.products.remove(product);
return true;
}
return false;
}
// 総額を返す。
int totalPrice() {
...
【よくある誤解】メソッドが複数あるから単一責任ではない
このクラスには商品を追加する add
、削除する remove
、総額を返す totalPrice
があります。こういう複数のメソッドを持っているクラスに対し
「追加責務と削除責務は違う。単一責任原則違反ではないか」
と考える方がおられるかも知れません。
しかしそれは誤解です。
「正常動作に責任を負う」ことに着目しましょう。
これは構造的には、 List型インスタンス変数 products
の正常動作に責任を持つクラス です。
商品追加する add
メソッドでは商品数上限を超えないよう、仕様的に不正にならないよう制御しています。
remove
メソッドも同様に、存在しない商品削除は失敗するように制御しています。
このように、コレクション型をインスタンス変数に持ち、コレクション型が仕様的に正常動作するようロジックをカプセル化するパターンを ファーストクラスコレクション と呼びます。
複数のクラスのインスタンス変数を持つ例
次に示すのは、矩形(四角形)を表現するクラスです。
矩形の位置、サイズ、角度について、それぞれ Location
、 Size
、 Angle
クラスのインスタンスとして持っています。
これらのインスタンス変数に基づき矩形を描画する draw
メソッドを備えています。
// 矩形を表現するクラス
class Rectangle {
private final Location location; // 位置
private final Size size; // サイズ
private final Angle angle; // 角度
void draw() {
// location, size, angleを使った
// 矩形描画処理。
}
}
【よくある誤解】複数のクラスのインスタンスを持っているから単一責任原則違反
Rectangle
クラスは、内部に複数のクラスのインスタンス変数を持っています。これに対する
「位置、サイズ、角度の3つの責務を持っているから単一責任原則違反」
のような考えも誤解です。
Location
、 Size
、 Angle
はそれぞれ位置、サイズ、角度について正常動作に責任を持つクラスです。それぞれの責任は各クラスが既に負っています。
一方 Rectangle
は矩形を表現するのに責任を負うクラスです。目的と果たすべき責任が違います。
【重要】ビジネスに関心がないと単一責任設計が困難
「私、技術には興味ありますけどビジネスには興味ありませんから。仕様はビジネス側で考えて下さい」
このような考えをお持ちの方にとっては単一責任設計は難しい、という話をします。
もちろん人によって技術的な嗜好に違いはあります。
但し、単一責任原則を遵守して設計するには、ソフトウェアが対象とするビジネスへの関心が不可欠です。
関心が薄いと単一責任設計が困難になります。
理由を以下に説明していきます。
DRY原則の誤用と密結合
冒頭の割引の例は、割引計算ロジックが流用できるように見えて、実際は流用していはいけないものでした。
なぜ流用してはいけなかったのでしょうか。
それは文脈、意味合いが違っていたからです。
「300円割り引く」仕様が最初たまたま同じであっただけで、あれらは
- 通常割引金額
- 夏季限定割引金額
と、それぞれ明確に違う概念です。
有名なソフトウェア原則に、DRY原則があります。
Don't Repeat Yourself.
の略で、直訳すると「繰り返しを避けろ」という意味です。
この原則、「コードの重複を許すな」といった解釈で広まっているようですが、原典「達人プログラマー」では以下のように説明されています。
すべての知識はシステム内において、単一、かつ明確な、そして信頼できる表現になっていなければならない。
(「新装版 達人プログラマー」より引用)
知識とは一体何でしょうか。
粒度、技術レイヤー、様々な観点で考えることができますが、その内のひとつに、ソフトウェアが対象とするビジネス知識があります。
ビジネス知識とは何でしょうか。
それはソフトウェアで扱うビジネス概念です。
例えばECサイトでは「割引」「気になる商品」「クリスマスキャンペーン」などといった概念です。
ゲームでは「HP」「攻撃力」「耐性」などといった概念です。
通常割引と夏季限定割引はそれぞれ別の概念です。
DRYにすべきは、それぞれの 概念単位 なのです。
同じようなロジック、似ているロジックであっても、概念が違えばDRYにすべきではないのです。
概念的に異なるもの同士を無理にDRYにすると密結合になります。
単一責任原則を遵守できなくなります。
こちらの記事「DRY原則の利用: コードの重複と密結合の間」でも、意図を理解せず濫用すると密結合に陥ることについて警鐘を鳴らしています。
ちなみに、コードの重複を許さないのはOAOO原則(Once and Only Once)です。
概念理解がザルだと違いを区別できなくなる
誤ったDRY適用による密結合を避け、単一責任で設計するには、概念の明確な区別が必要です。
区別には、ビジネスの理解が必要です。
単に仕様通りに動くだけのコードを書くのは論外。
仕様書には現れないビジネス概念は、意外なほど沢山あります。
冒頭の割引の例はまだ分かりやすい方です。
実際のプロダクトでは、 似て非なる概念 が沢山あります。
例えば以下のような。
主語クソデカ問題
概念の名前には、広すぎて曖昧なものがあります。
例えばECサイトで「商品」とクラスに命名したとします。
商品は出品、予約、注文、発送など様々なユースケースで用いられるため、様々なユースケースと密結合になる可能性があります。つまり責任が単一でなくなる可能性があります。
注意しなければならないのは、 名前は点ではなく範囲 であることです。
「商品」はある1点の概念を指し示しているのではなく、上図にあるように「予約品」や「注文品」など、広い範囲の意味を含みます。
これを避けるためには、意味範囲の狭い、特化した名前を選択することが大事です。詳細は私が過去に執筆した関心の分離を意識した名前設計で巨大クラスを爆殺するをご覧下さい。
なお、割引の例の DiscountManager
もクソデカな名前です。
Managerは「管理者」を意味しますが、管理とは一体何をするものなのか意味があまりにも広く、曖昧すぎます。
Managerと命名した結果待ち受ける地獄については、私が過去に制作したクソコード動画「Managerクラス」をご覧下さい。
クソコード動画「Managerクラス」#すえなみチャンス暑気払い pic.twitter.com/3FSQDkXfHu
— ミノ駆動 (@MinoDriven) August 3, 2019
概念解釈が途中で変化する、違う意味を持ち始める
この図は、最初顧客クラスが個人顧客だけを意味するものだったのに、途中の仕様変更により法人顧客の意味やロジックを持ち始め、責任が多重になる例を表しています。
仕様変更時、この手の意味変化と責任の多重化が特に起こりがちです。
ロジックが違う文脈で使い回されていたり、名前に違和感を覚えた場合、クラスの分割や、名前のリファクタリングを検討しましょう。
日常生活の例
ロボットアニメに興味のない人にとっては、ロボットとは全部「ああ、ガンダムみたいなやつ?」で区別がつきにくいでしょう。
ゲームに興味のない、一昔前の世代の方にとっては、ゲーム機は全てファミコンに見え、区別がつきにくいでしょう。
ちなみに私は自動車に関心がないので、車種とか言われてもまるで区別がつきません。
このように興味のない分野に関して、人は違いを区別するのは困難です。
区別する知識を持ち合わせていないのですから当然です。
ではより複雑なビジネス概念を扱うソフトウェア開発において、概念の細かな違いの区別なしに、まともに単一責任で設計できるでしょうか?
以上の例からも分かるように、概念をザルに解釈していると容易に単一責任原則に違反してしまいます。
ビジネスの関心が薄いと、ビジネス理解の解像度が悪くなります。モザイクになります。
理解がモザイクだと、細かな概念の違いを区別できなくなります。
概念の違いを区別できないと、本来別々ものとして定義されるべきロジックが、割引の例のように無理矢理流用されたりして密結合になり、単一責任原則に違反してしまうのです。
単一責任設計のスキルアップにこそドメイン駆動設計
細やかな概念の区別ができるようになるには、何を学べば良いのでしょうか。
いろいろある中でも、私は ドメイン駆動設計 を推薦します。
ドメイン駆動設計(DDD)には、 ユビキタス言語 、 境界付けられたコンテキスト 、 ドメインエキスパート 、 深いモデル など、概念分析の役に立つ考え方や手法が豊富です。
- サービスが解決したい顧客課題は何か?
- 顧客課題の周囲にはどんな世界があるか?
- その世界にはどんな概念があるのか?
概念を整理し噛み砕く考え方や手法が、DDDにはあふれています。
しっかり単一責任で設計したい人は、是非手にとってみることをオススメします。
【ダメ】やりがちな最悪の対処法【絶対】
なお、冒頭で挙げた割引の例。
バグを回避するために、付け焼き刃的につい以下のような実装で対処しがちです。
class DiscountManager {
// 中略
/**
* 割引価格を取得する
* @param price 商品価格
* @param isSummer 夏季限定割引の場合true
* @return 割引価格
*/
static int getDiscountPrice(int price, boolean isSummer) {
if (isSummer) {
int discountPrice = price - 300;
if (discountPrice < 0) {
discountPrice = 0;
}
return discountPrice;
}
return (int)(price * (1.00 - 0.04));
}
}
// これは超クソコードです。
// TVの前のみんなは絶対に真似してはいけません。
フラグ isSummer
で夏季限定割引かどうかを判定し、計算ロジックを切り替える実装です。
密結合で単一責任でない上に、条件分岐が増えて複雑化しています。ますます粗悪になり、保守性や変更容易性が低下してしまいます。
boolean引数でメソッドの機能を切り替える構造は、アンチパターン フラグ引数 と呼びます。
フラグ引数は、条件分岐の増大を招いて複雑化し変更容易性を低下する他、どんな機能であるかメソッド名からの類推を困難にする、邪悪な手法です。
仕様変更時、ついこの手の実装をやりがちですが、その後自分たちの首をしめることになるのでやってはいけません。
下手に共通化または流用し、内部で無理に条件分岐した結果待ち受ける地獄については、私が過去に制作したクソコード動画「共通化の罠」をご覧下さい。
クソコード動画「共通化の罠」 pic.twitter.com/MM750CNXc2
— ミノ駆動 (@MinoDriven) May 12, 2019
まとめ
- 概念、文脈、意味合いの違うロジックを無理に流用すると単一責任原則に違反した構造になる。
- 単一責任原則に違反すると、ある変更が意図せず別の機能に影響し、バグ化する。
- 単一責任原則の責任とは、ある単一の関心事についての正常動作に責任を負うことである。
- 単一責任原則を遵守するには、以下を意識して設計すること。
- 細かな概念を明確に区別すること。
- 各概念をクラス化すること。各クラス内で正常動作を保証するようロジックを組むこと。
- 単一責任原則を遵守するには、ビジネス理解が必須。
- ビジネス理解が浅いと細かな概念の違いを区別できなくなる。
- 概念の違いを区別できないとロジックレベルで密結合になりがち。
- ビジネス理解と概念分析には、ドメイン駆動設計が役立つ。