Статические члены класса. Не дай им загубить твой код

в 7:08, , рубрики: php, static, ооп, переводы

Давно хотел написать на эту тему. Первым толчком послужила статья Miško Hevery "Static Methods are Death to Testability". Я написал ответную статью, но так и не опубликовал ее. А вот недавно увидел нечто, что можно назвать «Классо-Ориентированное Программирование». Это освежило мой интерес к теме и вот результат.

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

  • это не дает никаких преимуществ по сравнению с процедурным программированием
  • не стоит отказываться от объектов
  • наличие статических членов класса != смерть тестам

Хотя эта статья про PHP, концепции применимы и к другим языкам.

Зависимости

Обычно, код зависит от другого кода. Например:

$foo = substr($bar, 42);

Этот код зависит от переменной $bar и функции substr. $bar — это просто локальная переменная, определенная немного выше в этом же файле и в той же области видимости. substr — это функция ядра PHP. Здесь все просто.

Теперь, такой пример:

$foo = normalizer_normalize($bar);

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

Теперь, такой вариант:

class Foo {

   public static function bar() {
       return Database::fetchAll("SELECT * FROM `foo` WHERE `bar` = 'baz'");
   }

}

Это типичный пример классо-ориентированного программирования. Foo жестко завязан на Database. И еще мы предполагаем, что класс Database был уже инициализирован и соединение с базой данных (БД) уже установлено. Предположительно, использование этого кода будет таким:

Database::connect('localhost', 'user', 'password');
$bar = Foo::bar();

Foo::bar неявно зависит от доступности Database и его внутреннего состояния. Вы не можете использовать Foo без Database, а Database, предположительно, требует соединения с БД. Как можно быть уверенным, что соединение с БД уже установлено, когда происходит вызов Database::fetchAll? Один из способов выглядит так:

class Database {

   protected static $connection;

   public static function connect() {
       if (!self::$connection) {
           $credentials = include 'config/database.php';
           self::$connection = some_database_adapter($credentials['host'], $credentials['user'], $credentials['password']);
       }
   }

   public static function fetchAll($query) {
       self::connect();

       // используем self::$connection...
       // here be dragons...

       return $data;
   }

}

При вызове Database::fetchAll, проверяем существование соединения, вызывая метод connect, который, при необходимости, получает параметры соединения из конфига. Это означает, что Database зависит от файла config/database.php. Если этого файла нет — он не может функционировать. Едем дальше. Класс Database привязан к одной базе данных. Если Вам понадобится передать другие параметры соединения, то это будет, как минимум, нелегко. Ком нарастает. Foo не только зависит от наличия Database, но также зависит от его состояния. Database зависит от конкретного файла, в конкретной папке. Т.е. неявно класс Foo зависит от файла в папке, хотя по его коду этого не видно. Более того, здесь куча зависимостей от глобального состояния. Каждый кусок зависит от другого куска, который должен быть в нужном состоянии и нигде это явно не обозначено.

Что-то знакомое...

Неправда ли, похоже на процедурный подход? Давайте попробуем переписать этот пример в процедурном стиле:

function database_connect() {
   global $database_connection;
   if (!$database_connection) {
       $credentials = include 'config/database.php';
       $database_connection = some_database_adapter($credentials['host'], $credentials['user'], $credentials['password']);
   }
}

function database_fetch_all($query) {
   global $database_connection;
   database_connect();

   // используем $database_connection...
   // ...

   return $data;
}

function foo_bar() {
   return database_fetch_all("SELECT * FROM `foo` WHERE `bar` = 'baz'");
}

Найдите 10 отличий…
Подсказка: единственное отличие — это видимость Database::$connection и $database_connection.

В классо-ориентированном примере, соединение доступно только для самого класса Database, а в процедурном коде эта переменная глобальна. Код имеет те же зависимости, связи, проблемы и работает так же. Между $database_connection и Database::$connection практически нет разницы — это просто разный синтаксис для одного и того же, обе переменные имеют глобальное состояние. Легкий налет пространства имен, благодаря использованию классов — это конечно лучше, чем ничего, но ничего серьезно не меняет.

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

Поворачиваем ключ зажигания

Теперь, давайте попробуем ООП. Начнем с реализации Foo:

class Foo {

   protected $database;

