Những cách khác nhau để nhìn thấy một đơn nguyên


29

Trong khi học Haskell, tôi đã phải đối mặt với rất nhiều hướng dẫn cố gắng giải thích thế nào là đơn nguyên và tại sao các đơn vị lại quan trọng trong Haskell. Mỗi người trong số họ đã sử dụng các phép loại suy nên sẽ dễ dàng nắm bắt ý nghĩa hơn. Vào cuối ngày, tôi đã kết thúc với 3 quan điểm khác nhau về một đơn nguyên là gì:

Xem 1: Monad như một nhãn

Đôi khi tôi nghĩ rằng một đơn nguyên như một nhãn hiệu cho một loại cụ thể. Ví dụ: một hàm kiểu:

myfunction :: IO Int

chức năng của tôi là một chức năng mà bất cứ khi nào được thực hiện, nó sẽ mang lại một giá trị Int. Loại kết quả không phải là Int mà là IO Int. Vì vậy, IO là nhãn của giá trị Int cảnh báo người dùng biết rằng giá trị Int là kết quả của một quá trình trong đó một hành động IO đã được thực hiện.

Do đó, giá trị Int này đã được đánh dấu là giá trị xuất phát từ một quá trình với IO do đó giá trị này là "bẩn". Quá trình của bạn không còn thuần khiết nữa.

Xem 2: Monad như một không gian riêng tư nơi những điều khó chịu có thể xảy ra.

Trong một hệ thống mà tất cả các quy trình là thuần túy và nghiêm ngặt đôi khi bạn cần phải có tác dụng phụ. Vì vậy, một đơn nguyên chỉ là một không gian nhỏ cho phép bạn thực hiện các tác dụng phụ khó chịu. Trong không gian này, bạn được phép thoát khỏi thế giới thuần khiết, không trong sạch, thực hiện quy trình của bạn và sau đó trở lại với một giá trị.

Xem 3: Monad như trong lý thuyết thể loại

Đây là quan điểm mà tôi không hiểu đầy đủ. Một đơn vị chỉ là một functor cho cùng một thể loại hoặc một thể loại phụ. Ví dụ: bạn có các giá trị Int và dưới dạng một thể loại con IO Int, đó là các giá trị Int được tạo sau một quá trình IO.

Những quan điểm này có đúng không? Cái nào chính xác hơn?


5
# 2 không phải là những gì một đơn nguyên nói chung. Trên thực tế, nó bị hạn chế khá nhiều đối với IO và không phải là một chế độ xem hữu ích (xem What a Monad is not ). Ngoài ra, "nghiêm ngặt" thường được sử dụng để đặt tên cho một tài sản mà Haskell không sở hữu (cụ thể là đánh giá nghiêm ngặt). Nhân tiện, Monads cũng không thay đổi điều đó (một lần nữa, hãy xem What a Monad is not).

3
Về mặt kỹ thuật, chỉ có thứ ba là chính xác. Monad là endofunctor, đối với hoạt động đặc biệt được xác định - khuyến mãi và ràng buộc. Các đơn vị rất nhiều - một danh sách đơn vị là ví dụ hoàn hảo để có được trực giác đằng sau các đơn vị. cơ sở readS thậm chí còn tốt hơn. Đáng ngạc nhiên là đủ, các đơn vị có thể sử dụng như các công cụ để xử lý trạng thái trong ngôn ngữ chức năng thuần túy. Đây không phải là một thuộc tính xác định của các đơn nguyên: đó là sự trùng hợp ngẫu nhiên, việc phân luồng trạng thái có thể được thực hiện theo các điều khoản của chúng. Áp dụng tương tự cho IO.
permeakra

Lisp thông thường có trình biên dịch riêng như một phần của ngôn ngữ. Haskell có Monads.
Will Ness

Câu trả lời:


33

Nhìn chung # 1 và # 2 là không chính xác.

  1. 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ế.
  2. (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 -> bg :: b -> cchú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 :: avà 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ạngid :: a -> a cho từng loại a. Đây là một yếu tố nhận dạng liên quan đến .: fbằng cả hai f . idid . f.
  • Các toán tử thành phần chức năng .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 mloạ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[], 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 bg :: b -> m cthà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 <=< returngiống fvà giống như return <=< f.

Bất kỳ m :: * -> *cái gì chúng ta có chức năng như vậy return<=<đượ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 avà 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<=<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>>=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 mtrở 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]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 gcho 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 IOhoặ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 acó nghĩa là giá trị của loại được axâ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 abằng cách sử dụng một số cấu trúc thuần túy. Ở đây IOchỉ đơ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 bg :: b -> IO cphương tiện: Tính toán ftương tác với môi trường, sau đó tính toán gsử dụng giá trị và tính kết quả tương tác với môi trường.
  • returnchỉ 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 ý:

  1. 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ó.
  2. 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 IOthường được gọi là thùng tội lỗi của lập trình viên .
  3. 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ư getCharphải có một loại kết quả IO something.

