Ruby Advent Calendar 2016 の7日目が未投稿だったので埋めちゃいます。
これは Ruby Advent Calendar 2016 の7日目の (代理) 投稿です。
「Railsのポリモーフィック関連とはなんなのか」という投稿の中で、
ポリモーフィック自体は非常に便利だけどもRubyのシンタックス的に厳しいものがある
との意見がありました。
個人的には Ruby 開発ではガンガンコーディングを気分良く進めていける一方で、その分ランタイムで落ちたり想定外のバグを出したりするのはもうある程度仕方ないものだと考えていて、だから気をつけてコード書かないとね、ちゃんとバグを拾えるテストコード書いとかないとね、と心得てやっていくのがいいと思っているのですが、それでせっかくの柔軟な機能の利用を自ら制限してしまったり、本来不要であるはずのドキュメンテーションを要求されたりすることもあるので、ぶっちゃけマジで実装でどうにか縛りたいんだけど!って感じるときも結構あります。
ということで、引用した投稿は Rails のポリモーフィック関連から派生したもので、かつ Rails のアドベントカレンダーへの投稿ではありますが、該当箇所は Rails に限らず Ruby におけるモジュールの mixin 全般に関連する話題だと思うので、その課題をどうにかうまく解決できないかを考えてみることにします。
モジュールのインターフェース設計上の課題
再度前述の投稿から引用させてもらいますが、Rails でポリモーフィック関連を利用する場合の課題として以下があげられています。
- あるインターフェースを実装することを強制できない
- 必須メソッドが実装されるかは実装者次第
- Javaで言うところのabstract/interfaceが欲しい
- オーバーライドした時にどのメソッドをオーバーライドしたのかわかりにくい
繰り返しますが、あくまでもこれは「Rails でポリモーフィック関連を利用する場合」の話なので、Ruby 全体に話を広げることは投稿者の意図とは異なるとは思います。しかし、おそらくRuby のモジュールのインターフェース設計として解決できたらこれも解決できることでしょう。
課題 1. あるインターフェースを実装することを強制できない
抽象クラスやインターフェースが存在しないという言語の特性上、Ruby ではそのままではこの「あるインターフェースを実装することを強制」することができません。
この課題に対する一般的なソリューションとして、
- オーバーライドを期待するメソッドが
NotImplementedError
をraise
するようにする
というものがあり、これは mixin 対象のモジュールのコード表現として「ああ、このメソッドはオーバーライドすべきなんだな」と一発でわかるし、実際に呼び出したら例外を吐くので有効ではあります。
ただ、結局はメソッドを呼び出してみないとわからない、という問題があり、残念ながら「あーこのインターフェースは必須なんだからもっとサクっと Fail Fast してほしいんだよ俺はああ」という要求を満たしてくれるものではありません。
課題 2. オーバーライドした時にどのメソッドをオーバーライドしたのかわかりにくい
これは、override
みたいなキーワードもなく、見た目上はフラットなメソッド定義になるため、どのメソッドがオーバーライドされたものでどのメソッドがそうでないのかがわかりにくい、というものです。
引用した記事中でいくつか対策もあげられていますが、冗長だったりトリッキーすぎたりと、なかなかうまい方法がないようです。
上記の課題を解決する方法を考えてみた
今短時間で考えたのでまだラフスケッチ程度ですが、「こういうやりかたはどーかな?」ってのを考えてみました。たぶんコードを見た方が早いのでいきなり貼り付けます。
module Abstraction
def include_abstraction(mod)
yield
include mod
end
def require_to_override(*methods)
class_variable_set(
"@@required_methods_by_#{self}",
methods.inject([]) { |acc, elem| acc << elem }
)
end
def required_methods
class_variable_get("@@required_methods_by_#{self}")
end
end
Module.include(Abstraction)
まずこの Abstraction
モジュールでやってることをざっくりいうと、
-
include
の薄いラッパーであるinclude_abstraction
メソッドを定義し、ブロックをとれるようにしている -
require_to_override
メソッドを定義し、mixin されるモジュール側で必須メソッドを指定できるようにしている- 引数で渡されたメソッド名はクラス変数
@@required_methods_by_モジュール名
に格納される
- 引数で渡されたメソッド名はクラス変数
require './abstraction'
module Notifiable
require_to_override :notify
def self.included(klass)
required_methods.each do |method|
raise NotImplementedError, "#{self} requires to implement #{method} on #{klass}" \
unless klass.instance_methods(false).include?(method)
end
end
end
これが mixin されるモジュールの定義です。
-
require_to_override
で mixin する側のクラスで実装してほしいメソッドを指定する -
included
のフックで必須メソッドの実装状況をチェックし、実装されていなければNotImplementedError
require './notifiable'
class Employee
include_abstraction Notifiable do
def notify
send_message
end
end
private
def send_message; end
end
そして、モジュールを mixin する側のクラスです。include_abstraction
にブロックを与えて、明示的にその中でモジュールに対応した実装したいメソッドの定義を書けるようになっています
もちろん、以下のように必須メソッドが実装されていなければエラーになります。
require './notifiable'
class Contact
# => NotImplementedError because "notify" is not defined
include_abstraction Notifiable do
def send_email; end
end
end
このやり方の利点は、
- メソッドが呼び出された時点ではなく、「クラスが読み込まれた時点で必須メソッドが実装されていないとエラーになることでメソッドの実装を強制する」インターフェース設計ができる
- mixin するモジュールと、そのモジュールに対して実装すべきメソッドをコード上で関連付けて定義できる
ことです。おっと、これはよい感じな気がする!
ただ、問題もあって、
- 各モジュールでそれぞれ
included
フックを定義しないといけない (めんどくさい。冗長) - オーバーライドと言っているが、実はぜんぜんオーバーライドではないので、
super
を活用するような設計にはできない -
include_abstraction
でメソッド定義を整理できるのはあくまでも見た目上だけ。別にブロック外に定義されていてもエラーにはならない - クラス変数…あまり積極的に使いたくないものですね…
などが微妙だな〜と感じます。なんかもうちょい改善できないものか…
Rails の ActiveSupport::Concern に応用
と、微妙なところもありますが、「読み込んだ時点で速攻エラーになってくれる」というのはなかなかよいので、Rails の ActiveSupport::Concern
にそのまま応用してみます。
コードだけを貼り付け。
module Abstraction
def include_abstraction(mod)
yield
include mod
end
def required_methods
class_variable_get("@@required_methods_by_#{self}")
end
def require_to_override(*methods)
class_variable_set(
"@@required_methods_by_#{self}",
methods.inject([]) { |acc, elem| acc << elem }
)
end
end
Module.include(Abstraction)
module Notifiable
extend ActiveSupport::Concern
require_to_override :notify
def self.included(klass)
required_methods.each do |method|
raise NotImplementedError, "#{self} requires to implement #{method} on #{klass}" \
unless klass.instance_methods(false).include?(method)
end
end
end
module Authorizable
extend ActiveSupport::Concern
require_to_override :authorize
def self.included(klass)
required_methods.each do |method|
raise NotImplementedError, "#{self} requires to implement #{method} on #{klass}" \
unless klass.instance_methods(false).include?(method)
end
end
end
class Employee < ApplicationRecord
include_abstraction Notifiable do
def notify
send_message
end
end
include_abstraction Authorizable do
def authorize
authorize_as_employee
end
end
private
def send_message; end
def authorize_as_employee; end
end
class Contact < ApplicationRecord
include_abstraction Notifiable do
def notify
send_email
end
end
# => NotImplementedError because authorize is not defined
include_abstraction Authorizable do
# def authorize
# authorize_as_contact
# end
end
private
def send_email; end
def authorize_as_contact; end
end
もちろん development
環境では autoload で定数が読み込まれるため、Employee
や Contact
などのクラスにアクセスした段階ではじめてエラーに気づけるのですが、eager loading される production
環境ではプロセスがあがった瞬間にエラーになるためわりと安心感ありそうです。
おわり
ということで、「Ruby でモジュールを mixin したクラスにメソッドの定義をわかりやすく強制する方法」の案を1つ考えてみました。実装方法はさておき、考え方の方向性としては結構実践的なものになったかなとは思っています。
ただほんとに思いつきで、自分でこれを利用しているわけでもないので、見逃した恐ろしい罠がどこかに隠れているかもしれません!