Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby 3のパターンマッチング応用(2)三目並べ(翻訳)

概要

概要

原著者の許諾を得て翻訳・公開いたします。

参考: 三目並べ - Wikipedia

Ruby 3のパターンマッチング応用(2)三目並べ(翻訳)

Ruby 3.0の目玉機能としてパターンマッチング(pattern matching)が導入されました。しかしパターンマッチングをどう使いこなせばよいのか、またパターンマッチングがどんなときに欲しくなるか、といった全貌がまだ見えていない方も大勢いらっしゃることでしょう。

今回は、パターンマッチングを「三目並べ」(Tic-Tac-Toe: 日本では「○×ゲーム」などとも呼ばれます)の勝ち判定に応用する方法をご紹介します。

今回のプログラム全体

最初に最終的なスクリプト全体をお目にかけます。その後で、個別のパートについて少しずつ見ていくことにします。

読み進めるうちに少々戸惑う部分もあるかと思いますが、何が行われているのかを頑張って読み取ってみましょう。

MOVE = /[XO]/.freeze

def board(*rows) = rows.map(&:chars)

def winner(board)
  case board
  in [
    [MOVE => move, ^move, ^move],
    [_, _, _],
    [_, _, _]
  ]
    [:horizontal, move]
  in [
    [_, _, _],
    [MOVE => move, ^move, ^move],
    [_, _, _]
  ]
    [:horizontal, move]
  in [
    [_, _, _],
    [_, _, _],
    [MOVE => move, ^move, ^move]
  ]
    [:horizontal, move]
  in [
    [MOVE => move, _, _],
    [^move, _, _],
    [^move, _, _]
  ]
    [:vertical, move]
  in [
    [_, MOVE => move, _],
    [_, ^move, _],
    [_, ^move, _]
  ]
    [:vertical, move]
  in [
    [_, _, MOVE => move],
    [_, _, ^move],
    [_, _, ^move]
  ]
    [:vertical, move]
  in [
    [MOVE => move, _, _],
    [_, ^move, _],
    [_, _, ^move]
  ]
    [:diagonal, move]
  in [
    [_, _, MOVE => move],
    [_, ^move, _],
    [^move, _, _]
  ]
    [:diagonal, move]
  else
    [:none, false]
  end
end

EXAMPLES = {
  straights: [
    # Win
    board('XXX', '   ', '   '),
    board('   ', 'OOO', '   '),
    board('   ', '   ', 'XXX'),

    # No Win
    board('X X', '   ', '   '),
    board('   ', 'O O', '   '),
    board('   ', '   ', 'X X'),
  ],

  verticals: [
    # Win
    board('X  ', 'X  ', 'X  '),
    board(' O ', ' O ', ' O '),
    board('  X', '  X', '  X'),

    # No Win
    board('   ', 'X  ', 'X  '),
    board(' O ', '   ', ' O '),
    board('  X', '  X', '   '),
  ],

  diagonals: [
    # Win
    board('O  ', ' O ', '  O'),
    board('  X', ' X ', 'X  '),

    # No Win
    board('O  ', ' O ', '   '),
    board('  X', ' X ', '   '),
  ]
}

EXAMPLES.each do |type, boards|
  boards.each do |board|
    puts "type: #{type}, win: #{winner(board)}"
  end
end

上のスクリプトにはさまざまなものが盛り込まれています。ここだけ読んで理解に不安があってもご心配なく。本記事でこの後詳しく解説いたします。

それでは深堀りを始めます。よろしいですか?

解説

石にマッチする正規表現

プログラムの冒頭は、XまたはOのいずれかにマッチする正規表現です。

MOVE = /[XO]/.freeze

freezeを追加している理由は、定数はfrozenにすべきだからです。さもないと本当の意味の定数ではなくなりますよね?

ゲーム盤

3x3のゲーム盤は、以下のように少々シンプルな方法で生成します。

def board(*rows) = rows.map(&:chars)

1つの行(row)は石の配置(move)を表します。

board('XXX', '   ', 'OO ')

原注: 本筋とは関係ありませんが、ここではto_sを表示に用いるまあまあのクラスを作りました。Boardクラスについて何かいいアイデアがありましたら、私のTwitter(@keystonelemur)までお知らせください。

これで以下のような2次元配列ができます。

[
  ['X', 'X', 'X'],
  [' ', ' ', ' '],
  ['O', 'O', ' ']
]

かなりそれっぽいゲーム盤に見えますが、私はirbやPryのようなREPL(Read-Eval-Print-Loop)でテストするのが常です。REPLならこうした実験を手っ取り早く行えるからです。

勝ちを判定する

ここからが本プログラムで面白くなってくる部分です。三目並べの解法はたくさんありますが、Rubyのパターンマッチングを応用すると問題解決方法に新しい視点を持ち込めます。

水平方向の勝ち判定

最初は「水平方向の勝ち判定」です。

case board
in [
  [MOVE => move, ^move, ^move],
  [_, _, _],
  [_, _, _]
]
  [:horizontal, move]
in [
  [_, _, _],
  [MOVE => move, ^move, ^move],
  [_, _, _]
]
  [:horizontal, move]
in [
  [_, _, _],
  [_, _, _],
  [MOVE => move, ^move, ^move]
]
  [:horizontal, move]

勝ち判定のパターンの中には、2種類の異なる行が含まれています。

最初のものは「値は何でもよい」ことを表します。

[_, _, _]

次のものはさらに興味深いものになっています。

[MOVE => move, ^move, ^move]

