Haskell #16: Monoid

Chương 17. Monad

17.1. Dẫn nhập

Trong chương này, chúng ta sẽ làm quen với Monad, vốn là các Applicative trừu tượng, cũng giống như bản thân Applicative là Functor được trừu tượng hóa.

Khi làm quen với Functor, chúng ta đã thấy rằng có thể ánh xạ các hàm lên nhiều kiểu dữ liệu khác nhau. Để phục vụ mục đích này, khi ta có hàm $f$ thuộc kiểu $a \rightarrow b$ và kiểu dữ liệu nào đó $f a$, thì để ánh xạ $f$ lên $f a$ để thu được $f b$, ta định nghĩa hàm fmap:

fmap :: (Functor f) => (a -> b) -> f a -> f b

Đặt trường hợp phức tạp hơn, giả sử $f (a \rightarrow b)$ được bọc trong một Functor $F$ (ví dụ: Just (*3)) và ta muốn ánh xạ (*3) lên Just 5, hoặc [(*2),(+4)] lên [1,2,3] thì khái niệm Applicative được ra đời và giúp ta giải quyết nhu cầu trên bằng cách định nghĩa hàm <*>:

(<*>) :: (Applicative f) => f (a -> b) -> f a -> f b

Dễ thấy rằng ta có thể lấy một giá trị bình thường và gói nó vào trong một kiểu dữ liệu nào đó. Chẳng hạn, 1 có thể được gói lại thành Just 1, hoặc [1], …

Như đã nói, một Applicative chính là một giá trị được gói trong một ngữ cảnh. 'a' là một kí tự thông thường nhưng Just 'a' là Maybe Char, ngữ cảnh Maybe cho ta biết rằng giá trị trong đó có thể là một kí tự hoặc không gì cả.

ghci> (*) <$> Just 2 <*> Just 8
Just 16
ghci> (++) <$> Just "klingon" <*> Nothing
Nothing
ghci> (-) <$> [3,4] <*> [1,2,3]
[2,1,0,3,2,1]

Monad là một sự mở rộng dựa trên Applicative; với Monad, ta có nhu cầu sau: nếu bạn có một giá trị được gói trong ngữ cảnh m a, chúng ta ánh xạ m a lên hàm $f$ nhận vào a và trả về b được gói trong m (a -> m b). Chúng ta thực hiện bằng cách định nghĩa hàm >>=:

(>>=) :: (Monad m) => m a -> (a -> m b) -> m b

Hàm >>= được đọc là bind.

17.2. Maybe Monad

Maybe cũng là một Monad, khi xem Maybe là Functor, ta thấy rằng nếu muốn ánh xạ hàm lên nó (đã được định nghĩa bằng fmap), thì Functor Maybe sẽ ánh xạ lên tất cả các phần tử trong nó nếu nó là Just, còn nếu không thì chỉ là Nothing.

ghci> fmap (++"!") (Just "wisdom")
Just "wisdom!"
ghci> fmap (++"!") Nothing
Nothing

Giống Applicative, Monad hoạt động tương tự như vậy. Tuy nhiên Applicative có hàm được gói lại. Maybe là một Applicative sao cho khi ta dùng <*> để ánh xạ hàm được gói trong Maybe lên một giá trị được gọi trong Maybe thì chúng phải cùng là Just nếu muốn có kết quả là Just, ngược lại kết quả là Nothing:

ghci> Just (+3) <*> Just 3
Just 6
ghci> Nothing <*> Just "greed"
Nothing
ghci> Just ord <*> Nothing
Nothing
ghci> max <$> Just 3 <*> Just 6
Just 6
ghci> max <$> Just 3 <*> Nothing
Nothing

Bây giờ, hãy xét đến >>= trong Maybe. Như đã nói, >>= nhận một Monad, hàm $f$ nhận vào giá trị thông thường - trả về một Monad. Để $f$ có thể ánh xạ lên Monad nếu $f$ chỉ nhận giá trị thường, phải tính đến ngữ cảnh của giá trị Monad đó.

Trong trường hợp này, giả sử ta có hàm \x -> Just (x + 1). Nó nhận vào một số, cộng với 1 rồi gói kết quả vào trong Just:

ghci> (\x -> Just (x+1)) 1
Just 2

