じゃんけんごときにクラスなんて要るのか

CodeIQというプログラミング課題に挑戦するサイトに、Rubyでジャンケンクラスを作れという問題があったのでやってみた。すでに問題は読めなくなっているけど、こんな感じのJankenクラスを作れという。

$ irb
> require './janken.rb'
> left = Janken.new
> right = Janken.new
> left.versus(right)
左の人が勝ました。右「チョキ」左「グー」
> left.versus(right)
右の人が勝ちました。右「チョキ」左「パー」
  :
  :

繰り返しirbで実行できるように、とある。

問題を見た瞬間、これは問題自体がおかしいのではないかと思ったけど、やってみた。

# -*- coding: utf-8 -*-
class Janken
  attr_reader :hand

  NAME = {
    goo:   "グー",
    choki: "チョキ",
    pah:   "パー"
  }

  def initialize
    generate_new_hand
  end

  def versus(other)
    generate_new_hand
    other.generate_new_hand
    case match(other)
    when :win
      puts "左の人が勝ちました。右「#{other}」左「#{self}"
    when :lose
      puts "右の人が勝ちました。右「#{other}」左「#{self}"
    when :even
      puts "あいこでした。#{self}"
    end
  end

protected

  def generate_new_hand
    @hand = [:goo, :choki, :pah].shuffle.pop
  end

  def match(other)
    match = [@hand, other.hand]
    case
    when @hand == other.hand; :even
    when match == [:goo, :choki] ||
        match == [:choki, :pah] ||
        match == [:pah, :goo]; :win
    else :lose
    end
  end

  def to_s
    NAME[@hand]
  end
end

勝敗メッセージの出力をJankenクラスでやるのは、いまいち。Jankenクラスは勝ち負けの判定ロジックを持つべきではあっても、その結果に基づくレポート機能まで含むのは抱え込みすぎだと思う。もう1つ、Jankenインスタンス自身は自分が右なのか左なのか分からないはずなので、右や左といった現実世界との結びつけは呼び出し側でやるべきこと。「右」とか「左」といった文字列がJankenクラスに含まれるのはおかしい。

ということを考えると、以下のほうが良い。

# -*- coding: utf-8 -*-
class Janken
  attr_reader :hand

  NAME = {
    goo:   "グー",
    choki: "チョキ",
    pah:   "パー"
  }

  def initialize
    generate_new_hand
  end

  def versus(other)
    generate_new_hand
    other.generate_new_hand
    match(other)
  end

protected

  def generate_new_hand
    @hand = [:goo, :choki, :pah].shuffle.pop
  end

  def match(other)
    match = [@hand, other.hand]
    case
    when @hand == other.hand; :even
    when match == [:goo, :choki] ||
        match == [:choki, :pah] ||
        match == [:pah, :goo]; :win
    else :lose
    end
  end

  def to_s
    NAME[@hand]
  end
end

if __FILE__ == $0
  left = Janken.new
  right = Janken.new

  10.times {
    case left.versus(right)
    when :win
      puts "左の人が勝ちました。右「#{right}」左「#{left}"
    when :lose
      puts "右の人が勝ちました。右「#{right}」左「#{left}"
    when :even
      puts "あいこでした。#{left}"
    end
  }
end

さらに疑問が。

Jankenクラスがversusを呼び出すたびに内部の状態を変えていくというのは違和感を覚える。Jankenクラスは手の状態を持っているべきではあっても、同じインスタンスがどんどん状態を変えていくのが良いAPIとは思えない。ある人がグーを出し、次にチョキを出した時、この2つが同じオブジェクトではマズイ。

むしろ設問にあるJankenクラスというのは、Playerクラスであるべきで、Janken#versusというのは、Player#jankenとでもしたほうがジャンケンの自然な表現。だから、2つのクラスに分けて、

# -*- coding: utf-8 -*-
class Player
  attr_accessor :hand

  def initialize
    @hand = Janken.new
  end

  def janken(other)
    case @hand.match(other.hand)
    when :win
      puts "私の勝ちです。私「#{@hand}」相手「#{other.hand}"
    when :lose
      puts "私の負けです。私「#{@hand}」相手「#{other.hand}"
    when :even
      puts "あいこでした。#{@hand}"
    end
  end
end

class Janken
  attr_reader :hand

  NAME = {
    goo:   "グー",
    choki: "チョキ",
    pah:   "パー"
  }

  def initialize
    @hand = [:goo, :choki, :pah].shuffle.pop
  end

  def match(other)
    match = [@hand, other.hand]
    case
    when @hand == other.hand; :even
    when match == [:goo, :choki] ||
        match == [:choki, :pah] ||
        match == [:pah, :goo]; :win
    else :lose
    end
  end

  def to_s
    NAME[@hand]
  end
end

if __FILE__ == $0
  me = Player.new
  opponent = Player.new

  5.times {
    me.janken opponent
    me.hand = Janken.new
    opponent.hand = Janken.new
  }
end

とでもしたほうがいい。

ただ、2手目以降で外部からJanken.newをPlayerインスタンスに突っ込むのはちょっとヘン。Playerクラスが常にJanken.newをするほうが良い。出す手を考えるのはPlayerだし。考えてみるとジャンケンというのは一連の手のシーケンスなので、以下のようにEnumeratorを使ったほうが、より現実のジャンケンの表現として適切かもしれない。

# -*- coding: utf-8 -*-
class Player
  def hand_seq
    @hand_seq ||= Enumerator.new do |y|
      loop { y << Janken.new }
    end
  end

  def janken(other)
    hand       = hand_seq.peek
    other_hand = other.hand_seq.peek

    case hand.match(other_hand)
    when :win
      puts "私の勝ちです。私「#{hand}」相手「#{other_hand}"
    when :lose
      puts "私の負けです。私「#{hand}」相手「#{other_hand}"
    when :even
      puts "あいこでした。#{hand}"
    end
  end
end

class Janken
  attr_reader :hand

  NAME = {
    goo:   "グー",
    choki: "チョキ",
    pah:   "パー"
  }

  def initialize
    @hand = [:goo, :choki, :pah].shuffle.first
  end

  def match(other)
    match = [@hand, other.hand]
    case
    when @hand == other.hand; :even
    when match == [:goo, :choki] ||
        match == [:choki, :pah] ||
        match == [:pah, :goo]; :win
    else :lose
    end
  end

  def to_s
    NAME[@hand]
  end
end

if __FILE__ == $0
  me = Player.new
  opponent = Player.new

  5.times {
    me.janken opponent

    me.hand_seq.next
    opponent.hand_seq.next
  }
end

Enumeratorを使うのはいいとしても、外部にそのまま見せるのはマズイ。peekって何だよという感じ。こういうときは必要なメソッドにだけ適当な名前を付けてデリゲート。

# -*- coding: utf-8 -*-
require 'forwardable'

class Player
  extend Forwardable

  def initialize
    @hand_seq = Enumerator.new do |y|
      loop { y << Janken.new }
    end
  end

  def_delegator :@hand_seq, :peek, :hand
  def_delegator :@hand_seq, :next, :next!

  def janken(other)
    case hand.match(other.hand)
    when :win
      puts "私の勝ちです。私「#{hand}」相手「#{other.hand}"
    when :lose
      puts "私の負けです。私「#{hand}」相手「#{other.hand}"
    when :even
      puts "あいこでした。#{hand}"
    end
  end
end

class Janken
  attr_reader :hand

  NAME = {
    goo:   "グー",
    choki: "チョキ",
    pah:   "パー"
  }

  def initialize
    @hand = [:goo, :choki, :pah].shuffle.first
  end

  def match(other)
    match = [@hand, other.hand]
    case
    when @hand == other.hand; :even
    when match == [:goo, :choki] ||
        match == [:choki, :pah] ||
        match == [:pah, :goo]; :win
    else :lose
    end
  end

  def to_s
    NAME[@hand]
  end
end

if __FILE__ == $0
  me = Player.new
  opponent = Player.new

  5.times {
    me.janken opponent

    me.next!
    opponent.next!
  }
end

ここまで来ると滅茶苦茶という気がしなくもない。

たかがジャンケンプログラムごときで大げさ。グーチョキパーを [0, 1, 2] で表現して、それを比較するif文をずらっと並べればいい話で、クラスなんて1つも作ることなんてないという気もする。いや、必要なのはハッシュテーブル1つと、2つのジャンケンを受け取って評価する関数の1つだけか。このあいだYouTubeで見たPythonな人のトークで、データ構造で済むものにイチイチ名前を付けたりクラスにしたりするなよという主張に、それはあるかもなと思ったりしている。PythonRubyで文化の違いがありそう。しかし、慣れてみたらクラスは便利で、別にコード行数が増えすぎるとか読みづらくなるというわけでもない。実行効率も多くの場合、全くどうでもいい話で、むしろ大事なのは人間が認識するインターフェースはどうなっているのかというところ。問題の捉え方をモジュールに分けることで読み手に示し、関連するコードをまとめる場としてクラスという容れ物を使うのは、すごく良いことに思えるのだよな。

