15. Наследование

15.1. Наследование

Чаще всего с объектно-ориентированным подходом ассоциируется такое свойство языка программирования, как наследование. Наследование — это способ определения нового класса, являющегося измененной версией уже существующего класса.

Наследование позволяет добавлять методы к классу без изменения существующего класса. Новый класс наследует методы существующего класса. Развивая метафору, существующий класс называют родительским классом, или суперклассом. А новый класс называют дочерним классом, или подклассом родительского класса.

В этой главе мы продемонстрируем использование наследования при написании программы для карточной игры Старая дева. Одной из наших целей будет создание кода, который можно повторно использовать для написания других карточных игр.

15.2. Рука с картами

Для написания карточной игры нужно уметь представлять руку с картами, то есть, набор карт, находящихся в руке одного игрока. Карточная рука похожа на колоду. Обе составлены из множества карт, и обе нуждаются в операциях по добавлению и удалению карт. Также будет не лишней возможность перетасовать колоду или руку.

Однако карточная рука отличается от колоды. В зависимости от игры, в которую играют, может понадобиться выполнять для руки некоторые операции, не имеющие смысла для колоды. Например, при игре в покер может понадобиться классифицировать руку (стрейт, флэш, и так далее) или сравнить одну руку с другой. А в бридже понадобится подсчитать стоимость руки, чтобы правильно вести торговлю.

Такая ситуация подталкивает нас к использованию наследования. Если класс Hand (англ.: рука) будет подклассом Deck, то он будет иметь все методы класса Deck, плюс новые методы, которые мы определим специально для него.

В определении класса имя родительского класса помещают в скобки:

class Hand(Deck):
    pass

Это предложение означает, что новый класс Hand наследует от существующего класса Deck.

Конструктор Hand инициализирует атрибуты руки, name и cards:

class Hand(Deck):
    def __init__(self, name=""):
       self.cards = []
       self.name = name

Строка name содержит имя руки, данное, возможно, по имени игрока. Параметр name опционален и имеет значение по умолчанию, пустую строку. Список карт в руке, cards, инициализируется пустым списком.

Практически в каждой карточной игре требуется добавлять и убирать карты из руки. С удалением у нас все в порядке, поскольку класс Hand унаследовал метод remove класса Deck. Но нужно написать метод add (англ.: добавить) для добавления карты в руку:

class Hand(Deck):
    ...

    def add(self,card):
        self.cards.append(card)

И вновь многоточие показывает, что мы опустили другие методы. Списочный метод append добавляет новую карту в конец списка карт.

15.3. Сдача карт

Теперь, когда у нас есть класс Hand, нам нужно сдать карты из колоды в руки игроков. На первый взгляд неочевидно, должен ли новый метод быть помещен в класс Hand или класс Deck. Но поскольку этот метод оперирует с одной колодой и, вероятно, с несколькими руками, будет более естественным поместить его в Deck.

Метод deal должен быть достаточно общим, так как в разных карточных играх разные требования. Может понадобиться сразу сдать всю колоду, или добавлять по одной карте в каждую руку.

Метод deal имеет два параметра, список (или кортеж) рук и общее количество карт, которые необходимо сдать. Если карт в колоде недостаточно, метод сдает все имеющиеся, и на этом завершает свою работу:

class Deck :
    ...
    def deal(self, hands, num_cards=999):
        num_hands = len(hands)
        for i in range(num_cards):
            if self.is_empty(): break   # break if out of cards
            card = self.pop()           # take the top card
            hand = hands[i % num_hands] # whose turn is next?
            hand.add(card)              # add the card to the hand

Параметр num_cards опционален, а его значение по умолчанию, большое число, гарантирует, что будут сданы все карты из колоды.

Переменная цикла i поочередно приобретает значения от 0 до num_cards-1. В каждой итерации одна карта извлекается из колоды методом pop, который удаляет и возвращает последний элемент списка.

Оператор взятия остатка от деления % позволяет нам сдавать карты по кругу, по одной карте в руку за один раз. Когда i становится равным количеству рук, выражение i % num_hands дает 0, и счет рук начинается сначала.

15.4. Вывод руки на печать

Для вывода на печать карточной руки можно воспользоваться методами print_deck и __str__, унаследованными от класса Deck. Например:

>>> deck = Deck()
>>> deck.shuffle()
>>> hand = Hand("frank")
>>> deck.deal([hand], 5)
>>> print hand
Hand frank contains
2 of Spades
 3 of Spades
  4 of Spades
   Ace of Hearts
    9 of Clubs

Не очень хорошая рука, но есть потенциал для стрейт флэша.

