- Регистрация
- 23 Авг 2023
- Сообщения
- 3,969
- Реакции
- 0
- Баллы
- 36
Ofline
Я почти никогда не использовал switch. Мне всегда казалось, что он ведёт себя непредсказуемо - без break код молча проваливается в следующую ветку. Нестрогое сравнение добавляет сюрпризов. В общем, я писал if/else и не парился.
Но однажды, когда-то давно, мне стало интересно: а что, если switch не просто «другой синтаксис», а реально работает иначе под капотом? Я полез в opcodes и обнаружил кое-что, что изменило моё отношение ко всем конструкциям.
Давайте разберёмся вместе. Возьмём простую задачу: определить размер скидки по статусу клиента. Вот три способа её решить:
Все три варианта дают одинаковый результат. Но одинаково ли работает php под капотом, когда исполняет этот код? Спойлер: нет. И разница весьма интересна.
Чтобы увидеть её, нужно заглянуть в opcodes - внутренние инструкции, которые PHP-движок реально исполняет.
Когда вы запускаете PHP-скрипт, происходит следующее: исходный код - компилятор - набор opcodes - Zend VM исполняет их по одному. if превращается в конкретные инструкции: сравнить, прыгнуть, присвоить.
Как посмотреть opcodes самому
Два способа - оба встроены в PHP, ничего ставить не нужно:
Все opcodes в этой статье получены через phpdbg на PHP 8.1 - это то, что генерирует компилятор напрямую, без оптимизаций OPcache. В продакшене OPcache может убрать часть инструкций, но логика остаётся той же.
А теперь давайте посмотрим, во что превращается каждая из трёх конструкций. Я буду показывать упрощённый вывод opcodes - без временных переменных и номеров строк, чтобы не отвлекаться от сути.
if/else: линейный перебор
Вот наш пример:
А вот во что его превращает компилятор:
Паттерн виден сразу: IS_IDENTICAL + JMPZ повторяется для каждой ветки. Это линейный перебор - проверка условий одно за другим, сверху вниз.
- Если $status === 'gold' - одна проверка, и мы в нужной ветке.
- Если $status === 'bronze' - три проверки, прежде чем найдём совпадение.
- Если ни одно не совпало - пройдём все проверки и попадём в else.
Сложность поиска: O(N), где N - количество веток. Чем больше elseif, тем больше сравнений в худшем случае.
Вложенные if/else - прыжки множатся
Когда условия вложены друг в друга, количество переходов растёт быстро. Допустим, скидка зависит и от статуса, и от суммы заказа:
В opcodes:
19 инструкций вместо 14 - и это всего два уровня вложенности. Каждый новый if внутри if добавляет свою пару IS_IDENTICAL/IS_SMALLER + JMPZ + JMP. Код растёт не линейно - при N уровнях вложенности и M ветках получаем порядка N × M переходов.
switch/case: хеш-таблица вместо перебора
Теперь тот же пример через switch:
Opcodes:
Обратите внимание на самую первую инструкцию - SWITCH_STRING. Здесь происходит магия, которая отличает switch от if/else.
Как это работает:
Представьте, что вы пришли в гардероб и отдали куртку. Вам дали номерок - скажем, 47. Когда вы возвращаетесь, гардеробщик не перебирает все 200 курток по очереди. Он смотрит на номерок и сразу идёт к нужной вешалке.
"gold" → переход к инструкции 0009
"silver" → переход к инструкции 0011
"bronze" → переход к инструкции 0013
default → переход к инструкции 0015
Когда Zend VM доходит до SWITCH_STRING, она берёт значение $status, вычисляет его хеш, и за одну операцию получает номер инструкции, куда нужно прыгнуть. Никакого перебора. Не важно, 3 у вас case или 30 - поиск занимает O(1) в среднем.
Да, формально хеш-таблица - это «амортизированный O(1)». Если у двух ключей совпадут хеши (коллизия), Zend Engine пойдёт по связанному списку, и поиск деградирует (раньше на Хабре я публиковал разбор). Но на практике с 3–30 строковыми ключами в case вероятность коллизий ничтожна - PHP использует хорошую хеш-функцию, и для типичных switch это действительно один поиск.
Ещё один вопрос: а сколько памяти жрёт эта хеш-таблица? Она создаётся один раз при компиляции и хранится в скомпилированном opcache. Для наших трёх case это порядка 200–300 байт - массив из нескольких указателей. Даже при 30 ветках это килобайт-другой. Цена минимальна.
Сравните с if/else, где при 30 ветках в худшем случае нужно выполнить 30 сравнений. Здесь - одно. Одна операция - и мы в нужной ветке, минуя все остальные.
А что за IS_EQUAL ниже?
Инструкции 0002–0008 - это fallback-путь, запасной линейный перебор. Он нужен на случай, когда хеш-таблица не может дать точный ответ (например, из-за неявного приведения типов - об этом чуть ниже). В обычном сценарии со строками SWITCH_STRING срабатывает сразу, и весь fallback-блок пропускается.
Для целочисленных значений существует аналогичный опкод SWITCH_LONG.
break под капотом
Посмотрите на инструкции 0010, 0012, 0014 - это JMP → 0016. Каждый break - это просто JMP (безусловный прыжок) на адрес после switch-блока. Всё.
А что если убрать break? Тогда JMP не генерируется. Zend VM просто шагает на следующую инструкцию, потому что ей всё равно что там «другой case». VM ходит по opcodes как по рельсам - case-метки для неё не существуют, это точки входа для SWITCH_STRING, а не границы блоков.
Представьте конвейерную ленту на почте. Посылки едут одна за другой. Каждый case - это работник, который стоит у ленты и ждёт свою посылку. break - это когда работник снимает посылку с ленты и уходит. Если он этого не сделает - посылка проедет мимо и попадёт к следующему работнику, а тот тоже что-то с ней сделает.
VM работает точно так же: opcodes - это лента, инструкции выполняются по порядку. break (JMP) снимает выполнение с ленты и перебрасывает в конец. Без break - выполнение просто катится дальше. Fall-through - это не специальное поведение, это отсутствие поведения.
И вот что из этого следует - вещь, которую многие упускают:
default тоже «на ленте»
default - это не какая-то магическая конструкция. Это просто ещё один блок кода, который физически расположен после всех case в opcodes. Посмотрите на наш пример: default стоит на позиции 0015, а последний case 'bronze' - на 0013.
Если сработал case 'bronze' и нет break - VM дойдёт до инструкции 0013 (тело bronze), выполнит её, и пойдёт дальше на 0015 - прямо в default. Не потому что «ничего не совпало», а просто потому что default - следующий по коду. Нет JMP - нет выхода.
Именно поэтому я долго избегал switch - легко получить ситуацию, когда default выполняется даже при совпадении с одним из case, просто из-за забытого break.
Когда default работает как задумано:
- Ни один case не совпал → хеш-таблица SWITCH_STRING направляет VM прямо в default
- case совпал, break стоит → JMP перепрыгивает default, VM его даже не увидит
Если default отсутствует, а ни один case не совпал, то весь switch пропускается, и выполнение продолжается с первой инструкции после блока.
switch(true): когда хеш-таблица невозможна
switch умеет работать с выражениями в case. Но вот загвоздка:
Opcodes:
Заметили? Никакого SWITCH_STRING. Компилятор не может построить хеш-таблицу из выражений $amount > 5000 - значения неизвестны на этапе компиляции. Вместо этого генерируется цепочка IS_SMALLER + JMPNZ - фактически тот же линейный перебор, что и у if/else.
Вывод: switch(true) с выражениями в case - это if/else в обёртке switch. Хеш-таблицы нет. Но небольшой выигрыш всё же есть, о нем ниже.
match(true): строгий аналог switch(true)
Раз уж мы разобрали switch(true), логичный вопрос: а что будет с match(true)?
Opcodes:
Обратите внимание: для каждой ветки две инструкции вместо одной - сначала IS_SMALLER, потом IS_IDENTICAL ... true. match не доверяет результату сравнения «на слово» - он дополнительно проверяет, что результат строго равен true. У switch(true) была одна IS_SMALLER + JMPNZ, здесь - на инструкцию больше на каждую ветку.
Вывод: match(true) с выражениями - это самый безопасный, но и самый «дорогой» вариант линейного перебора. На практике разница в наносекундах, но для полноты картины важно знать.
Неявное приведение типов
switch использует нестрогое сравнение (==). В fallback-пути нашего примера (инструкции 0002–0007) это видно явно - там стоит IS_EQUAL, а не IS_IDENTICAL.
Вот классический пример сюрприза:
В PHP 7 этот код выведет «Строка abc!», потому что 0 == 'abc' - это true при нестрогом сравнении (строка приводится к числу 0). В PHP 8 поведение изменилось: сравнение 0 == 'abc' теперь возвращает false, и код выведет «Ноль». Но другие неочевидные сравнения в switch никуда не делись: например, 0 == '0' по-прежнему true.
Ещё один повод, по которому я долго обходил switch стороной. Если нужно строгое сравнение без сюрпризов - используйте match.
match: строгий, компактный, выразительный
Теперь match - конструкция, появившаяся в PHP 8.0:
Opcodes:
Первое, что бросается в глаза: нет fallback-цепочки. У switch после SWITCH_STRING шёл запасной линейный перебор с IS_EQUAL. У match - только MATCH и сразу ветки. Почему компилятор обходится без подстраховки:
1. Строгое сравнение (
2. Нет fall-through - каждая ветка заканчивается JMP на итоговый ASSIGN. Это не вопрос дисциплины программиста (как break в switch), а гарантия компилятора.
Второе отличие: match - это выражение. Он возвращает значение, поэтому в opcodes вместо ASSIGN $discount, 30 стоит QM_ASSIGN во временную переменную T4, а уже потом T4 присваивается в $discount (инструкция 0010). Один ASSIGN вместо трёх-четырёх - код компактнее.
UnhandledMatchError
Что если ни одна ветка не совпала и нет default?
В отличие от switch, который молча пропускает весь блок, match выбрасывает \UnhandledMatchError. В opcodes это видно как MATCH_ERROR - инструкция, которая генерирует throw, если хеш-таблица не нашла совпадения. Это делает match безопаснее: вы не можете случайно забыть обработать вариант.
Несколько значений в одном arm
В opcodes оба значения просто добавляются в хеш-таблицу MATCH с одним и тем же адресом перехода:
Никакой дополнительной логики - два ключа в хеш-таблице ведут в одну точку. Стоимость поиска остаётся O(1).
Сравним операторы
switch(true) выделен отдельно, потому что под капотом это совсем другой механизм - по сути if/else в обёртке switch, без хеш-таблицы.
Бенчмарки
Теория - это хорошо, но я решил измерить. PHP 8.1, без расширений (php -n), 5 прогонов по 5 000 000 итераций, медиана.
4 ветки (строковые значения)
При 4 ветках switch и match примерно в 1.4 раза быстрее за счёт хеш-таблицы.
10 веток (строковые значения)
При 10 ветках картина нагляднее - switch и match в 2.5 раза быстрее if/else. Хеш-таблица не зависит от количества элементов, а if/else линейно деградирует.
А что с switch(true)?
Отдельно я замерил switch(true) с выражениями (>) против эквивалентного if/else - оба на 10 ветках, 5 прогонов по 5 000 000 итераций:
Я ожидал одинаковые числа - ведь мы выяснили, что switch(true) генерирует тот же линейный перебор. Но switch(true) на ~18% быстрее. Почему?
Если посмотреть в opcodes внимательнее, разница есть. В if/else проверки и тела веток чередуются: проверил - выполнил - проверил - выполнил. В switch(true) компилятор разделяет их: сначала идут все проверки (0001–0007), потом все тела (0008–0014). Такая компоновка лучше ложится в кэш процессора - блок проверок выполняется последовательно, без прыжков в разные участки памяти.
Так что даже без хеш-таблицы switch имеет небольшое структурное преимущество. Но, конечно, до полноценного switch с SWITCH_STRING ему далеко.
В реальном продакшен-коде все эти различия растворяются в I/O, запросах к базе и сетевых задержках. Но если вы пишете hot path - парсер, маршрутизатор, обработчик событий - знание о том, что происходит под капотом, может пригодиться.
А как же тернарный оператор?
Справедливый вопрос: ?: и ?? - тоже условные конструкции. Почему их нет в статье?
Тернарный оператор $x ? $a : $b генерирует тот же JMPZ/JMPNZ, что и if/else - это по сути синтаксический сахар для простого ветвления. А null coalescing $x ?? $default использует специальный опкод COALESCE (проверка на null в одну инструкцию). Оба интересны, но это одиночные ветвления - у них нет той разницы между O(N) и O(1), которая делает сравнение if/else vs switch/match таким наглядным.
Когда что использовать
После всего этого я пересмотрел свои привычки. Вот к чему пришёл:
- if/else - когда условия содержат выражения ($x > 10, strlen($s) < 5), вызовы функций или комбинации через &&/||. Тут альтернатив нет.
- switch - когда сравниваете одну переменную с набором константных значений и вам осознанно нужен fall-through. Помните про нестрогое сравнение и обязательный break.
- match - мой новый фаворит для простых маппингов. Строгое сравнение, возврат значения, UnhandledMatchError при пропущенном варианте - всё, чего мне не хватало в switch.
Заключение
Три конструкции - три разных механизма под капотом. if/else честно перебирает условия одно за другим. switch и match строят хеш-таблицу и находят нужную ветку за одну операцию. match делает это строже и безопаснее.
Для меня крайне интересно то, что break - это не «завершение блока», а явная инструкция JMP. И default - не магия, а просто следующий блок кода, в который VM провалится без break. Когда видишь opcodes, всё встаёт на свои места.
Попробуйте сами: запустите phpdbg -p на любом своём скрипте. Я серьёзно - потратьте 5 минут. Вы удивитесь, насколько отличается то, что вы пишете, от того, что PHP исполняет. Если, кончено, вы все еще пишите код напрямую 🙂
Но однажды, когда-то давно, мне стало интересно: а что, если switch не просто «другой синтаксис», а реально работает иначе под капотом? Я полез в opcodes и обнаружил кое-что, что изменило моё отношение ко всем конструкциям.
Давайте разберёмся вместе. Возьмём простую задачу: определить размер скидки по статусу клиента. Вот три способа её решить:
Код:
// Способ 1: if/else
if ($status === 'gold') {
$discount = 30;
} elseif ($status === 'silver') {
$discount = 20;
} elseif ($status === 'bronze') {
$discount = 10;
} else {
$discount = 0;
}
// Способ 2: switch
switch ($status) {
case 'gold':
$discount = 30;
break;
case 'silver':
$discount = 20;
break;
case 'bronze':
$discount = 10;
break;
default:
$discount = 0;
}
// Способ 3: match (PHP 8.0+)
$discount = match ($status) {
'gold' => 30,
'silver' => 20,
'bronze' => 10,
default => 0,
}
Все три варианта дают одинаковый результат. Но одинаково ли работает php под капотом, когда исполняет этот код? Спойлер: нет. И разница весьма интересна.
Чтобы увидеть её, нужно заглянуть в opcodes - внутренние инструкции, которые PHP-движок реально исполняет.
Что такое opcodes
Когда вы запускаете PHP-скрипт, происходит следующее: исходный код - компилятор - набор opcodes - Zend VM исполняет их по одному. if превращается в конкретные инструкции: сравнить, прыгнуть, присвоить.
Как посмотреть opcodes самому
Два способа - оба встроены в PHP, ничего ставить не нужно:
Код:
# Через phpdbg (показывает opcodes до оптимизаций)
phpdbg -p script.php
# Через OPcache (показывает opcodes после оптимизаций - то, что реально исполняется в проде)
php -d opcache.opt_debug_level=0x10000 script.php
Все opcodes в этой статье получены через phpdbg на PHP 8.1 - это то, что генерирует компилятор напрямую, без оптимизаций OPcache. В продакшене OPcache может убрать часть инструкций, но логика остаётся той же.
Что под капотом трёх конструкций
А теперь давайте посмотрим, во что превращается каждая из трёх конструкций. Я буду показывать упрощённый вывод opcodes - без временных переменных и номеров строк, чтобы не отвлекаться от сути.
if/else: линейный перебор
Вот наш пример:
Код:
$status = 'silver';
if ($status === 'gold') {
$discount = 30;
} elseif ($status === 'silver') {
$discount = 20;
} elseif ($status === 'bronze') {
$discount = 10;
} else {
$discount = 0;
}
А вот во что его превращает компилятор:
0000 ASSIGN $status, "silver"0001 IS_IDENTICAL $status, "gold" // сравниваем с "gold"0002 JMPZ → 0005 // не совпало? прыгаем дальше0003 ASSIGN $discount, 300004 JMP → 0014 // готово, прыгаем в конец0005 IS_IDENTICAL $status, "silver" // сравниваем с "silver"0006 JMPZ → 0009 // не совпало? прыгаем дальше0007 ASSIGN $discount, 200008 JMP → 0014 // готово, прыгаем в конец0009 IS_IDENTICAL $status, "bronze" // сравниваем с "bronze"0010 JMPZ → 0013 // не совпало? прыгаем дальше0011 ASSIGN $discount, 100012 JMP → 0014 // готово, прыгаем в конец0013 ASSIGN $discount, 0 // else - ничего не совпалоПаттерн виден сразу: IS_IDENTICAL + JMPZ повторяется для каждой ветки. Это линейный перебор - проверка условий одно за другим, сверху вниз.
- Если $status === 'gold' - одна проверка, и мы в нужной ветке.
- Если $status === 'bronze' - три проверки, прежде чем найдём совпадение.
- Если ни одно не совпало - пройдём все проверки и попадём в else.
Сложность поиска: O(N), где N - количество веток. Чем больше elseif, тем больше сравнений в худшем случае.
Вложенные if/else - прыжки множатся
Когда условия вложены друг в друга, количество переходов растёт быстро. Допустим, скидка зависит и от статуса, и от суммы заказа:
Код:
if ($status === 'gold') {
if ($amount > 5000) {
$discount = 40;
} else {
$discount = 30;
}
} elseif ($status === 'silver') {
if ($amount > 5000) {
$discount = 25;
} else {
$discount = 20;
}
} else {
$discount = 0;
}
В opcodes:
0002 IS_IDENTICAL $status, "gold"0003 JMPZ → 0010 // не gold → прыгаем к silver0004 IS_SMALLER 5000, $amount // вложенная проверка0005 JMPZ → 00080006 ASSIGN $discount, 400007 JMP → 0009 // ←── выход из вложенного if0008 ASSIGN $discount, 300009 JMP → 0019 // ←── выход на самый конец0010 IS_IDENTICAL $status, "silver"0011 JMPZ → 0018 // не silver → else0012 IS_SMALLER 5000, $amount // ещё одна вложенная проверка0013 JMPZ → 00160014 ASSIGN $discount, 250015 JMP → 00170016 ASSIGN $discount, 200017 JMP → 00190018 ASSIGN $discount, 019 инструкций вместо 14 - и это всего два уровня вложенности. Каждый новый if внутри if добавляет свою пару IS_IDENTICAL/IS_SMALLER + JMPZ + JMP. Код растёт не линейно - при N уровнях вложенности и M ветках получаем порядка N × M переходов.
switch/case: хеш-таблица вместо перебора
Теперь тот же пример через switch:
Код:
$status = 'silver';
switch ($status) {
case 'gold':
$discount = 30;
break;
case 'silver':
$discount = 20;
break;
case 'bronze':
$discount = 10;
break;
default:
$discount = 0;
}
Opcodes:
0000 ASSIGN $status, "silver"0001 SWITCH_STRING $status, "gold": → 0009, "silver": → 0011, "bronze": → 0013, default: → 00150002 IS_EQUAL $status, "gold" // fallback-путь (см. ниже)0003 JMPNZ → 00090004 IS_EQUAL $status, "silver"0005 JMPNZ → 00110006 IS_EQUAL $status, "bronze"0007 JMPNZ → 00130008 JMP → 00150009 ASSIGN $discount, 300010 JMP → 0016 // break0011 ASSIGN $discount, 200012 JMP → 0016 // break0013 ASSIGN $discount, 100014 JMP → 0016 // break0015 ASSIGN $discount, 0 // defaultОбратите внимание на самую первую инструкцию - SWITCH_STRING. Здесь происходит магия, которая отличает switch от if/else.
Как это работает:
Представьте, что вы пришли в гардероб и отдали куртку. Вам дали номерок - скажем, 47. Когда вы возвращаетесь, гардеробщик не перебирает все 200 курток по очереди. Он смотрит на номерок и сразу идёт к нужной вешалке.
SWITCH_STRING работает так же. На этапе компиляции PHP строит хеш-таблицу - внутренний «справочник», где для каждого значения case уже записан адрес перехода:"gold" → переход к инструкции 0009
"silver" → переход к инструкции 0011
"bronze" → переход к инструкции 0013
default → переход к инструкции 0015
Когда Zend VM доходит до SWITCH_STRING, она берёт значение $status, вычисляет его хеш, и за одну операцию получает номер инструкции, куда нужно прыгнуть. Никакого перебора. Не важно, 3 у вас case или 30 - поиск занимает O(1) в среднем.
Да, формально хеш-таблица - это «амортизированный O(1)». Если у двух ключей совпадут хеши (коллизия), Zend Engine пойдёт по связанному списку, и поиск деградирует (раньше на Хабре я публиковал разбор). Но на практике с 3–30 строковыми ключами в case вероятность коллизий ничтожна - PHP использует хорошую хеш-функцию, и для типичных switch это действительно один поиск.
Ещё один вопрос: а сколько памяти жрёт эта хеш-таблица? Она создаётся один раз при компиляции и хранится в скомпилированном opcache. Для наших трёх case это порядка 200–300 байт - массив из нескольких указателей. Даже при 30 ветках это килобайт-другой. Цена минимальна.
Сравните с if/else, где при 30 ветках в худшем случае нужно выполнить 30 сравнений. Здесь - одно. Одна операция - и мы в нужной ветке, минуя все остальные.
А что за IS_EQUAL ниже?
Инструкции 0002–0008 - это fallback-путь, запасной линейный перебор. Он нужен на случай, когда хеш-таблица не может дать точный ответ (например, из-за неявного приведения типов - об этом чуть ниже). В обычном сценарии со строками SWITCH_STRING срабатывает сразу, и весь fallback-блок пропускается.
Для целочисленных значений существует аналогичный опкод SWITCH_LONG.
break под капотом
Посмотрите на инструкции 0010, 0012, 0014 - это JMP → 0016. Каждый break - это просто JMP (безусловный прыжок) на адрес после switch-блока. Всё.
А что если убрать break? Тогда JMP не генерируется. Zend VM просто шагает на следующую инструкцию, потому что ей всё равно что там «другой case». VM ходит по opcodes как по рельсам - case-метки для неё не существуют, это точки входа для SWITCH_STRING, а не границы блоков.
Представьте конвейерную ленту на почте. Посылки едут одна за другой. Каждый case - это работник, который стоит у ленты и ждёт свою посылку. break - это когда работник снимает посылку с ленты и уходит. Если он этого не сделает - посылка проедет мимо и попадёт к следующему работнику, а тот тоже что-то с ней сделает.
VM работает точно так же: opcodes - это лента, инструкции выполняются по порядку. break (JMP) снимает выполнение с ленты и перебрасывает в конец. Без break - выполнение просто катится дальше. Fall-through - это не специальное поведение, это отсутствие поведения.
И вот что из этого следует - вещь, которую многие упускают:
default тоже «на ленте»
default - это не какая-то магическая конструкция. Это просто ещё один блок кода, который физически расположен после всех case в opcodes. Посмотрите на наш пример: default стоит на позиции 0015, а последний case 'bronze' - на 0013.
Если сработал case 'bronze' и нет break - VM дойдёт до инструкции 0013 (тело bronze), выполнит её, и пойдёт дальше на 0015 - прямо в default. Не потому что «ничего не совпало», а просто потому что default - следующий по коду. Нет JMP - нет выхода.
Именно поэтому я долго избегал switch - легко получить ситуацию, когда default выполняется даже при совпадении с одним из case, просто из-за забытого break.
Когда default работает как задумано:
- Ни один case не совпал → хеш-таблица SWITCH_STRING направляет VM прямо в default
- case совпал, break стоит → JMP перепрыгивает default, VM его даже не увидит
Если default отсутствует, а ни один case не совпал, то весь switch пропускается, и выполнение продолжается с первой инструкции после блока.
switch(true): когда хеш-таблица невозможна
switch умеет работать с выражениями в case. Но вот загвоздка:
Код:
$amount = 1500;
switch (true) {
case $amount > 5000:
$discount = 30;
break;
case $amount > 1000:
$discount = 20;
break;
case $amount > 500:
$discount = 10;
break;
default:
$discount = 0;
}
Opcodes:
0000 ASSIGN $amount, 15000001 IS_SMALLER 5000, $amount // $amount > 5000?0002 JMPNZ → 00080003 IS_SMALLER 1000, $amount // $amount > 1000?0004 JMPNZ → 00100005 IS_SMALLER 500, $amount // $amount > 500?0006 JMPNZ → 00120007 JMP → 0014 // default0008 ASSIGN $discount, 300009 JMP → 00150010 ASSIGN $discount, 200011 JMP → 00150012 ASSIGN $discount, 100013 JMP → 00150014 ASSIGN $discount, 0Заметили? Никакого SWITCH_STRING. Компилятор не может построить хеш-таблицу из выражений $amount > 5000 - значения неизвестны на этапе компиляции. Вместо этого генерируется цепочка IS_SMALLER + JMPNZ - фактически тот же линейный перебор, что и у if/else.
Вывод: switch(true) с выражениями в case - это if/else в обёртке switch. Хеш-таблицы нет. Но небольшой выигрыш всё же есть, о нем ниже.
match(true): строгий аналог switch(true)
Раз уж мы разобрали switch(true), логичный вопрос: а что будет с match(true)?
Код:
$amount = 1500;
$discount = match (true) {
$amount > 5000 => 30,
$amount > 1000 => 20,
$amount > 500 => 10,
default => 0,
};
Opcodes:
0000 ASSIGN $amount, 15000001 IS_SMALLER 5000, $amount // $amount > 5000? → результат в T40002 IS_IDENTICAL T4, true // T4 === true? (строгая проверка!)0003 JMPNZ → 00110004 IS_SMALLER 1000, $amount0005 IS_IDENTICAL T5, true0006 JMPNZ → 00130007 IS_SMALLER 500, $amount0008 IS_IDENTICAL T6, true0009 JMPNZ → 00150010 JMP → 0017 // default0011 QM_ASSIGN 30...0019 ASSIGN $discount, T7Обратите внимание: для каждой ветки две инструкции вместо одной - сначала IS_SMALLER, потом IS_IDENTICAL ... true. match не доверяет результату сравнения «на слово» - он дополнительно проверяет, что результат строго равен true. У switch(true) была одна IS_SMALLER + JMPNZ, здесь - на инструкцию больше на каждую ветку.
Вывод: match(true) с выражениями - это самый безопасный, но и самый «дорогой» вариант линейного перебора. На практике разница в наносекундах, но для полноты картины важно знать.
Неявное приведение типов
switch использует нестрогое сравнение (==). В fallback-пути нашего примера (инструкции 0002–0007) это видно явно - там стоит IS_EQUAL, а не IS_IDENTICAL.
Вот классический пример сюрприза:
Код:
$value = 0;
switch ($value) {
case 'abc':
echo 'Строка abc!';
break;
case 0:
echo 'Ноль';
break;
}
В PHP 7 этот код выведет «Строка abc!», потому что 0 == 'abc' - это true при нестрогом сравнении (строка приводится к числу 0). В PHP 8 поведение изменилось: сравнение 0 == 'abc' теперь возвращает false, и код выведет «Ноль». Но другие неочевидные сравнения в switch никуда не делись: например, 0 == '0' по-прежнему true.
Ещё один повод, по которому я долго обходил switch стороной. Если нужно строгое сравнение без сюрпризов - используйте match.
match: строгий, компактный, выразительный
Теперь match - конструкция, появившаяся в PHP 8.0:
Код:
$status = 'silver';
$discount = match ($status) {
'gold' => 30,
'silver' => 20,
'bronze' => 10,
default => 0,
};
Opcodes:
0000 ASSIGN $status, "silver"0001 MATCH $status, "gold": → 0002, "silver": → 0004, "bronze": → 0006, default: → 00080002 QM_ASSIGN 30 // T4 = 300003 JMP → 00100004 QM_ASSIGN 20 // T4 = 200005 JMP → 00100006 QM_ASSIGN 10 // T4 = 100007 JMP → 00100008 QM_ASSIGN 0 // T4 = 00009 JMP → 00100010 ASSIGN $discount, T4 // результат присваиваетсяПервое, что бросается в глаза: нет fallback-цепочки. У switch после SWITCH_STRING шёл запасной линейный перебор с IS_EQUAL. У match - только MATCH и сразу ветки. Почему компилятор обходится без подстраховки:
1. Строгое сравнение (
[B]===[/B]) - зашито прямо в опкод MATCH. Никаких сюрпризов с приведением типов - 0 и "0" здесь не равны.2. Нет fall-through - каждая ветка заканчивается JMP на итоговый ASSIGN. Это не вопрос дисциплины программиста (как break в switch), а гарантия компилятора.
Второе отличие: match - это выражение. Он возвращает значение, поэтому в opcodes вместо ASSIGN $discount, 30 стоит QM_ASSIGN во временную переменную T4, а уже потом T4 присваивается в $discount (инструкция 0010). Один ASSIGN вместо трёх-четырёх - код компактнее.
UnhandledMatchError
Что если ни одна ветка не совпала и нет default?
Код:
$discount = match ($status) {
'gold' => 30,
'silver' => 20,
};
// $status === 'bronze' → UnhandledMatchError
В отличие от switch, который молча пропускает весь блок, match выбрасывает \UnhandledMatchError. В opcodes это видно как MATCH_ERROR - инструкция, которая генерирует throw, если хеш-таблица не нашла совпадения. Это делает match безопаснее: вы не можете случайно забыть обработать вариант.
Несколько значений в одном arm
Код:
$discount = match ($status) {
'gold', 'platinum' => 30,
'silver' => 20,
default => 0,
};
В opcodes оба значения просто добавляются в хеш-таблицу MATCH с одним и тем же адресом перехода:
MATCH $status, "gold": → 0002, "platinum": → 0002, "silver": → 0004, default: → 0006Никакой дополнительной логики - два ключа в хеш-таблице ведут в одну точку. Стоимость поиска остаётся O(1).
Сравним операторы
if/else | switch | switch(true) | match | |
Поиск ветки | Линейный, O(N) | Хеш-таблица, O(1) | Линейный, O(N) | Хеш-таблица, O(1) |
Сравнение | Любой оператор | == (нестрогое) | Любой оператор | === (строгое) |
Возвращает значение | Нет | Нет | Нет | Да (expression) |
Fall-through | Нет | Да (без break) | Да (без break) | Нет |
Без совпадения | else или ничего | default или ничего | default или ничего | UnhandledMatchError |
switch(true) выделен отдельно, потому что под капотом это совсем другой механизм - по сути if/else в обёртке switch, без хеш-таблицы.
Бенчмарки
Теория - это хорошо, но я решил измерить. PHP 8.1, без расширений (php -n), 5 прогонов по 5 000 000 итераций, медиана.
4 ветки (строковые значения)
Конструкция | Время (сек) |
if/else | 0.538 |
switch | 0.377 |
match | 0.374 |
При 4 ветках switch и match примерно в 1.4 раза быстрее за счёт хеш-таблицы.
10 веток (строковые значения)
Конструкция | Время (сек) |
if/else | 0.878 |
switch | 0.348 |
match | 0.332 |
При 10 ветках картина нагляднее - switch и match в 2.5 раза быстрее if/else. Хеш-таблица не зависит от количества элементов, а if/else линейно деградирует.
А что с switch(true)?
Отдельно я замерил switch(true) с выражениями (>) против эквивалентного if/else - оба на 10 ветках, 5 прогонов по 5 000 000 итераций:
Конструкция | Медиана (сек) |
if/else (с >) | 0.542 |
switch(true) | 0.447 |
Я ожидал одинаковые числа - ведь мы выяснили, что switch(true) генерирует тот же линейный перебор. Но switch(true) на ~18% быстрее. Почему?
Если посмотреть в opcodes внимательнее, разница есть. В if/else проверки и тела веток чередуются: проверил - выполнил - проверил - выполнил. В switch(true) компилятор разделяет их: сначала идут все проверки (0001–0007), потом все тела (0008–0014). Такая компоновка лучше ложится в кэш процессора - блок проверок выполняется последовательно, без прыжков в разные участки памяти.
Так что даже без хеш-таблицы switch имеет небольшое структурное преимущество. Но, конечно, до полноценного switch с SWITCH_STRING ему далеко.
В реальном продакшен-коде все эти различия растворяются в I/O, запросах к базе и сетевых задержках. Но если вы пишете hot path - парсер, маршрутизатор, обработчик событий - знание о том, что происходит под капотом, может пригодиться.
А как же тернарный оператор?
Справедливый вопрос: ?: и ?? - тоже условные конструкции. Почему их нет в статье?
Тернарный оператор $x ? $a : $b генерирует тот же JMPZ/JMPNZ, что и if/else - это по сути синтаксический сахар для простого ветвления. А null coalescing $x ?? $default использует специальный опкод COALESCE (проверка на null в одну инструкцию). Оба интересны, но это одиночные ветвления - у них нет той разницы между O(N) и O(1), которая делает сравнение if/else vs switch/match таким наглядным.
Когда что использовать
После всего этого я пересмотрел свои привычки. Вот к чему пришёл:
- if/else - когда условия содержат выражения ($x > 10, strlen($s) < 5), вызовы функций или комбинации через &&/||. Тут альтернатив нет.
- switch - когда сравниваете одну переменную с набором константных значений и вам осознанно нужен fall-through. Помните про нестрогое сравнение и обязательный break.
- match - мой новый фаворит для простых маппингов. Строгое сравнение, возврат значения, UnhandledMatchError при пропущенном варианте - всё, чего мне не хватало в switch.
Заключение
Три конструкции - три разных механизма под капотом. if/else честно перебирает условия одно за другим. switch и match строят хеш-таблицу и находят нужную ветку за одну операцию. match делает это строже и безопаснее.
Для меня крайне интересно то, что break - это не «завершение блока», а явная инструкция JMP. И default - не магия, а просто следующий блок кода, в который VM провалится без break. Когда видишь opcodes, всё встаёт на свои места.
Попробуйте сами: запустите phpdbg -p на любом своём скрипте. Я серьёзно - потратьте 5 минут. Вы удивитесь, насколько отличается то, что вы пишете, от того, что PHP исполняет. Если, кончено, вы все еще пишите код напрямую 🙂