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 を見てみたら、そもそもハッシュは引数として受け取れないことになっていた。そういうものなのか。