寄り道してたらWikipediaに迷い込む

ちゃんと1行ずつ読もう、分からないところは飛ばさず読もうと思ってRailsのサンプルアプリを読み始めた。

1.year.from_nowって意味は自明だけど、実際のところ何だろうか? とか立ち止まりつつ。こういうのってActiveSupportの、確かDurationオブジェクトで。でも、from_nowってなんだ? ふむ、sinceのaliasかなるほど。

ユーザーのセッション管理はヘルパーにしてApplicationControllerにincludeしてしまうのが定石か、ところで待てよ、cookies.permanentのpermanentってなんだ、と思って、まず、ActionDispatchのディレクトリに移動。lsすると、何だか興味深いファイル名がたくさんあるので less * とかして読み始める。なるほど、ActionDispatch::BestStandardSupportなんていうRackミドルウェアがあるのか、こういう構造でミドルウェアって作るのか。なるほど、これはIEの互換性関連ぽいな、IE=Edge,chrome=1ってなんだ? なるほど、Chrome Frameがあれば、それをIEにロードしろというディレクティブなのか、げ、それってRailsのデフォルト? まあいいや、で、えーと、なんだっけ。ああ、cookiecookie、これこれ、ActionDispatch::Request::Cookiesだわ。なるほど、permanentで20年先にexpireする指定ができるのか。options[:expire] = 20.years.from_now とかしてるのか。そういえば、cookieって無限遠の未来って指定できたっけな。どれどれWikipedia。こういうときはRFCをいきなり読むより、Wikipediaでctrl-fしたほうが分かるよな。expir...そうそう、これこれ。絶対日時か秒数か。ふーん、cookieのセキュリティとかプライバシーって議論色々あるのなぁ。へぇぇ。ところでcookieの例として数値の配列なんかを入れてるけど、実際にRailsが吐くヘッダってどうなるんだろ。ていうか、ヘッダってどういう文字列だっけな。そういえば4096バイトという制限があったような。やっぱりRFC見てみるか。どれどれ、RFCってどれが最新なんや。IETFのサイトって分かりづらいな。これは古いバージョンのRFCか……、古いRFCのページは、もっと紙がかすれて腐りかけてるような画面デザインにしてほしいな。で、cookieの最新はRFC6265だ。ふむふむ、ブラウザ実装は最低限でも1つのcookieあたり4096バイト保存可能であるべきで、ドメインあたり50個のcookieに対応できて、全部で3000個のcookieを扱えないといけないのか。あれれ、ちょっと待てよ、Wikipediaの記述と違うような……。うわっ、やっぱり。RFCにリンクが貼ってあるのに、古いRFCの値を参照してるよ、これ。ドメイン単位で20、全部で300個のcookieと書いてあるのは情報が古いんだな、きっと。RFC2965のほうにはそう書いてあるし。コンピュータ関連の記述では、かなり信用している英語版Wikipediaだけど、こんな間違いがあるとはなぁ。よーし、パパ、Wikipedia編集提案しちゃうぞ、初めてのWikipedia編集だ。まずはアカウントを取って、うーん、キャプチャが読めん。よし、それで……、wikiかぁ、このtalkとeditっていうのの違いはなんだ? talkかな? ほぉ、なんか最新のブラウザ実装に基づいた議論してるんだな。それで……、それで……、それでオレは今朝は何をしていたんだっけな。そうそう、Railsアプリを読もうと思ってたんだった。げ、3行しか読んでないやん……。

CRubyで末尾最適化を使った再帰

Schemeなんかと違って、言語としてのRubyは末尾最適化(Tail Call Optimization)の実装は必須ではないけど、処理系としてのCRubyは2.0.0からオプション扱いで入っている、という話。2012年の6月ごろにはMatzさんはTCOをデフォルトにするという考えもあったようだけど、ここにある議論によれば、Ruby 2.0系のマイナーバージョンまで先延ばしになった模様。性急にTCOを入れなかった理由は、

  • バックトレースを失うので一般的なRuby利用者に影響が大きい
  • set_trace_func()のサポートが大変
  • 文法。ちゃんとドキュメントに落としこむのが難しい。これは半分冗談、半分本気

ということらしい。JRubyでも、JVMがサポートしない限り実装が難しいという(あれ? Clojureは明示的な末尾呼び出しの最適化をやってるように思うけど)。

Rubyイテレータでeachもmapもfoldもあるので、forを使わないのと同じくらい再帰を使う場面がないように思う。リスト処理という意味でいえば、RubyイテレータTCO入りの再帰と言える気がする。トランポリン的な相互再帰とかでもない限り、これで困らない。と、思うのはSchemerじゃないからなのか。

でも、例えば、Euler Project 160なんかは再帰を使ったほうがスンナリ書けて、しかも、TCOがないとスタックがあっという間に溢れるような問題だ。問題は、

For any N, let f(N) be the last five digits before the trailing zeroes in N!.
For example,

9! = 362880 so f(9)=36288
10! = 3628800 so f(10)=36288
20! = 2432902008176640000 so f(20)=17664

Find f(1,000,000,000,000)

という感じ。これを次のように書いた。

def f(n, res)
  if n == 1
    return res
  end

  res = res * n
  res = res.to_s.sub(/0+$/, '')
  res = res[-5..-1] if res.size > 5

  res = res.to_i
  f(n - 1, res)
end

p f(100, 1)
#p f(1_000_000_000_000, 1)

ぼくの環境ではnが1000ではオッケーで、10000にするとスタックは溢れた。Ruby 1.9.3-p286ではなく、Ruby 2.0.0系のheadを使って以下のように書いた。RubyVMでTCOをオンにできる。ついでに文字列と数字の変換が遅いので、全部数値としてやるように変更。

RubyVM::InstructionSequence.compile_option = {
  :tailcall_optimization => true,
  :trace_instruction => false
}

RubyVM::InstructionSequence.new(<<-EOF).eval
def f(n, res)
  if n == 1
    return res
  end

  res = res * n
  while (res % 10) == 0
    res /= 10
  end

  res %= 100000

  f(n - 1, res)
end

 p f(1_000_000_00, 1) # 18 sec
# p f(1_000_000_000_000, 1)
EOF
$ time ruby -v p160tco.rb
ruby 2.0.0dev (2012-10-12 trunk 37163) [x86_64-darwin11.4.2]
23616
ruby -v p160tco.rb  17.73s user 0.04s system 94% cpu 18.731 total

設問より4桁少なくして18秒ということは、答えがでるのに18万秒かかるということで、これは丸二日以上かかる計算。無理。メソッド呼び出しをやめて素直にfor的な何かを使うと、もしかしたら速くなるかな。

ともあれ、いくらループを回してもスタックは溢れなくなった。

find.rbにみるRubyのthrow/catchの大域脱出の例

Rubyのthrow/catchの大域脱出って、あまり見かけない気がしているけど、どういうときに使うのかなとぼんやり思っていたら、標準添付の lib/find.rbになるほどと思う例が見つかった。

#
# find.rb: the Find module for processing all files under a given directory.
#

#
# The +Find+ module supports the top-down traversal of a set of file paths.
#
# For example, to total the size of all files under your home directory,
# ignoring anything in a "dot" directory (e.g. $HOME/.ssh):
#
#   require 'find'
#
#   total_size = 0
#
#   Find.find(ENV["HOME"]) do |path|
#     if FileTest.directory?(path)
#       if File.basename(path)[0] == ?.
#         Find.prune       # Don't look any further into this directory.
#       else
#         next
#       end
#     else
#       total_size += FileTest.size(path)
#     end
#   end
#
module Find

  #
  # Calls the associated block with the name of every file and directory listed
  # as arguments, then recursively on their subdirectories, and so on.
  #
  # Returns an enumerator if no block is given.
  #
  # See the +Find+ module documentation for an example.
  #
  def find(*paths) # :yield: path
    block_given? or return enum_for(__method__, *paths)

    paths.collect!{|d| raise Errno::ENOENT unless File.exist?(d); d.dup}
    while file = paths.shift
      catch(:prune) do
        yield file.dup.taint
        begin
          s = File.lstat(file)
        rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR, Errno::ELOOP, Errno::ENAMETOOLONG
          next
        end
        if s.directory? then
          begin
            fs = Dir.entries(file)
          rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR, Errno::ELOOP, Errno::ENAMETOOLONG
            next
          end
          fs.sort!
          fs.reverse_each {|f|
            next if f == "." or f == ".."
            f = File.join(file, f)
            paths.unshift f.untaint
          }
        end
      end
    end
  end

  #
  # Skips the current file or directory, restarting the loop with the next
  # entry. If the current file is a directory, that directory will not be
  # recursively entered. Meaningful only within the block associated with
  # Find::find.
  #
  # See the +Find+ module documentation for an example.
  #
  def prune
    throw :prune
  end

  module_function :find, :prune
