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

Питання на співбесіді: Масиви й рядки

Питання з реальних співбесід з відповідями: 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', що інколи несподівано.

Жодна з функцій не змінює початковий масив - усі повертають новий.

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

Рядок у 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_*.

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

Усі три перевіряють «чи є значення», але з різною семантикою:

  • 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() перевіряє, чи оголошена властивість у класі.

Докладніше в документації: array_key_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();

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

Обидва об'єднують масиви, але по-різному поводяться з однаковими ключами:

  • $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.

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

Через 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']]).

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

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.

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

Деструктуризація - розпакування масиву в окремі змінні (короткий синтаксис замість 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.

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

Універсального «очищення» не існує: екранування залежить від того, куди потрапляє рядок. Тому дані зберігають як є, а екранують у момент виведення - під конкретний контекст.

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.

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

Ключ масиву в 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.

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