Nếu các ngôn ngữ lập trình chức năng không thể lưu bất kỳ trạng thái nào, thì làm cách nào để chúng thực hiện một số công việc đơn giản như đọc đầu vào từ người dùng (ý tôi là làm cách nào để chúng "lưu trữ" nó) hoặc lưu trữ bất kỳ dữ liệu nào cho vấn đề đó?
Như bạn đã thu thập, lập trình chức năng không có trạng thái — nhưng điều đó không có nghĩa là nó không thể lưu trữ dữ liệu. Sự khác biệt là nếu tôi viết một câu lệnh (Haskell) dọc theo dòng
let x = func value 3.14 20 "random"
in ...
Tôi đảm bảo rằng giá trị của x
luôn giống nhau ở chỗ ...
: không gì có thể thay đổi được. Tương tự, nếu tôi có một hàm f :: String -> Integer
(một hàm lấy một chuỗi và trả về một số nguyên), tôi có thể chắc chắn rằng nó f
sẽ không sửa đổi đối số của nó, hoặc thay đổi bất kỳ biến toàn cục nào hoặc ghi dữ liệu vào tệp, v.v. Như sepp2k đã nói trong một nhận xét ở trên, tính không thể thay đổi này thực sự hữu ích cho việc lập luận về các chương trình: bạn viết các hàm gập, trục chính và cắt xén dữ liệu của mình, trả về các bản sao mới để bạn có thể chuỗi chúng lại với nhau và bạn có thể chắc chắn rằng không của những lời gọi hàm có thể làm bất cứ điều gì "có hại". Bạn biết điều đó x
luôn là như vậy x
, và bạn không cần phải lo lắng rằng ai đó đã viếtx := foo bar
ở đâu đó giữa tuyên bố củax
và việc sử dụng nó, bởi vì điều đó là không thể.
Bây giờ, nếu tôi muốn đọc thông tin đầu vào từ người dùng thì sao? Như KennyTM đã nói, ý tưởng là một hàm không tinh khiết là một hàm thuần túy được truyền toàn bộ thế giới dưới dạng đối số và trả về cả kết quả của nó và thế giới. Tất nhiên, bạn không muốn thực sự làm điều này: đối với một điều, nó kỳ quặc kinh khủng, và đối với một điều khác, điều gì sẽ xảy ra nếu tôi sử dụng lại cùng một đối tượng thế giới? Vì vậy, điều này được trừu tượng hóa bằng cách nào đó. Haskell xử lý nó với loại IO:
main :: IO ()
main = do str <- getLine
let no = fst . head $ reads str :: Integer
...
Điều này cho chúng ta biết rằng đó main
là một hành động IO không trả về gì; thực hiện hành động này là ý nghĩa của việc chạy chương trình Haskell. Quy tắc là các loại IO không bao giờ có thể thoát khỏi một hành động IO; trong ngữ cảnh này, chúng tôi giới thiệu hành động đó bằng cách sử dụng do
. Do đó, getLine
trả về một IO String
, có thể được coi là theo hai cách: thứ nhất, như một hành động, khi chạy, tạo ra một chuỗi; thứ hai, là một chuỗi bị IO "làm bẩn" vì nó được lấy không thuần khiết. Cách đầu tiên đúng hơn, nhưng cách thứ hai có thể hữu ích hơn. Việc <-
lấy String
ra IO String
và cất vào str
— nhưng vì chúng ta đang trong một hành động IO, nên chúng ta sẽ phải bọc nó lại, vì vậy nó không thể "thoát ra". Dòng tiếp theo cố gắng đọc một số nguyên ( reads
) và lấy kết quả khớp thành công đầu tiên (fst . head
); cái này hoàn toàn là nguyên chất (không có IO), vì vậy chúng tôi đặt tên cho nó là let no = ...
. Sau đó chúng ta có thể sử dụng cả hai no
và str
trong ...
. Do đó, chúng tôi đã lưu trữ dữ liệu không tinh khiết (từ getLine
vào str
) và dữ liệu thuần túy ( let no = ...
).
Cơ chế làm việc với IO này rất mạnh mẽ: nó cho phép bạn tách phần thuần túy, thuật toán của chương trình khỏi phần không tinh khiết, tương tác với người dùng và thực thi điều này ở cấp loại. minimumSpanningTree
Chức năng của bạn không thể thay đổi điều gì đó ở đâu đó khác trong mã của bạn hoặc viết thông báo cho người dùng của bạn, v.v. Nó an toàn.
Đây là tất cả những gì bạn cần biết để sử dụng IO trong Haskell; nếu đó là tất cả những gì bạn muốn, bạn có thể dừng ở đây. Nhưng nếu bạn muốn hiểu tại sao điều đó lại hiệu quả, hãy tiếp tục đọc. (Và lưu ý rằng nội dung này sẽ dành riêng cho Haskell — các ngôn ngữ khác có thể chọn cách triển khai khác.)
Vì vậy, điều này có vẻ như là một chút gian lận, bằng cách nào đó đã thêm tạp chất vào Haskell nguyên chất. Nhưng không phải vậy - hóa ra chúng ta có thể triển khai kiểu IO hoàn toàn trong Haskell thuần túy (miễn là chúng ta được cung cấp RealWorld
). Ý tưởng là thế này: một hành động IO IO type
giống như một hàm RealWorld -> (type, RealWorld)
, lấy thế giới thực và trả về cả đối tượng kiểu type
và đối tượng đã sửa đổi RealWorld
. Sau đó, chúng tôi xác định một vài hàm để chúng tôi có thể sử dụng kiểu này mà không bị điên:
return :: a -> IO a
return a = \rw -> (a,rw)
(>>=) :: IO a -> (a -> IO b) -> IO b
ioa >>= fn = \rw -> let (a,rw') = ioa rw in fn a rw'
Điều đầu tiên cho phép chúng ta nói về các hành động IO không làm gì cả: return 3
là một hành động IO không truy vấn thế giới thực và chỉ trả về 3
. Các >>=
nhà điều hành, phát âm là "ràng buộc", cho phép chúng ta chạy hành động IO. Nó trích xuất giá trị từ hành động IO, chuyển nó và thế giới thực thông qua hàm và trả về hành động IO kết quả. Lưu ý rằng >>=
thực thi quy tắc của chúng tôi rằng kết quả của các hành động IO không bao giờ được phép thoát ra ngoài.
Sau đó, chúng ta có thể biến những điều trên main
thành một tập hợp các ứng dụng chức năng thông thường sau:
main = getLine >>= \str -> let no = (fst . head $ reads str :: Integer) in ...
Bước nhảy thời gian chạy Haskell bắt đầu main
với bước đầu tiên RealWorld
và chúng tôi đã thiết lập! Mọi thứ đều tinh khiết, nó chỉ có một cú pháp lạ mắt.
[ Chỉnh sửa: Như @Conal đã chỉ ra , đây thực sự không phải là những gì Haskell sử dụng để thực hiện IO. Mô hình này sẽ bị phá vỡ nếu bạn thêm đồng thời hoặc thực sự là bất kỳ cách nào để thế giới thay đổi giữa một hành động IO, vì vậy Haskell sẽ không thể sử dụng mô hình này. Nó chỉ chính xác cho tính toán tuần tự. Do đó, có thể IO của Haskell hơi né tránh; ngay cả khi nó không, nó chắc chắn không hoàn toàn thanh lịch. Theo quan sát của @ Conal, hãy xem Simon Peyton-Jones nói gì trong Tackling the Awesome Squad [pdf] , phần 3.1; anh ấy trình bày những gì có thể tương ứng với một mô hình thay thế dọc theo những dòng này, nhưng sau đó bỏ nó vì độ phức tạp của nó và thực hiện một cách giải quyết khác.]
Một lần nữa, điều này giải thích (khá nhiều) cách IO và khả năng thay đổi nói chung hoạt động trong Haskell; nếu đây là tất cả những gì bạn muốn biết, bạn có thể dừng đọc ở đây. Nếu bạn muốn một liều lý thuyết cuối cùng, hãy tiếp tục đọc — nhưng hãy nhớ rằng, tại thời điểm này, chúng tôi đã thực sự đi rất xa câu hỏi của bạn!
Vì vậy, điều cuối cùng: hóa ra cấu trúc này - một kiểu tham số với return
và >>=
- rất chung chung; nó được gọi là đơn nguyên, do
ký hiệu return
và >>=
hoạt động với bất kỳ đơn nguyên nào. Như bạn đã thấy ở đây, monads không phải là phép thuật; tất cả những gì kỳ diệu là do
các khối biến thành các lệnh gọi hàm. Các RealWorld
loại là nơi duy nhất chúng ta thấy bất kỳ ma thuật. Các loại như []
, hàm tạo danh sách, cũng là monads và chúng không liên quan gì đến mã không tinh khiết.
Bây giờ bạn biết (gần như) mọi thứ về khái niệm đơn nguyên (ngoại trừ một số định luật phải được thỏa mãn và định nghĩa toán học chính thức), nhưng bạn thiếu trực giác. Có rất nhiều hướng dẫn đơn nguyên trực tuyến vô lý; Tôi thích cái này , nhưng bạn có các tùy chọn. Tuy nhiên, điều này có thể sẽ không giúp bạn ; cách thực sự duy nhất để có được trực giác là kết hợp sử dụng chúng và đọc một vài hướng dẫn vào đúng thời điểm.
Tuy nhiên, bạn không cần trực giác đó để hiểu IO . Hiểu một cách tổng quát các monads một cách tổng quát đang đóng băng, nhưng bạn có thể sử dụng IO ngay bây giờ. Bạn có thể sử dụng nó sau khi tôi chỉ cho bạn main
chức năng đầu tiên . Bạn thậm chí có thể coi mã IO như thể nó ở một ngôn ngữ không tinh khiết! Nhưng hãy nhớ rằng có một đại diện chức năng cơ bản: không ai gian lận.
(Tái bút: Xin lỗi về độ dài. Tôi đã đi hơi xa một chút.)