🦓

[Rails]デメテルの法則

2023/09/05に公開

はじめに

デメテルの法則とRailsでの応用についてまとめてみました。

tl;dr

メテルの法則について
Railsに適用するため
悪い例
いい例
まとめ

デメテルの法則(Law of Demeter)とは?

デメテルの法則は、オブジェクト指向プログラミングの設計原則の一つで、以下は基本原則になります。

メソッド内で直接参照できるのは、以下のいずれかのオブジェクトだけであるべきである。

  1. メソッド自体のオブジェクト
  2. メソッドの引数で渡されたオブジェクト
  3. メソッド内で新しく生成されたオブジェクト

デメテルの法則の主な目的は、オブジェクト間の結合度を低くし、コードの保守性を高めることです。

https://ja.wikipedia.org/wiki/デメテルの法則

例を使ってもっと分かりやすく説明します。
ユーザーモデルとチームモデルがあるとします。
ユーザーがチームにエントリーすることができます。
ユーザーが自分のエントリーしたチームを取得するとします。

悪い例:

app/views/teams/index.html.erb
<% if user_signed_in? %>
  <% if current_user.team&.entry %>
    <%= link_to 'エントリー一覧', current_user.team.entry %>
  <% eles %>
    <% ... %>
  <% end %>
<% end %>

この例では、current_userオブジェクトがteamオブジェクト、さらにその中のentryオブジェクトにアクセスしています。

これに対して、デメテルの法則を適用すると、current_userは直接の関連オブジェクト(自身の属性や引数で渡されたオブジェクト)にのみアクセスすべきであり、中間のオブジェクトにアクセスすべきでないとされます。

もっと分かりやすく言うと、ドット(.)を一つにしましょう。

デメテルの法則をRailsで応用する方法

1. メソッドチェーンの制限

遠くのオブジェクトに連続してアクセスする代わりに、できるだけ近くのオブジェクトにアクセスするようにします。

app/models/user.rb
class User < ApplicationRecord
...

  def team_entry
    team&.entry
  end
end

ビューでは、できるだけ少ない変数やヘルパーメソッドにアクセスし、直接モデルに依存しないようにします。

先のビューをリファクタリングしますと:

良い例:

app/views/teams/index.html.erb
<% if user_signed_in? %>
  <% if current_user.team_entry %>
    <%= link_to 'エントリー一覧', current_user.team_entry %>
  <% eles %>
    <% ... %>
  <% end %>
<% end %>

そうすると、ビューから直接チームのエントリーにアクセスせず、モデル経由でアクセスするようになります。
変更や修正もメソッドの一箇所で行うことができ、コードの保守性が向上します。


二つまでのメソッドチェーンだと、デメテルの法則のメリットを感じないかもしれないですが、もう少しリアルの例も見てみます。

悪い例:

# デメテルの法則に適合しない例
class Order
  def initialize(customer)
    @customer = customer
  end

  def total_price
    # メソッドチェーンが長いし、Customerクラスの内部に依存している
    @customer.shopping_cart.items.sum(&:price)
  end
end

良い例:

# デメテルの法則に適合する例
class Order
  def initialize(customer)
    @customer = customer
  end

  def total_price
    # Customerクラスの内部構造に依存せず、直接必要なデータにアクセス
    @customer.total_shopping_cart_price
  end
end

class Customer
  def initialize(shopping_cart)
    @shopping_cart = shopping_cart
  end

  def total_shopping_cart_price
    @shopping_cart.total_price
  end
end

class ShoppingCart
  def initialize(items)
    @items = items
  end

  def total_price
    @items.sum(&:price)
  end
end

この例では、OrderクラスがCustomerクラスの内部構造に直接依存せず、代わりにCustomerクラスがtotal_shopping_cart_priceメソッドを提供しています。
これにより、OrderクラスはCustomerクラスの内部構造に対する依存を最小限に抑えることができます。

2. デリゲーション

デリゲーション(Delegation)は、ソフトウェアデザインパターンの一つで、オブジェクト指向プログラミングにおいて、一つのオブジェクトが別のオブジェクトに特定のタスクや責任を委譲(引き継ぎ)することによって実現されます。

中間オブジェクトに対するデリゲーションメソッドを使用します。
ActiveSupportのdelegateメソッドを使用することで、簡単にデリゲーションを実装できます。

先に定義したmteam_entrydelegateで書き換えてみます:

app/models/user.rb
class User < ApplicationRecord
...

  delegate :entry, to: :team
  # prefix
  delegate :entry, to: :team, prefix: :team
  # allow_nil
  delegate :entry, to: :team, prefix: :team, allow_nil
  
  # def team_entry
  #   team&.entry
  # end
end

teamオブジェクトに対してentryメソッドをデリゲートします。つまり、オブジェクト内でentryメソッドが呼び出された場合、実際にはteamオブジェクトのentryメソッドが呼び出されます。

デリゲーションを使うことで、ビューでcurrent_user.entryでエントリしたチームにアクセスできます。

一つのデメリットとして、ユーザーとteamの関係性を分かりにくくなります。
prefixオプションを指定すると解決されます。
prefix: :teamをつけるとデリゲーションされたメソッドの名前はteam_entryとなります。

