Ruby でモジュールを mixin したクラスにメソッドの定義をわかりやすく強制する方法を考える

  • 13
    いいね
  • 0
    コメント

Ruby Advent Calendar 2016 の7日目が未投稿だったので埋めちゃいます。


これは Ruby Advent Calendar 2016 の7日目の (代理) 投稿です。

Railsのポリモーフィック関連とはなんなのか」という投稿の中で、

ポリモーフィック自体は非常に便利だけどもRubyのシンタックス的に厳しいものがある

との意見がありました。

個人的には Ruby 開発ではガンガンコーディングを気分良く進めていける一方で、その分ランタイムで落ちたり想定外のバグを出したりするのはもうある程度仕方ないものだと考えていて、だから気をつけてコード書かないとね、ちゃんとバグを拾えるテストコード書いとかないとね、と心得てやっていくのがいいと思っているのですが、それでせっかくの柔軟な機能の利用を自ら制限してしまったり、本来不要であるはずのドキュメンテーションを要求されたりすることもあるので、ぶっちゃけマジで実装でどうにか縛りたいんだけど!って感じるときも結構あります。

ということで、引用した投稿は Rails のポリモーフィック関連から派生したもので、かつ Rails のアドベントカレンダーへの投稿ではありますが、該当箇所は Rails に限らず Ruby におけるモジュールの mixin 全般に関連する話題だと思うので、その課題をどうにかうまく解決できないかを考えてみることにします。

モジュールのインターフェース設計上の課題

再度前述の投稿から引用させてもらいますが、Rails でポリモーフィック関連を利用する場合の課題として以下があげられています。

  1. あるインターフェースを実装することを強制できない
    • 必須メソッドが実装されるかは実装者次第
    • Javaで言うところのabstract/interfaceが欲しい
  2. オーバーライドした時にどのメソッドをオーバーライドしたのかわかりにくい

繰り返しますが、あくまでもこれは「Rails でポリモーフィック関連を利用する場合」の話なので、Ruby 全体に話を広げることは投稿者の意図とは異なるとは思います。しかし、おそらくRuby のモジュールのインターフェース設計として解決できたらこれも解決できることでしょう。

課題 1. あるインターフェースを実装することを強制できない

抽象クラスやインターフェースが存在しないという言語の特性上、Ruby ではそのままではこの「あるインターフェースを実装することを強制」することができません。 

この課題に対する一般的なソリューションとして、

  • オーバーライドを期待するメソッドが NotImplementedErrorraise するようにする

というものがあり、これは mixin 対象のモジュールのコード表現として「ああ、このメソッドはオーバーライドすべきなんだな」と一発でわかるし、実際に呼び出したら例外を吐くので有効ではあります。

ただ、結局はメソッドを呼び出してみないとわからない、という問題があり、残念ながら「あーこのインターフェースは必須なんだからもっとサクっと Fail Fast してほしいんだよ俺はああ」という要求を満たしてくれるものではありません。

課題 2. オーバーライドした時にどのメソッドをオーバーライドしたのかわかりにくい

これは、override みたいなキーワードもなく、見た目上はフラットなメソッド定義になるため、どのメソッドがオーバーライドされたものでどのメソッドがそうでないのかがわかりにくい、というものです。

引用した記事中でいくつか対策もあげられていますが、冗長だったりトリッキーすぎたりと、なかなかうまい方法がないようです。

上記の課題を解決する方法を考えてみた

今短時間で考えたのでまだラフスケッチ程度ですが、「こういうやりかたはどーかな?」ってのを考えてみました。たぶんコードを見た方が早いのでいきなり貼り付けます。

abstraction.rb
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_モジュール名 に格納される
notifiable.rb
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
employee.rb
require './notifiable'

class Employee
  include_abstraction Notifiable do
    def notify
      send_message
    end
  end

  private

    def send_message; end
end

そして、モジュールを mixin する側のクラスです。include_abstraction にブロックを与えて、明示的にその中でモジュールに対応した実装したいメソッドの定義を書けるようになっています

もちろん、以下のように必須メソッドが実装されていなければエラーになります。

contact.rb
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 にそのまま応用してみます。

コードだけを貼り付け。

config/initializers/abstraction.rb
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)
app/models/concerns/notifiable.rb
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
app/models/concerns/authorizable.rb
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
app/models/employee.rb
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
/app/models/contact.rb
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 で定数が読み込まれるため、EmployeeContact などのクラスにアクセスした段階ではじめてエラーに気づけるのですが、eager loading される production 環境ではプロセスがあがった瞬間にエラーになるためわりと安心感ありそうです。

おわり

ということで、「Ruby でモジュールを mixin したクラスにメソッドの定義をわかりやすく強制する方法」の案を1つ考えてみました。実装方法はさておき、考え方の方向性としては結構実践的なものになったかなとは思っています。

ただほんとに思いつきで、自分でこれを利用しているわけでもないので、見逃した恐ろしい罠がどこかに隠れているかもしれません!