はじめに
Railsのポリモーフィック(polymorphic、多態性)関連について、実装方法を説明している記事は見かけるのですが、実際これがどんなものでどんな時に便利なのかを説明している記事があまりなく、よくわからないで使っている人もいるようなので本記事を書きました。
実装方法そのものについては詳細には説明しませんので、Railsのポリモーフィック関連をどう書くのかを知らない方はまずは公式ドキュメントを読んでいただけると理解が早いかと思います。
ボリュームは少ないので、2分もかからないかと思います。
Rails Guides: 2.9 ポリモーフィック関連付け
"どんなものか"について、先に結論
ポリモーフィックとはダックタイピングの一種であり、
別の言い方をするとGoFのデザインパターンで言うところの「プログラムは実装に対してではなく、インターフェースに対して行う(Program to an 'interface', not an 'implementation'.)」を実現する方法の一つになります。
この方針はポリモーフィックだけで無くSTI(Single Table Inheritance: 単一テーブル継承)などでも同じです。
......と言われて何のことかわかる人はこの記事を読む必要はないかもしれません。
わからない人は安心してください。これから説明していきます!
ダックタイピング/インターフェースとは
ポリモーフィックの話をする前に、まずはダックタイピング/インターフェースについて解説していきます。
まずは、以下のサンプルコードをご覧ください。
class Animal
def initialize(animal)
@animal = animal
end
def bark
@animal.sound
end
end
# 「この場合だとAnimalを継承する方が正しいのでは?」とかツッコミどころはあるかとは思いますが、
# そこはスルーでお願いします......
class Duck
def sound
'quack'
end
end
class Cat
def sound
'myaa'
end
end
# 使い方
Animal.new(Duck.new).bark #=> "quack"
Animal.new(Cat.new).bark #=> "myaa"
ダックタイピングの語源は以下の言葉です。
"If it walks like a duck and quacks like a duck, it must be a duck"
「もしもそれがアヒルのように歩き、アヒルのように鳴くのなら、それはアヒルである」
これを上記サンプルコードに則して表現すると以下のように言えるでしょう。
「もしもそれが動物(
Animal
)のように鳴く(#sound
メソッドを持っている)ならば、それは動物である。」
このように、「違うものがある決まった振る舞い/入出力を持つことで、同じように扱えるようにすること」をダックタイピングと呼びます。
そして、その「ある決まった振る舞い」「入出力の定義」のことをインターフェースと呼びます。
コード例に沿って表現すると、Duck
とCat
は違うクラスですが両方とも#sound
というメソッド(インターフェース)を持つので、Animal
の中ではどちらのオブジェクトであるかを意識せず#sound
を呼ぶことができる、というのがダックタイピングです。
ダックタイピングのメリット
さて、では上記のコードは何が便利なのでしょうか?
ここで注目して欲しいのは、上記コードは以下のようになっていることです。
-
Animal
クラスの@animal
に入るオブジェクトは、#sound
を呼べるオブジェクトである - 「
#sound
が存在すること」、「#sound
の返り値の形式が同じこと」が守られていれば、引数となるクラスは#bark
メソッドの内部実装を知る必要がないし、手を加える必要はない
これらを守らないNG例を書いてみました。
# NG例
class Animal
def initialize(animal)
@animal = animal
end
def bark
# @animalの実態ごとにメソッドの呼び方を変えなければならない。
# そのため、条件分岐が@animalに入りうるクラスの数だけ増える。
case @animal
when Duck
@animal.sound
when Cat
# @animal側の実装を理解して、返り値をちゃんと受け取るようにしなければいけない
@animal.cry[:sound]
end
end
def run
# メソッドの数だけ条件分岐を書かなければならないので、複雑かつ冗長になる
case @animal
when Duck
@animal.walk
when Cat
@animal.run
end
end
end
class Duck
def sound
'quack'
end
def walk
'walk'
end
end
class Cat
# Duck#soundとインターフェース(メソッド名、返り値)が違う
def cry
# 返り値の形式がDuckと違う
{ sound: 'myaa' }
end
def run
'run'
end
end
コード内にコメントで書いたように、色々問題が出てきます。
先ほどのルールが守られていれば、例えば新しいクラスを追加しなければならない場合にも「既存コードはいじらず」「新しく追加するコード側のインターフェースのみ気を付ければ良い」ようになるので、複雑度を上げないで済みます。
# 例えばこんなクラスを追加する
class Dog
# インターフェースを踏襲(ダックタイピング)することで、既存コードはいじらずに機能拡張を行える。
# インターフェースが違う場合はAnimal側も修正が必要になる。
def sound
'bow-wow'
end
end
# `Duck`, `Cat`と同じように扱える
Animal.new(Dog.new).bark #=> "bow-wow"
# 既存コードはいじっていないので、当然これまでの実装が壊れることはない
Animal.new(Duck.new).bark #=> "quack"
Animal.new(Cat.new).bark #=> "myaa"
このように、インタフェースを統一することで冗長なコードがなくなり、スケーラビリティを担保できることがダックタイピングのメリットです。
ポリモーフィックの説明、その前に
さて、ようやくポリモーフィックの説明です。
まず、これもサンプルコードがあった方が解説しやすいので、以下のようなアプリがあったとして説明をさせていただきます。
サンプルアプリ要件
- あるモデル間でメッセージを送り合える
- LINEとかそういうものだと考えてください
- (とりあえず)メッセージ送信者となるモデルには
Employer
,Employee
の2つがある- 本来ならばこれらをまとめる
User
モデルがいるべきかもしれませんが、今回のアプリでなかったとします
- 本来ならばこれらをまとめる
メッセージモデル(テーブル)の定義
さて、上記のようなメッセージアプリを作る時、メッセージのモデル(テーブル)はどのように作ればいいでしょうか?
もしポリモーフィックを使わずにやるならば、以下のようにEmployerMessage
モデル(employer_messages
テーブル)とEmployeeMessage
モデル(employee_messages
テーブル)を作り、thread_idのような両テーブルに共通するカラムを用意してJOINする、などという方法が考えられます。
class Employer < ApplicationRecord
has_many :employer_messages
end
class EmployerMessage < ApplicationRecord
belongs_to :employer
end
class Employee < ApplicationRecord
has_many :employee_messages
end
class EmployeeMessage < ApplicationRecord
belongs_to :employee
end
要件によってはこれもありなのかもしれません
ただ、EmployerMessage
とEmployeeMessage
が同じインターフェースを持つのならば、この方法では同じコードを繰り返し書くことになるのであまり好ましくありません。
また、メッセージ送信者モデルが増えれば増えるほどコードの重複も増え、複雑度もそれに応じて高くなることが予想されます。
ですので、メッセージモデルは一つにまとめ、それを各メッセージ送信者モデルで共有したくなります。
その場合でポリモーフィックを使わないならば、コードは以下のようになるかと思います。
class Message < ApplicationRecord
belongs_to :employer
belongs_to :employee
# メッセージを送るモデルが増えたら、それに従いbelongs_toの数も増えていく
end
class Employer < ApplicationRecord
has_many :messages
end
class Employee < ApplicationRecord
has_many :messages
end
messages
テーブルにはemployer_id
カラムとemployee_id
カラムがあり、それを使って各モデルとJOINすることになります。
実際のコード内での呼び出し方は以下のようになるでしょう。
employer = Employer.find(params[:employer_id])
employer.messages # select * from messages where employer_id = ?
message = Message.find(params[:message_id])
message.employee # select * from employees where employee_id = ?
ただ、この書き方は上記ダックタイピングNG例と同じような問題をはらんでおり、message.employer
とmessage.employee
のどちらが呼べるのかは都度チェックする処理を挟まないとメッセージ送信者モデルを取得できません。
またさらにデータベースが絡むことにより「メッセージ送信者モデルを増やす際にはxx_id
カラムを増やすためにmessages
に都度ALTER TABLE
を行う必要がある」といった問題もプラスされています。
こういった時にダックタイピングを使って簡潔に書けるようにするもの、それがポリモーフィックです
ポリモーフィックの使いどころ/便利なところ
上記のまとめになりますが、Railsのポリモーフィックが便利なのは「一つのモデルを同じインターフェースを持ったものが扱う(ダックタイピングする)」場合になります。
実際にポリモーフィックを利用したコードは以下のようになります。
# 注:以下のcompany_name, last_name, first_nameは各テーブルに定義されたカラムだと思ってください。
class Message < ApplicationRecord
belongs_to :messageable, polymorphic: true
end
class Employer < ApplicationRecord
has_many :messages, as: :messageable
def sender_name
company_name
end
end
class Employee < ApplicationRecord
has_many :messages, as: :messageable
def sender_name
"#{last_name} #{first_name}"
end
end
このコードは以下のように使うことができます。
# messageableを通すことで、EmployerとEmployeeのどちらであるかを意識する必要がない。
# また、messageableなモデルのインターフェースとして必ず`#sender_name`が定義されているので、メソッドが定義されているかどうかのチェックも必要ない。
Message.find(params[:id]).messageable.sender_name
# メッセージ送信者モデル側からは通常のhas_many関連として扱える
Employer.find(params[:id]).messages
Employee.find(params[:id]).messages
ダックタイピングを使うことによって、メッセージ送信者モデル(messageable
)の実際のクラスを意識せずに#sender_name
にアクセスすることができました。
また、messageable
なモデルを追加する時にも以下のように追加モデルを書くだけでよく、既存コードをいじる必要はありません。
class Guest < ApplicationRecord
has_many :messages, as: :messageable
def sender_name
"ゲスト"
end
end
これこそが、ポリモーフィックの便利なところです
よくあるポリモーフィックの書き方
以上でポリモーフィックの概念レベルの説明は終わりとなりますが、ついでによくあるポリモーフィックの書き方も書いておきます。
class Message < ApplicationRecord
belongs_to :messageable, polymorphic: true
end
# インターフェースを明確化するために、moduleで固める
module Messageable
extend ActiveSupport::Concern
included do
has_many :messages, as: :messageable
end
def sender_name
# オーバーライドされなかった場合はエラーが上がるようにしておく
raise NotImplementedError
end
def sender_email
raise NotImplementedError
end
end
class Employer < ApplicationRecord
# Messageを使うポリモーフィックなモデルはMessageableをincludeする
include Messageable
# moduleで定義されているメソッドをオーバーライドする
def sender_name
company_name
end
def sender_email
"admin@example.com"
end
end
class Employee < ApplicationRecord
include Messageable
def sender_name
"#{last_name} #{first_name}"
end
def sender_email
email
end
end
間違ったポリモーフィック
ダックタイピングNG例とかぶりますが、これまでを踏まえて「こういうポリモーフィックは間違っている」ということを説明します。
例えば、以下のようなコードがあるとします。
messageable = Message.find(params[:id]).messageable
# インターフェースが統一されていないと、クラスで条件分岐する必要がある。
case messageable
when Employer
messageable.employer_email
when Employee
messageable.employee_email
end
# もしくは、respond_to?でメソッドが定義されているかで分岐する
if messageable.respond_to?(:employer_email)
messageable.employer_email
elsif messageable.respond_to?(:employee_email)
messageable.employee_email
end
このようにmessageable
の実態・インターフェースを逐一調べるようになってしまうとポリモーフィックの意味がないどころか、抽象化レイヤが入る分複雑度が増し、非常に読みづらくテストが書きづらくバグりやすいコードになってしまいます。
ポリモーフィックを使う場合は必ずインターフェースを統一し、すべてのクラスを同じように扱えるようにしなければなりません。
まとめ
- インターフェースとは振る舞い・入出力の定義
- ダックタイピングとは、複数のクラスのインターフェースを統一し、同じように扱えるようにすること
- コードを簡潔に、拡張性を保って書くことができる
- ポリモーフィックとはダックタイピングの一種
雑談
以下、とりとめのない話です
個人的にはポリモーフィック自体は非常に便利だけどもRubyのシンタックス的に厳しいものがあるなー、と思っています。
理由は2つ
- あるインターフェースを実装することを強制できない
- 必須メソッドが実装されるかは実装者次第
- Javaで言うところのabstract/interfaceが欲しい
- オーバーライドした時に、どのメソッドがオーバーライドしたものなのかわかりにくい
2の観点に関してもう少し説明すると、まず以下のような感じにMessageableなmoduleがあったとして、
module Messageable
extend ActiveSupport::Concern
included do
has_many :messages, as: :messageable
end
def sender_name
raise NotImplementedError
end
end
個人的にはmodule
のメソッドのオーバーライドを明示的にするため、include
する際にブロックを使ってその中でオーバーライドさせたいのですが、残念ながらできません。(シンタックスエラーにはならないですが、オーバーライドはされません)
class Employer < ApplicationRecord
# includeにブロックを書いて、その中でオーバーライドしたい
include Messageable do
def sender_name
"#{last_name}様"
end
end
end
なので、以下のようにinclude
とオーバーライドは分ける必要があるのですが、コード量が多くなってくるとどれがオーバーライド用のメソッドでどれがそれ以外のメソッドなのかわかりにくくなるので、何か一工夫が欲しくなります。
class Employer < ApplicationRecord
include Messageable
# このメソッドはEmployerに普通に定義されたメソッドなのか、それともMessageableのオーバーライドなのか見た目ではわからない
def sender_name
"#{last_name}様"
end
end
メソッド群に名前を与えたいだけならば妥協案としては以下のようにオーバーライド用メソッドをmoduleに包んで即includeという手もあるのですが、冗長さは否めません
class Employer < ApplicationRecord
include Messageable
# moduleで包んで
module OverrideMessageable
def sender_name
"#{last_name}様"
end
end
# 即include
include OverrideMessageable
end
トリッキーな手としてはModule.new
を使う方法もあるのですが、読みにくいしmoduleの中でincludedが使えなくなるしでやはり微妙です
class Employer < ApplicationRecord
# do..endで書くとModule.newの引数でなくincludeの引数となるっぽくてオーバーライドされない
include Module.new {
include Messageable
# Messageableの中に`included do; has_many :foo; end`があると、
# Module.newの中で`has_many :foo`を実行して`has_many`を呼べずに死ぬ
def sender_name
"#{last_name}様"
end
}
end
このようなRubyのシンタックス上の限界からRailsのポリモーフィック関連は実装者の実力に依存しがちな構文に思えるので、利用する際には細心の注意を払う必要があるでしょう。