Статьи

Конструкторы и нарушение принципа подстановки Лискова

Рискуя быть объектом преследования ненавистников PHP, я должен признаться, что мне довольно комфортно с объектной моделью PHP. Я не настолько наивен, чтобы утверждать, что это полномасштабная модель, раскрывающая все навороты других «толстых» игроков, таких как C ++ и Java, но, несмотря на все свои причуды, PHP выполняет то, что обещает, за чистую монету. Я бы сказал, однако, что модель общеизвестно разрешительна, учитывая ослабленные ограничения, которые она накладывает, когда речь заходит об определении пользовательских интерфейсов.

Одна из проблем, связанных с «прощением» модели, заключается в том, что в один прекрасный день вы можете проснуться в причудливом настроении и указать конструктор в интерфейсе только потому, что можете это сделать без тени вины. Если вы думаете об этом, существование конструктора как части интерфейса довольно бессмысленно. Во-первых, каждый раз, когда вы определяете контракт, вы фактически абстрагируете поведение конкретных реализаций и не учитываете, как они должны быть собраны в будущем. Во-вторых, как в мире вы можете получить клиентский код, потребляющий конструктор после того, как были реализованы реализации? Не существует параллельной вселенной, в которой вы могли бы делать такие вещи, если только не безрассудно загрязняете потребителей кучкой новых операторов, которые должны жить и дышать где-то еще в соответствии с мантрой «изолировать логику приложения от построения объекта».

Если оставить в стороне, мораль этой истории можно свести к следующему: «Строительство объекта не является частью контракта, выполняемого его исполнителями».

Проще понять концепции на примере, чем читать скучную теорию, поэтому в этой статье я продемонстрирую с практической точки зрения, что реализация различных конструкторов в одной и той же иерархии не является нарушением принципа подстановки Лискова , который является причина, почему вы не должны поддаваться искушению испортить ваши интерфейсы конструкторами.

Миф о поломке LSP

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

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

