Абстрактные классы в PHP на примере

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

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

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

Делаем приложение!

Компьютерные шахматы уже лет 20 обыгрывают гроссмейстеров всухую и мы едва ли сможем сказать что-то новое в этой отрасли программирования. Поэтому и цель сегодня поставим перед собой ограниченную: опишем нашу доменную область, Это будут шахматные фигуры — возможно самое важное, что есть в игре.

В игре есть 6 типов фигур: пешка, ладья, слон, конь, ферзь и король. Давайте создадим для них по классу, который будет описывать их поведение. Какие свойства присущи фигурам? У них есть цвет (белый или черный), координаты на доске (e2, f8 etc), наконец они могут быть в игре или биты. На самом деле у них еще очень много разных свойств, но на данном этапе нам пока что хватит этих.

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

Создаем абстракцию для фигуры

Один из столпов ООП (да и любого программирования вообще) – это абстракция. Действительно, описать сколько-нибудь сложную систему без применения абстракции попросту невозможно. Современная наука не может дать ответ на то, как между собой будут взаимодействовать 3 элементарных частицы: задача слишком сложна для тех вычислительных мощностей, которые есть у человечества. Хочешь не хочешь, приходится прибегать к абстракциям. И выбор удачных абстракций, как мне кажется, является важнейшей проблемой программирования.

На этом моменте многие уже поняли, что все то, что объединяет наши фигуры может быть выражено в виде какой-то общей сущности, которая будет характеризовать фигуру как она есть. Это не имеет смысла реализовывать в каждом из 6 классов. Давайте создадим базовый класс! Надеюсь, что все прочли про наследование, а если нет, то бегом изучать матчасть (ссылка).

