Quan niệm sai về ngôn ngữ chức năng thuần túy?


39

Tôi thường gặp các tuyên bố / đối số sau đây:

  1. Các ngôn ngữ lập trình chức năng thuần túy không cho phép các tác dụng phụ (và do đó ít được sử dụng trong thực tế vì bất kỳ chương trình hữu ích nào cũng có tác dụng phụ, ví dụ như khi nó tương tác với thế giới bên ngoài).
  2. Các ngôn ngữ lập trình chức năng thuần túy không cho phép viết một chương trình duy trì trạng thái (điều này làm cho việc lập trình trở nên rất khó xử vì trong nhiều ứng dụng bạn cần trạng thái).

Tôi không phải là một chuyên gia về ngôn ngữ chức năng nhưng đây là những gì tôi đã hiểu về các chủ đề này cho đến bây giờ.

Về điểm 1, bạn có thể tương tác với môi trường bằng các ngôn ngữ chức năng thuần túy nhưng bạn phải đánh dấu rõ ràng mã (chức năng) giới thiệu các tác dụng phụ (ví dụ: trong Haskell bằng các loại đơn âm). Ngoài ra, theo như tôi biết tính toán bằng các tác dụng phụ (cập nhật dữ liệu một cách triệt để) cũng có thể (sử dụng các loại đơn nguyên?) Mặc dù đó không phải là cách làm việc ưa thích.

Về điểm 2, theo như tôi biết, bạn có thể biểu thị trạng thái bằng cách xâu chuỗi các giá trị thông qua một số bước tính toán (trong Haskell, một lần nữa, sử dụng các loại đơn âm) nhưng tôi không có kinh nghiệm thực tế khi làm điều này và sự hiểu biết của tôi khá mơ hồ.

Vì vậy, hai câu trên có đúng theo bất kỳ ý nghĩa nào không hay chúng chỉ là những quan niệm sai lầm về các ngôn ngữ chức năng thuần túy? Nếu họ là những quan niệm sai lầm, làm thế nào họ đến? Bạn có thể viết một đoạn mã (có thể nhỏ) minh họa cách thành ngữ Haskell để (1) thực hiện các tác dụng phụ và (2) thực hiện tính toán với trạng thái không?


7
Tôi nghĩ rằng hầu hết các bản lề này về những gì bạn xác định một ngôn ngữ chức năng 'thuần túy'.
jk.

@jk: Để tránh vấn đề xác định ngôn ngữ chức năng 'thuần túy', giả sử độ tinh khiết theo nghĩa Haskell (được xác định rõ). Trong điều kiện nào, một ngôn ngữ chức năng có thể được coi là "thuần túy" có thể là chủ đề của một câu hỏi trong tương lai.
Giorgio

Cả hai câu trả lời đều chứa rất nhiều ý tưởng làm rõ và thật khó để tôi chọn cái nào để chấp nhận. Tôi quyết định chấp nhận câu trả lời của sepp2k vì các ví dụ mã giả bổ sung.
Giorgio

Câu trả lời:


26

Với mục đích của câu trả lời này, tôi định nghĩa "ngôn ngữ chức năng thuần túy" có nghĩa là một ngôn ngữ chức năng trong đó các chức năng được minh bạch tham chiếu, tức là gọi cùng một chức năng nhiều lần với cùng một đối số sẽ luôn tạo ra kết quả giống nhau. Đây là, tôi tin rằng, định nghĩa thông thường của một ngôn ngữ hoàn toàn chức năng.

Các ngôn ngữ lập trình chức năng thuần túy không cho phép các tác dụng phụ (và do đó ít được sử dụng trong thực tế vì bất kỳ chương trình hữu ích nào cũng có tác dụng phụ, ví dụ như khi nó tương tác với thế giới bên ngoài).

Cách dễ nhất để đạt được tính minh bạch tham chiếu thực sự sẽ là không cho phép các tác dụng phụ và thực sự có các ngôn ngữ trong đó là trường hợp (chủ yếu là các miền cụ thể). Tuy nhiên, đây chắc chắn không phải là cách duy nhất và hầu hết các ngôn ngữ chức năng thuần túy (Haskell, Clean, ...) đều cho phép tác dụng phụ.

Ngoài ra, nói rằng ngôn ngữ lập trình không có tác dụng phụ trong sử dụng thực tế không thực sự công bằng - tôi chắc chắn không dành cho ngôn ngữ cụ thể của miền, nhưng ngay cả đối với các ngôn ngữ có mục đích chung, tôi tưởng tượng một ngôn ngữ có thể khá hữu ích mà không cung cấp tác dụng phụ . Có thể không dành cho các ứng dụng console, nhưng tôi nghĩ các ứng dụng GUI có thể được triển khai độc đáo mà không có tác dụng phụ trong mô hình phản ứng chức năng.

