Là ngôn ngữ chức năng tốt hơn tại đệ quy?


41

TL; DR: Các ngôn ngữ chức năng có xử lý đệ quy tốt hơn các ngôn ngữ không chức năng không?

Tôi hiện đang đọc Code Complete 2. Tại một số điểm trong cuốn sách, tác giả cảnh báo chúng tôi về đệ quy. Ông nói rằng nên tránh khi có thể và các chức năng sử dụng đệ quy thường kém hiệu quả hơn so với giải pháp sử dụng các vòng lặp. Ví dụ, tác giả đã viết một hàm Java bằng cách sử dụng đệ quy để tính giai thừa của một số như vậy (nó có thể không hoàn toàn giống nhau vì tôi không có cuốn sách này vào lúc này):

public int factorial(int x) {
    if (x <= 0)
        return 1;
    else
        return x * factorial(x - 1);
}

Đây là một giải pháp tồi. Tuy nhiên, trong các ngôn ngữ chức năng, sử dụng đệ quy thường là cách làm được ưa thích. Ví dụ, đây là hàm giai thừa trong Haskell bằng cách sử dụng đệ quy:

factorial :: Integer -> Integer
factorial 0 = 1
factorial n = n * factorial (n - 1)

Và được chấp nhận rộng rãi như một giải pháp tốt. Như tôi đã thấy, Haskell sử dụng đệ quy rất thường xuyên và tôi không thấy bất cứ nơi nào nó được tán thành.

Vì vậy, câu hỏi của tôi về cơ bản là:

  • Các ngôn ngữ chức năng xử lý đệ quy tốt hơn các ngôn ngữ không chức năng?

EDIT: Tôi biết rằng các ví dụ tôi đã sử dụng không phải là tốt nhất để minh họa câu hỏi của tôi. Tôi chỉ muốn chỉ ra rằng Haskell (và các ngôn ngữ chức năng nói chung) sử dụng đệ quy thường xuyên hơn nhiều so với các ngôn ngữ phi chức năng.


10
Trường hợp cụ thể: nhiều ngôn ngữ chức năng sử dụng tối ưu hóa cuộc gọi đuôi, trong khi rất ít ngôn ngữ thủ tục làm điều đó. Điều này có nghĩa là đệ quy cuộc gọi đuôi rẻ hơn nhiều trong các ngôn ngữ chức năng đó.
Joachim Sauer

7
Trên thực tế, định nghĩa Haskell bạn đưa ra là khá tệ. factorial n = product [1..n]ngắn gọn hơn, hiệu quả hơn và không tràn vào ngăn xếp cho lớn n(và nếu bạn cần ghi nhớ, các tùy chọn hoàn toàn khác nhau được yêu cầu). productđược định nghĩa theo một số fold, được định nghĩa đệ quy, nhưng hết sức cẩn thận. Đệ quy một giải pháp có thể chấp nhận được hầu hết thời gian, nhưng vẫn dễ dàng thực hiện sai / không tối ưu.

1
@JoachimSauer - Với một chút tô điểm, bình luận của bạn sẽ đưa ra một câu trả lời đáng giá.
Đánh dấu gian hàng

Chỉnh sửa của bạn cho thấy bạn đã không bắt được sự trôi dạt của tôi. Định nghĩa bạn đưa ra là một ví dụ hoàn hảo về đệ quy xấu ngay cả trong các ngôn ngữ chức năng . Sự thay thế của tôi cũng là đệ quy (mặc dù đó là trong chức năng thư viện) và rất hiệu quả, chỉ cách nó đệ quy mới tạo ra sự khác biệt. Haskell cũng là một trường hợp kỳ lạ ở chỗ sự lười biếng phá vỡ các quy tắc thông thường (trường hợp tại điểm: các hàm có thể tràn ngăn xếp trong khi được đệ quy đuôi và rất hiệu quả mà không bị đệ quy đuôi).

@delnan: Cảm ơn đã làm rõ! Tôi sẽ chỉnh sửa bản chỉnh sửa của mình;)
marco-fiset

Câu trả lời:


36

Vâng, họ làm, nhưng không chỉ vì họ có thể , mà vì họ phải làm .

Khái niệm chính ở đây là độ tinh khiết : một hàm thuần túy là một hàm không có tác dụng phụ và không có trạng thái. Các ngôn ngữ lập trình hàm nói chung nắm lấy độ tinh khiết vì nhiều lý do, chẳng hạn như lý luận về mã và tránh các phụ thuộc không rõ ràng. Một số ngôn ngữ, đặc biệt là Haskell, thậm chí còn đi xa đến mức chỉ cho phép mã thuần túy; bất kỳ tác dụng phụ nào mà chương trình có thể có (chẳng hạn như thực hiện I / O) được chuyển sang thời gian chạy không thuần túy, giữ cho ngôn ngữ tự thuần.

