<? phpukraine СПІВБЕСІДИ
Пошук по платформі

PHP для Middle: питання на співбесіду

6 питань рівня Middle з теми Core PHP з розгорнутими відповідями, порадами та перевіркою.

Тема
Рівень
6 питань
PHP
Core PHP·Middle ·strict_types ·declare ·TypeError

`declare(strict_types=1)` — це директива рівня одного файлу, яка вимикає приведення скалярів для викликів, зроблених із цього файлу: без неї PHP у coercive-режимі намагається сконвертувати аргумент до оголошеного типу (з PHP 8.0 нечислові рядки — це `TypeError`, а числові з «хвостом» — `int` плюс `E_WARNING`), зі strict приймається лише точний тип, з єдиним винятком `int` → `float`.

Чому `strlen(42)` в одному файлі працює, а в сусідньому кидає TypeError?
Я поставив `declare(strict_types=1)` у `bootstrap.php` — чому це не вплинуло на решту файлів?
Функція оголошена з `int $id`, а в базу пішло `0` замість `'abc'` — як так вийшло?
Чому `declare(strict_types=1)` не завадив передати `'10'` у функцію з `int` — файл-виклик чи файл-оголошення тут головний?

declare(strict_types=1) — це не налаштування рантайму й не ini-опція, а директива часу компіляції з областю дії рівно в один файл. Вона зʼявилась у PHP 7.0 разом зі скалярними тайп-хінтами (RFC Andrea Faulds) і перемикає перевірку оголошених типів між двома режимами: coercive (за замовчуванням) і strict. Записувати її треба першою інструкцією файлу — після <?php не може бути ані пробільного виводу, ані namespace, ані use; аргумент — тільки літерал 0 або 1, змінна там дасть помилку компіляції. Директива не успадковується: include строгого файлу з нестрогого нічого не змінює, автозавантажений клас живе за режимом свого файлу, і саме тому найпоширеніша помилка — поставити рядок в один bootstrap.php і вважати проєкт строгим.

Найважливіша механіка: режим бере той файл, у якому знаходиться виклик, а не той, де функція оголошена. PHP на етапі компіляції позначає опкод виклику прапорцем поточного файлу, і перевірка аргументів у момент виконання йде за цим прапорцем. Практичний наслідок — ваш строгий код, викликаючи бібліотеку без директиви або взагалі внутрішню функцію ядра, отримає строгу перевірку: strlen(42) зі strict-файлу кине TypeError, хоча strlen() до вашого файлу не має жодного стосунку. І навпаки: якщо строга бібліотека викликає ваш нестрогий callback, перевірка буде строгою, бо виклик стався в її файлі.

У coercive-режимі PHP намагається сконвертувати аргумент до оголошеного типу, і з PHP 8.0 ці правила стали значно чеснішими. Повністю числовий рядок ('5', ' 5 ', '1e3') конвертується тихо. Leading-numeric рядок ('5 apples') конвертується у 5, але з E_WARNING. Повністю нечисловий рядок ('apples') з PHP 8.0 — це вже TypeError, тоді як у 7.x він мовчки ставав 0 і саме через це в базу потрапляли записи з id = 0. Окремо PHP 8.1 задеприкейтив неявне floatint із втратою дробової частини: 8.5 у параметр int дає deprecation-повідомлення. У strict-режимі всього цього немає взагалі — приймається лише точний тип, з єдиним винятком: int можна передати в параметр float (widening), бо в PHP немає окремого літерала для «цілого числа з плаваючою комою» і вимагати 100.0 замість 100 було б знущанням. Зворотний напрямок, floatint, заборонений завжди.

Дуже поширене перебільшення на співбесіді — сказати, що strict_types «вимикає type juggling». Не вимикає. Директива діє лише там, де тип оголошено явно: параметри, тип повернення, типізовані властивості (PHP 7.4+) і типізовані константи класів (PHP 8.3+). Усе інше поводиться як завжди: '5' + 5 дає 10, if ($string) приводить до bool, == порівнює за своїми правилами (які, до речі, самі змінились у PHP 8.0 у RFC «Saner string to number comparisons»), ключ масиву '5' збігається з 5, а array_sum() підсумує змішаний масив без питань. Не чіпає директива й нетипізовані параметри — там просто нема чого перевіряти. Тому strict_types корисно розуміти вузько: це заборона неявного приведення скалярів у типізованих сигнатурах, не більше.

Практично це означає дві речі. Перша — вмикати директиву треба автоматично в кожному файлі: правило declare_strict_types у Pint/PHP-CS-Fixer, шаблон файлу в IDE, перевірка в CI; ручна дисципліна тут не працює, а частково строгий проєкт дає найгірший варіант — поведінка залежить від того, з якого файлу прийшов виклик. Друга — межа з зовнішнім світом. HTTP-параметри, значення з CLI, дані з БД у деяких драйверах, JSON без JSON_BIGINT-нюансів — усе це приходить рядками, і в строгому коді конвертація має бути явним, видимим кроком: filter_var(..., FILTER_VALIDATE_INT), FormRequest з правилом integer, $request->integer('page'). Строгі типи не роблять валідацію за вас — вони гарантують, що після точки конвертації неправильний тип уже не протече глибше в домен. І варто памʼятати про верхній шар: PHPStan рівня 6+ ловить ті самі невідповідності статично, до запуску, тоді як strict_types спрацьовує вже в рантаймі — ці два інструменти доповнюють один одного, а не замінюють.

<?php

declare(strict_types=1);          // ПЕРША інструкція файлу, інакше fatal error

final class Cart
{
    public int $itemCount = 0;    // типізована властивість теж під strict

    public function addItem(int $productId, float $price): float
    {
        $this->itemCount++;

        return $price * $productId;
    }
}

$cart = new Cart();

// int → float: ЄДИНЕ послаблення, дозволене і в strict-режимі (widening)
$cart->addItem(7, 100);           // 100 стає 100.0, помилки немає

try {
    // рядок у параметр int: у strict-режимі приведення заборонене
    $cart->addItem('7', 100.0);   // TypeError: Argument #1 must be of type int, string given
} catch (TypeError $e) {
    echo $e->getMessage();
}

