3 июня 2020 г.

Абстрактные классы и интерфейсы. Как выбрать?

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

Абстракция в ООП

Абстракция подразумевает собой скрытие деталей реализации и фокусирование на том, что объект делает, а не на том, как он это делает. К примеру, вам нужно сохранять ошибки приложения в файл. Вы создадите только возможность сохранять в файл (FileLogger) или подумаете о том, что вам могут понадобиться другие типы логеров? Именно здесь возникает абстракция, она позволяет вам думать о функционале с точки зрения логгера (Logger), а не с точки зрения именно файлового логгера (FileLogger). Но абстракция просто говорит нам о том, что должна делать конкретная реализация. На как это должно работать? Как вы можете разработать этот абстрактный функционал? Здесь на помощь приходит полиморфизм.

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

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

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

abstract class Logger {}

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

abstract public function log($message);

В любом классе мы можем наследовать абстрактный класс, используя ключевое слово extends. Абстрактные методы, переопределяются так же, как и обычные:

class FileLogger extends Logger {
   public function log($message)
   {
      // Реализация по логированию в файл
   }
}

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

Абстрактный класс все же является классом и поэтому, как и в случае с обычными классами, вы не можете наследовать более одного класса.

Интерфейсы

Интерфейсы могут содержать только объявления методов, которые те классы, которые его реализуют, должны реализовать в обязательном порядке. Обратите внимание, что я использовал термин реализуют. Если абстрактные классы мы наследуем, то интерфейсы реализуем. При этом используем ключевое слово implements вместо extends:

class FileLogger implements Logger {}

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

Абстрактный класс или интерфейс?

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

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

interface Logger {
   public function log($message);
}

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

abstract class Logger {
   abstract public function log($message);

   protected function formatMessage($message)
   {
     // Логика по форматированию сообщения
   }
}

Зачем использовать абстрактный класс или интерфейс?

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

У нас есть интерфейс Logger с одним лишь методом log:

interface Logger {
   public function log($message);
}

И у нас есть две конкретные реализации: FileLogger и DatabaseLogger:

class FileLogger implements Logger {
   public function log($message)
   {
     var_dump('Logging to a file');
   }
}

class DatabaseLogger implements Logger {
   public function log($message)
   {
     var_dump('Logging to a database');
   }  
}

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

class PagesController {
   public function index(FileLogger $fileLogger)
   {
     $fileLogger->log('Some message');
   }
}

Теперь передадим экземпляр FileLogger’а в метод index:

$controller = new PagesController;
$controller->index(new FileLogger);

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

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

class PagesController {
   public function index(Logger $logger)
   {
     $logger->log('Some message');
   }
}

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

Это и есть полиморфизм: в контроллере мы вызываем метод класса, зная только абстракцию, но не зная в каком классе вызывается этот метод.