   public function __construct(Database $database) {
       $this->database = $database;
   }

   public function bar() {
       return $this->database->fetchAll("SELECT * FROM `foo` WHERE `bar` = 'baz'");
   }

}

Теперь Foo не зависит от конкретного Database. При создании экземпляра Foo, нужно передать некоторый объект, обладающий характеристиками Database. Это может быть как экземпляр Database, так и его потомок. Значит мы можем использовать другую реализацию Database, которая может получать данные откуда-нибудь из другого места. Или имеет кеширующий слой. Или является заглушкой для тестов, а не настоящим соединением с БД. Теперь нужно создавать экземпляр Database, это означает, что мы можем использовать несколько разных подключений к разным БД, с разными параметрами. Давайте реализуем Database:

class Database {

   protected $connection;

   public function __construct($host, $user, $password) {
       $this->connection = some_database_adapter($host, $user, $password);
       if (!$this->connection) {
           throw new Exception("Couldn't connect to database");
       }
   }

   public function fetchAll($query) {
       // используем $this->connection ...
       // ...
       return $data;
   }

}

Обратите внимание, насколько проще стала реализация. В Database::fetchAll не нужно проверять состояние подключения. Чтобы вызвать Database::fetchAll, нужно создать экземпляр класса. Чтобы создать экземпляр класса, нужно передать параметры подключения в конструктор. Если параметры подключения не валидны или подключение не может быть установлено по другим причинам, будет брошено исключение и объект не будет создан. Это все означает, что когда Вы вызываете Database::fetchAll, у Вас гарантировано есть соединение с БД. Это значит, что Foo нужно только указать в конструкторе, что ему необходим Database $database и у него будет соединение с БД.

Без экземпляра Foo, Вы не можете вызвать Foo::bar. Без экземпляра Database, Вы не можете создать экземпляр Foo. Без валидных параметров подключения, Вам не создать экземпляр Database.

Вы попросту не сможете использовать код, если хоть одно условие не удовлетворено.

Сравним это с классо-ориентированным кодом: вызвать Foo::bar можно в любое время, но возникнет ошибка, если класс Database не готов. Вызвать Database::fetchAll можно в любое время, но возникнет ошибка, если будут проблемы с файлом config/database.php. Database::connect устанавливает глобальное состояние, от которого зависят все остальные операции, но эта зависимость ничем не гарантируется.

Инъекция

Посмотрим на это со стороны кода, который использует Foo. Процедурный пример:

$bar = foo_bar();

Написать эту строчку можно в любом месте и она выполнится. Ее поведение зависит от глобального состояния подключения к БД. Хотя из кода это не очевидно. Добавим обработку ошибок:

$bar = foo_bar();
if (!$bar) {
   // что-то не так с $bar, завершаем работу!
} else {
   // все хорошо, идем дальше
}

Из-за неявных зависимостей foo_bar, в случае ошибки будет тяжело понять, что именно сломалось.

Для сравнения, вот классо-ориентированная реализация:

$bar = Foo::bar();
if (!$bar) {
   // что-то не так с $bar, завершаем работу!
} else {
   // все хорошо, идем дальше
}

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

Теперь ООП:

$foo = new Foo;
$bar = $foo->bar();

PHP упадет с фатальной ошибкой, когда дойдет до new Foo. Мы указали что Foo необходим экземпляр Database, но не передали его.

$db  = new Database;
$foo = new Foo($db);
$bar = $foo->bar();

PHP опять упадет, т.к. мы не передали параметры подключения к БД, которые мы указали в Database::__construct.

$db  = new Database('localhost', 'user', 'password');
$foo = new Foo($db);
$bar = $foo->bar();

Теперь мы удовлетворили все зависимости, которые обещали, все готово к запуску.

Но давайте представим, что параметры подключения к БД неверные или у нас какие-то проблемы с БД и соединение не может быть установлено. В этом случае будет брошено исключение при выполнении new Database(...). Следующие строки просто не выполнятся. Значит у нас нет необходимости проверять ошибку после вызова $foo->bar() (конечно, Вы можете проверить что Вам вернулось). Если что-то пойдет не так с любой из зависимостей, код не будет выполнен. А брошенное исключение будет содержать полезную для отладки информацию.