try {
    // float → int заборонено ЗАВЖДИ, навіть без strict_types
    $cart->addItem(7.0, 100.0);   // TypeError: Argument #1 must be of type int, float given
} catch (TypeError $e) {
    echo $e->getMessage();
}

try {
    $cart->itemCount = '10';      // типізована властивість: теж TypeError
} catch (TypeError $e) {
    echo $e->getMessage();
}

// Директива не чіпає type juggling поза оголошеними типами:
var_dump('5' + 5);                // int(10) — арифметика працює як раніше
var_dump('abc' == 0);             // false у PHP 8+, true у PHP 7 — це вже інші правила

// Межа з HTTP: вхід завжди рядок, конвертація має бути явною
$page = filter_var($_GET['page'] ?? '1', FILTER_VALIDATE_INT) ?: 1;
$cart->addItem($page, 9.99);      // сюди приходить справжній int
Що `declare(strict_types=1)` діє на файл, у якому написаний, і не успадковується ані підключеними файлами, ані автозавантаженими класами — це не налаштування рантайму й не ini-опція.
Що вирішує файл **виклику**, а не файл оголошення функції: `strlen(42)` зі strict-файлу кинеться, хоча `strlen()` оголошена в ядрі.
Що директива стосується лише скалярних типів (`int`, `float`, `string`, `bool`) у параметрах, поверненнях і присвоєннях типізованим властивостям; `array`, обʼєкти, `iterable`, `callable` ніколи не приводяться і без неї.
Що єдине послаблення в strict-режимі — widening `int` → `float` (передати `5` у параметр `float` можна), а `float` → `int` заборонено завжди.
Що з PHP 8.0 coercive-режим став суворішим: `'abc'` в `int` — це `TypeError`, `'5 apples'` — `5` з `E_WARNING`, `'5'` — тихо `5`; до 8.0 `'abc'` мовчки ставало `0`.
Що `declare(strict_types=1)` має стояти першою інструкцією файлу (після `<?php`, до всього іншого, крім опційного блоку `declare` у фігурних дужках) — інакше фатальна помилка компіляції.
Ставити `declare(strict_types=1)` в один `index.php`/`bootstrap.php` і вважати, що весь проєкт тепер строгий: директива не поширюється на `include`, `require` й автозавантажені класи.
Казати, що strict_types вимикає «type juggling» узагалі: `'5' + 5`, `if ($str)`, `==` і `array_sum()` працюють точно так само — директива стосується лише перевірки оголошених типів.
Вважати, що директива діє там, де функція оголошена. Насправді режим бере з файлу, у якому стоїть виклик; бібліотека без strict, викликана зі strict-файлу, отримає строгу перевірку.
Думати, що в strict-режимі `int` не пройде в параметр `float` — widening дозволений і це навмисно, бо в PHP немає літерала для «цілого float».
Розраховувати на strict_types у даних із HTTP: `$_GET['page']` — завжди рядок, і в strict-режимі його треба явно кастити (`(int)`) або валідувати `filter_var`, а не сподіватись, що PHP «сам зрозуміє».
Плутати `declare(strict_types=1)` з `1` як «увімкнено для всіх» — допустимі лише літерали `0` і `1`, змінна чи константа там викличе помилку компіляції.
ПОРАДА

Формулювання, яке закриває питання: «strict_types — це директива компіляції на один файл, і вирішує вона файл виклику; вмикає не заборону type juggling, а заборону неявного приведення скалярів у типізованих сигнатурах — плюс один виняток, int → float». Далі додайте, що в проєкті це ставлять у кожен PHP-файл автоматично (Pint/PHP-CS-Fixer, правило `declare_strict_types`), а на межі з HTTP усе одно потрібен явний каст.

Сторінка питання →
PHP
Що таке property hooks у PHP 8.4+? ЧАСТО ПИТАЮТЬ
Core PHP·Middle ·PHP 8.4 ·property hooks ·інваріанти

Це можливість оголосити get і set логіку прямо на властивості, без окремих методів і без магії __get/__set.

Чим property hooks кращі за геттери й сеттери?
Що нового в PHP 8.4 для класів?
Чи можна оголосити hook в інтерфейсі?

Property hooks дозволяють оголосити логіку читання й запису прямо на властивості. Замість пари getName()/setName() клас має public string $name { get => ...; set => ...; }, а зовнішній код працює зі звичайним доступом до поля. Обійти сеттер неможливо: будь-яке присвоєння, зокрема в промоутнутому конструкторі, проходить через хук, тому інваріант виконується завжди.

На відміну від __get/__set хуки статично типізовані й привʼязані до конкретної властивості. IDE підказує тип, PHPStan перевіряє тіло хука, а рантайм не платить за виклик магічного методу на кожне звернення. Якщо хук get не читає саму властивість, вона стає віртуальною й не займає памʼяті, як обчислюване fullName.

Інтерфейси теж отримали цю можливість: public string $label { get; } у контракті замінює метод getLabel(). Клас може реалізувати вимогу і хуком, і звичайною публічною властивістю.

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

final class Product
{
    public function __construct(
        public string $name {
            set(string $value) {
                if (trim($value) === '') {
                    throw new InvalidArgumentException('Name cannot be empty.');
                }
                $this->name = trim($value);
            }
        },
        public int $priceCents { set => max(0, $value); },
    ) {}

    // Віртуальна властивість: памʼять не виділяється, значення обчислюється
    public string $label { get => $this->name.' — '.number_format($this->priceCents / 100, 2).' грн'; }
}

interface HasLabel
{
    public string $label { get; }   // інтерфейс вимагає властивість, а не метод getLabel()
}

