Программа для раскладывания карточного пасьянса проиллюстрирует всю мощь наследования и переопределения. В главах 3 и 4 встречались фрагменты этой программы, в частности абстракция игральной карты, представленная классом Card. Языком программирования этого учебного примера будет Java.
     Основное внимание будет уделено классу CardPile, абстрагирующему стопку игральных карт. Так как перекладывание карт из одной стопки в другую — это основное действие пасьянса, то подклассы CardPile будут базовыми структурами данных при реализации пасьянса. Имеется множество стопок карт, и наследование вкупе с переопределением интенсивно используется для упрощения разработки этих компонент и обеспечения их единообразия.
     В предыдущих главах мы обсуждали абстрактный класс Card. Повторим некоторые важные моменты.
     Каждый экземпляр класса Card (листинг 8.1) наделен мастью и рангом. Чтобы предотвратить их изменение, поля данных (переменные экземпляра) объявлены закрытыми, и сделать что-либо с ними можно только посредством функций доступа.
     Значения полей масти и ранга устанавливаются конструктором класса. Кроме того, отдельная функция позволяет пользователям определять цвет карты. Значения целочисленных констант (определяемых в языке Java с помощью спецификаторов final static) заданы для черного и красного цветов, а также для мастей. Еще одна пара целочисленных констант определяет высоту и ширину карты.
     Есть важные причины для того, чтобы обращаться к масти и рангу только через функции доступа. Прямой доступ к этим полям следует запретить. Тогда поля масти и ранга могут быть прочитаны, но не модифицированы. (Соответствующая функция, используемая главным образом для изменения значений полей данных, часто называется мутатором (mutator).)
Листинг 8.1.
Описание класса card
|
     Итак, все действия, которые может выполнить карта (кроме установки и возврата состояния), — это переворачивание и показ себя. Функция flip() состоит из одной строчки, которая просто обращает значение, содержащееся в переменной экземпляра faceup, на противоположное. Функция рисования draw() сложнее: она использует графические средства, предоставляемые стандартной библиотекой приложений Java. Библиотека приложений поставляет тип данных, называемый Graphics, который обеспечивает множество методов рисования линий и фигур, а также раскрашивание. В качестве аргумента функции рисования передается значение типа Graphics, а также целочисленные координаты, соответствующие верхнему левому углу карты.
     Черви и бубны нарисованы красным, а пики и крести — черным. Штриховка рубашки выполнена желтым цветом. Фрагмент процедуры рисования игральной карты показан в листинге 8.2.
     Наиболее важная особенность абстракции игральной карты — это стиль, при котором каждая карта ответственна за хранение в себе всей информации и поведения, к ней относящихся. Карта знает и свое значение, и то, как себя нарисовать. Таким образом, информация инкапсулирована и изолирована от приложения, использующего игральные карты. Если, например, программа перенесена на новую платформу, использующую другие графические средства, то изменить нужно будет только метод draw внутри самого класса.
Листинг 8.2.
Процедура рисования игральной карты
|
     Контейнер стопка карт использует для их хранения модель связного списка. Отделяя класс контейнера данных от его конкретных представителей (стопок игральных карт), мы позволяем каждому классу сконцентрироваться на ограниченном множестве задач.
     Это является продвижением по сравнению с главой 6 (где, как вы помните, каждый графический объект содержал указатель на следующий графический объект). В подходе, изложенном в главе 6, плохо не только то, что поле указателя-связки не особенно важно для объекта, содержащегося в контейнере, но и то, что при таком способе объект не может быть включен в два (или более) списка одновременно. Создавая отдельные классы для абстракции связных списков, мы получаем гораздо большую гибкость в использовании контейнеров.
     В абстракции связного списка задействованы два класса. Класс LinkedList — это «фасад» списка, то есть класс, с которым взаимодействует пользователь. В действительности значения хранятся в экземплярах класса List. Обычно пользователь даже не догадывается о существовании класса List. Оба класса показаны в лист. 8.3.
     Так как контейнер данных на основе связного списка является абстракцией общего назначения и ничего не знает о типе объекта, который он будет содержать, то тип данных, приписываемый объекту-значению, — это класс всех объектов Object. Переменная, объявленная с типом данных Object (в частности, поле данных value в классе Link), является полиморфной — она может содержать значение любого типа.
     Класс LinkedList обеспечивает: добавление элемента в список, проверку списка на наличие в нем элементов, доступ к первому элементу списка, удаление первого элемента списка.
