はじめに
「読みやすいコードのガイドライン」という本を読んでいる中で、
単一責任の原則
という原則が紹介されていましたので、記事にしてみたいと思います。
なお今回も前回の「KISSの原則」の記事の時と同様、
Kotlinで書かれた本著の内容をDartに置き換えて書いてみました。
単一責任の原則とは
単一責任の原則1とは、
「あるクラスやモジュールの役割・責務・関心事は一つに制約されるべき」
とする原則のことであり、SOLID原則の一つです。
一見すると、
「クラスが持つメソッドは少ない方がいい」
と認識されそうですが、どうやらそのような意味ではなく、
「メソッドも含め、クラスやモジュールの役割・責務・関心事は大きすぎてはいないか?」
と言うことに則ってコーディングできているか否かが重要みたいです。
例えば...
/// 単一責任の原則に則っていないクラス
class Alviss {
// テキスト表示、デバイスの破棄、ロケットの打ち上げ、その他諸々を行うかもれない...
void doEverything(UniverseState state) {...}
}
上記のメソッドは、
そのクラスの役割・責務・関心事を無視して最早なんでもやってしまいそうですよね...😅
このクラスのメソッドはこれだけなので
一見すると単一責任の原則を守っているようにも見受けられますが、
実はメソッドがそのスコープを超えた振る舞いをしてしまっているので、
単一責任の原則に違反してしまっているのです。
もっと具体例を見てみよう!
上記のコードでも
単一責任の原則のイメージが何とはなしに浮かんできたかとも思われますが、
ここではもう少し具体的なコードを見てみましょう!
単一責任の原則に則っていないケース
以下のコードを見てみてください。
/// 単一責任の原則に則っていないクラス
class LibraryBookRentalData {
// 書籍情報。IDとタイトルのリスト
final List<BookId> bookIds;
final List<String> bookNames;
// 書籍の貸し出し情報。利用者の名前と返却期限のマップ
final Map<BookId, String> bookIdToRenterNameMap;
final Map<BookId, DateTime> bookIdToDueDateMap;
LibraryBookRentalData({
required this.bookIds,
required this.bookNames,
required this.bookIdToRenterNameMap,
required this.bookIdToDueDateMap,
});
String? findRenterName(String? bookName) {...}
DateTime? findDueDate(String? bookName) {...}
}
上記のクラスは図書館にある蔵書情報や貸し出し関連の情報を扱っているクラスです。
確かに、こんな役割を持たせたクラスやその命名はなされるものとも思われすし、
一見すると問題らしい問題を感じにくいかとも思われます。
しかし、このクラスが扱う情報に
- 「書籍の著者や出版社名」
などといった情報が後から追加された際をイメージしてみてください。
(YAGNIの原則のことは、ここでは一旦置いておいて...)
すると、借りているユーザー名の取得やその貸し出し期限の取得といったメソッドにも、
何らかの影響が及ぶであろうことが想像できるかと思われます。
実際は及んでいないにしても、その調査は必要であり、
そもそもこれらの情報の追加に関しては、上記のメソッドの関心事とは本来異なるので、
「影響を受けるかもしれない。。。」
といった状況そのものがあるべき姿(単一責任)からかけ離れてしまっている
とも言えます。。。
とすると、このクラスの可読性やメンテナンス性、拡張性などが
どんどんと低下していきそうな気がしてしまいますね。。。😱
単一責任の原則を守るようにリファクタリング
そんな気配がしてきましたので、ちょっとリファクタリングしてみましょう💪
/// 単一責任の原則に則ってリファクタリングした例
class BookModel {
final String id;
final String name;
...
BookModel({required this.id, required this.name, ...});
}
class UserModel {
final String name;
...
UserModel({required this.name, ...});
}
class Entry {
final UserModel renter;
final DateTime dueDate;
Entry({required this.renter, required this.dueDate});
}
class CirculationRecord implements Entry {
final Map<BookModel, Entry> onLoanBookEntries;
CirculationRecord({required this.onLoanBookEntries});
@override
UserModel get renter {
...
}
@override
DateTime get dueDate {
...
}
}
上記のコードにより、本を借りているユーザー情報と書籍情報は別クラスとして抽出され、
貸し出し自体を管理するクラスは、
Entry
クラスを実装したCirculationRecord
クラスが担うことになりました。
先述のLibraryBookRentalData
クラスの時よりも、
「それぞれのクラスが何をするのか?」
こういった役割・責務・関心事が明確になった、
言い換えると、
「スコープが小さくなり、明瞭になった」
といったことが感じられたかと思われます。
まとめ
このように、単一責任の原則に則っていないケースでは、
エンティティーごとに抽出して切り出したり、
ロジック部分をコンポーネントやUtil関数として抽象化したり、
などといったことを行なって対象の責務を小さくして、
より対象の役割・責務・関心事を明確にすることにより、
可読性やメンテナンス性、拡張性の向上に繋がりそうですね🎶
その際は、
「このクラスって一言で言うと、どんな役割で何をしたいクラスなんだっけ?」
これを即答できなかったり、イメージが湧きづらいものになっていたり、
要約がしづらいものになっていた際は、
単一責任の原則に反している可能性が高そうなので、
リファクタリングのチャンスかもしれませんね☺️
参照
「読みやすいコードのガイドライン」Dart ver 4-2-3 直和型への置き換え
-
この原則もボーイスカウトルールと同じくRobert C. Martin氏が提唱されたとのことで、
「A class should have only one reason to change.」
(訳:あるクラスが変更される理由はただ一つであるべきである。)とのように提唱されたとのことです。2 ↩ -
「読みやすいコードのガイドライン」第1章30頁 ↩