Haskell sử dụng tính năng đánh giá lười biếng để triển khai đệ quy, vì vậy coi bất kỳ thứ gì như một lời hứa cung cấp một giá trị khi cần thiết (điều này được gọi là thunk). Thunks chỉ được giảm khi cần thiết để tiếp tục, không nhiều hơn. Điều này giống với cách bạn đơn giản hóa một biểu thức về mặt toán học, vì vậy sẽ rất hữu ích khi nghĩ về nó theo cách đó. Thực tế là thứ tự đánh giá không được chỉ định bởi mã của bạn cho phép trình biên dịch thực hiện nhiều tối ưu hóa thậm chí còn thông minh hơn là chỉ loại bỏ cuộc gọi đuôi mà bạn thường làm. Biên dịch với -O2
nếu bạn muốn tối ưu hóa!
Hãy xem cách chúng tôi đánh giá facSlow 5
như một nghiên cứu điển hình:
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
Vì vậy, đúng như bạn lo lắng, chúng tôi đã tích hợp các con số trước khi bất kỳ phép tính nào xảy ra, nhưng không giống như bạn lo lắng, không có chồng facSlow
lệnh gọi hàm nào đang chờ kết thúc - mỗi lần giảm được áp dụng và biến mất, để lại một khung ngăn xếp trong đó thức (đó là bởi vì (*)
nó nghiêm ngặt và do đó kích hoạt đánh giá của đối số thứ hai của nó).
Các hàm đệ quy của Haskell không được đánh giá theo cách rất đệ quy! Ngăn xếp cuộc gọi duy nhất quanh quẩn là chính các phép nhân. Nếu (*)
được xem như một phương thức khởi tạo dữ liệu nghiêm ngặt, thì đây được gọi là đệ quy được bảo vệ (mặc dù nó thường được gọi như vậy với các hàm tạo dữ liệu không hạn chế, trong đó những gì còn lại sau nó là các hàm tạo dữ liệu - khi bị buộc bởi truy cập thêm).
Bây giờ chúng ta hãy xem xét đệ quy đuôi 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
Vì vậy, bạn có thể thấy cách đệ quy đuôi tự nó đã không tiết kiệm cho bạn bất kỳ thời gian hoặc không gian nào. Nó không chỉ thực hiện nhiều bước hơn về tổng thể facSlow 5
, nó còn xây dựng một thunk lồng nhau (được hiển thị ở đây là {...}
) - cần thêm một không gian cho nó - mô tả việc tính toán trong tương lai, các phép nhân lồng nhau sẽ được thực hiện.
Cú đánh này sau đó được làm sáng tỏ bằng cách di chuyển nó xuống dưới cùng, tạo lại tính toán trên ngăn xếp. Ở đây cũng có một nguy cơ gây ra tràn ngăn xếp với các tính toán rất dài, cho cả hai phiên bản.
Nếu chúng ta muốn tối ưu hóa thủ công điều này, tất cả những gì chúng ta cần làm là làm cho nó nghiêm ngặt. Bạn có thể sử dụng toán tử ứng dụng nghiêm ngặt $!
để xác định
facSlim :: (Integral a) => a -> a
facSlim x = facS' x 1 where
facS' 1 y = y
facS' x y = facS' (x-1) $! (x*y)
Điều này buộc facS'
phải nghiêm ngặt trong lập luận thứ hai của nó. (Nó đã nghiêm ngặt trong lập luận đầu tiên của nó vì điều đó phải được đánh giá để quyết định facS'
áp dụng định nghĩa nào.)
Đôi khi sự nghiêm khắc có thể giúp ích rất nhiều, đôi khi đó lại là một sai lầm lớn vì lười biếng sẽ hiệu quả hơn. Đây là một ý kiến hay:
facSlim 5
facS' 5 1
facS' 4 5
facS' 3 20
facS' 2 60
facS' 1 120
120
Tôi nghĩ đó là những gì bạn muốn đạt được.
Tóm lược
- Nếu bạn muốn tối ưu hóa mã của mình, bước một là biên dịch với
-O2
- Đệ quy đuôi chỉ tốt khi không có sự tích tụ, và việc thêm thắt chặt chẽ thường giúp ngăn chặn nó, nếu và khi thích hợp. Điều này xảy ra khi bạn đang xây dựng một kết quả cần thiết sau này cùng một lúc.
- Đôi khi đệ quy đuôi là một kế hoạch tồi và đệ quy có bảo vệ là phù hợp hơn, tức là khi kết quả bạn đang xây dựng sẽ cần từng chút một, theo từng phần. Hãy xem câu hỏi này về
foldr
và foldl
ví dụ, và kiểm tra chúng với nhau.
Hãy thử hai điều sau:
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
là đệ quy đuôi, trong khi foldr1
thực hiện đệ quy được bảo vệ để mục đầu tiên được trình bày ngay lập tức để xử lý / truy cập tiếp theo. (Dấu ngoặc đơn đầu tiên "ngoặc" sang bên trái ngay lập tức, (...((s+s)+s)+...)+s
buộc danh sách đầu vào của nó đầy đủ đến cuối và xây dựng một loạt các phép tính trong tương lai sớm hơn nhiều so với kết quả đầy đủ của nó; dấu ngoặc đơn thứ hai dần dần sang phải s+(s+(...+(s+s)...))
, tiêu tốn đầu vào liệt kê từng chút một, vì vậy toàn bộ điều có thể hoạt động trong không gian không đổi, với sự tối ưu hóa).
Bạn có thể cần điều chỉnh số lượng số không tùy thuộc vào phần cứng bạn đang sử dụng.