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

概要

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

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

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

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の基本の意味は「蓄積するもの」で、コンピュータ方面では「累算器」やカタカナの「アキュムレーター」などと訳されます。

このコードを初めて読む人には少々込み入って見えるので、いくつかの部分に分解します。

[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で関数型プログラミング#1: ステート(翻訳)

Rails: 提案「コントローラから`@`ハックを消し去ろう」(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