case 式で when 10..20, -20..-10
とはできるのに、 Enumerable#grep
で ary.grep(10..20, -20..-10)
とはできない。これをどうにかしてみたいお話(小ネタ)。
Ruby バージョン: 3.1.2
背景
Ruby では case 式の条件分岐に使われる ===
という演算子がある。通常は ==
と同じだが、いくつかのクラスでは条件分岐が便利になるよう再定義されている。
def judge(obj)
case obj
when true, false
puts "%p is a boolean object." % obj
when 10..20, -20..-10
puts "abs(%p) is between 10 and 20." % obj
when /e/
puts "%p contains a character 'e'." % obj
when Exception
puts "%p is an instance of Exception." % obj
when :frozen?.to_proc
puts "%p is frozen." % obj
else
puts "%p did not match the defined patterns." % obj
end
end
[
false,
-12,
:Antares,
(Math.log(-1) rescue $!),
-"Antarctica",
self,
].each { |obj| judge(obj) }
false is a boolean object.
abs(-12) is between 10 and 20.
:Antares contains a character 'e'.
#<Math::DomainError: Numerical argument is out of domain - log> is an instance of Exception.
"Antarctica" is frozen.
main did not match the defined patterns.
また、 Enumerable#grep
や Enumerable#all?
などでも同じく ===
で判定していて、ブロックを書かず楽に条件を指定できるようになっている。
ary = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
# パターンを指定する方法
ary.grep(10..20) # Range を指定
ary.grep((10..20).method(:cover?)) # Method を指定
#=> [11, 13, 17, 19]
# ブロックを渡す方法 ※ grep に相当するメソッドは select
ary.select { |x| (10..20).cover?(x) }
ary.select(&(10..20).method(:cover?))
#=> [11, 13, 17, 19]
しかし、これらのメソッドで渡せるパターンは1つのみ。 when 節のように複数は書けない。
(-100..100).step(7).grep(10..20, -20..-10)
# wrong number of arguments (given 2, expected 1) (ArgumentError)
特段困った経験は無いが、 ===
で判定するという共通点からすれば複数書けてもいい気がする。
解決案
引数にパターンをひとつしか渡せないのなら、「内部で複数のパターンを評価するオブジェクト」を作って渡してしまえばいい。具体的には、例えば以下のように使えるクラス Patterns
を定義する。
(-100..100).step(7).grep(Patterns.new(10..20, -20..-10))
#=> [-16, 12, 19]
このクラスは2つの要件を満たせばいい。
- コンストラクタで任意の数のパターンを受け取って記憶する
- インスタンスメソッド
#===
を再定義し、それらのパターンを順次評価する- 各パターンの評価には再び
===
を用いる - 結果を集計して真偽値を返す
- 各パターンの評価には再び
when 節だと「複数パターンのいずれか」と評価するので、同様の評価をさせるには Enumerable#any?
を使うと便利。(今回は欲張りに #any?
以外も選べるようにしてみる)
class Patterns
def initialize(*patterns, cond: :any?)
@patterns = patterns.method(cond)
end
def ===(obj)
@patterns.call { |pattern| pattern === obj }
end
end
副次効果
cond
というオプションで評価方法を選べるようにしたことで、別の使い道が生まれる。
複数パターンの評価方法を #all?
に変えれば、今度は case 式で「すべての条件を満たす場合」という書き方もできるようになる。
multiple_of_3 = -> x { x % 3 == 0 }
multiple_of_5 = -> x { x % 5 == 0 }
(1..100).each do |x|
puts case x
when Patterns.new(multiple_of_3, multiple_of_5, cond: :all?)
"FizzBuzz"
when multiple_of_3
"Fizz"
when multiple_of_5
"Buzz"
else
x
end
end
また #none?
を使えば否定も作れるし、入れ子にして複雑な条件も表せる。実行速度はともかくとして、色々な使い方ができるかもしれない。
付録
#===
を再定義しているクラス
デフォルトでは同値性判定になっている。
メソッド
Object#==
の別名です。 case 式で使用されます。このメソッドは case 式での振る舞いを考慮して、各クラスの性質に合わせて再定義すべきです。一般的に所属性のチェックを実現するため適宜再定義されます。
所属性判定などの形に再定義しているのは、組み込みや標準添付ライブラリをざっと探すと以下があった。他のメソッドのエイリアスに過ぎないものと、固有の判定になっているものがあるため、詳細は各ドキュメントを参照。
クラス |
#=== の判定基準 |
類似の方法 |
---|---|---|
Gem::Requirement | バージョン指定を満たすか | req.satisfied_by?(obj) |
IPAddr | IPアドレス範囲に含まれるか | ip.include?(obj) |
Method | (あるオブジェクトをレシーバーとする)メソッドに引数を渡して評価 | method.call(obj) |
Module | そのクラス(継承・ mix-in を含む)のインスタンスか | obj.kind_of?(mdl) |
Proc | プロシージャに引数を渡して評価 | proc.call(obj) |
Range | オブジェクトが区間に属するか | range.cover?(obj) |
Regexp | 文字列が正規表現にマッチするか | regexp.match?(obj) |
Set | オブジェクトが集合に属するか | set.member?(obj) |
#===
を利用するメソッド
主なものは Enumerable
にある。このモジュールを mix-in しているリスト的なクラスで利用できる。
-
#grep
,#grep_v
: 要素の抽出- パターン指定が必須
- ブロックを追加した場合、要素を変換して返す(
#map
と同様)
-
#any?
,#all?
,#none?
,#one?
: 統計的判定- パターン、ブロック、何もなし(要素のまま評価)を選べる
-
#slice_after
,#slice_before
: リストの分割- パターンとブロックを選べる