Ruby: Enumerableをreduce
で徹底理解する#1 基本編(翻訳)
訳注
原文ではRubyのメソッドを「function」と表記しています。本文中でこれらを(おそらく通常のプリミティブなメソッドと区別する意味で)「高階関数」と呼んでいることから、それに従って本記事では原文に沿って「関数」と表記します。
Enumerable
で使える関数のうち、多くのRubyistたちの間で理解がなかなか進んでいないのがreduce
です。「これって合計取るぐらいしか使わないよね?」なお、Ruby 2.4以降ならsum
で同じことができるようになりました。するとreduce
は今や用無しになってしまったのでしょうか?
reduce
には秘密があります。「Enumerable
の他の関数は、すべてreduce
で実装できる」のです。そう、最後にやったことを返すのです。
訳注
reduceには「還元」「削減」「約分」「通分」「値下げ」など多様な意味があり、原文のこの部分では主に「還元」の意味にかかっています。
詳しく見ていく前に、Rubyに備わっている他の高階関数の動作をざっと見てみましょう。
map
まずはmap
から。map
はEnumerable
なコレクションに関数を適用するのに使われます。より一般化して言えば、この関数がどの項目にも個別に適用され、最終的に新しいArray
を1つ得られます。
[1,2,3].map { |i| i * 2 }
# => [2,4,6]
ここでご注意いただきたいのは、map
は元のarray(レシーバー)を改変しないという点です。返されるのは新しいarrayです。
select
select
はmap
と似ていますが、関数を述語(true
またはfalse
を表すもの)として使う点だけが異なります。true
の場合は新しいリストにその要素を含め、それ以外の場合はリストに含めません。
[1,2,3].select { |i| i > 1 }
# => [2, 3]
reduce
深掘りする前に、今回の主役であるreduce
の基本的な使い方を押さえておく必要があります。reduce
は要するに何をどんなふうに行うかご存知ですか?まずは基本的な合計値算出をやってみましょう。
[1,2,3].reduce(0) { |accumulator, i| accumulator + i }
# => 6
訳注
accumulatorの基本の意味は「蓄積するもの」で、コンピュータ方面では「累算器」やカタカナの「アキュムレータ」などと訳されます。
参考: アキュムレータ (コンピュータ) - Wikipedia
このコードを初めて読む人には少々込み入って見えるので、いくつかの部分に分解します。
[1,2,3].reduce(0)
[1,2,3]
というリストがあり、それを初期値0
でreduce
しようというのです。
{ |accumulator, i| accumulator + i }
そこにブロックを1つ渡しています。このブロックはaccumulator
とi
という2つのパラメータ(ブロックパラメータ)を取ります。accumulator
に最初に入る値は、初期値0
か、リストの最初の要素のどちらかになります。
reduce
のループを回すたびに、ループを最後に回したときの戻り値がaccumulator
にセットされます。この[1,2,3]
というリストの場合、次のように進行します。
a | i | reduceの結果
---+---+-----------
0 | 1 | 0 + 1 → 1
1 | 2 | 1 + 2 → 3
3 | 3 | 3 + 3 → 6
6 | - | -
---+---+-----------
最終的な戻り値: 6
リストの末尾まで到達すると、その結果はただちにaccumulator
に反映されます。reduce
の挙動を理解するには、同じ機能が他の言語でfoldLeft
(左に向かって畳み込む)という名前で呼ばれていることを知っておくと役に立つかもしれません。基本的に、私たちはこの[1,2,3]
というリストを、+
という演算を用いて左に向かって「畳み込んで」います。これは次のように見立てることができます。
((((0) + 1) + 2) + 3)
この丸かっこ()
たちを取っ払うこともできますが、accumulator
の新しい値の移り変わりを把握したいので、とりあえずこのままにしておきます。
お楽しみとしてですが、同じ処理をLISP言語で書いた場合と比較してもよいでしょう。
(+ (+ (+ (0) 1) 2) 3)
map
をreduce
で実装する
ここまでの知識を元に、どうやってmap
をreduce
で実装すればよいでしょうか?
ここでreduce
に隠された大きな秘密をひとつお教えしましょう。初期値の種類は何でも構わないのです。
たとえば空のarrayを1つ渡したらどうなるかおわかりでしょうか?何の問題もなくそのままスイスイ進みます。値は値であり、reduce
は値を1つ受け取るのです。
def map(list, &fn)
list.reduce([]) { |a, i| a.push(fn[i]) }
end
map([1,2,3]) { |i| i * 2 }
# => [2, 4, 6]
先ほど、「map
はある関数をリストに適用して新しいリストを得る」とご説明したことを思い出しましょう。このreduce
では関数呼び出しの結果を新しいリストにpush
し、最後にa
(accumulator)を返します。たまたまこのaccumulatorが新しいリストになったわけです。
先ほどのreduce
のときと同様、この動作をステップに分解して詳しく見てみましょう。
a | i | fn[i] | reduceの結果
--------+---+-----------+---------------
[] | 1 | 1 * 2 → 2 | [].push(2)
[2] | 2 | 2 * 2 → 4 | [2].push(4)
[2,4] | 3 | 3 * 2 → 6 | [2,4].push(6)
[2,4,6]| - | - | -
--------+---+-----------+----------------
最終的な戻り値: [2, 4, 6]
select
をreduce
で実装する
同じく、select
も割と簡単に作れます。
def select(list, &fn)
list.reduce([]) { |a, i| fn[i] ? a.push(i) : a }
end
select([1,2,3]) { |i| i > 1 }
# => [2, 3]
ここで必要なのは、関数がi
についてtrue
の場合はリストにpush
し、それ以外の場合はpush
しないでリストをそのまま返し、次のサイクルに備えるという操作だけです。
これもステップに分解して詳しく見てみましょう。
a | i | fn[i] | reduceの結果
-------+---+---------------+---------------------------
[] | 1 | 1 > 1 → false | false ? [].push(i) : []
[] | 2 | 2 > 1 → true | true ? [].push(i) : []
[2] | 3 | 3 > 1 → true | true ? [2].push(i) : [2]
[2,3] | - | - | -
-------+---+---------------+---------------------------
最終的な戻り値: [2, 3]
find
をreduce
で実装する
しかしこの動作は、find
で欲しい結果が早々に得られたらそこで処理を終了する、といった場合にはあまり向いてなさそうです。結果が出たのに処理を続行するのはいかにも馬鹿馬鹿しいですよね。そこで休憩がてら😎break
を入れてみましょう。
def find(list, &fn)
list.reduce(nil) { |_, i| break i if fn[i] }
end
find([1,2,3]) { |i| i == 2 }
# => 2
find([1,2,3]) { |i| i == 4 }
# => nil
ここではreduce
の結果をnil
にしています。というのも、蓄積された値そのものはどうでもよく、何も見つからなければnil
を返したいだけだからです。Rubyのfind
メソッドを完全に再現したいのであれば、さらに別の関数を渡さなければなりませんが、それはまたの機会にでも。
さて、break
があるとどうなるでしょうか?ここでは単にbreak
でreduce
のループから脱出しています。break
は値も返せるのがありがたい点で、必要なら途中でbreak
するときに値を渡せます。
a | i | fn[i] | reduceの結果
-----+---+----------------+------------------
nil | 1 | 1 == 2 → false | break i if false
nil | 2 | 2 == 2 → true | break i if true
-----+---+----------------+------------------
breakする場所: 2
a | i | fn[i] | reduceの結果
-----+---+----------------+------------------
nil | 1 | 1 == 4 → false | break i if false
nil | 2 | 2 == 4 → false | break i if false
nil | 3 | 3 == 4 → false | break i if false
nil | - | - | -
-----+---+----------------+------------------
最終的な戻り値: nil
関数を組み合わせる
きっと皆さんも、これらの関数を組み合わせてみたいと思ったことでしょう。map_compact
やmap_select
といった具合に、さまざまな関数を自在に組み合わせられるとしたらどうでしょう?
私たちはこのようにレデューサー(reducer)の関数にアクセスできるので、Rubyのあらゆる機能を使ってその決定を下すこともできます。
原注: 何らかの形で関数型プログラミングを嗜んでいて、思わず「(関数の)合成」(composition)と呟いた方へ: 今後の記事をお楽しみに。
それではmap_compact
を実装する方法を見てみましょう。
def map_compact(list, &fn)
list.reduce([]) { |a, i|
value = fn[i]
value.nil? ? a : a.push(value)
}
end
map_compact([1,2,3]) { |i| i > 1 ? i * 2 : nil }\
# => [4, 6]
どことなくselect
と近い感じがしませんか?
a | i | fn[i] | reduceで得られるもの
-------+---+---------------------+-------------------------------
[] | 1 | 1 > 1 : nil | nil.nil? ? [] : [].push(1)
[] | 2 | 2 > 1 : 2 * 2 : 4 | 4.nil? ? [] : [].push(4)
[4] | 3 | 3 > 1 : 3 * 2 : 6 | 6.nil? ? [4] : [4].push(6)
[4,6] | - | - | -
-------+---+---------------------+-------------------------------
最終的な戻り値: [4, 6]
というわけで、こうやって2つの関数のreduce
的な性質をうまく組み合わせられました。ここで何らかの抽象化された振る舞いを得たら面白いと思いませんか?
トランスデューサー(transducer)と呼ばれるものを使えば、さらに際立った楽しさを味わうこともできるようになります。
トランスデューサーは本シリーズの最初の記事の範疇を超えるので、今後の記事をどうぞお楽しみに。
いよいよ私たちは、「something(何かがある)を扱う方法」と「nothing(何もない)を扱う方法」に肉薄しつつあります。関数を組み合わせるときにnothingを構成するものをどのように定義すればよいのでしょうか?今はわからなくとも、おそらくそれはnil
やfalse
ではなく、空のリストかゼロを使うことになりそうです。
まとめ
本記事はシリーズ第1回です。次回ではBooleanやNo-Op関数を扱います。
注意
(関数の)合成(composition)などのトピックについては今後の記事で取り上げます。私の元ネタをご存知の方や勘の鋭い方向けに、Dr. River Songの箴言を引用します。
「ネタバレ注意」
概要
原著者の許諾を得て翻訳・公開いたします。