はじめに
ソフトウェア開発をしていると、初めは「とりあえず動くコード」を目指していたのが、だんだんと「どうすれば保守しやすい設計・コードになるか」が気になってきます。
そこで一先ず思いつくのが冗長なコードの共通化ですが、無暗やたらと共通化を進めてスパゲッティ化してしまったり、逆に良かれと思って冗長化していたところが修正漏れのバグを生んでしまったり・・・思うようにいかないものです。
そこでヒントになってくるのが、いわゆる「ソフトウェア設計原則」ってやつです。
本記事では、筆者が共通化を考えるうえでヒントなった2つのソフトウェア設計原則について、簡単なJavaコード例とともに紹介します。(どちらも有名なものですが、筆者は意味を誤解して覚えちゃってました)
- DRY原則
- 単一責任の原則(SRP)
DRY(ドライ)原則
DRYとは、「"Don't Repeat Yourself"(自分自身を繰り返すな)」という意味。
すべての知識はシステム内において、単一、かつ明確な、そして信頼できる表現になっていなければならない。(達人プログラマー)
ソフトウェア開発において、同じ知識や情報がいろんな場所に繰り返し登場すると、修正時の漏れなどバグの温床になります。
DRYの立場では、複数使用箇所があるものは共通部分として抜き出すことで、不具合修正時の変更コストを下げることが望ましいとされます。
DRYじゃない例
public class UserService {
public void registerUser(String email, String password) {
// メール形式の検証
if (!email.contains("@") || !email.contains(".")) {
throw new IllegalArgumentException("無効なメールアドレス");
}
// 登録処理...
}
public void updateUserEmail(String oldEmail, String newEmail) {
// メール形式の検証(重複している!)
if (!newEmail.contains("@") || !newEmail.contains(".")) {
throw new IllegalArgumentException("無効なメールアドレス");
}
// 更新処理...
}
}
DRYな例
public class UserService {
public void registerUser(String email, String password) {
validateEmail(email); // 共通メソッド呼び出し
// 登録処理...
}
public void updateUserEmail(String oldEmail, String newEmail) {
validateEmail(newEmail); // 共通メソッド呼び出し
// 更新処理...
}
private void validateEmail(String email) {
if (!email.contains("@") || !email.contains(".")) {
throw new IllegalArgumentException("無効なメールアドレス");
}
}
}
DRYが行き過ぎてしまう例
「重複は悪!同じコードは全て共通化しよう」
public class UserService {
public void registerUser(String email, String password) {
validateEmail(email); // 共通メソッド呼び出し
// 登録処理...
}
public void updateUserEmail(String oldEmail, String newEmail) {
validateEmail(newEmail); // 共通メソッド呼び出し
// 更新処理...
}
// 追加
public void loginUser(String email, String password) {
validateEmail(email); // 共通メソッド呼び出し
// ログイン処理...
}
共通メソッド
private void validateEmail(String email) {
if (!email.contains("@") || !email.contains(".")) {
throw new IllegalArgumentException("無効なメールアドレス");
}
}
}
上記の例は一見何の問題もないように見えますが、「メールのバリデーション」という表面上の共通点で、登録処理・更新処理とログイン処理時におけるバリデーションを共通化しています。
もし今後「登録・更新時のメールバリデーション条件を少し厳しくしたい」という要望があった場合どうなるでしょうか?(RFCの改訂で正しいメールの定義が変わる、とか)
バリデーション条件を厳しくする前のユーザも問題なくログインできるために、せっかく共通化したバリデーション処理を、ログイン処理から引き剥がす修正とそれに伴うテストが必要になります。
コードの共通化を考えるときは、表面的な類似だけでなく、「呼び出し元の要求は本質的に同じ要求か?」を見極める必要があります(次の原則に繋がります)。
単一責任の原則(SRP)
SRPとは、「Single Responsibility Principle」の略。
モジュールはたったひとりのユーザーやステークホルダーに対して責務を負うべきである。
(Clean Architecture 達人に学ぶソフトウェアの構造と設計)
単一責任の原則は、名前から正しい意味を連想しづらく、誤解されやすい法則と言われています。
たとえば、
「一つのメソッドは一つだけのことをやるべき=一つの処理だけに責任を持つべき」
という覚え方です(私もこんな感じの理解でした)。
上記は、単一責任の原則が真に意味するところではありません。
ここでの「責任」とは、「どのような責任を果たすか」ではなく、「誰に対して責任を果たすか」という意味になります。
つまり、単一責任の原則とは、「クラスや関数は、正常に動作する責任を、2つ以上のユーザやステークホルダーに対して負ってはいけない」ということになります。
DRYが行き過ぎてしまう例を単一責任の観点で眺めると
public class UserService {
public void registerUser(String email, String password) {
validateEmail(email); // 未登録メールを検証(細かいチェック必要)
// 登録処理...
}
public void updateUserEmail(String oldEmail, String newEmail) {
validateEmail(newEmail); // 未登録メールを検証(細かいチェック必要)
// 更新処理...
}
// 追加
public void loginUser(String email, String password) {
validateEmail(email); // 登録済みメールを検証(最低限でOK)
// ログイン処理...
}
共通メソッド
private void validateEmail(String email) {
if (!email.contains("@") || !email.contains(".")) {
throw new IllegalArgumentException("無効なメールアドレス");
}
}
}
3つの呼び出し元のうち、
上二つがvalidateEmailを呼び出す目的は、新しいメールアドレスが正しいメールフォーマットかの厳密な検証です。
loginUserがvalidateEmailを呼び出す目的は、ログイン処理前の前捌きとしての最低限の検証です。
つまり、上記でvalidateEmailは、
①新しいメールアドレスが正しいメールフォーマットかの厳密な検証
②ログイン処理前の前捌きとしての最低限の検証
という異なる目的に対して二重に責任を負っていることになります。
その結果、どちらかの要求に変更が入ることでもう一方の要求を満たせなくなる、という事故が発生するかもしれません(登録時のメールアドレス検証ロジックの修正で、既存ユーザの一部がログインできなくなる、など)。
以上を踏まえると、先述した単一責任の原則の意味が見えてくるのではないでしょうか。
「クラスや関数は、正常に動作する責任を、2つ以上のユーザやステークホルダーに対して負ってはいけない」
↓
「クラスや関数は、異なる目的を持つ二つ以上の呼び出し元を持つべきではない」
単一責任の原則を意識したコード例
public class UserService {
public void registerUser(String email, String password) {
validateEmailForRegistration(email); // 厳密な検証したい
// 登録処理...
}
public void updateUserEmail(String oldEmail, String newEmail) {
validateEmailForRegistration(newEmail); // 厳密な検証したい
// 更新処理...
}
public void loginUser(String email, String password) {
validateEmailForLogin(email); // 最低限の検証でOK
// ログイン処理...
}
// 新規データの品質保証
// 将来ルールが変わってもログイン処理に影響しない!
private void validateEmailForRegistration(String email) {
if (!email.contains("@") || !email.contains(".") || email.length() < 5) {
throw new IllegalArgumentException("無効なメールアドレス形式です");
}
}
// ログイン前の入力チェック(最低限)
// 将来ルールが変わっても登録・更新処理に影響しない!
private void validateEmailForLogin(String email) {
if (!email.contains("@")) {
throw new IllegalArgumentException("メールアドレスを正しく入力してください");
}
}
}
おわりに~DRYとSRPの狭間
今回紹介したDRY原則と単一責任の原則は、一見相反関係にあるように感じるかもしれません。
しかしどちらも最終目的はコードの品質・保守性を高めることです。
例えば、ある二つの重複コードの共通化を思いついたとき、「この共通コードが変更される時、全ての呼び出し元も一緒に変更されるべきか?」と自問してみるのもよいかもしれません。
その答えがYESなら、自信をもって共通化を進めてしまって良いと思います。
なお、DRYとSRPの狭間に立った時、本質的に同じ知識・同じ呼び出し理由か?を見極めるのは結構難しいと思っています。
先述のログイン処理の例も、将来的に処理が変更になる可能性は極めて低いと判断したうえで、DRYを遂行するという判断もアリだと思うからです。
そういう意味では、エンジニアも顧客のニーズやビジネスのことを考えて設計・実装をするということは必要なのだと思います(ポエム)
※「単一責任設計はビジネスに関心がないと困難」というお話
https://qiita.com/MinoDriven/items/76307b1b066467cbfd6a