Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Функції й замикання

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

10 питань

Обидві створюють об'єкт Closure, але по-різному працюють із зовнішніми змінними.

Анонімна функція (function () use (...)) бачить лише ті зовнішні змінні, які явно перелічені в use, і захоплює їх за значенням у момент створення:

$rate = 1.2;
$withTax = function (int $price) use ($rate): float {
    return $price * $rate;
};

Щоб змінювати зовнішню змінну, - захоплення за посиланням: use (&$total).

Стрілкова функція (fn, PHP 7.4) автоматично захоплює всі зовнішні змінні, які використовує, за значенням:

$withTax = fn (int $price): float => $price * $rate;

Відмінності:

  • Лише один вираз. Тіло стрілкової функції - один вираз, результат якого повертається. Кілька інструкцій - тільки анонімна функція.
  • Не можна змінити зовнішню змінну. fn () => $count++ змінить копію, а не $count ззовні. Для накопичення - анонімна функція з use (&$count).
  • use не потрібен - менше шуму в коді.

Де що використовують:

  • fn - короткі колбеки: array_map(fn ($u) => $u->email, $users), ->filter(fn ($o) => $o->isPaid()), ->when($search, fn ($q) => $q->where(...)).
  • function - колбеки з кількома кроками, розгалуженнями чи зміною зовнішнього стану.

Обидві автоматично прив'язують $this усередині класу. Щоб уникнути цього (і зайвого утримання об'єкта в пам'яті), - static fn чи static function.

Докладніше в документації: Стрілкові функції

Значення за замовчуванням роблять параметр необов'язковим:

function paginate(array $items, int $perPage = 15, int $page = 1): array
{
    return array_slice($items, ($page - 1) * $perPage, $perPage);
}

paginate($items);            // 15 на сторінку, перша сторінка
paginate($items, 50);
paginate($items, page: 3);   // іменований аргумент пропускає $perPage

Правила:

  • Значення за замовчуванням - константний вираз: літерал, константа, new (з PHP 8.1), масив. Не виклик функції.
  • Необов'язкові параметри йдуть після обов'язкових. Обов'язковий параметр після необов'язкового - deprecated.
  • null за замовчуванням вимагає nullable-типу: ?string $name = null.

Variadic-параметр ...$name збирає всі решту аргументів у масив:

function sum(int ...$numbers): int
{
    return array_sum($numbers);
}

sum(1, 2, 3);          // 6
sum(...[1, 2, 3]);     // розпаковка масиву в аргументи
  • Може бути лише останнім параметром.
  • Тип застосовується до кожного елемента: int ...$numbers.
  • Іменовані аргументи, що не відповідають жодному параметру, потрапляють у variadic з рядковими ключами.

Пастки:

  • Змінюване значення за замовчуванням через new (Options $o = new Options()) створює новий об'єкт при кожному виклику - на відміну від Python, тут проблеми спільного стану немає.
  • Багато необов'язкових параметрів - запах. Якщо їх п'ять, краще об'єкт параметрів (DTO) чи кілька окремих методів.
  • Булевий прапорець за замовчуванням (bool $force = false) робить виклики незрозумілими - іменований аргумент рятує: delete(force: true).

Докладніше в документації: Аргументи функцій

За замовчуванням аргументи передаються за значенням: функція отримує копію, і зміна параметра не впливає на змінну викликача. Знак & у сигнатурі робить передачу за посиланням:

function addItem(array &$cart, string $item): void
{
    $cart[] = $item;
}

$cart = [];
addItem($cart, 'книга');
// $cart === ['книга']

Де це в стандартній бібліотеці: sort(), array_push(), preg_match() (масив збігів), array_walk() - вони змінюють передану змінну.

Чому у власному коді це часто погана ідея:

  • Прихований побічний ефект. З виклику addItem($cart, 'книга') не видно, що $cart зміниться. Читач має знати сигнатуру.
  • Важче міркувати й тестувати: функція не просто повертає результат, а змінює стан ззовні.
  • Не дає економії пам'яті. Масиви копіюються ліниво (copy-on-write), тож передача за значенням не копіює дані, доки їх не змінять.
  • Не можна передати вираз: addItem([], 'x') чи addItem(getCart(), 'x') - помилка, потрібна саме змінна.

