11
7

モジュール結合度をrubyで理解する

Last updated at Posted at 2024-01-03

はじめに

(この記事は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のサンプルコードとともに解説させていただきました。
各結合度とその説明を簡単にまとめると以下の表になります。

結合の種類 説明 対応
内容結合 他のモジュールの内部にアクセスする 避けるべき
共通結合 グローバルデータを共有する 避けるべき
外部結合 外部システムやデバイスに依存する 許容される場合もある
制御結合 他のモジュールの制御フローに影響を与える 許容される場合もある
スタンプ結合 データ構造を共有する 許容される場合もある
データ結合 単純なデータをやり取りする 理想的
メッセージ結合 メッセージを介して通信する 理想的

記事の内容の正確性について

各結合度に対する認識や具体例はさまざまな例が存在するため、今回紹介した内容が必ず正しいという訳ではないことをご了承ください。
なのでフィードバックをお待ちしています!

参考文献

11
7
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
7