Bây giờ mới là chỗ khó: làm sao để đưa một giá trị Maybe vào trong hàm này? Nếu Maybe là Applicative thì sẽ dễ dàng, nếu đưa vào giá trị Just thì nó lấy thứ ở bên trong Just rồi áp dụng hàm lên thứ đó. Nếu ta đưa vào một Nothing, thì kết quả là Nothing.

Ta gọi applyMaybe là một phiên bản cụ thể của >>= trong trường hợp của Maybe:

applyMaybe :: Maybe a -> (a -> Maybe b) -> Maybe b
applyMaybe Nothing f  = Nothing
applyMaybe (Just x) f = f x
ghci> Just 3 `applyMaybe` \x -> Just (x+1)
Just 4
ghci> Nothing `applyMaybe` \x -> Just (x+1)
Nothing

Ở ví dụ trên, ta thấy rằng khi dùng applyMaybe với Just và một hàm thì đơn giản là hàm sẽ được ánh xạ lên giá trị bên trong Just. Khi ta cố gắng dùng nó với Nothing, toàn bộ kết quả là Nothing:

ghci> Just 3 `applyMaybe` \x -> if x > 2 then Just x else Nothing
Just 3
ghci> Just 1 `applyMaybe` \x -> if x > 2 then Just x else Nothing
Nothing

Nếu Monad ở bên trái là Nothing thì cả biểu thức lớn là Nothing, nếu hàm bên phải trả về một Nothing, thì kết quả vẫn là Nothing.

Có thể bạn đang tự hỏi, có vẻ Applicative thì mạnh hơn Monad vì Applicative cho phép ta lấy ánh xạ hàm lên các giá trị kèm ngữ cảnh. Ta sẽ thấy rằng Monad cũng có thể làm điều này vì chúng chính là Applicative, và chúng còn có thể làm những thứ hay ho mà Applicative không thể.

17.3. Monad

Đây là định nghĩa về Monad:

class Monad m where
    return :: a -> m a

    (>>=) :: m a -> (a -> m b) -> m b

    (>>) :: m a -> m b -> m b
    x >> y = x >>= \_ -> y

    fail :: String -> m a
    fail msg = error msg

Hàm đầu tiên Monad định nghĩa là return. Nó giống như hàm pure, chỉ khác ở tên gọi. return nhận một giá trị rồi đặt vào trong ngữ cảnh tối thiểu. Nói cách khác, nó nhận vào một thứ gì đó rồi bọc trong một Monad. Với Maybe, nó nhận một giá trị rồi bọc vào trong Just.

Một lưu ý là return không hề giống return trong đa số các ngôn ngữ khác.

Hàm tiếp theo là >>=, nó định nghĩa cách ánh xạ $f$ lên Monad, nhưng thay vì lấy một giá trị thường rồi đưa vào hàm thông thường, nó lấy một Monad rồi đưa nó vào hàm nhận một giá trị thường nhưng trả về một giá trị Monad.

Tiếp theo, ta có >>. Hãy xét đến nó sau.

Hàm cuối cùng của lớp Monad là fail. Haskell sẽ dùng nó để cho phép việc tính toán thất bại trong một cấu trúc cú pháp đặc biệt đối với Monad mà sau này ta sẽ gặp.

Tiếp theo là định nghĩa về Maybe Monad:

instance Monad Maybe where
    return x = Just x
    Nothing >>= f = Nothing
    Just x >>= f  = f x
    fail _ = Nothing

return cũng giống như pure, hàm >>= cũng giống như applyMaybe. Khi đưa Maybe a vào hàm này, ta cần để ý ngữ cảnh và trả về Nothing nếu giá trị vế trái là Nothing vì nếu không có giá trị ở đây thì sẽ không thể ánh xạ hàm. Nếu giá trị là Just x thì hàm sẽ gỡ bọc, lấy thứ bên trong ra rồi đem ánh xạ.

ghci> return "WHAT" :: Maybe String
Just "WHAT"
ghci> Just 9 >>= \x -> return (x*10)
Just 90
ghci> Nothing >>= \x -> return (x*10)
Nothing

Lưu ý cách đưa Just 9 vào hàm \x -> return (x*10), x lấy giá trị 9 mà vẫn không mất ngữ cảnh Maybe, vì khi xNothing, thì kết quả sẽ là Nothing.

