Help us understand the problem. What is going on with this article?

サンプルコードでわかる!Ruby 2.7の新機能・パターンマッチ(後編)

はじめに

本記事はRuby 2.7の新機能であるパターンマッチ(もしくはパターンマッチング)を紹介する記事です。
パターンマッチは説明する内容が多いので、次のように前編と後編の2部構成になっています。

  • 前編 = パターンマッチの概要、case文っぽい使い方、配列やハッシュとのマッチ、変数への代入
  • 後編 = 自作クラスをパターンマッチで活用する方法、パターン名の整理

この記事は後編ですので、まだ前編を読んでいない方は先に前編を読んでからこの記事に戻ってきてください。

それでは以下が後編です。

Step 3. 自作のクラスをパターンマッチに対応させる

ここまでは配列やハッシュなど、Rubyで元から用意されているオブジェクトをパターンマッチで使用してきました。
ですが、自分で定義した自作のクラスをパターンマッチに対応させることもできます。

ここでは次のようなライブラリのバージョン番号を表すクラスを題材にします。

# 独自にクラスを定義する
class Version
  attr_reader :major, :minor, :teeny

  def initialize(text)
    @major, @minor, @teeny = text.split('.').map(&:to_i)
  end
end

# Versionクラスではメジャー、マイナー、Teenyの値をそれぞれ取り出せる
version = Version.new('1.12.3')
version.major #=> 1
version.minor #=> 12
version.teeny #=> 3

ダメ元でこのクラスのインスタンスをパターンマッチに放り込んでみます。

version = Version.new('1.12.3')

# マッチに成功してnに3が代入されると嬉しい、が・・・
case version
in 1, 12, n
  "teeny is #[n]"
end
#=> NoMatchingPatternError (#<Version:0x00007fc76a0fe928>)

残念、やはりマッチしませんでしたね。でも、大丈夫です!
上のように配列とマッチさせたいときはdeconstructというメソッドを定義し、そこで配列を返すようにすれば、パターンマッチで使えるようになります。
さっそくやってみましょう。

class Version
  # ...

  # 配列のパターンマッチではdeconstructメソッドの戻り値が利用される
  def deconstruct
    [major, minor, teeny]
  end
end

version = Version.new('1.12.3')

# deconstructメソッドを定義したので、期待どおりにパターンマッチに成功する!
case version
in 1, 12, n
  "teeny is #{n}"
end
#=> teeny is 3

ご覧のとおり、うまくパターンマッチで利用することができました。

ハッシュとして比較したい場合

では今度はハッシュと比較してみましょう。
こんなコードを書くとうまく動くでしょうか?

version = Version.new('1.12.3')

# versionオブジェクトをハッシュと比較してマッチさせたい、が・・・
case version
in major: 1, minor: 12, teeny: n
  "teeny is #{n}"
end
#=> NoMatchingPatternError (#<Version:0x00007fc76a0fe928>)

うーん、残念。いきなりハッシュと比較するのは無理でした。
でも大丈夫です。ハッシュの場合はdeconstruct_keysというメソッドを定義してハッシュを返すようにすれば、ハッシュと比較できるようになります。

class Version
  # ...

  # ハッシュのパターンマッチではdeconstruct_keysメソッドの戻り値が利用される
  # (引数keysの役割は後述)
  def deconstruct_keys(keys)
    {major: major, minor: minor, teeny: teeny}
  end
end

version = Version.new('1.12.3')

case version
in major: 1, minor: 12, teeny: n
  "teeny is #{n}"
end
#=> teeny is 3

ご覧のとおり、ハッシュと比較することもできました。

deconstructメソッドやdeconstruct_keysメソッドを適切に実装していれば、ネストした配列やハッシュの中で使うこともできます。

case [1, version]
in 1, [1, 12, n]
  "teeny is #{n}"
end
#=> teeny is 3

case [1, version]
in 1, {major: 1, minor: 12, teeny: n}
  "teeny is #{n}"
