Haskell использует ленивую оценку для реализации рекурсии, поэтому обрабатывает все как обещание предоставить значение, когда это необходимо (это называется преобразователем). Преобразователи сокращаются ровно настолько, насколько это необходимо для продолжения работы, не более того. Это похоже на математическое упрощение выражения, поэтому полезно думать об этом именно так. Тот факт, что порядок оценки не указан в вашем коде, позволяет компилятору выполнять множество еще более умных оптимизаций, чем просто устранение хвостового вызова, к которому вы привыкли. Скомпилируйте, -O2
если хотите оптимизации!
Давайте посмотрим, как мы оцениваем facSlow 5
в качестве примера:
facSlow 5
5 * facSlow 4
5 * (4 * facSlow 3)
5 * (4 * (3 * facSlow 2))
5 * (4 * (3 * (2 * facSlow 1)))
5 * (4 * (3 * (2 * 1)))
5 * (4 * (3 * 2))
5 * (4 * 6)
5 * 24
120
Итак, как вы и беспокоились, у нас есть накопление чисел до того, как произойдут какие-либо вычисления, но, в отличие от вас, нет стека facSlow
вызовов функций, которые ждут завершения - каждое сокращение применяется и исчезает, оставляя фрейм стека в своем wake (это потому, что (*)
является строгим и поэтому запускает оценку его второго аргумента).
Рекурсивные функции Haskell не вычисляются очень рекурсивно! Единственная куча вызовов - это сами умножения. Если (*)
рассматриваются как строго конструктора данных, это то , что известно как охраняемая рекурсия (хотя обычно называют такие с не конструкторами данных -strict, где то , что осталось на своем пути являются конструкторы данных - при вынужденном пути дальнейшего доступа).
Теперь посмотрим на хвостовую рекурсию fac 5
:
fac 5
fac' 5 1
fac' 4 {5*1}
fac' 3 {4*{5*1}}
fac' 2 {3*{4*{5*1}}}
fac' 1 {2*{3*{4*{5*1}}}}
{2*{3*{4*{5*1}}}}
(2*{3*{4*{5*1}}})
(2*(3*{4*{5*1}}))
(2*(3*(4*{5*1})))
(2*(3*(4*(5*1))))
(2*(3*(4*5)))
(2*(3*20))
(2*60)
120
Итак, вы можете видеть, что хвостовая рекурсия сама по себе не сэкономила вам ни времени, ни места. Он не только выполняет больше шагов в целом facSlow 5
, но и создает вложенный преобразователь (показан здесь как {...}
) - для которого требуется дополнительное пространство, - который описывает будущие вычисления и выполняемые вложенные умножения.
Затем этот преобразователь раскрывается, просматривая его до самого низа, воссоздавая вычисление в стеке. Здесь также существует опасность переполнения стека при очень долгих вычислениях для обеих версий.
Если мы хотим оптимизировать это вручную, все, что нам нужно сделать, это сделать его строгим. Вы можете использовать строгий оператор приложения $!
для определения
facSlim :: (Integral a) => a -> a
facSlim x = facS' x 1 where
facS' 1 y = y
facS' x y = facS' (x-1) $! (x*y)
Это заставляет facS'
быть строгим во втором аргументе. (Он уже строг в своем первом аргументе, потому что его нужно оценить, чтобы решить, какое определение facS'
применить.)
Иногда строгость может очень помочь, иногда это большая ошибка, потому что лень эффективнее. Вот это хорошая идея:
facSlim 5
facS' 5 1
facS' 4 5
facS' 3 20
facS' 2 60
facS' 1 120
120
Думаю, именно этого вы и хотели достичь.
Резюме
- Если вы хотите оптимизировать свой код, первый шаг - скомпилировать с
-O2
- Хвостовая рекурсия хороша только тогда, когда нет наращивания преобразований, и добавление строгости обычно помогает предотвратить это, если и где это необходимо. Это происходит, когда вы сразу добиваетесь результата, который понадобится вам позже.
- Иногда хвостовая рекурсия - плохой план, и защищенная рекурсия лучше подходит, то есть когда результат, который вы создаете, будет нужен постепенно, по частям. Посмотрите этот вопрос о
foldr
и, foldl
например, и сравните их друг с другом.
Попробуйте эти два:
length $ foldl1 (++) $ replicate 1000
"The size of intermediate expressions is more important than tail recursion."
length $ foldr1 (++) $ replicate 1000
"The number of reductions performed is more important than tail recursion!!!"
foldl1
является хвостовой foldr1
рекурсией , тогда как выполняет защищенную рекурсию, так что первый элемент немедленно предоставляется для дальнейшей обработки / доступа. (Первый «помещается в скобки» сразу влево, (...((s+s)+s)+...)+s
заставляя свой входной список полностью до конца и создавая большой кусок будущих вычислений намного раньше, чем потребуются его полные результаты; второй заключен в круглые скобки вправо постепенно s+(s+(...+(s+s)...))
, потребляя ввод list по крупицам, так что все это может работать в постоянном пространстве с оптимизацией).
Возможно, вам потребуется настроить количество нулей в зависимости от того, какое оборудование вы используете.