Pierre quyết định nghỉ việc tại một trại cá và thử việc đi trên dây. Anh ấy không hề kém, nhưng chỉ gặp một vấn đề: những con chim luôn đậu vào cây sào dùng để giữ thăng bằng. Chúng đậu vào một lúc để nghỉ ngơi, chuyện trò và rồi lại tung cánh để kiếm tìm những mẩu vụn bánh mì. Điều này cũng sẽ chẳng làm Pierre bận tâm nếu như số chim đậu bên trái cây sào luôn bằng đúng với số chim đậu bên phải. nhưng đôi khi tất cả lũ chim quyết định đậu vào một bên và rồi làm anh ta mất thăng bằng và ngã nhào (xuống lưới bảo vệ).

Giả sử rằng anh đã giữ thăng bằng nhờ việc số chim đậu bên trái sào bằng không hơn kém so với bên phải là ba chú chim. Như vậy nếu có một con chim bên phải và bốn ở bên trái, thì anh ấy vẫn ổn. Nhưng nếu con chim thứ năm đậu vào bên trái, thì anh sẽ mất thăng bằng và ngã.

Chúng ta sẽ mô phỏng việc chim đậu xuống và bay khỏi sào rồi xem liệu rằng Pierre có giữ được thăng bằng không sau khi một số chim nhất định bay đến rồi bay đi. Chẳng hạn, ta sẽ xem rằng điều gì xảy đến với Pierre khi con chim thứ nhất bay đên đậu vào bên trái, rồi bốn con chim đến đậu vào bên phải sau đó con chim đã đậu vào bên trái quyết định bay đi.

Ta có thể biểu diễn cây sào chỉ bằng một cặp số nguyên. Số thứ nhất để chỉ số chim đậu bên trái còn số bên phải để chỉ số chim bên phải:

type Birds = Int
type Pole = (Birds,Birds)

Trước hết ta lập đồng kiểu với Int, Birds, là số lượng chim. Tiếp theo (Birds,Birds) được gọi là Pole.

Ta sẽ tạo các hàm nhận lấy số chim và đặt chúng lên từng phía của cây sào. Sau đây là các hàm này:

landLeft :: Birds -> Pole -> Pole
landLeft n (left, right) = (left + n, right)

landRight :: Birds -> Pole -> Pole
landRight n (left, right) = (left, right + n)

Khá là thuận lợi. Ta hãy thử dùng các hàm này xem:

ghci> landLeft 2 (0,0)
(2,0)
ghci> landRight 1 (1,2)
(1,3)
ghci> landRight (-1) (1,2)
(1,1)

Để xua chim bay đi ta chỉ cần lấy một số âm làm số chim đậu lên một bên sào. Vì việc chim đậu trên Pole sẽ trả về Pole, nên ta có thể ánh xạ landLeft và landRight liên tiếp:

ghci> landLeft 2 (landRight 1 (landLeft 1 (0,0)))
(3,1)

Nếu ta có một hàm như sau:

x -: f = f x

thì ta có thể áp dụng các hàm bằng cách trước hết là các tham số rồi mới đến hàm. Bằng việc này, ta có thể lặp lại việc cho chim đậu lên hai đầu sào theo cách dễ đọc hơn:

ghci> (0,0) -: landLeft 1 -: landRight 1 -: landLeft 2
(3,1)

Ở đây, sự rõ ràng đã thể hiện khi ta bắt đầu với (0,0) rồi cho một con chim đậu lên đầu trái, một con ở đầu phải, rồi sau cùng là hai con ở đầu trái.

Nếu 10 con chim đậu lên một đầu

ghci> landLeft 10 (0,3)
(10,3)

10 con chim đậu lên đầu trái trong khi chỉ có 3 con ở phải? Chắc chắn như vậy sẽ làm anh Pierre tội nghiệp ngã nhào! Điều này dễ thấy rồi, nhưng điều gì sẽ xảy ra nếu ta có chuỗi chim đậu như sau:

ghci> (0,0) -: landLeft 1 -: landRight 4 -: landLeft (-1) -: landRight (-2)
(0,2)

Nạn sẽ thấy rằng ở bước 3 sẽ có 4 con chim ở đầu phải và không có con chim nào ở đầu trái! Để sửa điều này, ta phải sửa hàm landLeftlandRight. Hai hàm này sẽ trả về Pole nếu sào vẫn thăng bằng, nhưng thất bại nếu mất cân đối.

landLeft :: Birds -> Pole -> Maybe Pole
landLeft n (left, right)
    | abs ((left + n) - right) < 4 = Just (left + n, right)
    | otherwise                    = Nothing

landRight :: Birds -> Pole -> Maybe Pole
landRight n (left,right)
    | abs (left - (right + n)) < 4 = Just (left, right + n)
    | otherwise                    = Nothing

