マインスイーパでオブジェクト指向の設計を学んだ
はじめに
オブジェクト指向の学び方について悩んだことはありませんか?
私は設計の技術書を読んでもイマイチ腹に落ちない感覚を何度も味わってきました。
本記事では、「マインスイーパ」を題材としてオブジェクト指向の設計をします。私はこの設計を通じてオブジェクト指向がチョットワカルになったので是非最後まで読んでいただけると嬉しいです。
対象読者
- オブジェクト指向を学びたい方
- ソフトウェア設計の技術書を1冊読んだことがある方
- ソフトウェア設計に興味がある方
- (番外編にて)Rubyのメタプログラミングを学びたい方
サンプルコード
サンプルコードはこちらです。
メインロジックはRubyで書かれていますが、WebAssembly上で実行可能にするruby.wasmを利用しています。そのため、Rubyの実行環境がなくてもhttpサーバーを立ち上げてブラウザからindex.htmlにアクセスすればマインスイーパをプレイすることができます。
マインスイーパーとは
マインスイーパ(Minesweeper)は、1980年代に発明された一人用のコンピュータゲームである。ゲームの目的は地雷原から地雷を取り除くこと(地雷除去)である。マインスイーパーとも表記される。
上記GIFはサンプルコードをwebブラウザ上で動かしている例です。地雷以外のセルを全て開けることでクリアとなります。セルに書かれた数字は8近傍(上・右上・右・右下・下・左下・左・左上)に存在する地雷の数を示しています。
仕様
今回実装するマインスイーパの仕様です。変更容易性の高い疎結合な設計を行い、今後追加する機能を実装しやすくすることを目指します。
- 地雷
- 地雷の目印として指定したセルに旗を立てることができる
- 地雷が開かれるとゲームオーバーになり、ゲームが終了する
- 地雷があるセル以外を全て開くとゲームクリアになり、ゲームが終了する
- セル
- 開いたセルが近傍に未開封のセルを持つ場合、近傍の地雷の数を表示する
- セルを開いたとき、近傍の地雷がないセルを自動で連続的に開く。ただし、近傍に地雷があるセルに到達した場合、そのセルで処理を停止する
- ゲーム
- 難易度を選択することができる
単一責務の原則と概念整理
単一責務の原則とは
単一責務の原則とはソフトウェア設計におけるSOLID原則の一つで、「モジュールを変更する理由をたった一つにするべき」だと定義されています。
概念整理とは
概念とはソフトウェア設計において対象を抽象的に表現するものです。しかし、近しい対象を扱う概念同士はその境界線が曖昧になることが多いです。そこで概念整理を行い境界線を明確にすることが重要です。具体的には以下のステップを踏みます。
- 責務を洗い出して言語化する
- 責務の境界線を明確にする
概念整理を行い、各概念ごとについて単一責務の原則を満たすことで変更容易性の高い疎結合な設計を行うことができます。
設計
以下は全体のクラス図になります。依存関係は黒矢印、継承は白矢印、実装は白点線矢印で表現しています。Rubyには言語としてJavaのようなインターフェースが存在しませんが、クラスがオブジェクトの振る舞いに依存している場合はインターフェースに依存しているものとします。
Cellクラス
Cellクラスはセル自身の状態に対する責務を持ちます。
Cellクラスは様々なクラスから間接的に依存されるクラスです。そのため、変更が少ない安定したクラスにする必要があります。そこで最もコアな概念であるセルの状態のみ責務を持つよう設計しました。
Cellクラスが責務外の知識を持っている例として、セルが自身の座標についての責務を持つとします。セルが座標の存在を知っていることは暗黙的に座標平面に存在していることに依存していることになります。これはセルが3次元であった場合などにはCellクラスを修正する必要があります。
❌ 座標についての責務を持つ場合
-
x
とy
には座標平面への依存が存在する
module Domains
class Cell < Base
attr_accessor :x, :y
def initialize(bomb:, x:, y:)
@bomb = bomb
@flag = false
@revealed = false
@x = x
@y = y
end
...
end
end
🟢 全体実装
- 状態に対する責務のみを持つ
module Domains
class Cell < Base
def initialize(bomb:)
@bomb = bomb
@flag = false
@revealed = false
end
def bomb?
@bomb
end
def flag?
@flag
end
def revealed?
@revealed
end
def toggle_flag
@flag = !@flag
end
def reveal
@revealed = true
end
end
end
CellWithNeighborsクラス
CellWithNeighborsクラスはセル自身の状態とその近傍のセルを認知する責務を持ちます。
マインスイーパでは「近傍の地雷の数を表示する」や「近傍の地雷がないセルを自動で連続的に開く」などの仕様があり、「近傍」は重要な関心ごとです。状態を管理するCellクラスと近傍を管理するCellWithNeighborsクラスを分離したことで、それぞれの責務が明確になります。
CellWithNeighborsクラスにCellクラスの振る舞いを持たせるためにbaseに振る舞いを委譲させています。これにより、Cellクラスからの継承による実装と比較してCellクラスへ依存を抑えつつ、振る舞いを再利用できます。
❌ Cellクラスの継承の場合
-
Cell
への依存が大きくなる -
super
により不要なインターフェースであっても暗黙的に取得してしまう
module Domains
class CellWithNeighbors < Cell
def initialize(bomb:, neighbors: [])
super(bomb:)
...
end
...
end
end
🟢 baseオブジェクトへ委譲
-
base
の振る舞いにのみ依存し、Cell
への依存を小さくできる -
def_delegators
による委譲によりインターフェースを明示的にできる
module Domains
class CellWithNeighbors < Base
extend Forwardable
def initialize(base:, neighbors: [])
@base = base
...
end
def_delegators :@base, :bomb?, :flag?, :revealed?, :toggle_flag, :reveal
end
end
neighborは近傍のセルを表しています。CellWithNeighborsクラスがインスタンス変数としてneigborの配列を持っているため、クラス内で近傍に関して座標への依存が無くなっています。その結果、複雑になりがちなセルの自動開封をシンプルに実装することができました。
❌ GridCellsクラスが自動開封の責務を持つ場合
- 自動開封
reveal_cell(x:, y:)
の実装に座標の知識が必要 -
neighbor.coordinations(x:, y:)
で近傍の座標を都度計算する必要がある
module Domains
class GridCells < Base
def reveal_cell(x:, y:)
cell = grid_cells[y][x]
cell.reveal
return if cell.bomb?
return unless cell.neighbor_bomb_cell_count.zero?
neighbor.coordinations(x:, y:).each do |nx, ny|
reveal_cell(x: nx, y: ny) if grid_cells[ny][nx].revealed?
end
end
end
end
🟢 自動開封の実装
- 座標の知識が必要ない
- 近傍
neighbors
が初期化時に計算されている
module Domains
class CellWithNeighbors < Base
...
def reveal_with_neighbors
reveal
# NOTE: 選択したセルが地雷の場合は再帰処理を終了
return if bomb?
# NOTE: 隣接したセルに地雷がある場合は再帰処理を終了
return unless neighbor_bomb_cell_count.zero?
neighbors.each do |neighbor|
neighbor.reveal_with_neighbors unless neighbor.revealed?
end
end
...
end
end
🟢 全体実装
require 'forwardable'
module Domains
class CellWithNeighbors < Base
extend Forwardable
attr_reader :neighbor_bomb_cell_count
def initialize(base:, neighbors: [])
@base = base
@neighbors = neighbors
@neighbor_bomb_cell_count = count_neighbor_bomb_cell
end
def_delegators :@base, :bomb?, :empty?, :flag?, :revealed?, :toggle_flag, :reveal
def neighbors
@neighbors.dup
end
def add_neighbor(neighbor:)
@neighbors << neighbor
@neighbor_bomb_cell_count += 1 if neighbor.bomb?
end
def reveal_with_neighbors
reveal
# NOTE: 選択したセルが地雷の場合は再帰処理を終了
return if bomb?
# NOTE: 隣接したセルに地雷がある場合は再帰処理を終了
return unless neighbor_bomb_cell_count.zero?
neighbors.each do |neighbor|
neighbor.reveal_with_neighbors unless neighbor.revealed?
end
end
def count_revealed_cell
neighbors.count(&:revealed?)
end
private
def count_neighbor_bomb_cell
neighbors.count(&:bomb?)
end
end
end
GridCellsクラス
GridCellsクラスはセルについての二次元配列のデータを保持し、それに対するインターフェースを提供する責務を持ちます。開封や旗立てなどの操作はpositionインターフェースを通じて行います。これにより、GridCellsを利用するクラスは内部が二次元配列であることを意識する必要がなくなります。
🟢 GridCellsのインターフェース
- 二次元配列
cells
を外部から隠蔽している -
position
を通じてセルの操作を行う
module Base
class GridCells < Base
...
def reveal_with_neighbors(position:)
@cells[position.y][position.x].reveal_with_neighbors
end
def toggle_flag(position:)
@cells[position.y][position.x].toggle_flag
end
def count_revealed_cell
@cells.flatten.count(&:revealed?)
end
def bombed?
@cells.flatten.any? { |cell| cell.bomb? && cell.revealed? }
end
def width
@cells.first.size
end
def height
@cells.size
end
...
end
end
GridCellsFactoryクラス
GridCellsFactoryクラスはGridCellsクラスの生成を行う責務を持ちます。いわゆるFactoryパターンによって実装しています。今回は難易度ごとに縦幅、横幅、地雷の数を変更するだけで良く、生成方法ごとにクラスを分けるほど複雑ではないため、横幅・縦幅・地雷数のハッシュをハードコードしています。「おすすめの盤面をプレイしたいなど」仕様として生成方法が増えてきた場合、クラスを分ける方針です。
🟢 全体実装
module Domains
module GridCellsFactory
class << self
def create(difficulty)
conditions = case difficulty
when Domains::Minesweeper::EASY
{ width: 5, height: 5, num_bombs: 3 }
when Domains::Minesweeper::NORMAL
{ width: 9, height: 9, num_bombs: 10 }
when Domains::Minesweeper::HARD
{ width: 16, height: 16, num_bombs: 40 }
else
raise ArgumentError, "unknown difficulty: #{difficulty}"
end
create_by_conditions(conditions)
end
private
def create_by_conditions(conditions)
num_cells = conditions[:width] * conditions[:height]
num_empty_cells = num_cells - conditions[:num_bombs]
cells = Array.new(conditions[:num_bombs]) { CellWithNeighbors.new(base: Cell.new(bomb: true)) } +
Array.new(num_empty_cells) { CellWithNeighbors.new(base: Cell.new(bomb: false)) }
grid_cells = cells.shuffle.each_slice(conditions[:width]).to_a
grid_cells.each_with_index do |row, y|
row.each_with_index do |cell, x|
coordinations(x: x, y: y, width: conditions[:width], height: conditions[:height]).each do |nx, ny|
cell.add_neighbor(neighbor: grid_cells[ny][nx])
end
end
end
GridCells.new(cells: grid_cells)
end
def coordinations(x:, y:, width:, height:)
num_neighbors = 8
dx = [0, 1, 1, 1, 0, -1, -1, -1]
dy = [-1, -1, 0, 1, 1, 1, 0, -1]
num_neighbors.times.map { |i| [x + dx[i], y + dy[i]] }
.select { |nx, ny| nx.between?(0, width - 1) && ny.between?(0, height - 1) }
end
end
end
end
Minesweeperクラス
MinesweeperクラスはGridCellsクラスを利用して、直接ゲームを操作するインターフェースを提供し、ゲームの進行を管理します。ゲームの終了条件をチェックし、終了条件を満たした場合にゲームを終了します。ゲーム終了条件がより複雑になった場合は終了条件を別クラスに切り出します。
🟢 全体実装
module Domains
class Minesweeper < Base
attr_reader :grid_cells
EASY = :easy
NORMAL = :normal
HARD = :hard
def initialize(difficulty)
@grid_cells = GridCellsFactory.create(difficulty)
@finished = false
end
def reveal_cell(x, y)
grid_cells.reveal_cell(position: Position.new(x:, y:))
check_finish_after_reveal
end
def toggle_flag(x, y)
grid_cells.toggle_flag(position: Position.new(x:, y:))
end
def finished?
@finished
end
private
def check_finish_after_reveal
@finished = grid_cells.bombed? || grid_cells.num_empties == grid_cells.count_revealed_cell
end
end
end
テスト
本設計では概念整理を行い、単一責務の原則に従うことで疎結合な設計となり、結果としてテストの記述が容易になりました。
以下はCellクラスとCellWithNeighborsクラスのテスト実装例です。
番外編 バリデーションとメタプログラミング
今までに紹介したクラス(以後、ドメインクラスと呼びます)は操作や状態についてのバリデーションを実装していません。今回はそれぞれのドメインクラスに対して、バリデータクラスを実装する方針を取ります。例えば、Cellクラスに対応するCellValidatorクラスを実装します。
番外編では各ドメインクラスがバリデーションロジックを意識せずに、自動的に各振る舞いに対してバリデーションが実行される仕組みをRubyのメタプログラミングによって実現したいと思います。
ドメインクラスにバリデーションを適用
各ドメインクラスはバリデーションを適用するメソッドをwith_validationによって指定します。Baseクラスを継承することでwith_validationを利用できるようになります。
🟢 バリデーションを適用するメソッドを指定
module Domains
class Cell < Base
...
with_validation :toggle_flag, :reveal
...
end
end
バリデータクラスではwith_validationによって指定されたメソッドに対して、validate_{メソッド名}!を実装することで振る舞いに対してのバリデーションを実行することができます。
🟢 バリデータクラスの実装
module Domains
module Validators
class CellValidator < Base
def validate_toggle_flag!
errors.add('Cannot toggle_flag a revealed cell') if revealed?
raise Error, errors.full_messages unless errors.empty?
end
def validate_reveal!
errors.add('Cannot reveal a revealed cell') if revealed?
raise Error, errors.full_messages unless errors.empty?
end
end
end
end
Domains::Baseクラス
Baseクラスは継承された際に、with_validationメソッドとバリデータクラスを継承元のドメインクラスへ定義します。
🟢 継承時のフック
module Domains
class Base
def self.inherited(subclass)
subclass.extend(ClassMethods)
define_validator_class(subclass)
end
...
end
end
🟢 バリデータクラスを定義するメソッド
module Domains
class Base
...
def self.define_validator_class(subclass)
subclass_name = subclass.to_s.split('::').last
validator_class_name = "::Domains::Validators::#{subclass_name}Validator"
validator_class = if Domains::Validators.const_defined?(validator_class_name)
Domains::Validators.const_get(validator_class_name)
else
Domains::Validators::Base
end
# NOTE: サブクラスのクラスインスタンス変数にバリデータクラスをセット
subclass.instance_variable_set('@validator_class', validator_class)
end
end
end
with_validationメソッドは、指定されたメソッドが実行されたタイミングでバリデータクラス内の対応するバリデーションを実行させます。仕組みとしては元のメソッドをラップして、バリデーションを実行する新しいメソッドを定義します。
🟢 with_validationメソッド
- 指定されたメソッドに対応するバリデーションメソッドを呼び出す
- alias_methodで元のメソッドを別名に変更する
- 元のメソッドをラップして、バリデーションを実行する新しいメソッドを定義する
module Domains
class Base
...
module ClassMethods
def validator_class
@validator_class
end
def with_validation(*method_names)
method_names.each do |method_name|
unless method_defined?(method_name) || private_method_defined?(method_name)
raise NotImplementedError, "#{self}##{method_name} must be implemented"
end
validate_method_name = "validate_#{method_name}!"
unless validator_class.method_defined?(validate_method_name)
raise NotImplementedError, "#{validator_class}##{validate_method_name} must be implemented"
end
# NOTE: 元のメソッドをラップする
alias_method "original_#{method_name}", method_name
define_method(method_name) do |*args, **kwargs, &block|
validator = self.class.validator_class.new(self)
validator.send(validate_method_name, *args, **kwargs)
send("original_#{method_name}", *args, **kwargs, &block)
end
end
end
...
end
end
end
まとめ
今回はマインスイーパを題材にオブジェクト指向の設計を行いました。概念整理を正しく行い、責務を正しく分離することができました。その結果テストを書くことが容易になりました。
学習においてインプットとアウトプットの適切な比率は3:7とも言われています。技術書や記事を読むことも大切ですが、実際に手を動かして設計を行うことで理解が深まるので、今後も継続的にアウトプットを行いたいと思います。
Discussion