Глобално състояние и единични числа

Предупреждение: Следните конструкции са симптоми на лошо проектиран код:

  • 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(). Въпреки това, както ще видим, ползата от него е минимална, а усложненията, които въвежда, са сериозни.

По отношение на поведението няма разлика между глобална и статична променлива. Те са еднакво вредни.

Призрачно действие на разстояние (Spooky Action at a Distance)

“Призрачно действие на разстояние” – така Алберт Айнщайн нарича едно явление в квантовата физика, което през 1935 г. му докарало ужас. Става дума за квантовото заплитане, чиято особеност е, че когато измервате информация за една частица, веднага въздействате върху друга частица, дори ако те са на милиони светлинни години една от друга. което привидно нарушава фундаменталния закон на Вселената, според който нищо не може да се движи по-бързо от светлината.

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

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

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

Изпълнявате кода, може би няколко пъти, и след известно време забелязвате на телефона си известия от банката, че всеки път, когато го изпълнявате, от кредитната ви карта са били изтеглени 100 долара 🤦‍♂️

Как, за Бога, тестът би могъл да предизвика действително таксуване? Не е лесно да се оперира с кредитна карта. Трябва да взаимодействате с уеб услуга на трета страна, трябва да знаете URL адреса на тази уеб услуга, трябва да влезете в системата и т.н. Нито една от тези информации не е включена в теста. Още по-лошо, дори не знаете къде присъства тази информация и следователно как да издекламирате външните зависимости, така че всяко изпълнение да не води до повторно таксуване на 100 USD. А като нов разработчик откъде трябваше да знаете, че това, което ще направите, ще доведе до обедняването ви със 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();

Този подход елиминира притеснението от скрити и неочаквани промени във връзките с бази данни. Сега сме сигурни къде се съхранява статията и никакви модификации на кода вътре в друг несвързан клас вече не могат да променят ситуацията. Кодът вече не е крехък, а стабилен.

Не пишете код, който използва глобално състояние, а предпочитайте да предавате зависимости. По този начин се въвежда инжектиране на зависимости.

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() метод и статична променлива) и ще изпълнява само своите функции. По този начин той ще спре да нарушава принципа на единичната отговорност.

Глобално състояние срещу тестове

Когато пишем тестове, приемаме, че всеки тест е изолирана единица и че в него не влиза никакво външно състояние. И никое състояние не напуска тестовете. Когато тестът завърши, всяко състояние, свързано с него, трябва да бъде премахнато автоматично от garbage collector. Това прави тестовете изолирани. Следователно можем да изпълняваме тестовете в произволен ред.

Ако обаче са налице глобални състояния/синглетони, всички тези хубави предположения се развалят. Едно състояние може да влиза и излиза от тест. Изведнъж редът на тестовете може да има значение.

За да могат изобщо да тестват единични състояния, разработчиците често трябва да смекчат техните свойства, може би като позволят даден екземпляр да бъде заменен с друг. Такива решения в най-добрия случай са хакове, които създават код, който е труден за поддържане и разбиране. Всеки тест или метод tearDown(), който засяга някое глобално състояние, трябва да отмени тези промени.

Глобалното състояние е най-голямото главоболие при тестването на единици!

Как да поправим ситуацията? Лесно. Не пишете код, който използва singletons, а предпочитайте да предавате зависимости. Това е инжектиране на зависимости.

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

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

Константи, чиято стойност не ни предоставя никаква нова (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') – символа İ, което водеше до това, че приложенията предизвикваха редица мистериозни грешки. Този проблем обаче беше отстранен във версия 8.2 на PHP и функциите вече не зависят от локалите.

Това е хубав пример за това как глобалното състояние е измъчвало хиляди разработчици по целия свят. Решението беше да го заменим с инжектиране на зависимости.

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

Има някои специфични ситуации, в които е възможно да се използва глобално състояние. Например, когато отстранявате грешки в кода и трябва да изхвърлите стойността на променлива или да измерите продължителността на определена част от програмата. В такива случаи, които се отнасят до временни действия, които по-късно ще бъдат премахнати от кода, е закономерно да се използва глобално достъпен дъмпер или хронометър. Тези инструменти не са част от дизайна на кода.

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

Обобщение

Показахме защо има смисъл

  1. Премахнете всички статични променливи от кода
  2. Декларирайте зависимостите
  3. И използвайте инжектиране на зависимости

Когато обмисляте дизайна на кода, имайте предвид, че всеки static $foo представлява проблем. За да може кодът ви да бъде среда, уважаваща DI, е необходимо напълно да изкорените глобалното състояние и да го замените с инжектиране на зависимости.

По време на този процес може да откриете, че трябва да разделите даден клас, защото той има повече от една отговорност. Не се притеснявайте за това; стремете се към принципа на една отговорност.

Искам да благодаря на Мишко Хевери, чиито статии като Flaw: Brittle Global State & Singletons са в основата на тази глава.

версия: 3.x