Không có tác dụng phụ có nghĩa là bạn không thể có bộ đếm vòng lặp (vì bộ đếm vòng lặp sẽ tạo thành trạng thái có thể thay đổi và sửa đổi trạng thái đó sẽ là tác dụng phụ), do đó, ngôn ngữ chức năng thuần túy nhất có thể nhận được là lặp lại danh sách ( Thao tác này thường được gọi là foreachhoặc map). Tuy nhiên, đệ quy là một kết hợp tự nhiên với lập trình hàm thuần túy - không cần trạng thái để lặp lại, ngoại trừ các đối số hàm (chỉ đọc) và giá trị trả về (chỉ ghi).

Tuy nhiên, không có tác dụng phụ cũng có nghĩa là đệ quy có thể được thực hiện hiệu quả hơn và trình biên dịch có thể tối ưu hóa nó mạnh hơn. Bản thân tôi chưa nghiên cứu bất kỳ trình biên dịch nào như vậy, nhưng theo như tôi có thể nói, hầu hết các trình biên dịch ngôn ngữ lập trình chức năng thực hiện tối ưu hóa cuộc gọi đuôi, và một số thậm chí có thể biên dịch một số loại cấu trúc đệ quy thành các vòng lặp phía sau hậu trường.


2
Đối với hồ sơ, loại bỏ cuộc gọi đuôi không dựa vào độ tinh khiết.
Scarfridge

2
@scarfridge: Tất nhiên là không. Tuy nhiên, khi độ tinh khiết là nhất định, trình biên dịch sẽ dễ dàng sắp xếp lại mã của bạn để cho phép gọi đuôi.
tdammers

GCC thực hiện công việc TCO tốt hơn nhiều so với GHC, vì bạn không thể thực hiện TCO trong suốt quá trình tạo ra một thunk.
dan_waterworth

18

Bạn đang so sánh đệ quy vs lặp. Không có loại bỏ cuộc gọi đuôi , lặp đi lặp lại thực sự hiệu quả hơn vì không có chức năng gọi thêm. Ngoài ra, việc lặp có thể diễn ra mãi mãi, trong khi có thể hết dung lượng ngăn xếp từ quá nhiều lệnh gọi hàm.

Tuy nhiên, lặp lại yêu cầu thay đổi một bộ đếm. Điều đó có nghĩa là phải có một biến có thể thay đổi , bị cấm trong một thiết lập chức năng thuần túy. Vì vậy, các ngôn ngữ chức năng được thiết kế đặc biệt để hoạt động mà không cần lặp lại, do đó các hàm gọi được sắp xếp hợp lý.

Nhưng không ai trong số đó giải quyết tại sao mẫu mã của bạn rất đẹp. Ví dụ của bạn cho thấy một thuộc tính khác, đó là khớp mẫu . Đó là lý do tại sao mẫu Haskell không có điều kiện rõ ràng. Nói cách khác, đó không phải là đệ quy được sắp xếp hợp lý làm cho mã của bạn nhỏ; đó là mẫu phù hợp.


Tôi đã biết kết hợp mẫu nào là tất cả và tôi nghĩ đó là một tính năng tuyệt vời trong Haskell mà tôi bỏ lỡ trong các ngôn ngữ tôi sử dụng!
marco-fiset

@marcof Quan điểm của tôi là tất cả các cuộc thảo luận về đệ quy vs phép lặp không đề cập đến độ mượt của mẫu mã của bạn. Đó thực sự là về kết hợp mẫu so với điều kiện. Có lẽ tôi nên đặt nó lên hàng đầu trong câu trả lời của tôi.
chrisaycock

Vâng, tôi cũng hiểu điều đó: P
marco-fiset

@chrisaycock: Có thể xem phép lặp là đệ quy đuôi trong đó tất cả các biến được sử dụng trong thân vòng lặp đều là đối số và trả về giá trị của các lệnh gọi đệ quy không?
Giorgio

@Giorgio: Có, làm cho chức năng của bạn mất và trả lại một tuple cùng loại.
Ericson2314

5

Về mặt kỹ thuật thì không, nhưng thực tế là có.