end
#=> teeny is 3

構造だけでなく、データ型もチェックする

さて、先ほど作成したVersionクラスはdeconstructメソッドとdeconstruct_keysメソッドを実装したので、パターンマッチでも利用できるようになりました。

しかし、今のままだとcase節に素の配列やハッシュが渡されたときと、Versionクラスのインスタンスが渡されたときとで区別が付きません。

version = Version.new('1.12.3')
array = [1, 12, 3]

# 配列を渡してもマッチする
case array
in 1, 12, n
  'Array? Version?'
end
#=> Array? Version?

# Versionを渡してもマッチする
case version
in 1, 12, n
  'Array? Version?'
end
#=> Array? Version?

hash = {major: 1, minor: 12, teeny: 3}

# ハッシュを渡してもマッチする
case hash
in major: 1
  'Hash? Version?'
end
#=> Hash? Version?

# Versionを渡してもマッチする
case version
in major: 1
  'Hash? Version?'
end
#=> Hash? Version?

ダックタイピングを基本とするRubyならこれでも問題ないのかもしれませんが、場合によってはデータ型までちゃんと区別したいケースがあるかもしれません。
これを解決する方法のひとつは、次のようにガード条件を付けることです。

# ガード条件でデータ型を判別する
case array
in [1, 12, n] => a if a.is_a?(Array)
  'Array'
in [1, 12, n] => a if a.is_a?(Version)
  'Version'
end
#=> Array

# 同じくガード条件でデータ型を判別する
case version
in [1, 12, n] => a if a.is_a?(Array)
  'Array'
in [1, 12, n] => a if a.is_a?(Version)
  'Version'
end
#=> Version

# ハッシュとVersionを区別したいときも考え方は同じなので省略

しかし、パターンマッチにはこういったケースで便利な構文が用意されています。
クラス名(データ構造)という形式でin節を記述すると、対象オブジェクトのデータ型も判断条件に追加されるのです。
つまり、先ほどガード条件を付けて書いたコードは次のように書くことができます。

# クラス名(データ構造)の形式で、データ型とデータ構造を同時に判定する
case array
in Array(1, 12, n)
  # Arrayクラスまたはそのサブクラスならここにマッチ
  'Array'
in Version(1, 12, n)
  # Versionクラスまたはそのサブクラスならここにマッチ
  'Version'
end
#=> Array

# 同上(こちらはVersion型にマッチする)
case version
in Array(1, 12, n)
  'Array'
in Version(1, 12, n)
  'Version'
end
#=> Version

ハッシュの場合も考え方は同じです。

# クラス名(データ構造)の形式で、データ型とキーと値の組み合わせを同時に判定する
case hash
in Hash(major: 1)
  # Hashクラスまたはそのサブクラスならここにマッチ
  'Hash'
in Version(major: 1)
  # Versionクラスまたはそのサブクラスならここにマッチ
  'Version'
end
#=> Hash

# 同上(こちらはVersion型にマッチする)
case version
in Hash(major: 1)
  'Hash'
in Version(major: 1)
  'Version'
end
#=> Version

次のようにこの構文をネストして使うこともできます。

case [1, version]
in Array(1, Version(1, 12, n))
  # 一番外側はArrayかつ、1つ目の要素は整数の1で、
  # 2つ目の要素はmajor=1、minor=12のVersionオブジェクトであればマッチ
  'match!'
end
#=> match!

さらに、クラス(データ構造)の構文は()の代わりに[]を使うこともできます。

case version
# ()の代わりに[]を使う
in Version[1, 12, n]
  'Version'
end
#=> Version

case version
# ハッシュを使う場合も[]で書ける
in Version[major: 1]
  'Version'
end
#=> Version

コラム:inの後ろはまったく新しい世界

Rubyを長年やってきた人は、もしかすると次のようなコードを読み書きしたことがあるかもしれません。

