Глобальний стан та singleton'и

Попередження: Наступні конструкції є ознакою погано спроектованого коду:

  • Foo::getInstance()
  • DB::insert(...)
  • Article::setDb($db)
  • ClassName::$var або static::$var

Чи зустрічаються деякі з цих конструкцій у вашому коді? Тоді у вас є можливість його покращити. Можливо, ви думаєте, що це звичайні конструкції, які ви бачите, наприклад, у демонстраційних рішеннях різних бібліотек та фреймворків. Якщо це так, то дизайн їхнього коду не є добрим.

Зараз ми точно не говоримо про якусь академічну чистоту. Всі ці конструкції мають одну спільну рису: вони використовують глобальний стан. А він має руйнівний вплив на якість коду. Класи брешуть про свої залежності. Код стає непередбачуваним. Плутає програмістів та знижує їхню ефективність.

У цьому розділі ми пояснимо, чому це так, і як уникнути глобального стану.

Глобальний зв'язок

В ідеальному світі об'єкт повинен мати можливість спілкуватися лише з об'єктами, які йому були безпосередньо передані. Якщо я створю два об'єкти A та B і ніколи не передам посилання між ними, то ні A, ні B не зможуть отримати доступ до іншого об'єкта або змінити його стан. Це дуже бажана властивість коду. Це схоже на те, якби у вас була батарейка та лампочка; лампочка не світитиме, доки ви не з'єднаєте її з батарейкою дротом.

Але це не стосується глобальних (статичних) змінних або singleton'ів. Об'єкт 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.

Крихкість глобального стану

У коді, який використовує глобальний стан та singleton'и, ніколи не можна бути впевненим, коли і хто цей стан змінив. Цей ризик з'являється вже при ініціалізації. Наступний код має створити підключення до бази даних та ініціалізувати платіжний шлюз, однак постійно викидає виняток, і пошук причини є надзвичайно тривалим:

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 — це патерн проектування, який, згідно з Singleton_pattern з відомої публікації Gang of Four, обмежує клас єдиним екземпляром і пропонує до нього глобальний доступ. Реалізація цього патерну зазвичай схожа на наступний код:

class Singleton
{
	private static self $instance;

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

	// та інші методи, що виконують функції даного класу
}

На жаль, singleton вводить у програму глобальний стан. А як ми показали вище, глобальний стан є небажаним. Тому singleton вважається антипатерном.

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

Глобальний стан проти тестів

При написанні тестів ми припускаємо, що кожен тест є ізольованою одиницею і що до нього не входить жоден зовнішній стан. І жоден стан тести не залишає. Після завершення тесту весь пов'язаний з тестом стан повинен бути автоматично видалений збирачем сміття. Завдяки цьому тести ізольовані. Тому ми можемо запускати тести в будь-якому порядку.

Однак, якщо присутні глобальні стани/singleton'и, всі ці приємні припущення руйнуються. Стан може входити в тест і виходити з нього. Раптом може мати значення порядок тестів.

Щоб взагалі мати можливість тестувати singleton'и, розробники часто змушені послаблювати їхні властивості, наприклад, дозволяючи замінити екземпляр іншим. Такі рішення в кращому випадку є хаком, який створює код, що важко підтримувати та розуміти. Кожен тест або метод tearDown(), який впливає на будь-який глобальний стан, повинен ці зміни скасувати.

Глобальний стан — це найбільший головний біль при юніт-тестуванні!

Як виправити ситуацію? Легко. Не пишіть код, який використовує singleton'и, надавайте перевагу передачі залежностей. Тобто dependency injection.

Глобальні константи

Глобальний стан не обмежується лише використанням singleton'ів та статичних змінних, але може стосуватися також глобальних констант.

Константи, значення яких не приносить нам жодної нової (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