https://www.rubydoc.info/docs/rails/Module:delegate

nilをレスキューする

チームのないユーザーの場合、entryメソッドにアクセスしようとするとNoMethodErrorが発生します。さらに、DelegationErrorも表示されます。

irb(main):001:0: User#team_entry delegated to team.entry, but team is nil: #<User id: 1, email: "bob1@team2.com", first_name: "Bob_1", last_name: "Ross 1", announcements_last_read_at: nil, admin: fal se, created_at: "2023-07-17 01:03:25.162383000 +0000", updated_at: "2023-07-17 01:03:25.162383000 +0000"> (Module :: DelegationError) :entry; raise DelegationError, "User#te
def team_entry(...); = team; _.entry(...); rescue NoMethodError e; if _.nil? && e.name am_entry delegated to team.entry, but team is nil: #self.inspect}"; else; raise; end; end 
irb(main):002:0:`team_entry': undefined method `entry' for nil:NilClass (NoMethodError)

def team_entry(...); = team; _.entry(...); rescue NoMethodError ⇒ e; if _.nil? && e.name = :entry; raise DelegationError, "User#te am_entry delegated to team.entry, but team is nil: #self.inspect}"; else; raise; end; end

If the target is nil and does not respond to the delegated method a Module::DelegationError is raised. If you wish to instead return nil, use the :allow_nil option.

delegateメソッドでは、:allow_nilオプションを使用して、デリゲート先のオブジェクトがnilである場合でもエラーを発生させずにデリゲートを設定できます。

&.演算子と同じ効果ですね。

3. Forwardableの導入

Forwardableは、Rubyの標準ライブラリで提供されるモジュールで、オブジェクトに対して他のオブジェクトのメソッドを委譲(デリゲート)するための手段を提供します。
これを使用すると、コードの再利用性を向上させ、クラスやモジュールの結合度を低く保つことができます。
https://docs.ruby-lang.org/ja/latest/class/Forwardable.html

あるオブジェクトが他のオブジェクトの一部の機能を必要とする場合、デメテルの法則に従って直接他のオブジェクトに依存するのではなく、Forwardableを使用して必要なメソッドを委譲できます。

例を使って見ていきましょう。

悪い例:

require 'forwardable'

class User
  extend Forwardable

  def initialize(name)
    @name = name
  end

  def_delegator :@name, :upcase, :uppercase_name
  def_delegator :@name, :reverse, :reversed_name
end

user = User.new("John")

# デリゲートされたメソッドを使用
puts user.uppercase_name  # "JOHN"
puts user.reversed_name   # "nhoJ"

Userクラスが内部の@nameオブジェクトのメソッドを直接デリゲートしていますが、メソッドが追加されたり変更されたりすると、Userクラス内のコードも変更が必要になります。この場合、Forwardableを使用する利点がほとんどありません。

良い例:

require 'forwardable'

class UserName
  def initialize(name)
    @name = name
  end

  def upcase
    @name.upcase
  end

  def reverse
    @name.reverse
  end
end

class User
  extend Forwardable

  def initialize(name)
    @name = UserName.new(name)
  end

  def_delegator :@name, :upcase, :uppercase_name
  def_delegator :@name, :reverse, :reversed_name
end

user = User.new("John")

# デリゲートされたメソッドを使用
puts user.uppercase_name  # "JOHN"
puts user.reversed_name   # "nhoJ"

UserNameクラスを導入し、ユーザーに関する情報の一部をUserNameクラスとしてカプセル化します。
Forwardableを使用してUserNameオブジェクトのメソッドをデリゲートしています。
これにより、UserNameクラスの変更がUserクラスに影響を与えなくなり、コードがより保守的で柔軟になります。

悪い例では内部の実装詳細を直接公開してしまっていますが、良い例では内部の実装詳細を隠し、デリゲートを通じて外部からアクセスすることで、オブジェクト間の結合度が低くなります。

https://docs.ruby-lang.org/ja/latest/method/Forwardable/i/def_delegators.html

デメテルの法則を維持するためには、デリゲートされたメソッドを過度に多く使用しないように注意する必要があります。

まとめ

  1. 直接の依存関係を最小限にする: クラスやメソッドは、他のクラスやモジュールとの直接の依存関係を最小限に抑えるべきです。これにより、変更が他の部分に波及しにくくなります。

  2. メソッドチェーンを避ける: 長いメソッドチェーンを使用すると、中間のオブジェクトに依存することになり、デメテルの法則に違反する可能性が高まります。代わりに、メソッド呼び出しをdelegateforwardableを通して行い、中間オブジェクトへの依存を減らすようにします。

  3. オブジェクト内部のデータや状態を外部からの直接アクセスを制限する: クラス内のデータに直接アクセスせず、データとそれに関連する操作(メソッド)を一つの単位としてカプセル化します。これにより、内部のデータとそのメソッドの一貫性を保ちやすくなります。

終わりに

少しデメテルの法則を理解できました。
メソッドチェーンを何も思わずよく書いてますがこれから意識していきたいと思います。

Discussion