やりたいこと
Enumerable#tally
はレシーバの要素を ==
で比較して、同じものが何個ずつ含まれているのかを調べ、その結果を Hash オブジェクトで返してくれます。
やりたいのは、例えば、複数の配列を通じて、同じ要素が何個ずつあるか数えたいということです。今回は、次のような ary の例で考えてみます。
ary = Array.new(200) do
Array.new(20) { rand(1..5) }
end
素朴だが効率の良くない方法
その1 ー tally
で計算した結果をマージする
ary.map(&:tally).then do |hashes|
{}.merge!(*hashes) { |_key, old, new| old + new }
end
=> {1=>759, 3=>801, 4=>833, 2=>851, 5=>756}
素朴で単純な方法ですが、Hash オブジェクトを200個も作った上で、それらをマージしなければならず、無駄です。
その2 ー ary を平坦化して tally
する
単純に考えて、二重配列を平坦化すれば、一つのレシーバになるので、これに tally
すれば上手くいくはずです。
ary.flatten.tally => {1=>759, 3=>801, 4=>833, 2=>851, 5=>756}
こんなふうにちゃんと計算できました。しかし、巨大な配列をわざわざ作るのは無駄で、美しくありません。
当然のことながら、Hash オブジェクトを使い回して、累積した計算結果が求められたら、巨大な配列を作る必要はありません。こんな感じです。
counter = Hash.new(0)
ary.each { |enum| enum.each { |e| counter[e] += 1 } }
counter
これを、Cで書かれた tally
で実現できれば最強・最速なはずです。
結果を使い回せる tally
を考えてみる。
Ruby で書くと、こんな感じです。
module Enumerable
def tally(counter = {})
each { |e| counter[e] = counter.fetch(e, 0) + 1 }
counter
end
end
Paul Graham の arc(ダウンロードはここ)を参考にしています。arc では、次のような counts
関数が定義されています。
(def counts (seq (o c (table)))
(if (no seq)
c
(do (++ (c (car seq) 0))
(counts (cdr seq) c))))
(let tbl (table)
(counts '(1 2) tbl)
(counts '(1 3) tbl)) => #hash((1 . 2) (2 . 1) (3 . 1))
計算結果が使い回せていますね。ちなみに、(o c (table))
の o
は 仮引数 c
が optional であり、(table)
を評価して作られる hash がその初期値になることを意味します。
これを Ruby で再現すると、
def counts(ary, counter = {})
if ary.empty?
counter
else
e, *rest = ary
counter[e] = counter.fetch(e, 0) + 1
counts(rest, counter)
end
となります。より Ruby 的にすると、
def counts(ary, counter = {})
ary.each do |e|
counter[e] = counter.fetch(e, 0) + 1
end
counter
end
となって、ここから先ほど書いた、あったら良いなという tally
につながっていきます。この tally
があれば、
counter = {}
ary.each { |enum| enum.tally(counter) }
counter => {1=>759, 3=>801, 4=>833, 2=>851, 5=>756}
と書けます。Cで実装されていれば、速いはずです。さらに、
ary.each_with_object({}) { |enum, counter| enum.tally(counter) }
とか
ary.each_with_object({}, &:tally)
と書けます。(最後の例では、ブロック・パラメータの順序の関係で、reduce
はできません。)
どうして Ruby は計算結果を使い回そうとしなかったのだろう。
現実に戻って、Enumerable#chain
を使う
Array#flatten
で巨大な配列を作るのはバカらしいけど、chain
で Enumerator オブジェクトを一つ作るだけであれば、はるかにスマートです。ただ、chain
を使うには、レシーバとなる Enumerable なオブジェクトが必要になります。そこで、やむなく、
ary[0].chain(*ary[1..]).tally
とすることになりますが、少し不格好です。更にやむなく、多重代入を使うことにします。
enum, *rest = ary
enum.chain(*rest).tally
スッキリ書けましたが、enum
や rest
で名前空間を汚したくありませんよね。そこで、
ary.then { |enum, *rest| enum.chain(*rest).tally }
のようにするとOKです。
ただ、ary をわざわざ分割するのはバカらしいことです。だって、ruby の内部では、rb_ary_cat
を使って、enum
と rest
をもう一度一つの配列につなぎ合わせて処理をしているからです。
それなら、いっそのこと、
[].chain(*ary).tally
にすればどうか。何と、一番スッキリした感じになりました。
「これで決まり!」と言いたいところですが、無駄に空の配列を chain しているのが気になります。
Kernel#chain(*enums)
があれば、スッキリするな〜。
chain(*ary).tally
結論
Enumerable#tally(counter = {})
や Kernel#chain(*enums)
を実装してほしい。
どなたか、もっとスマートな方法があれば、お教えください。
追記1
コメント欄で @obelisk68 さんとやり取りをしていて分かったことですが、Kernel#chain(*enums)
だと、配列の要素をわざわざスタックに展開するという無駄な処理をすることになるので、Kernel#.chain(enums)
の方が使い勝手がいいかもしれません。
全体が一つの配列になっていないのであれば、単純に Enumerable#chain(*enums)
を使えば良いわけですから。
ただ、いろいろ考えると、Enumerator オブジェクトを作るのだから、Enumerator.product
にならって
class Enumerator
def self.chain(enums)
Enumerator.new do |y|
enums.each {|enum| enum.each(&y) }
end
end
end
が良いのかな? これがあると、
Enumerator.chain(ary).tally
追記2
これも @obelisk68 さんに教えてもらった、ary.lazy.flat_map ...
の件です。
整合性とか考えていませんが、lazy なオブジェクトを返す Enumerator::Lazy.flatten
があれば、
ary.lazy.flatten.tally
とできて、良いのではないかと思いました。乱暴な書き方をすると、
class Enumerator::Lazy
def flatten
flat_map(&:itself)
end
end
的なものです。