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

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

  • 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 году назвал явление в квантовой физике, от которого у него мурашки по коже. Это квантовая запутанность, особенность которой заключается в том, что когда вы измеряете информацию об одной частице, вы немедленно воздействуете на другую частицу, даже если они находятся на расстоянии миллионов световых лет друг от друга. Это, казалось бы, нарушает фундаментальный закон Вселенной, согласно которому ничто не может двигаться быстрее света.

В мире программного обеспечения мы можем назвать “spooky action at a distance” ситуацию, когда мы запускаем процесс, который, как нам кажется, изолирован (потому что мы не передавали ему никаких ссылок), но неожиданные взаимодействия и изменения состояния происходят в удаленных местах системы, о которых мы не сообщили объекту. Это может произойти только через глобальное состояние.

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

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 патологическими лжецами.

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

Не пишите код, который использует глобальное состояние, предпочитая передавать зависимости. Это и есть инъекция зависимостей.

Хрупкость глобального государства

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

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

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

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

Синглтон

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

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

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

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

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

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

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

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

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

Резюме

Мы показали, почему это имеет смысл

  1. Убрать из кода все статические переменные
  2. Объявить зависимости
  3. И использовать инъекцию зависимостей

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

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

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

версия: 3.x