Array(nil)       #=> []
Array([1, 2, 3]) #=> [1, 2, 3]

Hash[:a, 1, :b, 2] #=> {:a=>1, :b=>2}

前者はKernelモジュールに定義されたArrayメソッドで、Rubyの中では珍しい大文字で始まるメソッドです。(クラス名と同じだけどメソッドなんです!)

module function Kernel.#Array

また、後者はHashクラスのクラスメソッドとして定義されている[]です。

singleton method Hash.[]

そして、先ほど説明したパターンマッチのクラス名(データ構造)(またはクラス名[データ構造])でも見た目がまったく同じ(または非常によく似た)構文が発生します。

case [nil]
# あれ?さっき見たArray(nil)!?
in Array(nil)
  # ...
end

case {a: 1, b: 2}
# あれ?さっき見たHash[:a, 1, :b, 1]!?(ちょっと違うけど)
in Hash[a: 1, b: 2]
  # ...
end

ですが、ここに出てくるArray(nil)Hash[a: 1, b: 2]は、最初に見たArray(nil)Hash[:a, 1, :b, 2]と全く意味や役割が異なります。
正しい解釈は以下のコメントの通りです。

case [nil]
# 「データ型がArrayで、要素が1つ、なおかつそれがnilであればマッチ」の意味
in Array(nil)
  # ...
end

case {a: 1, b: 2}
# データ型がHashで、なおかつキーと値の組み合わせが(略)を含むものであればマッチ」の意味
in Hash[a: 1, b: 2]
  # ...
end

Rubyにそこそこ詳しい人ほど、上のようなパターンマッチの構文を見たときに自分の中の豊富な知識が邪魔をして、パターンマッチの理解を妨げてしまうかもしれません。(何を隠そう、僕自身がそうでした・・・💧)

ですので、パターンマッチの構文を学習する際は「in節の後ろに続くコードは、まったく新しいRubyの世界である」と考えた方が、スムーズにパターンマッチを理解できると思います。

コラム:データ型を指定しないin節は暗黙的にBasicObjectを指定している

配列のパターンマッチでは次のようなコード例を紹介しました。

case [0, 1, 2]
in [0, 1, n]
  # ...
end

このコード例はin節にデータ型を指定していません。
この場合は暗黙的にBasicObject型を指定したことになります。
つまり、次のようなコードを書いたのと同じです。

case [0, 1, 2]
# 型指定のないin [0, 1, n]は暗黙的にBasicObject型を指定している
in BasicObject(0, 1, n)
  # ...
end

ハッシュの場合も同様で、以下の2つのコードは同じ意味になります。

case {a: 1, b: 2}
in {a: 1, b: 2}
  # ...
end

case {a: 1, b: 2}
# 型指定のないin {a: 1, b: 2}は暗黙的にBasicObject型を指定している
in BasicObject(a: 1, b: 2)
  # ...
end

Rubyにおいて、BasicObjectクラスはすべてのクラスのスーパークラスなので、in BasicObject(データ構造)と書いたときはすべてのデータ型が該当します。
つまり、何もデータ型を限定していることにならないため、配列(Array型)やハッシュ(Hash型)、さらに独自に定義したVersionクラス型もin 1, 2, 3in a: 1, b: 2にマッチする、というわけです。

コラム:Structクラスのサブクラスをパターンマッチで使う

Ruby 2.7では配列やハッシュ以外でもdeconstructメソッドやdeconstruct_keysメソッドが使えるクラスがあります。
Structクラス(とそのサブクラス)はそんなクラスのひとつです。

次のコードはStructクラスをパターンマッチで活用するコード例です。

# 下記ページのサンプルコードを一部改変
# https://speakerdeck.com/k_tsj/rubyconf2019-pattern-matching-new-feature-in-ruby-2-dot-7?slide=26

# Structクラスを使ってColorクラスを定義する(ColorはStructのサブクラスになる)
Color = Struct.new(:r, :g, :b)

