Open7

パターンマッチ・キャッチアップ

ksssksss

Ruby v2.7から追加されたパターンマッチを、今さらキャッチアップする。
自分で考えながら、実践的な使いみちを模索する。

とりあえずリンク集

ksssksss

幸いElixirを少し勉強したことがあるのでおさらい。
Elixir/Erlangでは、パターンマッチは言語のコアに組み込まれていて、one lineはもちろん、if/cond/case/receive文、関数のオーバーロードと引数の組み合わせ等、多くの場面で利用されている。

自分なりに感じたパターンマッチの役割は

  • アサーション(one line)
  • 例外処理
  • 分岐の名前付け(関数との組み合わせ)
  • 変数の捕縛

などがあった。

アサーションと例外処理

Elixirでは

{:ok, file} = File.open("foo.txt")

とすると、この行以降ファイルのopenに成功したことが保証される。
もしopenに失敗したら、例外を吐いてプログラムは終了する。
しかし

case File.open("foo.txt") do
  {:ok, file} ->
    # open成功
  {:error, error} ->
    # 例外処理
end

とか書くだけで、ファイルがopenできなかった場合の例外処理を追加できる。
openに成功した場合と失敗した場合が横並びの扱いで書ける。
関数の返り値を割と把握しておく必要がありそう。
このように、プログラムの流れを止めない例外処理は都度書きやすくなっているため、Elixirでは大域脱出的な例外を扱うことは珍しいっぽい。

Rubyでは

Rubyだと、アサーションの意味はそのまま使えそう。

# userは`id`と`name`と`address`を持っているはず
# 持ってなければ例外(NoMatchingPatternError)
params => {id:, name:, address:}
p id #=> 123
p name #=> "ksss"
p address #=> "tokyo"

例外処理は、言語のコアとして最初から入っていたわけではないので、自作class等に限定されそう。
ヘルパーを書いたらコアのメソッドでも無理やり使えるが、そこまで良さは感じない。

def tuplize
  [:ok, yield]
rescue => err
  [:error, err]
end

# NoMatchingPatternError
case tuplize { File.open("./notexists.txt") }
in [:ok, file]
  begin
    # file.read
  ensure
    file.close
  end
end

case tuplize { File.open("./notexists.txt") }
in [:ok, file]
  begin
    # file.read
  ensure
    file.close
  end
in [:error, error]
  # error handling
  pp error
end

分岐の名前付け

Elixirでは

関数のオーバーロードを使って、if文無しで分岐を書けるが、実質if文に名前をつける行為だと思った。
ここではファイルのopenに成功したかどうかの分岐にhandlerという名前をつけている。(名前付けとしてあんまりいい例が思いつかなかった……。)

def handler({:ok, file}) do
  # 成功時
end

def handler({:error, error}) do
  # 失敗時
end

File.open("foo.txt")
|> handler

Rubyでは

Rubyにオーバーロードの概念はないので、分岐に名前をつけるという意味では使いみちはなさそうかなあ。

変数の捕縛

Elixirでは

Elixirでは変数の捕縛方法がそもそもパターンマッチしかなかった。

Rubyでは

Rubyではパターンマッチを使わなくても変数捕縛なことはできる。
しかし、パターンマッチでかなりコードを分かりやすくできるケースはありそう。

アサーションでも出した例だと、1行で複数の変数が設定できる。

params => {id:, name:, address:}
p id #=> 123
p name #=> "ksss"
p address #=> "tokyo"

パターンマッチがないと

id = params[:id]
name = params[:name]
address = params[:address]

と3行かかる。one lineパターンマッチはまだexperimentalだけど、個人的にはこの使い方のほうが実践で使いやすそうな気がする

ksssksss

実は既に使ってるRubyのパターンマッチっぽい奴ら

keyword arguments

def user(id:, name:, address:)
  p id #=> 123
  p name #=> "ksss"
  p address #=> "tokyo"
end

user(**params)

keyword argumentsでは指定したkeywordが無いとArgumentErrorになる。
しかも変数に設定しておいてくれているので、かなりパターンマッチっぽい。

rescue

begin
  # 何が起きるかわからないコード
  raise [Errno::ENOENT, ZeroDivisionError, KeyError].sample
rescue Errno::ENOENT => err
  pp err
rescue ZeroDivisionError => err
  pp err
rescue KeyError => err
  pp err
end

rescueは複数書いて、上から条件が一致したらその分岐に入る。しかも=>で変数として名前をつけれるのでこれもパターンマッチっぽい。

Regexp

正規表現は文字列だけのパターンマッチという感じ。
特に名前付きキャプチャーを使うと、

/(?<id>.*),(?<name>.*),(?<address>.*)/ =~ "123,ksss,tokyo"
p id #=> "123"
p name #=> "ksss"
p tokyo #=> "tokyo"

