Почему следует избегать чрезмерной абстракции

Недавно, приступая к работе над существующим проектом, я прочитал документацию о нём. «Абстрагируйте, когда это возможно» было написано в начале contributing.md. Стало сразу ясно, что проект содержит больше абстрактных классов, чем любой другой нормальный проект. Такой подход приводит к слишком связанному и часто неизменному коду.

Почему же «абстрагируйте, когда это возможно» — вовсе не хороший совет, расскажу далее.

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

1 Как неправильно используются абстрактные классы?

Продемонстрирую примеры кода.

1.1 Сверхсложные абстрагированные методы

Во-первых, базовый класс с усложнённым методом:

abstract class AbstractRepository
{
    public function __construct(Model $models)
    {
        $this->models = $models;
    }

    public function get(array $options = [])
    {
        if (isset($options['sort'])) {
            //...
        }

        if (isset($options['filters'])) {
            //...
        }

        return $this->models->get();
    }
}

Очевидно, что базовый класс имеет метод get, возвращающий список моделей. Базовому классу не хватает гибкости, поскольку будут появляться множество вариантов использования. Get метод изменится, чтобы принять дополнительные настройки $options. Но т.к. количество возможных вариантов использования растёт, количество строк кода метода get будет расти также.

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

Как решить эту проблему? Учитесь определять, когда метод становится активным местом, куда добавляется функциональность. Массив $options в аргументе является хорошим индикатором для обнаружения этого. Попытайтесь осуществить рефакторинг путём перемещения реализации в дочерние классы. Позвольте им заботиться об их собственных вариантах использования, например, сортировки. Если сортировка должна быть добавлена в несколько репозиториев, просто сначала копируйте часть кода. Не абстрагируйте сразу же. Закончив дублирование кода и все работы, можно приступить к добавлению специализированных классов сортировки и использовать их для содержания логики сортировки. Помните про единственную ответственность? В идеале, чтобы внедрить поведение сортировки в другой репозиторий, должно потребоваться всего несколько строк кода.

1.2 Абстрактный класс, определяющий зависимости для всех его дочерних элементов

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

abstract class CsvExporter
{
    protected $repostory;

    protected $file;

    protected $writer;

    protected $parameters;

    public function __construct(RepositoryInterface $repository, TransformerInterface $transformer, array $parameters)
    {
        $this->repository = $repository;
        $this->parameters = $parameters;
        $this->file = new SplTempFileObject();

        $this->writer = Writer::createFromFileObject($this->file);
        $this->writer->insertOne($transformer->getHeaders());
        $this->writer->addFormatter([$transformer, 'transform']);
    }
}

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

Как же решить такую проблему? На помощь приходят трейты (traits)! То, в чём нуждается каждый экспортирующий CSV класс, является доступ к SplFileObject и объекту Writer (из league/csv пакета проекта). Всё остальное — детали реализации. Таким образом, мы создадим трейт, содержащий и $file, и $writer свойства.

trait CsvWriter
{
    private $file;
    private $writer;
}

Более того, бремя инициализации этих свойств может взять на себя сам трейт в методе setup.

trait CsvWriter
{
    private $file;
    private $writer;

    private function setup()
    {
        $this->file = new SplTempFileObject();
        $this->writer = Writer::createFromFileObject($this->file);
    }
}

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

private function setTransformer(TransformerInterface $transformer)
{
    $this->writer->insertOne($transformer->getHeaders());
    $this->writer->addFormatter([$transformer, 'transform']);
}

Теперь классу-экспортёру можно легко использовать league/csv Writer класс с помощью трейта.

class UserExporter
{
    use CsvWriter;

    public function (User $users, UserTransformer $transformer)
    {
        $this->setup();
        $this->setTransformer($transformer);
        $this->users = $users;
    }

    public function getFile()
    {
        $this->users->chunk(100, function ($users) {
            $this->writer->insertAll($users);
        });

        return $this->file;
    }
}

Да, это так просто. Каждый класс и трейт теперь допускают повторное использование, они тестируемы и не связаны.

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

2 Какие абстрактные классы правильные?

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

<?php

namespace League\Event;

interface ListenerInterface
{
    /**
    * Handle an event.
    *
    * @param EventInterface $event
    *
    * @return void
    */
    public function handle(EventInterface $event);

    /**
    * Check whether the listener is the given parameter.
    *
    * @param mixed $listener
    *
    * @return bool
    */
    public function isListener($listener);
}

Для почти каждой реализации Listener, метод isListener должен иметь ту же реализацию. Таким образом, league/event пакет идёт вместе с AbstractListener:

<?php

namespace League\Event;

abstract class AbstractListener implements ListenerInterface
{
    /**
    * {@inheritdoc}
    */
    public function isListener($listener)
    {
        return $this === $listener;
    }
}

Если по каким-то причинам вы захотите снова использовать существующий класс (который уже является частью его собственного дерева наследования, т.е. имеет собственный родительский класс), чтобы стать слушателем и реализовать ListenerInterface, то вы должны будете реализовать метод isListener самостоятельно. (Если в дереве наследования присутствует реализация метода isListener, неважно в абстрактном или нет классе, то дочерний класс получит этот метод, если только у метода область видимости не private, но в примере AbstractListener выше — таки public. Т.е., если класс родитель имеет в дереве наследования AbstractListener, то реализовывать метод isListener не обязательно. Прим. перев.). В качестве альтернативы вы можете создать и использовать простой трейт типа этого:

trait PartialListener
{
    /**
    * {@inheritdoc}
    */
    public function isListener($listener)
    {
        return $this === $listener;
    }
}

3 Резюме

* Плох тот метод в абстрактном классе, что растёт с каждым новым вариантом использования. Рефакторинг нужен ему.
* Абстрактные классы с конструкторами — антипаттерн.
* Если вы застреваете с существующим деревом наследования, то трейты (для частичной реализации) могут быть решением проблем.

Ура!

4 Примечание

Статья является авторским переводом «Why You Should Avoid Over-Abstracting :: madewithlove».

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *