4
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

アンチパターンから学ぶクラス設計

Last updated at Posted at 2023-04-14

はじめに

この記事はブラックジャックゲームで学ぶRubyのオブジェクト指向プログラミングの記事からさらに学習を進めて、良くないコードはどこが良くないのかをクラス設計の原則に照らして考えてみようという記事です。

良いコードの例:

ブラックジャックゲーム
class Card
  attr_reader :value, :suit  # カードの値とスートの読み取り専用アクセサを定義

  def initialize(value, suit)  # Cardクラスの初期化。値とスートを引数に取る
    @value = value  # カードの値をインスタンス変数に設定
    @suit = suit    # カードのスートをインスタンス変数に設定
  end

  def to_s  # カードの値とスートを文字列で返すメソッド
    "#{suit}#{value}"
  end

  def point  # カードのポイントを計算するメソッド
    case value
    when 'A' then 11
    when 'K', 'Q', 'J' then 10
    else value.to_i
    end
  end
end

class Deck
  attr_reader :cards  # デッキ内のカードの読み取り専用アクセサを定義

  def initialize  # Deckクラスの初期化
    @cards = build_deck  # デッキを構築
  end

  def build_deck  # 52枚のカードを含むデッキを構築してシャッフルするメソッド
    suits = ["ハート", "ダイヤ", "クラブ", "スペード"]  # 4種類のスートを表す配列
    values = %w(A 2 3 4 5 6 7 8 9 10 J Q K)  # 2から10までの数値およびA, K, Q, Jを表す配列
  
    # スートと値のすべての組み合わせを生成し、それぞれの組み合わせに対してCardインスタンスを作成
    # 最後に生成されたCardインスタンスの配列をシャッフルする
    suits.product(values).map { |suit, value| Card.new(value, suit) }.shuffle
  end
  

  def draw  # デッキから一枚のカードを引くメソッド
    cards.pop
  end
end

class Hand
  attr_accessor :cards  # 手札のカードのアクセサを定義

  def initialize  # Handクラスの初期化
    @cards = []
  end

  def add_card(card)  # 手札にカードを追加するメソッド
    cards << card
  end

  def points # 手札の合計ポイントを計算するメソッド
    aces = 0  # エースの数をカウントするための変数を初期化
  
    # cards配列(手札)にあるすべてのカードに対して、合計ポイントを計算
    total = cards.inject(0) do |sum, card|
      aces += 1 if card.value == 'A'  # カードがエースの場合、エースの数を1増やす
      sum + card.point  # 現在の合計ポイントにカードのポイントを加算
    end
  
    # エースの数だけループを回して、合計ポイントが21を超えている場合は10を引く
    aces.times { total -= 10 if total > 21 }
    total  # 最終的な合計ポイントを返す
  end

  def busted?  # 手札がバーストしているかどうかを判定するメソッド
    points > 21
  end
end

class Player
  attr_reader :hand  # プレイヤーの手札の読み取り専用アクセサを定義

  def initialize  # Playerクラスの初期化
    @hand = Hand.new
  end

  def hit(card)  # プレイヤーがカードをヒットするメソッド
    hand.add_card(card)
  end
end

class Dealer < Player  # DealerクラスはPlayerクラスを継承
  def show_one_card  # ディーラーが一枚のカードを見せるメソッド
    hand.cards.first
  end
end

class Game
  attr_reader :player, :dealer, :deck  # プレイヤー、ディーラー、デッキの読み取り専用アクセサを定義

  def initialize  # Gameクラスの初期化
    @player = Player.new
    @dealer = Dealer.new
    @deck = Deck.new
  end

  def deal_initial_cards  # プレイヤーとディーラーに初期カードを配るメソッド
    2.times do
      player.hit(deck.draw)
      dealer.hit(deck.draw)
    end
  end

  def play  # ゲームをプレイするメソッド
    puts "ブラックジャックを開始します。"
    deal_initial_cards

    puts "あなたの引いたカードは#{player.hand.cards[0]}です。"
    puts "あなたの引いたカードは#{player.hand.cards[1]}です。"
    puts "ディーラーの引いたカードは#{dealer.show_one_card}です。"
    puts "ディーラーの引いた2枚目のカードはわかりません。"

    loop do
      puts "あなたの現在の得点は#{player.hand.points}です。カードを引きますか?(Y/N)"
      decision = gets.chomp.downcase
      break if decision == "n"
    
      if decision == "y"
        card = deck.draw
        player.hit(card)
        puts "あなたの引いたカードは#{card}です。"
        break if player.hand.busted?
      end
    end
    
    if player.hand.busted?
      puts "あなたの現在の得点は#{player.hand.points}です。"
      puts "バーストしました、あなたの負けです..."
    else
      puts "ディーラーの引いた2枚目のカードは#{dealer.hand.cards[1]}でした。"
      puts "ディーラーの現在の得点は#{dealer.hand.points}です。"

      while dealer.hand.points < 17
        card = deck.draw
        dealer.hit(card)
        puts "ディーラーの引いたカードは#{card}です。"
        puts "ディーラーの現在の得点は#{dealer.hand.points}です。"
      end

      if dealer.hand.busted?
        puts "ディーラーの得点は#{dealer.hand.points}です。あなたの勝ちです!"
      elsif player.hand.points > dealer.hand.points
        puts "あなたの得点は#{player.hand.points}です。"
        puts "ディーラーの得点は#{dealer.hand.points}です。あなたの勝ちです!"
      elsif player.hand.points < dealer.hand.points
        puts "あなたの得点は#{player.hand.points}です。"
        puts "ディーラーの得点は#{dealer.hand.points}です。あなたの負けです..."
      else
        puts "引き分けです。"
      end
      puts "ブラックジャックを終了します。"
    end
  end
