【一人 Ruby Advent Calendar 2017】case-when と === 演算子について【3日目】

一人 Ruby Advent Calendar 2017 3日目の記事になります。
さて、今回は case 式の when 節と #=== 演算子について簡単に解説してみたいと思います。

case

さてさて、Rubycase 式は以下のようにして when 節と組み合わせて使います。

def func n
    case n
    # n の値が when に渡した値であればその式を評価して返す
    when 1
        "one"
    when 2
        "two"
    when 3
        "three"
    else
        "other"
    end
end

p (1..5).map &method(:func)
# => ["one", "two", "three", "other", "other"]

簡単な例だとこんな感じですね。

また、when 節には複数の引数を渡すことができます。

def func n
    case n
    # 引数のいずれかとマッチすれば OK
    when 1, 2, 3
        "low"
    when 4, 5, 6
        "middle"
    when 7, 8, 9
        "high"
    end
end

p (1..10).map &method(:func)
# => ["low", "low", "low", "middle", "middle", "middle", "high", "high", "high", "other"]

ちなみに Rubycase は式なので該当する when 節を評価した値を返します。

when 節はどのようにして判定しているのか

さて、では case-when がどのように処理されるのか見てみましょう。
例えば、以下のような case-when があった場合、

case n
when 1, 2
    "one, two"
when 3
    "three"
else
    "other"
end

これは以下のようなコードとほぼ等価です。

_tmp = n
if 1 === _tmp || 2 === _tmp
    "one, two"
elsif 3 === _tmp
    "three"
else
    "other"
end

こんな感じで複数の if 式で条件分岐しているのと同じですね。
注目すべきは 1 === _tmp のように比較演算子に『#=== 演算子を使っている点』です。

Ruby における #=== 演算子とは

では、Ruby における #=== 演算子とはどういう役割なんでしょうか。
Rubyリファレンスマニュアルでは Object#=== について以下のように記述されています。

メソッド Object#== の別名です。 case 式で使用されます。このメソッドは case 式での振る舞いを考慮して、 各クラスの性質に合わせて再定義すべきです。

一般的に所属性のチェックを実現するため適宜再定義されます。

when 節の式をレシーバーとして === を呼び出すことに注意してください。

また Enumerable#grep でも使用されます。
https://docs.ruby-lang.org/ja/latest/class/Object.html#I_--3D--3D--3D

このように #===caseEnumerable#grep のような『特定の構文やメソッド』で使用されることを想定しています。
そのため caseEnumerable#grep 等で振る舞いを変更したい場合はそのクラスの性質に合わせて #=== 再定義します(されています)。

Ruby 以外の言語、例えば JavaScript では #=== 演算子は『厳密等価演算子』と呼ばれその名の通り『== よりも厳密に値の比較を行う』という目的で使用されます。
しかし、このように Ruby では全く違う目的で使用されるので混同しないように注意しておく必要があります。
ちなみにデフォルトの #=== 演算子== 演算子と同等になります。

#=== が定義されているクラスいろいろ

Ruby では主に以下のようなクラスで #=== 演算子が定義されています。

オブジェクト obj === other の挙動
Range otherが範囲内に含まれているかどうか。 obj.include? other と等価
Regexp 文字列 other正規表現マッチを行う
Class other がそのクラス(obj)のインスタンスであるかどうか
Proc other を引数とした obj.call を呼び出す

なので上記のようなオブジェクトであれば when 節でいい感じに使うことができます。

Range の場合

p (1..10) === 3
# => true

p (1..10) === 25
# => false

p (1..10) === -5
# => false

def level n
    case n
    # 1〜3の範囲にマッチする場合みたいに記述することが出来る
    when (1..3)
        "low"
    when (4..6)
        "middle"
    when (7..9)
        "high"
    else
        "other"
    end
end

p (1..10).map &method(:level)
# => ["low", "low", "low", "middle", "middle", "middle", "high", "high", "high", "other"]

Regexp の場合

p /^\d+/ === "234"
# => true
p /^\d+/ === "homu"
# => false


def check str
    case str
    # 数値の場合
    when /^\d+$/
        "#{str} is number"
    # 英字の場合
    when /^\a+$/
        "#{str} is string"
    else
        "#{str} is other"
    end
end

p check "1234"
# => "1234 is number"
p check "homu"
# => "homu is other"
p check "homu1234"
# => "homu1234 is other"

Class の場合

p String === "homu"
# => true
p Numeric === "homu"
# => false
p Numeric === 1234
# => true
p Numeric === 3.14
# => true


def twice x
    case x
    # x が各クラスのインスタンスだった場合
    when Numeric
        x + x
    when String
        x.to_i + x.to_i
    when Symbol
        x.to_s.to_i + x.to_s.to_i
    else
        "???"
    end
end


p twice 42
# => 84
p twice "1234"
# => 2468
p twice :"5678"
# => 11356
p twice [1, 2, 3]
# => "???"

Proc の場合

p proc { |a| a + a } === 5
# => 10


def level n
    case n
    # Proc によりより詳細な条件を記述することが出来る
    when proc { |n| n < 4 }
        "low"
    when proc { |n| n < 7 }
        "middle"
    when proc { |n| n < 10 }
        "high"
    else
        "other"
    end
end

p (1..10).map &method(:level)
# => ["low", "low", "low", "middle", "middle", "middle", "high", "high", "high", "other"]

このように #=== 演算子が再定義されているクラスは case-when でただ比較するだけではないような使い方ができます。

応用

さて、case-when では #=== 演算子を再定義することで柔軟に挙動を変更することが出来ることがわかったと思います。
これを応用して例えば Array#=== を再定義すると次のようにコードを書くことが出来るようになります。

# Array#=== を定義
using Module.new {
    refine Array do
        def === other, &block
            size == other.size && zip(other).all? { |a, b| a.=== b, &block }
        end
    end
}

# 必ず true を返す proc を返す
def _
    proc { true }
end


def fizzbuzz n
    case [n % 3, n % 5]
    # n % 3 === 0 && n % 5 === 0
    when [0, 0]
        "FizzBuzz"
    # n % 3 === 0
    when [0, _]
        "Fizz"
    # n % 5 === 0
    when [_, 0]
        "Buzz"
    else
        n
    end
end

p (1..20).map &method(:fizzbuzz)

こんな感じでパターンマッチっぽく FizzBuzz を実装することができます。

まとめ

  • case-when ではオブジェクトを比較する時に #=== 演算子を使用する
  • Ruby#=== 演算子case-when などで使用されることを前提としている
  • #=== 演算子を再定義することで case-when などで使い勝手がよくなる


と、いうことで簡単にまとめてみました。
思ったよりも Rubycase-when は柔軟性が高くて応用が利きそうな感じがしますね。
Rubycase-whenC言語のような switch-case として考えるとあまり使う気かはないかもしれませんが、#=== 演算子を意識すると、もしかしたら case-when を使ったほうが簡単にコードを書くことが出来るかもしれませんね。
また、case-when 以外でも通常の条件分岐として #=== 演算子を使うことはできるので、覚えておくと意外なところで利用できるかもしれません。

参照