Статьи

Принцип подстановки Лискова

Добро пожаловать в (переопределенную) Матрицу. Тише … не говори никому! В удаленной сцене из трилогии Matrix происходит следующий диалог:

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

Нео: Прекрасно звучит, Морфеус. Могу ли я получить копию программы?

Морфеус: Нет проблем. Просто клонируйте наш репозиторий и посмотрите на файлы AgentMapper.php и index.php .

Neo запускает несколько команд Git, и вскоре перед ним появляется следующий код.

 <?php namespace ModelMapper; class AgentMapper { protected $_adapter; protected $_table = "agents"; public function __construct(PDO $adapter) { $this->_adapter = $adapter; } public function findAll() { try { return $this->_adapter->query("SELECT * FROM " . $this->_table, PDO::FETCH_OBJ); } catch (Exception $e) { return array(); } } } 
 <?php use ModelMapperAgentMapper; // a PSR-0 compliant class loader require_once __DIR__ . "/Autoloader.php"; $autoloader = new Autoloader(); $autoloader->register(); $adapter = new PDO("mysql:dbname=Nebuchadnezzar", "morpheus", "aa26d7c557296a4e8d49b42c8615233a3443036d"); $agentMapper = new AgentMapper($adapter); $agents = $agentMapper->findAll(); foreach ($agents as $agent) { echo "Name: " . $agent->name . " - Status: " . $agent->status . "<br>"; } 

Нео: Морфеус, я только что получил файлы. Я собираюсь создать подкласс PDO и переопределить его метод query() чтобы он мог работать с подготовленными запросами. Из-за моих сверхчеловеческих способностей, я должен быть в состоянии заставить это работать в одно мгновение. Сохраняй спокойствие.

Гладкий звук клавиатуры компьютера наполняет воздух.

Нео: Морфеус, подкласс готов к тестированию. Не стесняйтесь проверить это на вашей стороне.

