Хаскелл використовує метод ледачого оцінювання для реалізації рекурсії, тому розглядає будь-що як обіцянку надати значення, коли це потрібно (це називається глупотою). Думки зменшуються лише настільки, наскільки це необхідно для продовження, не більше. Це нагадує спосіб математичного спрощення виразу, тому корисно думати про це так. Той факт, що порядок оцінки не вказаний вашим кодом, дозволяє компілятору зробити ще більше розумних оптимізацій, ніж просто усунення хвостового виклику, до якого ви звикли. Компілюйте, -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 (це тому, що (*)
є суворим і тому викликає оцінку його другого аргументу).
Рекурсивні функції Хаскелла оцінюються не дуже рекурсивно! Єдиний стос дзвінків, що зависли, - це самі множення. Якщо (*)
розглядається як строгий конструктор даних, це те, що називається захищеною рекурсією (хоча її зазвичай називають такою з не конструкторами даних -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)...))
, споживаючи введені дані перераховуйте поступово, тому все це може працювати в постійному просторі з оптимізацією).
Можливо, вам доведеться відрегулювати кількість нулів залежно від того, яке обладнання ви використовуєте.