$p = new Product('  Кава ', 1990);
$p->name = '';      // InvalidArgumentException: сеттер неможливо обійти
echo $p->label;     // Кава — 19.90 грн
Що hook оголошується на самій властивості, тому обійти set неможливо: інваріант виконується завжди, навіть при прямому присвоєнні.
Що на відміну від __get/__set це статично типізовано: IDE й PHPStan бачать тип, а аналізатор перевіряє тіло хука.
Що властивість із хуком get без backing value стає віртуальною й не займає памʼяті.
Що інтерфейси можуть вимагати властивість з get або set, і це замінює пари getX/setX у контрактах.
Тверезу оцінку: масова міграція геттерів на hooks заради стилю не окупається, переписують те, де вже болять інваріанти.
Плутати з readonly: readonly забороняє зміну після ініціалізації, hooks додають логіку на читання й запис.
Вважати hooks заміною __get і __set: магічні методи працюють для неоголошених властивостей, hooks лише для оголошених.
Не знати, що властивість з хуками не можна зробити readonly, а всередині set треба писати $this->prop = ..., щоб зберегти значення.
Змішувати з asymmetric visibility public private(set), яка теж зʼявилась у 8.4, але вирішує іншу задачу.
ПОРАДА

Згадайте, що масова міграція геттерів на hooks заради стилю не окупається — переписують те, де вже болять інваріанти. І назвіть другу фічу 8.4 поруч: asymmetric visibility.

Сторінка питання →
PHP
Core PHP·Middle ·Generator ·yield ·yield from

Генератор — це функція з `yield`, яка при виклику не виконує жодного рядка тіла, а повертає обʼєкт `Generator` (реалізує `Iterator`): значення обчислюються по одному на вимогу, тому пікова памʼять не залежить від обсягу даних; ціна — один-єдиний прохід без перемотки, без `count()` і без доступу за індексом.

Експорт падає з Allowed memory size exhausted на 2 млн рядків — як переписати, не піднімаючи memory_limit?
Чим `yield` відрізняється від `return` і що взагалі повертає функція, у тілі якої є `yield`?
Чому `count()` на результаті такої функції падає, а `foreach` працює?
Чому другий `foreach` по тому самому генератору кидає «Cannot rewind»?
Навіщо `yield from`, якщо можна написати вкладений `foreach` з `yield` усередині?

Генератори зʼявилися в PHP 5.5 і працюють не так, як здається з синтаксису. Наявність yield будь-де в тілі функції змінює саму природу виклику: PHP не виконує жодного рядка тіла, а створює й повертає обʼєкт класу Generator, який реалізує інтерфейс Iterator. Тіло стартує лише тоді, коли хтось уперше запитає значення — foreach, current(), send() або iterator_to_array(). Дійшовши до yield, функція заморожується: її локальні змінні, позиція виконання й навіть напівпройдений try живуть у власному стек-фреймі генератора. Наступна ітерація не починає функцію спочатку, а розморожує її рівно там, де зупинила. Саме тому в прикладі RuntimeException з fopen() вилітає не на рядку allLines(...), а вже всередині foreach — класична пастка, на якій ловлять на співбесіді.

Практичний сенс цієї ліні — памʼять. Функція, що повертає масив, зобовʼязана дорахувати всі елементи до return, тому пік споживання пропорційний обсягу даних: мільйон рядків по 200 байт — це не 200 МБ, а помітно більше, бо кожен елемент хеш-таблиці коштує ще десятки байтів службових даних. Генератор тримає одночасно один елемент, і memory_limit перестає бути функцією розміру файла чи вибірки. Друга, менш очевидна вигода — конвеєр: onlyErrors(allLines(...)) не створює жодного проміжного масиву, кожен рядок проходить крізь усі ланки й одразу звільняється. Третя — час до першого результату: споживач отримує перший рядок майже миттєво, а не після повного читання. Тому генератори — природний інструмент для парсингу великих файлів, ітерації по вибірці з БД (Model::cursor(), LazyCollection), потокової віддачі відповіді через StreamedResponse і для нескінченних послідовностей, які просто не існують як масив.

Протокол генератора ширший за foreach. Ключі — його повноцінна частина: без явного ключа PHP нумерує елементи 0, 1, 2…, а yield $key => $value дозволяє віддавати свої (номер рядка, ідентифікатор запису). yield from, доданий у PHP 7.0, делегує іншому генератору, масиву чи будь-якому Traversable і повертає його return-значення як результат виразу — у прикладі це дозволяє порахувати сумарну кількість рядків, не заводячи зайвого стану. Важливий нюанс: yield from зберігає ключі внутрішнього джерела, тому при склеюванні кількох файлів нумерація кілька разів починається з нуля, і iterator_to_array() без другого аргументу false мовчки перетре частину даних. return у генераторі теж не те, чим здається: він не потрапляє в foreach, а забирається окремо через getReturn() — і лише після того, як генератор дійшов до кінця. Нарешті, send() робить обмін двонаправленим: вираз yield усередині обчислюється в значення, передане ззовні, — на цьому побудовані корутини асинхронних рушіїв.

Головне обмеження генератора — одноразовість. Він не має курсора, який можна повернути назад: Generator::rewind() дозволений лише доки виконання стоїть на першому yield, інакше кидає Exception: Cannot rewind a generator that was already run. Через це другий foreach по тому самому обʼєкту падає, а не повертає порожнечу. Так само немає count() (генератор не знає, скільки в нього елементів, поки не дорахує), немає доступу за індексом, і його не приймають array_map(), array_filter(), sort() — усі вони працюють з масивами. Якщо дані потрібні двічі, зберігайте не генератор, а спосіб його створити: фабрику-замикання або клас з IteratorAggregate; саме так влаштований LazyCollection, який приймає замикання й тому ітерується скільки завгодно разів. Матеріалізація через iterator_to_array() теж законна — просто памʼятайте, що вона повертає ту саму памʼять, заради якої все й починалось.

Межі варто називати чесно, бо на них ловлять найчастіше. Генератор не прискорює обчислення — він лише розтягує їх у часі; на маленькій колекції з десятків елементів звичайний масив простіший і швидший, а зайва ланка генераторів робить стек-трейси менш читабельними. Генератор не зменшує памʼять поза PHP: Model::cursor() на MySQL з увімкненою буферизацією PDO (PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, за замовчуванням true у mysqlnd) все одно затягне весь результат у клієнтський буфер — тут або небуферизований запит зі своїми обмеженнями, або chunkById(), який ріже вибірку на окремі запити. І генератор — погане місце для тримання ресурсу з довгим життям: якщо споживач вийде через break, генератор просто зависне на yield до знищення, тож звільнення файла чи відкат транзакції мають бути у finally, а сама транзакція — краще в коді-споживачі, а не всередині ітератора.

