Rubyでoverloadをする
Rubyは言語仕様としてはoverloadを提供していませんが、ライブラリを使うことでoverloadできます。
使い方
まず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さんのoverrider、finalist、abstrikerの三兄弟にも刺激を受けています。
素敵な発表とコードをありがとうございました🙏
Rubyでもoverloadしていきましょう。 私はプロダクションにoverloader gemを入れようとは思いませんが、人柱は大歓迎です。