Включаем лампу в стиле ООП

  1. Постановка задачи
  2. Разделение логики
  3. Проектирование
  4. Первый запуск
  5. Добавляем детализацию
  6. Сломанная лампа
  7. Постскриптум

Постановка задачи

<Заказчик>: Нужно смоделировать ситуацию, когда человек включает лампу. 
            Но, если лампа уже включена, то он ничего не делает.

Вот такая простая задача. Что здесь может быть сложного?

Разделение логики

Прежде, чем писать код, давайте ответим на несколько вопросов:

  1. Что такое лампа?
  2. Как человек её включает?
  3. Кто проверяет, что лампа включена?
  4. Кто принимает решение, включать ли лампу?

Для многих такие вопросы покажутся слишком малозначительными, потому что ответы лежат на поверхности. Лампа, это лампа; включает её кто-то; он же и проверяет, включена она или нет; он же и принимает решение. Всё это мы знаем, потому что так мы делаем каждый день. Это наши стереотипы.

Давайте посмотрим на эту проблему с точки зрения программиста:

  1. Лампа - это какой-то прибор, у которого есть состояние и возможность включить и выключить.
  2. У человека есть возможность включить лампу. То есть человек и лампа как-то связаны.
  3. Кто-то (или что-то) проверяет, включена лампа или нет. Значит лампа должна предоставить такую возможность.
  4. Кто-то (или что-то) принимает решение, включать лампу или нет.

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

Проектирование

Какой-то прибор

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

// Интрефейс чего-то, что можно включить и выключить
interface SwitchInterface {
    function turnOn();   // Включить
    function turnOff();  // Выключить
    function isOn();     // Возвращает true, если включено
    function isOff();    // Возвращает true, если выключено
    function state();    // Возвращает текущее состояние
}

Благодаря этому интерфейсу мы можем начинать проектировать другие сущности, такие как "кто-то" или "что-то", включающее лампу.

Кто-то или что-то, включающее лампу

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

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

// Какая-то стратегия, которая что-то делает
interface StrategyInterface {
    function apply();  // Что-то делаем
}

Теперь определим конкретный класс стратегии, которая включает лампу, если та ещё не включена:

// Стратегия включения лампы
class TurnOnStrategy implements StrategyInterface {
    function apply($subject = null) {
        // Если есть, что включать
        if ($subject instanceof SwitchInterface) {
            // Если оно выключено
            if ($subject->isOff()) {
                // Включаем
                $subject->turnOn();
            } else {
                // Если оно уже включено, ничего не делаем
            }
        }
    }
}

Хотя мы и оперируем понятием лампа, в коде никаких операций с лампой нет. Привязка идёт пока только к интерфейсам.

Лампа

Давайте ещё подумаем, что представляет из себя лампа? Если оглянуться в начало, то мы сказали, что лампа есть "какой-то прибор" и даже определили поведение этого прибора. Осталось только реализовать сначала "какой-то прибор" и на его основе сделать лампу:

// Какой-то прибор
abstract class DeviceAbstract implements SwitchInterface {
    const STATE_ON = 'on';    // Состояние "Включено"
    const STATE_OFF = 'off';  // Состояние "Выключено"
    
    private $state = self::STATE_OFF;  // Текущее состояние объекта (по умолчанию "Выключено")
    
    // Включить
    function turnOn() {
        $this->state = self::STATE_ON;
    }
    
    // Выключить
    function turnOff() {
        $this->state = self::STATE_OFF;
    }
    
    // Проверить, "включен" ли объект
    function isOn() {
        return $this->state === self::STATE_ON;
    }
    
    // Проверить, "выключен" ли объект
    function isOff() {
        return $this->state === self::STATE_OFF;
    }
    
    // Вернуть текущее состояние объекта
    function state() {
        return $this->state;
    }
}

// Лампа
class Lamp extends DeviceAbstract {
    // Никакой дополнительной логики пока нет
}

Разделив таким образом логику "какого-то прибора" и лампы, мы оставили задел на будущее, если захотим сделать поведение лампы более сложным.

Человек с лампой в руках

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

  1. Кто этот человек?
  2. Чего он добился в жизни?
  3. Хранит ли он одну лампу у себя или ходит и включает все лампы подряд?
  4. Что он делает после включения и куда идёт дальше?