end

Find.findでpathsを順繰りにyieldしている最中に途中でプツンと処理を切って、次のpathを使ってブロックを起動するには、Find.pruneを使う。これはクラスメソッドというかモジュール関数だけど、呼び先のfind.rbでは throw :prune として大域脱出してる。グローバルなクラス定数のメソッドで処理の流れをゴリッと変えるっていうのは、やや荒っぽい印象も受けるけど、スクリプト言語っぽいなと思った。ていうか、それが大域脱出というものか。あれ、じゃあ、Find.findしてないときにFind.pruneしたら? と思ったら、

> Find.prune
ArgumentError: uncaught throw :prune

となるだけのことだった。

それより、? の単項演算子に戸惑った。

#       if File.basename(path)[0] == ?.

文脈からすると、右辺はカレントディレクトリを示すピリオドのことかと思ったけど、実際、これは1文字からなる文字リテラルだった。そんなもんあったっけな……。Emacs Lisp由来なのかな。メタ文字も書ける。でも素直に "." と書くほうが分かりやすいと思う。

$ irb
[1] pry(main)> ?.
=> "."
[2] pry(main)> ?a
=> "a"
[3] pry(main)> ?1
=> "1"
[4] pry(main)> ?abc
SyntaxError: unexpected '?', expecting $end
[4] pry(main)> ?\s
=> " "
[5] pry(main)> ?\t
=> "\t"
[6] pry(main)> ?\x20
=> " "

Rubyで自前の例外クラスを作るときExceptionではなくStandardErrorを継承する理由

Rubyの例外について少し調べたので、まとめてみる。

多くのモダンな言語同様にRubyでは例外処理機構が組み込まれている。

  • ファイルを開こうと思ったらファイルが存在しなかった
  • ネットワーク先のサーバが反応しなくてタイムアウトした
  • 定義されていない(存在しない)メソッドを呼んだ
  • 0で割り算をしてしまった

など想定外の問題に遭遇したときに、その問題を無視せずプログラマが何らかの対応処理をするための枠組みを提供する。

C言語など古い言語では、関数からの戻り値でエラーコードを返し、それによって呼び出し側がエラー処理をその場で記述する。例えば、fopen(3)が失敗すると戻り値としてNULLが戻ってきてグローバル変数のerrnoに失敗の理由を示すエラーコードが設定される。

#include <stdio.h>
#include <string.h>
#include <errno.h>

int main(void) {

  FILE *fp;

  if ((fp = fopen("some_file_that_does_not_exist.txt", "r")) != NULL) {
      fputs(fp);
      fclose(fp);
  } else {
    printf("Error: %s\n", strerror(errno));
  }

  return 0;
}
$ cc error.c && ./a.out
Error: No such file or directory

C言語のような戻り値によるエラー処理に比べて、モダンな言語に備わる例外処理のメリットは、

  • エラー処理のコードを本体のコードと分けてまとめて記述できるので見通しが良くなる
  • 例外が発生すると基本的に処理系は止まる。いかにプログラマがずぼらであっても例外処理を記述することになるので、想定外の状況に対応できる堅牢なプログラムになる

というところか。

で、Rubyでは、QA@ITにあるこのQAにあるとおり例外クラスは以下のような階層になっている。Ruby 1.9.3の例。

Exception
  NoMemoryError
  ScriptError
    LoadError
      Gem::LoadError
    NotImplementedError
    SyntaxError
  SecurityError
  SignalException
    Interrupt
  StandardError
    ArgumentError
    EncodingError
      Encoding::CompatibilityError
      Encoding::ConverterNotFoundError
      Encoding::InvalidByteSequenceError
      Encoding::UndefinedConversionError
    FiberError
    IOError
      EOFError
    IndexError
      KeyError
      StopIteration
    LocalJumpError
    Math::DomainError
    NameError
      NoMethodError
    RangeError
      FloatDomainError
    RegexpError
    RuntimeError
      Gem::Exception
        Gem::CommandLineError
        Gem::DependencyError
        Gem::DependencyRemovalException
        Gem::DocumentError
        Gem::EndOfYAMLException
        Gem::FilePermissionError
        Gem::FormatException
        Gem::GemNotFoundException
        Gem::GemNotInHomeException
        Gem::InstallError
        Gem::InvalidSpecificationException
        Gem::OperationNotSupportedError
        Gem::RemoteError
        Gem::RemoteInstallationCancelled
        Gem::RemoteInstallationSkipped
        Gem::RemoteSourceException
        Gem::VerificationError
    SystemCallError
    ThreadError
    TypeError
    ZeroDivisionError
  SystemExit
    Gem::SystemExitException
  SystemStackError
  fatal

大事そうなのを抜き出すと、

Exception
  NoMemoryError
  ScriptError
    LoadError
    NotImplementedError
    SyntaxError
  SecurityError
  SignalException
    Interrupt
  StandardError
    ArgumentError
    EncodingError
    IOError
      EOFError
    IndexError
      KeyError
      StopIteration
    NameError
      NoMethodError
    RangeError
      FloatDomainError
    RegexpError
    RuntimeError
      Gem::Exception
    TypeError
    ZeroDivisionError
  SystemExit
  SystemStackError
  fatal

という感じかしら。

Rubyでは例外は自分でも定義できる。ただこのとき、ふつうは全ての例外クラスのスーパークラスであるExceptionクラスを継承しない。以下のようなケースで困ったことになるからだ。

class MyException < Exception; end

begin
  puts "hello"
  raise MyException
rescue
  puts "exception handled"
end

上の例で MyExceptionは補足されない。なぜなら、rescue は第1引数で指定した例外クラスの下の階層にある例外だけを補足するけど、引数を省略すると StandardErrorクラスを指定したものとみなすからだ。MyExceptionはException直下の子クラスなので、rescue されない。

Exceptionを直接継承した自前定義の例外クラスを複数作って、それらを補足しようと思うと、いちばん上にあるExceptionごと補足するしかない。そうすると今度は、すべての例外を補足することになってしまう。例えば、exitを呼ぶと発生するSystemExitも補足されてしまう。これは恐らく望ましい動作ではない。

class MyException < Exception; end
class MyException2 < Exception; end

begin
  puts "hello"
  exit
rescue Exception => ex
  p ex
  puts "exception handled"
end
$ ruby system-exceptions.rb
hello
#<SystemExit: exit>
exception handled
$

というわけで、自前の例外クラスを作るときには、システム関連の例外まで含むExceptionではなく、アプリケーションレベルのトップレベルとも言える例外クラスのStandardErrorを継承するのがいい。

class MyError < StandardError; end
class MyError2 < StandardError; end

begin
  puts "hello"
  raise MyError
rescue
  puts "exception handled" # 実行される
end

RuntimeErrorを継承するケースも良く見る。名前付けの習慣も、MyExceptionより、MyErrorのほうが多い。名前としては、ほかには InvalidHoge とか、NoHogeFoundとかもある。RuntimeErrorは、動作中の停止なので停止理由を動詞の過去形で書くのが多いようだ。例えばHerokuのCLIのgemを見たら、

class AppCrashed < RuntimeError; end
class CommandFailed  < RuntimeError; end

とRuntimeErrorを継承するシンプルな例外クラスが2つあるだけだった。シンプル。

自前で例外クラスを定義するとき、どういう風に階層化するのかという疑問もあり得る。Rubyじゃないけど、例えばこのC++のコーディングガイドには、注意深く例外クラスの階層を設計したところで利用者は誰も気にしないんだから時間の無駄だと書いてある。1つのライブラリもしくは名前空間に付いて1つの例外を定義すれば十分だ、と。Rubyでもmoduleの名前空間の階層に合わせてStandardErrorを継承することが多いようだ。

gemのトップディレクトリから以下のように調べてみた。

