Igor Wiedler написал простенькую функцию retry, которая повторяет выполнение коллбека до получения успешного результата или достижения заданного количества неудач. При этом он использовал goto для реализации цикла. Когда его спросили, а почему goto, а не, скажем, рекурсия, он неожиданно очень подробно и интересно ответил. Ниже приводится перевод его ответа.
Конечно же, я рассматривал альтернативы goto
. Я очень подробно их изучил, и рад представить вам результаты.
Когда парсер PHP читает исходник, код компилируется в последовательность опкодов, которая затем будет выполнена движком Zend (tm) (r). Компилятор выполняет кое-какие оптимизации, но вообще он довольно тупой. Поэтому, в зависимости от написанного вами кода он будет генерировать разные опкоды. Это напрямую влияет на производительность.
Существует несколько способов написать цикл. Начнём с упомянутого вами — рекурсии.
function retry($retries, callable $fn)
{
try {
return $fn();
} catch (\Exception $e) {
if (!$retries) {
throw new FailingTooHardException('', 0, $e);
}
retry($retries - 1, $fn)
}
}
Компилятор PHP этот код преобразует в такие опкоды:
function name: igorw\retry
number of ops: 24
compiled vars: !0 = $retries, !1 = $fn, !2 = $e
line # * op fetch ext return operands
---------------------------------------------------------------------------------
7 0 > RECV !0
1 RECV !1
11 2 INIT_FCALL_BY_NAME !1
3 DO_FCALL_BY_NAME 0 $0
4 > RETURN $0
12 5* JMP ->23
6 > CATCH 17 'Exception', !2
13 7 BOOL_NOT ~1 !0
8 > JMPZ ~1, ->17
14 9 > FETCH_CLASS 4 :2 'igorw%5CFailingTooHardException'
10 NEW $3 :2
11 SEND_VAL ''
12 SEND_VAL 0
13 SEND_VAR !2
14 DO_FCALL_BY_NAME 3
15 > THROW 0 $3
15 16* JMP ->17
16 17 > INIT_NS_FCALL_BY_NAME
18 SUB ~5 !0, 1
19 SEND_VAL ~5
20 SEND_VAR !1
21 DO_FCALL_BY_NAME 2 $6
22 > RETURN $6
18 23* > RETURN null
Как видите, выходит 24 инструкции. Дороже всего здесь обходятся вызовы функций, ибо каждый аргумент задаётся по отдельности, и есть ещё дополнительная инструкция (DO_FCALL_BY_NAME
) для собственно вызова функции.
На самом деле, в этом нет никакой необходимости. По словам Steele в его статье «Lambda: The Ultimate GOTO», хвостовые рекурсии (tail call — прим. пер.) могут быть скомпилированы в инструкции весьма эффективно. Однако, компилятор PHP не использует преимущества этой техники, поэтому вызовы функций довольно дорогие.
Попробуем улучшить ситуацию при помощи цикла while
.
function retry($retries, callable $fn)
{
while (true) {
try {
return $fn();
} catch (\Exception $e) {
if (!$retries) {
throw new FailingTooHardException('', 0, $e);
}
$retries--;
}
}
}
Вот что говорит на это компилятор:
function name: igorw\retry
number of ops: 23
compiled vars: !0 = $retries, !1 = $fn, !2 = $e
line # * op fetch ext return operands
---------------------------------------------------------------------------------
7 0 > RECV !0
1 RECV !1
9 2 > FETCH_CONSTANT ~0 'igorw%5Ctrue'
3 > JMPZ ~0, ->22
11 4 > INIT_FCALL_BY_NAME !1
5 DO_FCALL_BY_NAME 0 $1
6 > RETURN $1
12 7* JMP ->21
8 > CATCH 15 'Exception', !2
13 9 BOOL_NOT ~2 !0
10 > JMPZ ~2, ->19
14 11 > FETCH_CLASS 4 :3 'igorw%5CFailingTooHardException'
12 NEW $4 :3
13 SEND_VAL ''
14 SEND_VAL 0
15 SEND_VAR !2
16 DO_FCALL_BY_NAME 3
17 > THROW 0 $4
15 18* JMP ->19
16 19 > POST_DEC ~6 !0
20 FREE ~6
18 21 > JMP ->2
19 22 > > RETURN null
Уже лучше. Но тут в самом верху есть довольно неэффективная инструкция FETCH_CONSTANT
. Она требует проверки на наличие константы в неймспейсе: igorw\true
. Мы можем поправить это, заменив while (true)
на while (\true)
.
Это позволит избавиться от FETCH_CONSTANT
, теперь там прямо указана константа true
:
line # * op fetch ext return operands
---------------------------------------------------------------------------------
7 0 > RECV !0
1 RECV !1
9 2 > > JMPZ true, ->21
Но JUMPZ
с аргументом true
излишне. true
не бывает нулём. В идеале нам бы просто убрать эту проверку.
PS: с циклом for (;;)
та же фигня, лишние переходы, поэтому едем дальше.
Итак, можем ли мы избавиться от лишнего перехода? Попробуем do-while
!
function name: igorw\retry
number of ops: 21
compiled vars: !0 = $retries, !1 = $fn, !2 = $e
line # * op fetch ext return operands
---------------------------------------------------------------------------------
7 0 > RECV !0
1 RECV !1
11 2 > INIT_FCALL_BY_NAME !1
...
15 > THROW 0 $3
15 16* JMP ->17
16 17 > POST_DEC ~5 !0
18 FREE ~5
18 19 > JMPNZ true, ->2
19 20 > > RETURN null
Шикарно! Лишний JMPZ
ушёл! Правда, ценой появления JMPNZ
в конце. Так будет лучше в случае успешного завершения функции, но в случае необходимости повторения мы по-прежнему будем выполнять лишние переходы, которые вообще-то должны быть безусловными.
И есть способ убрать этот последний условный переход: использовать замечательную встроенную в PHP фичу под названием goto
. С goto
мы получаем тот же набор опкодов как и с do...while
, но последний переход становится безусловным!
Вот так вот. Это наиболее эффективный способ писать безусловные циклы в PHP. Всё остальное работает слишком медленно.