はじめに
(この記事はProgaku Advent Calendar 2023 17日目の記事です。)
こんにちは!
普段は東京都でバックエンドエンジニアをしているおっちーと言います!
最近、業務でコードレビューをする機会が増えてきました。
そこで、今回はコードレビューの観点として含めると有用な「モジュール結合度」について、rubyによるサンプルコードとともに説明させて頂こうと思います!
モジュール結合度とは
モジュール結合度とは、モジュール間の関連性の強さを示すもので、一般的にソフトウェアの設計において、モジュール間の結合度が可能な限り低いほうが良いとされています。
結合度は、次の7つの種類があります。上から順番に結合度が強いです。
実務でよく見かけるのは、内容結合と共通結合以外の結合かなと思います。
また、目指すべき結合の状態は、データ結合やメッセージ結合です。
(場合によっては外部結合・制御結合・スタンプ結合は許容されることがありますが、その場合でもその形になっている箇所を局所化するべきです。)
ちなみに、ここでいうモジュールは、一般に独立した機能単位のことを指します。
具体的に言うと、クラス、rubyにおけるmodule、メソッド、またはそれらの集まりなど、独立して機能するひとまとまりのコードのことを指します。
内容結合
概要
内容結合は、以下のような状態になっている場合を指します。
- モジュールが他のモジュールの内部動作に直接影響を与えている状態
- モジュールが他のモジュールの内部の状態を直接参照している状態
2パターンあるので、以下のセクションでそれぞれをサンプルコードで示します。
サンプルコード①
「モジュールが他のモジュールの内部動作に直接影響を受けるような状態」のサンプルコードは、以下のような感じになります。
下記コードの説明をすると、UserクラスはTweetクラスのインスタンスのset_content
メソッドの振る舞いを動的に変更しています。
これは、「モジュール(Tweetクラス)が他のモジュール(Userクラス)の内部動作に直接影響を受けるような状態」となっています。
class Tweet
def initialize
@content = 'Initial content'
end
def set_content
@content = 'Updated by Tweet'
end
end
class User
def self.modify_tweet_behavior(tweet)
# シングルトンメソッドを用いて、set_contentをオーバーライドする
def tweet.set_content
@content = 'Updated by User'
end
end
end
tweet = Tweet.new
tweet.instance_variable_get(:@content) # => "Initial content"
User.modify_tweet_behavior(tweet)
tweet.set_content
puts tweet.instance_variable_get(:@content) # => "Updated by User"
改善されたサンプルコード①
改善するには、UserクラスがTweetクラスの内部実装(具体的にはメソッドの実装)に直接干渉するのをやめると良いでしょう。
以下の改善案では、Tweet内で@content
を書き換えるようにしています。
class Tweet
attr_writer :content
def initialize
@content = 'Initial content'
end
end
tweet = Tweet.new
tweet.instance_variable_get(:@content) # => "Initial content"
tweet.content = 'Updated content'
puts tweet.instance_variable_get(:@content) # => "Updated content"
サンプルコード②
次に、「他のモジュールの内部の状態を直接参照している」の例をrubyのコードで表すと、下記のような感じになるかと思います。
この例では、Userクラスのmodify_tweet_content
メソッドが、Tweetクラスのインスタンスの内部インスタンス変数@content
に直接アクセスしてしまっています。
UserクラスがTweetクラスの内部実装に依存しているため、Tweetクラスの内部実装が変更されるとUserクラスにも影響を与える可能性があります。
class Tweet
def initialize(content = nil)
@content = content
end
end
class User
def self.modify_tweet_content(tweet:, content:)
tweet.instance_variable_set(:@content, content)
end
end
User.modify_tweet_content(tweet: Tweet.new, content: 'Hello, Ruby!')
puts Tweet.content # => "Hello, Ruby!"
改善されたサンプルコード②
改善すると、下記のようなコードになるかなと思います。
modify_tweet_content
メソッド内で@content
の内容を直接書き換えるのをやめています。
class Tweet
attr_accessor :content
def initialize(content = nil)
@content = content
end
end
class User
def self.modify_tweet_content(tweet, new_content)
tweet.content = new_content
end
end
tweet = Tweet.new
User.modify_tweet_content(tweet, 'Hello, World!')
puts tweet.content # => "Hello, Ruby!"
共通結合
概要
共通結合(Common Coupling)とは、複数のモジュールが同じグローバルデータを共有することを指します。
サンプルコード
これはサンプルコードで表すとわかりやすいかと思います。
ModuleAとModuleBが、グローバル変数$global_data
を共有してしまっています。
# グローバル変数の定義
$global_data = '共通データ'
module ModuleA
def self.show_data
puts $global_data
end
end
module ModuleB
def self.modify_data(new_data)
$global_data = new_data
end
end
ModuleA.show_data # => "共通データ"
ModuleB.modify_data('新しいデータ')
ModuleA.show_data # => "新しいデータ"
改善されたサンプルコード
そもそも上記のコードでは、グローバル変数を使う必要がないので、グローバル変数を使うのをやめましょう。
あえて改善するとしたら以下のようなコードになるかと思います。
# データを管理するStruct
CommonDataManager = Struct.new(:data) do
def show_data
puts data
end
def modify_data(new_data)
self.data = new_data
end
end
data_manager = CommonDataManager.new('共通データ')
module ModuleA
def self.show_data(data_manager)
data_manager.show_data
end
end
module ModuleB
def self.modify_data(data_manager, new_data)
data_manager.modify_data(new_data)
end
end
# 使用例
ModuleA.show_data(data_manager) # => "共通データ"
ModuleB.modify_data(data_manager, '新しいデータ')
ModuleA.show_data(data_manager) # => "新しいデータ"
外部結合
概要
外部結合は、プログラムの複数の部分が直接、外部で定義されたデータ形式や通信プロトコルに依存している状態を指します。
サンプルコード
外部結合になっているコード例は、下記のようになるかと思います。
WeatherServiceとWeatherAlertServiceのどちらからも、そのクラス内の独立した実装によって、
http://api.openweathermap.org/data/2.5/weather
へGETリクエストしてしまっていますね。
require 'net/http'
require 'json'
class WeatherService
API_URL = 'http://api.openweathermap.org/data/2.5/weather'
def get_weather_data(city)
uri = URI("#{API_URL}?q=#{city}&appid=YOUR_API_KEY")
response = Net::HTTP.get(uri)
process_weather_data(JSON.parse(response))
end
private
def process_weather_data(data)
# 天気データの処理
end
end
class WeatherAlertService
API_URL = 'http://api.openweathermap.org/data/2.5/weather'
def check_weather_alert(city)
uri = URI("#{API_URL}?q=#{city}&appid=YOUR_API_KEY")
response = Net::HTTP.get(uri)
process_alert_data(JSON.parse(response))
end
private
def process_alert_data(data)
# 警報データの処理
end
end
改善されたサンプルコード
改善するとしたら、下記のようなコードになるかと思います。
WeatherAPIAdapterがAPIの詳細(URL、APIキー、レスポンス形式など)をカプセル化しています。
これにより、プログラムの複数の部分が直接、外部で定義されたデータ形式や通信プロトコルに依存している状態が解消されています。
module WeatherAPIAdapter
API_URL = 'http://api.openweathermap.org/data/2.5/weather'
def self.get_weather_data(city)
uri = URI("#{API_URL}?q=#{city}&appid=YOUR_API_KEY")
response = Net::HTTP.get(uri)
JSON.parse(response)
end
end
class WeatherService
def get_weather_data(city)
data = WeatherAPIAdapter.get_weather_data(city)
process_weather_data(data)
end
private
def process_weather_data(data)
# 天気データの処理が入る
# 省略
end
end
class WeatherAlertService
def check_weather_alert(city)
data = WeatherAPIAdapter.get_weather_data(city)
process_alert_data(data)
end
private
def process_alert_data(data)
# 警報データの処理が入る
# 省略
end
end
制御結合
概要
他のモジュールにその処理内容を制御するためのデータ(フラグなど)などを渡して内部の処理を制御する関係にある場合を指します。
サンプルコード
下記の例では、ReportGeneratorクラスのgenerate
メソッドが引数report_type
に基づいて異なる種類のレポートを生成します。
このメソッドは、処理内容を制御するためのデータ(report_type)によって制御され(条件分岐して)動作が変わるため、制御結合の一例となっています。
class ReportGenerator
def generate(report_type)
case report_type
when :html
generate_html_report
when :pdf
generate_pdf_report
else
raise ArgumentError, 'Unsupported report type'
end
end
private
def generate_html_report
# HTMLレポートの生成ロジック
puts 'HTML Report generated.'
end
def generate_pdf_report
# PDFレポートの生成ロジック
puts 'PDF Report generated.'
end
end
# 使用例
report_generator = ReportGenerator.new
report_generator.generate(:html) # => "HTML Report generated."
report_generator.generate(:pdf) # => "PDF Report generated."
改善されたサンプルコード
下記が改善されたコードです。
ReportGeneratorが具体的なレポートクラスのタイプを知る必要がなくなり、代わりに外部から渡されたクラスに基づいてレポートを生成するようになっています。
これによって、レポートの種類によって条件分岐されることがなくなったため、制御結合が解消されました。
加えて、新しいレポートタイプ(ex. ExcelReport
)を追加する場合にも、ReportGenerator
モジュールを変更する必要がなくなります。このように、コードの再利用性と拡張性が向上し、メンテナンスが容易になります。
# レポート生成のための共通インターフェース
class Report
def generate
raise NotImplementedError, "Subclasses must implement this method"
end
end
# HTMLレポート生成クラス
class HtmlReport < Report
def generate
puts 'HTML Report generated.'
end
end
# PDFレポート生成クラス
class PdfReport < Report
def generate
puts 'PDF Report generated.'
end
end
# レポートジェネレーター
module ReportGenerator
def self.generate(report_class)
report_class.new.generate
end
end
ReportGenerator.generate(HtmlReport) # => "HTML Report generated."
ReportGenerator.generate(PdfReport) # => "PDF Report generated."
スタンプ結合
概要
モジュール間で複数のデータを連結した複合的なデータ構造を受け渡すが、そのすべてを使用するわけではない状況を指します。
これの何が問題なのかと言うと、本来知る必要のないデータの内部構造を知っている必要が出てくることです。
サンプルコード
コード例を示すと、下記のようになります。
favorited_by?
メソッドの引数はuserですが、実際の処理の中で使用しているのはuserのidのみになっています。
概要のセクションに書いた内容そのままの状態になっていますね。
class Tweet
def favorited_by?(user)
favorites.where(user_id: user.id).exists?
end
end
改善されたサンプルコード
改善すると、以下のコードのようになるかと思います。
user_idを引数に渡すようにすることで、favorited_by?
メソッドはuserの内部構造を知る必要がなくなりました。
(ちなみに、後述するデータ結合の形になっています。)
class Tweet
def favorited_by?(target_user_id)
favorites.where(user_id: target_user_id).exists?
end
end
データ結合
概要
モジュールが単純なデータ(例えば数値、文字列、ブール値など)を引数として受け取り、これを別のモジュールに渡す際に発生する結合を指します。
こちらは、理想的な結合の一つとして考えられています。
サンプルコード
サンプルコードを示すとしたら、以下のような感じになるかなと思います。
def print_message(message)
puts "Message: #{message}"
end
def greet_user(name)
greeting = "Hello, #{name}!"
print_message(greeting)
end
# ユーザーに挨拶するためにメソッドを呼び出す
greet_user('Alice')
メッセージ結合
概要
最も結合度が低い結合の種類で、引数のないメソッドの呼び出しのことを指します。
メソッドの実行でしか結合しないです。
サンプルコード
サンプルコードで表すとしたら、以下のような感じになるかと思います。
def main
print_hello
end
def print_hello
puts 'Hello, World!'
end
main # => "Hello, World!"
最後に
本記事では、モジュール結合度についてrubyのサンプルコードとともに解説させていただきました。
各結合度とその説明を簡単にまとめると以下の表になります。
結合の種類 | 説明 | 対応 |
---|---|---|
内容結合 | 他のモジュールの内部にアクセスする | 避けるべき |
共通結合 | グローバルデータを共有する | 避けるべき |
外部結合 | 外部システムやデバイスに依存する | 許容される場合もある |
制御結合 | 他のモジュールの制御フローに影響を与える | 許容される場合もある |
スタンプ結合 | データ構造を共有する | 許容される場合もある |
データ結合 | 単純なデータをやり取りする | 理想的 |
メッセージ結合 | メッセージを介して通信する | 理想的 |