Về điểm 1, bạn có thể tương tác với môi trường bằng các ngôn ngữ chức năng thuần túy nhưng bạn phải đánh dấu rõ ràng mã (hàm) giới thiệu chúng (ví dụ: trong Haskell bằng các loại đơn âm).

Đó là một chút đơn giản hóa nó. Chỉ cần có một hệ thống trong đó các chức năng tác dụng phụ cần được đánh dấu như vậy (tương tự như tính chính xác trong C ++, nhưng với các tác dụng phụ chung) là không đủ để đảm bảo tính minh bạch tham chiếu. Bạn cần đảm bảo rằng một chương trình không bao giờ có thể gọi một hàm nhiều lần với cùng một đối số và nhận được các kết quả khác nhau. Bạn có thể làm điều đó bằng cách làm những thứ nhưreadLinelà một cái gì đó không phải là một chức năng (đó là những gì Haskell làm với đơn vị IO) hoặc bạn có thể không thể gọi các chức năng tác dụng phụ nhiều lần với cùng một đối số (đó là những gì Clean làm). Trong trường hợp sau, trình biên dịch sẽ đảm bảo rằng mỗi khi bạn gọi hàm có hiệu ứng phụ, bạn sẽ làm như vậy với một đối số mới và nó sẽ từ chối bất kỳ chương trình nào mà bạn chuyển cùng một đối số cho hàm hiệu ứng phụ hai lần.

Các ngôn ngữ lập trình chức năng thuần túy không cho phép viết một chương trình duy trì trạng thái (điều này làm cho việc lập trình trở nên rất khó xử vì trong nhiều ứng dụng bạn cần trạng thái).

Một lần nữa, một ngôn ngữ chức năng thuần túy rất có thể không cho phép trạng thái có thể thay đổi, nhưng chắc chắn nó có thể là thuần túy và vẫn có trạng thái có thể thay đổi, nếu bạn thực hiện nó theo cách tương tự như tôi đã mô tả với các tác dụng phụ ở trên. Trạng thái thực sự đột biến chỉ là một hình thức khác của tác dụng phụ.

Điều đó nói rằng, các ngôn ngữ lập trình chức năng chắc chắn không khuyến khích trạng thái đột biến - những ngôn ngữ thuần túy đặc biệt là như vậy. Và tôi không nghĩ rằng điều đó làm cho việc lập trình trở nên khó xử - hoàn toàn ngược lại. Đôi khi (nhưng không phải tất cả thường xuyên) trạng thái có thể thay đổi không thể tránh được mà không làm giảm hiệu suất hoặc sự rõ ràng (đó là lý do tại sao các ngôn ngữ như Haskell có cơ sở cho trạng thái có thể thay đổi), nhưng thường thì có thể.

Nếu họ là những quan niệm sai lầm, làm thế nào họ đến?

Tôi nghĩ rằng nhiều người chỉ đơn giản đọc "một hàm phải tạo ra cùng một kết quả khi được gọi với cùng một đối số" và kết luận rằng không thể thực hiện một cái gì đó giống như readLinehoặc mã duy trì trạng thái có thể thay đổi. Vì vậy, họ chỉ đơn giản là không nhận thức được "mánh gian lận" mà các ngôn ngữ chức năng thuần túy có thể sử dụng để giới thiệu những điều này mà không phá vỡ tính minh bạch tham chiếu.

Ngoài ra, trạng thái có thể thay đổi được khuyến khích rất nhiều trong các ngôn ngữ chức năng, do đó, sẽ không có nhiều bước nhảy vọt khi cho rằng nó hoàn toàn không được phép trong các ngôn ngữ hoàn toàn chức năng.

Bạn có thể viết một đoạn mã (có thể nhỏ) minh họa cách thành ngữ Haskell để (1) thực hiện các tác dụng phụ và (2) thực hiện tính toán với trạng thái không?

Đây là một ứng dụng trong Pseudo-Haskell yêu cầu người dùng đặt tên và chào đón anh ta. Pseudo-Haskell là ngôn ngữ mà tôi vừa phát minh ra, có hệ thống IO của Haskell, nhưng sử dụng cú pháp thông thường hơn, tên hàm mô tả nhiều hơn và không có dochú thích (vì điều đó sẽ làm sao lãng chính xác hoạt động của đơn vị IO):

greet(name) = print("Hello, " ++ name ++ "!")
main = composeMonad(readLine, greet)

Manh mối ở đây là readLinemột giá trị của kiểu IO<String>composeMonadlà một hàm lấy một đối số của kiểu IO<T>(đối với một số loại T) và một đối số khác là một hàm lấy một đối số của kiểu Tvà trả về một giá trị của kiểu IO<U>(đối với một số loại U). printlà một hàm lấy một chuỗi và trả về giá trị của kiểu IO<void>.

