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

Питання на співбесіді: Типи й помилки

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

12 питань

=== (тотожність) - true, лише якщо однакові і значення, і тип. == (рівність) спершу приводить операнди до спільного типу, а тоді порівнює.

1 === '1';    // false - int і string
1 == '1';     // true
0 == 'abc';   // false у PHP 8, true у PHP 7
null == false; // true
'1e3' == '1000'; // true - обидва числові рядки

Що змінив PHP 8: порівняння числа з нечисловим рядком тепер порівнює їх як рядки. Раніше 0 == 'abc' було true, бо 'abc' перетворювався на 0 - джерело реальних вразливостей у перевірках паролів і токенів.

Правило: за замовчуванням ===. == - лише коли приведення типів свідомо потрібне, і тоді краще привести явно: (int) $request->input('page') === 1.

Ще пастки ==:

  • in_array($value, $list) і array_search за замовчуванням порівнюють через ==. Третій аргумент true вмикає строге порівняння.
  • switch теж порівнює через ==; match - через ===.

Докладніше в документації: Оператори порівняння

У PHP дві гілки «кидабельних» об'єктів, і обидві реалізують інтерфейс Throwable:

  • Exception - очікувані ситуації, які код програми кидає сам і може обробити: немає файлу, сервіс не відповів, дані не пройшли перевірку.
  • Error - помилки в самому коді чи середовищі, які кидає рушій: TypeError, ArgumentCountError, DivisionByZeroError, виклик методу на null.
try {
    $user->profile->avatar(); // $user->profile === null
} catch (Exception $e) {
    // сюди не потрапить: це Error
} catch (Error $e) {
    // Call to a member function avatar() on null
}

Що з цим робити:

  • Ловіть конкретні класи винятків, які очікуєте: catch (ConnectionException $e).
  • catch (Throwable $e) - лише на самому верху: в обробнику помилок фреймворку, у воркері черги, щоб записати збій і не впасти цілком.
  • Error зазвичай не «обробляють», а виправляють: це баг, і його треба побачити в логах, а не проковтнути.

До PHP 7 більшість таких помилок були фатальними й не ловилися взагалі - Error з'явився саме для того, щоб їх можна було перехопити.

Докладніше в документації: Помилки в PHP 7+

  • $a ?? $b (null coalescing) - повертає $a, якщо воно існує і не null, інакше $b. Не кидає попереджень на неіснуючих змінних і ключах.
  • $a ?: $b (скорочений тернарний, «elvis») - повертає $a, якщо воно істинне, інакше $b. Для 0, '', '0', [], false теж поверне $b.
$page = $_GET['page'] ?? 1;      // ключа немає - 1, без попередження
$page = $_GET['page'] ?: 1;      // ключа немає - попередження "Undefined array key"

$discount = 0;
$discount ?? 10;    // 0  - нуль не null
$discount ?: 10;    // 10 - нуль хибний

$title = '';
$title ?? 'Без назви';   // ''
$title ?: 'Без назви';   // 'Без назви'

Як обирати:

  • ?? - «значення за замовчуванням, якщо нічого немає»: ключі масиву, необов'язкові параметри, конфігурація. Найчастіший випадок.
  • ?: - коли порожній рядок, нуль чи порожній масив справді треба замінити: показати «Без назви» замість порожнього заголовка.

Присвоєння з ??= (PHP 7.4):

$options['timeout'] ??= 30;   // присвоїти, лише якщо ключа немає чи там null

Ланцюжки: $a ?? $b ?? $c - перше значення, що не null. Разом із nullsafe-оператором: $user?->profile?->city ?? 'Невідомо'.

Докладніше в документації: Оператор null coalescing

match (PHP 8.0) - вираз, що повертає значення залежно від умови:

