💣

マインスイーパでオブジェクト指向の設計を学んだ

2025/02/10に公開

はじめに

オブジェクト指向の学び方について悩んだことはありませんか?

私は設計の技術書を読んでもイマイチ腹に落ちない感覚を何度も味わってきました。

本記事では、「マインスイーパ」を題材としてオブジェクト指向の設計をします。私はこの設計を通じてオブジェクト指向がチョットワカルになったので是非最後まで読んでいただけると嬉しいです。

対象読者

  • オブジェクト指向を学びたい方
  • ソフトウェア設計の技術書を1冊読んだことがある方
  • ソフトウェア設計に興味がある方
  • (番外編にて)Rubyのメタプログラミングを学びたい方

サンプルコード

サンプルコードはこちらです。

https://github.com/Yuhi-Sato/minesweeper

メインロジックはRubyで書かれていますが、WebAssembly上で実行可能にするruby.wasmを利用しています。そのため、Rubyの実行環境がなくてもhttpサーバーを立ち上げてブラウザからindex.htmlにアクセスすればマインスイーパをプレイすることができます。

マインスイーパーとは

マインスイーパ(Minesweeper)は、1980年代に発明された一人用のコンピュータゲームである。ゲームの目的は地雷原から地雷を取り除くこと(地雷除去)である。マインスイーパーとも表記される。

Wikipediaより

上記GIFはサンプルコードをwebブラウザ上で動かしている例です。地雷以外のセルを全て開けることでクリアとなります。セルに書かれた数字は8近傍(上・右上・右・右下・下・左下・左・左上)に存在する地雷の数を示しています。

仕様

今回実装するマインスイーパの仕様です。変更容易性の高い疎結合な設計を行い、今後追加する機能を実装しやすくすることを目指します。

  • 地雷
    • 地雷の目印として指定したセルに旗を立てることができる
    • 地雷が開かれるとゲームオーバーになり、ゲームが終了する
    • 地雷があるセル以外を全て開くとゲームクリアになり、ゲームが終了する
  • セル
    • 開いたセルが近傍に未開封のセルを持つ場合、近傍の地雷の数を表示する
    • セルを開いたとき、近傍の地雷がないセルを自動で連続的に開く。ただし、近傍に地雷があるセルに到達した場合、そのセルで処理を停止する
  • ゲーム
    • 難易度を選択することができる

単一責務の原則と概念整理

単一責務の原則とは

単一責務の原則とはソフトウェア設計におけるSOLID原則の一つで、「モジュールを変更する理由をたった一つにするべき」だと定義されています。

https://blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html

概念整理とは

概念とはソフトウェア設計において対象を抽象的に表現するものです。しかし、近しい対象を扱う概念同士はその境界線が曖昧になることが多いです。そこで概念整理を行い境界線を明確にすることが重要です。具体的には以下のステップを踏みます。

  • 責務を洗い出して言語化する
  • 責務の境界線を明確にする

概念整理を行い、各概念ごとについて単一責務の原則を満たすことで変更容易性の高い疎結合な設計を行うことができます。

設計

以下は全体のクラス図になります。依存関係は黒矢印、継承は白矢印、実装は白点線矢印で表現しています。Rubyには言語としてJavaのようなインターフェースが存在しませんが、クラスがオブジェクトの振る舞いに依存している場合はインターフェースに依存しているものとします。

Overview

Cellクラス

Cell

Cellクラスはセル自身の状態に対する責務を持ちます。

Cellクラスは様々なクラスから間接的に依存されるクラスです。そのため、変更が少ない安定したクラスにする必要があります。そこで最もコアな概念であるセルの状態のみ責務を持つよう設計しました。

Cellクラスが責務外の知識を持っている例として、セルが自身の座標についての責務を持つとします。セルが座標の存在を知っていることは暗黙的に座標平面に存在していることに依存していることになります。これはセルが3次元であった場合などにはCellクラスを修正する必要があります。

❌ 座標についての責務を持つ場合

  • xyには座標平面への依存が存在する
cell.rb
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

🟢 全体実装

  • 状態に対する責務のみを持つ
cell.rb
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

CellWithNeighborsクラスはセル自身の状態とその近傍のセルを認知する責務を持ちます。

マインスイーパでは「近傍の地雷の数を表示する」や「近傍の地雷がないセルを自動で連続的に開く」などの仕様があり、「近傍」は重要な関心ごとです。状態を管理するCellクラスと近傍を管理するCellWithNeighborsクラスを分離したことで、それぞれの責務が明確になります。

base

CellWithNeighborsクラスにCellクラスの振る舞いを持たせるためにbaseに振る舞いを委譲させています。これにより、Cellクラスからの継承による実装と比較してCellクラスへ依存を抑えつつ、振る舞いを再利用できます。