/** Читає лог рядок за рядком: у памʼяті живе один рядок, а не весь файл. */
function lines(string $path): Generator
{
    // ця перевірка спрацює НЕ на виклику lines(), а на першій ітерації
    $handle = fopen($path, 'rb') ?: throw new RuntimeException("Немає {$path}");
    $n = 0;
    try {
        while (($line = fgets($handle)) !== false) {
            yield $n++ => rtrim($line, "\r\n");   // ключ задаємо явно
        }
    } finally {
        fclose($handle);          // виконається і при break у споживача
    }
    return $n;                    // видно лише через getReturn() після кінця
}

/** yield from делегує іншому генератору й повертає його return-значення. */
function allLines(string ...$paths): Generator
{
    $total = 0;
    foreach ($paths as $path) {
        $total += (yield from lines($path));   // ключі 0,1,2… стартують заново!
    }
    return $total;
}

/** Ланка конвеєра: фільтр нічого не матеріалізує. */
function onlyErrors(iterable $lines): Generator
{
    foreach ($lines as $key => $line) {
        if (str_contains($line, ' ERROR ')) {
            yield $key => $line;
        }
    }
}

$stream = allLines('app-09-01.log', 'app-09-02.log'); // тіло ще не виконувалось
foreach (onlyErrors($stream) as $key => $line) {
    echo "{$key}: {$line}\n";     // пікова памʼять не залежить від розміру логів
}
echo $stream->getReturn();        // усього рядків; до завершення — Exception
// повторний foreach ($stream) → Cannot rewind a generator that was already run
Що виклик функції з `yield` не виконує її тіло: повертається обʼєкт `Generator`, і код стартує лише на першій ітерації (тому валідація аргументів усередині генератора «мовчить» до `foreach`).
Що памʼять стає сталою замість лінійної: масив на мільйон рядків — це сотні мегабайтів (рядок плюс десятки байтів на елемент хеш-таблиці), генератор тримає один рядок і власний стек-фрейм.
Що `Generator` реалізує `Iterator`, тому працюють `foreach` та `iterator_to_array()`, але не працюють `count()`, `$gen[0]`, `array_map()` і будь-який другий прохід.
Що ключі є повноцінною частиною протоколу: без явного ключа PHP нумерує 0, 1, 2…, а `yield $key => $value` задає свій — і `yield from` (PHP 7.0+) ключі внутрішнього генератора **не** переномеровує.
Що `send()` робить генератор двонаправленим — значенням виразу `yield` усередині стає те, що передали ззовні; це основа корутин у ReactPHP/Amp.
Що межа економії — не лише PHP: `Model::cursor()` не врятує памʼять на MySQL, поки PDO буферизує весь результат на боці клієнта.
Класти перевірку аргументів на початок генератора і чекати винятку одразу після виклику: тіло не виконається, поки хтось не почне ітерацію.
«Для зручності» обгортати результат в `iterator_to_array()` — це повертає рівно ту памʼять, заради якої генератор і писався.
Забувати про дублікати ключів після `yield from`: `iterator_to_array($gen)` тихо перетирає рядки, потрібен другий аргумент `false`.
Викликати `getReturn()` до того, як генератор дійшов до кінця (або після `break`), і отримувати `Exception: Cannot get return value of a generator that hasn't returned`.
Писати `return $value;` у генераторі й чекати, що `foreach` віддасть це значення як останній елемент — воно доступне лише через `getReturn()`.
Вважати `Model::cursor()` або `LazyCollection` автоматичною гарантією низької памʼяті, не подивившись на буферизацію запиту в драйвері БД.
ПОРАДА

Формула, яка закриває питання: «функція з `yield` повертає не дані, а обʼєкт `Generator`; він рахує значення по одному, памʼять стала, але прохід рівно один — перемотати не можна, лише викликати функцію ще раз». Далі одразу назвіть трійку `yield from` / `send()` / `getReturn()` — це показує, що ви бачили генератори не лише в `foreach`.

Сторінка питання →
PHP
Core PHP·Middle ·named arguments ·constructor promotion ·PHP 8.0

Named arguments (PHP 8.0) дозволяють передавати аргументи за іменем параметра й пропускати необовʼязкові, а constructor property promotion (PHP 8.0) оголошує властивість прямо в сигнатурі конструктора; ціна — імена параметрів стають частиною публічного API, а промотована властивість ініціалізується ще до тіла конструктора.

У нас конструктор на вісім параметрів — як зробити виклик читабельним, не роблячи білдер?
Чому після перейменування параметра `$str` на `$string` у сервісі впав чужий код, хоча сигнатура сумісна?
Можна передати масив з рядковими ключами як іменовані аргументи — і з якої версії PHP?
Чому `$this->name = trim($name)` у тілі конструктора падає з «Cannot modify readonly property», якщо параметр промотований?

Обидві фічі приїхали в PHP 8.0, але лікують різні болі. Constructor property promotion прибирає дублювання в оголошенні: замість трьох рядків на кожну залежність (властивість, параметр конструктора, присвоєння) модифікатор видимості просто ставиться перед параметром — public function __construct(private LoggerInterface $logger) {} — і PHP сам створює властивість із тим самим типом та імʼям. Named arguments лікують місце виклику: аргумент передається за іменем параметра, а не за позицією, тому необовʼязкові параметри можна пропускати вибірково, а не «дотягувати» дефолтами до потрібного. new SearchQuery(text: 'php', city: 'Львів') читається без заглядання в сигнатуру, тоді як new SearchQuery('php', 1, 20, 'Львів') — ні.