Đệ quy là phổ biến hơn nhiều khi bạn đang thực hiện một cách tiếp cận chức năng cho vấn đề. Do đó, các ngôn ngữ được thiết kế để sử dụng phương pháp tiếp cận chức năng thường bao gồm các tính năng giúp việc đệ quy dễ dàng hơn / tốt hơn / ít vấn đề hơn. Ngoài đỉnh đầu của tôi, có ba cái phổ biến:

  1. Tối ưu hóa cuộc gọi đuôi. Như được chỉ ra bởi các áp phích khác, ngôn ngữ chức năng thường yêu cầu TCO.

  2. Đánh giá lười biếng. Haskell (và một vài ngôn ngữ khác) được đánh giá một cách lười biếng. Điều này làm trì hoãn 'công việc' thực tế của một phương thức cho đến khi nó được yêu cầu. Điều này có xu hướng dẫn đến các cấu trúc dữ liệu đệ quy hơn và bằng cách mở rộng, các phương thức đệ quy để làm việc trên chúng.

  3. Bất biến. Phần lớn những thứ bạn làm việc với các ngôn ngữ lập trình chức năng là không thay đổi. Điều này làm cho đệ quy dễ dàng hơn vì bạn không phải lo lắng về tình trạng của các đối tượng theo thời gian. Bạn không thể có một giá trị thay đổi từ bên dưới bạn chẳng hạn. Ngoài ra, nhiều ngôn ngữ được thiết kế để phát hiện các chức năng thuần túy . Vì các hàm thuần túy không có tác dụng phụ, trình biên dịch có nhiều tự do hơn về thứ tự các hàm chạy trong và các tối ưu hóa khác.

Không có điều nào trong số này thực sự cụ thể đối với các ngôn ngữ chức năng so với các ngôn ngữ khác, vì vậy chúng không chỉ đơn giản là tốt hơn vì chúng hoạt động. Nhưng vì chúng hoạt động, các quyết định thiết kế được đưa ra sẽ thiên về các tính năng này vì chúng hữu ích hơn (và nhược điểm của chúng ít có vấn đề hơn) khi lập trình theo chức năng.


1
Re: 1. Trả lại sớm không có gì để làm với các cuộc gọi đuôi. Bạn có thể quay lại sớm bằng một cuộc gọi đuôi và có cuộc gọi lại "trễ" cũng có một cuộc gọi đuôi và bạn có thể có một biểu thức đơn giản với cuộc gọi đệ quy không ở vị trí đuôi (xem định nghĩa giai thừa của OP).

@del Nam: Cảm ơn; đó là sớm và đã khá lâu kể từ khi tôi nghiên cứu điều này.
Telastyn

1

Haskell và các ngôn ngữ chức năng khác thường sử dụng đánh giá lười biếng. Tính năng này cho phép bạn viết các hàm đệ quy không kết thúc.

Nếu bạn viết một hàm đệ quy mà không xác định trường hợp cơ sở nơi đệ quy kết thúc, bạn sẽ có các cuộc gọi vô hạn đến hàm đó và stackoverflow.

Haskell cũng hỗ trợ tối ưu hóa chức năng đệ quy. Trong Java, mỗi lệnh gọi hàm sẽ xếp chồng lên nhau và gây ra phí.

Vì vậy, có, ngôn ngữ chức năng xử lý đệ quy tốt hơn so với những ngôn ngữ khác.


5
Haskell là một trong số rất ít các ngôn ngữ không nghiêm ngặt - toàn bộ gia đình ML (ngoài một số spinoff nghiên cứu có thêm sự lười biếng), tất cả các ngôn ngữ phổ biến Lisps, Erlang, v.v ... đều nghiêm ngặt. Ngoài ra, các đoạn thứ hai dường như tắt - như bạn nêu chính xác trong đoạn đầu tiên, sự lười biếng không cho phép đệ quy vô hạn (khúc dạo đầu Haskell có vô cùng hữu ích forever a = a >> forever achẳng hạn).

@deinan: Theo tôi biết SML / NJ cũng cung cấp đánh giá lười biếng nhưng nó là một bổ sung cho SML. Tôi cũng muốn đặt tên cho hai trong số ít các ngôn ngữ chức năng lười biếng: Miranda và Clean.
Giorgio

1

Lý do kỹ thuật duy nhất tôi biết là một số ngôn ngữ chức năng (và một số ngôn ngữ bắt buộc nếu tôi nhớ lại) có cái được gọi là tối ưu hóa cuộc gọi đuôi cho phép phương thức đệ quy không tăng kích thước của ngăn xếp với mỗi cuộc gọi đệ quy (ví dụ: cuộc gọi đệ quy nhiều hơn hoặc ít hơn thay thế cuộc gọi hiện tại trên ngăn xếp).

Lưu ý rằng tối ưu hóa này không hoạt động trên bất kỳ cuộc gọi đệ quy nào , chỉ có các phương thức đệ quy gọi đuôi (tức là các phương thức không duy trì trạng thái tại thời điểm của cuộc gọi đệ quy)


1
(1) Tối ưu hóa như vậy chỉ áp dụng trong các trường hợp rất cụ thể - ví dụ của OP không phải là chúng và nhiều chức năng đơn giản khác cần được chăm sóc thêm để trở thành đệ quy đuôi. (2) Tối ưu hóa cuộc gọi đuôi thực sự không chỉ tối ưu hóa các chức năng đệ quy, nó loại bỏ chi phí không gian khỏi bất kỳ cuộc gọi nào ngay sau đó là trả về.