Хотя пользоваться унаследованным методом довольно удобно, у объектов класса Hand есть кое-что еще, что хотелось бы вывести на печать. Это имя руки. Чтобы вывести его на печать, напишем свой метод __str__ для класса Hand, который переопределит одноименный метод родительского класса:

class Hand(Deck)
    ...
    def __str__(self):
        s = "Hand " + self.name
        if self.is_empty():
            s += " is empty\n"
        else:
            s += " contains\n"
        return s + Deck.__str__(self)

Вначале переменной s присваивается строка с именем руки. Если рука пуста, то программа добавляет к строке слова is empty (англ.: пусто) и возвращает s.

В противном случае, программа добавляет к строке слово contains (англ.: содержит) и строковое представление Deck, полученное путем вызова метода __str__ класса Deck для объекта self.

Возможно, вам кажется странным передача self, ссылки на текущий объект Hand, в метод класса Deck. Но вспомните, что Hand является подклассом и разновидностю Deck, иными словами, Hand есть Deck. Объекты Hand могут делать все, что могут делать объекты Deck, поэтому мы вправе передать объект Hand методу Deck.

Вообще, всегда можно использовать экземпляр подкласса вместо экземпляра родительского класса.

15.5. Класс CardGame

Класс CardGame (англ.: карточная игра) берет на себя действия, общие для всех карточных игр, такие, как создание и тасование колоды:

class CardGame:
    def __init__(self):
        self.deck = Deck()
        self.deck.shuffle()

Это первый случай, когда мы видим метод __init__, не просто инициализирующий атрибуты, а выполняющий нетривиальные действия.

Чтобы реализовать конкретную карточную игру, можно унаследовать новый класс от CardGame и добавить операции, необходимые для реализуемой игры. В качестве примера, напишем симуляцию игры Старая дева.

Цель игры — избавиться от всех карт в руке. Вы делаете это, сопоставляя карты по значению и цвету одновременно. Например, четверка треф соответствует четверке пик, поскольку обе масти черные. Валет червей соответствует валету бубен, так как масти обеих карт красные.

В начале игры дама треф убирается из колоды, и, следовательно, дама пик не имеет пары. Пятьдесят одна оставшаяся карта сдается игрокам по кругу. После сдачи все игроки сбрасывают имеющиеся у каждого в руке парные карты.

Когда в руках игроков пар больше нет, начинается игра. Игроки по очереди берут карту (рубашкой вверх) из руки ближайшего соседа слева, у которого есть карты, и добавляют в свою руку. Если пришедшая карта образует пару с имеющейся в руке, пара сбрасывается. В противном случае карта остается у игрока. В конце концов все возможные пары сбрасываются, и только пиковая дама (старая дева) остается в руке проигравшего.

В нашей компьютерной симуляции компьютер играет за всех игроков. К сожалению, некоторые нюансы настоящей игры теряются. В настоящей игре игрок, у которого в руке старая дева, пускается на маленькие хитрости, чтобы его сосед вытянул именно эту карту, например, кладет ее ближе других к соседу, или, напротив, максимально скрывает другими своими картами. Компьютер же выбирает карту случайным образом.

15.6. Класс OldMaidHand

Рука для Старой девы должна уметь кое-что, помимо стандартных возможностей класса Hand. Определим новый класс, OldMaidHand (англ.: рука старой девы), подкласс класса Hand, и добавим метод remove_matches (англ.: удалить пары):

class OldMaidHand(Hand):
    def remove_matches(self):
        count = 0
        original_cards = self.cards[:]
        for card in original_cards:
            match = Card(3 - card.suit, card.rank)
            if match in self.cards:
                self.cards.remove(card)
                self.cards.remove(match)
                print "Hand %s: %s matches %s" % (self.name, card, match)
                count += 1
        return count

Вначале мы создаем копию списка карт, чтобы пройти по всем элементам списка-копии, при необходимости удаляя элементы из оригинального списка. Поскольку self.cards изменяется в теле цикла, не стоит использовать этот список с переменной цикла. Вы озадачите Python, если заставите его в цикле идти по списку, который изменяется.

Для каждой карты в руке мы определяем парную ей карту и пробуем найти ее в списке. Парная карта имеет ту же величину и другую масть того же цвета, что и текущая карта. Выражение 3 - card.suit превращает трефы (масть 0) в пики (масть 3), а бубны (1) в черви (2). Проверьте сами, что другие преобразования тоже работают. Если парная карта находится в руке, обе карты удаляются.

Следующий пример демонстрирует использование remove_matches:

>>> game = CardGame()
>>> hand = OldMaidHand("frank")
>>> game.deck.deal([hand], 13)
>>> print hand
Hand frank contains
Ace of Spades
 2 of Diamonds
  7 of Spades
   8 of Clubs
    6 of Hearts
     8 of Spades
      7 of Clubs
       Queen of Clubs
        7 of Diamonds
         5 of Clubs
          Jack of Diamonds
           10 of Diamonds
            10 of Hearts
>>> hand.remove_matches()
Hand frank: 7 of Spades matches 7 of Clubs
Hand frank: 8 of Spades matches 8 of Clubs
Hand frank: 10 of Diamonds matches 10 of Hearts
>>> print hand
Hand frank contains
Ace of Spades
 2 of Diamonds
  6 of Hearts
   Queen of Clubs
    7 of Diamonds
     5 of Clubs
      Jack of Diamonds

Обратите внимание, что в классе OldMaidHand нет метода __init__. Он унаследован от Hand.

15.7. Класс OldMaidGame

А теперь займемся собственно игрой. Класс OldMaidGame, подкласс класса CardGame, получит новый метод play, который принимает список игроков в качестве параметра.

Поскольку метод __init__ наследуется от CardGame, новый объект OldMaidGame будет содержать перетасованную колоду:

class OldMaidGame(CardGame):
    def play(self, names):
        # remove Queen of Clubs
        self.deck.remove(Card(0,12))

        # make a hand for each player
        self.hands = []
        for name in names:
            self.hands.append(OldMaidHand(name))

        # deal the cards
        self.deck.deal(self.hands)
        print "---------- Cards have been dealt"
        self.print_hands()

        # remove initial matches
        matches = self.remove_all_matches()
        print "---------- Matches discarded, play begins"
        self.print_hands()

        # play until all 50 cards are matched
        turn = 0
        num_hands = len(self.hands)
        while matches < 25:
            matches += self.play_one_turn(turn)
            turn = (turn + 1) % num_hands

        print "---------- Game is Over"
        self.print_hands()

Напишите метод print_hands() самостоятельно в качестве упражнения.

Некоторые части игры выделены в отдельные методы. Метод remove_all_matches обходит все руки и вызывает remove_matches для каждой:

class OldMaidGame(CardGame):
    ...
    def remove_all_matches(self):
        count = 0
        for hand in self.hands:
            count += hand.remove_matches()
        return count

Переменная count — аккумулятор, который накапливает общее количество пар, сброшенных со всех рук.

Когда общее количество сброшенных пар достигнет двадцати пяти, игроками будут сброшены пятьдесят карт. Это означает, что осталась одна карта и игра завершена.

Переменная turn (англ.: очередность) следит за тем, какая рука играет. Вначале установленное в 0, значение переменной в каждой итерации увеличивается на 1. Когда значение достигает num_hands, операция взятия остатка от деления вновь устанавливает его в 0.

Метод play_one_turn принимает параметр, показывающий, чья очередь играть, и возвращает количество совпадений (или сброшенных пар) в сыгравшей руке:

class OldMaidGame(CardGame):
    ...
    def play_one_turn(self, i):
        if self.hands[i].is_empty():
            return 0
        neighbor = self.find_neighbor(i)
        picked_card = self.hands[neighbor].pop_card()
        self.hands[i].add(picked_card)
        print "Hand", self.hands[i].name, "picked", picked_card
        count = self.hands[i].remove_matches()
        self.hands[i].shuffle()
        return count

Если рука игрока пуста, то игрок находится вне игры; в этом случае метод ничего не делает и возвращает 0.

В противном случае, метод находит ближайшего игрока слева, у которого есть карты, берет одну карту из его руки, добавляет ее в свою руку и проверяет свою руку на наличие пар. Перед завершением метода карты в руке тасуются, чтобы сделать выбор следующего игрока случайным.

Метод find_neighbor (англ.: найти соседа) начинает с ближайшего соседа слева, и переходит к следующему до тех пор, пока не будет найден игрок с картами в руке:

class OldMaidGame(CardGame):
    ...
    def find_neighbor(self, i):
        num_hands = len(self.hands)
        for next in range(1,num_hands):
            neighbor = (i + next) % num_hands
            if not self.hands[neighbor].is_empty():
                return neighbor

Если find_neighbor когда-либо обойдет весь круг, не найдя карт в руках игроков, он вернет None и приведет к ошибке в вызывающей программе. Однако, мы можем доказать, что этого никогда не случится (во всяком случае, пока программа корректно обнаруживает завершение игры).

Ниже приведены сообщения, выведенные в ходе симуляции сокращенной версии игры, в которой трем игрокам были сданы только 15 карт, от десятки и старше. С такой колодой игра завершается после сброса семи пар, а не двадцати пяти:

>>> import cards
>>> game = cards.OldMaidGame()
>>> game.play(["Allen","Jeff","Chris"])
---------- Cards have been dealt
Hand Allen contains
King of Hearts
 Jack of Clubs
  Queen of Spades
   King of Spades
    10 of Diamonds

Hand Jeff contains
Queen of Hearts
 Jack of Spades
  Jack of Hearts
   King of Diamonds
    Queen of Diamonds

Hand Chris contains
Jack of Diamonds
 King of Clubs
  10 of Spades
   10 of Hearts
    10 of Clubs

Hand Jeff: Queen of Hearts matches Queen of Diamonds
Hand Chris: 10 of Spades matches 10 of Clubs
---------- Matches discarded, play begins
Hand Allen contains
King of Hearts
 Jack of Clubs
  Queen of Spades
   King of Spades
    10 of Diamonds

Hand Jeff contains
Jack of Spades
 Jack of Hearts
  King of Diamonds

Hand Chris contains
Jack of Diamonds
 King of Clubs
  10 of Hearts

Hand Allen picked King of Diamonds
Hand Allen: King of Hearts matches King of Diamonds
Hand Jeff picked 10 of Hearts
Hand Chris picked Jack of Clubs
Hand Allen picked Jack of Hearts
Hand Jeff picked Jack of Diamonds
Hand Chris picked Queen of Spades
Hand Allen picked Jack of Diamonds
Hand Allen: Jack of Hearts matches Jack of Diamonds
Hand Jeff picked King of Clubs
Hand Chris picked King of Spades
Hand Allen picked 10 of Hearts
Hand Allen: 10 of Diamonds matches 10 of Hearts
Hand Jeff picked Queen of Spades
Hand Chris picked Jack of Spades
Hand Chris: Jack of Clubs matches Jack of Spades
Hand Jeff picked King of Spades
Hand Jeff: King of Clubs matches King of Spades
---------- Game is Over
Hand Allen is empty

Hand Jeff contains
Queen of Spades

Hand Chris is empty

Джефф проиграл.

15.8. Наследование

Наследование — очень мощное и выразительное средство. Некоторые программы, не будь наследования, были бы чрезвычайно сложны, а использование наследования позволяет сделать их краткими и понятными. Кроме того, наследование способствует повторному использованию кода, поскольку подклассы используют уже написанный код родительских классов, и позволяют изменять их поведение, не изменяя сами родительские классы. А в некоторых случаях структура наследования повторяет структуру, присущую решаемой задаче, что делает работу над программой легче, а саму программу понятнее.

С другой стороны, наследование может и затруднить понимание программы. При вызове метода не всегда ясно, где искать определение метода. Код родительских и дочерних классов может быть разбросан по нескольким модулям. Кроме того, многие вещи, которые можно сделать при помощи наследования, можно сделать и не прибегая к нему. Если задача естественным образом не решается с помощью наследования, то его использование может принести больше вреда, чем пользы.

15.9. Глоссарий

дочерний класс
Класс, наследующий методы и атрибуты родительского класса.
наследование
Определение нового класса на основе ранее определенного класса, так что новый класс является его измененной версией.
подкласс
То же, что дочерний класс.
родительский класс
Класс, методы и атрибуты которого наследует дочерний класс.
суперкласс
То же, что родительский класс.

15.10. Упражнения

  1. Добавьте к классу OldMaidGame метод print_hands, который обходит список рук self.hands и выводит каждую руку на печать.
  2. В определении метода remove_matches для получения парной масти использовано выражение 3 - card.suit, смысл которого далеко не очевиден. Напишите метод matched_suit класса OldMaidHand, который принимает в качестве параметра карточную масть и возвращает парную ей, при этом логика его работы должна быть очевидной. Назовите сравнительные достоинства и недостатки нового метода и выражения 3 - card.suit.
  3. Определите классы Cat и Dog, дочерние для класса Pet из упражнения к главе 13. Классы должны иметь методы make_sound, которые выводят мяуканье “Meow!” и гавканье “Bark!”, соответственно. Создайте список с несколькими объектами Cat и Dog, и в цикле по всем элементам списка выведите их на печать и вызовите метод make_sound для каждого объекта.
  4. Определите метод make_sound в классе Pet так, чтобы он выводил значение нового атрибута sound этого класса. Инициализируйте атрибута sound пустой строкой. Удалите методы make_sound из классов Cat и Dog и инициализируйте в них атрибут sound значениями “Meow!” и “Bark!”, соответственно. Выполните код со списком и циклом из предыдущего упражнения.