Thay vì trả về Pole, hàm trả về Maybe Pole:

ghci> landLeft 2 (0,0)
Just (2,0)
ghci> landLeft 10 (0,3)
Nothing

Khi sử dụng phương pháp này, ta không thể ánh xạ hàm lên tiếp như landLeft 1 (landRight 1 (0,0)) nữa vì chúng ta không lấy được Pole, mà là Maybe Pole.

Như vậy, ta cần nhận Maybe Pole và đưa nó vào hàm nhận Pole rồi trả về một Maybe Pole. Điều này có vẻ khớp với hàm >>=:

ghci> landRight 1 (0,0) >>= landLeft 2
Just (2,1)
ghci> Nothing >>= landLeft 2
Nothing

Cần nhớ rằng, landLeft 2 có kiểu Pole -> Maybe Pole. Ta không thể đưa trực tiếp kết quả trả về của landRight 1 (0,0) vì nó có kiểu Maybe Pole,

Với cách này, bây giờ ta có thể xâu các hàm vì >>= cho phép ta đưa một giá trị Monad vào một hàm nhận giá trị thường.

ghci> return (0,0) >>= landRight 2 >>= landLeft 2 >>= landRight 2
Just (2,4)

Ngay ở đầu, ta dùng return để gói Pole vào trong Just. Just (0,0) được đưa vào landRight 2, trả về Just (0,2), lại đưa vào landLeft 2, trả về Just (2,2), và cuối cùng là Just (2,4).

Đối với trường hợp 2 dây không cân bằng:

ghci> return (0,0) >>= landLeft 1 >>= landRight 4 >>= landLeft (-1) >>= landRight (-2)
Nothing

Trước hết, Just (1,4) được đưa vào landLeft (-1), nghĩa là landLeft (-1) (1,4) trả về Nothing.Khi Nothing được đưa vào landRight (-2), kết quả đương nhiên sẽ là Nothing.

Ta cũng có thể lập một hàm để phớt lờ số chim hiện có trên cây sào thăng bằng mà chỉ nhằm làm cho Pierre trượt ngã. Ta sẽ gọi hàm này là banana:

banana :: Pole -> Maybe Pole
banana _ = Nothing

Bây giờ ta có thể xâu chuỗi nó lại cùng với các hàm chỉ định cho chim đậu. Nó sẽ luôn làm cho người đi trên dây bị ngã, vì hàm banana luôn trả về Nothing. Hãy kiểm tra này:

ghci> return (0,0) >>= landLeft 1 >>= banana >>= landRight 1
Nothing

Thay vì sử dụng hàm banana, thì ta có thể dùng hàm >>:

(>>) :: (Monad m) => m a -> m b -> m b
m >> n = m >>= \_ -> n

Sau đây là cách mà >> hoạt động với Maybe:

ghci> Nothing >> Just 3
Nothing
ghci> Just 3 >> Just 4
Just 4
ghci> Just 3 >> Nothing
Nothing

Nếu thay thế >> bằng >>= \_ ->, bạn sẽ dễ thấy được tại sao nó lại có hành vi như trên.

Ta có thể thay thế banana bằng >> Nothing:

ghci> return (0,0) >>= landLeft 1 >> Nothing >>= landRight 1
Nothing

17.3. do Monad

Trong Haskell, một số Monad có tên riêng (như do). Ta đã làm quen với do khi thực hiện I/O và do được sử dụng để nối nhiều thao tác I/O với nhau. Hóa ra cách viết do còn dùng được cho bất kì Monad nào đó là xâu chuỗi các giá trị Monad lại với nhau.

Hãy xét ví dụ thường gặp sau đây:

ghci> Just 3 >>= (\x -> Just (show x ++ "!"))
Just "3!"

Ta đã gặp ví dụ này rồi, đưa một Monad vào hàm trả về một Monad. Bây giờ, nếu ta có >>= khác trong hàm.

ghci> Just 3 >>= (\x -> Just "!" >>= (\y -> Just (show x ++ y)))
Just "3!"

Ở lambda ngoài cùng, ta đưa Just "!" cho lambda \y -> Just (show x ++ y). y trở thành "!", x3 vì ta lấy nó từ lambda bên ngoài. Tất cả những điều tương tự với biểu thức sau:

ghci> let x = 3; y = "!" in show x ++ y
"3!"