# Colorクラスのインスタンスを作成する
color = Color.new(0, 10, 20)

# Structのサブクラスはdeconstructメソッドが使える
color.deconstruct
#=> [0, 10, 20]

# colorをパターンマッチで使う
case color
in 0, 10, n
  "b = #{n}"
end
#=> b = 20

# deconstruct_keysも使える
color.deconstruct_keys(nil)
#=> {:r=>0, :g=>10, :b=>20}

# パターンマッチでハッシュと比較することもできる
case color
in r: 0, g: 10, b: b
  "b = #{b}"
end
#=> b = 20

ちなみに、Structクラスのサブクラスは[]メソッドでインスタンスを作成することもできます。

Color = Struct.new(:r, :g, :b)

# Color.new(0, 10, 20)と同じ
color = Color[0, 10, 20]

次のようなコードを書くとColor[0, 10, 20]が2回出てきて、「あれっ?」と思うかもしれません。
ですが、それぞれやっていることが違うので注意してください。(前述のコラム「inの後ろはまったく新しい世界」を参照)

Color = Struct.new(:r, :g, :b)

# case節で書いているのはColorクラスのインスタンスを作成するためのColor[0, 10, 20]
case Color[0, 10, 20]
# in節で書いているのはデータ型(データ構造)でパターンマッチするためのColor[0, 10, 20]
in Color[0, 10, 20]
  'match!'
end
#=> match!

コラム:()[]の使い分け

すでに説明した通り、パターンマッチでクラス名(データ構造)の形式でパターンを指定する場合は、()[]の両方が使えます。

version = Version.new('1.12.3')

# ()を使う場合
case version
in Version(1, 12, 3)
  # ...
end

# []を使う場合
case version
in Version[1, 12, 3]
  # ...
end

なぜ()[]の両方が使えるのか、そしてどう使い分けたらよいのか、という点については、「オブジェクトの生成方式によく似た記法を用意したかったから、そして使い分けも生成方式に近い記法を採用するのが良い」というのが、設計者の意図のようです。(出典:n月刊ラムダノート Vol.1, No.3(2019) p90)

具体例を挙げると、次の通りです。

# new()でオブジェクトを生成するので、inでも()を使う
case Version.new('1.12.3')
in Version(1, 12, 3)
  # ...
end

# []でオブジェクトを生成するので、inでも[]を使う
Color = Struct.new(:r, :g, :b)
case Color[0, 10, 20]
in Color[0, 10, 20]
  # ...
end

# [ハッシュ]でオブジェクトを生成するので、inでも[ハッシュ]を使う
AnotherColor = Struct.new(:r, :g, :b, keyword_init: true)
case AnotherColor[r: 0, g: 10, b: 20]
in AnotherColor[r: 0, g: 10, b: 20]
  # ...
end

ただし、今後の議論によってはどちらか片方の記法(おそらく[])だけを残す可能性もあるとのことです。(出典:n月刊ラムダノート Vol.1, No.3(2019) p91)

コラム:パフォーマンスに配慮したdeconstruct_keysメソッドを実装する

先ほど説明で使用したVersionクラスではdeconstruct_keysメソッドを次のように実装していました。

class Version
  # ...

  def deconstruct_keys(keys)
    {major: major, minor: minor, teeny: teeny}
  end
end

この場合はこのままでも特に問題はないのですが、もし次のようなクラスがあるとパフォーマンスが問題になってくるかもしれません。

# データベース上のテーブル行数を集めてくるクラス
# (注:説明用に考えた架空のクラスです。実際にこんなクラスを作ることはないと思います)
class TableCount
  # ...

  def deconstruct_keys(keys)
    # データベース上の全テーブルについて行数を取得する
    {
      users: User.count,
      products: Product.count,
      orders: Order.count,
      # 以下、大量のcount取得が続く...
    }
end

table_count = TableCount.new