$ find . -iname '*rb' |xargs grep -h '^ *rescue ' | sed 's/^ *//' |sort | uniq -c |sort -nr
 718 rescue LoadError
 604 rescue Sass::SyntaxError => e
 445 rescue Exception => e
 235 rescue Sass::SyntaxError => err
 218 rescue LoadError => e
 206 rescue => e
 183 rescue Exception
 145 rescue NameError
 128 rescue ArgumentError
 125 rescue Errno::ENOENT
  89 rescue NameError => e
  60 rescue ArgumentError, TypeError
  58 rescue Interrupt
  57 rescue Exception => exception
  51 rescue NoMethodError => e
  50 rescue NoMethodError
  47 rescue Timeout::Error
  46 rescue ArgumentError => e
  36 rescue TypeError
  35 rescue Exception => ex
  33 rescue MemCache::MemCacheError => e
  32 rescue RestClient::RequestFailed => e
  32 rescue EOFError
  31 rescue LoadError => boom
  30 rescue RuntimeError => e
  29 rescue SyntaxError => e
  29 rescue NotImplementedError
  28 rescue StandardError => e
  27 rescue URI::InvalidURIError => e
  27 rescue SystemExit
  27 rescue RuntimeError
  27 rescue Errno::ECONNREFUSED
  25 rescue StandardError, ScriptError => e
  25 rescue => ex
  25 rescue ::Exception
  24 rescue TypeError, NoMethodError
  24 rescue Haml::Error => e
  22 rescue Test::Unit::AssertionFailedError => e
  22 rescue MemCache::MemCacheError
  22 rescue ::Test::Unit::AssertionFailedError => e
  21 rescue ThrowResult
  21 rescue ActionView::MissingTemplate => e
  20 rescue TZInfo::InvalidTimezoneIdentifier
  20 rescue Sass::UnitConversionError
  20 rescue EncodingError
  20 rescue ::Sass::SyntaxError => e
  19 rescue Gem::LoadError
  19 rescue => boom
  18 rescue RestClient::ResourceNotFound => e
  18 rescue InvalidNumberError => e
  18 rescue I18n::ArgumentError => e
  18 rescue Exception => database_transaction_rollback
  18 rescue Encoding::UndefinedConversionError => e
  17 rescue StandardError
  17 rescue Mysql::Error
  17 rescue LoadError => err
  16 rescue SystemCallError
  16 rescue PGError
  16 rescue Errno::ENOENT => e
  15 rescue Timeout::Error => e
  15 rescue Errno::ESRCH
  15 rescue Errno::EPIPE
  15 rescue ChildProcess::TimeoutError
  15 rescue ActiveRecord::StatementInvalid
  14 rescue SetupError
  14 rescue Errno::EACCES
  13 rescue IndexError
  12 rescue RuntimeError; end
  12 rescue LoadError => load_error
  12 rescue FSSM::FileNotRealError => e
  12 rescue Exception => e # errors from template code
  12 rescue Errno::EINPROGRESS
  12 rescue ActiveRecord::RecordNotFound
  11 rescue StandardError # JRuby
  11 rescue OpenSSLCipherError, TypeError
  11 rescue Object => e
  11 rescue NoMethodError then raise
  11 rescue NoMethodError                                # rescue NoMethodError
  11 rescue Mocha::ExpectationError => e
  11 rescue LocalJumpError
  11 rescue Exception => exception  # errors from loading file
  11 rescue ArgumentError => argument_error
  11 rescue ActiveRecord::StatementInvalid => exception
  11 rescue => frozen_object_error
  11 rescue => error
  11 rescue ::TZInfo::PeriodNotFound
  11 rescue ::NoMethodError
  10 rescue java.io.IOException => ex
  10 rescue TypeError, LoadError => e
  10 rescue ServerError => e
  10 rescue Sass::SyntaxError
  10 rescue Mysql::Error => e
  10 rescue MemCache::MemCacheError, Errno::ECONNREFUSED
  10 rescue Heroku::OkJson::Error
  10 rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError
  10 rescue EOFError, TypeError, ArgumentError, LoadError => e
  10 rescue EOFError, TypeError, ArgumentError => e
  10 rescue ArgumentError # Encoding charset + endianness doesn't exist
  10 rescue *NOT_CONNECTED_ERRORS
  10 rescue 
   9 rescue ZipError
   9 rescue UnauthorizedAccess => e
   9 rescue Thor::Error, LoadError, Errno::ENOENT => e
   9 rescue ResourceInvalid => error
   9 rescue RescuableException => e
   9 rescue RescuableException
   9 rescue OpenSSL::SSL::SSLError => e
   9 rescue MissingSourceFile => e
   9 rescue LoadError, NameError => const_error
   9 rescue IOError
   9 rescue FixtureClassNotFound
   9 rescue Exception => failsafe_error
   9 rescue Exception => err
   9 rescue Exception => e # YAML, XML or Ruby code block errors
   9 rescue Errno::EWOULDBLOCK, Errno::EAGAIN
   9 rescue Errno::EEXIST
   9 rescue ActiveSupport::MessageVerifier::InvalidSignature
   9 rescue ActiveResource::ResourceNotFound, ActiveResource::ResourceGone
   9 rescue ActiveResource::ResourceNotFound
   9 rescue ActiveRecord::RecordInvalid
   9 rescue *RESCUE_ERRORS => error
   8 rescue TypeError => e
   8 rescue TimeoutError
   8 rescue SocketError, Errno::EADDRNOTAVAIL
   8 rescue RSpec::Mocks::MockExpectationError => e
   8 rescue Pry::RescuableException
   8 rescue NoMethodError, TypeError
   8 rescue LoadError, NameError
   8 rescue ImportError => e
   8 rescue I18n::ArgumentError
   8 rescue Heroku::API::Errors::RequestFailed => e
   8 rescue ::Timeout::Error => e
   8 rescue ::Exception => e
   8 rescue *PASSTHROUGH_EXCEPTIONS => e
   7 rescue SyntaxError
   7 rescue SwiftError => e
   7 rescue Sprockets::FileOutsidePaths
   7 rescue RestClient::Unauthorized
   7 rescue RestClient::BadRequest => e
   7 rescue Racc::ParseError => e
   7 rescue PGError => e
   7 rescue OptionParser::InvalidOption => ex
   7 rescue OpenURI::HTTPError
   7 rescue Netrc::Error
   7 rescue LoadError; end
   7 rescue Exception => error
   7 rescue Errno::ENOTEMPTY
   7 rescue Errno::ECHILD, Errno::ESRCH
   7 rescue Encoding::ConverterNotFound => _
   7 rescue ActionView::MissingTemplate
   7 rescue ::MultiJson::DecodeError => e
   7 rescue *RESCUE_ERRORS
   6 rescue ZeroDivisionError
   6 rescue URI::InvalidURIError
   6 rescue Thor::Error => e
   6 rescue SystemCallError => exception
   6 rescue RestClient::RequestTimeout
   6 rescue Rack::Test::Error
   6 rescue OpenSSL::SSL::SSLError => error
   6 rescue LoadError => ignore
   6 rescue Journey::Router::RoutingError
   6 rescue Johnson::Error => e
   6 rescue JSON::ParserError => e
   6 rescue JSON::NestingError
   6 rescue IOError, Errno::EPIPE
   6 rescue I18n::MissingTranslationData => exception
   6 rescue Heroku::Client::AppCrashed => e
   6 rescue Haml::SyntaxError => e
   6 rescue Exception  # errors from Marshal or YAML
   6 rescue Errno::EPERM => e
   6 rescue Errno::ENOSPC
   6 rescue Errno::ENOENT, IOError
   6 rescue Errno::ENOENT, Errno::ELOOP
   6 rescue Errno::EINVAL
   6 rescue Errno::ECHILD
   6 rescue Errno::EAGAIN
   6 rescue Errno::EADDRINUSE
   6 rescue Dalli::DalliError
   6 rescue DRb::DRbConnError
   6 rescue CallbackError => e
   6 rescue ArgumentError # if Date.new raises an exception on an invalid date
   6 rescue ::V8::JSError => e
   5 rescue java.lang.IllegalThreadStateException
   5 rescue Win32::Registry::Error
   5 rescue Timeout::Error, EOFError
   5 rescue SystemCallError => ex
   5 rescue SocketError, Errno::EADDRINUSE, Errno::EBADF => ex
   5 rescue SocketError, Errno::EADDRINUSE
   5 rescue ScriptError, StandardError => e
   5 rescue RestClient::SSLCertificateNotVerified => ex
   5 rescue RestClient::Locked => ex
   5 rescue RestClient::BadGateway => e
   5 rescue RSpec::Expectations::ExpectationNotMetError => last_error
   5 rescue ProtocolError => why
   5 rescue OptionParser::InvalidOption => e
   5 rescue Object
   5 rescue Nokogiri::XML::XPath::SyntaxError => e
   5 rescue NameError, ArgumentError => e
   5 rescue Interrupt => interrupt
   5 rescue Heroku::Command::CommandFailed
   5 rescue Gem::LoadError => e
   5 rescue Errno::EISCONN
   5 rescue Errno::EIO
   5 rescue Errno::ECONNREFUSED, Errno::ECONNRESET, OpenSSL::SSL::SSLError
   5 rescue Errno::ECONNABORTED, Errno::ECONNRESET, Errno::EADDRINUSE
   5 rescue Dalli::DalliError => e
   5 rescue ConnectionError
   5 rescue CommandFailed => e
   5 rescue ChildProcess::MissingPlatformError => ex
   5 rescue ChildProcess::Error
   5 rescue Bundler::BundlerError => e
   5 rescue => err
   5 rescue ::OAuth::Unauthorized => e
   5 rescue *exceptions => ex
   5 rescue *QUIT_ERRORS
   5 rescue *CONNECTED_ERRORS
   4 rescue Utf8Error
   4 rescue Undefined => e
   4 rescue TypeError, ArgumentError
   4 rescue SystemCallError, SocketError => e
   4 rescue SignalException => e
   4 rescue Sequel::DatabaseDisconnectError
   4 rescue RuntimeException => e
   4 rescue RSpec::Mocks::MockExpectationError => error
   4 rescue RSpec::Expectations::ExpectationNotMetError
   4 rescue PostTooBigException
   4 rescue Pending::PendingDeclaredInExample => e
   4 rescue OAuth::Unauthorized => e
   4 rescue Nokogiri::XML::XPath::SyntaxError
   4 rescue Nokogiri::SyntaxError, RuntimeError
   4 rescue NoMethodError, ArgumentError
   4 rescue NewRelic::Command::CommandFailure => e
   4 rescue NewRelic::Agent::ForceRestartException => e
   4 rescue Net::LDAP::LdapError
   4 rescue NameError => predicate_missing_error
   4 rescue MultiJson::DecodeError => de
   4 rescue Memcached::NotFound
   4 rescue Mail::Field::ParseError => e
   4 rescue LoadError # 1.8 support
   4 rescue Iconv::InvalidEncoding => e
   4 rescue Iconv::IllegalSequence, Iconv::InvalidEncoding, Errno::EINVAL
   4 rescue Iconv::IllegalSequence => e
   4 rescue I18n::ArgumentError => exception
   4 rescue Heroku::API::Errors::NotFound, Heroku::API::Errors::Unauthorized => e
   4 rescue Heroku::API::Errors::NotFound => e
   4 rescue Heroku::API::Errors::ErrorWithResponse => e
   4 rescue GemNotFound => e
   4 rescue Gem::RemoteFetcher::FetchError
   4 rescue Gem::GemNotFoundException
   4 rescue FFI::NotFoundError
   4 rescue Exception => raised
   4 rescue Exception => e # Net::SMTP errors or sendmail pipe errors
   4 rescue Exception => @actual_error
   4 rescue Error::NoSuchElementError => last_error
   4 rescue Errno::EWOULDBLOCK
   4 rescue Errno::ESRCH => e
   4 rescue Errno::EHOSTDOWN
   4 rescue Errno::EAGAIN, Errno::EINTR
   4 rescue Errno::EACCES => e
   4 rescue EncodingFound => e
   4 rescue CmdException, OptionParser::ParseError => e
   4 rescue Capistrano::ConnectionError => e
   4 rescue Capistrano::CommandError => e
   4 rescue Bundler::GemNotFound => e
   4 rescue @expected_exception => @rescued_exception
   4 rescue @expected_error => @actual_error
   4 rescue => other_exception
   4 rescue ::Timeout::Error, ::Errno::ETIMEDOUT => e
   4 rescue ::Rhino::JavascriptError => e
   4 rescue ::Net::HTTPFatalError => e
   4 rescue ::Memcached::NotFound
   4 rescue ::Kramdown::Error
   4 rescue ::Exception # work around a bug in ruby 1.9
   4 rescue ::Exception   # for example on EPERM (process exists but does not belong to us)
   3 rescue get_exception(arg)
   3 rescue get_exception(0), NameError
   3 rescue Zlib::BufError
   3 rescue ValueError, msg:
   3 rescue V8::JSError => e
   3 rescue SystemExit => se
   3 rescue StandardError, ScriptError
   3 rescue StandardError, LoadError, SyntaxError => e
   3 rescue Sprockets::FileNotFound, Sprockets::ContentTypeMismatch
   3 rescue Spec::Expectations::ExpectationNotMetError => e
   3 rescue SignalException
   3 rescue ServerError => why
   3 rescue Sequel::NotImplemented
   3 rescue SecurityError
   3 rescue RuntimeError => why
   3 rescue Rhino::JSError => e
   3 rescue RestClient::Locked => e
   3 rescue Rack::Mount::RoutingError
   3 rescue RSpec::Expectations::ExpectationNotMetError => e
   3 rescue OptionParser::ParseError => ex
   3 rescue OptionParser::ParseError => err
   3 rescue OptionParser::ParseError => e
   3 rescue NotFoundError => e
   3 rescue NoMethodError, Haml::Util.av_template_class(:Error)
   3 rescue Net::SSH::AuthenticationFailed
   3 rescue NativeException, JavaSQL::SQLException => e
   3 rescue NamespaceMissingError
   3 rescue NameError, LoadError => e
   3 rescue MissingTranslationData
   3 rescue Message::KeyNotFound => why
   3 rescue Magick::ImageMagickError
   3 rescue LoadError; end # RCov doesn't see this, but it is run
   3 rescue LoadError, SyntaxError => ex
   3 rescue LoadError, NameError => error
   3 rescue LoadError, NameError => e
   3 rescue LoadError => ignore_if_database_cleaner_not_present
   3 rescue LoadError => error
   3 rescue LoadError => cannot_require
   3 rescue LoadError # => rubygems_not_installed
   3 rescue LoadError # => gem_not_installed
   3 rescue InvalidValue
   3 rescue InvalidURIError, TypeError
   3 rescue Interrupt => e
   3 rescue I18n::MissingTranslationData
   3 rescue Haml::Util.av_template_class(:Error) => e
   3 rescue ForwardRequest => req
   3 rescue FileNotFound
   3 rescue ExecutionError => e
   3 rescue Excon::Errors::NotFound
   3 rescue Exception => exp
   3 rescue Exception => e  # errors from Marshal or YAML
   3 rescue Exception =>  e
   3 rescue Errno::ETIMEDOUT
   3 rescue Errno::ESPIPE
   3 rescue Errno::EOPNOTSUPP
   3 rescue Errno::ENOENT, Errno::ENOTDIR
   3 rescue Errno::EAGAIN, Errno::EWOULDBLOCK, IO::WaitWritable
   3 rescue Errno::EAGAIN, Errno::EWOULDBLOCK, IO::WaitReadable
   3 rescue Encoding::ConverterNotFoundError => _
   3 rescue Bundler::RubyVersionMismatch => e
   3 rescue ArgumentError, NameError
   3 rescue ArgumentError => ex # if Date.new raises an exception on an invalid date
   3 rescue AgentProxyException => e
   3 rescue => socket_error
   3 rescue => request_error
   3 rescue => details
   3 rescue ::OptionParser::ParseError => pe
   3 rescue ::Haml::Error => e
   3 rescue ::Excon::Errors::SocketError
   3 rescue ::Exception => boom
   3 rescue *exceptions => @rescued_exception
   3 rescue # else leave as is
   2 rescue parser.parse_error => error
   2 rescue engine::ParseError => exception
   2 rescue adapter::ParseError => exception
   2 rescue YARD::Parser::UndocumentableError => err
   2 rescue UnserializeError
   2 rescue Timeout::Error, StandardError => e
   2 rescue Timeout::Error, StandardError
   2 rescue Timeout::Error, NewRelic::Agent::ServerConnectionException
   2 rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, EOFError,
   2 rescue Timeout::Error => err
   2 rescue TemplateOperatorAbortedError
   2 rescue Taps::CorruptedData => e
   2 rescue Tags::TagFormatError
   2 rescue SystemExit, NoMemoryError, SignalException
   2 rescue SystemExit => ex
   2 rescue SystemExit => e
   2 rescue SystemCallError => er
   2 rescue SyntaxError, RegexpError
   2 rescue SyntaxError, NoMethodError
   2 rescue SyntaxError, NameError
   2 rescue SyntaxError => ex
   2 rescue StandardError, Timeout::Error
   2 rescue StandardError => ex
   2 rescue StandardError => err
   2 rescue Spork::TestFramework::NoFrameworksAvailable => e
   2 rescue Spork::TestFramework::FactoryException => e
   2 rescue SocketError
   2 rescue Server::ProtocolError => err
   2 rescue Sequel::InvalidValue => e
   2 rescue ScriptError, StandardError => error
   2 rescue ScriptError, RegexpError, NameError, ArgumentError => e
   2 rescue Sass::SyntaxError => keyword_exception
   2 rescue RuntimeError => err
   2 rescue RunnerError => e
   2 rescue RubyPython::PythonError => exc
   2 rescue RestClient::Unauthorized, Heroku::API::Errors::Unauthorized
   2 rescue RestClient::RequestTimeout, Heroku::API::Errors::Timeout
   2 rescue RestClient::Request::Unauthorized => e
   2 rescue RestClient::PaymentRequired, Heroku::API::Errors::VerificationRequired => e
   2 rescue RestClient::Locked, Heroku::API::Errors::Locked => e
   2 rescue RestClient::Exception
   2 rescue RemoteError => error
   2 rescue RbSupport::NilWorld => e
   2 rescue Rack::AdapterNotFound => e
   2 rescue Racc::ParseError, Regin::Parser::ScanError
   2 rescue QuestionError
   2 rescue Question::NoAutoCompleteMatch
   2 rescue PythonError, NoMethodError
   2 rescue ProfilesNotDefinedError, YmlLoadError, ProfileNotFound => e
   2 rescue PluginHost::PluginNotFound
   2 rescue Pending => e
   2 rescue PathError, GitError
   2 rescue PGError, NoMethodError
   2 rescue OptionParser::ParseError
   2 rescue OptionParser::InvalidArgument, OptionParser::InvalidOption => e
   2 rescue OpenSSL::PKey::RSAError, OpenSSL::PKey::DSAError, OpenSSL::PKey::ECError => e
   2 rescue OpenSSL::PKey::ECError => e
   2 rescue OpenID::DiscoveryFailure => e
   2 rescue Object # Ignore since ObjectSpace might not be loaded on JRuby
   2 rescue NotSupportedByDriverError
   2 rescue NotImplementedError => ex
   2 rescue NoMethodError => pre_cucumber_0_4 # REMOVE WHEN SUPPORT FOR PRE-0.4 IS DROPPED
   2 rescue NilWorld => e
   2 rescue NewRelic::Command::CommandFailure => c
   2 rescue NewRelic::Command::CommandFailure
   2 rescue NewRelic::Agent::ServerConnectionException => e
   2 rescue NewRelic::Agent::Sampler::Unsupported => e
   2 rescue NewRelic::Agent::LicenseException => e
   2 rescue NewRelic::Agent::ForceRestartException, NewRelic::Agent::ForceDisconnectException
   2 rescue NewRelic::Agent::ForceDisconnectException => e
   2 rescue Net::HTTPBadResponse => e
   2 rescue Net::HTTP::Persistent::Error => error
   2 rescue NameError => name_error
   2 rescue LoadError, NotImplementedError
   2 rescue LoadError => try_rspec_1_2_4_or_higher
   2 rescue LoadError => try_rspec_1
   2 rescue LoadError => loadError
   2 rescue LoadError => give_up
   2 rescue LoadError => ex
   2 rescue LoadError                  # If we're not on Windows try...
   2 rescue LoadError
   2 rescue InvalidRequest => e
   2 rescue Interrupt, StandardError, SystemExit => error
   2 rescue Interrupt, IOError
   2 rescue IOError, EOFError, Timeout::Error,
   2 rescue IOError => e
   2 rescue Heroku::API::Errors::VerificationRequired, RestClient::PaymentRequired => e
   2 rescue Heroku::API::Errors::Unauthorized, RestClient::Unauthorized
   2 rescue Heroku::API::Errors::Timeout, RestClient::RequestTimeout
   2 rescue Heroku::API::Errors::Locked => e
   2 rescue HTTParty::RedirectionTooDeep, Timeout::Error => e
   2 rescue HTTPError, TypeError => e
   2 rescue GitError
   2 rescue Gherkin::Lexer::LexingError, Gherkin::Parser::ParseError => e
   2 rescue GemNotFound, VersionConflict
   2 rescue Gem::Package::FormatError
   2 rescue Gem::InvalidSpecificationException => e
   2 rescue ForkDiedException, EOFError
   2 rescue FetchingError => why
   2 rescue Faraday::Error::ClientError
   2 rescue Excon::Errors::StubNotFound, Excon::Errors::Timeout => error
   2 rescue Excon::Errors::HTTPStatusError => error
   2 rescue Errno::EWOULDBLOCK, Errno::EAGAIN => e
   2 rescue Errno::ESRCH # No such process
   2 rescue Errno::EPIPE, Errno::ECONNRESET, Errno::ECONNREFUSED
   2 rescue Errno::EPIPE => e
   2 rescue Errno::EPERM
   2 rescue Errno::EMFILE
   2 rescue Errno::EFBIG # FreeBSD 4.9 raise Errno::EFBIG instead of Errno::EINVAL
   2 rescue Errno::EFBIG # FreeBSD 4.5 may raise Errno::EFBIG
   2 rescue Errno::ECONNRESET => e
   2 rescue Errno::ECONNREFUSED => exception
   2 rescue Errno::EBADF
   2 rescue Errno::EAGAIN, Errno::EWOULDBLOCK
   2 rescue Errno::EADDRINUSE => e
   2 rescue Errno::EACCES, Errno::ENOENT
   2 rescue EOFError => e
   2 rescue EOFError  # HighLine throws this if @input.eof?
   2 rescue DiscoveryFailure => why
   2 rescue DalliError, NetworkError => e
   2 rescue Dalli::NetworkError
   2 rescue DRbClientError => e
   2 rescue DRb::DRbConnError => e
   2 rescue Cucumber::Ast::Table::Different => e
   2 rescue Cucumber::Ast::Table::Different
   2 rescue Cucumber::ArityMismatchError => e
   2 rescue ConnectionError => error
   2 rescue Compass::Error
   2 rescue CommandOptionError => ex
   2 rescue Capybara::TimeoutError; end
   2 rescue Capybara::ExpectationNotMet
   2 rescue Capistrano::NoMatchingServersError, Capistrano::NoSuchTaskError => error
   2 rescue Capistrano::Error => error
   2 rescue Capistrano::CommandError
   2 rescue BundlerError
   2 rescue Bundler::VersionConflict => e
   2 rescue Bundler::GitError => e
   2 rescue Bundler::GemNotFound
   2 rescue ArgumentError, TypeError => e
   2 rescue ArgumentError, SyntaxError, Gem::EndOfYAMLException, Gem::Exception
   2 rescue ArgumentError, NameError => error
   2 rescue ArgumentError => why
   2 rescue ArgumentError => boom
   2 rescue Ambiguous => e
   2 rescue ActiveRecord::StatementInvalid => e
   2 rescue ActiveRecord::ActiveRecordError => e
   2 rescue => foo
   2 rescue ::WIN32OLERuntimeError => e
   2 rescue ::SocketError => e
   2 rescue ::Rhino::JSError => e
   2 rescue ::Patron::TimeoutError => err
   2 rescue ::Object
   2 rescue ::ODBC::Error, ArgumentError => e
   2 rescue ::OAuth2::HTTPError, ::OAuth2::AccessDenied, CallbackError => e
   2 rescue ::OAuth2::HTTPError => e
   2 rescue ::OAuth2::Error, CallbackError => e
   2 rescue ::NoMethodError, ::MultiJson::DecodeError => e
   2 rescue ::Net::HTTPFatalError, ::OpenSSL::SSL::SSLError => e
   2 rescue ::Memcached::NotStored
   2 rescue ::GrowlNotify::GrowlNotFound
   2 rescue ::Errno::ETIMEDOUT
   2 rescue ::DataObjects::Error => e
   2 rescue *NET_HTTP_EXCEPTIONS
   2 rescue *HTTP_ERRORS => e
   2 rescue *EXCEPTIONS => e
   1 rescue stub_exception_class
   1 rescue exception => @rescued_exception
   1 rescue error => ex
   1 rescue Zlib::GzipFile::Error
   1 rescue Zlib::Error
   1 rescue Zlib::DataError
   1 rescue Yadis::XRDSError => why
   1 rescue Yadis::XRDSError
   1 rescue XRDSError => err
   1 rescue WebSocketError => e
   1 rescue UndocumentableError => err
   1 rescue URIClassifier::RegistrationError => e
   1 rescue URI::InvalidURIError => why
   1 rescue URI::InvalidURIError => err
   1 rescue URI::Error => why
   1 rescue URI::Error => e
   1 rescue TypeURIMismatch
   1 rescue TypeError
   1 rescue Timeout::Error => why
   1 rescue Test::Unit::AssertionFailedError => err
   1 rescue TerminateLineInput
   1 rescue Taps::DuplicatePrimaryKeyError => e
   1 rescue TagFormatError
   1 rescue SystemStackError
   1 rescue SystemCallError; end
   1 rescue SystemCallError, TypeError
   1 rescue SystemCallError, Timeout::Error, EOFError, SocketError
   1 rescue SystemCallError, Timeout::Error, EOFError
   1 rescue SystemCallError, Timeout::Error
   1 rescue SyntaxError => e2
   1 rescue StopServer
   1 rescue Sprockets::FileNotFound
   1 rescue SocketError => err
   1 rescue Slim::Parser::SyntaxError => ex
   1 rescue Server::UntrustedReturnURL => err
   1 rescue Server::MalformedTrustRoot => why
   1 rescue Sequel::UndefinedAssociation
   1 rescue Sequel::Rollback
   1 rescue Sequel::DatabaseError=>e
   1 rescue Sequel::DatabaseError => e
   1 rescue Sequel::DatabaseError
   1 rescue Sequel::DatabaseConnectionError
   1 rescue Selenium::WebDriver::Error::WebDriverError
   1 rescue Selenium::WebDriver::Error::UnhandledError => e
   1 rescue SQLite3::Exception => e
   1 rescue RuntimeError => exc
   1 rescue RuntimeError => ex
   1 rescue RubyVersionMismatch => e
   1 rescue RubyPython::PythonError => e
   1 rescue RestClient::PaymentRequired => e
   1 rescue RestClient::Exception, Taps::BaseError => e
   1 rescue ResponseError => error
   1 rescue Redis::CannotConnectError
   1 rescue RealmVerificationRedirected => err
   1 rescue RangeError
   1 rescue Rack::Lint::LintError => e
   1 rescue RDoc::RubyLex::Error
   1 rescue RDoc::Markup::Parser::Error => e
   1 rescue RDoc::Error
   1 rescue PythonError => exc
   1 rescue PythonError => e
   1 rescue Psych::SyntaxError
   1 rescue ProtocolError => e
   1 rescue Polyglot::NestedLoadError => e
   1 rescue ParserSyntaxError => e
   1 rescue ParserError
   1 rescue Parser::UndocumentableError => undocerr
   1 rescue Parser::ParserSyntaxError
   1 rescue PGError =>e
   1 rescue OptionParser::InvalidOption
   1 rescue OpenURI::HTTPRedirect => e
   1 rescue OpenURI::HTTPError => e
   1 rescue OpenSSL::SSL::SSLError => why
   1 rescue OpenSSL::PKey::PKeyError
   1 rescue OpenIDError => why
   1 rescue OpenID::OpenIDError => e
   1 rescue OpenID::FetchingError => why
   1 rescue OkJson::Parser
   1 rescue Object; end }
   1 rescue Object => rails_error
   1 rescue Object => exc
   1 rescue Object => boom
   1 rescue Object => anything
   1 rescue Object # Ignore since obj.class can sometimes take parameters            
   1 rescue OCIInvalidHandle
   1 rescue OCIException => e
   1 rescue NotImplementedError, NameError
   1 rescue NotFoundError
   1 rescue NotFound => boom
   1 rescue NonMethodContextError => err
   1 rescue NoSuchKey
   1 rescue NoMethodError => no_method_error
   1 rescue NoMethodError => ex
   1 rescue NetworkError => e
   1 rescue Net::SSH::Authentication::DisallowedMethod
   1 rescue NativeException => e
   1 rescue NamespaceMissingError => missingerr
   1 rescue NamespaceAliasRegistrationError => e
   1 rescue NameError, NoMethodError
   1 rescue NameError => ne
   1 rescue NameError => ex
   1 rescue Mongo::ConnectionFailure
   1 rescue MissingTranslationData => exception
   1 rescue Message::KeyNotFound, ArgumentError => why
   1 rescue LoadError, RuntimeError => e
   1 rescue LoadError, RuntimeError
   1 rescue LoadError, ArgumentError => error
   1 rescue LoadError => load_exception
   1 rescue LoadError => library_not_installed
   1 rescue LoadError => e2
   1 rescue LoadError => e 
   1 rescue LoadError # try rspec 1
   1 rescue LoadError            # If our first choice fails, try using ffi-ncurses.
   1 rescue LoadError            # Finally, if all else fails, use stty
   1 rescue LoadError                # If the ffi-ncurses choice fails, try using stty
   1 rescue LoadError                # If our first choice fails, try using JLine
   1 rescue LoadError 
   1 rescue LibraryNotPreparedError
   1 rescue Less::ParseError => e
   1 rescue KVFormError => err
   1 rescue JSON::ParserError
   1 rescue InvalidOpenIDNamespace => e
   1 rescue InvalidOpenIDNamespace
   1 rescue Interrupt, StandardError, RDoc::Error, SystemStackError => e
   1 rescue InternalError, RequestTimeout
   1 rescue IncompleteExpression
   1 rescue ImageMagickError
   1 rescue IOError => e                                 
   1 rescue HttpParserError => e
   1 rescue Heroku::Plugin::ErrorUpdatingSymlinkPlugin
   1 rescue Heroku::API::Errors::Error
   1 rescue HandshakeError => e
   1 rescue HTTPStatusError => why
   1 rescue HTTPRedirectLimitReached => e
   1 rescue HTMLTokenizerError # just stop parsing if there's an error
   1 rescue Gem::LoadError, LoadError, RuntimeError
   1 rescue Gem::LoadError => load_error
   1 rescue Gem::Exception
   1 rescue Gem::DocumentError => e
   1 rescue Foreman::Export::Exception => ex
   1 rescue FloatDomainError
   1 rescue FinishRequest
   1 rescue FilterNotFound
   1 rescue FSSM::CallbackError => e
   1 rescue Excon::Errors::StubNotFound => stub_not_found
   1 rescue Excon::Errors::SocketError => error
   1 rescue Excon::Errors::Error => error
   1 rescue Exception=>exception
   1 rescue Exception, OpenSSL::OpenSSLError => e 
   1 rescue Exception => why
   1 rescue Exception => e  # errors from ActiveRecord setup
   1 rescue Exception # just stop parsing if there's an error
   1 rescue EventMachine::ConnectionError => e
   1 rescue Error => e
   1 rescue Error
   1 rescue Errno::EPIPE, Timeout::Error, Errno::EINVAL, EOFError
   1 rescue Errno::EPIPE, RestClient::RequestFailed, RestClient::RequestTimeout
   1 rescue Errno::EPIPE, Errno::ECONNRESET
   1 rescue Errno::EPIPE => boom
   1 rescue Errno::ENOTCONN => e
   1 rescue Errno::ENOENT => e  
   1 rescue Errno::ENETUNREACH
   1 rescue Errno::EISDIR, Errno::ENOENT
   1 rescue Errno::EINVAL, Errno::EBADF
   1 rescue Errno::EDOM
   1 rescue Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED, Errno::EBADF, Errno::EINVAL => e
   1 rescue Errno::ECONNRESET, Errno::ECONNREFUSED, EOFError
   1 rescue Errno::ECONNRESET, EOFError
   1 rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
   1 rescue Errno::ECONNREFUSED, Errno::ECONNRESET, EOFError, SystemCallError, OpenURI::HTTPError, Timeout::Error => error
   1 rescue Errno::ECONNREFUSED, Errno::EBADF
   1 rescue Errno::ECONNREFUSED => ex
   1 rescue Errno::ECONNREFUSED => e
   1 rescue Errno::ECONNABORTED
   1 rescue Errno::EADDRNOTAVAIL
   1 rescue Errno::EACCES; end
   1 rescue Errno::EACCES => error
   1 rescue Errno::EACCES # unreadble file
   1 rescue EncodingFoundException => e
   1 rescue EOFError,Errno::ECONNRESET,Errno::EPIPE,Errno::EINVAL,Errno::EBADF
   1 rescue EOFError, Errno::ESPIPE
   1 rescue EOFError, Errno::ECONNRESET, Errno::ECONNREFUSED
   1 rescue EOFError, Errno::ECONNRESET
   1 rescue DatabaseError
   1 rescue Dalli::MarshalError => ex
   1 rescue Dalli::DalliError => ex
   1 rescue Dalli::DalliError # SASL auth failure
   1 rescue Conversion::UnsupportedConversion => exc
   1 rescue ContextMiss
   1 rescue ConnectionError => e
   1 rescue Compass::Error => e
   1 rescue CommandError, Slop::InvalidOptionError => e
   1 rescue CommandError
   1 rescue ChannelRequestFailed
   1 rescue ChannelOpenFailed => err
   1 rescue BeforeHookFailed 
   1 rescue BSON::InvalidObjectId, ::Mongoid::Errors::DocumentNotFound
   1 rescue BSON::InvalidObjectId
   1 rescue ArgumentError, TypeError, RangeError => error
   1 rescue ArgumentError, NotImplementedError => e
   1 rescue ArgumentError => err
   1 rescue ArgumentError # for ruby < 1.9 compat
   1 rescue ArgumentError # bad timestamp
   1 rescue ArgumentError # If HOME is relative
   1 rescue AgentNotAvailable
   1 rescue @container::ResponseError => e
   1 rescue @container::AccessDenied => e
   1 rescue => e2
   1 rescue => detail
   1 rescue => @e
   1 rescue ::StandardError => e
   1 rescue ::RestClient::Unauthorized, ::RestClient::ResourceNotFound => e
   1 rescue ::OpenID::OpenIDError, Timeout::Error => e
   1 rescue ::OmniAuth::NoSessionError => e
   1 rescue ::ODBC::Error => e
   1 rescue ::Mysql2::Error => e
   1 rescue ::LoadError
   1 rescue ::Hpricot::Error, RuntimeError, ArgumentError
   1 rescue ::Amalgalite::Error, ::Amalgalite::SQLite3::Error => e
   1 rescue ::ActionView::MissingTemplate => exception
   1 rescue ::AWS::S3::NoSuchBucket
   1 rescue *parse_error
   1 rescue *@ignored => last_error
   1 rescue # ignore failed gsub, for instance when non-utf8
   1 rescue  ::Nokogiri::SyntaxError, RuntimeError, ArgumentError
   1 rescue  # Defend against inspect not taking a parameter