<?php namespace LibraryDatabase; interface DatabaseAdapterInterface { public function executeQuery($sql, array $parameters = array()); public function select($table, array $bind, $operator = "AND"); public function insert($table, array $bind); public function update($table, array $bind, $where = ""); public function delete($table, $where = ""); } 
 <?php namespace LibraryDatabase; class PdoAdapter extends PDO implements DatabaseAdapterInterface { private $statement; public function __construct($dsn, $username = null, $password = null, array $options = array()) { if (!extension_loaded("pdo")) { throw new InvalidArgumentException( "The adapter needs the PDO extension to be loaded."); } if (!is_string($dsn) || empty($dsn)) { throw new InvalidArgumentException("The DSN is invalid."); } parent::__construct($dsn, $username, $password, $options); $this->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); } // Prepare and execute an SQL statement public function executeQuery($sql, array $parameters = array()) { try { $this->statement = $this->prepare($sql); $this->statement->execute($parameters); $this->statement->setFetchMode(PDO::FETCH_OBJ); return $this->statement; } catch (PDOException $e) { throw new RunTimeException($e->getMessage()); } } // Prepare and execute a SELECT statement public function select($table, array $bind = array(), $operator = "AND") { if ($bind) { $where = array(); foreach ($bind as $col => $value) { unset($bind[$col]); $bind[":" . $col] = $value; $where[] = $col . " = :" . $col; } } $sql = "SELECT * FROM " . $table . (($bind) ? " WHERE " . implode(" " . $operator . " ", $where) : " "); return $this->executeQuery($sql, $bind); } // Prepare and execute an INSERT statement public function insert($table, array $bind) { $cols = implode(", ", array_keys($bind)); $values = implode(", :", array_keys($bind)); foreach ($bind as $col => $value) { unset($bind[$col]); $bind[":" . $col] = $value; } $sql = "INSERT INTO " . $table . " (" . $cols . ") VALUES (:" . $values . ")"; $this->executeQuery($sql, $bind); return $this->lastInsertId(); } // Prepare and execute an UPDATE statement public function update($table, array $bind, $where = "") { $set = array(); foreach ($bind as $col => $value) { unset($bind[$col]); $bind[":" . $col] = $value; $set[] = $col . " = :" . $col; } $sql = "UPDATE " . $table . " SET " . implode(", ", $set) . (($where) ? " WHERE " . $where : " "); return $this->executeQuery($sql, $bind)->rowCount(); } // Prepare and execute a DELETE statement public function delete($table, $where = "") { $sql = "DELETE FROM " . $table . (($where) ? " WHERE " . $where : " "); return $this->executeQuery($sql)->rowCount(); } } с <?php namespace LibraryDatabase; class PdoAdapter extends PDO implements DatabaseAdapterInterface { private $statement; public function __construct($dsn, $username = null, $password = null, array $options = array()) { if (!extension_loaded("pdo")) { throw new InvalidArgumentException( "The adapter needs the PDO extension to be loaded."); } if (!is_string($dsn) || empty($dsn)) { throw new InvalidArgumentException("The DSN is invalid."); } parent::__construct($dsn, $username, $password, $options); $this->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); } // Prepare and execute an SQL statement public function executeQuery($sql, array $parameters = array()) { try { $this->statement = $this->prepare($sql); $this->statement->execute($parameters); $this->statement->setFetchMode(PDO::FETCH_OBJ); return $this->statement; } catch (PDOException $e) { throw new RunTimeException($e->getMessage()); } } // Prepare and execute a SELECT statement public function select($table, array $bind = array(), $operator = "AND") { if ($bind) { $where = array(); foreach ($bind as $col => $value) { unset($bind[$col]); $bind[":" . $col] = $value; $where[] = $col . " = :" . $col; } } $sql = "SELECT * FROM " . $table . (($bind) ? " WHERE " . implode(" " . $operator . " ", $where) : " "); return $this->executeQuery($sql, $bind); } // Prepare and execute an INSERT statement public function insert($table, array $bind) { $cols = implode(", ", array_keys($bind)); $values = implode(", :", array_keys($bind)); foreach ($bind as $col => $value) { unset($bind[$col]); $bind[":" . $col] = $value; } $sql = "INSERT INTO " . $table . " (" . $cols . ") VALUES (:" . $values . ")"; $this->executeQuery($sql, $bind); return $this->lastInsertId(); } // Prepare and execute an UPDATE statement public function update($table, array $bind, $where = "") { $set = array(); foreach ($bind as $col => $value) { unset($bind[$col]); $bind[":" . $col] = $value; $set[] = $col . " = :" . $col; } $sql = "UPDATE " . $table . " SET " . implode(", ", $set) . (($where) ? " WHERE " . $where : " "); return $this->executeQuery($sql, $bind)->rowCount(); } // Prepare and execute a DELETE statement public function delete($table, $where = "") { $sql = "DELETE FROM " . $table . (($where) ? " WHERE " . $where : " "); return $this->executeQuery($sql)->rowCount(); } } с <?php namespace LibraryDatabase; class PdoAdapter extends PDO implements DatabaseAdapterInterface { private $statement; public function __construct($dsn, $username = null, $password = null, array $options = array()) { if (!extension_loaded("pdo")) { throw new InvalidArgumentException( "The adapter needs the PDO extension to be loaded."); } if (!is_string($dsn) || empty($dsn)) { throw new InvalidArgumentException("The DSN is invalid."); } parent::__construct($dsn, $username, $password, $options); $this->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); } // Prepare and execute an SQL statement public function executeQuery($sql, array $parameters = array()) { try { $this->statement = $this->prepare($sql); $this->statement->execute($parameters); $this->statement->setFetchMode(PDO::FETCH_OBJ); return $this->statement; } catch (PDOException $e) { throw new RunTimeException($e->getMessage()); } } // Prepare and execute a SELECT statement public function select($table, array $bind = array(), $operator = "AND") { if ($bind) { $where = array(); foreach ($bind as $col => $value) { unset($bind[$col]); $bind[":" . $col] = $value; $where[] = $col . " = :" . $col; } } $sql = "SELECT * FROM " . $table . (($bind) ? " WHERE " . implode(" " . $operator . " ", $where) : " "); return $this->executeQuery($sql, $bind); } // Prepare and execute an INSERT statement public function insert($table, array $bind) { $cols = implode(", ", array_keys($bind)); $values = implode(", :", array_keys($bind)); foreach ($bind as $col => $value) { unset($bind[$col]); $bind[":" . $col] = $value; } $sql = "INSERT INTO " . $table . " (" . $cols . ") VALUES (:" . $values . ")"; $this->executeQuery($sql, $bind); return $this->lastInsertId(); } // Prepare and execute an UPDATE statement public function update($table, array $bind, $where = "") { $set = array(); foreach ($bind as $col => $value) { unset($bind[$col]); $bind[":" . $col] = $value; $set[] = $col . " = :" . $col; } $sql = "UPDATE " . $table . " SET " . implode(", ", $set) . (($where) ? " WHERE " . $where : " "); return $this->executeQuery($sql, $bind)->rowCount(); } // Prepare and execute a DELETE statement public function delete($table, $where = "") { $sql = "DELETE FROM " . $table . (($where) ? " WHERE " . $where : " "); return $this->executeQuery($sql)->rowCount(); } } с <?php namespace LibraryDatabase; class PdoAdapter extends PDO implements DatabaseAdapterInterface { private $statement; public function __construct($dsn, $username = null, $password = null, array $options = array()) { if (!extension_loaded("pdo")) { throw new InvalidArgumentException( "The adapter needs the PDO extension to be loaded."); } if (!is_string($dsn) || empty($dsn)) { throw new InvalidArgumentException("The DSN is invalid."); } parent::__construct($dsn, $username, $password, $options); $this->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); } // Prepare and execute an SQL statement public function executeQuery($sql, array $parameters = array()) { try { $this->statement = $this->prepare($sql); $this->statement->execute($parameters); $this->statement->setFetchMode(PDO::FETCH_OBJ); return $this->statement; } catch (PDOException $e) { throw new RunTimeException($e->getMessage()); } } // Prepare and execute a SELECT statement public function select($table, array $bind = array(), $operator = "AND") { if ($bind) { $where = array(); foreach ($bind as $col => $value) { unset($bind[$col]); $bind[":" . $col] = $value; $where[] = $col . " = :" . $col; } } $sql = "SELECT * FROM " . $table . (($bind) ? " WHERE " . implode(" " . $operator . " ", $where) : " "); return $this->executeQuery($sql, $bind); } // Prepare and execute an INSERT statement public function insert($table, array $bind) { $cols = implode(", ", array_keys($bind)); $values = implode(", :", array_keys($bind)); foreach ($bind as $col => $value) { unset($bind[$col]); $bind[":" . $col] = $value; } $sql = "INSERT INTO " . $table . " (" . $cols . ") VALUES (:" . $values . ")"; $this->executeQuery($sql, $bind); return $this->lastInsertId(); } // Prepare and execute an UPDATE statement public function update($table, array $bind, $where = "") { $set = array(); foreach ($bind as $col => $value) { unset($bind[$col]); $bind[":" . $col] = $value; $set[] = $col . " = :" . $col; } $sql = "UPDATE " . $table . " SET " . implode(", ", $set) . (($where) ? " WHERE " . $where : " "); return $this->executeQuery($sql, $bind)->rowCount(); } // Prepare and execute a DELETE statement public function delete($table, $where = "") { $sql = "DELETE FROM " . $table . (($where) ? " WHERE " . $where : " "); return $this->executeQuery($sql)->rowCount(); } } 

