Глобальное состояние и синглтоны
Внимание: следующие конструкции – симптомы плохо спроектированного кода:
Foo::getInstance()DB::insert(...)Article::setDb($db)ClassName::$varилиstatic::$var
Встречаются ли какие-нибудь из этих конструкций в вашем коде? Если да, у вас есть возможность его улучшить. Вы можете подумать, что это обычные конструкции, которые встречаются в примерах решений из разных библиотек и фреймворков. Если так, то их код спроектирован ошибочно.
Речь здесь не о какой-то академической чистоте. У всех этих конструкций есть одна общая черта: они используют глобальное состояние. А глобальное состояние губительно сказывается на качестве кода. Классы начинают вводить в заблуждение относительно своих зависимостей. Код становится непредсказуемым. Он сбивает разработчиков с толку и снижает их эффективность.
В этой главе мы объясним, почему так происходит и как избежать глобального состояния.
Глобальные связи
В идеальном мире объект должен общаться только с объектами, которые
ему передали напрямую.
Если я создам два объекта A и B и никогда не передам ссылку
между ними, то ни A, ни B не смогут обратиться к состоянию
другого или изменить его. Это крайне желательное свойство кода. Это
похоже на батарейку и лампочку: лампочка не загорится, пока вы не
соедините её с батарейкой проводом.
Однако для глобальных (статических) переменных или синглтонов это не
так. Объект A может по воздуху обратиться к объекту C
и изменить его без всякой передачи ссылки, вызвав C::changeSomething().
Если объект B тоже подключается к глобальному C, то
A и B могут влиять друг на друга через C.
Использование глобальных переменных вводит новый вид
беспроводной связанности, невидимой снаружи. Оно создаёт дымовую
завесу, из-за которой код становится сложнее понимать и использовать.
Чтобы по-настоящему разобраться в зависимостях, разработчикам
приходится читать каждую строку исходного кода, а не полагаться на
интерфейсы классов. Кроме того, эта связанность совершенно не нужна.
Глобальное состояние используют потому, что оно легко доступно откуда
угодно и позволяет, например, писать в базу данных через глобальный
(статический) метод DB::insert(). Однако, как мы покажем, кажущееся
удобство ничтожно по сравнению с тяжёлыми осложнениями, которые оно
приносит.
С точки зрения поведения между глобальной и статической переменной нет разницы. Они одинаково вредны.
Жуткое действие на расстоянии
“Жуткое действие на расстоянии” – так Альберт Эйнштейн знаменито назвал явление квантовой физики, которое приводило его в содрогание. Речь о квантовой запутанности, когда измерение свойства одной частицы мгновенно влияет на другую запутанную частицу, независимо от разделяющего их расстояния, даже в миллионы световых лет, что как будто нарушает основополагающий закон Вселенной о том, что ничто не может двигаться быстрее света.
В мире программ “жуткое действие на расстоянии” описывает ситуацию, когда мы запускаем процесс, который считаем изолированным (ведь никаких зависимостей явно не передавали), а в отдалённых частях системы неожиданно происходят взаимодействия и изменения состояния, о которых мы не подозреваем. Такое возможно только через глобальное состояние.
Представьте, что вы приходите в команду разработки проекта с большой зрелой кодовой базой. Ваш новый руководитель просит реализовать новую возможность, и вы, как хороший разработчик, начинаете с написания теста. Но поскольку вы в проекте новичок, вы много экспериментируете в духе “что будет, если вызвать этот метод”. И вы пробуете написать такой тест:
function testCreditCardCharge()
{
$cc = new CreditCard('1234567890123456', 5, 2028); // номер вашей карты
$cc->charge(100);
}
Вы запускаете код, возможно несколько раз, и через некоторое время замечаете на телефоне уведомления банка: с вашей кредитной карты при каждом запуске списывали 100 долларов! 🤦♂️
Как вообще тест мог вызвать настоящее списание? Работа с кредитной картой – дело непростое. Нужно обратиться к стороннему веб-сервису, знать его URL, пройти аутентификацию и так далее. Ничего этого в тесте нет. Хуже того, вы не знаете, где эти сведения находятся, поэтому не можете подменить внешние зависимости заглушками, чтобы не терять 100 долларов при каждом запуске теста. И как вы, новый разработчик, должны были догадаться, что то, что вы собираетесь сделать, оставит вас на 100 долларов беднее?
Вот это и есть жуткое действие на расстоянии!
Вы вынуждены перелопачивать огромный исходный код и советоваться со
старшими коллегами, чтобы понять взаимосвязи в проекте. Трудность
возникает потому, что интерфейс класса CreditCard не выдаёт
необходимой инициализации глобального состояния. Даже изучение
исходного кода класса может не подсказать, какой метод инициализации
вызвать. В лучшем случае вы найдёте, к какой глобальной переменной он
обращается, и попробуете вывести, как её инициализировать.
Классы в таком проекте – патологические лжецы. Класс CreditCard
делает вид, что его можно просто создать и вызвать метод charge().
Однако втайне он взаимодействует с другим классом PaymentGateway,
представляющим платёжный шлюз. Даже интерфейс PaymentGateway может
намекать на самостоятельную инициализацию, а на деле он может
вытаскивать учётные данные из конфигурационного файла и так далее.
Исходные разработчики понимают, что CreditCard требует
PaymentGateway. Они так написали код. Но для новичков это полная
загадка, мешающая им учиться и вносить полезный вклад.
Как исправить положение? Легко. Пусть API объявляет зависимости.
function testCreditCardCharge()
{
$gateway = new PaymentGateway(/* ... */);
$cc = new CreditCard('1234567890123456', 5, 2028);
$cc->charge($gateway, 100);
}
Обратите внимание, как взаимозависимости в коде сразу становятся
очевидны. Поскольку метод charge() объявляет, что ему нужен
PaymentGateway, вам больше не нужно догадываться об этой зависимости
или спрашивать о ней. Вы знаете, что нужно создать экземпляр, и при этом
обнаружите нужные параметры доступа. Без них код просто не
запустится.
И, что важнее всего, теперь вы можете подменить платёжный шлюз заглушкой, чтобы с вас не списывали 100 долларов при каждом запуске теста.
Глобальное состояние позволяет объектам тайно обращаться к зависимостям, не объявленным в их API, фактически превращая ваши API в патологических лжецов.
Возможно, раньше вы об этом так не думали, но всякий раз, используя глобальное состояние, вы создаёте тайные беспроводные каналы связи. Это жуткое действие на расстоянии заставляет разработчиков читать каждую строку кода, чтобы понять возможные взаимодействия, снижает продуктивность и сбивает с толку новых членов команды. Если код создали вы, вы знаете настоящие зависимости, но тот, кто придёт после вас, останется в неведении.
Не пишите код, опирающийся на глобальное состояние; предпочитайте явную передачу зависимостей. Возьмите на вооружение внедрение зависимостей.
Хрупкость глобального состояния
В коде, использующем глобальное состояние и синглтоны, вы никогда не можете быть уверены, когда и кем состояние было изменено. Этот риск проявляется даже при инициализации. Следующий код собирается создать соединение с базой данных и инициализировать платёжный шлюз, но раз за разом выбрасывает исключения, а искать причину крайне утомительно:
PaymentGateway::init();
DB::init('mysql:', 'user', 'password');
Вам придётся дотошно проследить код, чтобы обнаружить, что объект
PaymentGateway по воздуху обращается к другим объектам, часть которых
требует соединения с базой данных. Поэтому базу нужно
инициализировать раньше PaymentGateway. Однако дымовая завеса
глобального состояния скрывает это от вас. Сколько времени было бы
сэкономлено, если бы API этих классов были честны и объявляли свои
зависимости?
$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, /* ... */);
Похожая проблема возникает при глобальном доступе к соединению с базой данных:
use Illuminate\Support\Facades\DB;
class Article
{
public function save(): void
{
DB::insert(/* ... */);
}
}
При вызове метода save() неясно, установлено ли соединение с
базой данных и кто отвечает за его установку. Если нам нужно
динамически поменять соединение с базой (например, для тестирования),
мы можем скатиться к добавлению методов вроде DB::reconnect(...) или
DB::reconnectForTest().
Рассмотрим пример:
$article = new Article;
// ...
DB::reconnectForTest();
Foo::doSomething();
$article->save();
Как нам убедиться, что при вызове $article->save() действительно
используется тестовая база данных? Что, если метод Foo::doSomething()
изменил глобальное соединение с базой? Чтобы это выяснить, нам
пришлось бы изучить исходный код Foo и, возможно, множества
других классов. И это расследование дало бы лишь временный ответ,
потому что позже положение может измениться.
А что, если мы перенесём соединение с базой данных в статическую
переменную внутри класса Article?
class Article
{
private static DB $db;
public static function setDb(DB $db): void
{
self::$db = $db;
}
public function save(): void
{
self::$db->insert(/* ... */);
}
}
Это ничего не меняет. Проблема в самом глобальном состоянии,
независимо от того, внутри какого класса оно спрятано. В этом случае,
как и в предыдущем, при вызове $article->save() у нас нет уверенности,
в какую базу данных запишутся данные. Кто угодно и где угодно в
приложении мог в любой момент поменять базу через Article::setDb(). Без
нашего ведома.
Глобальное состояние делает наше приложение чрезвычайно хрупким.
Однако с этой проблемой можно справиться просто. Достаточно, чтобы API объявлял зависимости, нужные для правильной работы.
class Article
{
public function __construct(
private DB $db,
) {
}
public function save(): void
{
$this->db->insert(/* ... */);
}
}
$article = new Article($db);
// ...
Foo::doSomething();
$article->save();
Такой подход снимает опасения по поводу скрытых или неожиданных изменений соединения с базой данных. Теперь мы точно знаем, куда сохраняется статья, и изменения в несвязанных классах на это уже не повлияют. Код больше не хрупкий, а устойчивый.
Не пишите код, опирающийся на глобальное состояние; предпочитайте явную передачу зависимостей. Возьмите на вооружение внедрение зависимостей.
Синглтон
Синглтон – шаблон проектирования, который, по определению из знаменитой книги “банды четырёх”, ограничивает класс одним экземпляром и предоставляет к нему глобальный доступ. Реализация этого шаблона обычно похожа на такой код:
class Singleton
{
private static self $instance;
public static function getInstance(): self
{
self::$instance ??= new self;
return self::$instance;
}
// и другие методы, выполняющие задачи класса
}
К сожалению, синглтон вносит в приложение глобальное состояние. А как мы показали выше, глобальное состояние нежелательно. Поэтому синглтон считается антипаттерном.
Не используйте синглтоны в своём коде и заменяйте их другими
механизмами. Синглтоны вам действительно не нужны. Однако если вам
нужно обеспечить, чтобы во всём приложении существовал только один
экземпляр класса, поручите эту обязанность DI-контейнеру. Так возникнет
синглтон в пределах приложения, который обычно называют сервисом. Сам
класс тогда освобождается от заботы о собственной уникальности (то
есть у него не будет метода getInstance() или статического свойства с
экземпляром) и может сосредоточиться исключительно на своих
обязанностях. Тем самым он перестанет нарушать принцип единственной
ответственности.
Глобальное состояние и тесты
Когда мы пишем тесты, мы в идеале исходим из того, что каждый тест – изолированная единица, в которую не входит и из которой не выходит никакое внешнее состояние. После завершения теста всё связанное с ним состояние должно автоматически убираться сборщиком мусора. Это делает тесты изолированными. Поэтому мы можем запускать тесты в любом порядке.
Однако при наличии глобального состояния или синглтонов эти полезные предположения рушатся. Состояние может утекать в тесты и из них. Внезапно порядок тестов начинает иметь значение.
Чтобы вообще протестировать код с синглтонами, разработчикам часто
приходится поступаться их цельностью, например разрешая подменять
экземпляр синглтона. Такие решения в лучшем случае остаются костылями,
приводящими к коду, который трудно поддерживать и понимать. Любой тест
(или его метод tearDown()), меняющий глобальное состояние, обязан
дотошно откатывать эти изменения.
Глобальное состояние – главная головная боль модульного тестирования!
Как это исправить? Просто. Не пишите код, использующий синглтоны; предпочитайте явную передачу зависимостей. Возьмите на вооружение внедрение зависимостей.
Глобальные константы
Глобальное состояние не ограничивается синглтонами и статическими переменными, оно может относиться и к глобальным константам.
Константы, значения которых выражают всеобщие истины (M_PI) или
несут самодостаточные сведения (PREG_BACKTRACK_LIMIT_ERROR), в целом
допустимы. И наоборот, константы, используемые как способ по
воздуху подсунуть сведения в код, фактически являются скрытыми
зависимостями. Как LOG_FILE в следующем примере. Использование
константы FILE_APPEND совершенно корректно.
const LOG_FILE = '...';
class Foo
{
public function doSomething()
{
// ...
file_put_contents(LOG_FILE, $message . "\n", FILE_APPEND);
// ...
}
}
Вместо этого нам следует объявить путь к файлу лога параметром
конструктора класса Foo, сделав его явной частью его API:
class Foo
{
public function __construct(
private string $logFile,
) {
}
public function doSomething()
{
// ...
file_put_contents($this->logFile, $message . "\n", FILE_APPEND);
// ...
}
}
Теперь мы передаём путь к файлу лога явно. Мы легко можем изменить его при необходимости, что упрощает тестирование и поддержку кода.
Глобальные функции и статические методы
Мы хотим подчеркнуть, что использование статических методов и
глобальных функций само по себе не проблема. Мы объяснили сложности с
методами вроде DB::insert(), но корнем проблемы всегда было лежащее в
основе глобальное состояние, обычно хранящееся в статической
переменной. Метод DB::insert() опирается на статическую переменную,
хранящую соединение с базой данных. Без этой переменной реализовать
метод было бы невозможно.
Использование детерминированных статических методов и функций
вроде Closure::fromCallable(), strlen() и множества других полностью
совместимо с внедрением зависимостей. Эти функции предсказуемы,
потому что при одних и тех же входных параметрах всегда возвращают
один и тот же результат. Они не используют никакого глобального
состояния.
Однако в PHP есть и недетерминированные функции. К ним относится,
например, функция htmlspecialchars(). Её третий параметр $encoding,
если его опустить, по умолчанию берёт значение параметра конфигурации
default_charset (ini_get('default_charset')). Поэтому рекомендуется всегда
указывать этот параметр, чтобы избежать возможного непредсказуемого
поведения. Nette последовательно так и делает.
Некоторые функции, такие как strtolower() и strtoupper(), ещё
недавно вели себя недетерминированно в зависимости от настройки
локали (setlocale()). Это вызывало множество осложнений, чаще всего
при работе с турецким языком. Дело в том, что в турецком различаются
“I” с точкой и без точки как в нижнем, так и в верхнем регистре. В
результате strtolower('I') возвращала ı (строчную i без точки), а
strtoupper('i') – İ (заглавную I с точкой), что приводило к
множеству загадочных ошибок в приложениях. Однако в PHP версии 8.2 эта
проблема исправлена, и функции больше не зависят от локали.
Это хороший пример того, как глобальное состояние (настройка локали) досаждало тысячам разработчиков по всему миру. Решением в итоге стало сделать функции независимыми от локали, фактически убрав скрытую зависимость.
Когда глобальное состояние всё же можно использовать?
Есть отдельные, ограниченные ситуации, когда использование глобального состояния может быть допустимо. Например, при отладке, когда нужно вывести значение переменной или измерить время выполнения определённого участка кода. В этих случаях, связанных с временными действиями, которые позже уберут из кода, использование глобально доступного дампера или таймера может быть законным. Эти инструменты не входят в основную архитектуру приложения.
Другой пример – функции регулярных выражений PHP (preg_*), которые
внутренне кешируют скомпилированные регулярные выражения в
статической памяти. Когда вы многократно вызываете функции с одним и
тем же регулярным выражением в разных местах кода, выражение
компилируется только один раз. Такое кеширование повышает
производительность и совершенно невидимо для пользователя, поэтому
такое использование внутреннего статического состояния в целом
допустимо.
Итоги
Мы обсудили, почему имеет смысл:
- убрать из кода все изменяемые статические свойства (глобальное состояние)
- явно объявлять зависимости
- и использовать внедрение зависимостей
Проектируя код, помните, что каждое изменяемое static $foo –
потенциальный источник проблем. Чтобы создать среду, дружественную к
DI, принципиально важно полностью убрать глобальное состояние и
заменить его внедрением зависимостей.
В ходе этого вы можете обнаружить, что классы с несколькими обязанностями нужно разделить. Не стесняйтесь это делать, стремитесь к принципу единственной ответственности.
Хочу поблагодарить Miško Hevery, чьи статьи, такие как Flaw: Brittle Global State & Singletons, легли в основу этой главы.