Giá trị của loại IO<A>là một giá trị "mã hóa" một hành động nhất định tạo ra giá trị của loại A. composeMonad(m, f)tạo ra một IOgiá trị mới mã hóa hành động mtiếp theo là hành động của f(x), trong đó xgiá trị được tạo ra bằng cách thực hiện hành động của m.

Trạng thái có thể thay đổi sẽ trông như thế này:

counter = mutableVariable(0)
increaseCounter(cnt) =
    setIncreasedValue(oldValue) = setValue(cnt, oldValue + 1)
    composeMonad(getValue(cnt), setIncreasedValue)

printCounter(cnt) = composeMonad( getValue(cnt), print )

main = composeVoidMonad( increaseCounter(counter), printCounter(counter) )

Đây mutableVariablelà một hàm lấy giá trị của bất kỳ loại nào Tvà tạo ra một MutableVariable<T>. Hàm getValuelấy MutableVariablevà trả về một IO<T>giá trị tạo ra giá trị hiện tại của nó. setValuelấy a MutableVariable<T>và a Tvà trả về một IO<void>giá trị đặt. composeVoidMonadcũng giống như composeMonadngoại trừ rằng đối số thứ nhất là một đối số IOkhông tạo ra giá trị hợp lý và đối số thứ hai là một đơn vị khác, không phải là một hàm trả về một đơn nguyên.

Trong Haskell có một số đường cú pháp, làm cho toàn bộ thử thách này bớt đau đớn, nhưng rõ ràng trạng thái có thể thay đổi là điều mà ngôn ngữ không thực sự muốn bạn làm.


Câu trả lời tuyệt vời, làm rõ rất nhiều ý tưởng. Nên dòng cuối cùng của đoạn mã sử dụng tên counter, ví dụ increaseCounter(counter)?
Giorgio

@Giorgio Vâng, nó nên. Đã sửa.
sepp2k

1
@Giorgio Một điều tôi quên đề cập rõ ràng trong bài viết của mình là hành động IO được trả về mainsẽ là hành động thực sự được thực thi. Khác với việc trả lại IO từ mainđó, không có cách nào để thực thi IOcác hành động (mà không sử dụng các chức năng xấu khủng khiếp có unsafetrong tên của chúng).
sepp2k

ĐƯỢC. Scarfridge cũng đề cập đến IOgiá trị phá hủy . Tôi không hiểu liệu anh ta có đề cập đến khớp mẫu hay không, tức là thực tế là bạn có thể giải cấu trúc các giá trị của kiểu dữ liệu đại số, nhưng người ta không thể sử dụng khớp mẫu để làm điều này với IOcác giá trị.
Giorgio

16

IMHO bạn bối rối vì có sự khác biệt giữa ngôn ngữ thuần túy và chức năng thuần túy . Hãy để chúng tôi bắt đầu với chức năng. Một hàm là thuần nếu nó sẽ (được đưa ra cùng một đầu vào) luôn trả về cùng một giá trị và không gây ra bất kỳ tác dụng phụ nào có thể quan sát được. Ví dụ điển hình là các hàm toán học như f (x) = x * x. Bây giờ hãy xem xét việc thực hiện chức năng này. Nó sẽ là thuần túy trong hầu hết các ngôn ngữ ngay cả những ngôn ngữ thường không được coi là ngôn ngữ chức năng thuần túy, ví dụ ML. Ngay cả một phương thức Java hoặc C ++ với hành vi này cũng có thể được coi là thuần túy.

Vậy một ngôn ngữ thuần túy là gì? Nói một cách nghiêm túc người ta có thể mong đợi rằng một ngôn ngữ thuần túy không cho phép bạn thể hiện các chức năng không thuần túy. Chúng ta hãy gọi đây là định nghĩa duy tâm của một ngôn ngữ thuần túy. Một hành vi như vậy là rất mong muốn. Tại sao? Điều tốt đẹp về một chương trình chỉ bao gồm các hàm thuần túy là bạn có thể thay thế ứng dụng hàm bằng giá trị của nó mà không thay đổi ý nghĩa của chương trình. Điều này làm cho rất dễ dàng để lý luận về các chương trình bởi vì một khi bạn biết kết quả, bạn có thể quên cách nó được tính toán. Độ tinh khiết cũng có thể cho phép trình biên dịch thực hiện các tối ưu hóa tích cực nhất định.