Листинг 8.3.
Классы Link и LinkedList
|
     В более общем случае мы хотели бы предоставить пользователю нашей абстракции связного списка способ для перебора величин, содержащихся в списке, без необходимости их удаления и без знания детальной информации о внутренней структуре списка (в данном случае без сведений о классе Link). Как мы увидим в главе 16, такие возможности часто обеспечиваются разработчиками класса «список» через доступ к специальной разновидности объектов, называемых итераторами. Итератор скрывает детали представления контейнера данных и обеспечивает простой интерфейс для доступа к значениям в порядке очереди. Итератор для связного списка показан в листинг 8.4. С его помощью цикл записывается следующим образом:
|
     Обратите внимание на то, как сам список возвращает итератор в результате вызова метода и как использование итератора позволяет избежать упоминания о связных полях списка.
Листинг 8.4.
Листинг 8.4.
Класс ListIterator
|
     Версия пасьянса, которую мы будем описывать, известна под названием «Косынка» (или Klondike). Бесчисленные вариации этой игры делают ее, возможно, наиболее распространенной версией пасьянса, так что когда вы говорите слово «пасьянс», многие люди думают о «косынке». Версия, которую мы будем использовать здесь, описана в книге [Morehead 1949]. В упражнениях мы рассмотрим некоторые распространенные разновидности этого пасьянса.
     Расположение карт показано на рис. 8.1. Используется одна стандартная колода из 52 карт. Расклад пасьянса (tableau) состоит из 28 карт в 7 стопках. Первая стопка состоит из 1 карты, вторая — из 2 и т. д. до 7. Верхняя карта в каждой стопке изначально лежит картинкой вверх; все остальные — картинкой вниз.
     Стопки мастей (иногда называемые основаниями (foundations)) строятся от тузов до королей по мастям. Они создаются сверху расклада по мере того, как нужные карты становятся доступными. Цель игры — сложить все 52 карты в основания по мастям.
     Те карты, которые не выложены в стопки, изначально находятся в колоде (deck). Карты там лежат картинкой вниз, они достаются из колоды по одной и кладутся картинкой вверх в промежуточную стопку (discard pile). Оттуда они перемещаются на расклад или в основания. Карты достаются из колоды, пока она не опустеет. Игра заканчивается, если дальнейшие перемещения карт невозможны.
     Карты кладутся в стопки расклада только на карту следующего по старшинству ранга и противоположного цвета. Карта переносится в основание, если она той же масти и следует по старшинству за верхней картой одного из оснований (или если основание пустое и карта является тузом). Пустые промежутки, возникающие в раскладе во время игры, заполняются только королями.
     Самая верхняя карта промежуточной стопки всегда доступна. Существует только одна возможность переместить более одной карты — положить целый набор открытых карт расклада (называемый последовательностью (build)) в другую стопку расклада. Это можно сделать, если самая нижняя карта последовательности может быть по правилам положена на самую верхнюю карту в стопке назначения. Наша первоначальная игра не будет поддерживать перемещение последовательностей, но мы обсудим это в качестве возможного расширения. Самая верхняя карта расклада всегда лежит картинкой вверх. Если карта удаляется из расклада, оставляя на вершине закрытую карту, то последнюю можно открыть — перевернуть ее картинкой вверх.
     Из этого короткого описания ясно, что пасьянс в основном заключается в манипулировании стопками карт. Каждый тип стопки, имея ряд общих свойств с другими стопками, обладает своей спецификой.
     В следующем разделе мы детально проанализируем, как в таком случае может быть использовано наследование для упрощения реализации различных стопок карт. Идея ясна уже сейчас: создать класс стопки с основными действиями и для каждой конкретной стопки переопределить его.
     Значительная часть поведения, которое мы связываем со стопкой карт, является общим для всех типов стопок в игре. Например, каждая стопка содержит связный список карт; операции добавления и удаления элементов из этого связного списка тоже похожи. Другие операции, которым приписано поведение «по умолчанию» от класса CardPile, иногда переопределяются для разных подклассов. Класс CardPile показан в листинге 8.5.
     Каждая стопка карт содержит координаты своего верхнего левого угла, а также связный список карт в стопке. Все эти значения устанавливаются конструктором класса. Поля данных объявлены как protected и таким образом доступны только методам класса (или его подкласса).
     Три функции top(), pop() и empty(), манипулирующие списком карт, используют интерфейс, предоставляемый классом LinkedList. Новая карта добавляется в список путем вызова addCard(Card). Она модифицируется внутри подклассов. Обратите внимание: метод класса front() связного списка возвращает значение типа Object. Оно должно быть преобразовано к типу данных Card в функциях top() и pop().
