Понимание и применение Интерфейсов и Абстрактных классов

Понимание и применение Интерфейсов и Абстрактных классов

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

Интерфейс

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

Вот пример простого интерфейса:

interface Logger {
    public function execute();
}

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

class DatabaseLogger 
{
    public function execute($message)
    {
        // сохраняем сообщение $message лога в базу данных
    }
}

class FileLogger 
{
    public function execute($message)
    {
        // сохраняем сообщение $message лога в файл
    }
}

class UsersController 
{ 
    protected $logger;
    
    public function __construct(FileLogger $logger)
    {
        $this->logger = $logger;
    }
    
    public function show()
    { 
        $user = \Auth::user();
        $this->logger->execute("Пользователь {$user->id} выполнил какое-то действие.");
    }
}
$controller = new UsersController(new FileLogger());
$controller->show();

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

// так было раннее 
// public function __construct(FileLogger $logger)

// так стало
public function __construct(DatabaseLogger $logger) // класс UsersController

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

Сейчас, давайте посмотрим на код ниже, в котором реализуем работу с интерфейсом:

interface Logger {
    public function execute();
}

class DatabaseLogger implements Logger 
{
    public function execute($message)
    {
        // сохраняем сообщение $message лога в базу данных
    }
}

class FileLogger implements Logger 
{
    public function execute($message)
    {
        // сохраняем сообщение $message лога в файл
    }
}

class UsersController 
{
    protected $logger;
    
    public function __construct(Logger $logger) 
    {
        $this->logger = $logger;
    }
    
    public function show()
    { 
        $user = \Auth::user();
        $this->logger->execute("Пользователь {$user->id} выполнил какое-то действие.");
    }
}

$controller = new UsersController(new DatabaseLogger());
$controller->show();

И в этом случае, если я изменю использование логгера с FileLogger на DatabaseLogger, мне больше не придётся изменять метод контроллера напрямую. В конструктор я заинжектил интерфейс, вместо жестко указанного конкретного класса реализацию, таких как DatabaseLogger или FileLogger.

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

В этом примере я использовал логгер DatabaseLogger, и теперь, чтобы просто переключиться на файлов лог, то достаточно написать:

$controller = new UsersController(new FileLogger());
$controller->show();

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

Абстрактный класс

Абстрактный класс - это класс, который имеет только частично реализованную логику. Если класс содержит как минимум один абстрактный метод, который не имеет готовой реализации (содержит просто имя и принимаемые параметры) и метод обозначен как абстрактный abstract, то такой класс помечается как абстрактный соответственно. То есть, абстрактный класс - это что-то среднее между интерфейсом и обычным классом. Отличие абстрактного класса от интерфейса заключается в том, что он может содержать логику в методах. А общее - то, что, как и интерфейс, он описывает шаблоны методов которые необходимо реализовать.

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

abstract class AbstractExample
{
    // метод без логики, флаг abstract обязует программиста определить логику в дочерних классах (классах-наследниках)
    abstract protected function getValue();
    
    // определённая логика, которая считается общей, независимо от потенциальной реализации
    public function printOut() 
    {
        print $this->getValue() . PHP_EOL;
    }
}

Но, теперь вопрос на миллион, в каких случаях этот абстрактный метод должен быть определён? Ниже я постараюсь ответить на этот вопрос и объяснить:

class Tea 
{
    public function addTea()
    {
        var_dump('Добавить щепотку чая.');
        return $this;
    }
    
    protected  function addHotWater()
    {
        var_dump('Добавить горячей воды.');
        return $this;
    }
    
    protected  function addSugar()
    {
        var_dump('Добавить ложку сахара.');
        return $this;
    }
    
    protected function addMilk()
    {
        var_dump('Добавить молоко.');
        return $this;
    }
    
    public function make()
    {
        return $this
            ->addHotWater()
            ->addSugar()
            ->addTea()
            ->addMilk();
    }
}
$tea = new Tea();
$tea->make(); // завариваем чай

Дальше, реализуем похожий класс для кофе:

class Coffee 
{
    public function addCoffee()
    {
        var_dump('Добавить ложку кофе.');
        return $this;
    }
    
    protected  function addHotWater()
    {
        var_dump('Добавить горячей воды.');
        return $this;
    }
    
    protected  function addSugar()
    {
        var_dump('Добавить сахара.');
        return $this;
    }
    
    protected function addMilk()
    {
        var_dump('Добавить молока.');
        return $this;
    }
    
    public function make()
    {
        return $this
            ->addHotWater()
            ->addSugar()
            ->addCoffee()
            ->addMilk();
    }
}
$tea = new Coffee();
$tea->make(); // сделали кофе

В описанных двух классах 3 метода addHotWater(), addSugar() и addMilk() имеют одну и ту же логику, потому, нам нужно избавиться от дублируемого кода. И, это как раз и является ответом на вопрос, когда использовать абстрактный класс: в случае, когда существует определённая общая логика для всех абстракций классов. И поступить можно таким путём:

abstract class Template
{
    public function make()
    {
        return $this
            ->addHotWater()
            ->addSugar()
            ->addPrimaryToppings()
            ->addMilk();
    }
    
    protected  function addHotWater()
    {
        var_dump('Добавить горячей воды.');
        return $this;
    }
    
    protected  function addSugar()
    {
        var_dump('Добавить сахара.');
        return $this;
    }
    
    protected function addMilk()
    {
        var_dump('Добавить молока.');
        return $this;
    }
    
    protected abstract function addPrimaryToppings();
}

class Tea extends Template
{
    // пишем логику для абстрактного метода
    public function addPrimaryToppings()
    {
        var_dump('Добавить щепотку чая.');
        return $this;
    }
}

class Coffee extends Template
{
    // пишем логику для абстрактного метода
    public function addPrimaryToppings()
    {
        var_dump('Добавить ложку кофе.');
        return $this;
    }
}

// сделать чай
$tea = new Tea();
$tea->make();

// сделать кофе
$coffee = new Coffee();
$coffee->make();

Я сделал абстрактный класс с именем Template. В нём я определил методы с реализацией: addHotWater(), addSugar() и addMilk(), а так же, добавил абстрактный метод addPrimaryToppings(), который, в каждой из реализаций будет отличаться и содержать собственную логику.

Сейчас, классом Tea унаследоваться от класса Template, он сходу получит 3 готовых метода от абстрактного класса, и один абстрактный, в котором обязательно нужно написать свою логику. С классом Coffee дела обстоят таким же образом.