3
Câu trả lời chính xác. Tôi muốn làm rõ rằng IOkhông có ngữ nghĩa đặc biệt từ quan điểm ngôn ngữ. Nó không đặc biệt, nó hoạt động như bất kỳ mã nào khác. Chỉ thực hiện thư viện thời gian chạy là đặc biệt. Ngoài ra, có một cách đặc biệt để thoát ( unsafePerformIO). Tôi nghĩ điều này rất quan trọng vì mọi người thường nghĩ về IOmột yếu tố ngôn ngữ đặc biệt hoặc thẻ khai báo. Không phải vậy.
usr

2
@usr Điểm tốt. Tôi muốn thêm rằng unsafePerformIO thực sự không an toàn và chỉ nên được sử dụng bởi các chuyên gia. Nó cho phép bạn phá vỡ mọi thứ, ví dụ, bạn có thể tạo một hàm coerce :: a -> bchuyển đổi bất kỳ hai loại nào (và làm hỏng chương trình của bạn trong hầu hết các trường hợp). Xem ví dụ này - bạn có thể chuyển đổi ngay cả một chức năng thành Intvv
Petr Pudlák

Một đơn vị "ma thuật đặc biệt" khác sẽ là ST, cho phép bạn khai báo các tham chiếu đến bộ nhớ mà bạn có thể đọc và ghi vào khi bạn thấy phù hợp (mặc dù chỉ trong đơn nguyên), và sau đó bạn có thể trích xuất kết quả bằng cách gọirunST :: (forall s. GHC.ST.ST s a) -> a
sara

5

Xem 1: Monad như một nhãn

"Do đó, giá trị Int này đã được đánh dấu là giá trị xuất phát từ một quá trình với IO do đó giá trị này là" bẩn "."

"IO Int" nói chung không phải là giá trị Int (mặc dù có thể trong một số trường hợp như "return 3"). Đây là một thủ tục đưa ra một số giá trị Int. Các thực thi khác nhau của "thủ tục" này có thể mang lại các giá trị Int khác nhau.

Một đơn nguyên m, là một "ngôn ngữ lập trình" nhúng (bắt buộc): trong ngôn ngữ này có thể định nghĩa một số "thủ tục". Một giá trị đơn âm (thuộc loại ma), là một thủ tục trong "ngôn ngữ lập trình" này tạo ra giá trị của loại a.

Ví dụ:

foo :: IO Int

là một số thủ tục đưa ra giá trị của kiểu Int.

Sau đó:

bar :: IO (Int, Int)
bar = do
  a <- foo
  b <- foo
  return (a,b)

là một số thủ tục đưa ra hai Ints (có thể khác nhau).

Mỗi "ngôn ngữ" như vậy hỗ trợ một số thao tác:

  • hai thủ tục (ma và mb) có thể được "nối": bạn có thể tạo một thủ tục lớn hơn (ma >> mb) được thực hiện từ quy trình đầu tiên sau đó đến quy trình thứ hai;

  • những gì đầu ra (a) của cái đầu tiên có thể ảnh hưởng đến cái thứ hai (ma >> = \ a -> ...);

  • một thủ tục (return x) có thể mang lại một số giá trị không đổi (x).

Các ngôn ngữ lập trình nhúng khác nhau khác nhau về các loại mà chúng hỗ trợ, chẳng hạn như:

  • mang lại giá trị ngẫu nhiên;
  • "rèn" (đơn vị []);
  • trường hợp ngoại lệ (ném / bắt) (The Either e monad);
  • tiếp tục rõ ràng / hỗ trợ callcc;
  • gửi / nhận tin nhắn cho các "đại lý" khác;
  • tạo, đặt và đọc các biến (cục bộ cho ngôn ngữ lập trình này) (đơn vị ST).

1

Đừng nhầm lẫn một loại đơn nguyên với lớp đơn nguyên.

Một loại đơn nguyên (tức là một loại là một thể hiện của lớp đơn nguyên) sẽ giải quyết một vấn đề cụ thể (về nguyên tắc, mỗi loại đơn nguyên giải quyết một loại khác nhau): Trạng thái, Ngẫu nhiên, Có thể, IO. Tất cả chúng đều là các loại có ngữ cảnh (cái mà bạn gọi là "nhãn", nhưng đó không phải là thứ khiến chúng trở thành một đơn nguyên).

Đối với tất cả trong số họ, có nhu cầu "hoạt động chuỗi với sự lựa chọn" (một hoạt động phụ thuộc vào kết quả của trước đó). Ở đây đi vào chơi lớp đơn nguyên: có loại của bạn (giải quyết một vấn đề nhất định) là một thể hiện của lớp đơn nguyên và vấn đề chuỗi được giải quyết.

Xem lớp đơn nguyên giải quyết gì?

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.