Краща альтернатива - повертати нове значення:

function withItem(array $cart, string $item): array
{
    return [...$cart, $item];
}

$cart = withItem($cart, 'книга');

Або - об'єкт зі станом і методом: $cart->add('книга') - зміна очевидна з виклику.

Об'єкти й так не копіюються: функція, що отримала об'єкт, працює з тим самим об'єктом. & для об'єкта потрібен лише тоді, коли функція має замінити змінну іншим об'єктом.

Коли посилання виправдані: зміна великих вкладених структур на місці в гарячому коді та рідкісні API на кшталт «повернути кілька значень» - хоча тут читабельніше повернути масив чи об'єкт.

Докладніше в документації: Передача аргументів за посиланням

Анонімні й стрілкові функції в PHP - об'єкти класу Closure. Цей клас має методи для керування контекстом виконання.

Closure::fromCallable() - створює замикання з будь-якого callable: імені функції, [$object, 'method'], [Class::class, 'staticMethod']. Сьогодні замість нього пишуть first-class callable syntax:

$fn = Closure::fromCallable([$this, 'normalize']);
$fn = $this->normalize(...);    // те саме, з PHP 8.1

bind() / bindTo() - змінюють $this і область видимості класу (scope) замикання:

final class Counter
{
    private int $count = 5;
}

$peek = function (): int {
    return $this->count;    // приватна властивість
};

$bound = Closure::bind($peek, new Counter(), Counter::class);
echo $bound();   // 5

Другий аргумент - новий $this, третій - клас, від імені якого замикання має доступ до приватних і захищених членів.

call() - тимчасово прив'язати й одразу викликати: $peek->call(new Counter()).

Де це використовують:

  • Макроси в Laravel (Collection::macro('toUpper', function () { return $this->map(...); })) - замикання прив'язується до екземпляра колекції, тож $this усередині - колекція.
  • Тести й налагодження - доступ до приватного стану без Reflection.
  • DSL і конфігурація - виконати колбек «всередині» об'єкта-будівника.

Застереження: доступ до приватних членів через bind обходить інкапсуляцію. У робочому коді це майже завжди ознака, що потрібен публічний метод. Статичне замикання (static function) прив'язати до об'єкта не можна взагалі.

Докладніше в документації: Клас Closure

Замикання, створене всередині методу класу, автоматично захоплює $this - навіть якщо не використовує його. Ключове слово static вимикає це:

final class ReportBuilder
{
    public function totals(array $rows): array
    {
        return array_map(static fn (array $row): int => $row['qty'] * $row['price'], $rows);
    }
}

