Питання на співбесіді: Типи й помилки
Питання з реальних співбесід з відповідями: 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 з'явився саме для того, щоб їх можна було перехопити.
$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 ?? 'Невідомо'.
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) попередить ще до запуску.
- 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.
?-> (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().
Мета - щоб код, який використовує модуль, міг зловити саме те, що вміє обробити, і нічого зайвого.
Типова структура:
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.
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, і саме такий тип має змінна з моком.
Альтернатива: якщо одна й та сама комбінація інтерфейсів повторюється скрізь, можливо, це окреме поняття домену - і варто оголосити інтерфейс, що розширює обидва.