Ruby のハッシュの select
, reject
などのメソッドについて,公式リファレンスにも載っていない「あれ?」と思った仕様に気付いたので。
おさらい
まず,Hash の select
や reject
がどんなメソッドなのかを振り返る。
Ruby 1.8 時代は仕様が違ったり,Ruby 2.1 の頃に黒歴史があったりしたが,この記事では最近のバージョンだけを考慮することにする。とりあえず Ruby 2.6 だ。(現時点で公式サポートが継続している Ruby 2.4 以降なら同じだと思う)
Hash には Enumerable が include してあるが,select
や reject
は Hash で独自に実装されており,Enumerable#select や Enumerable#reject が使われることはない。
Hash#select
は自身の要素から特定の条件を満たす要素だけを採用した新たなハッシュを生成して返すもの。
Hash#reject
はその論理反転であり,自身の要素から特定の条件を満たす要素だけを取り除いた新たなハッシュを生成して返すもの。
いずれも条件はブロックで与える。
以下の例では,条件として,キーが 'ba'
を含み,かつ値が正であるもの,としよう。
hash = {foo: 3, bar: 4, baz: 0}
p hash.select{ |key, value| (/ba/ =~ key) && (value > 0) }
# => {:bar=>4}
p hash.reject{ |key, value| (/ba/ =~ key) && (value > 0) }
# => {:foo=>3, :baz=>0}
これで動作は一目瞭然。
公式リファレンス(Ruby 2.6.0)は以下のとおり。
reject
のほうの説明は「self を複製し」とあるが,これは間違いではないかと思う。select
も reject
もデフォルト値は引き継がれないし,Hash のサブクラスに適用してもサブクラスでなく Hash を返すから。
なお,ブロックを与えない使い方もあるが,本題に関係ないので割愛。
ブロックパラメーターは一つでも
ここからが本題。
これらのメソッドは,前節のコード例のように,ブロックパラメーターとしてキーと値の二つを使うのが普通だ。公式リファレンスでもそのような例しか載っていない。
しかし,実はブロックパラメーターを一つだけにする使い方がある。
hash = {foo: 3, bar: 4, baz: 0}
p hash.select{ |key| /ba/ =~ key }
# => {:bar=>4, :baz=>0}
p hash.reject{ |key| /ba/ =~ key }
# => {:foo=>3}
見てのとおり,ブロックパラメーターを一つにした場合,要素のキーが渡ってくる。
これに気付いたとき,「え?」と思った。
ブロックパラメーターが二つであるべきところに一つしか記述しなかったら,配列の形で渡ってくるんじゃないのか?(後述するがこの認識は間違い)
だって,Hash#each
だったら
hash = {foo: 3, bar: 4}
hash.each do |a, b|
p a, b
end
# => :foo
# => 3
# => :bar
# => 4
hash.each do |a|
p a
end
# => [:foo, 3]
# => [:bar, 4]
になるよね?
てことは,Hash#select
や Hash#reject
は与えられたブロックのパラメーターの数1で挙動を変えているのか?(次節で述べるようにこれは誤解)
特別な動作ではなかった
結論を先に言えば,ブロックパラメーターを一つだけ指定したときにキーだけが渡ってくるのは,特別な動作でも何でもなかった。だから公式リファレンスにも特に言及はなかったわけだ。
すっかり忘れていたが,メソッドから引数二つで yield
したとき,ブロックパラメーターが一つしかなかったら,yield
の最初の引数だけが渡され,二つ目は無視されるのだった。
つまりこういうこと:
def foo
yield 1, 2
end
foo{ |x| p x } # => 1
これは Ruby の yield
の仕様であって,特定のクラスとかには関係ない。
ではなぜ,私は配列が渡ってくるはずと勘違いしたのだろうか?
原因は明らかに Hash#each
の動作を見たためだが,Hash#each
の動作はどのように理解すればいいのだろうか?
どうやら,Hash#each
は,キーと値を引数として yield
しているのではなく,キーと値からなる配列を引数として yield
しているらしい。
いや,Hash#each
は Ruby でなく C で実装しているから,そもそも yield
なんか呼んでいないのだが,「Ruby コードとして表現すれば」という話である。
Ruby 2.6 の Hash#each
の実装は↓ここであるらしい。
https://github.com/ruby/ruby/blob/ruby_2_6/hash.c#L2779-L2810
私は C が読めないのでよく分からないが,ぼんやりとは分かる。
ブロックパラメーターの数で場合分けしてあるのは高速化のためのようだが,ともかく 0 個や 1 個の場合は each_pair_i
が呼ばれる。
each_pair_i
の実装は↓ここ。
https://github.com/ruby/ruby/blob/ruby_2_6/hash.c#L2762-L2767
ここに
rb_yield(rb_assoc_new(key, value));
とある。rb_assoc_new
というのは引数をつなげた配列(?)を作る関数ぽいので,やはり配列を yield
しているのだ。
ブロックパラメーターが 2 個以上のときは each_pair_i_fast
が呼ばれるが,その実装は↓ここ。
https://github.com/ruby/ruby/blob/ruby_2_6/hash.c#L2769-L2777
よく分からないが,こちらも配列を作っているぽい。
Ruby では,配列を yield
した場合,ブロックパラメーターが 1 個ならその配列がそのまま渡され,2 個なら展開されて渡される,という仕様であった。
つまりこういうこと:
def foo
yield [1, 2]
end
foo{ |a| p a }
# => [1, 2]
foo{ |a, b| p a; p b }
# => 1
# => 2
これで謎が解けた。
どちらかといえば,与えるブロックパラメーターの数について注意が必要なのは Hash#each
のほうであったか。
-
ブロックが持つブロックパラメーターの数をそのブロックの arity という。一般にコンピューター用語で,関数などの引数が 1 個,2 個,3 個であることを unary, binary, ternary という。arity はここから作られた単語で,関数などの引数の個数のこと。Ruby ではメソッド側が,与えられたブロックのアリティーを知る手段を持っている。「有り体に申せ」ってね。 ↩