ここでは正規表現を用いて、石の配置(あるいは石の不在)が有効か無効かを判定しています。有効な場合は、=>記号(右矢印)を用いてmoveに値を代入します。なお=>記号はパターンマッチングで一般的に用いられます。

原注: 上のコードで正規表現が使えることを不思議に感じる方もいらっしゃるかもしれませんね。その理由は「Rubyのパターンマッチングではあらゆる値を===で比較する」からです。ここが重要です。===について詳しくは以下の記事をお読みになることをおすすめします。これが後ほど威力を発揮しますので、今は私を信じてください。
参考: Triple Equals Black Magic. For the most part, === is either… | by Brandon Weaver | Ruby Inside | Medium

その後、^moveを用いて値を呼び出し、同じ水平行にある残りの2つの値も同じであるという期待を記述します。水平行の最初(最も左)の値がXなら、残りの2つのXも同じ値、という具合です。

水平方向の3つのパターンのいずれかがマッチすれば、戻り値を得られます。

[:horizontal, move]

最初の3つのinの中でmoveに値が入れば勝ちが確定するので、勝者を返せるようになります。ここでは特にどのようにして勝ったかを知りたいので、勝った瞬間の手を最初の要素に含む「タプル的なArrayペア」を返します。

垂直方向の勝ち判定

垂直方向の勝ち判定は、水平方向の勝ち判定ととても似ていますが、チェックするカラムが垂直方向に並んでいる点だけが異なります。

in [
  [MOVE => move, _, _],
  [^move, _, _],
  [^move, _, _]
]
  [:vertical, move]
in [
  [_, MOVE => move, _],
  [_, ^move, _],
  [_, ^move, _]
]
  [:vertical, move]
in [
  [_, _, MOVE => move],
  [_, _, ^move],
  [_, _, ^move]
]
  [:vertical, move]

最初の例と同様、有効な配置の最初のキャプチャをmoveに代入してから、ピン演算子^でピン留めした^moveを用いて残りの2つのカラムの値も同じであることを確かめます。

その他の違いは、[:vertical, move]という戻り値で垂直方向の勝ちを示している点しかありません。

対角線方向の勝ち判定

対角線方向の勝ち判定はこれまでと見た目が少々異なりますが、考え方は同じです。

in [
  [MOVE => move, _, _],
  [_, ^move, _],
  [_, _, ^move]
]
  [:diagonal, move]
in [
  [_, _, MOVE => move],
  [_, ^move, _],
  [^move, _, _]
]
  [:diagonal, move]

ここは対角線上の値がすべて同じ配置になっているかどうかを知りたい箇所です。ここで勝ちが発生した場合は:diagonalを勝ちの戦略として返します。配置が斜めになっていると読みづらいので、いつもの私なら適度にスペース文字を入れてきれいに揃えるところですが、対角線上の勝ち判定についてはこのままにしておきます。

引き分け

Rubyのパターンマッチングは網羅的にチェックすることが期待できます。つまり、石がすべて置かれたのにどちらも勝たなかった場合をキャプチャするためのelseが必要ということです。ここでは以下のように書くことで引き分けの配置をすべてキャッチできます。

else
  [:none, false]
end

ここでは:noneを返して勝者がいなかったことを示し、勝ったときの配置の代わりにfalseを返します。

このelseを忘れると、引き分けのときに例外が発生してしまうのでよろしくありません。

本記事のサンプルは、前回のポーカーのときよりずっと読みやすいものになっています。

EXAMPLES = {
  straights: [
    # Win
    board('XXX', '   ', '   '),
    board('   ', 'OOO', '   '),
    board('   ', '   ', 'XXX'),

    # No Win
    board('X X', '   ', '   '),
    board('   ', 'O O', '   '),
    board('   ', '   ', 'X X'),
  ],

  verticals: [
    # Win
    board('X  ', 'X  ', 'X  '),
    board(' O ', ' O ', ' O '),
    board('  X', '  X', '  X'),

    # No Win
    board('   ', 'X  ', 'X  '),
    board(' O ', '   ', ' O '),
    board('  X', '  X', '   '),
  ],

  diagonals: [
    # Win
    board('O  ', ' O ', '  O'),
    board('  X', ' X ', 'X  '),

    # No Win
    board('O  ', ' O ', '   '),
    board('  X', ' X ', '   '),
  ]
}

コードを検証するため、いくつか勝ち条件や負け条件を並べてみたいと思います。こういうときにRSpecなどのテスティングツールをつい使いたくなりますが、ここでの目的はパターンマッチングを紹介することであって、テストそのものに深入りすることではありません。

rspec/rspec - GitHub

原注: ご興味のある方は元記事のコメント欄でリクエストいただければ、RSpecなどのテスティングツールを用いてテストする方法も追記したいと思います。

ここで重要なポイントは、エッジケースを見逃していないかどうかを確かめられるように、いくつかネガティブケース(マッチしないケース)を加えておくことです。

ここまで進んだら、以下のようにすべての例を実行して結果を表示します。

EXAMPLES.each do |type, boards|
  boards.each do |board|
    puts "type: #{type}, win: #{winner(board)}"
  end
end

まとめ

三目並べの解法は、ポーカーに比べて本質的にずっと簡単ですが、Ruby 3のある種のパターンマッチングのデモに使えます。本記事を皆さんが楽しんで学びを得られることを願っています。本ブログで取り上げて欲しいトピックが他にもありましたら、ぜひ私にお知らせください。

Ruby 3には楽しい機能が満載です。ときにはRuby 3を探検して楽しみましょう!

関連記事

Ruby 3のパターンマッチング応用(1)ポーカーゲーム(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。