LoadErrorが多いのはgemの性質から来るものかしら。

例外オブジェクトに自前メッセージや自前スタックトレースを付け加えることもできるけど、例外クラス名そのものがズバリのエラーの原因を示している、という例も多い。というか、それが多数派。

以下のようにRSpecを調べてみたら、「RSpec::Configuration::MustBeConfiguredBeforeExampleGroupsError < StandardError」なんていうのもある。なるほど。

$ find gems/rspec-*11* -name '*rb' |xargs grep -hi -C10 'class.*<.*Error'

    #       c.before(:suite) { establish_connection }
    #       c.before(:each)  { log_in_as :authorized }
    #       c.around(:each)  { |ex| Database.transaction(&ex) }
    #     end
    #
    # @see RSpec.configure
    # @see Hooks
    class Configuration
      include RSpec::Core::Hooks

      class MustBeConfiguredBeforeExampleGroupsError < StandardError; end

      # @private
      def self.define_reader(name)
        eval <<-CODE
          def #{name}
            value_for(#{name.inspect}, defined?(@#{name}) ? @#{name} : nil)
          end
        CODE
      end

--
module RSpec
  module Core
    module Pending
      class PendingDeclaredInExample < StandardError; end

      # If Test::Unit is loaed, we'll use its error as baseclass, so that Test::Unit
      # will report unmet RSpec expectations as failures rather than errors.
      begin
        class PendingExampleFixedError < Test::Unit::AssertionFailedError; end
      rescue
        class PendingExampleFixedError < StandardError; end
      end

      class PendingExampleFixedError
        def pending_fixed?; true; end
      end

      NO_REASON_GIVEN = 'No reason given'
      NOT_YET_IMPLEMENTED = 'Not yet implemented'

      # @overload pending()
--
    #       c.before(:suite) { establish_connection }
    #       c.before(:each)  { log_in_as :authorized }
    #       c.around(:each)  { |ex| Database.transaction(&ex) }
    #     end
    #
    # @see RSpec.configure
    # @see Hooks
    class Configuration
      include RSpec::Core::Hooks

      class MustBeConfiguredBeforeExampleGroupsError < StandardError; end

      # @private
      def self.define_reader(name)
        eval <<-CODE
          def #{name}
            value_for(#{name.inspect}, defined?(@#{name}) ? @#{name} : nil)
          end
        CODE
      end

--
module RSpec
  module Core
    module Pending
      class PendingDeclaredInExample < StandardError; end

      # If Test::Unit is loaed, we'll use its error as baseclass, so that Test::Unit
      # will report unmet RSpec expectations as failures rather than errors.
      begin
        class PendingExampleFixedError < Test::Unit::AssertionFailedError; end
      rescue
        class PendingExampleFixedError < StandardError; end
      end

      class PendingExampleFixedError
        def pending_fixed?; true; end
      end

      NO_REASON_GIVEN = 'No reason given'
      NOT_YET_IMPLEMENTED = 'Not yet implemented'

      # @overload pending()
--
module RSpec
  module Expectations
    if defined?(Test::Unit::AssertionFailedError)
      class ExpectationNotMetError < Test::Unit::AssertionFailedError; end
    else
      class ExpectationNotMetError < ::StandardError; end
    end
  end
end
--
require 'spec_helper'

class UnexpectedError < StandardError; end
module MatcherHelperModule
  def self.included(base)
    base.module_eval do
      def included_method; end
    end
  end

  def self.extended(base)
    base.instance_eval do
      def extended_method; end
--
module RSpec
  module Expectations
    if defined?(Test::Unit::AssertionFailedError)
      class ExpectationNotMetError < Test::Unit::AssertionFailedError; end
    else
      class ExpectationNotMetError < ::StandardError; end
    end
  end
end
--
require 'spec_helper'

class UnexpectedError < StandardError; end
module MatcherHelperModule
  def self.included(base)
    base.module_eval do
      def included_method; end
    end
  end

  def self.extended(base)
    base.instance_eval do
      def extended_method; end
--
module RSpec
  module Mocks
    # @private
    class MockExpectationError < Exception
    end
    
    # @private
    class AmbiguousReturnError < StandardError
    end
  end
end

--
require 'spec_helper'

module RSpec
  module Mocks
    describe "#any_instance" do
      class CustomErrorForAnyInstanceSpec < StandardError;end

      let(:klass) do
        Class.new do
          def existing_method; :existing_method_return_value; end
          def existing_method_with_arguments(arg_one, arg_two = nil); :existing_method_with_arguments_return_value; end
          def another_existing_method; end
          private
          def private_method; :private_method_return_value; end
        end
      end
--

      it "raises instance of submitted ArgumentError" do
        error = ArgumentError.new("error message")
        @double.should_receive(:something).and_raise(error)
        lambda {
          @double.something
        }.should raise_error(ArgumentError, "error message")
      end

      it "fails with helpful message if submitted Exception requires constructor arguments" do
        class ErrorWithNonZeroArgConstructor < RuntimeError
          def initialize(i_take_an_argument)
          end
        end

        @double.stub(:something).and_raise(ErrorWithNonZeroArgConstructor)
        lambda {
          @double.something
        }.should raise_error(ArgumentError, /^'and_raise' can only accept an Exception class if an instance/)
      end

--
module RSpec
  module Mocks
    # @private
    class MockExpectationError < Exception
    end
    
    # @private
    class AmbiguousReturnError < StandardError
    end
  end
end

--
require 'spec_helper'

module RSpec
  module Mocks
    describe "#any_instance" do
      class CustomErrorForAnyInstanceSpec < StandardError;end

      let(:klass) do
        Class.new do
          def existing_method; :existing_method_return_value; end
          def existing_method_with_arguments(arg_one, arg_two = nil); :existing_method_with_arguments_return_value; end
          def another_existing_method; end
          private
          def private_method; :private_method_return_value; end
        end
      end
--

      it "raises instance of submitted ArgumentError" do
        error = ArgumentError.new("error message")
        @double.should_receive(:something).and_raise(error)
        lambda {
          @double.something
        }.should raise_error(ArgumentError, "error message")
      end

      it "fails with helpful message if submitted Exception requires constructor arguments" do
        class ErrorWithNonZeroArgConstructor < RuntimeError
          def initialize(i_take_an_argument)
          end
        end

        @double.stub(:something).and_raise(ErrorWithNonZeroArgConstructor)
        lambda {
          @double.something
        }.should raise_error(ArgumentError, /^'and_raise' can only accept an Exception class if an instance/)
      end

--
module RSpec::Rails
  module Matchers
  end
end

begin
  require 'test/unit/assertionfailederror'
rescue LoadError
  module Test
    module Unit
      class AssertionFailedError < StandardError
      end
    end
  end
end

require 'rspec/rails/matchers/have_rendered'
require 'rspec/rails/matchers/redirect_to'
require 'rspec/rails/matchers/routing_matchers'
require 'rspec/rails/matchers/be_new_record'
require 'rspec/rails/matchers/be_a_new'
--
require 'active_support/core_ext'
require 'active_model'

module RSpec
  module Rails

    class IllegalDataAccessException < StandardError; end

    module Mocks

      module ActiveModelInstanceMethods
        # Stubs `persisted?` to return false and `id` to return nil
        # @return self
        def as_new_record
          self.stub(:persisted?) { false }
          self.stub(:id) { nil }
          self

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

Rubyではメソッドの中で定数を変更できないのはflymakeもお見通し

Rubyではメソッドの中で定数を変更できない(リフレクション系のメソッドを使わない限り、という意味で)。これは動的にエラーや例外となっているのではなくて、パーズした段階で吐き出されるエラーのようだ。

MyConst = 2

def change_const
  MyConst = 3
end

change_const
$ ruby constchange.rb
constchange.rb:4: dynamic constant assignment
  MyConst = 3
           ^

で、よく見ると、オレのEmacsのflymakeはちゃんと赤のアンダーラインを出してエラーを認識してた。

flymakeは裏で ruby -c を実行して文法をチェックしている。以下と同様。-cを付けるとRubyは実行をせずに文法エラーがないかだけをチェックする。

$ ruby -c constchange.rb 
constchange.rb:4: dynamic constant assignment
  MyConst = 3
           ^

シンタックスだけで判定できるエラーだから当たり前か。

以下のようにするとシンタックスはオッケーでも実行するとビミョウに怒られる。このことからも、最初の例で単に定数を変更しようとしたから怒られたわけではなくて、Rubyでは文法としてメソッド内での定数の変更を許していないことが分かる。

MyConst = 1
MyConst = 2

def change_const
  MyConst == 3
end

change_const
$ ruby -c constchange.rb 
Syntax OK

$ ruby constchange.rb 
constchange.rb:2: warning: already initialized constant MyConst