Объектно-орентированный подход может показаться более сложным. В нашем примере процедурного или классо-ориентированного кода всего лишь одна строчка, которая вызывает foo_bar или Foo::bar, в то время как объектно-орентированный подход занимает три строки. Здесь важно уловить суть. Мы не инициализировали БД в процедурном коде, хотя нам нужно это сделать в любом случае. Процедурный подход требует обработку ошибок постфактум и в каждой точке процесса. Обработка ошибок очень запутана, т.к. сложно отследить какая из неявных зависимостей вызвала ошибку. Хардкод скрывает зависимости. Не очевидны источники ошибок. Не очевидно от чего зависит ваш код для нормального его функционирования.

Объектно-орентированный подход делает все зависимости явными и очевидными. Для Foo нужен экземпляр Database, а экземпляру Database нужны параметры подключения.

В процедурном подходе ответственность ложится на функции. Вызываем метод Foo::bar — теперь он должен вернуть нам результат. Этот метод, в свою очередь, делегирует задачу Database::fetchAll. Теперь уже на нем вся ответственность и он пытается соединиться к БД и вернуть какие-то данные. И если что-то пойдет не так в любой точке… кто знает что Вам вернется и откуда.

Объектно-ориентированный подход перекладывает часть ответственности на вызывающий код и в этом его сила. Хотите вызвать Foo::bar? Хорошо, тогда дайте ему соединение с БД. Какое соединение? Неважно, лишь бы это был экземпляр Database. Это сила внедрения зависимостей. Она делает необходимые зависимости явными.

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

Статические члены класса. Не дай им загубить твой код

В объектно-орентированном коде с внедрением зависимостей, Вы создаете много маленьких блоков, каждый из которых самостоятелен. У каждого блока есть четко определенный интерфейс, который могут использовать другие блоки. Каждый блок знает, что ему нужно от других чтобы все работало. В процедурном и классо-ориентированном коде Вы связываете Foo с Database сразу во время написания кода. В объектно-орентированном коде Вы указываете что Foo нужен какой-нибудь Database, но оставляете пространство для маневра, каким он может быть. Когда Вы захотите использовать Foo, Вам нужно будет связать конкретный экземпляр Foo с конкретным экземпляром Database:

Статические члены класса. Не дай им загубить твой код

Классо-ориентированный подход выглядит обманчиво просто, но намертво приколачивает код гвоздями зависимостей. Объектно-ориентированный подход оставляет все гибким и изолированым до момента использования, что может выглядеть более сложным, но это более управляемо.

Статические члены

Зачем же нужны статические свойства и методы? Они полезны для статических данных. Например, данные от которых зависит экземпляр, но которые никогда не меняются. Полностью гипотетический пример:

class Database {

   protected static $types = array(
       'int'    => array('internalType' => 'Integer', 'precision' => 0,      ...),
       'string' => array('internalType' => 'String',  'encoding'  => 'utf-8', ...),
       ...
   )

}

Представим, что этот класс должен связывать типы данных из БД с внутренними типами. Для этого нужна карта типов. Эта карта всегда одинакова для всех экземпляров Database и используется в нескольких методах Database. Почему бы не сделать карту статическим свойством? Данные никогда не изменяются, а только считываются. И это позволит сэкономить немного памяти, т.к. данные общие для всех экземпляров Database. Т.к. доступ к данным происходит только внутри класса, это не создаст никаких внешних зависимостей. Статические свойства никогда не должны быть доступны снаружи, т.к. это просто глобальные переменные. И мы уже видели к чему это приводит…

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

Проблема статических методов в том, что они создают жесткую зависимость. Когда Вы вызываете Foo::bar(), эта строка кода становится связана с конкретным классом Foo. Это может привести в проблемам.