Механіка named arguments має кілька жорстких правил, і саме на них ловлять. Позиційні аргументи мають іти першими: f(text: 'php', 3) — це помилка парсингу, а не рантайму. Передати той самий параметр двічі (позиційно й за іменем) не можна — буде Error: Named parameter $x overwrites previous argument. Неіснуюче імʼя дає Error: Unknown named parameter $x. Іменовані аргументи, що не збіглися з жодним параметром, збираються у варіадик із рядковими ключами — саме тому прозорі декоратори виду handle(...$args) продовжують працювати. Розпакування масиву з рядковими ключами в іменовані аргументи (f(...['page' => 2])) дозволене з PHP 8.1; у 8.0 воно кидало Cannot unpack array with string keys. Окремо варто памʼятати, що func_get_args() бачить виклик так, ніби все передали позиційно: пропущені необовʼязкові параметри підставляються своїми дефолтами, тому старий код на func_num_args() починає рахувати інакше.

Головна ціна named arguments — імена параметрів стають публічним API. PHP звіряє сумісність сигнатур за типами й кількістю параметрів, але не за іменами: клас, що реалізує інтерфейс, може назвати параметр як завгодно і завантажиться без жодної помилки. Тому виклик $gateway->charge(amount: 100) через тип-інтерфейс впаде в рантаймі на тій єдиній реалізації, де параметр названо $sum. Так само перейменування $str на $string у власній бібліотеці — це BC break, навіть якщо тип і позиція незмінні. Практичне правило: іменовані аргументи безпечні для конструкторів конкретних класів, DTO, атрибутів і вбудованих функцій (їх імена якраз і причесали в PHP 8.0 саме заради цього), а для поліморфних викликів через абстракцію надійніше лишатися позиційним.

У промоції своя пастка, і вона про порядок. Присвоєння промотованих властивостей відбувається до тіла конструктора, тому тіло вже працює з $this->…. Для звичайних властивостей це нічого не змінює, а для readonly (PHP 8.1+) означає, що властивість уже ініціалізована, і $this->text = trim($text) у тілі дасть Cannot modify readonly property. Трансформацію значень доводиться виносити або на рівень виклику — приватний конструктор плюс статичний fromRequest(), який чистить дані, — або в окремі value-обʼєкти, що нормалізують себе самі. Валідація ж без зміни значення (кинути InvalidArgumentException, прочитавши $this->perPage) у тілі конструктора цілком легальна.

Межі промоції варто називати списком: тільки __construct не-абстрактного класу, не варіадичний параметр, не тип callable (замість нього — Closure), не можна дублювати ту саму властивість у тілі класу, модифікатор видимості обовʼязковий. readonly доступний з 8.1, асиметрична видимість public private(set) — з 8.4. Атрибут перед промотованим параметром вішається і на параметр, і на властивість, а ReflectionProperty::isPromoted() дозволяє відрізнити такі властивості в рантаймі. Останнє — документація типів: узагальнені типи промотованих властивостей описують @param list<string> $tags у docblock конструктора, бо окремого місця для @var більше немає; при міграції старих DTO на promotion це найчастіша тиха втрата типів для PHPStan.

final class SearchQuery
{
    /** @param list<string> $tags */
    public function __construct(
        public readonly string $text,
        public readonly int $page = 1,
        public readonly int $perPage = 20,
        public readonly ?string $city = null,
        public readonly array $tags = [],
    ) {
        // тіло виконується ПІСЛЯ присвоєння — властивості вже заповнені
        if ($this->perPage > 100) {
            throw new InvalidArgumentException('perPage максимум 100');
        }
        // $this->text = trim($text);  // Error: readonly вже ініціалізовано промоцією
    }
}

// називаємо лише те, що відрізняється від типового; page і perPage пропускаємо
$q = new SearchQuery(text: 'php', city: 'Львів', tags: ['remote']);

// new SearchQuery(text: 'php', 3);   // Parse error: позиційний після іменованого
// new SearchQuery('php', text: 'x'); // Error: Named parameter $text overwrites previous argument

// розпакування масиву з РЯДКОВИМИ ключами як іменованих — PHP 8.1+
$filters = ['text' => 'php', 'perPage' => 50];
$q2 = new SearchQuery(...$filters);

// зайвий ключ ламає виклик, тому вхідні дані фільтруємо явно
// new SearchQuery(...['text' => 'php', 'sort' => 'new']); // Unknown named parameter $sort

function collect(...$args): array
{
    return $args;                     // іменовані аргументи стають рядковими ключами
}

var_dump(collect(1, 2));              // [0 => 1, 1 => 2]
var_dump(collect(a: 1, b: 2));        // ['a' => 1, 'b' => 2]
Що обидві фічі зʼявилися в PHP 8.0 і вирішують різні задачі: named arguments — про місце виклику, promotion — про місце оголошення.
Що після переходу на іменовані аргументи назва параметра стає частиною контракту: перейменування `$str` → `$string` — це BC break, хоч типи й порядок незмінні.
Що позиційний аргумент після іменованого — синтаксична помилка, а повторна передача того самого параметра позиційно й за іменем дає `Error: Named parameter $x overwrites previous argument`.
Що розпакування масиву з рядковими ключами (`f(...['page' => 2])`) як іменованих аргументів працює з PHP 8.1, а в 8.0 давало `Cannot unpack array with string keys`.
Що промоція присвоює властивості ДО виконання тіла конструктора, тому для `readonly` повторний запис у тілі вже неможливий — трансформацію треба робити в аргументі або у статичному конструкторі.
Що промотувати не можна все підряд: тільки в конструкторі не-абстрактного класу, не варіадичний параметр, не тип `callable`.
Вважати named arguments «просто цукром» і вільно перейменовувати параметри в публічних класах і в реалізаціях інтерфейсів — PHP не перевіряє сумісність імен при успадкуванні, і виклик за іменем впаде вже в рантаймі.
Писати `f(text: 'php', 3)` — позиційний аргумент після іменованого не парситься взагалі, це не рантайм-помилка.
Розраховувати, що `new Dto(...$request->all())` безпечний: зайвий ключ дає `Error: Unknown named parameter $sort`, тому масив треба явно фільтрувати або валідувати.
Дублювати промотовану властивість ще й у тілі класу (`private string $name;` + `private string $name` у конструкторі) — фатальна помилка «Cannot redeclare property».
Робити `$this->name = trim($name)` у тілі конструктора для промотованого `readonly`-параметра й дивуватись `Cannot modify readonly property`.
Промотувати параметри в класі з великою логікою ініціалізації й вважати, що це «звільняє» від валідації: перевірки в тілі конструктора нікуди не діваються, просто працюють уже з `$this->…`.
ПОРАДА