end

Game.new.play

クラス設計の5つの原則

単一責任の原則(Single Responsibility Principle, SRP):

各クラスは、1つの責任を持つべきです。この原則に従って、良いコードの例では、それぞれのクラスが独立した役割を持っています。
例えば、Cardクラスはカードの情報を管理し、Deckクラスはカードの山を管理し、Handクラスは手札を管理します。

開放閉鎖の原則(Open/Closed Principle, OCP):

クラスは拡張に対してオープンで、修正に対してクローズドであるべきです。これは、既存のコードを変更せずに、新しい機能を追加できるようにすることです。良いコードの例では、新しいルールやカードの種類が追加されても、既存のクラスに影響を与えずに対応できるように設計されています。

リスコフの置換原則(Liskov Substitution Principle, LSP):

派生クラスは、その基底クラスと置換可能であるべきです。良いコードの例では、DealerクラスがPlayerクラスを継承しており、DealerクラスのオブジェクトはPlayerクラスのオブジェクトとして扱うことができます。これにより、コードの再利用性が向上します。

インターフェイス分離の原則(Interface Segregation Principle, ISP):

クライアントが不要なインターフェイスに依存しないように、インターフェイスを分離することが重要です。良いコードの例では、各クラスが独立した役割を持っており、他のクラスとのやり取りは最小限に抑えられています。これにより、コードの変更が容易になります。

依存性逆転の原則(Dependency Inversion Principle, DIP):

高レベルのモジュールは、低レベルのモジュールに依存すべきではなく、両者は抽象に依存すべきです。良いコードの例では、各クラスが他のクラスと明確なインターフェイスを介してやり取りを行っており、直接的な依存関係が最小限に抑えられています。これにより、コードの保守性と拡張性が向上します。

このような設計原則に従うことで、コードが整理され、将来的にも変更や拡張が容易になるため、開発効率が向上します。ブラックジャックのコードは、これらの原則に基づいてクラスが設計されており、ゲームの各要素が独立した役割を持ち、お互いに協力してゲームを進める構造になっています。

アンチパターンから学ぶ

では原則を確認した上でAIに悪いコードを書いてもらいました。

class Card
  attr_accessor :suit, :value

  def initialize(suit, value)
    @suit = suit
    @value = value
  end
end

class Deck
  attr_accessor :cards

  def initialize
    @cards = []
    suits = ['hearts', 'diamonds', 'clubs', 'spades']
    values = (1..13).to_a
    suits.each do |suit|
      values.each do |value|
        @cards << Card.new(suit, value)
      end
    end
  end

  def shuffle
    @cards.shuffle!
  end
end

class Blackjack
  attr_accessor :deck, :player_hand, :dealer_hand

  def initialize
    @deck = Deck.new
    @player_hand = []
    @dealer_hand = []
  end

  def deal
    @player_hand << @deck.cards.pop
    @dealer_hand << @deck.cards.pop
    @player_hand << @deck.cards.pop
    @dealer_hand << @deck.cards.pop
  end

  def display_hands
    puts "Player hand: #{@player_hand.map { |card| "#{card.value} of #{card.suit}" }}"
    puts "Dealer hand: #{@dealer_hand.map { |card| "#{card.value} of #{card.suit}" }}"
  end

  def hit(hand)
    hand << @deck.cards.pop
  end

  def score(hand)
    total = 0
    hand.each do |card|
      if card.value == 1
        total += 11
      elsif card.value > 10
        total += 10
      else
        total += card.value
      end
    end
    hand.select { |card| card.value == 1 }.count.times do
      total -= 10 if total > 21
    end
    total
  end

  def play
    @deck.shuffle
    deal
    display_hands
    # ゲームのロジックは省略
  end
end

blackjack = Blackjack.new
blackjack.play

このコードのどこが良くないのか確認してみましょう💡

