14. Коллекции объектов

14.1. Композиция

К этому моменту вы уже видели несколько примеров композиции. Один из первых примеров — вызов функции как часть выражения. Другой пример — вложенные предложения: можно поместить предложение if в цикл while, находящийся внутри if, и так далее.

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

В этой и следующей главах мы рассмотрим подобные возможности, работая с объектом Card (англ.: игральная карта).

14.2. Объект Card

Если вы незнакомы с игральными картами, то самое время взять в руки колоду. Иначе эта глава будет иметь для вас мало смысла.

В колоде 52 карты, каждая из которых принадлежит одной из четырех мастей и имееет одно из 13 значений. Масти такие: пики (англ.: Spades), червы (англ.: Hearts), бубны (англ.: Diamonds) и трефы (англ.: Clubs). Масти перечислены в порядке убывания старшинства в карточной игре бридж. Значения такие: туз (англ.: Ace), 2, 3, 4, 5, 6, 7, 8, 9, 10, валет (англ.: Jack), дама (англ.: Queen) и король (англ.: King). В зависимости от игры, в которую вы играете, туз может быть старше короля или младше двойки.

Определяя класс для представления игральной карты, мы должны определить атрибуты rank (англ.: ранг, звание) и suit (англ.: масть). Какого же типа должны быть эти атрибуты? Как вариант, это могут быть строки, со значениями "Spade" и т.д. для масти, и "Queen", "King" и т.д. для значений. Проблема такого подхода в том, что будет непросто сравнивать карты, чтобы определить, которая из них старше по значению или масти.

Другой вариант — использовать целые числа для кодирования значений и мастей. Здесь под кодированием имеется в виду не шифрование, которое превращает некоторый текст в нечитаемый секретный код. Под кодированием часто понимают отображение объектов (в широком смысле слова) на коды, например, числовые. Вот пример кодирования:

Spades   -->  3
Hearts   -->  2
Diamonds -->  1
Clubs    -->  0

В данной кодировке масти отображены на целые числа в порядке старшинства мастей, и это дает нам возможность сравнивать масти по старшинству, сравнивая целые числа. Так же просто можно закодировать и значения карт. Туз отобразится на 1, следующие значения отобразятся на соответствующие им числа от 2 до 10, а оставшиеся — таким образом:

Jack   -->  11
Queen  -->  12
King   -->  13

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

Определение класса Card, представляющего игральную карту, будет выглядеть так:

class Card:
    def __init__(self, suit=0, rank=0):
        self.suit = suit
        self.rank = rank

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

Для того, чтобы создать объект, представляющий тройку треф, мы напишем:

three_of_clubs = Card(0, 3)

Первый аргумент, 0, представляет трефовую масть.

14.3. Атрибуты класса и метод __str__

Для того, чтобы выводить карты на печать в удобном для чтения виде, отобразим целочисленные коды на строки. Естественный способ сделать это — с помощью списков. Создадим эти списки как атрибуты класса в самом начале определения класса:

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

    ...

    def __str__(self):
        return (self.ranks[self.rank] + " of " +
                self.suits[self.suit])

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

Доступ к атрибутам класса имеют все методы класса. Внутри метода __str__ мы используем suits и ranks для того, чтобы отображать числовые значения suit и rank на строки. Например, выражение self.suits[self.suit] использует значение атрибута suit данного объекта self как индекс для получения элемента списка suits. Значение полученного элемента и есть название масти.

Элемент "narf" на первом месте в списке ranks нужен только для того, чтобы занять место с индексом 0, который нам не понадобится. Значения, которые имеют смысл для нас, — от 1 до 13. Без элемента "narf" можно было бы обойтись, если бы мы закодировали туз как 0, 2 как 1, и так далее. Но такой подход сделает наше решение более запутанным и приведет к ошибкам, которых легко избежать, если кодировать 2 как 2, 3 как 3, и так далее.

С имеющимися у нас методами мы можем создать и вывести на печать игральную карту:

>>> card1 = Card(1, 11)
>>> print card1
Jack of Diamonds