Формула на дві фрази: «promotion скорочує оголошення, named arguments — виклик; разом вони роблять DTO читабельним без білдера». І одразу назвіть ціну: «але імена параметрів після цього — публічний API, перейменування = BC break».

Сторінка питання →
PHP
Core PHP·Middle ·match ·switch ·PHP 8.0

`match` (PHP 8.0+) — це вираз, який повертає значення й порівнює строго через `===` без провалювання між гілками, а якщо жодна умова не збіглася і немає `default` — кидає `UnhandledMatchError`; `switch` — оператор, який порівнює нежорстко через `==`, потребує `break` і при відсутності збігу мовчки нічого не робить.

`match` — це просто коротший `switch`, чи різниця глибша?
Чому `match ($_GET['page'])` з гілкою `1 => ...` не спрацьовує, хоча в URL стоїть `page=1`?
Що станеться, якщо жодна гілка `match` не збіглася і `default` немає?
Чому в `switch` треба писати `break`, а в `match` — ні? І що буде, якщо все-таки написати?

Головна відмінність не в синтаксисі, а в тому, що це різні мовні категорії. switch — оператор: він передає керування в гілку, а результат ви мусите самі кудись покласти, тому кожен case закінчується присвоєнням у тимчасову змінну і break. match, що зʼявився в PHP 8.0, — вираз: він обчислюється у значення, тому пишеться там, де очікується значення — праворуч від =, у return, в аргументі виклику, у тілі стрілочної функції, у рядковій інтерполяції через {}. Звідси й дрібна деталь синтаксису, на якій спотикаються: після закриваючої фігурної дужки match ставиться крапка з комою, бо це закінчення виразу, а не блоку.

Друга відмінність — семантика порівняння. switch порівнює тему з кожним case нежорстко, через ==, з усіма приведеннями типів; match — строго, через ===, тобто збіг має бути і за значенням, і за типом. PHP 8.0 прибрав найгидкішу пастку switch, змінивши порівняння числа з нечисловим рядком (0 == 'foo' тепер false), але сам == нікуди не подівся: '1' == 1, '1e2' == '100', '0' == false, null == false — усе це в switch досі дає збіг. Практичний наслідок видно в прикладі коду: значення '1', що прийшло з query-рядка, потрапляє в case 1 у switch і не потрапляє в гілку 1 => у match. Це не баг match, а причина №1 регресій при механічній заміні однієї конструкції на іншу — тип теми треба нормалізувати явно ((int) $page, Status::from($raw)).

Третя відмінність — поведінка на невідомому значенні. switch без відповідного case і без default мовчки не робить нічого; це зручно рівно доти, доки на цьому «нічого» випадково не почала триматися логіка. match зобовʼязаний повернути значення, тому відсутність збігу для нього — помилковий стан: інтерпретатор кидає \UnhandledMatchError. Важлива для співбесіди деталь: цей клас успадковує \Error, а не \Exception, тож catch (Exception $e) його не перехопить — потрібні catch (\UnhandledMatchError), catch (\Error) або \Throwable. Найцінніше це на enum: match по кейсах enum без default дає вичерпність, яку перевіряє статичний аналізатор на етапі CI, а рантайм страхує помилкою. Дописаний «щоб не падало» default => null вимикає обидва рівні захисту — і забутий новий кейс перетворюється з голосної помилки на тихий null, який спливе за три екрани звідси.

Решта відмінностей дрібніші, але їх теж питають. Провалювання (fallthrough) в match немає взагалі: кілька значень в одній гілці перелічуються комою (200, 201, 204 =>), а break там не пишеться і не парситься — гілка є виразом, а не блоком інструкцій. Через це ж обмеження гілка не може містити кілька інструкцій: багатокрокова логіка або витягується в метод, або лишається в switch. Для діапазонів і складених умов є ідіома match (true), де кожна гілка — булевий вираз; вона читається краще за драбину if/elseif, бо повертає значення, але порядок гілок стає критичним, а умови після першого збігу не обчислюються взагалі. Щодо продуктивності: коли всі умови — літерали одного типу (int або string), обидві конструкції компілюються в таблицю переходів (ZEND_SWITCH_LONG/ZEND_SWITCH_STRING для switch, ZEND_MATCH для match), тож обирати між ними за швидкістю немає сенсу.

Межі варто назвати чесно. match — не патерн-матчинг: у PHP немає ні деструктуризації, ні guard-умов, ні матчингу за типом (instanceof доводиться писати руками всередині match (true)). Порівняння через === для звичайних обʼєктів означає ідентичність екземпляра, а не рівність вмісту, тому два еквівалентні DateTimeImmutable у match не збігатимуться — з enum це працює лише тому, що його кейси є синглтонами. І якщо match розростається до пари десятків гілок, це вже не питання вибору між ним і switch: там просилася або мапа array<string, callable>, або поліморфізм із окремим класом на кожен випадок.

declare(strict_types=1);

$page = '1';                          // усе, що приходить з HTTP, — рядок

switch ($page) {                      // switch порівнює через ==
    case 1:                           // '1' == 1 → true, гілка спрацює
        $bySwitch = 'перша сторінка';
        break;                        // без break провалиться в default
    default:
        $bySwitch = 'інша';
}
echo $bySwitch;                       // 'перша сторінка'

$byMatch = match ($page) {            // match порівнює через ===
    1 => 'перша сторінка',            // '1' !== 1 → гілка НЕ спрацює
    '1' => 'перша сторінка (рядок)',
    default => 'інша',
};                                    // match — вираз, тому крапка з комою
echo $byMatch;                        // 'перша сторінка (рядок)'

