はじめに
この記事はブラックジャックゲームで学ぶ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クラスがゲームプレイだけでなくプレイヤーと手札の管理をしている。 -
なぜ良くないのか
プレイヤーや手札が変動した場合のメンテナンスや機能追加が難しくなる。 -
どうするべきか
プレイヤークラスや手札クラスを別々に作成して役割を分割する。
このクラス設計のまま機能が追加された場合どうなるかを確認してみます
- 手札にカードを追加する際に、同じカードが既に手札に存在していたら追加できないようにする。
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
さいごに
最後まで読んでいただきありがとうございます。ここまでアンチパターンからクラス設計の原則について考えてみました。
アンチパターンを避けることで、より良いクラス設計を実現することができます。いま学習されているという方は是非、これらの原則を意識してクラス設計に取り組んでみてください。