Пакет задач, выполняемых классом PdoAdapter , действительно прост: он ограничен выполнением нескольких подготовленных запросов с помощью метода executeQuery() и выполнением некоторых операций CRUD для данной таблицы. Наиболее важная деталь, которую следует здесь выделить, — это то, как адаптер переопределяет конструктор своего родителя, чтобы выполнить некоторые проверки и посмотреть, установлено ли расширение PDO, прежде чем начать его использовать.

Вот как мы можем получить и запустить адаптер:

 <?php use LibraryLoaderAutoloader, LibraryDatabasePdoAdapter; require_once __DIR__ . "/Library/Loader/Autoloader.php"; $autoloader = new Autoloader; $autoloader->register(); $adapter = new PdoAdapter("mysql:dbname=test", "myusername", "mypassword"); $stmt = $adapter->prepare("SELECT * FROM users"); $stmt->execute(); $users = $stmt->fetchAll(PDO::FETCH_OBJ); foreach ($users as $user) { echo $user->name . " " . $user->email . "<br>"; } 

Я намеренно избегаю метода executeQuery() адаптера и вместо этого обращаюсь к собственному методу execute() чтобы извлечь несколько пользователей из базы данных. На первый взгляд, все это кажется довольно скучным материалом, который следует пропустить для более интересных вещей … но потерпите меня, потому что здесь есть крошечный улов, который стоит подчеркнуть. Даже если учесть, что адаптер эффективно реализует конструктор, отличный от конструктора по умолчанию, предоставляемого PDO, контракт, который он соглашается с клиентским кодом, аккуратно поддерживается сверху вниз. Короче говоря, это означает, что наличие несопоставимых реализаций конструктора в базовом типе и в подтипе никоим образом не нарушает правила заменяемости LSP, если контракт, предоставляемый этими двумя классами, должным образом соблюдается.