$status = 503;

// match повертає значення: присвоюємо одразу, тимчасова змінна не потрібна
$label = match (true) {               // умови перевіряються згори вниз
    $status >= 500 => 'помилка сервера',
    $status >= 400 => 'помилка клієнта',
    $status >= 200 => 'успіх',
    default => 'невідомо',
};
echo $label;                          // 'помилка сервера'

try {
    // default немає; кілька значень в одній гілці — через кому, без fallthrough
    echo match ($status) {
        200, 201, 204 => 'тіло можна кешувати',
        301, 302 => 'редірект',
    };
} catch (\UnhandledMatchError $e) {   // це Error, а не Exception
    echo $e->getMessage();            // у повідомленні — незіставлене значення
}
Що `match` порівнює через `===` (тип і значення), а `switch` — через `==` з приведенням типів; це не стилістична, а семантична різниця.
Що `match` — вираз: його результат можна присвоїти, повернути з `return`, передати аргументом, покласти в тіло стрілочної функції; `switch` — оператор, тому в кожній гілці доводиться писати присвоєння у тимчасову змінну.
Що провалювання (fallthrough) в `match` немає взагалі: кілька значень групуються комою `200, 201, 204 =>`, а `break` там не потрібен і навіть неможливий.
Що при відсутності збігу `match` без `default` кидає `\UnhandledMatchError`, який успадковує `Error`, а не `Exception` — тобто `catch (Exception)` його не спіймає.
Що тіло гілки `match` — рівно одне вираження, тому багатокрокова логіка з кількома інструкціями лишається за `switch`, окремим методом або `if`.
Що `match` зʼявився в PHP 8.0 і що на enum він дає перевірювану вичерпність: без `default` статичний аналізатор бачить пропущений case, а рантайм ловить його `UnhandledMatchError`.
Називати `match` «синтаксичним цукром над switch»: цукор не змінює семантику, а тут змінюються і порівняння, і поведінка при відсутності збігу.
Механічно замінювати `switch` на `match` у коді, що працює з даними з HTTP, БД чи CSV: там значення — рядки, і гілка `1 =>` після заміни перестає спрацьовувати, бо `'1' !== 1`.
Вважати, що в PHP 8 `switch` став строгим: змінилося лише порівняння числа з нечисловим рядком (`0 == 'foo'` тепер `false`), а `==` з усіма іншими приведеннями (`'1' == 1`, `'1e2' == '100'`, `'0' == false`) у `switch` лишився.
Писати `break` або кілька інструкцій через `;` усередині гілки `match` — це помилка парсингу, бо гілка є виразом, а не блоком.
Ловити `UnhandledMatchError` через `catch (Exception $e)` — не спрацює; потрібен `catch (\UnhandledMatchError)`, `catch (\Error)` або `\Throwable`.
Дописувати `default => null` «щоб не падало» в `match` по enum: це вимикає перевірку вичерпності, і новий case мовчки почне повертати `null` замість того, щоб зламатися голосно.
ПОРАДА

Одна фраза, яка закриває питання: «`switch` — оператор із `==` і провалюванням, `match` — вираз із `===` і без провалювання, а на невідоме значення `switch` мовчить, `match` кидає `UnhandledMatchError`». Далі додайте головний практичний наслідок: `match` по enum без `default` перетворює забутий новий case із тихого бага на помилку.

Сторінка питання →
PHP
Core PHP·Middle ·Fiber ·PHP 8.1 ·корутини

`Fiber` (PHP 8.1+) — це низькорівневий примітив кооперативної багатозадачності: блок коду з власним стеком, який можна призупинити з будь-якої глибини викликів через `Fiber::suspend()` і відновити через `resume()`. Планувальника й циклу подій у ядрі немає — їх дають revolt/event-loop та AMPHP v3.

Fibers — це нарешті багатопоточність у PHP?
Чим `Fiber` відрізняється від генератора, якщо обидва вміють призупинятися й віддавати значення?
Я обгорнув запити через PDO у `Fiber`, а сторінка не пришвидшилась — чому?
Ви колись писали `new Fiber()` руками? Якщо ні, то навіщо воно взагалі в мові?

Fiber — це клас у глобальному просторі імен, доданий у PHP 8.1 разом із FiberError. Обʼєкт створюють від будь-якого callable (new Fiber($callback)), запускають через start(...$args), а всередині коду волокна викликають статичний Fiber::suspend($value) — виконання завмирає, а start() повертає передане значення тому, хто волокно запустив. Далі resume($value) продовжує роботу з тієї ж точки, причому аргумент resume() стає значенням, яке поверне Fiber::suspend(). Стан читають через isStarted(), isSuspended(), isRunning(), isTerminated(), результат callable — через getReturn() (до завершення волокна це FiberError), а «розбудити з винятком» можна через throw(). Усе разом це один примітив: перемикання стеків, і нічого більше.

Порівняння з генераторами — головна змістовна частина відповіді. Обидва механізми кооперативні й обидва вміють двобічний обмін (Generator::send() проти Fiber::resume()), але генератор призупиняє тільки власне тіло: yield має стояти в тій самій функції, а функція з yield перестає бути звичайною — вона повертає Generator, і викликач мусить її ітерувати. Щоб призупинитися з глибини, кожну функцію в ланцюжку доводиться робити генератором і прокидати yield from — це та сама «розфарбованість функцій», через яку синхронний і корутинний код не змішувалися. Волокно має власний стек, тому Fiber::suspend() спрацьовує на будь-якій глибині, а проміжний код лишається звичайним і взагалі не знає про своє оточення; за потреби бібліотека перевіряє контекст через Fiber::getCurrent(). Зворотний бік: волокно не є Iterator, у нього немає ключів і foreach, тож для лінивого перебору даних генератор нікуди не подівся.