# deconstruct_keysが呼ばれたタイミングで全テーブルのデータを読みに行くので当然遅い
case table_count
in users: n
  "User count: #{n}"
end

上のパターンマッチではin users: nと書いたので、実際に必要なデータはUser.countだけです。
それ以外の情報は取得しても使用されません。

こんなときに役立つのがdeconstruct_keysメソッドの引数keysです。
この引数にはパターンマッチで要求されたキー情報が配列で格納されます。
ですので、deconstruct_keysメソッドの中で要求されたキーに対応する値だけを返すようにすれば、処理効率が良くなります。

class TableCount
  # ...

  def deconstruct_keys(keys)
    hash = {}

    # パターンマッチで要求されたキーに対応するデータだけを取得する
    # (keysには[:users]のようにパターンマッチで要求されたキーの情報が入っている)
    hash[:users]    = User.count    if keys.include?(:users)
    hash[:products] = Product.count if keys.include?(:products)
    hash[:orders]   = Order.count   if keys.include?(:orders)
    # ...

    hash
  end
end

ただし、in users: n, **restのように「その他のキーと要素」を要求された場合はすべてのキーと値を返す必要があります。
**restのようなパターンマッチが要求されたときは引数keysにはnilが入るので、それで判断します。

class TableCount
  # ...

  def deconstruct_keys(keys)
    hash = {}

    # **restのように「その他のキーと要素」を要求された場合はすべての要素を返す
    # (keysがnilなら「その他のキーと要素」が要求されている)
    hash[:users]    = User.count    if keys.nil? || keys.include?(:users)
    hash[:products] = Product.count if keys.nil? || keys.include?(:products)
    hash[:orders]   = Order.count   if keys.nil? || keys.include?(:orders)

    # 注:上のコードは「もっとリファクタリングの余地がある!」と思われるかもしれませんが、
    # あくまで説明用のサンプルコードなので、あえてベタな書き方をしています

    # ...

    hash
  end
end

上の例ではデータベースアクセスが発生する場合を想定しましたが、単純に返すキーと値が多すぎる場合もパフォーマンスの問題が発生するかもしれません(たとえば、ハッシュの要素が1000個ある場合を想像してみてください)。

RubyKaigi 2019のパターンマッチングに関する講演では、「ハッシュの要素が4個ぐらいまでなら毎回無条件にすべての要素を返しても問題はない。それ以上であれば返す要素の取捨選択を検討した方が良い」という旨の説明がされていました(参考)。

Step 4. パターンマッチのパターン名を整理する

ここまでで、パターンマッチの機能的な説明はひととおり終わりました。
ところで、パターンマッチで使った各種の構文にはそれぞれ名前(パターン名)が付いています。
これらのパターン名を覚えておくと、他の開発者とコミュニケーションを取る際に説明がしやすくなるかもしれません。

(注:下記のコード例はこちらのスライドのサンプルコードを一部改変したものです)

Valueパターン

値(value)を===で比較して真になればマッチに成功するパターンです。

# Valueパターン
case 0
in 0
  # ...
in -1..1
  # ...
in Integer
  # ...
end

Variableパターン

任意の値にマッチし、それを変数(variable)に代入するパターンです。

# Variableパターン
case 0
in a
  p a
end
#=> 0

Alternativeパターン

|で区切ったいずれかのパターンにマッチすればマッチ成功と見なすパターンです。
(alternativeは「1つ以上のものから選択可能な」という意味です)

# Alternativeパターン
case 0
in 0 | 1 | 2
  # ...
end

Asパターン

=> (変数名)の形でマッチしたオブジェクトを変数に代入するパターンです。
(英語ではA as Bで「BとしてのA」の意味になります)

# Asパターン
case 0
in Integer => a
  p a
end
#=> 0

Arrayパターン

in データ型(配列形式のデータ構造)またはin 配列の形式で条件を記述するパターンです。

# Arrayパターン
case [0, 1, 2]
in Array(0, 1, 2)
  # ...
