はじめに
Ruby で,数値の配列から最小のもの,最大のものを見つけるのは簡単だ。専用のメソッドがあるから:
numbers = [3, 6, 0, 4, 7, 0, 9, 6, 2, 4]
p numbers.min # => 0
p numbers.max # => 9
p numbers.minmax # => [0, 9]
では,文字列の配列から最も短い文字列,最も長い文字列を見つけるには?
〈各要素に対し何らかの評価式を計算して,その値によって大小を決めたうえで,それが最小になるもの,最大になるもの〉を得るには Enumerable#min_by, Enumerable#max_by が使える。
両方いっぺんに得る Enumerable#minmax_by もある。
languages = ["Ruby", "R", "Python"]
p languages.min_by{ _1.length } # => "R"
p languages.max_by{ _1.length } # => "Python"
p languages.minmax_by{ _1.length } # => ["R", "Python"]
うむ,これも簡単だ。
……でいいのか? 本当に?
以下の例を見よう:
languages = ["Ruby", "Pascal", "Rust", "Python"]
p languages.min_by{ _1.length } # => "Ruby"
p languages.max_by{ _1.length } # => "Pascal"
たしかにこの四つのなかで "Ruby" は最も短いし,"Pascal" は最も長い。
しかし最短は "Rust" もそうだし,最長は "Python" もそうだ。
目的によるだろうが,多くの場合に,
- 最も短いものすべて
- 最も長いものすべて
が欲しいのではないだろうか?
Ruby の Enumerable モジュールには,「あるとよさそうなメソッド」はたいがいある。「こんなメソッドあるんじゃね?」と思ったらだいたい実際にある。
ところが,上記の目的に使えるメソッドは,なぜか無いんである。
なければ作ろう
Enumerable モジュールに以下の二つのメソッドを追加しよう。
-
minima_by1-
min_byと似ているが,評価値が最小の要素すべてを配列で返す - そのような要素が一つしかなかった場合は,もちろん長さ 1 の配列を返す
- 空配列に対しては空配列を返す
-
-
maxima_by2-
max_byと似ているが,評価値が最大の要素すべてを配列で返す - 他は
minima_byと同じ
-
「Enumerable にメソッドを追加する」といっても,組込みのモジュールである Enumerable をグローバルに変えてしまうのはお行儀の悪いやり方。
refinements という仕組みを使うと,モジュールを「特定のスコープでのみ,そのメソッドが追加された状態にする」ということができる。
さっそくコードを:
module AddMinimaByAndMaximaByToEnumerable
refine Enumerable do
def minima_by
elems_by_value = group_by{ yield _1 }
elems_by_value[elems_by_value.keys.min]
end
def maxima_by
elems_by_value = group_by{ yield _1 }
elems_by_value[elems_by_value.keys.max]
end
end
end
使うときは以下のようにする:
require_relative "add_minima_by_and_maxima_by_to_enumerable"
# メソッドを追加したいスコープで `using` する
using AddMinimaByAndMaximaByToEnumerable
languages = %w[C Ruby Pascal R Rust Python]
p languages.minima_by{ _1.length }
# => ["C", "R"]
p languages.maxima_by{ _1.length }
# => ["Pascal", "Python"]
期待通りに動作した。
ぷち解説
まず,refinements のやり方をご存知ない方には何を書いているのか非常に分かりにくいと思う。
本記事では,その説明は省くが,Enumerable をグローバルに改変してしまう以下のやり方に読み替えてもらっても構わない:
module Enumerable
def minima_by
elems_by_value = group_by{ yield _1 }
elems_by_value[elems_by_value.keys.min]
end
def maxima_by
elems_by_value = group_by{ yield _1 }
elems_by_value[elems_by_value.keys.max]
end
end
この場合,もちろん使う側のコードで
using AddMinimaByAndMaximaByToEnumerable
は書かない。
なお,minima_by メソッドの動作は,以下のコードを見れば理解しやすいと思う:
languages = %w[C Ruby Pascal R Rust Python]
elems_by_value = languages.group_by{ _1.length }
p elems_by_value
# =>
# {
# 1 => ["C", "R"],
# 4 => ["Ruby", "Rust"],
# 6 => ["Pascal", "Python"]
# }
min_value = elems_by_value.keys.min
p min_value
# => 1
p elems_by_value[min_value]
# => ["C", "R"]
改良:上位 n 位まで取れるように
ところで,Enumerable の min, max, min_by, max_by とかには,非負整数の引数を与えることができる。
引数 n を与えると,「上位最大 n 個まで」の要素を配列で返すようになる。
つまり
ary = [2, 7, 3, 1, 2, 3]
p ary.min(4)
# => [1, 2, 2, 3]
といった具合。
この例では,「小ささで上位 4 要素まで」なので,二つある 3 のうち一つしか入っていないことに注意。
我らが minima_by, maxima_by も引数 n が受け取れるようにしよう。
しかし,もちろん n は「上位何位までか」を意味する。
こんな感じに,二重配列を返す:
languages = %w[C Ruby Pascal R Rust Python]
p languages.minima_by(2){ _1.length }
# => [["C", "R"], ["Ruby", "Rust"]]
上位 1 位は ["C", "R"] であり,2 位は ["Ruby", "Rust"] なのでこうなる。
コードは以下のとおり:
module AddMinimaByAndMaximaByToEnumerable
refine Enumerable do
def minima_by(n = nil)
elems_by_value = group_by{ yield _1 }
if n
elems_by_value.values_at(*elems_by_value.keys.min(n))
else
elems_by_value[elems_by_value.keys.min]
end
end
def maxima_by(n = nil)
elems_by_value = group_by{ yield _1 }
if n
elems_by_value.values_at(*elems_by_value.keys.max(n))
else
elems_by_value[elems_by_value.keys.max]
end
end
end
end