Питання на співбесіді: Масиви й рядки
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
12 питань
Усі три обходять масив і викликають функцію для кожного елемента, але повертають різне:
array_map- новий масив тієї ж довжини, кожен елемент перетворено.array_filter- лише ті елементи, для яких функція повернулаtrue. Ключі зберігаються.array_reduce- одне значення, накопичене з усіх елементів.
$prices = [100, 250, 40];
array_map(fn (int $p): int => $p * 2, $prices); // [200, 500, 80]
array_filter($prices, fn (int $p): bool => $p > 50); // [0 => 100, 1 => 250]
array_reduce($prices, fn (int $sum, int $p): int => $sum + $p, 0); // 390
Пастки, про які питають:
- Порядок аргументів різний: у
array_mapспершу функція, уarray_filter- масив. Плутають постійно. - Після
array_filterключі «дірчасті» ([0, 2, 5]), іjson_encodeперетворить такий масив на об'єкт. Допомагаєarray_values(). array_filterбез функції прибирає всі «порожні» значення - разом із0і'0', що інколи несподівано.
Жодна з функцій не змінює початковий масив - усі повертають новий.
Рядок у PHP - це послідовність байтів, а не символів. strlen() рахує байти. У UTF-8 кожна кирилична літера займає 2 байти, тож «привіт» - 12 байтів.
strlen('привіт'); // 12
mb_strlen('привіт'); // 6
mb_substr('привіт', 0, 3); // 'при'
substr('привіт', 0, 3); // зламаний символ: 'п' і половина 'р'
Правило: для тексту, де можуть бути не лише латинські літери, використовуйте mb_*-функції: mb_strlen, mb_substr, mb_strtoupper, mb_str_split. Байтові функції зріжуть символ посередині, і в результаті з'явиться «�».
Нюанси:
mb_*рахують кодові точки Unicode. Емодзі з модифікатором шкіри чи прапор складаються з кількох точок, і для «видимих символів» потрібенgrapheme_strlen()з розширення intl.strlen()не помилка - він потрібен, коли важливі саме байти: розмір тіла запиту, обмеження колонки в байтах.- У Laravel
Str::length(),Str::limit()уже працюють черезmb_*.
Усі три перевіряють «чи є значення», але з різною семантикою:
isset($a['key'])- ключ існує і значення неnull.array_key_exists('key', $a)- ключ існує, хай навіть зі значеннямnull.empty($a['key'])- ключа немає або значення «порожнє»:null,false,0,0.0,'','0',[].
$data = ['name' => '', 'phone' => null, 'age' => 0];
isset($data['phone']); // false - значення null
array_key_exists('phone', $data); // true - ключ є
empty($data['age']); // true - 0 вважається порожнім
empty($data['name']); // true
isset($data['missing']); // false, без попередження
Де це важливо:
- PATCH-запит до API:
{"phone": null}означає «очистити телефон», а відсутній ключ - «не чіпати». Розрізнити допоможе лишеarray_key_exists(у Laravel -$request->has('phone')проти$request->filled('phone')). empty('0')-true. Рядок «0» у формі (кількість, рейтинг) стане «порожнім». Для чисел краще явні перевірки.- Вкладені ключі:
isset($a['user']['address']['city'])безпечно перевіряє весь ланцюжок без попереджень.
isset і empty - мовні конструкції, а не функції: не кидають попереджень на неіснуючих змінних і ключах. Через це їх легко використати для приховування опечаток - isset($usr) мовчки поверне false.
Для об'єктів: isset($obj->prop) для неініціалізованої типізованої властивості - false; property_exists() перевіряє, чи оголошена властивість у класі.
Основні функції:
explode(',', 'php,laravel,vue'); // ['php', 'laravel', 'vue']
explode(',', 'a,b,c,d', 2); // ['a', 'b,c,d'] - не більше 2 частин
implode(', ', ['php', 'laravel']); // 'php, laravel'
str_split('abcdef', 2); // ['ab', 'cd', 'ef'] - байтами!
mb_str_split('привіт', 2); // ['пр', 'ив', 'іт'] - символами
preg_split('/[\s,;]+/', 'php, laravel; vue', -1, PREG_SPLIT_NO_EMPTY);
// ['php', 'laravel', 'vue'] - кілька роздільників
Типові пастки:
- Порожній рядок:
explode(',', '')повертає['']- масив з одним порожнім елементом, а не порожній масив. Для введення користувача часто потрібнеarray_filter(array_map('trim', explode(',', $input))). - Пробіли:
explode(',', 'a, b')дає['a', ' b']- з пробілом. Звідсиarray_map('trim', ...). - Кирилиця:
str_splitділить байти й ламає багатобайтові символи. Для тексту -mb_str_split. - CSV не розбирають через
explode: значення можуть містити коми в лапках ("Київ, Україна"). Для цьогоstr_getcsv()чиSplFileObjectзREAD_CSV.
У Laravel зручніше через Str і колекції:
Str::of('php, laravel , vue')->explode(',')->map(fn ($s) => trim($s))->filter();
Обидва об'єднують масиви, але по-різному поводяться з однаковими ключами:
$a + $b- бере все з$a, а з$bдодає лише ключі, яких у$aнемає. Перемагає лівий.array_merge($a, $b)- для рядкових ключів перемагає правий, а числові ключі перенумеровує з нуля й дописує в кінець.
$defaults = ['color' => 'red', 'size' => 'M'];
$options = ['color' => 'blue'];
$options + $defaults; // ['color' => 'blue', 'size' => 'M']
array_merge($defaults, $options); // ['color' => 'blue', 'size' => 'M']
[0 => 'a', 1 => 'b'] + [0 => 'c', 1 => 'd', 2 => 'e']; // ['a', 'b', 'e']
array_merge(['a', 'b'], ['c', 'd', 'e']); // ['a', 'b', 'c', 'd', 'e']
Коли що:
+- накласти значення за замовчуванням: «мої опції, а чого бракує - з дефолтів».array_mergeабо spread[...$a, ...$b]- склеїти списки.
Пастка: числові ключі, які насправді ідентифікатори ([15 => 'Київ']), array_merge перенумерує й загубить. Для таких масивів потрібен + або array_replace.
Через usort і оператор <=> («космічний корабель»), який повертає -1, 0 або 1. Масиви теж можна порівнювати через <=> - поелементно, зліва направо, і це дає сортування за кількома полями одним рядком:
usort($users, fn (array $a, array $b): int =>
[$b['score'], $a['name']] <=> [$a['score'], $b['name']]
);
Тут спершу за score за спаданням (тому $b і $a поміняні місцями), а за однакового рахунку - за name за зростанням.
Що ще варто знати:
usortскидає ключі. Щоб зберегти їх, потрібенuasort.- З PHP 8.0 сортування стабільне: рівні за критерієм елементи лишаються в початковому порядку. На цьому можна будувати сортування в кілька проходів.
- Рядки
<=>порівнює побайтово, тож «Ярослав» і «Андрій» стануть не за абеткою. Для тексту людською мовою -Collatorз розширення intl.
У Laravel колекції роблять те саме читабельніше: collect($users)->sortBy([['score', 'desc'], ['name', 'asc']]).
PHP використовує бібліотеку PCRE: функції preg_* приймають шаблон з роздільниками (/.../, ~...~, #...#) і модифікаторами після них.
preg_match('/^\d{4}-\d{2}-\d{2}$/', $date); // 1, 0 або false при помилці
preg_match('/(?<year>\d{4})-(?<month>\d{2})/', $s, $m); // $m['year'], $m['month']
preg_match_all('/#(\w+)/u', $text, $matches); // усі хештеги
preg_replace('/\s+/', ' ', $text); // стиснути пробіли
preg_replace_callback('/\d+/', fn ($m) => $m[0] * 2, $s);
preg_split('/[,;]\s*/', $list);
Модифікатор u вмикає режим UTF-8:
- шаблон і рядок обробляються як UTF-8, а не як байти;
.відповідає одному символу, а не байту;- працюють Unicode-класи:
\p{L}(будь-яка літера),\p{Lu}(велика),\p{Cyrillic}.
Без u шаблон /^.{3}$/ для «кіт» не спрацює (там 6 байтів), а \w не вважає кирилицю літерами.
preg_match('/^[\p{L}\s\'-]+$/u', "Мар'яна Іваненко"); // 1
Інші корисні модифікатори: i - без урахування регістру, m - ^/$ для кожного рядка, s - . охоплює й переведення рядка, x - пробіли й коментарі в шаблоні для читабельності.
Пастки:
- Помилка ≠ збіг:
preg_matchповертаєfalseпри помилці (зламаний UTF-8 у рядку зu, перевищення ліміту backtracking). Причина - уpreg_last_error_msg(). - Катастрофічний backtracking: шаблони на кшталт
(a+)+$на зловмисному введенні виконуються експоненційно довго (ReDoS). Уникайте вкладених квантифікаторів. - Екранування введення користувача в шаблоні - через
preg_quote($input, '/'). - Для простих випадків швидші й зрозуміліші
str_contains,str_starts_with,str_replace.
Деструктуризація - розпакування масиву в окремі змінні (короткий синтаксис замість list()):
[$first, $second] = [10, 20];
[, $second] = [10, 20]; // пропустити елемент
['id' => $id, 'name' => $name] = $user; // за ключами
[$a, [$b, $c]] = [1, [2, 3]]; // вкладене
[$a, $b] = [$b, $a]; // обмін значеннями
foreach ($rows as ['id' => $id, 'email' => $email]) {
// ...
}
Якщо ключа немає, змінна отримає null з попередженням.
Spread-оператор ... розпаковує масив у місці виклику чи в літералі масиву:
$all = [...$defaults, ...$overrides]; // склеїти масиви
$merged = [...['a' => 1], ...['a' => 2, 'b' => 3]]; // ['a' => 2, 'b' => 3] (PHP 8.1+)
function sum(int ...$numbers): int // збір аргументів (variadic)
{
return array_sum($numbers);
}
sum(...[1, 2, 3]); // передача масиву як аргументів
Що варто знати:
- Рядкові ключі в spread дозволені з PHP 8.1 і поводяться як
array_merge: пізніший ключ перемагає. Числові ключі перенумеровуються. - Spread ітератора:
[...$generator]працює з будь-якимTraversable. - Іменовані аргументи зі spread:
create(...['name' => 'Оля', 'email' => '...'])(PHP 8.1+) - ключі масиву стають іменами параметрів. - Не для великих масивів у циклі:
$result = [...$result, $item]у циклі щоразу копіює весь масив - квадратична складність. Для накопичення -$result[] = $item.
Масиви в PHP передаються за значенням, але копіюються ліниво - за принципом copy-on-write. Присвоєння чи передача в функцію не копіює дані, а лише збільшує лічильник посилань. Справжня копія з'являється в момент, коли одну з «копій» змінюють.
$a = range(1, 1_000_000);
$b = $a; // пам'ять не виросла: обидві змінні дивляться на ті самі дані
$b[] = 1; // тепер копія - і ще ~кілька десятків МБ
Що з цього випливає:
- Передавати великий масив у функцію дешево, поки функція його не змінює. Передача за посиланням (
&$items) «для швидкодії» зазвичай нічого не дає. - Посилання можуть, навпаки, спричинити копіювання: змішування посилань і звичайних змінних на ті самі дані змушує PHP розділяти масив раніше.
- Класична пастка -
foreach ($items as &$item)безunset($item)після циклу: змінна лишається посиланням на останній елемент, і наступнийforeachза значенням його перезапише.
Об'єкти поводяться інакше: змінна зберігає ідентифікатор об'єкта, тож передача в функцію дає доступ до того самого об'єкта. Для копії потрібен clone.
=== порівнює рядки посимвольно і зупиняється на першій розбіжності. Отже, час порівняння залежить від того, скільки перших символів збіглося. Вимірюючи час відповіді на багатьох запитах, нападник може підбирати секрет символ за символом - це timing-атака.
hash_equals() порівнює рядки за сталий час: завжди проходить усю довжину, незалежно від того, де розбіжність.
// Перевірка підпису вебхука
$expected = hash_hmac('sha256', $payload, $secret);
if (! hash_equals($expected, $request->header('X-Signature'))) {
abort(403);
}
Де це потрібно: скрізь, де рядок від користувача порівнюється із секретом - підписи вебхуків, API-токени, коди підтвердження, CSRF-токени.
Що варто знати:
- Першим аргументом іде відомий рядок, другим - отриманий від користувача.
- Довжину
hash_equalsне приховує: рядки різної довжини даютьfalseодразу. Тому порівнюють хеші чи HMAC фіксованої довжини, а не сирі секрети. - Для паролів не потрібен ні
===, ніhash_equals:password_verify()уже порівнює безпечно.
Laravel використовує hash_equals усередині - наприклад, у перевірці CSRF-токена й підписаних URL.
Універсального «очищення» не існує: екранування залежить від того, куди потрапляє рядок. Тому дані зберігають як є, а екранують у момент виведення - під конкретний контекст.
HTML (вміст і атрибути):
echo htmlspecialchars($comment, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
ENT_QUOTES екранує обидва типи лапок (важливо для атрибутів), ENT_SUBSTITUTE замінює зламаний UTF-8 замість повернення порожнього рядка. Blade {{ }} робить саме це.
Але HTML-екранування не захищає атрибут href: javascript:alert(1) пройде без змін. URL від користувача перевіряють за схемою (http/https).
SQL - не екранувати, а прив'язувати параметри:
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);
addslashes для SQL небезпечний (кодування, інші СУБД), а ручне екранування легко забути.
URL:
$url = 'https://example.com/search?' . http_build_query(['q' => $query, 'page' => 2]);
rawurlencode($pathSegment); // для частини шляху
Командний рядок:
exec('convert ' . escapeshellarg($input) . ' ' . escapeshellarg($output));
Ще краще - не будувати рядок команди взагалі, а передати аргументи масивом (Symfony Process, Process::run([...]) у Laravel), тоді оболонка не бере участі.
JavaScript усередині HTML:
<script>const user = <?= json_encode($user, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;</script>
У Blade - @json($user) чи Js::from($user).
Правило: знати контекст виведення і використовувати засіб саме для нього. Рядок, безпечний для HTML, може бути небезпечним у SQL чи shell.
Ключ масиву в PHP може бути лише int або string. Усе інше приводиться, і деякі рядки теж:
- Рядок з десятковим цілим числом (
'1','-5') стає цілим:'1'і1- один ключ. - Рядок з ведучим нулем чи пробілом (
'01',' 1') лишається рядком. floatобрізається до цілого:1.7→1(з PHP 8.1 - з попередженням про втрату точності).boolстає0або1.nullстає порожнім рядком''.- Масиви й об'єкти як ключі - помилка (
TypeError), зокрема й enum.
$a = ['1' => 'a', 1 => 'b', true => 'c', '01' => 'd'];
// [1 => 'c', '01' => 'd'] - перші три записи перезаписали один одного
Де це кусає:
- Ідентифікатори-рядки: масив з ключами
'007'і'7'- два ключі, а з'7'і7- один. array_mergeперенумеровує числові ключі. Масив[10 => 'Київ', 20 => 'Львів'], де ключі - ID міст, післяarray_mergeстане[0 => 'Київ', 1 => 'Львів']. Рядкові ключі, схожі на числа, - теж числові, тож проблема та сама.- JSON:
json_encodeмасиву з ключами0, 1, 2дає масив[...], а з «дірками» (0, 2) - об'єкт{"0":..,"2":..}. Клієнт отримає інший тип даних. in_array/array_searchбез третього параметраtrueпорівнюють через==.
Порада: для словників зі «справжніми» рядковими ключами, де ці правила заважають, - SplObjectStorage, Map з ds-розширення чи колекції з явними ключами; для ID - не покладатися на перенумерацію і використовувати + чи array_replace замість array_merge.