Vì vậy, nếu bạn cần một số trạng thái nội bộ? Bạn có thể bắt chước trạng thái trong một ngôn ngữ thuần túy bằng cách thêm trạng thái trước khi tính toán làm tham số đầu vào và trạng thái sau khi tính toán như một phần của kết quả. Thay vì Int -> Boolbạn nhận được một cái gì đó như Int -> State -> (Bool, State). Bạn chỉ cần làm cho sự phụ thuộc rõ ràng (được coi là thực hành tốt trong bất kỳ mô hình lập trình nào). BTW có một đơn vị là một cách đặc biệt tao nhã để kết hợp các chức năng bắt chước trạng thái này thành các chức năng bắt chước trạng thái lớn hơn. Bằng cách này, bạn chắc chắn có thể "duy trì trạng thái" bằng một ngôn ngữ thuần túy. Nhưng bạn phải làm cho nó rõ ràng.

Vì vậy, điều này có nghĩa là tôi có thể tương tác với bên ngoài? Sau khi tất cả một chương trình hữu ích phải tương tác với thế giới thực để có ích. Nhưng đầu vào và đầu ra rõ ràng là không thuần túy. Viết một byte cụ thể vào một tệp cụ thể có thể tốt lần đầu tiên. Nhưng thực hiện chính xác thao tác lần thứ hai có thể trả về lỗi vì đĩa đã đầy. Rõ ràng không tồn tại ngôn ngữ thuần túy (theo nghĩa duy tâm) có thể ghi vào một tệp.

Vì vậy, chúng tôi phải đối mặt với một vấn đề nan giải. Chúng tôi muốn hầu hết các chức năng thuần túy nhưng một số tác dụng phụ là hoàn toàn bắt buộc và những tác dụng đó không thuần túy. Bây giờ một định nghĩa thực tế của một ngôn ngữ thuần túy sẽ là phải có một số phương tiện để tách các phần thuần túy khỏi các phần khác. Cơ chế phải đảm bảo rằng không có hoạt động không tinh khiết lén lút vào các bộ phận tinh khiết.

Trong Haskell, điều này được thực hiện với loại IO. Bạn không thể phá hủy kết quả IO (không có cơ chế không an toàn). Do đó, bạn chỉ có thể xử lý kết quả IO với các hàm được xác định trong chính mô đun IO. May mắn thay, có một tổ hợp rất linh hoạt cho phép bạn lấy kết quả IO và xử lý nó trong một chức năng miễn là chức năng đó trả về một kết quả IO khác. Tổ hợp này được gọi là liên kết (hoặc >>=) và có loại IO a -> (a -> IO b) -> IO b. Nếu bạn khái quát khái niệm này, bạn đến lớp đơn nguyên và IO là một thể hiện của nó.


4
Tôi thực sự không thấy Haskell (bỏ qua bất kỳ chức năng nào unsafetrong tên của nó) không đáp ứng định nghĩa lý tưởng của bạn. Không có chức năng không tinh khiết trong Haskell (một lần nữa bỏ qua unsafePerformIOvà đồng.).
sepp2k

4
readFilewriteFilesẽ luôn trả về cùng một IOgiá trị, được đưa ra cùng một đối số. Vì vậy, ví dụ hai đoạn mã let x = writeFile "foo.txt" "bar" in x >> xwriteFile "foo.txt" "bar" >> writeFile "foo.txt" "bar"sẽ làm điều tương tự.
sepp2k

3
@AidanCully Ý của "Hàm IO" là gì? Hàm trả về giá trị của loại IO Something? Nếu vậy, hoàn toàn có thể gọi một hàm IO hai lần với cùng một đối số: putStrLn "hello" >> putStrLn "hello"- ở đây cả hai lệnh gọi putStrLncó cùng một đối số. Tất nhiên đó không phải là vấn đề bởi vì, như tôi đã nói trước đó, cả hai cuộc gọi sẽ dẫn đến cùng một giá trị IO.
sepp2k

3
@scarfridge Đánh giá writeFile "foo.txt" "bar"không thể gây ra lỗi vì đánh giá lệnh gọi hàm không thực thi hành động. Nếu bạn nói rằng trong ví dụ trước của tôi, phiên bản letchỉ có một cơ hội gây ra lỗi IO trong khi phiên bản không letcó hai, thì bạn đã nhầm. Cả hai phiên bản đều có hai cơ hội cho một thất bại IO. Vì letphiên bản chỉ đánh giá cuộc gọi writeFilemột lần trong khi phiên bản không letđánh giá cuộc gọi hai lần, bạn có thể thấy rằng việc gọi hàm thường xuyên không quan trọng. Nó chỉ quan trọng tần suất kết quả ...
sepp2k

6
@AidanCully "Cơ chế đơn nguyên" không vượt qua các tham số ngầm. Các putStrLnchức năng có chính xác một đối số, đó là loại String. Nếu bạn không tin tôi, hãy nhìn vào loại của nó : String -> IO (). Nó chắc chắn không có bất kỳ đối số của loại IO- nó tạo ra một giá trị của loại đó.
sepp2k
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.