Nhìn chung # 1 và # 2 là không chính xác.
- Bất kỳ loại dữ liệu nào
* -> *
cũng có thể hoạt động như một nhãn hiệu, các đơn nguyên còn nhiều hơn thế.
- (Ngoại trừ
IO
đơn nguyên) các tính toán trong một đơn nguyên không phải là không trong sạch. Chúng chỉ đơn giản là đại diện cho các tính toán mà chúng tôi cho là có tác dụng phụ, nhưng chúng là thuần túy.
Cả hai sự hiểu lầm này đều đến từ việc tập trung vào IO
đơn nguyên, điều này thực sự hơi đặc biệt.
Tôi sẽ cố gắng xây dựng trên # 3 một chút, mà không đi vào lý thuyết thể loại nếu có thể.
Tính toán tiêu chuẩn
Tất cả các tính toán trong ngôn ngữ lập trình chức năng có thể được xem là các hàm với loại nguồn và loại mục tiêu : f :: a -> b
. Nếu một hàm có nhiều hơn một đối số, chúng ta có thể chuyển đổi nó thành hàm một đối số bằng cách currying (xem thêm wiki Haskell ). Và nếu chúng ta chỉ có một giá trị x :: a
(một hàm có 0 đối số), chúng ta có thể chuyển đổi nó thành một hàm lấy một đối số của loại đơn vị : (\_ -> x) :: () -> a
.
Chúng ta có thể xây dựng các chương trình phức tạp hơn tạo thành các chương trình đơn giản hơn bằng cách soạn các hàm đó bằng .
toán tử. Ví dụ, nếu chúng ta có f :: a -> b
và g :: b -> c
chúng ta nhận được g . f :: a -> c
. Lưu ý rằng điều này cũng hoạt động cho các giá trị được chuyển đổi của chúng tôi: Nếu chúng tôi có x :: a
và chuyển đổi nó thành đại diện của chúng tôi, chúng tôi nhận được f . ((\_ -> x) :: () -> a) :: () -> b
.
Đại diện này có một số tính chất rất quan trọng, cụ thể là:
- Chúng tôi có một chức năng rất đặc biệt - chức năng nhận dạng
id :: a -> a
cho từng loại a
. Đây là một yếu tố nhận dạng liên quan đến .
: f
bằng cả hai f . id
và id . f
.
- Các toán tử thành phần chức năng
.
là kết hợp .
Tính toán đơn trị
Giả sử chúng ta muốn chọn và làm việc với một số loại tính toán đặc biệt, kết quả của nó chứa thứ gì đó không chỉ là giá trị trả về đơn. Chúng tôi không muốn chỉ định "cái gì đó nhiều hơn" nghĩa là gì, chúng tôi muốn giữ mọi thứ chung chung nhất có thể. Cách tổng quát nhất để biểu diễn "cái gì đó nhiều hơn" là biểu diễn nó dưới dạng hàm kiểu - một kiểu m
loại * -> *
(tức là nó chuyển đổi loại này sang loại khác). Vì vậy, đối với mỗi loại tính toán mà chúng tôi muốn làm việc, chúng tôi sẽ có một số loại chức năng m :: * -> *
. (Trong Haskell, m
là []
, IO
, Maybe
, vv) Và các loại sẽ chứa tất cả các chức năng của các loại a -> m b
.
Bây giờ chúng tôi muốn làm việc với các hàm trong một danh mục như vậy giống như trong trường hợp cơ bản. Chúng tôi muốn có thể soạn các hàm này, chúng tôi muốn bố cục có tính liên kết và chúng tôi muốn có một danh tính. Chúng ta cần:
- Để có một toán tử (hãy gọi nó
<=<
) kết hợp các hàm f :: a -> m b
và g :: b -> m c
thành một cái gì đó như g <=< f :: a -> m c
. Và, nó phải được liên kết.
- Để có một số chức năng nhận dạng cho từng loại, hãy gọi nó
return
. Chúng tôi cũng muốn điều đó f <=< return
giống f
và giống như return <=< f
.
Bất kỳ m :: * -> *
cái gì chúng ta có chức năng như vậy return
và <=<
được gọi là một đơn nguyên . Nó cho phép chúng ta tạo ra các tính toán phức tạp từ những cái đơn giản hơn, giống như trong trường hợp cơ bản, nhưng bây giờ các loại giá trị trả về được định dạng bằng m
.
(Trên thực tế, tôi hơi lạm dụng danh mục thuật ngữ ở đây. Theo nghĩa lý thuyết phạm trù, chúng ta chỉ có thể gọi công trình của mình là một danh mục sau khi chúng ta biết nó tuân theo các luật này.)
Monads ở Haskell
Trong Haskell (và các ngôn ngữ chức năng khác), chúng tôi chủ yếu làm việc với các giá trị, không phải với các chức năng của các loại () -> a
. Vì vậy, thay vì xác định <=<
cho mỗi đơn nguyên, chúng tôi xác định một chức năng (>>=) :: m a -> (a -> m b) -> m b
. Một định nghĩa thay thế như vậy là tương đương, chúng ta có thể diễn đạt >>=
bằng cách sử dụng <=<
và ngược lại (thử như một bài tập, hoặc xem các nguồn ). Nguyên tắc ít rõ ràng hơn bây giờ, nhưng vẫn giữ nguyên: Kết quả của chúng tôi luôn là các loại m a
và chúng tôi soạn các hàm của các loại a -> m b
.
Đối với mỗi đơn nguyên chúng tôi tạo ra, chúng tôi không được quên kiểm tra điều đó return
và <=<
có các thuộc tính chúng tôi yêu cầu: tính kết hợp và nhận dạng trái / phải. Thể hiện bằng cách sử dụng return
và >>=
chúng được gọi là luật đơn nguyên .
Một ví dụ - danh sách
Nếu chúng ta chọn m
trở thành []
, chúng ta sẽ có một loại chức năng của các loại a -> [b]
. Các hàm như vậy đại diện cho các tính toán không xác định, có kết quả có thể là một hoặc nhiều giá trị, nhưng cũng không có giá trị. Điều này dẫn đến cái gọi là danh sách đơn nguyên . Thành phần f :: a -> [b]
và g :: b -> [c]
hoạt động như sau: g <=< f :: a -> [c]
có nghĩa là tính toán tất cả các kết quả có thể có của loại [b]
, áp dụng g
cho từng loại và thu thập tất cả các kết quả trong một danh sách. Thể hiện bằng Haskell
return :: a -> [a]
return x = [x]
(<=<) :: (b -> [c]) -> (a -> [b]) -> (a -> [c])
g (<=<) f = concat . map g . f
hoặc sử dụng >>=
(>>=) :: [a] -> (a -> [b]) -> [b]
x >>= f = concat (map f x)
Lưu ý rằng trong ví dụ này, các kiểu trả về [a]
là có thể chúng không chứa bất kỳ giá trị nào của loại a
. Thật vậy, không có yêu cầu nào đối với một đơn nguyên mà kiểu trả về phải có các giá trị như vậy. Một số đơn vị luôn có (thích IO
hoặc State
), nhưng một số không, thích []
hoặc Maybe
.
Đơn vị IO
Như tôi đã đề cập, IO
đơn nguyên có phần đặc biệt. Giá trị của loại IO a
có nghĩa là giá trị của loại được a
xây dựng bằng cách tương tác với môi trường của chương trình. Vì vậy (không giống như tất cả các đơn nguyên khác), chúng tôi không thể mô tả giá trị của loại IO a
bằng cách sử dụng một số cấu trúc thuần túy. Ở đây IO
chỉ đơn giản là một thẻ hoặc nhãn phân biệt các tính toán tương tác với môi trường. Đây là (trường hợp duy nhất) trong đó các quan điểm # 1 và # 2 là chính xác.
Đối với IO
đơn nguyên:
- Thành phần
f :: a -> IO b
và g :: b -> IO c
phương tiện: Tính toán f
tương tác với môi trường, sau đó tính toán g
sử dụng giá trị và tính kết quả tương tác với môi trường.
return
chỉ cần thêm IO
"thẻ" vào giá trị (chúng tôi chỉ đơn giản là "tính toán" kết quả bằng cách giữ nguyên môi trường).
- Các luật đơn nguyên (tính kết hợp, danh tính) được đảm bảo bởi trình biên dịch.
Một số lưu ý:
- Vì các tính toán đơn nguyên luôn có loại kết quả
m a
, nên không có cách nào để "thoát" khỏi IO
đơn nguyên. Ý nghĩa là: Một khi tính toán tương tác với môi trường, bạn không thể xây dựng một tính toán từ nó.
- Khi một lập trình viên chức năng không biết cách tạo ra thứ gì đó theo cách thuần túy, anh ta có thể (là phương sách cuối cùng ) lập trình nhiệm vụ bằng một số tính toán có trạng thái trong
IO
đơn nguyên. Đây là lý do tại sao IO
thường được gọi là thùng tội lỗi của lập trình viên .
- Lưu ý rằng trong một thế giới không trong sạch (theo nghĩa lập trình chức năng), việc đọc một giá trị cũng có thể thay đổi môi trường (như tiêu thụ đầu vào của người dùng). Đó là lý do tại sao các chức năng như
getChar
phải có một loại kết quả IO something
.