Những câu trả lời khác có phần sai lệch. Tôi đồng ý rằng họ nêu chi tiết thực hiện có thể giải thích sự chênh lệch này, nhưng họ nói quá về trường hợp này. Như được đề xuất chính xác bởi jmite, chúng được định hướng triển khai theo hướng thực hiện các lệnh gọi / đệ quy hàm bị hỏng . Nhiều ngôn ngữ thực hiện các vòng lặp thông qua đệ quy, vì vậy các vòng lặp rõ ràng sẽ không nhanh hơn trong các ngôn ngữ đó. Đệ quy không có cách nào kém hiệu quả hơn việc lặp (khi cả hai đều có thể áp dụng) trên lý thuyết. Hãy để tôi trích dẫn bản tóm tắt cho bài viết năm 1977 của Guy Steele về việc hoang tưởng "Cuộc gọi thủ tục tốn kém" hoặc, Việc thực hiện thủ tục được coi là có hại hoặc, Lambda: GOTO tối thượng
Văn hóa dân gian nói rằng các tuyên bố GOTO là "rẻ", trong khi các cuộc gọi thủ tục là "đắt tiền". Huyền thoại này phần lớn là kết quả của việc thực hiện ngôn ngữ được thiết kế kém. Sự phát triển lịch sử của huyền thoại này được xem xét. Cả hai ý tưởng lý thuyết và một triển khai hiện có đều được thảo luận để gỡ bỏ huyền thoại này. Nó cho thấy rằng việc sử dụng các cuộc gọi thủ tục không hạn chế cho phép tự do phong cách tuyệt vời. Đặc biệt, bất kỳ sơ đồ nào cũng có thể được viết dưới dạng chương trình "có cấu trúc" mà không cần đưa ra các biến phụ. Khó khăn với câu lệnh GOTO và lệnh gọi thủ tục được đặc trưng là xung đột giữa các khái niệm lập trình trừu tượng và các cấu trúc ngôn ngữ cụ thể.
"Xung đột giữa các khái niệm lập trình trừu tượng và các cấu trúc ngôn ngữ cụ thể" có thể được nhìn thấy từ thực tế là hầu hết các mô hình lý thuyết, ví dụ, phép tính lambda chưa được xử lý , không có một ngăn xếp . Tất nhiên, xung đột này là không cần thiết như bài viết trên minh họa và cũng được thể hiện bởi các ngôn ngữ không có cơ chế lặp ngoài trừ đệ quy như Haskell.
fix
fix f x = f (fix f) x
( λ x . M) N⇝ M[ N/ x][ N/ x]xMN⇝
Bây giờ cho một ví dụ. Xác định fact
là
fact = fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1
Dưới đây là đánh giá về sự fact 3
gọn nhẹ, ở đâu, tôi sẽ sử dụng g
làm từ đồng nghĩa với fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1))
, nghĩa là fact = g 1
. Điều này không ảnh hưởng đến lập luận của tôi.
fact 3
~> g 1 3
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1 3
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 1 3
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 1 3
~> (λn.if n == 0 then 1 else g (1*n) (n-1)) 3
~> if 3 == 0 then 1 else g (1*3) (3-1)
~> g (1*3) (3-1)
~> g 3 2
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 3 2
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 3 2
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 3 2
~> (λn.if n == 0 then 3 else g (3*n) (n-1)) 2
~> if 2 == 0 then 3 else g (3*2) (2-1)
~> g (3*2) (2-1)
~> g 6 1
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 1
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 1
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 1
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 1
~> if 1 == 0 then 6 else g (6*1) (1-1)
~> g (6*1) (1-1)
~> g 6 0
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 0
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 0
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 0
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 0
~> if 0 == 0 then 6 else g (6*0) (0-1)
~> 6
Bạn có thể nhìn từ hình dạng mà không cần nhìn vào các chi tiết không có sự tăng trưởng và mỗi lần lặp lại cần cùng một không gian. (Về mặt kỹ thuật, kết quả số tăng lên là điều không thể tránh khỏi và đúng như vậy đối với một while
vòng lặp.) Tôi thách thức bạn chỉ ra "ngăn xếp" tăng trưởng vô biên ở đây.
Có vẻ như ngữ nghĩa nguyên mẫu của phép tính lambda đã thực hiện những gì thường được đặt tên sai là "tối ưu hóa cuộc gọi đuôi". Tất nhiên, không có "tối ưu hóa" đang xảy ra ở đây. Không có quy tắc đặc biệt nào ở đây cho các cuộc gọi "đuôi" trái ngược với các cuộc gọi "thông thường". Vì lý do này, thật khó để đưa ra một đặc tính "trừu tượng" về cái mà đuôi gọi là "tối ưu hóa" đang làm, vì trong nhiều đặc tính trừu tượng của ngữ nghĩa gọi hàm, không có gì để gọi "tối ưu hóa" đuôi!
Rằng định nghĩa tương tự của fact
nhiều ngôn ngữ "chồng tràn", là những thất bại của những ngôn ngữ đó để thực hiện chính xác ngữ nghĩa của hàm gọi. (Một số ngôn ngữ có một lý do.) Tình huống này gần giống với việc thực hiện ngôn ngữ thực hiện các mảng với các danh sách được liên kết. Lập chỉ mục vào các "mảng" như vậy sau đó sẽ là một hoạt động O (n) không đáp ứng được kỳ vọng của các mảng. Nếu tôi thực hiện một ngôn ngữ riêng biệt, sử dụng các mảng thực thay vì các danh sách được liên kết, bạn sẽ không nói rằng tôi đã triển khai "tối ưu hóa truy cập mảng", bạn sẽ nói rằng tôi đã sửa lỗi triển khai mảng.
Vì vậy, trả lời câu trả lời của Veedrac. Ngăn xếp không phải là "cơ bản" để đệ quy . Trong phạm vi hành vi "giống như ngăn xếp" xảy ra trong quá trình đánh giá, điều này chỉ có thể xảy ra trong trường hợp các vòng lặp (không có cấu trúc dữ liệu phụ trợ) sẽ không được áp dụng ngay từ đầu! Nói cách khác, tôi có thể thực hiện các vòng lặp với đệ quy với các đặc tính hiệu suất chính xác như nhau. Thật vậy, cả Scheme và SML đều chứa các cấu trúc lặp, nhưng cả hai đều xác định chúng theo thuật ngữ đệ quy (và, ít nhất là trong Scheme, do
thường được triển khai như một macro mở rộng thành các lệnh gọi đệ quy.) Tương tự, đối với câu trả lời của Johan, không có gì nói trình biên dịch phải phát ra hội nghị mà Johan mô tả cho đệ quy. Thật,chính xác cùng một hội đồng cho dù bạn sử dụng các vòng lặp hoặc đệ quy. Lần duy nhất trình biên dịch sẽ (phần nào) bắt buộc phải phát ra lắp ráp giống như những gì Johan mô tả là khi bạn đang làm một cái gì đó không thể biểu thị bằng một vòng lặp. Như được nêu trong bài báo của Steele và được chứng minh bằng thực tiễn thực tế của các ngôn ngữ như Haskell, Scheme và SML, không phải là "cực kỳ hiếm" mà các cuộc gọi đuôi có thể được "tối ưu hóa", chúng luôn có thểđược "tối ưu hóa". Việc sử dụng đệ quy cụ thể có chạy trong không gian không đổi hay không tùy thuộc vào cách viết, nhưng những hạn chế bạn cần áp dụng để thực hiện điều đó có thể là những hạn chế bạn cần để phù hợp với vấn đề của mình theo hình vòng lặp. (Trên thực tế, chúng ít nghiêm ngặt hơn. Có một số vấn đề, chẳng hạn như máy trạng thái mã hóa, được xử lý sạch sẽ và hiệu quả hơn thông qua các cuộc gọi đuôi thay vì các vòng lặp yêu cầu các biến phụ trợ.) Một lần nữa, đệ quy thời gian duy nhất yêu cầu thực hiện nhiều công việc hơn là khi mã của bạn không phải là một vòng lặp.
Tôi đoán là Johan đang đề cập đến trình biên dịch C có các hạn chế tùy ý khi nào nó sẽ thực hiện cuộc gọi "tối ưu hóa" đuôi. Johan cũng có lẽ đề cập đến các ngôn ngữ như C ++ và Rust khi ông nói về "các ngôn ngữ với các loại được quản lý". Thành ngữ RAII từ C ++ và hiện diện trong Rust cũng làm cho mọi thứ trông bề ngoài giống như các cuộc gọi đuôi, không phải các cuộc gọi đuôi (vì "kẻ hủy diệt" vẫn cần phải được gọi). Đã có đề xuất sử dụng một cú pháp khác nhau để chọn tham gia vào một ngữ nghĩa hơi khác nhau sẽ cho phép đệ quy đuôi (cụ thể là gọi hàm hủy trước đócuộc gọi đuôi cuối cùng và rõ ràng không cho phép truy cập các đối tượng "bị phá hủy". (Bộ sưu tập rác không có vấn đề như vậy và tất cả Haskell, SML và Scheme đều là ngôn ngữ được thu gom rác.) Trong một tĩnh mạch hoàn toàn khác, một số ngôn ngữ, như Smalltalk, phơi bày "ngăn xếp" như một đối tượng hạng nhất, trong các ngôn ngữ này trong trường hợp "ngăn xếp" không còn là chi tiết triển khai, mặc dù điều này không loại trừ việc có các loại cuộc gọi riêng biệt với ngữ nghĩa khác nhau. (Java nói rằng nó không thể do cách nó xử lý một số khía cạnh của bảo mật, nhưng điều này thực sự sai .)
Trong thực tế, sự phổ biến của việc thực hiện các lệnh gọi hàm bị hỏng xuất phát từ ba yếu tố chính. Đầu tiên, nhiều ngôn ngữ kế thừa việc triển khai bị hỏng từ ngôn ngữ thực hiện của chúng (thường là C). Thứ hai, quản lý tài nguyên xác định là tốt và làm cho vấn đề trở nên phức tạp hơn, mặc dù chỉ có một số ít ngôn ngữ cung cấp điều này. Thứ ba, và, theo kinh nghiệm của tôi, lý do hầu hết mọi người quan tâm, là họ muốn theo dõi ngăn xếp khi xảy ra lỗi cho mục đích gỡ lỗi. Chỉ có lý do thứ hai là một lý do có thể có động lực về mặt lý thuyết.