Глобальна держава та одинаки

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

  • 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 патологічними брехунами.

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

Не пишіть код, який використовує глобальний стан, краще передавайте залежності. Тобто, ін'єкція залежностей.

Крихкість глобальної держави

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

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