はじめに
この記事では、ついに単一責任原則をより詳しい言葉で説明します。これにより、本連載の目標である 「どうしてあなたの共通化は間違っているのか」をより理論的・体系的な言葉で言語化することが可能になります。これは、設計のバグがソフトウェアの成長によって顕在化して明らかになる前に、理論によって設計ミスを検出してソフトウェアの品質を高められるということです。ソフトウェアアーキテクトにとっては非常にうれしい理論的な結果になると思います。
いいね・ストックが励みになります!
新・単一責任原則
第1章で述べた通り、単一責任原則とは、設計単位は責務・関心事をただひとつだけ扱うべきだという原則です。この連載では、この原則についてより解像度を高めるために、抽象度と文脈という概念について詳しく解説してきました。この概念を利用することで、単一責任原則は以下のように書くことが出来ます。
新・単一責任原則
- 設計上の単位は、単一の抽象度を持つべきである
- 設計上の単位は、単一の文脈を持つべきである
この原則は、より判定がしやすいように、次のように書き換えられます。
抽象化に関する原則
- 文脈を保つ・保たないにかかわらず、抽象化が出来る場合は抽象化をしなければならない
- 上位モジュール・下位モジュールともに抽象化は破壊してはならない
文脈に関する原則
- 文脈依存性があるモジュールを指定された文脈の直下以外で呼び出してはならない
- 文脈依存性がないモジュールを不適切な文脈で呼び出してはならない
これらの処理の内容についての基本的な原則に加えて、モジュール分割ではモジュールに名前を付けなければならないため、次の原則も必要です。
命名に関する原則
- モジュールは名前から推測できない処理を行ってはならない
- 名前は、グループの名前、ディレクトリ構成と合わせて文脈を完全に指定しなければならない
- 名前は、モジュールの文脈の範囲で最も抽象的でなければならない
抽象化に関する原則1
抽象化に関する原則1
文脈を保つ・保たないにかかわらず、抽象化が出来る場合は抽象化をしなければならない
原則1で述べられている抽象化とはなんなのかについては、第3章にて詳しく解説しています。
この原則は、抽象化を出来るだけ行うべきであるというものです。この原則が適切に守られている場合、全てのモジュールからは、内部の処理の内容を知らなくてもいい処理を取り出すことは出来ず、かつ、処理のまとまりにより分かりやすい役割の名前を与えて分離することも出来ません。
このことから、単一の抽象度とは何なのかが分かります。
単一の抽象度
あるモジュールを複数モジュールに分割して抽象化が成立すれば、そのモジュールの抽象度は単一でない
これ以上分離できないという意味で、単一という言葉から直感的に理解できる定義になっています。
抽象化に関する原則2
抽象化に関する原則2
上位モジュール・下位モジュールともに抽象化は破壊してはならない
この原則は、抽象化したつもりでモジュールを分割しても、不適切な分割であれば単一の抽象度を実現できないという意味です。分かりやすく言えば、単に設計が壊れていて変更容易性が低いせいでコードが分離できないことを、抽象度が単一で良い設計と呼ぶのは誤りであるという意図です。この原則を満たさないパターンを説明します。
親モジュールに詳細知識を要求するパターン
第3章抽象化に失敗しているパターンにて詳しく解説しています。
このパターンは、下位モジュールの設計が悪いために抽象化が破壊されています。
本来隠せている処理を無理やり突破しているパターン
抽象化に失敗しているパターンは、子モジュールの設計が悪いために抽象化が出来なくなっています。それに対して、親モジュールの設計が悪いせいで抽象化が破壊されるパターンも存在します。
親モジュールによる抽象化の破壊は、本来知らなくていい処理を勝手に呼び出してしまうことが原因です。
具体的に、ステートを持たずに重要情報を全てCookieに保存するWebアプリケーションを考えてみましょう。ここでは、次の2つの抽象化を行います。
saveSession
loadSession
saveSession
では、内部的に以下のような処理を起こっているとします。
-
saveSession
- AES暗号化
- 署名
- LZ4圧縮
- Cookieに保存
loadSession
はsaveSession
と逆順です。
-
loadSession
- Cookieからの読み出し
- LZ4解凍
- 署名の検証
- AES復号化
さて、上位モジュールが勝手にクッキーを読み込んで解凍して署名検証して復号化を呼んでいることを把握して、loadSessionの存在を知らずに自前実装すれば、抽象化が完全に破壊されます。どうしてこんな当然のことを仰々しく書くのかと思えるかもしれませんが、このような抽象化の破壊は現実問題発生します。このアンチパターンが発生しやすいのは次のような場合です。
- 適切なライブラリの使用方法が分かっていないとき
- ライブラリのドキュメントが不足しているとき
- プログラマが余計な内部知識を持っているとき
- 設計意図が分かっていない新メンバーがコードを書いているとき
割れ窓理論
抽象化に失敗している設計は、プログラマの理解を困難にし、より破綻した設計を生み出す
例えば、上記のWebアプリケーションと通信する別アプリケーションでセッションに保持されている一部の情報を利用したいとします。このとき、仮にその情報を提供する機能がWebアプリケーションに不足していた場合、および十分にドキュメントが無いせいでその処理が発見すらされなかった場合、利用者が気合で全てを理解してその結果全てを破壊してしまうことがあります。
このアンチパターンを防ぐには、出来るだけ内部で使っている生のデータ構造を見せず、型を用いて適切な処理以外を使えなくすることが有効です。例えば、上記の例ではEncodedCookieValue
型を定義して、ここからstringに変換する唯一のモジュールとしてloadSession
を用意しておけば守りやすいです。とはいえ、これは単なる緩和策に過ぎません。例えば、リクエストをパースしてEncodedCookieValue
を作成する部分の処理すらスキップして、勝手にリクエストのCookie情報を直接読み込んで直接文字列をパースされたらどうしようもないです。結局はプログラマが正確にコードベースを理解することが根本的な解決策になります。
文脈に関する原則1
解説
文脈に関する原則1
文脈依存性があるモジュールを指定された文脈の直下以外で呼び出してはならない
この原則は、文脈依存性があるモジュールについて、文脈が単一であることを求めるものです。
文脈が単一であるとは次のように定義されます。
あるモジュールAのすべての上位モジュールが、Aの文脈が想定する条件を満たすとき、文脈は単一である。
文脈依存性があるモジュールは、特定のモジュールの直下で呼ばれるものだという文脈を持っています。このようなモジュールを別のモジュールから呼び出すと、文脈を無視した影響で次のような不具合が起きます。
- メソッド名・ローカル変数名が不自然になる
- 呼び出し元によって処理を分岐させたくなる
これらの不具合は、つまるところ、複数の呼び出し元ごとの文脈情報が1つのモジュールに含まれることが原因でモジュールの複雑性が高まっていることを意味します。
このことから、モジュールが想定していない文脈を持った上位モジュールが存在するとき、文脈が単一でなくなると分かります。
このような事態は、文脈依存性の有無を明確にしておけば防ぐことが出来ます。特定の1か所だけから呼ばれることを想定しているメソッドが2か所から呼ばれているというのは、比較的気づきやすい誤りなので、設計を大切にするという意識さえあれば防ぎやすいミスです。
文脈に関する原則2
解説
文脈に関する原則2
文脈依存性がないモジュールを不適切な文脈で呼び出してはならない
文脈依存性が無いモジュールにも文脈があります。そのため、モジュールが想定していない範囲での再利用は設計上の問題を引き起こします。発生する不具合の内容は文脈依存性がある抽象化の場合と同じです。
具体例
具体例として、設定ファイルを読み込む機能がある会員制Webサイトを考えてみましょう。このWebサイトでは、会員登録の際にユーザーのホームページを登録することが出来るとします。このとき、ソフトウェアには以下の2つのモジュールがあるとします。
- 設定読み込みモジュール:設定ファイルを開いて適切にバリデーションを行い、
Config
構造体を返す - ユーザー登録画面:登録画面のWebページを生成する。Webページからのリクエストに応じて適切にDBを更新する
このとき、設定ファイルとユーザー登録の両方にURLを書き込む項目があって、その検証処理を原則2を満たさない形で共通化した場合、モジュールの構成は次のようになります。
設定読み込みの文脈におけるIsValidURL
モジュールは、あくまで設定ファイルの検証の範囲でのURLの検証しかやっていないので、厳密なURLの検証は不要です。設定ファイルを書くのはプログラマなので、ある程度のバリデーションが出来ればそれでよいのです。対して、ユーザー登録では悪意をもった攻撃者が想定されます。そのため、より厳密な検証でなければユーザー登録におけるIsValidURL
に使うことが出来ないという可能性があります。したがって、ソフトウェアの成長につれて2つのURL検証処理に求められる内容の違いは顕在化していきます。
図式で表示するとこんなミスはしないだろう、と思うかもしれません。しかし、仮にURL検証モジュールがプロジェクトのルートディレクトリのcommon.js
に配置されていたとすればどうでしょうか。これも割れ窓理論ですが、モジュールの設計意図とモジュールの配置・命名の設計意図が異なれば原則2は簡単に破られてしまいます。
文脈に関する原則が破られる原因
文脈に対して抽象度が低い命名
上位のモジュール内部の役割を無視して必要以上に技術的・汎用的な命名をすると、原則が破られる原因になります。
これに関連して、第3章の命名に関する節で「モジュール名の文脈は、モジュールの文脈の範囲から逸脱してはならない」と書きました。そのときは、モジュールの命名単体では文脈が分からなくても、ディレクトリ構成やグループへの命名などの情報が文脈を伝えるために命名で完全に文脈を指定することは不要であるとしました。逆に言えば、様々な手段を使ってもなお表現されていない文脈がある場合は、問題を引き起こします。
例えば、スクレイピングによってQiitaから情報を取得するアプリケーションを作っていたとします。最初はすべてをmain.go
に記述していました。このアプリケーションでは、文脈依存性のない抽象化として次の関数を定義していました。
// ログイン用クレデンシャルを取得する
func getCredential() (string, string, error) {
// configファイル、環境変数の順に情報を探す
// メールアドレスとパスワードを取り出す
}
設計を行う過程で、各画面ごとの操作の詳細をlogin.go
やtimeline.go
に分割したとします。login.go
では、ログイン画面のボタンの場所などを抽象化します。このとき、getCredential()
がmain.go
に放置されてしまいました。なぜなら、文脈依存性のない抽象化として別の関数として定義されていたので、忘れ去られていたからです。
これにより、ログイン以外では使い道のないはずのクレデンシャルの取得が、全モジュールが使える汎用処理のように見えるパスに配置されました。
さて、プロフィール画面で、「ログイン中のアカウントのメールアドレスが知りたい」という要望が得られたとします。本来ならばプロフィール画面をスクレイピングして情報を取得して、フォロー中のタグなどすべてのプロフィール情報を適切にまとめた構造体を返すべきです。しかし、文脈に関する単一責任原則を無視してしまえば、getCredential()
を使って、パスワードを無視するという手段を取ることが出来ます。これは、設計上の誤りであり、仮にQiitaのログインがユーザーIDとパスワードに変更されたら無関係で済むはずだったプロフィール情報の取得が壊れてしまいます。加えて、今回の例では、誤ってパスワードを表示してしまうなどのセキュリティインシデントの発生確率も上がっています。
アクセシビリティの設定の不備
アクセシビリティの設定とは、C#におけるprivate、publicなどの修飾子やGoにおけるunexported、exportedのように、どこからであれば特定のモジュールにアクセスすることが出来るかという設定のことです。(Webアクセシビリティなどのユニバーサルデザインの文脈におけるアクセシビリティとは別の概念のため注意してください)
仮に命名が不適切であっても、アクセシビリティ設定によって設計者の意図を伝えることが出来れば、文脈に関する原則を破ることのおかしさに気づくことが出来ます。加えて、アクセシビリティ設定は機械的に原則の違反を判定できる点で優れています。
例えば、以下のような工夫をすることが出来ます。
- 文脈依存性のあるモジュールは1回しか呼び出されないため、privateにする
- リファクタリングで一部の処理を子モジュールに分割する際、その子モジュールでしか使わない処理を全て子モジュールへ移動させる
文脈を保持しない抽象化が不十分
抽象化に関する原則1で反しているプログラムは、文脈に関する原則にも違反しやすくなります。なぜなら、文脈を保たない抽象化をしていないために、再利用したくなる処理が文脈と切り離されていないからです。このような問題は2パターンのうちどちらかです。
具体的な処理が再利用できない
これは車輪の再発明と呼ばれるアンチパターンです。
既にライブラリとして存在する内容を自力でメソッド内部で実装してしまう場合、生産性が下がってしまいます。ライブラリはアプリケーション開発者以外が公開しているため、再利用に固有の困難があります。
- ライブラリに無駄な機能がある
- ライブラリに機能が不足している
これらの問題は一般の開発者が対処できる問題ではありません。ある程度の回避方法として、次のようなライブラリを選びましょう。
- なんらかの標準に従っている:標準を作成するときにはアプリケーション固有の機能などはOPTIONALにするように一定の議論が行われていることが多い
- インクリメンタルなライブラリである:最初からひとまとまりでフルスタックに提供されるのではなく、基礎的な機能と応用的な機能を別々にしている
- 基礎的な機能の設計が使いやすい
UNIX哲学のひとつ
ひとつのことをうまくやるプログラムを書く
ライブラリに便利な機能があれば、設計的にはそこでやるべきでないこともついつい使いたくなってしまいます。設計を正しく保つという観点からは、多少の機能不足があったとしても、基礎的な機能を持ったライブラリを利用して、抽象化により自分のアプリケーションの文脈をもった形にするほうが、設計上の問題は起こりにくくなります。ライブラリに対して適切な抽象化を提供する例は第3章の具体例で解説しています。
※ライブラリによる工数削減と設計上の誤りを比較したとき、どこに損益分岐点があるのかというのは設計論の範囲を出てしまうので解説しませんが、重要な論点です。
抽象的な処理が再利用できない
抽象的な処理がうまく再利用できていない場合、1つのモジュール内で抽象的な処理を無理やり再利用しようとします。これにより、具体的な文脈を大量に含む巨大な汎用モジュールが出来上がってしまいます。そして、この巨大モジュールは、そのモジュールが含むすべての個別文脈で呼び出され、結果として全体の変更容易性を下げます。この現象については第4章で解説しました。
これが原因で文脈に関する原則が破られると、例えば @MinoDriven さんのクソコード動画 switch文のようなコードが作られます。switch文自体は悪いものでないのですが、次のような条件を満たすswitch文は悪いコードです。
- 各caseがswitch文以外の文脈よりも多くの情報を持っている
- 各caseごとに異なる文脈を持っている
文脈に関する原則に強く違反するモジュール
違反の度合いにも強い弱いがあります。弱い違反であればリファクタリングが容易ですが、強い違反をしているソフトウェアの設計を修正するのは困難です。
文脈に関する原則の違反度合い
- サブモジュールを呼び出す複数の上位モジュールのうち、ただひとつのモジュールの固有の文脈だけがサブモジュールで使われている場合、サブモジュールの違反度合いが弱いまたはサブモジュールは弱く違反するという
- サブモジュールを呼び出す複数の上位モジュール固有の文脈が、サブモジュール内部で使われている場合、サブモジュールの違反度合いが強いまたは、サブモジュールは強く違反するという
サブモジュールで使われている文脈は、以下のようなものから読み取れる上位モジュールに関する情報です。
- ローカル変数名
- 内部で呼び出しているサブモジュール名
- モジュール自体の名前
弱い違反の場合
違反度合いが弱い場合、サブモジュールは複数の呼び出しの内、特定のモジュールから呼び出されることが想定されていることが明確に分かります。そのため、単に違反した呼び出しを削除すれば文脈に関する原則に違反することはなくなります。
ただし、文脈に関する原則に違反したくなったのは、おそらく共通の処理が含まれているからです。したがって、単に呼び出しを削除しても抽象化に関する原則には違反したままであることに注意してください。共通化したい部分を文脈依存性のない抽象化によって抽出してください。
強い違反の場合
違反度合いが強い場合、どのモジュールが違反した呼び出しをしていて、どのモジュールが正しい呼び出しをしているのかが分かりません。そのため、リファクタリングをしようにも、どの呼び出しを削除すればいいのかが分かりません。理論的には、すべての呼び出しが原則に違反しているため、すべての呼び出しを削除して作り直す必要があります。いわゆる「作り直した方が速いクソコード」が生まれてしまったわけです。
違反度合いの強いモジュールの典型的なパターンが、前述の文脈ごとにcaseが用意されたswitch文を含むメソッドです。
中間的な強さの違反
違反度合いの強さは「強い」「弱い」の2択ではありません。
例えば、サブモジュール内部で1つだけif文があって、その内部だけで別の上位モジュールの文脈固有のローカル変数名が使われていて、それ以外は特定の上位モジュールの文脈で統一されている場合などは、弱い違反から強い違反に成長しようとしている途中だと言えます。
「共通化の罠」を違反の強さの概念から解釈する
一般に、ソフトウェアが成長していくと違反度合いは強くなります。これについては、@MinoDriven さんのクソコード動画を見るのが分かりやすいです。ソフトウェアは成長すると様々なロジックを増やすため、共通モジュール内部に呼び出し元の文脈特有の処理を書きたくなってしまうのです。
動画の中の序盤の共通化は、共通化の内容が文脈依存性の無いものである限りは単一責任原則を満たす可能性があります。(動画の情報では単に似たような処理としか書かれていないため、実際のところどうなのかは分かりません)
この瞬間に共通モジュールには「呼び出し元がAである」という文脈が追加されました。これにより、クラスB、クラスC、クラスDで呼び出してはいけなくなりました。これが弱い違反です。この時点でリファクタリングをしておけばまだどうにかなっていた可能性があります。
この瞬間に共通モジュールには「呼び出し元がBである」という文脈が追加されました。これにより、共通モジュールを呼び出せるのはもはや「クラスAかつクラスB」という矛盾した存在だけになります。これが強い違反です。
命名に関する原則1
命名に関する原則1
モジュールは名前から推測できない処理を行ってはならない
この原則は、命名に関するもっとも基本的な原則です。モジュールの命名は、あくまでモジュールがやっている処理を抽象的にまとめるものであって、名前の抽象化としてありうる範囲外の挙動を行うことは悪いことです。次のような設計原則は、命名に関する原則1の具体例として解釈することが出来ます。
- REST APIでは
GET
で状態を変更することが禁止されている - JavaScriptの
==
演算子は、名前から分かる内容は同じだが、勝手に型を変換して予想されない挙動をするため===
演算子を使うことが推奨されている
命名に関する原則2
命名に関する原則2
名前は、グループの名前、ディレクトリ構成と合わせて文脈を完全に指定しなければならない
この原則は、文脈に関する原則と深く関係しています。命名に関する原則2を守ったコードは、文脈に対して抽象度が低い命名の節で説明したQiitaのスクレイピングアプリの例ような誤った文脈外のモジュールの再利用が行われた際に、誤ったコードがどこにあるのかが明白になります。なぜなら、文脈外の言葉で名前が書かれた、文脈外の場所に配置された、文脈外のグループに属するサブモジュールを無理やり呼び出していることが見えるからです。
命名に関する原則3
命名に関する原則3
名前は、モジュールの文脈の範囲で最も抽象的でなければならない
この原則は、抽象化に関する原則と深く関係しています。抽象的な名前については第3章のモジュール名のメトリクスの節で詳しく説明しました。
できるだけ抽象的な名前を選ぶことで、上位モジュールが処理の意味や役割だけを理解して使うことが出来るため、個々のモジュールの認知負荷を下げることが出来ます。
単一責任原則が破られるパターン
ここまでで、単一責任原則についてより詳しく学習してきました。この考え方をもとに、種々の単一責任原則の失敗について分類してみましょう。
抽象度が複数であるが、文脈は単一である
第1章で取り上げた割引価格計算プログラムを単一責任原則の観点から考察します。
class DiscountManager {
// 中略
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));
}
}
このコードは、複数の抽象度と単一の文脈をもつメソッドです。このモジュールの構成を図にすると次のようになります。
モジュール単体が多くのことを知りすぎていることが分かります。このタイプの原則の違反は比較的簡単に修正できます。なぜなら、メソッドの内容から抽象化できそうな場所を少しずつ切り出していけば良いからです。
また、今回の例では名前に関する原則にも反しています。DiscountManager
クラスは、「割引に関連する」程度しか説明していません。そもそもプライベート変数を使う処理が存在しないので通常のクラスである必要がありません。staticクラスとしましょう。
修正後のモジュールを説明する図は次のようになります。
例えば、DiscountedPrice
グループにCalc
、getSummerDiscount
、getNormalDiscount
の3つのサブモジュールを作成することで抽象度を単一にできます。
抽象度は単一だが、文脈が複数である
文脈に関する原則2の会員制WebサイトにおけるIsValidURL
モジュールは、単一の抽象度で複数の文脈を持つ処理です。このモジュール自体は適切に抽象化を行って処理内容を分かりやすくしていますが、複数のモジュールから呼び出されてしまうことにより、文脈が増えてしまいます。
このようなメソッドが発生するのは文脈に関係ない共通処理があるからです。例えば、IsValidURL
では、次のような処理を行っていたとします。
ここで呼び出されているサブモジュールは次の4つです。
- URLとして正しいこと
- httpsであること
- (isConfigフラグがtrueのときのみ)事前に登録されたホストであること
- (isRegisterURLフラグがtrueのときのみ)クエリパラメータを持たないこと
設定ファイルのURL検証で必要なのは1番,2番,3番であり、会員登録ページでのURL検証で必要なのは1番,2番,4番です。IsValidURL
の文脈が複数になったのは、1番と2番を再利用したかったからです。
解決策として、IsHTTPSURL
モジュールをより文脈の少ないモジュールとして切り出して文脈依存性のない抽象化を行います。そして、IsValidURL
をConfig
配下とUserRegistration
配下にそれぞれ作成して、IsHTTPSURL
を呼び出すようにします。
本質的により少ない文脈で成立する部分を分離することで再利用を実現していることが分かります。
抽象度も文脈も複数である
複数の抽象度で複数の文脈を持つモジュールは、上で挙げた2つの誤りが同時に発生しているものです。つまり、抽象化が行われずに読みにくくなっているモジュールが、想定外の複数個所で呼び出されている状態です。
この状態から適切なコードにリファクタリングすることは困難です。なぜなら、文脈の失敗を取りかえす前提として必要な「どこかどの文脈に対応しているのか」という情報を抽出する前提として必要な抽象化が出来ていないために、問題解決のための手順が多すぎるからです。また、コードの複雑性が大きすぎて簡潔で分かりやすい具体例を提供することも困難です。
このパターンを表しているのが @MinoDriven さんのクソコード動画「共通化の罠」です。この動画においてクラスAとか共通処理Bという表記が使われているのも、抽象度と文脈の両方が単一でないような具体例を作ることの困難さを表していると推察できます。
このようなコードをリファクタリングするには、まずコードを何とかして解析し、特定の意味単位でサブモジュールを作成して、抽象化を進めましょう。なぜなら、複雑なものを抽象化せずに複雑なまま扱って処理を分割すると意図しない不具合が発生しやすいからです。
次に、抽象度が単一であることを活かしてどのコードがどの文脈で使われているのかを解析しましょう。解析が出来たら、文脈依存性のない抽象化を行って共通部分の処理を抽象化します。さらに、呼び出し元の文脈ごとに文脈依存性のある抽象化を行って文脈を単一に戻します。
問題のレベルによっては、作り直した方が良いレベルのクソコードになります。そのため、次の原則で対応しましょう。
抽象度と文脈が両方単一責任原則を満たさないモジュール
このようなモジュールが生まれた時点で設計の敗北である。治療よりも予防が重要
片方が単一責任原則を満たさなくなった時点で発見し、リファクタリングを行うことによって予防することが出来ます。
おわりに
この記事では第1章からの目標を達成し、単一責任原則という理論面だけから(つまり、ドメイン知識に頼らずに)設計の失敗を指摘するということが出来ました。これから出会うすべてのモジュール分割の例に対して、読者が明確に問題点を言語化することが出来るようになればうれしいです。
なお、この連載の内容はこの記事で一区切りとなります。これ以降の記事は補足的な内容です。
コラム:設計トレーニング
この記事で学んだ内容は、かなり理論的なので、実践するためには多少の練習が必要です。筆者のおすすめする練習法は、適当なソフトウェアに対して、全てのモジュールに対してそれが提供する抽象化とモジュールの文脈をコメントとして記述することです。明示的に抽象度と文脈を言語化することで、単一責任原則を満たさない場所を見つけ出す訓練が出来ます。