Ruby の Hash#select, reject のブロックパラメーター

Ruby のハッシュの select, reject などのメソッドについて,公式リファレンスにも載っていない「あれ?」と思った仕様に気付いたので。


おさらい

まず,Hash の selectreject がどんなメソッドなのかを振り返る。

Ruby 1.8 時代は仕様が違ったり,Ruby 2.1 の頃に黒歴史があったりしたが,この記事では最近のバージョンだけを考慮することにする。とりあえず Ruby 2.6 だ。(現時点で公式サポートが継続している Ruby 2.4 以降なら同じだと思う)

Hash には Enumerable が include してあるが,selectreject は Hash で独自に実装されており,Enumerable#selectEnumerable#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 を複製し」とあるが,これは間違いではないかと思う。selectreject もデフォルト値は引き継がれないし,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#selectHash#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 のほうであったか。





  1. ブロックが持つブロックパラメーターの数をそのブロックの arityアリティー という。一般にコンピューター用語で,関数などの引数が 1 個,2 個,3 個であることを unary, binary, ternary という。arity はここから作られた単語で,関数などの引数の個数のこと。Ruby ではメソッド側が,与えられたブロックのアリティーを知る手段を持っている。「有あり体ていに申せ」ってね。