Глобално състояние и сингълтъни
Предупреждение: Следните конструкции са признак на лошо проектиран код:
Foo::getInstance()
DB::insert(...)
Article::setDb($db)
ClassName::$var
илиstatic::$var
Срещат ли се някои от тези конструкции във вашия код? Тогава имате възможност да го подобрите. Може би си мислите, че това са обичайни конструкции, които виждате дори в примерни решения на различни библиотеки и framework-ове. Ако е така, тогава дизайнът на техния код не е добър.
Сега определено не говорим за някаква академична чистота. Всички тези конструкции имат едно общо нещо: те използват глобално състояние. А то има разрушителен ефект върху качеството на кода. Класовете лъжат за своите зависимости. Кодът става непредсказуем. Обърква програмистите и намалява тяхната ефективност.
В тази глава ще обясним защо е така и как да избегнем глобалното състояние.
Глобална свързаност
В идеалния свят обектът трябва да може да комуникира само с обекти,
които са му били директно
предадени. Ако създам два обекта 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 адреса на тази уеб услуга, трябва да влезете и т.н. Нито една от тази информация не се съдържа в теста. Още по-лошо, дори не знаете къде се намира тази информация и следователно как да mock-нете външните зависимости, така че всяко стартиране да не води до повторно теглене на 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
, не е
нужно да питате никого как е свързан кодът. Знаете, че трябва да
създадете негова инстанция и когато се опитате да го направите, ще
откриете, че трябва да предоставите параметри за достъп. Без тях кодът
дори не би могъл да се изпълни.
И най-важното, сега можете да mock-нете платежния портал, така че няма да ви бъдат таксувани 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()
и статична променлива) и ще изпълнява само своите
функции. Така ще спре да нарушава принципа на единствената
отговорност.
Глобално състояние срещу тестове
При писане на тестове предполагаме, че всеки тест е изолирана единица и че в него не влиза никакво външно състояние. И никакво състояние не напуска тестовете. След приключване на теста цялото свързано с теста състояние трябва да бъде автоматично премахнато от garbage collector-а. Благодарение на това тестовете са изолирани. Затова можем да изпълняваме тестовете в произволен ред.
Ако обаче са налице глобални състояния/сингълтъни, всички тези приятни предположения се разпадат. Състоянието може да влиза и излиза от теста. Изведнъж редът на тестовете може да има значение.
За да можем изобщо да тестваме сингълтъни, разработчиците често
трябва да разхлабят техните свойства, например като позволят
инстанцията да бъде заменена с друга. Такива решения в най-добрия
случай са хак, който създава трудно поддържаем и разбираем код. Всеки
тест или метод tearDown()
, който повлияе на някакво глобално
състояние, трябва да върне тези промени обратно.
Глобалното състояние е най-голямото главоболие при unit тестването!
Как да поправим ситуацията? Лесно. Не пишете код, който използва сингълтъни, предпочитайте предаването на зависимости. Тоест 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 и функциите вече не зависят
от locale.
Това е хубав пример как глобалното състояние е измъчвало хиляди разработчици по целия свят. Решението беше да се замени с dependency injection.
Кога е възможно да се използва глобално състояние?
Съществуват определени специфични ситуации, в които е възможно да се използва глобално състояние. Например, при дебъгване на код, когато трябва да изведете стойността на променлива или да измерите продължителността на определена част от програмата. В такива случаи, които се отнасят до временни актове, които по-късно ще бъдат премахнати от кода, е възможно легитимно да се използва глобално достъпен dumper или хронометър. Тези инструменти всъщност не са част от дизайна на кода.
Друг пример са функциите за работа с регулярни изрази preg_*
,
които вътрешно съхраняват компилирани регулярни изрази в статичен кеш
в паметта. Така че, когато извиквате един и същ регулярен израз
многократно на различни места в кода, той се компилира само веднъж.
Кешът спестява производителност и в същото време е напълно невидим за
потребителя, затова такова използване може да се счита за легитимно.
Обобщение
Обсъдихме защо има смисъл:
- Да премахнете всички статични променливи от кода
- Да декларирате зависимости
- И да използвате dependency injection
Когато обмисляте дизайна на кода, имайте предвид, че всяко
static $foo
представлява проблем. За да бъде вашият код среда,
уважаваща DI, е необходимо напълно да изкорените глобалното състояние и
да го замените с dependency injection.
По време на този процес може да откриете, че е необходимо да разделите класа, защото той има повече от една отговорност. Не се страхувайте от това; стремете се към принципа на единствената отговорност.
Бих искал да благодаря на Miško Hevery, чиито статии, като Flaw: Brittle Global State & Singletons, са в основата на тази глава.