И тут `в качестве инструмента для реализации той самой абстракции нам пригодится абстрактный класс. Почему? Ну дело в том, что в шахматах нет никакой «просто фигуры». У каждой фигуры на доске есть своя роль, свое название и набор присущих только ей свойств. Поэтому если мы объявим базовую фигуру обычным классом, то это будет не совсем правильно: мы сможем создать объект от этого класса, поставить его на доску и играть им. Но как играть фигурой, которой нет в правилах? Вопрос риторический. Поэтому наша базовая фигура будет абстрактной — как мы помним, нельзя создать объект такого класса. Все конкретные фигуры будут наследоваться от этого класса и получат общие для всех свойства и методы через механизм наследования. Базовая фигура никогда не появится на доске, но поможет нам реализовать все остальные фигуры. Ведь в большинстве случаев, когда мы говорим о шахматной фигуре, нам необязательно знать, слон это или пешка, нас интересует то, что это – фигура.

Как-то так выглядели бы абстрактные фигуры

Абстрактная фигура – ссылка на гитхаб

Свойства абстрактной фигуры

Давайте же посмотрим на нашу абстрактную фигуру. У нее есть два свойства, отвечающие за координаты на доске: x (буквы a-h) и y (цифры 1-8). Числовая координата объявлена как int, чтобы две координаты нельзя было перепутать. Также есть свойство, отвечающее за цвет типа bool (true – белый, false – черный) и еще одно свойство isAlive типа bool, которое говорит нам о том, находится ли фигура в игре. По умолчанию все фигуры начинают живыми на доске, поэтому оно тоже имеет значение по умолчанию true.

Методы абстрактной фигуры

Помимо свойств у фигуры есть методы – это ее поведение, то, что она может делать. Мы определили три метода у фигуры.

abstract public function move(string $newX, int $newY);

Первый метод – move(). Он объявлен абстрактным, то есть оставлен без реализации. Действительно все фигуры ходят по разному, нет никакого “движения по умолчанию”, поэтому нет смысла реализовывать функциональность перемещения в базовом классе. Нам надо будет сделать это в наследниках, в классах, отвечающих за конкретные фигуры. Можно было бы конечно оставить тело метода пустым, не делая его абстрактным, но тогда оставался бы шанс забыть реализовать его: тогда фигура-наследник осталась обездвиженной и в программе произошел бы баг. Объявление метода абстрактным не оставляет нам пространства для маневра; нам придется реализовывать движение в каждом наследнике, в противном случае код просто не будет работать!

public function kill(string $newX, int $newY): AbstractPiece
    {
        if ($piece = self::getPieceByCoordinates($newX, $newY)) {
            /** Фигуры одного цвета нельзя бить! */
            if ($piece instanceof King) {
                throw new Exception('Нельзя съесть короля!');
            }
            if ($piece->colour !== $this->colour) {
                $this->move($newX, $newY);
                $piece->isAlive = false;
                return $piece;
            }
        }
        throw new Exception(sprintf('Не получается съесть фигуру по координатам %s:%s', $newX, $newY));
    }

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

Те, кто знаком с шахматами скажет, что здесь не учтен ряд нюансов, в особенности – поведение пешки, но к этому мы вернемся позже. А пока давайте посмотрим на последний метод.

public static function getPieceByCoordinates(string $xCoordinates, int $yCoordinates): ?AbstractPiece

Последний метод getPieceByCoordinates() объявлен как статический. Давайте вспомним, что это значит. Статический метод вызывается в контексте класса, тогда как нестатический – в контексте конкретного объекта. Что это значит? На примере тех же фигур. Ходит по доске и бьет других какая-то конкретная фигура. Это может быть белый конь или черная ладья, а вот получение информации о том, что находится по определенной координате – процесс сам по себе не связанный с конкретной фигурой, потому и вызов этого кода не привязан к конкретным объектам, его выполняет шахматист. Поэтому он объявлен статическим и к классу Piece относится только потому, что фигура может выступать результатом его вызова. Метод возвращает либо абстрактную фигуру, либо NULL, если по переданным координатам ничего не нашлось. Реализацию этого метода оставим за скобками, так как сама она нас сейчас не интересует.

А что дальше?

Теперь, когда наш краткий (на самом деле не очень краткий) экскурс по базовому классу для фигур окончен, возникает вопрос, что дальше? А дальше начинается то, ради чего и затевалось все, что было выше!

Начинаем создавать классы конкретных фигур. Мы помним, что у нас их шесть типов, соответственно мы создадим шесть классов. Поехали!

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

Слон, Конь, Ферзь, Ладья

Начнем с простых случаев.Все четыре фигуры выше объединяет одно: для них понадобится только реализовать метод move(). Все остальное у них будет подходить от абстрактной фигуры, а какого-то особого специфического поведения у них нет. И грозный ферзь и “особенный” конь, который может прыгать через другие фигуры отличаются от всех остальных объектов на шахматной доске только тем, как они двигаются. Давайте посмотрим на то, как это будет выглядеть в коде:

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

Пешка

Наш маленький герой

Пешка в массовом сознании является самой простой фигурой. Само слово “пешка” даже стало уничижительным: мы говорим так про человека, когда он ничего не решает и лишь безвольно подчиняется приказам. И правда, с пешкой как будто все понятно: она просто ползет вперед, пока не упрется в другую фигуру или не станет разменной монетой в сражении. И лишь изредка одной из многих пешек посчастливится дойти до конца доски и стать значительной фигурой. Пешка это просто воплощение простого солдата, который выносит основные тяготы войны! Действительно, может ли что-то быть примитивнее?

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

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

public function promote(): AbstractPiece

Реализация также оставим за скобками. Обращу внимание на то, что возвращает метод абстрактную фигуру, потому что пешка может превратиться во что угодно. В 95% случаев это будет ферзь, но возможно игровая ситуация потребует коня или же игрок решит поддаться и возьмет более слабую фигуру.

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

public bool $isMoved = false;

Свойство объявлено публичным, но строго говоря, правильнее было бы его объявить приватным. А на вопрос “почему” предлагаю ответить читателю.

И последнее, что отличает пешку, это то как она бьет фигуры. Она делает это по своему, очень хитро и поэтому это будет единственный класс, где мы переопределим метод kill().

Код пешки можно посмотреть тут: Пешка

Король

Где-то здесь должна быть шутка про Netflix и инклюзивность

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

Код короля можно посмотреть тут: Король

И что же в итоге?

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

Наследование помогает сократить объем кода и устранить дублирование

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

Абстракция позволяет нам выделить существенные свойства системы

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

Абстрактные классы и методы делают программу безопаснее и предсказуемее

Сделай мы базовую фигуру не абстрактным классом, а обычным, мы могли бы получить абсолютно непонятную фигуру на шахматной доске, которой бы не было в правилах. Конечно, можно “договориться” не работать с фигурами базового класса, но куда проще явно запретить создавать невалидные объекты. То же самое и с методами. Чем делать какой-то метод по умолчанию, который можно забыть переопределить или метод с пустым телом, который можно забыть реализовать, можно просто написать, что метод абстрактный и тогда программисту придется реализовывать его в конкретных классах. Используя такие инструменты как абстрактные классы и интерфейсы, мы обязываем разработчика нашей системы сделать определенные вещи вместо того, чтобы надеяться на то, что он их сделает.

Материал сделан на основе моих вебинаров для Otus по курсу php базовый. Приходите к нам, там еще много чего интересного 🙂