[Rails]デメテルの法則
はじめに
デメテルの法則とRailsでの応用についてまとめてみました。
tl;dr
メテルの法則について
Railsに適用するため
悪い例
いい例
まとめ
デメテルの法則(Law of Demeter)とは?
デメテルの法則は、オブジェクト指向プログラミングの設計原則の一つで、以下は基本原則になります。
メソッド内で直接参照できるのは、以下のいずれかのオブジェクトだけであるべきである。
- メソッド自体のオブジェクト
- メソッドの引数で渡されたオブジェクト
- メソッド内で新しく生成されたオブジェクト
デメテルの法則の主な目的は、オブジェクト間の結合度を低くし、コードの保守性を高めることです。
例を使ってもっと分かりやすく説明します。
ユーザーモデルとチームモデルがあるとします。
ユーザーがチームにエントリーすることができます。
ユーザーが自分のエントリーしたチームを取得するとします。
悪い例:
<% 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. メソッドチェーンの制限
遠くのオブジェクトに連続してアクセスする代わりに、できるだけ近くのオブジェクトにアクセスするようにします。
class User < ApplicationRecord
...
def team_entry
team&.entry
end
end
ビューでは、できるだけ少ない変数やヘルパーメソッドにアクセスし、直接モデルに依存しないようにします。
先のビューをリファクタリングしますと:
良い例:
<% 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_entry
をdelegate
で書き換えてみます:
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
となります。
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 aModule::DelegationError
is raised. If you wish to instead return nil, use the:allow_nil
option.
delegate
メソッドでは、:allow_nil
オプションを使用して、デリゲート先のオブジェクトがnilである場合でもエラーを発生させずにデリゲートを設定できます。
&.
演算子と同じ効果ですね。
3. Forwardableの導入
Forwardable
は、Rubyの標準ライブラリで提供されるモジュールで、オブジェクトに対して他のオブジェクトのメソッドを委譲(デリゲート)するための手段を提供します。
これを使用すると、コードの再利用性を向上させ、クラスやモジュールの結合度を低く保つことができます。
あるオブジェクトが他のオブジェクトの一部の機能を必要とする場合、デメテルの法則に従って直接他のオブジェクトに依存するのではなく、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
クラスに影響を与えなくなり、コードがより保守的で柔軟になります。
悪い例では内部の実装詳細を直接公開してしまっていますが、良い例では内部の実装詳細を隠し、デリゲートを通じて外部からアクセスすることで、オブジェクト間の結合度が低くなります。
デメテルの法則を維持するためには、デリゲートされたメソッドを過度に多く使用しないように注意する必要があります。
まとめ
-
直接の依存関係を最小限にする: クラスやメソッドは、他のクラスやモジュールとの直接の依存関係を最小限に抑えるべきです。これにより、変更が他の部分に波及しにくくなります。
-
メソッドチェーンを避ける: 長いメソッドチェーンを使用すると、中間のオブジェクトに依存することになり、デメテルの法則に違反する可能性が高まります。代わりに、メソッド呼び出しを
delegate
やforwardable
を通して行い、中間オブジェクトへの依存を減らすようにします。 -
オブジェクト内部のデータや状態を外部からの直接アクセスを制限する: クラス内のデータに直接アクセスせず、データとそれに関連する操作(メソッド)を一つの単位としてカプセル化します。これにより、内部のデータとそのメソッドの一貫性を保ちやすくなります。
終わりに
少しデメテルの法則を理解できました。
メソッドチェーンを何も思わずよく書いてますがこれから意識していきたいと思います。
Discussion