Давно хотел написать на эту тему. Первым толчком послужила статья Miško Hevery "Static Methods are Death to Testability". Я написал ответную статью, но так и не опубликовал ее. А вот недавно увидел нечто, что можно назвать «Классо-Ориентированное Программирование». Это освежило мой интерес к теме и вот результат.
«Классо-Ориентированое Программирование» — это когда используются классы, состоящие только из статических методов и свойств, а экземпляр класса никогда не создается. В этой статье я буду говорить о том, что:
Хотя эта статья про PHP, концепции применимы и к другим языкам.
Обычно, код зависит от другого кода. Например:
Этот код зависит от переменной
Теперь, такой пример:
normalizer_normalize — это функция пакета Intl, который интегрирован в PHP начиная с версии 5.3 и может быть установлен отдельно для более старых версий. Здесь уже немного сложнее — работоспособность кода зависит от наличия конкретного пакета.
Теперь, такой вариант:
Это типичный пример классо-ориентированного программирования.
При вызове
Неправда ли, похоже на процедурный подход? Давайте попробуем переписать этот пример в процедурном стиле:
Найдите 10 отличий…
Подсказка: единственное отличие — это видимость
В классо-ориентированном примере, соединение доступно только для самого класса
Классо-ориентированное программирование — это как покупка машины, для того чтобы сидеть в ней, периодически открывать и закрывать двери, прыгать на сидениях, случайно заставляя срабатывать подушки безопасности, но никогда не поворачивать ключ зажигания и не сдвинуться с места. Это полное непонимание сути.
Теперь, давайте попробуем ООП. Начнем с реализации
Теперь
Обратите внимание, насколько проще стала реализация. В
Без экземпляра
Вы попросту не сможете использовать код, если хоть одно условие не удовлетворено.
Сравним это с классо-ориентированным кодом: вызвать
Посмотрим на это со стороны кода, который использует
Написать эту строчку можно в любом месте и она выполнится. Ее поведение зависит от глобального состояния подключения к БД. Хотя из кода это не очевидно. Добавим обработку ошибок:
Из-за неявных зависимостей
Для сравнения, вот классо-ориентированная реализация:
Никакой разницы. Обработка ошибок — идентична, т.е. все также сложно найти источник проблем. Это все потому, что вызов статического метода — это просто вызов функции, который ничем не отличается от любого другого вызова функции.
Теперь ООП:
PHP упадет с фатальной ошибкой, когда дойдет до
PHP опять упадет, т.к. мы не передали параметры подключения к БД, которые мы указали в
Теперь мы удовлетворили все зависимости, которые обещали, все готово к запуску.
Но давайте представим, что параметры подключения к БД неверные или у нас какие-то проблемы с БД и соединение не может быть установлено. В этом случае будет брошено исключение при выполнении
Объектно-ориентированный подход может показаться более сложным. В нашем примере процедурного или классо-ориентированного кода всего лишь одна строчка, которая вызывает
Объектно-ориентированный подход делает все зависимости явными и очевидными. Для
В процедурном подходе ответственность ложится на функции. Вызываем метод
Объектно-ориентированный подход перекладывает часть ответственности на вызывающий код и в этом его сила. Хотите вызвать
В процедурном коде, Вы создаете много жестких зависимостей и спутываете стальной проволокой разные участки кода. Все зависит от всего. Вы создаете монолитный кусок софта. Я не хочу сказать, что оно не будет работать. Я хочу сказать, что это очень жесткая конструкция, которую очень сложно разобрать. Для маленьких приложений это может работать хорошо. Для больших это превращается в ужас хитросплетений, который невозможно тестировать, расширять и отлаживать:
В объектно-орентированном коде с внедрением зависимостей, Вы создаете много маленьких блоков, каждый из которых самостоятелен. У каждого блока есть четко определенный интерфейс, который могут использовать другие блоки. Каждый блок знает, что ему нужно от других чтобы все работало. В процедурном и классо-ориентированном коде Вы связываете
Классо-ориентированный подход выглядит обманчиво просто, но намертво приколачивает код гвоздями зависимостей. Объектно-ориентированный подход оставляет все гибким и изолированым до момента использования, что может выглядеть более сложным, но это более управляемо.
Зачем же нужны статические свойства и методы? Они полезны для статических данных. Например, данные от которых зависит экземпляр, но которые никогда не меняются. Полностью гипотетический пример:
Представим, что этот класс должен связывать типы данных из БД с внутренними типами. Для этого нужна карта типов. Эта карта всегда одинакова для всех экземпляров
Статические свойства также могут быть полезны, чтобы закешировать некоторые данные, которые идентичны для всех экземпляров. Статические свойства существуют, по большей части, как техника оптимизации, они не должны рассматриваться как философия программирования. А статические методы полезны в качестве вспомогательных методов и альтернативных конструкторов.
Проблема статических методов в том, что они создают жесткую зависимость. Когда Вы вызываете
Использование статических методов допустимо при следующих обстоятельствах:
Остальные варианты использования статических методов влияют на связывание и могут образовывать неявные зависимости.
Зачем вся эта возня с зависимостями? Возможность абстрагировать! С ростом Вашего продукта, растет его сложность. И абстракция — ключ к управлению сложностью.
Для примера, у Вас есть класс
Наиболее эффективный подход, при создании сложных приложений — это создание отдельных частей, на которые можно опираться в дальнейшем. Частей, о которых можно перестать думать, в которых можно быть уверенным. Например, при вызове статического
Если код внутри этой функции будет выполнен — это значит, что экземпляр
Без возможности не думать о зависимостях и зависимостях этих зависимостей, практически невозможно написать хоть сколь-нибудь сложное приложение.
Классо-ориентированное программирование — глупость. Учитесь использовать ООП.
«Классо-Ориентированое Программирование» — это когда используются классы, состоящие только из статических методов и свойств, а экземпляр класса никогда не создается. В этой статье я буду говорить о том, что:
- это не дает никаких преимуществ по сравнению с процедурным программированием
- не стоит отказываться от объектов
- наличие статических членов класса != смерть тестам
Хотя эта статья про 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
. Это может привести к проблемам.Использование статических методов допустимо при следующих обстоятельствах:
- Зависимость гарантированно существует. В случае если вызов внутренний или зависимость является частью окружения. Например:
class Database { ... public function __construct($host, $user, $password) { $this->connection = new PDO(...); } ... }
ЗдесьDatabase
зависит от конкретного класса —PDO
. НоPDO
— это часть платформы, это класс для работы с БД, предоставляемый PHP. В любом случае, для работы с БД придется использовать какое-то API.
- Метод для внутреннего использования. Пример из реализации фильтра Блума:
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));
Это не создает никаких дополнительных зависимостей. Класс зависит сам от себя.
- Альтернативный конструктор. Хорошим примером является класс
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
.Классо-ориентированное программирование — глупость. Учитесь использовать ООП.