Доступ к атрибутам класса Card может быть получен с помощью объекта класса Card, а также с помощью самого класса Card:

>>> card2 = Card(1, 3)
>>> print card2
3 of Diamonds
>>> print card2.suits[1]
Diamonds
>>> print Card.suites[1]
Diamonds

Изменение атрибута класса немедленно отразится на всех объектах данного класса. Например, если мы решим, что отныне Jack of Diamonds будет называться Jack of Swirly Whales, то нам достаточно сделать следующее:

>>> Card.suits[1] = "Swirly Whales"
>>> print card1
Jack of Swirly Whales
>>> print card2
3 of Swirly Whales

Конечно, в данном случае изменение атрибутов класса не имеет смысла. Нам стоит смотреть на атрибуты suits и ranks как на неизменяемые.

14.4. Сравнение карт

Для примитивных типов существуют операторы сравнения ( <, >, ==, и так далле), которые сравнивают значения и определяют, является ли одно значение больше, меньше или равным другому. Для типов, определенных пользователем, можно переопределить поведение этих операторов, написав собственный метод __cmp__. Метод __cmp__ принимает два параметра, self и other (англ.: другой), и возвращает 1, если первый объект больше второго, -1, если меньше, и 0, если объекты равны.

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

Множество игральных карт частично упорядочено. Это значит, что иногда можно сравнивать карты, а иногда нет. Например, известно, что тройка треф старше, чем двойка треф, а тройка бубен старше, чем тройка треф. Но что старше: тройка треф или двойка бубен? Первая больше по значению, а у второй старше масть.

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

Приняв это решение, мы можем написать метод __cmp__:

def __cmp__(self, other):
    # check the suits
    if self.suit > other.suit: return 1
    if self.suit < other.suit: return -1
    # suits are the same... check ranks
    if self.rank > other.rank: return 1
    if self.rank < other.rank: return -1
    # ranks are the same... it's a tie
    return 0

При таком сравнении тузы младше двоек.

14.5. Колоды карт

Теперь, когда у нас есть класс Card для представления игральных карт, нам нужен класс для представления колоды карт. Колода, разумеется, состоит из карт, поэтому каждый объект Deck (англ.: колода) будет содержать список карт в качестве атрибута.

Определим класс Deck. Инициализирующий метод создает атрибут cards и помещает в список стандартные 52 карты:

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

Простейший способ наполнить колоду — с помощью вложенного цикла. Внешний цикл перебирает масти от 0 до 3. Внутренний цикл перебирает значения от 1 до 13. Поскольку внешний цикл делает четыре прохода, а внутренний тринадцать, то тело внутреннего цикла, в общей сложности, выполняется 52 раза (четырежды тринадцать). В каждой итерации создается новый экземпляр класса Card, с текущими мастью suit и значением rank, и добавляется в конец списка cards.

Метод append есть у списков, но, конечно же, отсутствует у кортежей.

14.6. Вывод колоды карт на печать

Как обычно, когда мы определяем новый тип объектов, нам требуется метод, который выведет объект на печать. Для печати Deck мы пройдем по всему списку и выведем каждую карту:

class Deck:
    ...

    def print_deck(self):
        for card in self.cards:
            print card

Вместо метода print_deck мы могли бы написать метод __str__ для класса Deck. Преимущество __str__ в том, что этот метод более гибок. Он не выводит на печать содержимое объекта, а формирует строковое представление объекта, которое можно использовать как для вывода на печать, так и для других целей.

Вот вариант метода __str__, возвращающий строковое представление объекта Deck. Код код метода располагает карты лесенкой:

class Deck:
    ...

    def __str__(self):
        s = ""
        for i in range(len(self.cards)):
            s = s + " "*i + str(self.cards[i]) + "\n"
        return s

Этот код имеет несколько особенностей. Во-первых, вместо перемещения по списку self.cards и последовательных присваиваний карт переменной цикла (как в методе print_deck), здесь мы используем i в качестве переменной цикла и в качестве индекса для доступа к элементам списка.