Логически, это можно увидеть более четко, если мы переключим предыдущий сценарий на собственный экземпляр PDO, а не потребляем адаптер:

 <?php $adapter = new PDO("mysql:dbname=test", "myusername", "mypassword"); $stmt = $adapter->prepare("SELECT * FROM users"); $stmt->execute(); $users = $stmt->fetchAll(PDO::FETCH_OBJ); foreach ($users as $user) { echo $user->name . " " . $user->email . "<br>"; } 

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

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

 <?php namespace LibraryDatabase; class PdoAdapter extends PDO implements DatabaseAdapterInterface { private $statement; public function __construct(array $config = array()) { if (!extension_loaded("pdo")) { throw new InvalidArgumentException( "The adapter needs the PDO extension to be loaded."); } if (!isset($config["dsn"]) || !is_string($config["dsn"])) { throw new InvalidArgumentException( "The DSN has not been specified or is invalid."); } $username = $password = $options = null; extract($config); parent::__construct($dsn, $username, $password, $options); $this->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); } // the same implementation goes here } 

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

 <?php $adapter = new PdoAdapter(array( "dsn" => "mysql:dbname=test", "username" => "myusername", "password" => "mypassword" )); $stmt = $adapter->prepare("SELECT * FROM users"); $stmt->execute(); $users = $stmt->fetchAll(PDO::FETCH_OBJ); foreach ($users as $user) { echo $user->name . " " . $user->email . "<br>"; } 

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

Тем не менее, если вы чувствуете укол скептицизма и думаете, что пример не подходит, когда дело доходит до демонстрации взаимозаменяемости между типами данной иерархии, посмотрите на следующее, что еще более резко реорганизует конструктор и предоставляет аргументы соединения через наивный DTO ( Объект передачи данных):

 <?php namespace LibraryDatabase; interface ConnectionDefinitionInterface { public function getDsn(); public function getUserName(); public function getPassword(); public function getOptions(); } 
 <?php namespace LibraryDatabase; class ConnectionDefinition implements ConnectionDefinitionInterface { private $dsn; private $username; private $password; private $options = array(); public function __construct($dsn, $username = null, $password = null, array $options = array()) { if (!is_string($dsn) || empty($dsn)) { throw new InvalidArgumentException("The DSN is invalid."); } $this->dsn = $dsn; $this->username = $username; $this->password = $password; $this->options = $options; } public function getDsn() { return $this->dsn; } public function getUserName() { return $this->username; } public function getPassword() { return $this->password; } public function getOptions() { return $this->options; } } 
 <?php namespace LibraryDatabase; class PdoAdapter extends PDO implements DatabaseAdapterInterface { private $statement; public function __construct(ConnectionDefinitionInterface $connectionDefinition) { if (!extension_loaded("pdo")) { throw new InvalidArgumentException( "The adapter needs the PDO extension to be loaded."); } parent::__construct( $connectionDefinition->getDsn(), $connectionDefinition->getUserName(), $connectionDefinition->getPassword(), $connectionDefinition->getOptions() ); $this->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); } // the same implementation goes here } 

Хотя справедливо признать, что DTO не имеют плодовитого существования в мире PHP (хотя некоторые популярные фреймворки, такие как Zend Framework 2 и Simfony 2, используют их повсеместно), в этом случае я обратился к простому, чтобы передать набор соединений аргументы конструктора адаптера. Радикальное изменение по очевидным причинам приведет к появлению артефактов в коде, отвечающем за создание экземпляра адаптера, будь то DIC, низкоуровневая фабрика или любой другой независимый механизм в этом направлении, который явно не мешает логике приложения. :

 <?php $adapter = new PdoAdapter( new ConnectionDefinition("mysql:dbname=test", "myusername", "mypassword") ); 

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

Хотя этот пример может быть несколько излишним, когда речь заходит о продвижении функциональности PDO за пределы тонкой оболочки, он отлично демонстрирует грубые поломки LSP, которые легко обнаружить в дикой природе по другим причинам, которые я осмелюсь утверждение даже не связано с «недостатками» схемы построения базового типа / подтипа.

Заключительные замечания

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

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

Изображение через Fotolia