method_missing の中で gsub 呼んだら無限ループに
method_missing って自分で試したことがなかったので、Eloquent Rubyの21章を参考に、オレオレ定義を書いてみた。
Eloquent Rubyのサンプルは英語で単語間の距離を発音の近さで判定するSoundexというものを使っていたけど、全く同じでもつまらないし、日本人が苦手な「LとR」、「VとB」の区別をなくして、String#capitarize とlとrを間違えて呼んだら、「もしかしてcapitalizeのこと?」と提示する、というのを書いてみた。
class Object def method_missing(method, *args) msg = "No such method: #{method}" if meth = similar_method(method) msg = "Did you mean?: #{meth}" else raise NoMethodError, msg end end def similar_method(name) method = public_methods.find do |m| accented(name.to_s) == accented(m.to_s) end end private def accented(word) word.tr('lv', 'rb') end end puts "hello".capitarize puts :world.pubric_methods puts 123.eben? puts "method".there_really_is_no_such_method
実行すると、これで確かにうまく行く。
$ ruby engrish_method.rb Did you mean?: capitalize Did you mean?: public_methods Did you mean?: even? engrish_method.rb:8:in `method_missing': No such method: there_really_is_no_such_method (NoMethodError) from engrish_method.rb:28:in `<main>' $
最初、thとsの音も同一視しようと思って以下のように書いたら、すごくよく分からない stack level too deep なエラーが出て、ちょっと悩んだ。
module Engrish THE_SAME = [ ['th', 's'], ['r', 'l'], ['b', 'v'] ] def accented(word) THE_SAME.inject(word) do |word, pair| word.gsub(*pair) end end module_function :accented end class String def method_missing(method, *args) msg = "No such method: #{method}" if meth = similar_method(method) msg = "Did you mean?: #{meth}" else raise NoMethodError, msg end end def similar_method(name) method = public_methods.find do |m| Engrish.accented(name.to_s) == Engrish.accented(m.to_s) end end end puts "hello".capitarize puts "method".pubric_mesod
何が起こってるのか分からなくて、あちこちに p を入れてみて気付いたのは、無限ループになっているのは、gsub に2つの引数を渡しているところだということ。
gsubは、
"hoge".gsub('o', 'a') => "hage"
のように文字列を2つ引数に取れるので、Engrish.accented はちゃんと動くように思える。実際、メソッドを単体で切り出して文字列を渡すと、メソッドの実装自体に問題はない。ただ、問題なのは gsub の暗黙の動作。
gsubには2つ目の引数としてハッシュを渡す用法もある。
"hoge".gsub(/[eo]/, 'e' => 'u', 'o' => 'a') => "hagu"
というように。
String#gsub に引数を2つ渡すと、CRubyは、まず2つ目の引数に対してハッシュとして振る舞うかどうかをチェックする。このとき、2つ目のオブジェクトに to_hash を投げているのが無限ループになった原因だったようだ。String#to_hash は存在しないので、ここで再び method_missig が呼ばれてしまう。
CRubyのstring.cでは、
3788 static VALUE 3789 str_gsub(int argc, VALUE *argv, VALUE str, int bang) 3790 { 3791 VALUE pat, val, repl, match, dest, hash = Qnil; 3792 struct re_registers *regs; 3793 long beg, n; 3794 long beg0, end0; 3795 long offset, blen, slen, len, last; 3796 int iter = 0; 3797 char *sp, *cp; 3798 int tainted = 0; 3799 rb_encoding *str_enc; 3800 3801 switch (argc) { 3802 case 1: 3803 RETURN_ENUMERATOR(str, argc, argv); 3804 iter = 1; 3805 break; 3806 case 2: 3807 repl = argv[1]; 3808 hash = rb_check_hash_type(argv[1]); 3809 if (NIL_P(hash)) { 3810 StringValue(repl); 3811 } 3812 if (OBJ_TAINTED(repl)) tainted = 1; 3813 break; 3814 default: 3815 rb_check_arity(argc, 1, 2); 3816 } 3817
となっている。3808行目の rb_check_hash_type の先で、convertを試みて、to_hash が呼ばれ、method_missingに再び戻るということが起こっている、ような気がする。単純な method_missing 遊びのつもりだったけど、やっぱり処理系の動作に手を入れるような手法って、なめたらアカンなーと思った。ちょっとした間違いでも原因特定が難しくなる。
しかし、良く分からん。
class String def method_missing(method, *args) msg = "No such method: #{method}" if meth = similar_method(method) msg = "Did you mean?: #{meth}" end raise NoMethodError, msg end def similar_method(name) puts name.to_s.respond_to?(:to_hash) # this is ok name.to_s.reverse end end "hoge".foobar
という風に respond_to? を投げれば問題はない。だけど、
puts name.to_s.to_hash
とやると stack level too deep で落ちる。
ということは、CRubyだって、respond_to? で to_hash が反応するかどうかを見ればいいだけのような気がする。どうしてそうなっていないんだろうか。rb_check_hash_type -> rb_check_convert_type -> convert_type -> rb_check_funcall -> check_funcall と見てみたけど、良く分からん。check_funcall って、respond_to? で呼ばれてる関数じゃないのかな。いや、Kernel#respond_to? は vm_method.c の obj_respond_to にバインドされていて、うーん……。と、ぼくにはとても追えない。
Rubinius のString#gsub実装はどうなってるんだろうかと思って kernel/common/string.rb を見てみたら、そもそもハッシュは引数として受け取れないことになっていた。そういうものなのか。