Во-вторых, мы используем строковый оператор повторения, *, для сдвига каждой карты вправо на один пробел относительно предыдущей. Выражение " "*i формирует строку из i пробелов.

В-третьих, вместо использования предложения print для вывода карт на печать, мы используем функцию str. Передача объекта функции str в качестве аргумента влечет вызов метода __str__ этого объекта.

И в-четвертых, мы используем переменную s как аккумулятор. Вначале s пустая строка. Но в каждой итерации новые строки присоединяются к старому значению s, формируя новое значение. Когда цикл заканчивается, s содержит полное строковое представление объекта Deck. Вот как это выглядит:

>>> 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

И так далее. Хотя результат выводится в 52 строки экрана, это одна длинная последовательность символов, включающая символы перевода строки, содержащаяся в одной строковой переменной Python.

14.7. Тасуем колоду

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

Для тасования колоды воспользуемся функцией randrange из модуля random. Эта функция с двумя аргументами, a и b, возвращает случайно выбранное целое число из диапазона a <= x < b. Поскольку верхняя граница диапазона, как всегда в Python, исключается, то в качестве второго аргумента можно использовать длину списка. Следующее выражение дает случайный индекс для списка карт:

random.randrange(0, len(self.cards))

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

class Deck:
    ...

    def shuffle(self):
        import random
        num_cards = len(self.cards)
        for i in range(num_cards):
            j = random.randrange(i, num_cards)
            self.cards[i], self.cards[j] = self.cards[j], self.cards[i]

Вместо того, чтобы предполагать, что в колоде 52 карты, мы получаем реальную длину списка и сохраняем ее в переменной num_cards. Далее, в цикле для каждой карты в колоде мы выбираем случайную карту из тех, что еще не рассмотрели. Затем мы меняем местами текущую карту (i) с выбранной (j). Для того, чтобы поменять карты местами, используется присваивание кортежей:

self.cards[i], self.cards[j] = self.cards[j], self.cards[i]

14.8. Извлекаем и сдаем карты

Другой полезный метод для класса Deck — метод remove, принимающий карту в качестве аргумента, удаляющий ее из колоды, и возвращающий True, если карта была в колоде, и False в противном случае:

class Deck:
    ...

    def remove(self, card):
        if card in self.cards:
            self.cards.remove(card)
            return True
        else:
            return False

Оператор in возвращает True, если первый операнд содержится во втором, который может быть списком или кортежем. Если первый операнд является объектом, Python использует метод __cmp__ этого объекта для сравнения объекта с элементами списка. Поскольку метод __cmp__ класса Card выполняет глубокое сравнение, то метод remove использует глубокое сравнение.

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

class Deck:
    ...

    def pop(self):
        return self.cards.pop()

Поскольку pop удаляет последнюю карту из списка, может показаться, что мы сдаем нижние карты из колоды. Но ведь мы не договаривались, является верхом колоды начало списка или его конец! Так что с нашим методом все в порядке.

Еще одна операция, которая, вероятно, будет полезной — это логическая функция is_empty, возвращающая True, если колода пуста:

class Deck:
    ...

    def is_empty(self):
        return (len(self.cards) == 0)

14.9. Глоссарий

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

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

  1. Измените метод __cmp__ класса Card так, чтобы туз стал старше короля.
  2. Напишите метод __cmp__ для класса Rectangle, который сравнивает прямоугольники по их площадям. Протестируйте работу нового метода с помощью доктестов.
  3. Чтобы перегрузить оператор in для своего класса, нужно написать метод __contains__(self, o). Перегрузите оператор in для Rectangle, чтобы он определял принадлежность точки (левый операнд) прямоугольнику (правый операнд). Точка представлена объектом Point. Протестируйте работу нового метода с помощью доктестов.
  4. Что случится, если левый операнд перегруженного вами оператора in для Rectangle будет не объектом Point, а объектом другого типа, или целым числом? Сделайте так, чтобы в этом случае возбуждалось исключение TypeError.