Морфеус делает быстрый поиск на своем ноутбуке и видит класс ниже.

 <?php namespace LibraryDatabase; class PdoAdapter extends PDO { protected $_statement; public function __construct($dsn, $username = null, $password = null, array $driverOptions = array()) { // check if a valid DSN has been passed in if (!is_string($dsn) || empty($dsn)) { throw new InvalidArgumentException("The DSN must be a non-empty string."); } try { // attempt to create a valid PDO object and set some attributes. parent::__construct($dsn, $username, $password, $driverOptions); $this->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); } catch (PDOException $e) { throw new RunTimeException($e->getMessage()); } } public function query($sql, array $parameters = array()) { try { $this->_statement = $this->prepare($sql); $this->_statement->execute($parameters); return $this->_statement->fetchAll(PDO::FETCH_OBJ); } catch (PDOException $e) { throw new RunTimeException($e->getMessage()); } } } 

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

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

А потом крик!

Морфеус: Нео, я уверен, что ты Единственный! Просто у меня на лице ужасная фатальная ошибка со следующим сообщением:

  Исправляемая фатальная ошибка: Аргумент 2, передаваемый в LibraryDatabasePdoAdapter :: query (), должен быть массивом, заданным целым числом, вызываемым в пути / к / AgentMapper на линии (кого это волнует?) 

Еще один крик.

Нео: Что пошло не так ?! Что пошло не так?!

Больше криков.

Морфеус: Я действительно не знаю. Ох, агент Смит сейчас идет за мной!

Связь внезапно обрывается. Долгое, тяжелое молчание завершает диалог, предполагая, что Морфеус был застигнут врасплох и был серьезно ранен агентом Смитом.

LSP не обозначает (L) ази, (S) или (P) программистов

Излишне говорить, что вышеприведенный диалог вымышленный, но проблема, несомненно, реальна. Если бы Нео узнал только одну или две вещи о Принципе замещения Лискова (LSP) как известного хакера, которым он был, мистер Смит мог бы быть найден в один миг. Лучше всего, Морфеус был бы спасен от злых намерений агента. Как жаль его, действительно.

Однако во многих случаях разработчики PHP думают о LSP почти так же, как Neo раньше: LSP — это не что иное, как теоретический принцип пуриста, который практически не имеет практического применения. Но они идут по неверному пути.

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

Проще говоря, LSP устанавливает, что при переопределении метода в подклассе он должен удовлетворять следующим требованиям:

  1. Его подпись должна соответствовать подписи его родителя
  2. Его предпосылки (что принимать) должны быть такими же или более слабыми
  3. Его пост условия (что ожидать) должны быть такими же или сильнее
  4. Исключения (если они есть) должны быть того же типа, что и их родительские

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

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

 <?php public function findAll() { try { return ($this->_adapter instanceof PdoAdapter) ? $this->_adapter->query("SELECT * FROM " . $this->_table) : $this->_adapter->query("SELECT * FROM " . $this->_table, PDO::FETCH_OBJ); } catch (Exception $e) { return array(); } } 

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

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

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

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

Итак, как вышеперечисленное решение будет реализовано?

Дизайн по контракту и дело против наследования

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

 <?php namespace LibraryDatabase; interface DatabaseAdapterInterface { public function connect(); public function disconnect(); public function query($sql); } 

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

 <?php namespace LibraryDatabase; class PdoAdapter implements DatabaseAdapterInterface { protected $_config = array(); protected $_connection; public function __construct($dsn, $username = null, $password = null, array $driverOptions = array()) { if (!is_string($dsn) || empty($dsn)) { throw new InvalidArgumentException("The DSN must be a non-empty string."); } // save connection parameters in the $_config field $this->_config = compact("dsn", "username", "password", "driverOptions"); } public function connect() { // if there is a PDO object already, return early if ($this->_connection) { return; } // otherwise try to create a PDO object try { $this->_connection = new PDO( $this->_config["dsn"], $this->_config["username"], $this->_config["password"], $this->_config["driverOptions"]); $this->_connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->_connection->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); $this->_connection->setAttribute(PDO::ATTR_CASE, PDO::CASE_NATURAL); } catch (PDOException $e) { throw new RunTimeException($e->getMessage()); } } public function disconnect() { $this->_connection = null; } public function query($sql, $fetchStyle = PDO::FETCH_OBJ) { $this->connect(); try { return $this->_connection->query($sql, $fetchStyle); } catch (PDOException $e) { throw new RunTimeException($e->getMessage()); } } } $ <?php namespace LibraryDatabase; class PdoAdapter implements DatabaseAdapterInterface { protected $_config = array(); protected $_connection; public function __construct($dsn, $username = null, $password = null, array $driverOptions = array()) { if (!is_string($dsn) || empty($dsn)) { throw new InvalidArgumentException("The DSN must be a non-empty string."); } // save connection parameters in the $_config field $this->_config = compact("dsn", "username", "password", "driverOptions"); } public function connect() { // if there is a PDO object already, return early if ($this->_connection) { return; } // otherwise try to create a PDO object try { $this->_connection = new PDO( $this->_config["dsn"], $this->_config["username"], $this->_config["password"], $this->_config["driverOptions"]); $this->_connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->_connection->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); $this->_connection->setAttribute(PDO::ATTR_CASE, PDO::CASE_NATURAL); } catch (PDOException $e) { throw new RunTimeException($e->getMessage()); } } public function disconnect() { $this->_connection = null; } public function query($sql, $fetchStyle = PDO::FETCH_OBJ) { $this->connect(); try { return $this->_connection->query($sql, $fetchStyle); } catch (PDOException $e) { throw new RunTimeException($e->getMessage()); } } } 

Выполнено. Даже когда вышеупомянутый класс довольно надуманный, он четко придерживается контракта, который он реализует. Это позволяет выполнять кучу SQL-запросов без особой борьбы и, самое главное, без переопределения чего-либо значимого по ошибке.

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

Тем не менее, вот как выглядит вышеупомянутый интерфейс:

 <?php namespace ModelMapper; interface AgentMapperInterface { public function findAll(); } 

А вот обновленная версия агента маппера:

 <?php namespace ModelMapper; use LibraryDataBaseDatabaseAdapterInterface; class AgentMapper implements AgentMapperInterface { protected $_adapter; protected $_table = "agents"; public function __construct(DatabaseAdapterInterface $adapter) { $this->_adapter = $adapter; } public function findAll() { try { return $this->_adapter->query("SELECT * FROM " . $this->_table); } catch (Exception $e) { return array(); } } } 

Миссия выполнена. Учитывая, что теперь рефакторированный маппер ожидает получить реализацию более раннего интерфейса базы данных, можно с уверенностью сказать, что его findAll() не должен будет проходить уродливые проверки, чтобы увидеть, что он вставил в конструктор. Определенно, у нас здесь большой победитель!

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

 <?php use LibraryDatabasePdoAdapter, ModelMapperAgentMapper; // a PSR-0 compliant class loader require_once __DIR__ . "/Autoloader.php"; $autoloader = new Autoloader(); $autoloader->register(); $adapter = new PdoAdapter("mysql:dbname=Nebuchadnezzar", "morpheus", "aa26d7c557296a4e8d49b42c8615233a3443036d"); $agentMapper = new AgentMapper($adapter); $agents = $agentMapper->findAll(); foreach ($agents as $agent) { echo "Name: " . $agent->name . " - Status: " . $agent->status . "<br>"; } 

Совсем неплохо, а? Недостатком этого подхода является то, что весь процесс рефакторинга слишком радикальный. А в более реалистичных случаях использования это даже не будет жизнеспособным решением (особенно при работе с большими кусками грязного унаследованного кода). Несмотря на это, он в двух словах показывает, как создавать абстракции, производные которых не будут нарушать условия, налагаемые LSP, с использованием композиции поверх наследования, а также с помощью функции Design by Contract.

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

Конец.

Заключительные замечания (вне матрицы)

Будучи центральной точкой объектно-ориентированного проектирования и буквой «L» в принципах SOLID, принцип замещения Лискова за многие годы приобрел множество недоброжелательных недоброжелателей, скорее всего потому, что его академическое определение полно технических терминов, которые закрывают глаза и затрудняют понять, что на самом деле скрывается за завесой. Более того, на первый взгляд оно, кажется, противоречит или даже осуждает неизбежную гибель существования наследования.

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

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