Sự khác biệt là các giá trị trong ví dụ thứ nhất đều là Monad.

ghci> Nothing >>= (\x -> Just "!" >>= (\y -> Just (show x ++ y)))
Nothing
ghci> Just 3 >>= (\x -> Nothing >>= (\y -> Just (show x ++ y)))
Nothing
ghci> Just 3 >>= (\x -> Just "!" >>= (\y -> Nothing))
Nothing

Ở dòng lệnh thứ nhất, việc đưa Nothing vào một hàm sẽ có kết quả Nothing. Ở dòng thứ hai, đưa Just 3 vào một hàm và x trở thành 3 nhưng sau đó ta đưa Nothing vào cho lambda phía trong và kết quả là Nothing, và khiến cho lambda phía ngoài cũng cho ra Nothing. Như vậy việc này cũng tựa như gán các giá trị vào các biến trong biểu thức let, chỉ khác là các giá trị ở đây là những Monad.

Để minh họa rõ hơn về điểm này, ta có ví dụ sau:

foo :: Maybe String
foo = Just 3   >>= (\x ->
      Just "!" >>= (\y ->
      Just (show x ++ y)))

Để giúp tất cả ngắn gọn lại, Haskell cho phép dùng từ khóa do:

foo :: Maybe String
foo = do
    x <- Just 3
    y <- Just "!"
    Just (show x ++ y)

Nếu có bất kì giá trị là Nothing thì toàn bộ biểu thức do sẽ trả về Nothing. Điều quan trọng là nhớ rằng các do chỉ là cú pháp khác dùng để xâu chuỗi các Monad.

Trong một biểu thức do, mỗi dòng là một Monad. Nếu ta có một Maybe String và dùng <- để gán vào một biến, thì biến này sẽ là String, giống như khi dùng >>= để đưa các Monad vào trong lambda. Giá trị Monad cuối cùng trong biểu thức do, Just (show x ++ y) không thể dùng với <- vì nó là kết quả của các Monad đã được hợp lại.

Chẳng hạn, hãy xét dòng sau:

ghci> Just 9 >>= (\x -> Just (x > 8))
Just True

Khi viết lại:

marySue :: Maybe Bool
marySue = do 
    x <- Just 9
    Just (x > 8)

Quá trình cân bằng còn có thể được diễn đạt bằng do, quay trở lại ví dụ trước đó:

routine :: Maybe Pole
routine = do
    start <- return (0,0)
    first <- landLeft 2 start
    second <- landRight 2 first
    landLeft 1 second
ghci> routine
Just (3,2)

Khi viết hẳn ra >>=, thì ta ghi return (0,0) >>= landLeft 2, vì landLeft 2 là một hàm trả về Maybe. Tuy vậy, với các biểu thức do thì mỗi dòng phải là một Monad.

Vì các biểu thức trong do được viết theo từng dòng, chúng có tính tuần tự, mỗi giá trị trên từng dòng lại dựa vào kết quả của những giá trị trước cùng với ngữ cảnh của chúng.

Nếu ta muốn ném vỏ chuối vào bước đi của Pierre bằng việc can thiệp vào khối lệnh do, ta có thể viết như sau:

routine :: Maybe Pole
routine = do
    start <- return (0,0)
    first <- landLeft 2 start
    Nothing
    second <- landRight 2 first
    landLeft 1 second

Khi một Monad không được gán bằng <- sẽ tương đương với việc sử dụng >>.

Trong do, khi gán Monad vào các biến, ta có thể tận dụng pattern matching, let, …

justH :: Maybe Char
justH = do
    (x:xs) <- Just "hello"
    return x

Ta dùng pattern matching để lấy kí tự thứ nhất từ chuỗi "hello", justH trả về Just 'h'.

Lưu ý rằng nếu tất cả các pattern bị bỏ sót thì chương trình bị crash. Khi thất bại trong việc matching xảy ra trong biểu thức do thì hàm fail sẽ được gọi. Hàm fail mặc định như sau:

fail :: (Monad m) => String -> m a
fail msg = error msg

Theo mặc định, hàm này sẽ làm chương trình crash, nhưng trong các Monad cụ thể, fail có sự thay đổi như sau:

fail _ = Nothing

fail luôn trả về Nothing khi pattern matching thất bại trong do:

wopwop :: Maybe Char
wopwop = do
    (x:xs) <- Just ""
    return x
ghci> wopwop
Nothing

Haskell #17: Monad (kế)