CIS 1051 - Temple Rome Spring 2023¶

Intro to Problem solving and¶

Programming in Python¶

LOGO

LOGO

Inheritance¶

Prof. Andrea Gallegati

( tuj81353@temple.edu )

One of the most important features of object-oriented programming is inheritance.

The ability to define a new class that is a modified version of an existing class.

We will use classes representing playing cards, decks of cards, and poker hands!

( If you don’t play poker, you can read about it at http://en.wikipedia.org/wiki/Poker )

Card Objects¶

  • 52 cards in a deck
  • each belongs to 1 of 4 suits and 1 of 13 ranks.
  • suits are Spades, Diamonds, Clubs and Hearts
  • ranks are Ace, 2, 3, 4, 5, 6, 7, 8, 9, 10, Jack, Queen, and King.

Depending on the game, Ace may be higher than King or lower than 2!

To represent a playing card with an object, it is:

  • it is obvious what the attributes should be rank and suit
  • it is not as obvious what type the attributes should be

One possibility is to use strings containing ranks names.

One problem with this implementation is that it would not be that easy to compare cards (higher rank/suit).

3

1

As alternative use integers to encode ranks and suits defining a mapping.

2

0

This kind of encoding is not meant to be a secret (“encryption”).

Thus, it's easy to compare cards. Higher suits map to higher numbers: we can compare suits by comparing their codes.

11

The ranks mapping is even more obvious. Each of the numerical ranks maps to the corresponding integer, and for face cards:

12

13

The Card class definition looks like this:

In [1]:
class Card:
    """Represents a standard playing card."""
    def __init__(self, suit=0, rank=2):
        self.suit = suit
        self.rank = rank

The default card thus, is the 2 of Clubs.

Otherwise, pass the optional parameters for each attribute to the constructor.

In [2]:
queen_of_diamonds = Card(1, 12)

Class Attributes¶

To print Card objects in an easy way, let's map from integer codes to corresponding ranks and suits.

In [3]:
class Card:
    """Represents a standard playing 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=0, rank=2):
        self.suit = suit
        self.rank = rank
    def __str__(self):
        return '%s of %s' % (Card.rank_names[self.rank],
                             Card.suit_names[self.suit])

For example, with above lists of strings, assigned to class attributes.

class attributes like suit_names and rank_names, are defined:

  • inside a class
  • outside of any method

being associated with the class object Card itself.

instance attributes like suit and rank, contrary are associated with a particular instance.

Every card has its own suit and rank, but there is only one copy of suit_names and rank_names.

Both kinds of attribute are accessed using dot notation.

For Example, Card.rank_names[self.rank] means:

“Use attribute rank from the object self as an index to select from the list rank_names from the class Card.”

The place-keeper None for the first element of rank_names is because there is no card with rank zero.

This way, we get a mapping with the nice property that the index 2 maps to the string 2, and so on.

Otherwise, use a dictionary instead of a list.

Let's create and print a card:

In [4]:
card1 = Card(2, 11)
print(card1)
Jack of Hearts

Comparing Cards¶

For built-in types, relational operators (<, >, ==, etc.) compare values.

For programmer-defined types, we can override built-in operators by providing a (“less than”) __lt__ method, for example.

__lt__ takes two parameters and return True if self is strictly less than other.

Correct ordering for cards is not obvious. The answer might depend on the game.

To keep things simple, we’ll make an arbitrary choice: suit is more important.

With that decided, we can write

In [3]:
class Card:
    """Represents a standard playing 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=0, rank=2):
        self.suit = suit
        self.rank = rank
    def __str__(self):
        return '%s of %s' % (Card.rank_names[self.rank],
                             Card.suit_names[self.suit])
    def __lt__(self, other):
        # check the suits
        if self.suit < other.suit: return True
        if self.suit > other.suit: return False

        # suits are the same... check ranks
        return self.rank < other.rank

... or more concisely (with tuple comparison)