$label = match ($status) {
    'new', 'pending' => 'Очікує',
    'paid' => 'Оплачено',
    'cancelled' => 'Скасовано',
};

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

  • Строге порівняння ===. switch порівнює через ==, тож switch ('1') спрацює на case 1, а switch (0) у PHP 7 - навіть на case 'abc'.
  • Повертає значення - його можна присвоїти чи повернути одразу. switch - інструкція.
  • Немає провалювання (fall-through) і не потрібен break. Забутий break у switch - класичний баг.
  • Необроблене значення - виняток UnhandledMatchError. switch без default просто нічого не робить, і помилка залишається непомітною.
  • Кілька умов через кому в одній гілці.

match (true) - заміна ланцюжку if/elseif з довільними умовами:

$grade = match (true) {
    $score >= 90 => 'A',
    $score >= 75 => 'B',
    $score >= 60 => 'C',
    default => 'F',
};

Обмеження: у кожній гілці - один вираз. Якщо гілці потрібно кілька інструкцій, - викликати метод або лишити if/switch.

У поєднанні з enum match без default - спосіб гарантувати, що кожен варіант оброблено: новий case без гілки дасть UnhandledMatchError, а статичний аналізатор (PHPStan) попередить ще до запуску.

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

  • Nullable ?T - значення типу T або null: ?string.
  • Union A|B (PHP 8.0) - будь-який з перелічених типів: int|string. ?string - це скорочення для string|null.
  • mixed (PHP 8.0) - будь-який тип, включно з null. Явно каже: «тут справді що завгодно».
  • Intersection A&B (PHP 8.1) - об'єкт, що реалізує всі перелічені інтерфейси.
function findUser(int|string $id): ?User
{
    return is_int($id) ? User::find($id) : User::firstWhere('slug', $id);
}

Що варто знати:

  • Union-тип змушує код усередині розрізняти варіанти (is_int, instanceof). Якщо гілок стає багато, це сигнал, що функція робить дві різні речі.
  • mixed не означає «тип не вказали»: він дозволяє все і забирає в аналізатора можливість допомогти. Використовуйте його лише там, де значення справді довільне - наприклад, у кеші.
  • Під declare(strict_types=1) скалярні типи не приводяться: findUser('5') передасть рядок, а не число.
  • PHP 8.2 дозволив і DNF-типи: (A&B)|null.

Докладніше в документації: Оголошення типів

finally виконується завжди: після успішного try, після обробленого винятку, після необробленого і навіть після return у try чи catch. Це місце для прибирання - закрити файл, зняти блокування, відкотити стан.

$lock = Cache::lock('import', 60);
$lock->block(5);

try {
    $this->import();
} finally {
    $lock->release(); // і після помилки теж
}

Пастка з return: якщо finally сам робить return, він перекриває все попереднє - і значення з try, і виняток, що летів:

function f(): string
{
    try {
        throw new RuntimeException('збій');
    } finally {
        return 'ok'; // виняток загублено без сліду
    }
}

Тому в finally не повертають значень і не кидають нових винятків - лише прибирають.

Альтернатива в багатьох випадках - готові обгортки, які роблять try/finally за вас: $lock->get(fn () => ...), DB::transaction(fn () => ...).

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

У PHP є дві системи повідомлень про проблеми: винятки (throw) і помилки рушія - E_WARNING, E_NOTICE, E_DEPRECATED. Попередження за замовчуванням лише пишеться в лог, а виконання продовжується - часто з неправильними даними:

$content = file_get_contents('/missing.txt');   // Warning, $content === false
$data = json_decode($content);                  // працюємо далі з false

set_error_handler перехоплює такі помилки. Найпоширеніший прийом - перетворити їх на ErrorException:

set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
    if (! (error_reporting() & $severity)) {
        return false;   // помилку придушено через @ чи налаштування
    }

    throw new ErrorException($message, 0, $severity, $file, $line);
});

Тепер file_get_contents для неіснуючого файлу кидає виняток, і код не піде далі з false.