1.単一責任の原則: 各クラスは、1つの責任を持つ

  • どこが良くないのか
    Blackjackクラスがゲームプレイだけでなくプレイヤーと手札の管理をしている。

  • なぜ良くないのか
    プレイヤーや手札が変動した場合のメンテナンスや機能追加が難しくなる。

  • どうするべきか
    プレイヤークラスや手札クラスを別々に作成して役割を分割する。

このクラス設計のまま機能が追加された場合どうなるかを確認してみます

  1. 手札にカードを追加する際に、同じカードが既に手札に存在していたら追加できないようにする。
def hit(hand)
  new_card = @deck.cards.pop
  # 同じカードが既に手札に存在するかどうかをチェック
  card_exists = hand.any? { |card| card.suit == new_card.suit && card.value == new_card.value }

  # 同じカードが存在しない場合のみ、手札に追加
  hand << new_card unless card_exists
end

この変更を行うと、hitメソッドが手札の管理だけでなく、重複チェックの役割も持つことになります。このような変更が繰り返されると、コードが複雑になり、メンテナンスや機能追加が困難になります。

2.開放閉鎖の原則: クラスは拡張に対してオープンで、修正に対してクローズドである

  • どこが良くないのか
    Blackjackクラスが直接CardクラスとDeckクラスを操作している。

  • なぜ良くないのか
    新しいルールや機能を追加する場合、既存のBlackjackクラスを変更しなければならず、メンテナンス性が低下する。

  • どうするべきか
    Blackjackクラスが直接CardクラスやDeckクラスを操作するのではなく、抽象的なインターフェイスを介して操作できるように設計する。

カードを追加するための別のクラスやメソッドを作成し、それらを具体的な実装を隠蔽して、外部のクラスやメソッドが必要な機能を使うことができるようにするためのインターフェースで利用できるようにすることで、新しいルールやカードの追加方法が必要になった場合でも、既存のコードを変更せずに拡張できるようになります。

3.リスコフの置換原則: 派生クラスが基本クラスと置き換えられるようにする

悪いコード例の中ではリフコフの置換原則に反している箇所はありません。
ただし、リスコフの置換原則を満たすために、拡張性を意識したコード設計を行うことが重要です。

  • どうするべきか
    例えば、Cardクラスを継承して別のゲーム(ポーカーなど)のカードを表現するクラスを作成する場合、以下の点に注意してください。
    • 基本クラスのメソッドをオーバーライドする場合は、派生クラスのメソッドが基本クラスと同じ契約(引数、戻り値の型や範囲)を持つようにする。
    • 基本クラスのメソッドをオーバーライドする場合は、基本クラスのメソッドが満たすべき不変条件を維持するようにする。
    • 派生クラスで新しいメソッドを追加する場合は、基本クラスに影響を与えないようにする。

4.インターフェイス分離の原則: クラスが不必要なインターフェイスを持たないようにする

  • どこが良くないのか
    Deckクラスが、外部からカードを取得する方法を提供していない。

  • なぜ良くないのか
    Deckクラスが外部からカードを取得する方法を提供していないため、Blackjackクラスがデッキ内部のカード配列に直接アクセスする必要があります。これにより、Deckクラスのカード管理の詳細が他のクラスに漏れてしまい、カプセル化が破られる。

  • どうするべきか
    Deckクラスに、外部からカードを取得するためのメソッド(例えば draw_card)を追加します。これにより、BlackjackクラスはDeckクラスの内部実装に依存せず、必要なインターフェイスのみを利用することができます。

5.依存性逆転の原則: 高水準のモジュールが低水準のモジュールに依存するのではなく、両方が抽象に依存するようにする

  • どこが良くないのか
    Blackjackクラスが具体的なDeckクラスとCardクラスに依存している。

  • なぜ良くないのか
    デッキやカードの実装が変更されると、Blackjackクラスも修正が必要になる。

  • どうするべきか
    抽象化したインターフェイスや基底クラスを導入し、具体的な実装に依存しないようにします。

    • 例えば、DeckクラスとCardクラスに対して、それぞれIDeckとICardというインターフェイスを導入し、Blackjackクラスはこれらのインターフェイスに依存するようにします。これにより、DeckやCardの具体的な実装が変更されても、Blackjackクラスは影響を受けなくなります。

具体的なコード例は以下のとおりです。

module ICard
  # ICard インターフェイスのメソッド定義
end

module IDeck
  # IDeck インターフェイスのメソッド定義
end

class Card
  include ICard
  # Card クラスの実装
end

class Deck
  include IDeck
  # Deck クラスの実装
end

class Blackjack
  def initialize(deck: IDeck, card: ICard)
    @deck = deck.new
    @card = card
    # ...
  end

  # ...
end

さいごに

最後まで読んでいただきありがとうございます。ここまでアンチパターンからクラス設計の原則について考えてみました。
アンチパターンを避けることで、より良いクラス設計を実現することができます。いま学習されているという方は是非、これらの原則を意識してクラス設計に取り組んでみてください。

4
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?