@delnan: (1) Vâng, rất đúng. Trong 'bản nháp gốc' của câu trả lời này, tôi đã đề cập rằng :( (2) Có, nhưng trong bối cảnh của câu hỏi, tôi nghĩ rằng sẽ không liên quan để đề cập đến.
Steven Evers

Có, (2) chỉ là một bổ sung hữu ích (mặc dù không thể thiếu đối với kiểu tiếp tục truyền), câu trả lời không cần đề cập là.

1

Bạn sẽ muốn xem Bộ sưu tập rác rất nhanh, nhưng một ngăn xếp thì nhanh hơn , một bài báo về việc sử dụng những gì các lập trình viên C sẽ nghĩ là "đống" cho các khung ngăn xếp trong biên dịch C. Tôi tin rằng tác giả đã mày mò với Gcc để làm điều đó . Đây không phải là một câu trả lời chắc chắn, nhưng điều đó có thể giúp bạn hiểu một số vấn đề với đệ quy.

Các ngôn ngữ lập trình Alef , mà sử dụng để đi cùng với kế hoạch 9 từ Bell Labs, đã có một "trở thành" tuyên bố (Xem phần 6.6.4 của thông tin này ). Đó là một loại tối ưu hóa đệ quy cuộc gọi đuôi rõ ràng. "Nhưng nó sử dụng lên ngăn xếp cuộc gọi!" lập luận chống lại đệ quy có thể có khả năng được thực hiện với.


0

TL; DR: Có, họ thực hiện
Recursion là một công cụ chính trong lập trình chức năng và do đó, rất nhiều công việc đã được thực hiện để tối ưu hóa các cuộc gọi này. Ví dụ, R5RS yêu cầu (trong thông số kỹ thuật!) Rằng tất cả các cài đặt đều xử lý các cuộc gọi đệ quy đuôi không liên kết mà không cần lập trình viên lo lắng về lỗi tràn ngăn xếp. Để so sánh, theo mặc định, trình biên dịch C sẽ không thực hiện tối ưu hóa cuộc gọi đuôi rõ ràng (thử đảo ngược đệ quy danh sách được liên kết) và sau một số cuộc gọi, chương trình sẽ chấm dứt (tuy nhiên, trình biên dịch sẽ tối ưu hóa nếu bạn sử dụng - Ôxy).

Tất nhiên, trong các chương trình được viết một cách khủng khiếp, chẳng hạn như fibví dụ nổi tiếng theo cấp số nhân, trình biên dịch có rất ít hoặc không có tùy chọn để thực hiện 'phép thuật' của nó. Vì vậy, cần thận trọng để không cản trở những nỗ lực biên dịch trong tối ưu hóa.

EDIT: Theo ví dụ về sợi, tôi có nghĩa như sau:

(define (fib n)
 (if (< n 3) 1 
  (+ (fib (- n 1)) (fib (- n 2)))
 )
)

0

Các ngôn ngữ chức năng tốt hơn ở hai loại đệ quy rất cụ thể: đệ quy đuôi và đệ quy vô hạn. Chúng cũng tệ như các ngôn ngữ khác ở các loại đệ quy khác, như factorialví dụ của bạn .

Điều đó không có nghĩa là không có thuật toán nào hoạt động tốt với đệ quy thường xuyên trong cả hai mô hình. Ví dụ, bất cứ điều gì yêu cầu cấu trúc dữ liệu giống như ngăn xếp, như tìm kiếm cây sâu đầu tiên, là cách đơn giản nhất để thực hiện với đệ quy.

Đệ quy xuất hiện thường xuyên hơn với lập trình chức năng, nhưng nó cũng được sử dụng quá mức, đặc biệt là bởi người mới bắt đầu hoặc trong hướng dẫn cho người mới bắt đầu, có lẽ vì hầu hết người mới bắt đầu lập trình chức năng đã sử dụng đệ quy trước khi lập trình bắt buộc. Có các cấu trúc lập trình chức năng khác, như hiểu danh sách, hàm bậc cao và các thao tác khác trên các bộ sưu tập, thường phù hợp hơn về mặt khái niệm, về kiểu dáng, tính đơn giản, hiệu quả và khả năng tối ưu hóa.

Ví dụ, đề xuất của del Nam factorial n = product [1..n]không chỉ ngắn gọn và dễ đọc hơn, mà còn có tính song song cao. Tương tự cho việc sử dụng foldhoặc reducenếu ngôn ngữ của bạn không productđược tích hợp sẵn. Đệ quy là giải pháp cuối cùng cho vấn đề này. Lý do chính mà bạn thấy nó được giải quyết đệ quy trong hướng dẫn là như một điểm xuất phát trước khi có được giải pháp tốt hơn, chứ không phải là một ví dụ về thực tiễn tốt nhất.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.