Увы, но из начального задания мы не можем однозначно ответить на эти вопросы. И здесь появляется свобода выбора: мы можем дать лампу человеку и потом попросить её включить, можем указать на лампу, которую нужно включить, а можем дать охапку ламп и попросить включить их.

Чтобы выбрать лучшее решение, попробуем посмотреть разные варианты реализации на черновике:

// Вариант первый - человек и лампа одно целое
$person = new Preson($lamp, $strategy);
$person->turnOnLamp();

// Вариант второй - человек включает разные лампы
$person = new Person();
$person->trunOnLamp($lamp, $strategy);

// Вариант третий - человек берёт охапку ламп и включает их
$person = new Person();
$person->addLamp($lamp1);
$person->addLamp($lamp2);
$person->applyStrategyToLamps($strategy);  // Применяем стратегию к лампам

На мой взгляд, второй вариант больше всего подходит под условия задачи. Мы ничего не знаем про человека, который включает лампу, поэтому просто дадим ему и лампу, и стратегию включения. Ему останется только сделать "щёлк":

// Некто
class Person {
    // Применить стратегию к переключателю
    function applyStrategyToSwitch(StrategyInterface $strategy, SwitchInterface $device) {
        $strategy->apply($device);
    }
}

Вы можете спросить, зачем нужен такой просто класс? Ответ прост: на будущее. Задача программиста - правильно разделить логику на объекты. Логика добавится потом.

Первый запуск

Давайте создадим лампу, человека и стратегию и попробуем всё это запустить:

// Создаём объекты
$lamp = new Lamp();
$person = new Person();
$turnOn = new TurnOnStrategy();

// Несколько раз включаем лампу
$person->applyStrategyToSwitch($turnOn, $lamp);
$person->applyStrategyToSwitch($turnOn, $lamp);

// Выводим результат
print_r($lamp->state());  // Выведет "on"

[Исходный код программы]

Формально задача решена, но простое on в выводе не очень отражает логику работы программы, нужна детализация.

Добавляем детализацию

Перед началом кодирования зададим себе вопросы:

  1. А что есть детализация?
  2. В каком виде мы хотим её видеть?
  3. Где мы хотим её видеть?

А теперь дадим ответы:

  1. Детализация есть простое текстовое описание действий.
  2. Мы хотим её видеть в виде лога на экране.
  3. Мы хотим видеть пока только на экране, но позднее это может быть и лог-файл, и база данных.

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

// Раширение для реализации событий
trait Events {
    private $eventSubscribers = [];  // Подписчики
    
    // Добавляем подписчика
    function addSubscriber(callable $subscriber) {
        $this->eventSubscribers[] = $subscriber;
    }
    
    // Уведомляем подписчиков о событии
    function notify($data) {
        foreach($this->eventSubscribers as $subscriber) {
            // Вызываем каждого подписчика и передаём ему себя и данные
            $subscriber($this, $data);
        }
    }
}

// Вспомогательная функция для генерации имени объекта
function get_object_name($object) {
    if (is_object($object)) {
        return get_class($object) . '(' . spl_object_id($object) . ')';
    } else {
        return gettype($object);
    }
}

Добавляем события в классы:

// Стратегия включения лампы
class TurnOnStrategy implements StrategyInterface {
    use Events;

    function apply($subject = null) {
        // Если есть, что включать
        if ($subject instanceof SwitchInterface) {
            // Если оно выключено
            if ($subject->isOff()) {
                // Отсылаем уведомление
                $this->notify('Устройство ' . get_object_name($subject) . ' выключено, включаем');

                // Включаем
                $subject->turnOn();
            } else {
                // Если оно уже включено, ничего не делаем
                $this->notify('Устройство ' . get_object_name($subject) . ' уже включено, ничего не делаем');
            }
        }
    }
}

// Лампа
class Lamp extends DeviceAbstract {
    use Events;
    
    // Перегружаем метод "Включить"
    function turnOn() {
        $this->notify('Включаем лампу');
        parent::turnOn();
    }
    
    // Перегружаем метод "Выключить"
    function turnOff() {
        $this->notify('Выключаем лампу');
        parent::turnOff();
    }
}

