pockestrap

Programmer's memo

Rubyでoverloadをする

Rubyは言語仕様としてはoverloadを提供していませんが、ライブラリを使うことでoverloadできます。

github.com

使い方

まずgemをインストールします。

$ gem install overloader

クラスにOverloaderモジュールをextendし、overloadメソッドを呼び出すことでoverloadを行えます。

require 'overloader'

class A
  extend Overloader
  overload do
    def foo() "no args" end
    def foo(x) "one arg" end
    def foo(x, y) "two args" end
  end
end

a = A.new
p a.foo # => "no args"
p a.foo(1) # => "one args"
p a.foo(1, 2) # => "two args"

この例ではAクラスにfooメソッドを3つ定義しています。 通常だとこの定義は上書きされて最後の引数が2つのfooメソッドのみが生き残りますが、overloadに渡したブロックの中では、なんと全てのメソッドが有効になります。 そして、fooメソッドの呼び出し時の引数に応じて、適切なメソッドを呼び分けます。

実装

overloaderの実装は結構単純なので、全体的に解説をします。

overloadメソッド

まずoverloadメソッドの定義を見てみます。これはlib/overload.rbに定義されています。 コードが少ないので全文のせてしまいます。

overloader/overloader.rb at e8ae420987f1e14698b933feff8805d830974aa8 · pocke/overloader · GitHub

require "overloader/version"
require 'overloader/ast_ext'
require 'overloader/core'

module Overloader
  def overload(&block)
    Core.define_overload(self, block)
  end
end

ブロックを受け取って、Core.define_overloadメソッドに引き渡しているだけですね。

Overloader::Core.define_overload

Overloader::Core.define_overloadも引数を引き渡しているだけのメソッドです。

overloader/core.rb at e8ae420987f1e14698b933feff8805d830974aa8 · pocke/overloader · GitHub

def self.define_overload(klass, proc)
  self.new(klass, proc).define_overload
end

Overloader::Core#define_overloadメソッドを呼んでいますね。このメソッドが実装の本体なので、次から詳しく解説していきます。

Overloader::Core#define_overload

まずメソッドの全文をのせます。

def define_overload
  ast = RubyVM::AbstractSyntaxTree.of(@proc)
  methods = {}
  ast.find_nodes(:DEFN).each.with_index do |def_node, index|
    args = def_node.method_args
    body = def_node.method_body
    name = def_node.method_name
    args_source = args.to_source(absolute_path)
    args_source = "" if args_source == "(" # RubyVM::AST's bug?

    @klass.class_eval <<~RUBY
      def __#{name}_#{index}_checker_inner(#{args_source}) end
      def __#{name}_#{index}_checker(*args)
        __#{name}_#{index}_checker_inner(*args)
        true
      rescue ArgumentError
        false
      end
    RUBY

    @klass.class_eval <<~RUBY, absolute_path, def_node.first_lineno
      def __#{name}_#{index}(#{args_source})
      #{body.to_source(absolute_path)}
      end
    RUBY
    (methods[name] ||= []) << index
  end

  methods.each do |name, indexes|
    @klass.class_eval <<~RUBY
      def #{name}(*args, &block)
        #{indexes.map do |index|
          "return __#{name}_#{index}(*args, &block) if __#{name}_#{index}_checker(*args)"
        end.join("\n")}
        raise ArgumentError
      end
    RUBY
  end
end

RubyVM::AbstractSyntaxTree.of(@proc)

最初にRubyVM::AbstractSyntaxTree.ofメソッドで、ProcをRubyVM::ASTに変換しています。 これが今回の肝です。ここさえ理解すればあとはおまけです。

RubyVM::AbstractSyntaxTree.ofメソッドの強みは2つあります。

1つは、Procオブジェクト(やMethodオブジェクト)をASTに変換できることです。 これはすなわち、特定のコード片のみをASTとして扱えることを意味します。 従来のRipperやparser gemではファイル単位でのパースが基本であったため、コード片のみをASTとして扱うのは難しいです。 それをメソッド1つで簡単に行えてしまうのはとてもパワフルだと言えます。

もう1つは、Procオブジェクトを実行する必要はないことです。 ProcオブジェクトはProc#callを呼ばない限り実行されません。 つまり、Procの中にはSyntax Errorが起きない限りなにを書いても良いことになります。 たとえば同じ名前のメソッド定義を繰り返し書くことができます。

どのメソッドを呼び出すか

overloadを実装するからには、メソッド呼び出し時に与えられた実引数にマッチするメソッドを選択する必要があります。 真面目に実装するなら、メソッド定義のASTから引数の情報を引っ張り出してきて、実引数の数やキーワード引数を比較するコードを書くことになるでしょう。

ですが、そんなコードは書きたくないですね。Rubyの引数は難しいので。

そこで、横着をします。 自前で仮引数と実引数をマッチするコードを書くのは大変なので、既にあるものを使うことにします。

具体的には、次のfooメソッドの定義から、__foo_N_checker_inner__foo_N_checkerメソッドを定義します。1

def foo(x, y)
end

def __foo_N_checker_inner(x, y) end
def __foo_N_checker(*args)
  __foo_N_checker_inner(*args)
  true
rescue ArgumentError
  false
end

__foo_N_checkerメソッドは任意の引数を受け取り、それをそのまま__foo_N_checker_innerメソッドに渡しています。 そしてArgumentErrorが出たらfalseを、でなければtrueを返します。