Навіщо:

  • Пам'ять і час життя об'єкта. Звичайне замикання тримає посилання на $this. Якщо його зберегли надовго (у кеші, в реєстрі обробників подій, у властивості іншого об'єкта), то й весь об'єкт з усіма залежностями не звільниться. У довгоживучих процесах (Octane, воркери) це шлях до витоків пам'яті.
  • Ясність намірів. static показує читачу й аналізатору: колбек не залежить від стану об'єкта, це чиста функція даних.
  • Захист від випадкового доступу: використання $this у статичному замиканні - помилка, а не тиха залежність.
  • Незначна економія: менше роботи на прив'язку контексту.

Обмеження: статичне замикання не можна прив'язати до об'єкта через bind()/bindTo(). Тому в місцях, де фреймворк навмисно прив'язує замикання (макроси Laravel, деякі колбеки конфігурації), static ламає поведінку.

Практика: багато команд вмикають правило PHP-CS-Fixer static_lambda, яке автоматично додає static до замикань, що не використовують $this. Rector має аналогічне правило.

Те саме для стрілкових функцій: static fn - стрілкова функція без прив'язки $this, але з автоматичним захопленням інших зовнішніх змінних.

Докладніше в документації: Статичні анонімні функції

Рекурсія - функція викликає сама себе. Природна для деревовидних даних: каталоги, коментарі з відповідями, категорії, розбір вкладеного JSON.

function totalSize(array $node): int
{
    $size = $node['size'];
    foreach ($node['children'] as $child) {
        $size += totalSize($child);
    }
    return $size;
}

Обмеження в PHP:

  • Кожен виклик займає пам'ять у стеку викликів. Глибина в десятки тисяч рівнів може вичерпати стек.
  • Немає оптимізації хвостової рекурсії: навіть return f($n - 1) наприкінці функції займає новий кадр стеку.
  • Наслідки переповнення залежать від версії й платформи. PHP 8.3 додав налаштування zend.max_allowed_stack_size, з яким рушій кидає Error «Maximum call stack size reached» замість падіння. Але на частині конфігурацій процес усе одно аварійно завершується (segfault) без жодного стек-трейсу. З Xdebug діє власний ліміт xdebug.max_nesting_level.

Як обійти глибоку рекурсію - ітерацією з явним стеком:

function totalSize(array $root): int
{
    $size = 0;
    $stack = [$root];

    while ($stack !== []) {
        $node = array_pop($stack);
        $size += $node['size'];
        foreach ($node['children'] as $child) {
            $stack[] = $child;
        }
    }

    return $size;
}

Стек тепер - звичайний масив у купі, обмежений лише memory_limit.

Інші варіанти:

  • Генератори з yield from - обхід дерева без накопичення результатів у пам'яті.
  • Рекурсія в базі даних - WITH RECURSIVE для ієрархій, що зберігаються в таблиці, замість рекурсивних запитів з PHP (і N+1).

Захист від нескінченної рекурсії: умова зупинки на першому рядку і, для даних ззовні (зациклені посилання в графі), - облік уже відвіданих вузлів чи обмеження глибини.

Докладніше в документації: Функції користувача

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

Статична змінна у функції:

function countryName(string $code): string
{
    static $cache = [];

    return $cache[$code] ??= Country::where('code', $code)->value('name');
}

Властивість об'єкта - кеш живе разом з екземпляром сервісу:

final class ExchangeRates
{
    private array $rates = [];

    public function rate(string $currency): float
    {
        return $this->rates[$currency] ??= $this->fetchFromApi($currency);
    }
}

WeakMap - коли аргумент - об'єкт, і кеш не має утримувати його в пам'яті:

private WeakMap $totals;

public function total(Order $order): int
{
    return $this->totals[$order] ??= $this->calculate($order);
}

У Laravel є хелпер once(): результат замикання кешується на час запиту в межах конкретного об'єкта й місця виклику.

public function permissions(): array
{
    return once(fn () => $this->loadPermissionsFromDatabase());
}

Коли мемоізація доречна: функція чиста (той самий вхід - той самий результат), виклики з однаковими аргументами повторюються, а обчислення дороге.

Пастки:

  • Довгоживучі процеси. Статична змінна в PHP-FPM живе один запит, а в Octane чи воркері черги - доки живе процес. Кеш накопичується і може віддавати застарілі дані одного користувача іншому.
  • Нечисті функції: мемоізувати «поточного користувача» чи «час зараз» - отримати застарілі значення.
  • Ключ кешу для кількох аргументів - серіалізувати їх однозначно (serialize, json_encode), пам'ятаючи про об'єкти.
  • Мемоізація ≠ кеш застосунку. Вона живе в пам'яті процесу; для спільного між запитами кешу - Redis і Cache::remember.

Докладніше в документації: Статичні змінні

Чиста функція:

  1. для тих самих аргументів завжди повертає той самий результат;
  2. не має побічних ефектів - не змінює нічого за своїми межами й не залежить від прихованого стану.

Побічні ефекти: запис у базу чи файл, HTTP-запит, надсилання листа, зміна глобальних чи статичних змінних, зміна переданих за посиланням аргументів, читання поточного часу, випадкових чисел, $_GET, конфігурації.

// Нечиста: залежить від часу й бази
function isExpired(int $subscriptionId): bool
{
    $sub = Subscription::find($subscriptionId);
    return $sub->ends_at < now();
}

// Чиста: усе потрібне - в аргументах
function isExpired(DateTimeImmutable $endsAt, DateTimeImmutable $now): bool
{
    return $endsAt < $now;
}

Чому це важливо для тестів:

  • Чисту функцію тестують одним рядком: дав вхід - перевірив вихід. Без бази, моків, фейкового часу.
  • Тести швидкі й стабільні: немає залежності від порядку, часу запуску, стану бази.
  • Легко перевірити граничні випадки - просто передати інші аргументи.

Як застосувати на практиці - «функціональне ядро, імперативна оболонка»:

  • Ядро - чисті обчислення бізнес-правил: розрахунок ціни зі знижками, перевірка правил, переходи станів, форматування.
  • Оболонка - тонкий шар, що збирає дані (база, запит, час), викликає ядро й виконує побічні ефекти з результатом (зберегти, надіслати).

Більшість логіки опиняється в легко тестованому ядрі, а оболонку покривають кількома інтеграційними тестами.

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

Докладніше в документації: Pure function

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

function walk(array $node): Generator
{
    yield $node['path'];

    foreach ($node['children'] as $child) {
        yield from walk($child);    // рекурсивний обхід дерева
    }
}

foreach (walk($tree) as $path) {
    echo $path, PHP_EOL;
}

Працює з генераторами, масивами й будь-яким Traversable.

Повернення значення з генератора: return у генераторі не віддає значення в цикл, а зберігає його як результат - його читають через getReturn() після завершення:

function importRows(iterable $rows): Generator
{
    $imported = 0;

    foreach ($rows as $row) {
        yield $row['id'];      // проміжні результати
        $imported++;
    }

    return $imported;          // підсумок
}

$gen = importRows($rows);
foreach ($gen as $id) { /* ... */ }
echo $gen->getReturn();        // кількість

А yield from повертає результат делегованого генератора:

$count = yield from importRows($rows);

Пастки:

  • Ключі. yield from зберігає ключі делегованого джерела. Два yield from по масивах з ключами 0, 1, 2 дадуть дублікати ключів, і iterator_to_array() перезапише значення. Рішення - iterator_to_array($gen, preserve_keys: false).
  • getReturn() до завершення генератора кидає виняток.
  • Генератор одноразовий: пройти його вдруге не можна.

Двосторонній обмін: $received = yield $value; разом із $gen->send($data) дозволяє передавати дані в генератор - на цьому будували співпрограми до появи Fibers.

Докладніше в документації: Синтаксис генераторів

extract($array) створює змінні в поточній області видимості з ключів масиву:

extract(['name' => 'Оля', 'role' => 'admin']);
echo $name;   // 'Оля'

compact('name', 'role') - зворотне: збирає змінні в масив за іменами.

Чому extract небезпечний:

  • Перезапис змінних. За замовчуванням extract перезаписує наявні змінні. Якщо масив прийшов від користувача, нападник може підмінити будь-яку змінну функції:
$isAdmin = false;
extract($_POST);         // POST isAdmin=1 → $isAdmin = '1'
if ($isAdmin) { /* ... */ }

Історично це була реальна вразливість у CMS і плагінах.

  • Невидимі змінні. Читач коду не бачить, звідки взялася $role - її оголошення ніде немає. IDE й статичний аналіз теж не бачать.
  • Опечатки не ловляться: відсутній ключ просто не створить змінну, і код отримає «Undefined variable» далі.

Якщо extract таки потрібен: ніколи з даними від користувача і з прапорцем, що забороняє перезапис - extract($data, EXTR_SKIP).

compact безпечніший, але має свої мінуси:

return view('orders.show', compact('order', 'items', 'total'));
  • Імена змінних - рядки: перейменування змінної в IDE не оновить рядок у compact, і ключ зникне.
  • Відсутня змінна - лише попередження.

Явний масив читабельніший і надійніший при рефакторингу:

return view('orders.show', ['order' => $order, 'items' => $items, 'total' => $total]);

Де ці функції живуть легітимно: шаблонізатори (Blade під капотом робить extract даних подання в області видимості шаблону) - там джерело даних контрольоване, і це їхня пряма задача.

Докладніше в документації: extract