in [0, 1, 2]
  # ...
end

Arrayパターンという名前が付いていますが、すでに説明した通り、配列(Array型)以外のオブジェクトを使う場合でも、パターン名としてはArrayパターンと呼びます。

version = Version.new('1.12.3')

# これもArrayパターン
case version
in [1, 12, 3]
  # ...
end

Hashパターン

in データ型(ハッシュ形式のデータ構造)またはin ハッシュの形式で条件を記述するパターンです。

# Hashパターン
case {a: 0, b: 1}
in Hash(a: 0, b: 1)
  # ...
in {a: 0, b: 1}
  # ...
end

Hashパターンという名前が付いていますが、すでに説明した通り、ハッシュ(Hash型)以外のオブジェクトを使う場合でも、パターン名としてはHashパターンと呼びます。

version = Version.new('1.12.3')

# これもHashパターン
case version
in {major: 1, minor: 12, teeny: 3}
  # ...
end

コラム:メソッドの引数には直接パターンマッチを適用できない

Elixirのような他の関数型言語では、関数の引数に対して直接パターンマッチが適用できるものがあります。
たとえば、以下のコードはElixirでパターンマッチを使って、再帰的に階乗を求めるコード例です。

defmodule MyModule do
  # 引数が0だったら、この関数定義を実行する
  def factorical(0) do 1 end

  # それ以外の引数はこの関数定義を実行する
  def factorical(n) do n * factorical(n - 1) end
end

# 5の階乗を求める(5! = 5 * 4 * 3 * 2 * 1)
MyModule.factorical(5)
#=> 120

RubyのパターンマッチではElixirのように、メソッドの引数に対して直接パターンマッチを適用することはできません。

# Rubyではこんなコードは書けない(最初のメソッド定義で構文エラーが発生する)
def factorical(0)
  1
end

def factorical(n)
  n * factorical(n - 1)
end

Rubyでパターンマッチを使って階乗を求める場合は次のようになります。

# パターンマッチを使って階乗を求める
def factorical(n)
  case n
  in 0
    1
  else
    n * factorical(n - 1)
  end
end

factorical(5)
#=> 120

# ちなみにcase/whenで書いても結果は同じ
def factorical(n)
  case n
  when 0
    1
  else
    n * factorical(n - 1)
  end
end

factorical(5)
#=> 120

もしかすると、他の言語経験者は「直接引数でパターンマッチしたかったのに!」と思うかもしれませんが、この点は考え方を切り替える必要があるので注意してください。

まとめ

というわけで、この記事ではRuby 2.7で導入される新機能、パターンマッチについて紹介しました。

最初見たときは「Rubyでパターンマッチなんているの!?」と思ったりしたんですが、こうやってじっくり見てみるとこれまでのRubyでは実現できなかった新たな可能性が広がった気がします。
Ruby 2.7の時点ではまだ「試験的な機能」という位置づけですがですが、普段から「あ、ここはパターンマッチを使うと便利かも」という利用シーンを探しながらRubyのコードを書くのも面白いかもしれません。

みなさんもぜひパターンマッチをいろいろ試してみてください!

パターンマッチ以外のRuby 2.7の新機能はこちら

Ruby 2.7ではパターンマッチ以外にもさまざまな新機能や変更点があります。
それらについては以下の記事で紹介しています。こちらもあわせてご覧ください。

PR: 書籍「プロを目指す人のためのRuby入門」ではこんな感じでRubyの機能や構文を説明しています

今回のパターンマッチの説明記事では、拙著「プロを目指す人のためのRuby入門」と同じスタイルで、簡単な内容から徐々に高度な内容を説明していきました。
この記事を読んで「なんかわかりやすかったかも」と思われたRuby初心者の方は、書店で「プロを目指す人のためのRuby入門」を手に取っていただけると幸いです😊

プロを目指す人のためのRuby入門|技術評論社
9784774193977.jpg

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away