Laravel робить це за вас: обробник помилок фреймворку (HandleExceptions) перетворює попередження й notice на ErrorException. Тому в Laravel-застосунку «Undefined array key» - це виняток зі стек-трейсом, а не рядок у лозі. Застарілі (E_DEPRECATED) Laravel на винятки не перетворює, а пише в окремий канал логів, якщо його налаштовано (LOG_DEPRECATIONS_CHANNEL).

Що варто знати:

  • Фатальні помилки (E_ERROR, вичерпання пам'яті) set_error_handler не перехоплює. Для них - register_shutdown_function() з error_get_last().
  • Оператор @ придушує попередження, але приховує й справжні проблеми. Краще перевіряти результат функції явно.
  • Сучасні функції часто вже кидають винятки: json_decode(..., flags: JSON_THROW_ON_ERROR), random_int(), більшість методів класів SPL і intl.

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

?-> (PHP 8.0) звертається до методу чи властивості, лише якщо об'єкт ліворуч не null. Якщо він null, весь ланцюжок повертає null і далі не виконується (short-circuit).

// До PHP 8
$country = null;
if ($session !== null) {
    $user = $session->user();
    if ($user !== null && $user->address !== null) {
        $country = $user->address->country;
    }
}

// PHP 8
$country = $session?->user()?->address?->country;

Short-circuit важливий: якщо $session - null, метод user() навіть не викликається, а аргументи далі по ланцюжку не обчислюються.

Обмеження:

  • Лише читання. $user?->name = 'Оля' - помилка компіляції: присвоювати через ?-> не можна.
  • Не працює з масивами: $data?['key'] такого синтаксису немає. Для масивів - ??.
  • Не для посилань: foo($obj?->prop) у параметр за посиланням не передати.
  • null - не «помилка». Якщо об'єкт мав бути там завжди, ?-> ховає баг: замість зрозумілого винятку код тихо працює з null.

Разом з ?? дає значення за замовчуванням:

$city = $order->customer?->address?->city ?? 'Не вказано';

Порада: ?-> - для справді необов'язкових зв'язків (у користувача може не бути профілю). Довгі ланцюжки ?-> - запах: код занадто багато знає про внутрішню будову інших об'єктів, і варто подумати про метод на кшталт $order->shippingCity().

Докладніше в документації: Nullsafe-оператор

Мета - щоб код, який використовує модуль, міг зловити саме те, що вміє обробити, і нічого зайвого.

Типова структура:

interface PaymentException extends Throwable {}

final class CardDeclined extends RuntimeException implements PaymentException
{
    public static function forReason(string $reason): self
    {
        return new self("Картку відхилено: {$reason}");
    }
}

final class GatewayUnavailable extends RuntimeException implements PaymentException {}

Принципи:

  • Маркерний інтерфейс модуля (PaymentException) дозволяє зловити все з модуля одним catch, не прив'язуючись до ієрархії класів.
  • Успадкування від SPL-винятків (RuntimeException, InvalidArgumentException, LogicException) зберігає зміст: LogicException - помилка програміста, RuntimeException - обставини під час виконання.
  • Іменовані конструктори (forReason()) тримають формулювання повідомлень в одному місці.
  • Контекст як властивості, а не лише в тексті: $e->orderId можна записати в лог чи показати користувачу без розбору рядка.
  • Ланцюжок причин: загортаючи чужий виняток, передавайте його як previous - інакше в логах загубиться справжня причина.

Чого уникати: винятків для звичайного керування потоком (користувач не знайдений - часто це null, а не виняток) і одного «універсального» AppException на все.

Докладніше в документації: Розширення винятків

  • void (PHP 7.1) - функція нічого не повертає. return; дозволено, return null; - ні.
  • never (PHP 8.1) - функція ніколи не повертає керування: завжди кидає виняток, завершує скрипт чи працює нескінченно.
  • static (PHP 8.0) - повертає екземпляр класу, через який метод викликали (а не того, де він оголошений).
function abortNotFound(): never
{
    throw new NotFoundHttpException();
}

function process(?Order $order): void
{
    $order ?? abortNotFound();
    $order->ship(); // аналізатор знає: тут $order уже не null
}

Навіщо це:

  • never дає статичному аналізатору й IDE зрозуміти, що код після виклику недосяжний. Так звужується тип, і зникають хибні попередження «можливий null».
  • static потрібен для fluent-інтерфейсів і фабрик у базових класах: Builder::where() у нащадку повертає нащадка, а не базовий клас.
  • void чесно документує, що результат не варто використовувати.

Усі три перевіряються рушієм: функція з never, яка все ж завершилася, кине TypeError.

Докладніше в документації: Тип never

PHP 8 переробив правила приведення рядків до чисел, зробивши їх суворішими й передбачуванішими.

Три види рядків:

  • Числовий - лише число, з можливими пробілами на початку й у кінці: '42', ' 3.14 ', '1e3'.
  • З числовим початком - число, а за ним інші символи: '5 apples'.
  • Нечисловий - усе інше: 'abc', ''.

В арифметиці:

'42' + 1;          // 43
'5 apples' + 1;    // 6 і Warning: A non-numeric value encountered
'abc' + 1;         // TypeError

У PHP 7 'abc' + 1 давав 1 з попередженням - помилка тихо перетворювалася на нуль. Тепер це виняток.

У порівнянні ==:

  • два числові рядки порівнюються як числа: '1e3' == '1000' - true, '10' == '010' - true;
  • число з числовим рядком - як числа;
  • число з нечисловим рядком - як рядки: 0 == 'abc' - false у PHP 8 (у PHP 7 - true, бо 'abc' ставав нулем).

Під declare(strict_types=1) скалярні параметри функцій не приводяться взагалі: int $id не прийме '5' - буде TypeError. Арифметичних операторів і порівнянь strict_types не стосується.

Практичні висновки:

  • Дані з форм і запитів - завжди рядки. Приводьте їх явно й валідуйте ((int), filter_var($v, FILTER_VALIDATE_INT), правила валідації Laravel), а не покладайтеся на неявне приведення.
  • === замість ==, особливо для значень від користувача: «магічні хеші» на кшталт '0e123' == '0e456' (обидва - нуль у науковій нотації) досі true.
  • Для перевірки «чи це число» - is_numeric() (приймає числові рядки) чи ctype_digit() (лише цифри, без знаку й крапки).

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

Intersection-тип A&B (PHP 8.1) - значення має відповідати всім перелічених типам одночасно. На практиці - об'єкт, що реалізує кілька інтерфейсів:

function process(Countable&Traversable $items): void
{
    echo count($items);
    foreach ($items as $item) { /* ... */ }
}

Раніше доводилося або обирати один інтерфейс і перевіряти другий вручну (instanceof), або створювати штучний інтерфейс CountableTraversable, який нічого не додає.

DNF-типи (Disjunctive Normal Form, PHP 8.2) - поєднання union і intersection: об'єднання груп, кожна з яких - перетин.

function save((Countable&ArrayAccess)|null $data): void {}
function cache((Stringable&JsonSerializable)|string $value): void {}

Правило DNF: перетини мають бути в дужках, і вони об'єднуються через |. Запис A&(B|C) недозволений - його треба переписати як (A&B)|(A&C).

Обмеження:

  • Перетин - лише з класів і інтерфейсів, не зі скалярів (int&string не має сенсу).
  • Дублювання й надлишкові типи ((A&B)|A) - помилка компіляції.

Коли потрібні:

  • Функція справді покладається на кілька незалежних можливостей об'єкта (лічильність і ітерація, рядкове представлення й серіалізація).
  • Бібліотеки з дрібними інтерфейсами-можливостями (принцип розділення інтерфейсів) - щоб вимагати рівно потрібні, а не один «товстий».
  • Моки в тестах: PHPUnit створює об'єкти-перетини MockObject&Service, і саме такий тип має змінна з моком.

Альтернатива: якщо одна й та сама комбінація інтерфейсів повторюється скрізь, можливо, це окреме поняття домену - і варто оголосити інтерфейс, що розширює обидва.

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