// Некто
class Person {
    use Events;
    
    // Применить стратегию к переключателю
    function applyStrategyToSwitch(StrategyInterface $strategy, SwitchInterface $device) {
        // Отсылаем уведомление
        $this->notify(sprintf('Применяем стратегию %s к %s',
            get_object_name($strategy),
            get_object_name($device)
        ));
        
        $strategy->apply($device);
    }
}

Меняем поведение наших объектов, добавляем обработчик событий:

// Создаём объекты
$lamp = new Lamp();
$person = new Person();
$turnOn = new TurnOnStrategy();

// Создаём обработчик событий, который будет выводить события на экран
$displayLogger = function ($object, $text) {
    echo get_object_name($object), ": $text\n";
};

// Подписываемся на события разных объектов
$lamp->addSubscriber($displayLogger);
$person->addSubscriber($displayLogger);
$turnOn->addSubscriber($displayLogger);

// Несколько раз включаем лампу
$person->applyStrategyToSwitch($turnOn, $lamp);
$person->applyStrategyToSwitch($turnOn, $lamp);

После запуска результат будет более детальный:

Person(2): Применяем стратегию TurnOnStrategy(3) к Lamp(1)
TurnOnStrategy(3): Устройство Lamp(1) выключено, включаем
Lamp(1): Включаем лампу
Person(2): Применяем стратегию TurnOnStrategy(3) к Lamp(1)
TurnOnStrategy(3): Устройство Lamp(1) уже включено, ничего не делаем

[Исходный код программы]

Теперь можно быть уверенным, что программа работает именно так, как было указано в задании.

Сломанная лампа

А давайте сломаем лампу! Как вам идея? Пусть лампа включается не с первого раза, а с третьего. И специально для этой лампы напишем стратегию, которая трижды будет включать её. Таким образом мы моделируем изменение внешних условий, при которых усложняется начальная логика задачи.

class BrokenLamp extends Lamp {
    private $attempts = 0;
    
    // Перегружаем метод "Включить"
    function turnOn() {
        if (++$this->attempts < 3) {
            $this->notify('Что-то заело, не могу включить');
        } else {
            parent::turnOn();
        }
    }
    
    // Перегружаем метод "Выключить"
    function turnOff() {
        // Сбрасываем попытки включения
        $this->attempts = 0;
        parent::turnOff();
    }
}

// Тройное включение
class TripleTurnOnStrategy extends TurnOnStrategy {
    function apply($subject = null) {
        parent::apply($subject);
        parent::apply($subject);
        parent::apply($subject);
    }
}

Меняем наши объекты:

// Создаём объекты
$lamp = new BrokenLamp();
$turnOn = new TripleTurnOnStrategy();

Запускаем и наслаждаемся выводом:

Person(3): Применяем стратегию TripleTurnOnStrategy(2) к BrokenLamp(1)
TripleTurnOnStrategy(2): Устройство BrokenLamp(1) выключено, включаем
BrokenLamp(1): Что-то заело, не могу включить
TripleTurnOnStrategy(2): Устройство BrokenLamp(1) выключено, включаем
BrokenLamp(1): Что-то заело, не могу включить
TripleTurnOnStrategy(2): Устройство BrokenLamp(1) выключено, включаем
BrokenLamp(1): Включаем лампу
Person(3): Применяем стратегию TripleTurnOnStrategy(2) к BrokenLamp(1)
TripleTurnOnStrategy(2): Устройство BrokenLamp(1) уже включено, ничего не делаем
TripleTurnOnStrategy(2): Устройство BrokenLamp(1) уже включено, ничего не делаем
TripleTurnOnStrategy(2): Устройство BrokenLamp(1) уже включено, ничего не делаем

[Исходный код программы]

Постскриптум

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

Но кроме этого, я бы хотел обратить ваше внимание на то, сколько времени было потрачено на продумывание решения. Я специально записал вопросы и ответы на них, потому что это ключ к решению задач через ООП. Если вы не зададите эти вопросы, то не поймёте, какие решения вам подходят, а какие нет. Неправильное ООП в разы хуже кода без объектов, помните это.

Хорошая статья, мне понравилась. Оставлю отзыв!