Руками new Fiber() майже ніхто не пише, і це нормальна відповідь на питання «навіщо воно тоді». RFC свідомо лишив у ядрі лише примітив, без планувальника й циклу подій, бо його місце — у користувацькому просторі. Планувальником став revolt/event-loop — спільний цикл подій, який використовують AMPHP v3 і ReactPHP; поверх нього amphp/amp v3 дає Amp\async() (запускає волокно) та Future::await() (усередині кличе suspend()). Найкраща ілюстрація зиску — сама історія AMPHP: у v2 корутини будувалися на генераторах і кожен асинхронний виклик писався як yield $promise, у v3 бібліотеку переписали на волокна, і yield із користувацького коду зник — асинхронний виклик виглядає як звичайний. У ReactPHP той самий підхід дає пакет react/async зі своїми async()/await().

Чому це не async у розумінні JavaScript. По-перше, у PHP немає вбудованого рантайму з циклом подій: у Node цикл є завжди й усі I/O-API неблокуючі за замовчуванням, у PHP цикл треба принести бібліотекою і явно запустити. По-друге, у ядрі немає ані Promise, ані ключових слів async/await — є один клас Fiber. По-третє й найважливіше практично: волокно не перетворює блокуючий виклик на неблокуючий. PDO::query(), file_get_contents(), curl_exec(), sleep() зупиняють увесь процес разом з усіма волокнами, тож конкурентність зʼявляється лише тоді, коли ви користуєтесь неблокуючими клієнтами (amphp/http-client, amphp/mysql, amphp/postgres, amphp/socket). Це відрізняє підхід ядра PHP від Swoole, який підміняє блокуючі функції власними реалізаціями через runtime hooks.

Межі варто назвати одразу, щоб відповідь звучала як досвід, а не як переказ документації. Волокна — це конкурентність, а не паралелізм: у кожен момент виконується рівно одне волокно, і задачу, що впирається в CPU, вони не пришвидшать — там потрібні amphp/parallel, ext-parallel або кілька воркерів. У класичній моделі PHP-FPM виграш обмежений одним запитом: розпаралелити три звернення до зовнішніх API можна, а тримати цикл подій між запитами — ні (для цього потрібен довгоживучий воркер). Памʼять теж не безкоштовна: кожне призупинене волокно тримає власний стек, тому «мільйон волокон» — не той дизайн, який варто пропонувати. І нарешті, ресурси всередині волокна закривають у finally: коли призупинене волокно втрачає останнє посилання, PHP розкручує його стек саме заради finally, а призупинитися ще раз у цей момент уже не дасть.

// Функції нижче — звичайні: ні yield, ні Generator у сигнатурах
function fetchBody(string $url): string
{
    return readResponse($url);        // виклик на рівень глибше
}

function readResponse(string $url): string
{
    // тут був би неблокуючий сокет; призупиняємось із глибини стека
    $answer = Fiber::suspend("чекаю на {$url}");

    return "тіло {$url} ({$answer})";  // suspend() повертає те, що дали в resume()
}

$fibers = [
    new Fiber(fn (): string => fetchBody('/api/jobs')),
    new Fiber(fn (): string => fetchBody('/api/companies')),
];

// start() повертає значення, передане у Fiber::suspend()
foreach ($fibers as $fiber) {
    echo $fiber->start(), PHP_EOL;    // «чекаю на /api/jobs», «чекаю на /api/companies»
}

// Примітивний планувальник: обидва волокна вже «в польоті» одночасно
foreach ($fibers as $fiber) {
    if ($fiber->isSuspended()) {
        $fiber->resume('200 OK');     // значення повертається з suspend()
    }
}

foreach ($fibers as $fiber) {
    // getReturn() дійсний лише після завершення волокна
    echo $fiber->isTerminated() ? $fiber->getReturn() : 'ще виконується', PHP_EOL;
}

try {
    Fiber::suspend('з {main}');       // поза волокном це заборонено
} catch (FiberError $e) {
    echo $e->getMessage(), PHP_EOL;   // Cannot suspend outside of fiber
}
Що `Fiber` — це не «async у PHP», а лише механізм призупинення: RFC свідомо не додав ані планувальника, ані циклу подій, ані `Promise` в ядро.
Що головна відмінність від генератора — власний стек: `Fiber::suspend()` викликається з будь-якої глибини вкладених функцій, і проміжні функції не треба переписувати на `yield from`.
Що волокна кооперативні й однопотокові: у кожен момент виконується рівно одне, паралельності на кількох ядрах вони не дають.
Що реальні споживачі — `revolt/event-loop` (спільний цикл подій для AMPHP v3 і ReactPHP) та `amphp/amp` v3; AMPHP v2 будував корутини на генераторах і `yield`, v3 переписали на волокна й `yield` з користувацького коду зник.
Що блокуючий виклик (`PDO::query`, `file_get_contents`, `sleep`, `curl_exec`) усередині волокна блокує весь процес — волокно саме собою нічого не робить неблокуючим.
Казати «Fibers — це потоки» або «тепер PHP паралелить запити на ядрах»: це один процес, один потік, кооперативне перемикання за явним `suspend()`.
Обгортати у волокно звичайний `PDO`/`file_get_contents` і чекати прискорення: потрібні неблокуючі клієнти (`amphp/http-client`, `amphp/mysql`, `amphp/postgres`), інакше виграшу нуль.
Викликати `Fiber::suspend()` із `{main}` або з коду поза волокном — буде `FiberError: Cannot suspend outside of fiber`.
Читати `$fiber->getReturn()`, поки волокно ще призупинене — `FiberError`; спочатку `isTerminated()`.
Плутати ролі: брати `Fiber` там, де потрібна лінива послідовність даних — для ітерації по мільйону рядків правильний інструмент і далі генератор.
Стверджувати, що в PHP 8.1 зʼявилися `async`/`await` і `Promise`: у ядрі є лише клас `Fiber`, решта — бібліотеки.
ПОРАДА

Формула, яка закриває питання: «генератор — це корутина, видима в сигнатурі; волокно — корутина, невидима для викликаного коду». Далі одне речення про межі: «PHP отримав перемикач стеків, а планувальник і неблокуючий I/O довелося взяти з Revolt і AMPHP».

Сторінка питання →
Прогрес карток і тестів зберігається у профілі. Створити профіль·Увійти