Взагалі я розробник фронтенда. Але деколи доводиться працювати і з серверної частиною. Команда у нас невелика, і коли все справжні бекенд-програмісти зайняті, буває швидше реалізувати якийсь метод самому. А іноді ми сідаємо разом попрацювати над завданнями, щоб не втрачати часу на перегін коммітов туди-сюди. Нещодавно під час одного з таких раундів парного програмування ми з товаришем по команді зіткнулися з багом, який мене так вразив, що я вирішив з вами поділитися.
Отже, коли після обіду я підійшов до свого колеги Романа parpalak. він якраз закінчив приводити в порядок юніт-тести, і запустив всю пачку. Один з тестів викинув виняток і впав. Ага, подумали ми, зараз виправимо баг. Запустили тест на самоті, поза пакетом, і він пройшов успішно.
Перш ніж скинути з себе післяобідню дрімоту, ми запустили Codeception ще кілька разів. У пакеті тест падав, поодинці проходив, в пакеті падав ...
Ми полізли в код.
Фаталка Call to private method вилітала з методу, що перетворює об'єкт суті в масив для відправки клієнтові. Нещодавно механізм цього процесу трохи змінився, але ще не всі класи отрефакторілі, тому в методі варто перевірка, перевизначений чи метод, який повертає список необхідних полів (це старий спосіб), в дочірньому класі. Якщо немає, список полів формується через рефлексію (це новий спосіб), і викликаються відповідні геттери. У нашому випадку один з геттеров був оголошений як private, і, відповідно, недоступний з базового класу. Все це виглядає приблизно так:
Трохи спрощений код, щоб сфокусуватися на суті
Як бачите, результат роботи рефлектора кешируєтся в статичної змінної $ isClientPropsOriginal всередині методу.
- А що, рефлексія така важка операція? - запитав я.
- Ну да, - кивнув Роман.
Брейкпоінт на сходинці з рефлексією взагалі не спрацьовував в цьому класі. Жодного разу. Статичної змінної вже було присвоєно значення true. інтерпретатор ліз в метод toClientModelNew і падав. Я запропонував подивитися, де ж тоді відбувається привласнення:
У змінній $ isClientPropsOriginal стояло "PaymentList". Це ще один клас, успадкований від AbstractEntity. примітний рівно двома речами: він не перекриває метод getClientProperties і він тестувався юніт-тестом, який вже успішно відпрацював трохи раніше.
- Як таке може бути? - запитав я. - Статична змінна всередині методу шарітся при спадкуванні? Чому тоді ми раніше цього не помітили?
Роман був спантеличений не менш мого. Поки я ходив за кавою, він накидав невеликий юніт-тест з імітацією нашої ієрархії класів, але він не падав. Ми щось не брали до уваги. Статична змінна поводилася неправильно, не так, як ми очікували, але не у всіх випадках, і ми не могли зрозуміти, чому. Гугленіе за запитом "php static variable inside class method" не давало нічого путнього, окрім того, що статичні змінні - це недобре. Well, duh!
Тепер Роман пішов за кавою, а я в задумі відкрив PHP-пісочницю і написав найпростіший код:
Якось так це і повинно працювати. Принцип найменшого подиву, всі справи. Але у нас адже статична змінна визначена всередині методу toClientModel. а він перевизначений в дочірньому класі. А що, якщо ми запишемо так:
"Як дивно," подумав я. Але якась логіка тут є. У другому випадку метод, що містить статичну змінну, викликається через parent. виходить, використовується її примірник з батьківського класу? А як же вийти з цього положення? Я почухав потилицю і трохи доповнив свій приклад:
Ось воно! Роман якраз повернувся, і я, задоволений собою, продемонстрував свої напрацювання. Йому знадобилося всього кілька натискань на клавіатуру в PHPStorm, щоб отрефакторіть ділянку зі статичної змінної в окремий метод:
Але не тут-то було! Наша помилка зберігалася. Придивившись, я помітив, що метод hasOriginalClientProps оголошений як private. в моєму прикладі був public. Швидка перевірка показала, що працюють protected і public. а private не працює.
Час не чекав, і ми перейшли до подальших завдань, але все ж така поведінка спантеличувало. Я вирішив розібратися, чому ж PHP поводиться саме таким чином. У документації не вдалося нарити нічого, крім неясних натяків. Нижче я спробую відновити картину того, що відбувається, грунтуючись на вдумливому читанні PHP Internals Book. PHP Wiki. вивченні початкових кодів та інформації про те, як реалізуються об'єкти в інших мовах програмування.
Функція всередині інтерпретатора PHP описується структурою op_array. яка, серед іншого, містить хеш-таблицю з статичними змінними цієї функції. При спадкуванні. якщо статичних змінних немає, функція переіспользуется в дочірньому класі, а якщо є - створюється дублікат, щоб у дочірнього класу в методі були свої статичні змінні.
Поки все добре, але якщо ми викликаємо батьківський метод через parent :: printCount (). то, природно, потрапляємо в метод батьківського класу, який працює зі своїми статичними змінними. Тому приклад 2 не працює, а приклад 1 - працює. А коли ми винесли статичну змінну в окремий метод, як в прикладі 3, нас виручає пізніше зв'язування: метод A :: printCount все одно викличе копію методу A :: doPrintCount з класу B (яка, звичайно, ідентична оригіналу A :: doPrintCount).
Така поведінка повторюється на всіх версіях PHP, які я спробував в пісочниці. починаючи з волохатою 5.0.4.
Чому ж баг в коді нашого проекту раніше ніяк не давав про себе знати? Мабуть, сутності рідко створювалися різнотипними групами, а якщо і створювалися - то рефактору їх одночасно. А ось при прогоні тестів в один запуск скрипта потрапили два об'єкти, що працюють через різні механізми, і один з них зіпсував іншому стан.
(Адже в кожної серйозної статті повинні бути висновки)
- Статичні змінні - зло.
Ну тобто як і будь-яке інше зло в програмуванні, вони вимагають обережного і вдумливого підходу. Звичайно, можна критикувати нас за використання прихованого стану, але при акуратному застосуванні це дозволяє писати досить ефективний код. Однак за static'амі можуть ховатися підводні камені, один з яких я вам продемонстрував. Тому
Ніхто не поручиться, що прихований косяк в вашому коді НЕ вилізе на світ після чергового рефакторінга. Так що пишіть тестований код і покривайте його тестами. Якби подібний до описаного мною баг виник в бойовому коді, а не в тестах, на його налагодження цілком міг би піти весь день, а не півтори-дві години.
- Не бійтеся влізти в нетрі.
Навіть така проста штука, як статичні змінні, може послужити приводом для того, щоб глибоко зануритися в системну документацію та вихідні PHP. І навіть щось в них зрозуміти.
P.S. Дякую Романа parpalak за цінні поради при підготовці матеріалу.