Глобальное состояние и синглтоны

Предупреждение: Следующие конструкции являются признаком плохо спроектированного кода:

  • 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(). Но, как мы покажем, преимущество, которое это дает, незначительно, в то время как осложнения вызывает фатальные.

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

Жуткое действие на расстоянии

«Жуткое действие на расстоянии» — так знаменито назвал в 1935 году Альберт Эйнштейн явление в квантовой физике, которое вызывало у него мурашки по коже. Речь идет о квантовой запутанности, особенностью которой является то, что когда вы измеряете информацию об одной частице, вы немедленно влияете на другую частицу, даже если они находятся на расстоянии миллионов световых лет друг от друга. Что, казалось бы, нарушает основной закон Вселенной, что ничто не может распространяться быстрее света.

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

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

function testCreditCardCharge()
{
	$cc = new CreditCard('1234567890123456', 5, 2028); // номер вашей карты
	$cc->charge(100);
}

Вы запускаете код, возможно, несколько раз, и через некоторое время замечаете на мобильном телефоне уведомления от банка, что при каждом запуске с вашей платежной карты списывалось 100 долларов 🤦‍♂️

Как, черт возьми, тест мог вызвать реальное списание денег? Оперировать платежной картой непросто. Нужно общаться со сторонним веб-сервисом, нужно знать URL этого веб-сервиса, нужно войти в систему и так далее. Никакой из этой информации в тесте нет. Хуже того, вы даже не знаете, где эта информация находится, и, следовательно, как замокать внешние зависимости, чтобы каждый запуск не приводил к тому, что снова спишется 100 долларов. И как вы, как новый разработчик, должны были знать, что то, что вы собираетесь сделать, приведет к тому, что вы станете на 100 долларов беднее?

Это жуткое действие на расстоянии!

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

Классы в таком проекте — патологические лжецы. Платежная карта делает вид, что ее достаточно инстанцировать и вызвать метод charge(). Втайне же она сотрудничает с другим классом PaymentGateway, который представляет платежный шлюз. И его интерфейс говорит, что его можно инициализировать самостоятельно, но на самом деле он извлекает учетные данные из какого-то конфигурационного файла и так далее. Разработчикам, написавшим этот код, ясно, что CreditCard нуждается в PaymentGateway. Они написали код таким образом. Но для любого, кто новичок в проекте, это полная загадка и мешает обучению.

Как исправить ситуацию? Легко. Пусть API декларирует зависимости.

function testCreditCardCharge()
{
	$gateway = new PaymentGateway(/* ... */);
	$cc = new CreditCard('1234567890123456', 5, 2028);
	$cc->charge($gateway, 100);
}

Обратите внимание, как сразу становятся очевидными связи внутри кода. Тем, что метод charge() декларирует, что ему нужен PaymentGateway, вам не нужно никого спрашивать о том, как связан код. Вы знаете, что нужно создать его экземпляр, и когда вы попытаетесь это сделать, вы столкнетесь с тем, что нужно предоставить параметры доступа. Без них код даже не запустится.

И главное, теперь вы можете замокать платежный шлюз, так что при каждом запуске теста с вас не будет списываться 100 долларов.

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

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

Не пишите код, использующий глобальное состояние, отдавайте предпочтение передаче зависимостей. То есть dependency injection.

Хрупкость глобального состояния

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

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();

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

Не пишите код, использующий глобальное состояние, отдавайте предпочтение передаче зависимостей. То есть dependency injection.

Singleton

Singleton — это паттерн проектирования, который согласно определению из известной публикации Gang of Four ограничивает класс единственным экземпляром и предлагает к нему глобальный доступ. Реализация этого паттерна обычно напоминает следующий код:

class Singleton
{
	private static self $instance;

	public static function getInstance(): self
	{
		self::$instance ??= new self;
		return self::$instance;
	}

	// и другие методы, выполняющие функции данного класса
}

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

Не используйте в своем коде синглтоны и замените их другими механизмами. Синглтоны вам действительно не нужны. Однако, если вам нужно гарантировать существование единственного экземпляра класса для всего приложения, оставьте это DI-контейнеру. Создайте таким образом прикладной синглтон, то есть сервис. Тем самым класс перестанет заниматься обеспечением своей собственной уникальности (т.е. не будет иметь метода getInstance() и статической переменной) и будет выполнять только свои функции. Так он перестанет нарушать принцип единственной ответственности.

Глобальное состояние и тесты

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

Однако, если присутствуют глобальные состояния/синглтоны, все эти приятные предположения рушатся. Состояние может входить в тест и выходить из него. Внезапно порядок тестов может иметь значение.

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

Глобальное состояние — самая большая головная боль при юнит-тестировании!

Как исправить ситуацию? Легко. Не пишите код, использующий синглтоны, отдавайте предпочтение передаче зависимостей. То есть dependency injection.

Глобальные константы

Глобальное состояние не ограничивается только использованием синглтонов и статических переменных, но может касаться и глобальных констант.

Константы, значение которых не несет нам никакой новой (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() требует существования статической переменной, потому что в ней хранится подключение к базе данных. Без этой переменной было бы невозможно реализовать метод.

Использование детерминированных статических методов и функций, таких как DateTime::createFromFormat(), Closure::fromCallable, strlen() и многих других, полностью соответствует dependency injection. Эти функции всегда возвращают одинаковые результаты для одинаковых входных параметров и, следовательно, предсказуемы. Они не используют никакого глобального состояния.

Однако существуют и функции в PHP, которые не являются детерминированными. К ним относится, например, функция htmlspecialchars(). Ее третий параметр $encoding, если не указан, по умолчанию имеет значение конфигурационной опции ini_get('default_charset'). Поэтому рекомендуется всегда указывать этот параметр и предотвращать тем самым возможное непредсказуемое поведение функции. Nette это последовательно делает.

Некоторые функции, такие как strtolower(), strtoupper() и подобные, в недавнем прошлом вели себя недетерминированно и зависели от настройки setlocale(). Это вызывало много осложнений, чаще всего при работе с турецким языком. Он различает как строчную, так и прописную букву I с точкой и без точки. Так что strtolower('I') возвращало символ ı, а strtoupper('i') — символ İ, что приводило к тому, что приложения начинали вызывать ряд загадочных ошибок. Однако эта проблема была устранена в PHP версии 8.2, и функции больше не зависят от локали.

Это хороший пример того, как глобальное состояние доставило хлопот тысячам разработчиков по всему миру. Решением было заменить его на dependency injection.

Когда можно использовать глобальное состояние?

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

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

Резюме

Мы рассмотрели, почему имеет смысл:

  1. Удалить все статические переменные из кода
  2. Декларировать зависимости
  3. И использовать dependency injection

Когда вы продумываете дизайн кода, помните, что каждое static $foo представляет собой проблему. Чтобы ваш код был средой, уважающей DI, необходимо полностью искоренить глобальное состояние и заменить его с помощью dependency injection.

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

Я хотел бы поблагодарить Мишко Хеверого, чьи статьи, такие как Flaw: Brittle Global State & Singletons, легли в основу этой главы.

версия: 3.x