と、こんな表題で書くからには当然、「等価ではないこともある」と言いたいわけだが、その前に一般的な話をおさらいしておこう。
Ruby では表題の通り、 method1 { |e| e.method2 }
と method1(&:method2)
は基本的に等価だとされる。こんな感じだ:
[1,2,3].map { |e| e.to_s } # => ["1", "2", "3"]
[1,2,3].map(&:to_s) # => ["1", "2", "3"]
どちらの書き方が良いのか、というのは人やプロジェクトなどによりけりではあるが、後者のメリットのひとつとして「ブロック内の変数を命名しなくて良い」というものがある。我々ソフトウェアエンジニアにとって命名とは一般的に重労働であり、その労働力はできればもっと重要な箇所で使いたい。そんなわけで我々としてはできれば後者の記法を利用していきたいわけである。
ところが表題の通り、この二つは必ずしも同じ挙動になるとは限らない。実際に二つほど発見したので紹介しよう。
ひとつは Refinement の対象となる場合。実例を見てみよう。
module FixnumEx
refine Fixnum do
def to_s
"X"
end
end
end
p [1,2,3].map { |e| e.to_s }
p [1,2,3].map(&:to_s)
using FixnumEx
p [1,2,3].map { |e| e.to_s }
p [1,2,3].map(&:to_s)
# Result:
# ["1", "2", "3"]
# ["1", "2", "3"]
# ["X", "X", "X"]
# ["1", "2", "3"]
例は実に無意味な Refine だが、まあともあれ Fixnum#to_s
をちょいと Hack して必ず "X"
となるように変更するのが目的だ。using
を行う前は双方共にオリジナルの to_s
が参照される。これは期待通りの挙動である。ところが、using
を行った後の &
付き呼び出しでは Refine されていない。「仕様通り」の挙動ではあるが、期待通りではないこともあるだろう。
これは Refinement が厳密に Lexical scope で適用されることによる。前者のブロックを明示する形式では、 to_s
呼び出しの Lexical scope は Refinement の影響下にある。従って "X"
になる Hack が適用されるわけだ。一方、 &
をつけたシンボルを渡す形式において、この to_s
はあくまでシンボルであり、メソッド呼び出し「ではない」。
つまりコトの問題は Refinement が字句レベルで該当メソッドの呼び出しなのかどうかを判断していることで、これは次のようにしてみるとはっきりする。
using FixnumEx
p [1,2,3].map { |e| e.send(:to_s) }
# => ["1", "2", "3"]
この場合でもここの字句レベルにおいてはあくまで send
の呼び出しであって、 to_s
の呼び出しではない。だから Refinement も適用されないのだ。一見、同じに見えることでも、書き方によって実際の挙動が異なるということに気を付けなければならない。
他にも挙動が変わる例があって、それはメソッドが受け取ったブロックの arity
によって挙動を変える場合だ。また例を挙げる:
def arity_check(&block)
yield block.arity
end
p arity_check { |e| e.to_s }
p arity_check(&:to_s)
# Result:
# "1"
# "-1"
こうしてみると、受け取ったブロックの arity
によって全体の挙動が変わってしまうような実装をするべきではないと言えそうではあるが、いくら「べきではない」と言ったところで、どこの誰がどんなふうに呼び出すか分からないライブラリ設計ではそう簡単な話ではないことは想像に難くない。 arity
を調べて挙動を変えるべきだと判断されるケースもないとは言い切れず、その場合は呼び出す側が注意せざるを得ないのが現実である。
一般的に等価とみなされる書き換えでも、一部等価にはならないパターンが存在することが分かった。Lint ツールなどが「書き換えできるよ」と指摘してくれることもあるが、安易に指摘通りに変更を加えてしまうとバグの原因になるかもしれないことは心に留めておくと良さそうである。