In [3]:
class Card:
    """Represents a standard playing 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=0, rank=2):
        self.suit = suit
        self.rank = rank
    def __str__(self):
        return '%s of %s' % (Card.rank_names[self.rank],
                             Card.suit_names[self.suit])
    def __lt__(self, other):
        t1 = self.suit, self.rank
        t2 = other.suit, other.rank
        return t1 < t2

Decks¶

Once we have Cards, let's define Decks.

A deck is made up of cards: it's natural for each Deck to contain a list of cards as an attribute.

The following is a class definition for Deck. The init method creates the attribute cards and generates the standard set of 52 cards:

In [5]:
class Deck:
    def __init__(self):
        self.cards = []
        for suit in range(4):
            for rank in range(1, 14):
                card = Card(suit, rank)
                self.cards.append(card)

This nested loop populates the deck with the standard set of 52 cards:

  • The outer loop enumerates the suits from 0 to 3.
  • The inner loop enumerates the ranks from 1 to 13.

Each iteration appends a new Card to self.cards.

Printing the Deck¶

In [8]:
class Deck:
    def __init__(self):
        self.cards = []
        for suit in range(4):
            for rank in range(1, 14):
                card = Card(suit, rank)
                self.cards.append(card)
    def __str__(self):
        res = []
        for card in self.cards:
            res.append(str(card))
        return '\n'.join(res)

__str__ method print Deck objects, as a large string, in an efficient way.

It builds a list of strings (invoking __str__ on each card) and then invoke the string method join on a newline character \n to separate Card objects representation.

In [7]:
deck = Deck()
print(deck)
Ace of Clubs
2 of Clubs
3 of Clubs
4 of Clubs
5 of Clubs
6 of Clubs
7 of Clubs
8 of Clubs
9 of Clubs
10 of Clubs
Jack of Clubs
Queen of Clubs
King of Clubs
Ace of Diamonds
2 of Diamonds
3 of Diamonds
4 of Diamonds
5 of Diamonds
6 of Diamonds
7 of Diamonds
8 of Diamonds
9 of Diamonds
10 of Diamonds
Jack of Diamonds
Queen of Diamonds
King of Diamonds
Ace of Hearts
2 of Hearts
3 of Hearts
4 of Hearts
5 of Hearts
6 of Hearts
7 of Hearts
8 of Hearts
9 of Hearts
10 of Hearts
Jack of Hearts
Queen of Hearts
King of Hearts
Ace of Spades
2 of Spades
3 of Spades
4 of Spades
5 of Spades
6 of Spades
7 of Spades
8 of Spades
9 of Spades
10 of Spades
Jack of Spades
Queen of Spades
King of Spades

... this is just one long string that contains (52) newlines!

Add, Remove, Shuffle and Sort¶

To deal cards, we need a method to remove/return a card from the deck. The pop list method it's a convenient way to do that.

In [8]:
class Deck:
    def __init__(self):
        self.cards = []
        for suit in range(4):
            for rank in range(1, 14):
                card = Card(suit, rank)
                self.cards.append(card)
    def __str__(self):
        res = []
        for card in self.cards:
            res.append(str(card))
        return '\n'.join(res)
    def pop_card(self):
        return self.cards.pop()

This (pop) removes the last card in the list thus, we are dealing from the bottom of the deck.

To add a card, list method append is the most appropriate one:

In [8]:
class Deck:
    def __init__(self):
        self.cards = []
        for suit in range(4):
            for rank in range(1, 14):
                card = Card(suit, rank)
                self.cards.append(card)
    def __str__(self):
        res = []
        for card in self.cards:
            res.append(str(card))
        return '\n'.join(res)
    def pop_card(self):
        return self.cards.pop()
    def add_card(self, card):
        self.cards.append(card)

... methods like these are called a veneer, just using another method without doing much work.

It comes from woodworking: a veneer is a thin layer of good quality wood glued to the surface of a cheaper piece of wood to improve the appearance.

Here add_card is a “thin” method expressing a list operation in appropriate terms.

It improves the appearance thus, the interface, of the implementation.

Then, after importing random module, we can use the shuffle function to define a Deck method.

In [17]:
class Deck:
    def __init__(self):
        self.cards = []
        for suit in range(4):
            for rank in range(1, 14):
                card = Card(suit, rank)
                self.cards.append(card)
    def __str__(self):
        res = []
        for card in self.cards:
            res.append(str(card))
        return '\n'.join(res)
    def pop_card(self):
        return self.cards.pop()
    def add_card(self, card):
        self.cards.append(card)
    def shuffle(self):
        random.shuffle(self.cards)

Inheritance¶

the ability to define a new class that is a modified version of an existing class.

This relationship between classes – similar, but different – lends itself to inheritance.

Class representing “hands” (cards held by players) is similar to a deck:

  • made up of a collection of cards
  • adding/removing cards operations

But it is also different.

Some operations on hands don’t make sense for a deck:

  • compare two hands
  • compute a score for a hand

A new class (aka child) that inherits from an existing class (aka parent) is defined as follows:

In [11]:
class Hand(Deck):
    """Represents a hand of playing cards."""

putting the name of the parent class into parentheses.

With this definition Hand inherits from Deck methods like

  • pop_card
  • add_card

to be used by Hands as well as Decks.

Thus, Hand inherits the __init__ method too, from Deck.

But instead of populating the hand with all the 52 cards, it should initialize it with an empty list.

Providing for an __init__ method, overrides the one in the parent class.

In [18]:
class Hand(Deck):
    """Represents a hand of playing cards."""
    def __init__(self, label=''):
        self.cards = []
        self.label = label

Creating an Hand, Python invokes this __init__ method:

In [20]:
hand = Hand('new hand')
hand.cards
hand.label
Out[20]:
'new hand'

... and the other methods (pop_card and add_card) are inherited too, to deal a card:

In [21]:
deck = Deck()
card = deck.pop_card()
hand.add_card(card)
print(hand)
King of Spades

It is then natural to encapsulate it in a move_cards method:

In [18]:
class Hand(Deck):
    """Represents a hand of playing cards."""
    def __init__(self, label=''):
        self.cards = []
        self.label = label
    def move_cards(self, hand, num):
        for i in range(num):
            hand.add_card(self.pop_card())

It takes two arguments:

  • an Hand object
  • the number of cards to deal

modifying both self, hand and returning None.

With move_cards, depending on the game, cards can be moved:

  • from the deck to an hand
  • from one hand to another
  • from an hand back to the deck

This because:

  • self can be either a Deck or a Hand
  • hand can even be a Deck (despite its name!)

Inheritance is a useful feature:

  • Programs that would be repetitive without it, are written more elegantly with.
  • Facilitate code reuse, just customizing the parent classes with childs.
  • Reflects the natural structure of the problem, making the design easier to understand.

But, it makes programs difficult to read.

When invoking a method, sometimes, it's not clear where to find its definition.

Relevant code may be spread across several modules.

Many things done with inheritance can be done – as well or better – without it!

Class Diagrams¶

Snapshots that change as the program executes:

  • stack diagrams, show the state of a program
  • object diagrams, show the attributes values of an object

Sometimes, too detailed.

  • class diagrams, represent the program structure:
    • classes (abstract representation)
    • relationships (between them)

Several kinds of relationship between classes:

  • HAS-A objects with references to other classes objects.

For example, “a Rectangle has a Point.”

  • IS-A child class inheriting from a parent one.

For example, “a Hand is a kind of a Deck.”

  • dependency objects take other classes objects as parameters.

  • IS-A relationship, arrow with a hollow triangle head
  • HAS-A relationship, standard arrowhead with its multiplicity
    • simple number or range
    • star * (any number)
  • dependency relationship, dashed arrow (sometimes omitted)

Built-in types (as list/dict) are usually not shown.