とパターンマッチっぽさがある。

つまり

これらの既にRubyにある機能を、もっと汎用的にコードで使いたいと思ったときに使い所のヒントがあるのかも???

ksssksss

試しに色々アイデア出し

FizzBuzz

パターンマッチがあるとき

def fizzbuzz(num)
  case [num % 3, num % 5]
  in [0, 0]
    puts 'FizzBuzz'
  in [0, _]
    puts 'Fizz'
  in [_, 0]
    puts 'Buzz'
  else
    puts num
  end
end

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

ないとき

def fizzbuzz(num)
  fizz = num % 3 == 0
  buzz = num % 5 == 0
  if fizz && buzz
    puts 'FizzBuzz'
  elsif fizz
    puts 'Fizz'
  elsif buzz
    puts 'Buzz'
  else
    puts num
  end
end

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

そこはかとなく良さがあるような気もするが、使わなくてもこれはこれで……うーん。

Testing

https://qiita.com/kentaro/items/477c92a57c8aaf694251 にインスパイアされて、Ruby版を書いてみた。

user = {
  id: 123_456,
  name: "ksss",
  profile: {
    real_name: "栗原勇樹",
    image: {
      original: "...",
      thumbnail: "...",
    }
  }
}

def test_user
  user => { id: _, name: "ksss", profile: profile }
  profile => { real_name: "栗原勇樹", image: image }
  image => { original: _, thumbnail: _ }
end
assert { user => { id: _, name: "ksss", profile: profile } }

とできたらpower assertと組み合わせれるかもと思ったが、ここではprofileがassertの外に出せないので複数行な使い方はできない。
keyの存在確認や、数値や文字列などの簡単な値の確認だけならできるかも。

assert { user => { id: 123_456, name: "ksss", profile: _ } }

type => "foo"

HashのArrayで{type: "foo"}みたいにtypeキーで他のパラメーターが変わるパターン。

パターンマッチがあるとき

ary.each do |params|
  case params
  in { type: "text", text: }
    # ...
  in { type: "image", image: , width:, height: }
    # ...
  in { type: "url", url: }
    # ...
  end
end

ないとき

ary.each do |params|
  case params[:type]
  when "text"
    text = params[:text]
  when "image"
    image = params[:image]
    height = params[:height]
    width = params[:width]
  when "url"
    url = params[:url]
  end
end

これは結構わかりやすくなったかもしれない。
やはり実戦的なパターンマッチとしてはArrayやHashやObjectが鍵なきがする。

Overloadもどき

パターンマッチがあるとき

class Overload
  def method_missing(name, *args)
    case [name, args]
    in [:hello, []]
      puts "hello"
    in [:hello, [a]]
      puts "hello #{a}"
    in [:hello, [a, Integer => i]]
      puts("hello #{a}!" * i)
    end
  end
end

o = Overload.new

o.hello #=> hello
o.hello('ksss') #=> hello ksss
o.hello('ksss', 3) #=> hello ksss!hello ksss!hello ksss!
o.hello('ksss', 'yo') #=> in `method_missing': [:hello, ["ksss", "yo"]] (NoMatchingPatternError)

ないとき

class Overload
  def method_missing(name, *args)
    case args.length
    when 0
      puts "hello"
    when 1
      puts "hello #{args[0]}"
    when 2
      raise ArgumentError unless args[1].kind_of?(Integer)
      puts("hello #{args[0]}!" * args[1])
    else
      super
    end
  end
end

o = Overload.new

o.hello #=> hello
o.hello('ksss') #=> hello ksss
o.hello('ksss', 3) #=> hello ksss!hello ksss!hello ksss!
o.hello('ksss', 'yo') #=> in `method_missing': ArgumentError (ArgumentError)

これもやりたいイメージにはパターンマッチが合ったほうが近い感じになる。
Kernel#systemみたいな引数が複雑なメソッドを実装したいときにいいかも。

ksssksss

パターンマッチは、高級なif/case文。複雑な記述をスッキリ書くためにある。
静的言語ではパターンが網羅されてるからチェックする機能もある。
rubyの今の文法や実装範囲は本当に色々考えられた結果。採用しなかった仕様も理由をつけて多く語られている。
rubyにオーバーロードは検討されている。(もちろんパターンマッチと組み合わせて)
多重代入や、キーワード引数もパターンマッチだよねと語られている。

ksssksss

JSでもある、欲しい値だけ名前つけて取り出したいときも使えそう。

def api
  { data: "...", error: "...", is_valid: false, func: ->(){ ... } }
end

api => { data: code }
code.each { ... }

このとき、返り値をArrayにする場合は順番を意識する必要があるしsplatが必要。

def api
  ["...", "...", false, ->(){ ... }]
end

api => [code, *]
code.each { ... }