Листинг 8.5.
Описание класса CardPile
|
     Оставшиеся пять операций являются типичными с точки зрения нашей абстракции стопки игральных карт. Однако они различаются в деталях в каждом отдельном случае. Например, функция canTake(Card) запрашивает, можно ли положить карту в данную стопку. Карта может быть добавлена к основанию, только если она следует по старшинству и имеет ту же масть, что и верхняя карта основания (или если карта — туз, а стопка пуста). С другой стороны, карта может быть добавлена в стопку расклада, только если 1) цвет карты противоположен цвету текущей верхней карты в стопке и 2) карта имеет следующее по рангу младшее значение, чем верхняя карта в стопке или 3) стопка пуста, а карта является королем.
     Действия пяти виртуальных функций, определенных в классе CardPile, могут быть охарактеризованы так:
     Следующая таблица иллюстрирует пользу наследования. Даны пять операторов и пять классов, так что имеется 25 потенциальных методов, которые мы должны были бы определить. Используя наследование, мы должны реализовать только 13 методов. Более того, нам гарантировано, что каждая стопка будет реагировать одинаковым образом на похожие запросы.
    
  | CardPile | SuitPile | DeckPile | DiscardPile | TablePile |
includes | * |   |   |   | * |
canTake | * | * |   |   | * |
addCard | * |   |   | * |   |
display | * |   |   |   | * |
select | * |   | * | * | * |
Стопки карт — наследование в действии
     Мы детально рассмотрим каждый из подклассов CardPile, заостряя внимание на различных свойствах объектно-ориентированного программирования по мере их проявления. Самый простой подкласс — это основания SuitPile. Он показан в листинге 8.6. Стопка лежит в верхнем углу стола, в ней находятся карты одной масти от туза до короля.
Листинг 8.6.
Класс SuitPile
|
     Класс SuitPile определяет только два метода. Его конструктор берет два целочисленных аргумента и не делает ничего, кроме вызова конструктора надкласса CardPile. Обратите внимание на ключевое слово super, указывающее родительский класс. Метод canTake определяет, можно или нет поместить карту в стопку. Перемещение карты законно, если стопка пуста и эта карта — туз или если эта карта той же масти, что и верхняя карта в стопке, и ее ранг — следующий по старшинству (например, тройка пик может быть положена только на двойку пик).
     Все остальное поведение стопки SuitPile такое же, как и у общей стопки карт. При выборе мышью основание не выполняет никаких действий. Когда карта добавляется, она просто вставляется в связный список. Для отображения стопки на экране рисуется только верхняя карта.
Стопки карт — наследование в действии
     Класс DeskPile (листинг 8.7) обслуживает исходную колоду карт. Она отличается от стопки карт общего типа двумя моментами. При конструировании экземпляра вместо пустой стопки класс создает полную колоду из 52 карт, вставляя их в случайном порядке в связный список. Подпрограмма random библиотеки языка Java генерирует случайную величину с двойной точностью в диапазоне от 0 до 1. Она преобразуется в случайное целое число во время процесса тасования колоды.
     Метод select вызывается, когда щелчок мыши производится над колодой DeskPile. Если она пуста, то ничего не происходит. В противном случае верхняя карта удаляется из колоды и добавляется в промежуточную стопку.
     В языке Java нет глобальных переменных. Когда значение используется несколькими объектами классов (такими, как разные стопки карт в нашем пасьянсе), переменная объявляется с ключевым словом static. Как мы увидим в главе 20, при этом создается одна копия статической переменной, которая доступна всем экземплярам. В данной программе статические переменные применяются для хранения различных стопок карт. Они будут содержаться в экземпляре класса Solitare, который мы опишем впоследствии. Для доступа к ним мы используем полностью специфицированное имя, которое кроме имени переменной включает название класса. Это показано в методе select (листинг 8.8), который обращается к переменной Solitare.discardPile для доступа к промежуточной стопке.
