この記事について
年末年始の期間で、良いコードや良い設計とは何なのかについて書かれた書籍をいくつか読み、その中で凝集と結合が取り上げられていたので、調べました。
この記事では、モジュール(クラスや関数)の品質を評価する概念である凝集と結合について、簡単に要点をまとめています。
凝集と結合
No. | 概念 | 概要 |
---|---|---|
① | 凝集 | モジュール内の要素がどれだけ密接に関連しているかを示す概念 |
② | 結合 | モジュールが他のモジュールにどれだけ依存しているかを示す概念 |
①高凝集であるべき
凝集についてです。
モジュール内に所属しているデータとロジックが適切に協調している状態が良いとされていて、この状態を高凝集と呼びます。
凝集には凝集度という分類があり、凝集度が高いほど堅牢な設計となり良い設計と言えます。
凝集度が最も低い偶発的凝集は避けるべき状態で、最も高い機能的凝集が推奨される状態です。
凝集度 | 種類 | 概要 |
---|---|---|
高 | 機能的凝集 | 1つの明確なタスクを実行するために全ての要素が所属 |
逐次的凝集 | ある処理の出力が次の処理の入力になる要素が所属 | |
通信的凝集 | 同じデータを扱う複数の要素が所属 | |
手順的凝集 | 一連の手順に従う要素が所属 | |
時間的凝集 | 実行のタイミングが近い要素が所属 | |
論理的凝集 | 同じカテゴリに属するが、異なる処理を行う要素が所属 | |
低 | 偶発的凝集 | 要素間に関連性がなく、偶発的にクラスに所属 |
現実的には全てのソースコードで機能的凝集を実現することは難しいので、部分的には逐次的凝集、通信的凝集、手順的凝集、時間的凝集などが混在してクラスが構成されることになります。
目指すべき状態は、要件に応じて適切な凝集度を選択し、偶発的凝集や論理的凝集を避け、凝集度のバランスをとることで全体的に高凝集に近づけることです。
機能的凝集
目指すべき最も高凝集な状態。
モジュール全体が「支払い処理」という1つの明確な責務に集中している例。
class Payment {
fun process(userId: Int, amount: Double) {
if (validatePayment(userId, amount)) {
withdraw(userId, amount)
sendReceipt(userId, amount)
}
}
private fun validatePayment(userId: Int, amount: Double): Boolean {...}
private fun withdraw(userId: Int, amount: Double) {...}
private fun sendReceipt(userId: Int, amount: Double) {...}
}
逐次的凝集
あるモジュールの出力が、他のモジュールの入力に使用される例。
gatherDataの出力がprocessに渡され、processの出力がsaveの入力になる。
class TaskProcessor {
fun execute() {
val data = gatherData()
val processedData = process(data)
save(processedData)
}
private fun gatherData(): List<String> {...}
private fun process(data: List<String>): String {...}
private fun save(result: String) {...}
}
通信的凝集
1つのデータ構造 (UserDatabase) を扱うメソッドが集約されている例。
class UserManager(private val userDatabase: UserDatabase) {
fun addUser(userId: Int, userName: String) {...}
fun getUser(userId: Int): String {...}
fun removeUser(userId: Int) {...}
}
時間的凝集
アプリケーション起動時に行う初期化関連処理をまとめた例。
class AppInitializer {
fun init() {
loadConfiguration()
setUpLogger()
connectDatabase()
}
private fun loadConfiguration() {...}
private fun setUpLogger() {...}
private fun connectDatabase() {...}
}
手順的凝集
モジュール内のメソッドが「ファイル処理」という一連の手順を順番に実行する例。
class FileProcessor {
fun processFile() {
readFile()
parseData()
validateData()
saveData()
}
private fun readFile() {...}
private fun parseData() {...}
private fun validateData() {...}
private fun saveData() {...}
}
論理的凝集
可能な限り避けるべき。
論理的に近しいロジックを1つのモジュールに所属させてしまっている例。
改善策の一例としては、sendEmail / sendSms / sendPushNotification をそれぞれNotificationManagerクラスから分離させて、通知方法の実装詳細はNotificationManagerクラスに知られないようにする。
class NotificationManager {
fun sendNotification(type: String, message: String) {
when (type) {
"email" -> sendEmail(message)
"sms" -> sendSms(message)
"push" -> sendPushNotification(message)
}
}
private fun sendEmail(message: String) {...}
private fun sendSms(message: String) {...}
private fun sendPushNotification(message: String) {...}
}
偶発的凝集
最も避けるべき。
RandomManagerというモジュールに全く関連性の無いメール送信のロジックが所属している例。
class RandomManager {
fun sendEmail(email: String) {...}
fun generateRandomString(): String = "abc123"
}
②疎結合であるべき
結合についてです。
あるモジュールが他の多くのモジュールに依存している状態を密結合と呼び、避けるべき状態です。
逆に、他のモジュールに対して最小限の依存関係を持ちながら機能を実現する状態を疎結合と呼び、推奨される状態です。
結合度 | 種類 | 概要 |
---|---|---|
疎 | メッセージ結合 | メソッド呼び出し等により、最小限依存で通信する状態 |
データ結合 | 単純なデータを介してモジュール間で通信する状態 | |
スタンプ結合 | 複合データ構造を共有し、一部のみを利用する状態 | |
制御結合 | 処理の制御を他モジュールに渡してその動作を制御する状態 | |
外部結合 | 外部データ形式を共有している状態 | |
共通結合 | 複数モジュールが共通のリソースにアクセスしている状態 | |
密 | 内容結合 | 他モジュールの内部データや実装に直接依存している状態 |
メッセージ結合
メッセージ(命令や指示)をモジュールに送る形でやり取りする例。
呼び出し元は「こんな処理を実行してほしい」というメッセージ(インターフェースやメソッドの呼び出し)を送り、具体的な実装の中身や詳細なデータ構造には依存しません。
interface Message {
fun execute()
}
class EmailMessage(private val address: String) : Message {
override fun execute() {
println("Sending email to $address")
}
}
class NotificationManager {
fun sendMessage(message: Message) {
message.execute()
}
}
fun main() {
val email = EmailMessage("sample@example.com")
val manager = NotificationManager()
manager.sendMessage(email)
}
データ結合
引数や戻り値を使って、単純なデータをモジュール間でやり取りする例。
やり取りされるのは「値(データ)」だけで、具体的な処理の方法やフラグ(制御情報)などは渡しません。
class SquareCalculater {
fun calculateSquare(value: Int): Int {
return value * value
}
}
class SquarePrinter {
fun printSquare(value: Int) {
println("Square: $value")
}
}
fun main() {
val calculater = SquareCalculater()
val printer = SquarePrinter()
val square = calculater.calculateSquare(5)
printer.printSquare(square)
}
スタンプ結合
複合データ構造の1つであるデータクラスを複数モジュールで共有している例。
data class User(
val id: Int,
val name: String
)
class Repository {
fun getUser(): User {
return User(1, "sample user")
}
}
class Processor {
fun processUser(user: User) {...}
}
fun main() {
val repo = Repository()
val processor = Processor()
val user = repo.getUser()
processor.processUser(user)
}
制御結合
あるモジュールから他モジュールにflagを渡し、処理の流れを制御している例。
class Processor {
fun process(flag: Boolean) {
when(flag) {
true -> println("update true")
false -> println("update false")
}
}
}
class Executer(private val processor: Processor) {
fun execute() {
processor.process(true)
}
}
外部結合
外部データフォーマットにアクセスしている例。
class ExternalService {
fun getFormattedData() {...}
}
class Repository {
fun processExternalData(service: ExternalService) {
val data = service.getFormattedData()
}
}
共通結合
複数モジュールがグローバルデータにアクセスしている例。
var globalData = "sample global data"
class ModuleA {
fun update() {
globalData = "updated by module A"
}
}
class ModuleB {
fun update() {
globalData = "updated by module B"
}
}
内容結合
最も避けるべき。
あるモジュールが別モジュールの内部実装やデータを直接参照・変更する例。
なぜ避けるべきか?
- 保守性の低下
あるモジュールの内部を変更すると、直接アクセスしているすべてのモジュールを修正する必要がある。 - 再利用性の低下
内部実装に依存するモジュールが多いと、単独でモジュールを切り出して再利用するのが難しくなる。 - 可読性の低下
設計が複雑化し、どこでどのようにデータが変更されているか把握しづらくなる。
class ModuleA {
var data: String = "sample data"
}
class ModuleB(private val moduleA: ModuleA) {
fun modifyData() {
moduleA.data = "modified data"
}
}
参考文献