Haskell menggunakan evaluasi malas untuk mengimplementasikan rekursi, jadi memperlakukan apa pun sebagai janji untuk memberikan nilai saat diperlukan (ini disebut thunk). Thunk dikurangi hanya sebanyak yang diperlukan untuk melanjutkan, tidak lebih. Ini mirip dengan cara Anda menyederhanakan ekspresi secara matematis, jadi akan sangat membantu jika Anda menganggapnya seperti itu. Fakta bahwa urutan evaluasi tidak ditentukan oleh kode Anda memungkinkan compiler untuk melakukan banyak pengoptimalan yang lebih cerdik daripada hanya eliminasi tail-call yang biasa Anda lakukan. Kompilasi dengan -O2
jika Anda ingin pengoptimalan!
Mari kita lihat bagaimana kami mengevaluasi facSlow 5
sebagai studi kasus:
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
Jadi seperti yang Anda khawatirkan, kami memiliki penumpukan angka sebelum penghitungan apa pun terjadi, tetapi tidak seperti Anda khawatir, tidak ada tumpukan facSlow
panggilan fungsi yang menunggu untuk dihentikan - setiap pengurangan diterapkan dan menghilang, meninggalkan bingkai tumpukan di dalamnya. bangun (itu karena (*)
ketat dan memicu evaluasi argumen keduanya).
Fungsi rekursif Haskell tidak dievaluasi dengan cara yang sangat rekursif! Satu-satunya tumpukan panggilan yang berkeliaran adalah perkalian itu sendiri. Jika (*)
dipandang sebagai konstruktor data yang ketat, ini adalah apa yang dikenal sebagai dijaga rekursi (meskipun biasanya disebut sebagai tersebut dengan non konstruktor Data -strict, di mana apa yang tersisa di belakangnya adalah konstruktor Data - ketika dipaksa oleh akses lebih lanjut).
Sekarang mari kita lihat rekursif-ekor 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
Jadi Anda dapat melihat bagaimana rekursi ekor itu sendiri tidak menghemat waktu atau ruang Anda. Tidak hanya membutuhkan lebih banyak langkah secara keseluruhan facSlow 5
, itu juga membangun thunk bersarang (ditampilkan di sini sebagai {...}
) - membutuhkan ruang ekstra untuk itu - yang menjelaskan komputasi masa depan, perkalian bertingkat yang akan dilakukan.
Dunk ini kemudian terurai dengan melintasi itu ke bawah, menciptakan perhitungan pada stack. Ada juga bahaya di sini karena tumpukan melimpah dengan komputasi yang sangat lama, untuk kedua versi.
Jika kita ingin mengoptimalkan ini secara manual, yang perlu kita lakukan adalah membuatnya ketat. Anda bisa menggunakan operator aplikasi yang ketat $!
untuk mendefinisikan
facSlim :: (Integral a) => a -> a
facSlim x = facS' x 1 where
facS' 1 y = y
facS' x y = facS' (x-1) $! (x*y)
Ini memaksa facS'
untuk menjadi tegas dalam argumen keduanya. (Ini sudah ketat dalam argumen pertamanya karena itu harus dievaluasi untuk memutuskan definisi mana yang facS'
akan diterapkan.)
Terkadang ketegasan dapat sangat membantu, terkadang itu adalah kesalahan besar karena kemalasan lebih efisien. Ini ide yang bagus:
facSlim 5
facS' 5 1
facS' 4 5
facS' 3 20
facS' 2 60
facS' 1 120
120
Menurut saya, apa yang ingin Anda capai.
Ringkasan
- Jika Anda ingin mengoptimalkan kode Anda, langkah pertama adalah mengompilasi
-O2
- Rekursi ekor hanya baik jika tidak ada tumpukan, dan menambahkan ketegasan biasanya membantu mencegahnya, jika dan jika perlu. Ini terjadi saat Anda membangun hasil yang dibutuhkan di lain waktu sekaligus.
- Kadang-kadang rekursi ekor adalah rencana yang buruk dan rekursi yang dijaga lebih cocok, yaitu ketika hasil yang Anda buat akan dibutuhkan sedikit demi sedikit, dalam porsi. Lihat pertanyaan ini tentang
foldr
dan foldl
misalnya, dan uji mereka satu sama lain.
Coba dua ini:
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
adalah rekursif ekor, sedangkan foldr1
melakukan rekursi yang dilindungi sehingga item pertama segera ditampilkan untuk diproses / diakses lebih lanjut. (Yang pertama "mengurung" ke kiri sekaligus, (...((s+s)+s)+...)+s
memaksa daftar masukan sepenuhnya ke ujungnya dan membuat perhitungan besar di masa depan jauh lebih cepat daripada yang dibutuhkan hasil lengkapnya; yang kedua tanda kurung ke kanan secara bertahap s+(s+(...+(s+s)...))
, memakan masukan daftar sedikit demi sedikit, sehingga semuanya dapat beroperasi dalam ruang konstan, dengan pengoptimalan).
Anda mungkin perlu menyesuaikan jumlah nol tergantung pada perangkat keras apa yang Anda gunakan.