__foo_N_checker_innerメソッドは、fooメソッドと同じ仮引数を持った、中身のないメソッドです。 つまりこのinnerメソッドを呼び出してArgumentErrorが起きるかどうかを見ることで、副作用なく2メソッドの仮引数が実引数とマッチするか調べることができます。

ASTを使うとこの2つのメソッドの生成が簡単にできます。 proc全体のASTからメソッド定義を表すDEFNノードを抜き出して、そのノードから仮引数の定義部分を抜き出せばあとはやるだけです。

この処理を行っている部分のコードを下に抜き出します。

ast.find_nodes(:DEFN).each.with_index do |def_node, index|
  args = def_node.method_args
  body = def_node.method_body
  name = def_node.method_name
  args_source = args.to_source(absolute_path)
  args_source = "" if args_source == "(" # RubyVM::AST's bug?

  @klass.class_eval <<~RUBY
    def __#{name}_#{index}_checker_inner(#{args_source}) end
    def __#{name}_#{index}_checker(*args)
      __#{name}_#{index}_checker_inner(*args)
      true
    rescue ArgumentError
      false
    end
  RUBY
  # ...

メソッド本体の定義

次に、仮引数と実引数がマッチした時に呼び出されるメソッド本体の定義を見てみましょう。 これはとても簡単です。もとのメソッド定義のメソッド名だけを変えて定義します。

@klass.class_eval <<~RUBY, absolute_path, def_node.first_lineno
  def __#{name}_#{index}(#{args_source})
  #{body.to_source(absolute_path)}
  end
RUBY

また、class_evalメソッドの引数に、overloadメソッドを呼び出したファイルのパスと、メソッド定義の行番号を渡しています。 これによって例外が起きた際のスタックトレースが見やすくなります。3

ラッパーメソッドの定義

最後にラッパーメソッドの定義をします。 このメソッドは次のようなコードとして定義されます。

def foo(*args, &block)
  return __foo_0(*args, &block) if __foo_0_checker(*args)
  return __foo_1(*args, &block) if __foo_1_checker(*args)
  return __foo_2(*args, &block) if __foo_2_checker(*args)
  raise ArgumentError
end

実引数を上から順に1つ1つ試していって、最初にマッチしたメソッドを呼び出しています。簡単ですね。

このメソッドは次のコードで定義されます。

@klass.class_eval <<~RUBY
  def #{name}(*args, &block)
    #{indexes.map do |index|
      "return __#{name}_#{index}(*args, &block) if __#{name}_#{index}_checker(*args)"
    end.join("\n")}
    raise ArgumentError
  end
RUBY

RubyVM::AST::Nodeの拡張

overloaderでは実装を簡単にするため、RubyVM::AbstractSyntaxTree::Nodeクラスをrefinementsで拡張しています。 children[0]のようなコードが頻発するので適宜エイリアスをつけたり、ASTからソースコードへの変換をしやすいようにノードのバイト単位のindexを位置情報として扱えるようにしたりしています。4

具体的な実装は次のファイルを見てください。

overloader/ast_ext.rb at e8ae420987f1e14698b933feff8805d830974aa8 · pocke/overloader · GitHub

今後のTODO

引数のデフォルト値を評価しないようにする。

https://github.com/pocke/overloader/issues/1

overloaderは仮引数のコードを引数のチェックのためにそのままコピーして使っています。 つまり、引数のチェックの際に仮引数が評価されてしまいます。

これは引数にデフォルト値がある時に問題となります。

overload do
  def foo(x = do_something_with_side_effect)
  end

  def foo(x, y)
  end
end

1つ目のfooメソッドは、いかにも副作用がありそうなdo_something_with_side_effectメソッドをデフォルト値として持っています。 このメソッドは引数のチェック時に評価されて呼び出されてしまいます。

これを防ぐには、仮引数のデフォルト値をnilのような適当な値に置き換えてやれば良いでしょう。

どのメソッドにもマッチしなかった時のエラーメッセージをちゃんとする

https://github.com/pocke/overloader/issues/2

現在、どのメソッドにもマッチしなかったときは単にraise ArgumentErrorしており、メッセージがなにもありません。 これは不親切なのでoverloadされた各メソッドの引数の情報を元に、よりわかりやすいメッセージを出力したいですね。


これらの改善をやってみたい人がいたら、ぜひIssueにコメントして手を付けてみてください。 そんなに難しくないと思います。

さいごに

RubyKaigi 2019で高まったRubyVM::AST熱によってoverloaderは生まれました。5 @joker1007さんの「Pragmatic Monadic Programing in Ruby」、@kddeiszさんの「Pre-evaluation in Ruby」、@tagomorisさんの「Invitation to Dark Side of Ruby」の3つの発表に、実装のアイディアと発想と沢山の情熱をもらっています。

また、@joker1007さんのoverriderfinalistabstrikerの三兄弟にも刺激を受けています。

素敵な発表とコードをありがとうございました🙏


Rubyでもoverloadしていきましょう。 私はプロダクションにoverloader gemを入れようとは思いませんが、人柱は大歓迎です。


  1. Nには数字が入ります。

  2. 正確には今の実装だと副作用があります。後述します

  3. ちゃんと動くか試してないからずれてるかも

  4. overloaderではindexを使うほどでもない気はしますが、ソースコードの書き換えをガリガリやろうとすると、行番号と列番号で頑張るよりもindexを使ったほうがはるかに楽できます。

  5. RubyVM::AST熱に浮かされて別のプロダクトを書いていたら、overloaderを思いついた。