Skip to content

Week 15 - Inheritance

Object-Oriented Programming - Inheritance

Learning Objectives

  1. Understand the concept of inheritance and its role in object-oriented programming.
  2. Implement inheritance to extend the functionality of base classes.
  3. Learn how to override methods and call methods from parent classes.
  4. Explore real-world examples to deepen understanding.

Introduction to Inheritance

Inheritance allows a child class to acquire the properties and methods of its parent class (i.e. the class the child is inheriting from). It helps in code reuse and creates a hierarchical relationship between classes.

Example

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Parent class
class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return f"{self.name} makes a sound."

# Child class
class Dog(Animal):
    def speak(self):
        return f"{self.name} barks."

# Demonstration
generic_animal = Animal("Generic Animal")
dog = Dog("Buddy")

print(generic_animal.speak())  # Output: Generic Animal makes a sound.
print(dog.speak())             # Output: Buddy barks.

Important Concepts about Inheritance

  • Though not demonstrated in the code above, inheritance avoids duplicating code by allowing child classes to inherit already implmented methods from the parent..
  • Demonstrated abovee, overriding methods allows specialization of behavior in child classes (i.e. the dog objects now bark instead of simply makeing sound)..

Demonstrating OOP Inheritance with Cards

To demonstrate OOP and inheritance, we first start off with a simple object blueprint for a Card. There is no inheritance in the Card class, but you should see how we compose Cards into a Deck class in the UML and of course the code below.

As you can see from the UML and code, the Deck class demonstrates inheritance by creating a hierarchy of card-related classes like Hand, PokerHand and BridgeHand (see UML below).

classDiagram class Card { - suit: int - rank: int + suit_names: list + rank_names: list + __init__(suit: int, rank: int) + __str__() str + __eq__(other: Card) bool + __lt__(other: Card) bool + __le__(other: Card) bool + to_tuple() tuple } class Deck { - cards: list + __init__(cards: list) + make_cards() list + __str__() str + take_card() Card + put_card(card: Card) + shuffle() + sort() + move_cards(other: Deck, num: int) + deal_hand(label: str, num_cards: int) Hand } class Hand { - label: str - cards: list + __init__(label: str) + add_card(card: Card) + remove_card(card: Card) + __str__() str } class PokerHand { + __init__(label: str) + evaluate_hand() str } class BridgeHand { + __init__(label: str) + evaluate_hand() dict } Deck <|-- Hand Deck *-- Card Hand <|-- PokerHand Hand <|-- BridgeHand

Example

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
from random import shuffle

class Card:
    suit_names = ["Clubs", "Diamonds", "Hearts", "Spades"]
    rank_names = [None, "Ace", "2", "3", "4", "5", "6", "7", "8", "9", "10", "Jack", "Queen", "King"]

    def __init__(self, suit: int, rank: int):
        self.suit = suit
        self.rank = rank

    def __str__(self):
        return f"{self.rank_names[self.rank]} of {self.suit_names[self.suit]}"

    def __eq__(self, other: 'Card') -> bool:
        return self.suit == other.suit and self.rank == other.rank

    def __lt__(self, other: 'Card') -> bool:
        return self.to_tuple() < other.to_tuple()

    def __le__(self, other: 'Card') -> bool:  # Example of using tuples directly
        return (self.suit, self.rank) <= (other.suit, other.rank)

    def to_tuple(self) -> tuple:
        return self.suit, self.rank


class Deck:
    def __init__(self, cards=None):
        self.cards = cards if cards else Deck.make_cards()

    @staticmethod
    def make_cards():
        return [Card(suit, rank) for suit in range(4) for rank in range(1, 14)]

    def __str__(self):
        return "\n".join(str(card) for card in self.cards)

    def take_card(self) -> Card:
        return self.cards.pop() if self.cards else None

    def put_card(self, card: Card):
        self.cards.append(card)

    def shuffle(self):
        shuffle(self.cards)

    def sort(self):
        self.cards.sort()

    def move_cards(self, other: 'Deck', num: int):
        for _ in range(num):
            other.put_card(self.take_card())


class Hand(Deck):
    def __init__(self, label: str):
        super().__init__()
        self.label = label
        self.cards = []

    def add_card(self, card: Card):
        self.put_card(card)

    def remove_card(self, card: Card):
        self.cards.remove(card)

    def __str__(self):
        return f"{self.label}: " + ", ".join(str(card) for card in self.cards)


class PokerHand(Hand):
    def __init__(self, label: str):
        super().__init__(label)

    def evaluate_hand(self) -> str:
        return "Evaluation of Poker Hand"


class BridgeHand(Hand):
    def __init__(self, label: str):
        super().__init__(label)

    def evaluate_hand(self) -> dict:
        return "Evaluation of Bridge Hand"

Expanding Functionality with Deck

Compostion: This was last week’s topic, but notice how the Deck class extends it’s own functionality by managing a collection of Card objects in the code above?

A few other things to notice

  • Static methods for creating structured data (e.g. creating the cards).
  • Delegating complex logic to helper methods.

Adding Hands with Inheritance

The Hand class inherits from Deck, specializing its behavior for card games.

Example Usage

1
2
3
4
5
6
7
# Usage
deck = Deck()
hand = Hand("Player 1")
hand.add_card(deck.deal_card())
hand.add_card(deck.deal_card())

print(hand)  # Output: Player 1's Hand: Random Card 1, Random Card 2

Notice a few things about the Hand code

  • super().__init__() is used to call the parent constructor.
  • The child doesn’t need to implement any methods the parent contains already, though the child class can override (i.e. take_card, etc.).
  • We can also provide helper methods or provide more appropriate method names that utilize the inherited methods (i.e. add_card).

Comparing Cards with Custom Methods - Pythonic Coding Tip

Custom comparison methods should use tuples for consistent ordering of cards.

Example

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Card:
    # Existing code...

    def to_tuple(self):
        return self.suit, self.rank

    def __lt__(self, other):
        return self.to_tuple() < other.to_tuple()

# Usage
card1 = Card(2, 12)  # Queen of Hearts
card2 = Card(3, 6)   # 6 of Spades

print(card1 < card2)  # Output: True

In General

  • Defining comparison behavior for sorting and compare logic when building classes and hierarchies is advised.
  • Using tuple-based comparisons increase readability and correctness since tuples are immutable.

Advanced Applications

Inheritance enables specialized decks, such as a deck for a specific card game.

Example

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class PokerDeck(Deck):
    def __init__(self):
        super().__init__()
        self.shuffle()  # Shuffle upon creation

    def deal_hand(self, num_cards):
        return [self.deal_card() for _ in range(num_cards)]

# Usage
poker_deck = PokerDeck()
hand = poker_deck.deal_hand(5)
for card in hand:
    print(card)

Homework/Practice

  1. Challenge: Create a BlackjackHand class that extends Hand, adding a method to calculate the total value of the cards.
  2. Reflection: Write a short paragraph on the advantages of using inheritance in the card-related examples.