Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: Enumerableをreduceで徹底理解する#1 基本編(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。


  • 2018/07/17: 初版公開
  • 2021/02/03: 更新

Ruby: Enumerableをreduceで徹底理解する#1 基本編(翻訳)

訳注

原文ではRubyのメソッドを「function」と表記しています。本文中でこれらを(おそらく通常のプリミティブなメソッドと区別する意味で)「高階関数」と呼んでいることから、それに従って本記事では原文に沿って「関数」と表記します。

Enumerableで使える関数のうち、多くのRubyistたちの間で理解がなかなか進んでいないのがreduceです。「これって合計取るぐらいしか使わないよね?」なお、Ruby 2.4以降ならsumで同じことができるようになりました。するとreduceは今や用無しになってしまったのでしょうか?

reduceには秘密があります。「Enumerableの他の関数は、すべてreduceで実装できる」のです。そう、最後にやったことを返すのです。

訳注

reduceには「還元」「削減」「約分」「通分」「値下げ」など多様な意味があり、原文のこの部分では主に「還元」の意味にかかっています。

詳しく見ていく前に、Rubyに備わっている他の高階関数の動作をざっと見てみましょう。

map

まずはmapから。mapEnumerableなコレクションに関数を適用するのに使われます。より一般化して言えば、この関数がどの項目にも個別に適用され、最終的に新しいArrayを1つ得られます。

[1,2,3].map { |i| i * 2 }
# => [2,4,6]

ここでご注意いただきたいのは、mapは元のarray(レシーバー)を改変しないという点です。返されるのは新しいarrayです。

select

selectmapと似ていますが、関数を述語(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]というリストがあり、それを初期値0reduceしようというのです。

{ |accumulator, i| accumulator + i }

そこにブロックを1つ渡しています。このブロックはaccumulatoriという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)

mapreduceで実装する

ここまでの知識を元に、どうやってmapreduceで実装すればよいでしょうか?

ここで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]

selectreduceで実装する

同じく、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]

findreduceで実装する

しかしこの動作は、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があるとどうなるでしょうか?ここでは単にbreakreduceのループから脱出しています。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_compactmap_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を構成するものをどのように定義すればよいのでしょうか?今はわからなくとも、おそらくそれはnilfalseではなく、空のリストかゼロを使うことになりそうです。

まとめ

本記事はシリーズ第1回です。次回ではBooleanやNo-Op関数を扱います。

注意

(関数の)合成(composition)などのトピックについては今後の記事で取り上げます。私の元ネタをご存知の方や勘の鋭い方向けに、Dr. River Songの箴言を引用します。

「ネタバレ注意」

関連記事

Ruby: Enumerableを`reduce`で徹底理解する#2 — No-OpとBoolean(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。