❌ Cellクラスの継承の場合

  • Cellへの依存が大きくなる
  • superにより不要なインターフェースであっても暗黙的に取得してしまう
cell_with_neighbors.rb
module Domains
  class CellWithNeighbors < Cell
    def initialize(bomb:, neighbors: [])
      super(bomb:)
      ...
    end
    ...
  end
end

🟢 baseオブジェクトへ委譲

  • baseの振る舞いにのみ依存し、Cellへの依存を小さくできる
  • def_delegatorsによる委譲によりインターフェースを明示的にできる
cell_with_neighbors.rb
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

neighborは近傍のセルを表しています。CellWithNeighborsクラスがインスタンス変数としてneigborの配列を持っているため、クラス内で近傍に関して座標への依存が無くなっています。その結果、複雑になりがちなセルの自動開封をシンプルに実装することができました。

❌ GridCellsクラスが自動開封の責務を持つ場合

  • 自動開封reveal_cell(x:, y:)の実装に座標の知識が必要
  • neighbor.coordinations(x:, y:)で近傍の座標を都度計算する必要がある
cell_with_neighbors.rb
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が初期化時に計算されている
cell_with_neighbors.rb
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

🟢 全体実装

cell_with_neighbors.rb
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クラスはセルについての二次元配列のデータを保持し、それに対するインターフェースを提供する責務を持ちます。開封や旗立てなどの操作はpositionインターフェースを通じて行います。これにより、GridCellsを利用するクラスは内部が二次元配列であることを意識する必要がなくなります。

🟢 GridCellsのインターフェース

  • 二次元配列cellsを外部から隠蔽している
  • positionを通じてセルの操作を行う
grid_cells.rb
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

GridCellsFactoryクラスはGridCellsクラスの生成を行う責務を持ちます。いわゆるFactoryパターンによって実装しています。今回は難易度ごとに縦幅、横幅、地雷の数を変更するだけで良く、生成方法ごとにクラスを分けるほど複雑ではないため、横幅・縦幅・地雷数のハッシュをハードコードしています。「おすすめの盤面をプレイしたいなど」仕様として生成方法が増えてきた場合、クラスを分ける方針です。

🟢 全体実装

grid_cells_factory.rb
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

MinesweeperクラスはGridCellsクラスを利用して、直接ゲームを操作するインターフェースを提供し、ゲームの進行を管理します。ゲームの終了条件をチェックし、終了条件を満たした場合にゲームを終了します。ゲーム終了条件がより複雑になった場合は終了条件を別クラスに切り出します。

🟢 全体実装

minesweeper.rb
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クラスのテスト実装例です。

https://github.com/Yuhi-Sato/minesweeper/blob/bf594e444e3948076277b66745d6faf914d33b7c/spec/cell_spec.rb#L3-L96

https://github.com/Yuhi-Sato/minesweeper/blob/bf594e444e3948076277b66745d6faf914d33b7c/spec/cell_with_neighbors_spec.rb#L3-L142

番外編 バリデーションとメタプログラミング

今までに紹介したクラス(以後、ドメインクラスと呼びます)は操作や状態についてのバリデーションを実装していません。今回はそれぞれのドメインクラスに対して、バリデータクラスを実装する方針を取ります。例えば、Cellクラスに対応するCellValidatorクラスを実装します。

番外編では各ドメインクラスがバリデーションロジックを意識せずに、自動的に各振る舞いに対してバリデーションが実行される仕組みをRubyのメタプログラミングによって実現したいと思います。

ドメインクラスにバリデーションを適用

各ドメインクラスはバリデーションを適用するメソッドをwith_validationによって指定します。Baseクラスを継承することでwith_validationを利用できるようになります。

🟢 バリデーションを適用するメソッドを指定

cell.rb
module Domains
 class Cell < Base
  ...
  with_validation :toggle_flag, :reveal
  ...
 end
end

バリデータクラスではwith_validationによって指定されたメソッドに対して、validate_{メソッド名}!を実装することで振る舞いに対してのバリデーションを実行することができます。

🟢 バリデータクラスの実装

validators/cell_validator.rb
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メソッドとバリデータクラスを継承元のドメインクラスへ定義します。

🟢 継承時のフック

domains/base.rb
module Domains
  class Base
    def self.inherited(subclass)
      subclass.extend(ClassMethods)
      define_validator_class(subclass)
    end
    ...
  end
end

🟢 バリデータクラスを定義するメソッド

domains/base.rb
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で元のメソッドを別名に変更する
  • 元のメソッドをラップして、バリデーションを実行する新しいメソッドを定義する
domains/base.rb
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