Использование статических методов допустимо при следующих обстоятельствах:

  1. Зависимость гарантированно существует. В случае если вызов внутренний или зависимость является частью окружения. Например:
    class Database {
    
       ...
    
       public function __construct($host, $user, $password) {
           $this->connection = new PDO(...);
       }
    
       ...
    
    }
    

    Здесь Database зависит от конкретного класса — PDO. Но PDO — это часть платформы, это класс для работы с БД, предоставляемый PHP. В любом случае, для работы с БД придется использовать какое-то API.

  2. Метод для внутреннего использования. Пример из реализации фильтра Блума:
    class BloomFilter {
    
       ...
    
       public function __construct($m, $k) {
           ...
       }
    
       public static function getK($m, $n) {
           return ceil(($m / $n) * log(2));
       }
    
       ...
    
    }
    

    Эта маленькая вспомогательная функция просто предоставляет обертку для конкретного алгоритма, который помогает рассчитать хорошее число для аргумета $k, используемого в конструкторе. Т.к. она должна быть вызвана до создания экземпляра класса, она должна быть статичной. Этот алгоритм не имеет внешних зависимостей и вряд ли будет заменен. Он используется так:

    $m = 10000;
    $n = 2000;
    $b = new BloomFilter($m, BloomFilter::getK($m, $n));
    

    Это не создает никаких дополнительных зависимостей. Класс зависит сам от себя.

  3. Альтернативный конструктор. Хорошим примером является класс DateTime, встроенный в PHP. Его экземпляр можно создать двумя разными способами:
    $date = new DateTime('2012-11-04');
    $date = DateTime::createFromFormat('d-m-Y', '04-11-2012');
    

    В обоих случая результатом будет экземпляр DateTime и в обоих случаях код привязан к классу DateTime так или иначе. Статический метод DateTime::createFromFormat — это альтернативный коструктор объекта, возвращающий тоже самое что и new DateTime, но используя дополнительную функциональность. Там, где можно написать new Class, можно написать и Class::method(). Никаких новых зависимостей при этом не возникает.

Остальные варианты использования статических методов влияют на связывание и могут образовывать неявные зависимости.

Слово о абстракции

Зачем вся эта возня с зависимостями? Возможность абстрагировать! С ростом Вашего продукта, растет его сложность. И абстракция — ключ к управлению сложностью.

Для примера, у Вас есть класс Application, который представляет Ваше приложение. Он общается с классом User, который является предствлением пользователя. Который получает данные от Database. Классу Database нужен DatabaseDriver. DatabaseDriver нужны параметры подключения. И так далее. Если просто вызвать Application::start() статически, который вызовет User::getData() статически, который вызовет БД статически и так далее, в надежде, что каждый слой разберется со своими зависимостями, можно получить ужасный бардак, если что-то пойдет не так. Невозможно угадать, будет ли работать вызов Application::start(), потому что совсем не очевидно, как себя поведут внутренние зависимости. Еще хуже то, что единственный способ влиять на поведение Application::start() — это изменять исходный код этого класса и код классов которые он вызызвает и код классов, которые вызызвают те классы… в доме который построил Джек.

Наиболее эффективный подход, при создании сложных приложений — это создание отдельных частей, на которые можно опираться в дальнейшем. Частей, о которых можно перестать думать, в которых можно быть уверенным. Например, при вызове статического Database::fetchAll(...), нет никаких гарантий, что соединение с БД уже установлено или будет установлено.

function (Database $database) {
   ...
}

Если код внутри этой функции будет выполнен — это значит, что экземпляр Database был успешно передан, что значит, что экземпляр объекта Database был успешно создан. Если класс Database спроектирован верно, то можно быть уверенным, что наличие экземпляра этого класса означает возможность выполнять запросы к БД. Если экземпляра класса не будет, то тело функции не будет выполнено. Это значит, что функция не должна заботиться о состоянии БД, класс Database это сделает сам. Такой подход позволяет забыть о зависимостях и сконцентрироваться на решении задач.

Без возможности не думать о зависимостях и зависимостях этих зависимостей, практически невозможно написать хоть сколь-нибудь сложное приложение. Database может быть маленьким классом-оберткой или гиганским многослойным монстром с кучей зависимостей, он может начаться как маленькая обертка и мутировать в гигансткого монстра со временем, Вы можете унаследовать класс Database и передать в функцию потомок, это все не важно для Вашей function (Database $database), до тех пор пока, публичный интерфейс Database не изменяется. Если Ваши классы правильно отделены от остальных частей приложения с помощью внедрения зависимостей, Вы можете тестировать каждый из них, используя заглушки вместо их зависимостей. Когда Вы протестировали класс достаточно, чтобы убедиться, что он работает как надо, Вы можете выкинуть лишнее из головы, просто зная, что для работы с БД нужно использовать экземпляр Database.

Классо-ориентированное программирование — глупость. Учитесь использовать ООП.

Автор: truezemez

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js