Листинг 8.7.
Класс DeckPile
|
Стопки карт — наследование в действии
     Класс DiscardPile (см. листинг 8.8) интересен тем, что он демонстрирует две совершенно разные формы наследования. Метод select замещает или переопределяет поведение, по умолчанию обеспечиваемое классом CardPile. Новый код при вызове (то есть при нажатии кнопки мыши в области стопки) проверяет, может ли верхняя карта быть перемещена на какое-нибудь основание или на одну из стопок расклада. Если карта не может быть перемещена, она остается в промежуточной стопке.
     Метод addCard демонстрирует другой тип переопределения. Здесь поведение уточняет функциональность надкласса. То есть полностью отрабатывается поведение надкласса и, кроме того, добавляется новое поведение. В данном случае новый код гарантирует, что когда карта лежит в промежуточной стопке, она всегда будет смотреть картинкой вверх. После того как это условие удовлетворено, путем посылки сообщения для псевдопеременной super вызывается код надкласса, который добавляет карту в стопку.
     Другая форма уточнения возникает для конструкторов различных подклассов. До того как конструктор выполнит свои собственные действия, каждый из них должен вызвать конструктор надкласса, дабы гарантировать, что предок инициализировался должным образом. Конструктор предка вызывается через псевдо-переменную super; он вызывается как функция внутри конструктора дочернего класса. В главе 11 мы поговорим подробнее о различии между замещением и уточнением при переопределении методов.
Листинг 8.8.
Класс DiscardPile
|
Стопки карт — наследование в действии
     Наиболее сложный из подклассов класса CardPile — это тот, который используется для хранения стопок расклада TablePile. Он показан в листингах 8.9 и 8.10. Стопки расклада отличаются от стопок карт общего назначения следующими моментами:
Листинг 8.9.
Класс TablePile, часть I
|
Листинг 8.10.
Класс TablePile, часть II
|
     Как мы уже видели в «Задаче о восьми ферзях» (глава 5), среда для всех приложений на языке Java обеспечивается классом Applet. Для создания нового приложения программист определяет подклассы Applet, переопределяя при этом различные методы. Класс Solitare, который является центральным классом нашего приложения, показан в листинге 8.11.
     Мы ранее уже отмечали, что переменные, которые хранят общие для всех объектов данные, объявляются с ключевым словом static. Такие поля инициализируются в методе init класса1.
     Массивы в языке Java — это нечто, отличное от массивов в большинстве других языков программирования. Java различает для массивов три действия: объявление, распределение и присваивание. Заметьте, что объявление
Листинг 8.11.
Класс Solitaire
|
     показывает только то, что объекты являются массивами; про их границы ничего не говорится. Один из первых шагов процедуры инициализации — выделение места под три массива (основания, стопки расклада и массив allPiles, который мы рассмотрим ниже). Команда new отводит память для этих массивов, но не присваивает никаких значений их элементам.
     Следующий шаг — создание колоды DeskPile. Вспомните, что конструктор этого класса генерирует и перетасовывает полную колоду из 52 карт. Промежуточная стопка DiscardPile создается аналогичным образом. Затем в цикле порождаются и инициализируются четыре основания SuitPile, а второй цикл создает и инициализирует стопки расклада TablePile. Вспомните, что при инициализации стопок расклада карты берутся из колоды и вставляются в стопку расклада.
     Массив allPiles используется для представления всех 13 стопок карт. Заметьте, что как только создается очередная стопка, ей тут же присваивается ячейка в этом массиве, равно как и соответствующая статическая переменная. Мы воспользуемся этим массивом для иллюстрации еще одного аспекта наследования. Следуя принципу подстановки, allPiles объявлен как массив из элементов с типом данных CardPile, но на самом деле он содержит стопки карт разнообразного вида.
     Данный массив используется в ситуациях, когда различия между типами стопок карт не важны. Например, в процедуре перерисовки экрана каждую стопку просто просят самостоятельно перерисовать себя. Похожим образом при щелчке мышью опрашивается каждая стопка, не содержит ли она указанную точку экрана. Если да, то стопка выделяется. Среди них есть семь стопок расклада, четыре основания, промежуточная стопка и колода. Более того, фактический код, исполняемый в ответ на вызов методов select и includes, может различаться в зависимости от типа обрабатываемой стопки.
     Использование переменных, объявленных как экземпляры родительского класса, но содержащих значения, относящиеся к подклассам, — это один из аспектов полиморфизма (тема, к которой мы вернемся в следующей главе).
     Пасьянс, описанный здесь, обладает минимальными возможностями и в нем чрезвычайно трудно выиграть. Более реалистичная игра включала бы по крайней мере некоторые из следующих вариаций:
     Другие альтернативные правила описаны в упражнениях.