Chương 11: Functor, Functor áp dụng và Monoid

Trở về Mục lục cuốn sách

Sự kết hợp của tính thuần túy, hàm cấp cao, những kiểu dữ liệu đại số được tham số hóa, và lớp trong Haskell đã cho phép ta thực hiện phép đa hình trên cấp độ cao hơn nhiều so với ở các ngôn ngữ khác. Ta không phải hình dung kiểu thuộc về một hệ thống phân cấp kiểu đồ sộ. Thay vào đó, ta nghĩ xem những kiểu đóng vai trò là gì rồi kết nối chúng lại bằng những lớp phù hợp. Một kiểu Int có thể đóng vai trò của nhiều thứ khác nhau. Nó có thể đóng vai trò của thứ so sánh ngang bằng, của thứ xếp được theo trật tự, của thứ liệt kê được, v.v.

Lớp có tính mở, nghĩa là ta có thể định nghĩa những kiểu dữ liệu riêng, nghĩa là ta có thể định nghĩa những kiểu dữ liệu riêng, hình dung về vai trò của nó và kết nối kiểu dư liệu này tới những lớp có nhiệm vụ quy định các hành vi của kiểu. Vì lý do trên và vì do Haskell có hệ thống kiểu rất tốt trong đó ta có thể biết được nhiều thông tin vè một hàm chỉ qua việc biết lời khai báo kiểu của hàm đó, mà ta có thể định nghĩa các lớp để quy định những hành vi rất tổng quát và trừu tượng. Ta đã gặp những lớp quy định các phép toán để xét xem hai thứ có bằng nhau không, hoặc so sánh hai thứ dựa trên một thứ tự nào đó. Những hành vi này rất trừu tượng và “đẹp”, nhưng bạn đừng nghĩ rằng chúng quá đặc biệt, vì chúng ta bắt gặp những hành vi này trong suốt phần lớn thời gian lập trình. Gần đây ta gặp functor; về cơ bản đó là những thứ có thể được ánh xạ lên. Đó là một ví dụ của một thuộc tính trừu tượng hữu dụng và khá đẹp mà lớp có thể mô tả. Ở chương này, ta sẽ xem xét kĩ hơn về functor, cùng với những dạng mạnh hơn và hữu dụng hơn của functor có tên là functor áp dụng. Ta cũng xem qua một khái niệm tương tự, đó là monoid.

Functor hồi sinh

frogs dont even need money

Ta đã nhắc đến functor trong một mục nhỏ rồi. Nếu bạn vẫn chưa đọc mục này thì bây có thể nên xem qua, hoặc có thể sau này khi có thêm thời gian. Hoặc bạn có thể chỉ việc giả vờ rằng mình đã đọc rồi.

Dù sao thì tôi cũng nhắc qua: Functor là những thứ mà có thể được ánh xạ lên, nhưng danh sách, các Maybe, cây, và những thứ như vậy. Trong Haskell, chúng được mô tả bởi lớp Functor, vốn chỉ có duy nhất một phương thức tên là fmap, vốn có kiểu fmap :: (a -> b) -> f a -> f b. Lời khai báo kiểu này phát biểu rằng: hãy cho tôi một hàm nhận một a và trả lại một b, cùng một hộp với một (hoặc nhiều) a bên trong nó rồi tôi sẽ trả lại bạn một hộp với một (hoặc nhiều) b trong đó. Đại loại là nó áp dụng hàm vào các phần tử bên trong cái hộp này.

Một lời khuyên. Nhiều khi sự tương tự với cái hộp được dùng đến để giúp ta có thêm trực giác về cách hoạt động của functor, và sau này, ta sẽ có thể dùng cách nói tương tự như thế đối với các functor áp dụng và monad. Để giúp ta hiểu được functor ban đầu thì cũng ổn, nhưng bạn đừng hiểu quá máy móc, vì với một số functor thì cái “hộp” phải được giãn dài ra mới đảm bảo đúng nghĩa được. Một thuật ngữ chính xác hơn cho functor có thể sẽ là ngữ cảnh tính toán. Ngữ cảnh này có thể là việc tính toán có thể nhận một giá trị hoặc nó có thể thất bại (MaybeEither a) hoặc có thể có thêm những giá trị nữa (danh sách), đại loại như vậy.

Nếu ta muốn tạo lập một constructor kiểu là thực thể của Functor thì constructor kiểu này phải có kiểu * -> *, nghĩa là nó phải nhận đúng một kiểu cụ thể làm tham số kiểu. Chảng hạn, Maybe có thể làm thành một thực thể nó nhận mootj tham số kiểu để tạo ra một kiểu cụ thể, như Maybe Int hay Maybe String. Nếu một constructor kiểu nhận hai tham số, như Either thì ta phải áp dụng từng phần constructor kiểu đến khi nó chỉ còn nhận một tham số kiểu. Vì vậy, ta không thể viết instance Functor Either where, nhưng lại có thể viết instance Functor (Either a) where và rồi nếu ta hình dung rằng fmap chỉ là dành cho Either a, thì nó sẽ phải có một lời khai báo kiểu là fmap :: (b -> c) -> Either a b -> Either a c. Bạn thấy đấy, phần Either a là cố định, vì Either a chỉ nhận một tham số kiểu, trong khi một mình Either sẽ nhận hai tham số kiểu, vì vậy màfmap :: (b -> c) -> Either b -> Either c không có ý nghĩa gì.

Đến giờ ta đã học được cách mà rất nhiều kiểu (à, thực ra là constructor kiểu) là thực thể của Functor, như [], Maybe, Either a và một kiểu Tree mà ta đã tự tạo ra. Ta đã thấy cách ánh xạ các hàm lên chúng tốt như thế nào. Trong mục này, ta sẽ xem hai thực thể functor khác, tên là IO(->) r.

Nếu một giá trị nào đó có kiểu là IO String chẳng hạn, thì điều này có nghĩa nó là một hành động I/O mà khi được thực hiện, thì sẽ nhảy ra môi trường ngoài và lấy cho ta một chuỗi nào đó, mà nó sẽ trả lại làm kết quả. Ta có thể dùng <- trong cú pháp do để gắn kết quả đó vào một tên gọi. Chúng tôi có nhắc đến rằng các thao tác I/O giống như những chiếc hộp có chân để chạy ra lấy cho ta một giá trị nào đó từ môi trường bên ngoài. Ta có thể điều tra xem chúng đã lấy được gì, nhưng sau khi điều tra, ta lại phải gói giá trị vào trong IO. Bằng cách hình dung về chiếc hộp nhỏ có chân, ta có thể thấy cách mà IO đóng vai trò như một functor.

Ta hãy xem IO là thực thể của Functor như thế nào. Khi fmap một hàm lên một thao tác I/O, là lúc ta muốn lấy lại một thao tác I/O để thực hiện điều tương tự, nhưng với hàm hiện có được áp dụng cho giá trị thu được.

instance Functor IO where
    fmap f action = do
        result <- action
        return (f result)

Kết quả của việc ánh xạ một thứ nào đó lên một thao tác I/O sẽ là một thao tác I/O, vì vậy ta dùng ngay cú pháp do để dính hai thao tác lại với nhau thành một thao tác mới. Trong phần lập trình cho fmap, ta đã tạo mới thao tác I/O mới để đầu tiên là thực hiện thao tác I/O gốc và rồi gọi kết quả result của nó. Tiếp theo, ta viết return (f result). Bạn biết rồi đấy, return là một hàm để tạo ra một thao tác I/O không làm gì cả mà chỉ trưng ra một kết quả là gì đó. Thao tác mà khối do tạo ra thì sẽ luôn có giá trị kết quả là thao tác cuối của nó. Điều này lý giải tại sao ta dùng “return” để tạo ra một thao tác I/O chẳng để làm gì cả, nó chỉ trình bày f result là kết quả của thao tác I/O mới.

Ta có thể nghịch với fmap để nâng cao trực giác của mình. Thực ra nó khá đơn giản. Hãy xét đoạn mã lệnh sau:

main = do line <- getLine 
          let line' = reverse line
          putStrLn $ "You said " ++ line' ++ " backwards!"
          putStrLn $ "Yes, you really said" ++ line' ++ " backwards!"

Người dùng được nhắc để nhập vào một dòng chữ và máy sẽ trả lại nó cho người dùng dòng chữ đảo ngược lại. Sau đây là cách viết lại hàm này có dùng fmap:

main = do line <- fmap reverse getLine
          putStrLn $ "You said " ++ line ++ " backwards!"
          putStrLn $ "Yes, you really said" ++ line ++ " backwards!"

w00ooOoooOO

Cũng như khi ta fmap reverse đối với Just "blah" để thu được Just "halb", ta có thể fmap reverse đối với getLine. getLine là một thao tác I/O có kiểu IO String và ánh xạ reverse lên nó đã cho ta một thao tác I/O mà sẽ đi ra môi trường bên ngoài để lấy một dòng chữ rồi áp dụng reverse vào cho kết quả của nó. Cũng như ta có thể áp dụng một hàm cho thứ bên trong hộp Maybe, ta cũng có thể áp dụng một hàm cho thứ bên trong một hộp IO, chỉ khác là nó phải đi ra môi trường ngoài để lấy. Sau đó khi ta gắn nó vào với một tên gọi, bằng cách dùng <-, thì tên gọi sẽ phản ánh kết quả mà đã được áp dụng reverse lên.

Thao tác I/O fmap (++"!") getLine biểu hiện như là getLine, chỉ khác là kết quả của nó luôn có dấu "!" gắn ở đuôi!

Nếu ta xem xét kiểu của fmap sẽ là gì nếu nó bị giới hạn vào IO, thì đây: fmap :: (a -> b) -> IO a -> IO b. fmap nhận một hàm và một thao tác I/O rồi trả lại một thao tác I/O gần giống như cái cũ, chỉ khác ở chỗ hàm được áp dụng đối với kết quả chứa trong nó.

Nếu bạn đã từng ở vào tình trạng cần gắn kết quả của một thao tác I/O vào một tên gọi, chỉ để áp dụng một hàm cho nó và gọi thứ mới tạo ra bằng một tên khác, thì bạn có thể dùng fmap, vì nó trông đẹp hơn. Nếu bạn muốn áp dụng nhiều phép biến đổi với dữ liệu nào đó trong một functor thì bạn có thể khai báo hàm riêng của mình ở cấp chương trình cao nhất, tạo một hàm lambda, hoặc lý tưởng nhất là, dùng hàm hợp:

import Data.Char
import Data.List

main = do line <- fmap (intersperse '-' . reverse . map toUpper) getLine
          putStrLn line
$ runhaskell fmapping_io.hs
hello there
E-R-E-H-T- -O-L-L-E-H

Có thể bạn đã biết, intersperse '-' . reverse . map toUpper là một hàm nhận một chuỗi, ánh xạ toUpper lên nó, sau đó áp dụng reverse đối với kết quả này rồi áp dụng intersperse '-' lên kết quả mới tìm được. Như thế cũng giống như viết (\xs -> intersperse '-' (reverse (map toUpper xs))), chỉ có điều là đẹp hơn.

Một trường hợp khác của Functor mà ta vẫn thường làm việc cùng suốt mà có thể vẫn chưa biết, đó là Functor có dạng (->) r. Có thể lúc này bạn đã hơi bối rối: cái (->) r này là quái gì? Kiểu hàm r -> a có thể được viết lại thành (->) r a, rất giống với việc ta có thể viết 2 + 3 thành (+) 2 3. Khi coi nó như là (->) r a, ta có thể thấy (->) theo khía cạnh hơi khác, vì ta thấy rằng nó chỉ là một constructor kiểu nhận vào hai tham số kiểu, cũng như Either. Nhưng cần nhớ, ta nói rằng một constructor kiểu muốn là thực thể của Functor thì buộc phải nhận đúng một tham số kiểu. Điều này lý giải tại sao ta không thể làm cho (->) trở thành một thực thể của Functor, nhưng nếu ta áp dụng nó từng phần cho (->) r, thì sẽ không sao. Nếu cú pháp cho phép constructor kiểu có áp dụng từng phần được với các đoạn (như ta có thể áp dụng từng phần + bằng cách viết (2+), tức là giống như (+) 2), thì bạn có thể viết (->) r như là (r ->). Vậy còn các functor của hàm? À, ta hãy xem cách tạo lập chúng, ở trong Control.Monad.Instances

Với các hàm nhận vào bất kì thứ gì và trả lại bất kì thứ gì, ta thường đánh dấu chúng bằng a -> b. Còn r -> a cũng tương tự, ta chỉ dùng chữ cái khác để biểu diễn các biến chứa kiểu.
instance Functor ((->) r) where
    fmap f g = (\x -> f (g x))

Giá mà cú pháp của Haskell cho phép điều này, thì ta có thể viết thành

instance Functor (r ->) where
    fmap f g = (\x -> f (g x))

Nhưng thực ra thì không, vì vậy ta đã phải viết theo cách ban đầu.

Trước hết, hãy nghĩ về kiểu của fmap. Đó là kiểu fmap :: (a -> b) -> f a -> f b. Bây giờ điều mà ta sẽ làm là hình dung việc thay thế toàn bộ các f, là vai trò đảm nhiệm bởi các thực thể functor đang xét, bằng các (->) r. Ta sẽ làm như vậy để thấy được rằng fmap sẽ biểu hiện như thế nào trong trường hợp cụ thể này. Ta nhận được fmap :: (a -> b) -> ((->) r a) -> ((->) r b). Bây giờ việc ta có thể làm là viết các kiểu (->) r a(-> r b) dưới hình thức trung tố r -> ar -> b, như ta vẫn thường làm đối với hàm. Thứ mà ta nhận được là fmap :: (a -> b) -> (r -> a) -> (r -> b).

Hừm được rồi. Ánh xạ một hàm này lên một hàm khác sẽ phải tạo ra một hàm, cũng như ánh xạ một hàm lên một Maybe phải tạo ra một Maybe, còn ánh xạ một hàm lên một danh sách ắt sẽ phải tạo ra danh sách. Thế còn kiểu fmap :: (a -> b) -> (r -> a) -> (r -> b) của thực thể này báo cho ta điều gì? À, ta thấy rằng nó nhận một hàm từ a đến b và một hàm từ r đến a rồi trả lại một hàm từ r đến b. Việc này có gợi cho bạn điều gì không? Đúng rồi! Hàm hợp! Ta dẫn kết quả đầu ra của r -> a đến đầu vào của a -> b để nhận một hàm r -> b, tức là chính xác một hàm hợp. Nếu bạn nhìn vào cách mà thực thể này được định nghĩa ở trên, thì bạn sẽ thấy được nó chỉ là một hàm hợp. Một cách khác để viết thực thể này sẽ là:

instance Functor ((->) r) where
    fmap = (.)

Điều này cho thấy hiển nhiên là việc dùngfmap lên các hàm chỉ là cách dùng hàm hợp. Bạn hãy gõ vào :m + Control.Monad.Instances, vì đó là nơi định nghĩa các thực thể, rồi thử ngợi chơi với cách ánh xạ lên các hàm.

ghci> :t fmap (*3) (+100)
fmap (*3) (+100) :: (Num a) => a -> a
ghci> fmap (*3) (+100) 1
303
ghci> (*3) `fmap` (+100) $ 1
303
ghci> (*3) . (+100) $ 1
303
ghci> fmap (show . (*3)) (*100) 1
"300"

Ta có thể gọi fmap theo hình thức một hàm trung tố để cho giống hẳn với .   . Ở dòng lệnh thứ hai, ta đã ánh xạ (*3) lên (+100), và kết quả là một hàm để nhận dữ liệu đầu vào, gọi (+100) lên dữ liệu đó rồi gọi (*3) lên kết quả mới tìm được. Ta gọi hàm nói trên bằng 1 [số 1].

Vậy ở đây sự tương đồng với “chiếc hộp” thể hiện ở chỗ nào? Ồ, nếu bạn kéo giãn nó ra, thì sự tương đồng trên vẫn còn đúng chứ. Khi ta dùng fmap (+3) lên Just 3, có thể dễ dàng tưởng tượng Maybe như là một cái hộp chứa thứ gì đó mà ta sẽ áp dụng hàm (+3). Nhưng thế còn khi ta viết fmap (*3) (+100)? À, bạn có thể hình dung hàm (+100) như cái hộp có chứa kết quả cuối cùng của nó. Giống như cách mà ta hình dung một thao tác I/O như một cái hộp chạy ra môi trường bên ngoài để thu lượm kết quả nào đó. Bằng cách dùng fmap (*3) lên (+100) ta sẽ tạo ra một hàm khác hoạt động như là (+100), nhưng khác ở chỗ trước khi đưa ra kết quả, thì sẽ áp dụng (*3) đối vơí kết quả. Bây giờ hãy xem cách mà fmap đóng vai trò như toán tử . với các hàm.

Việc fmap đóng vai trò là hàm hợp khi dùng với các hàm ngay bây giờ thì chưa có gì quá ghê gớm cả, nhưng ít nhất thì cũng là điều hay. Nó cũng khiến ta phải suy nghĩ và cho ta thấy những thứ đóng vai trò của phép tính toán hơn là những cái hộp (IO(->) r) có thể là các functor. Hàm sẽ được ánh xạ lên một kết quả tính toán trong cùng một phép tính toán nhưng kết quả của tính toán đó được sửa đổi bằng hàm này.

lifting a function is easier than lifting a million pounds

Trước khi ta tiếp tục tìm hiểu những quy tắc mà fmap phải tuân theo, ta hãy một lần nữa hình dung về kiểu của fmap. Kiểu của nó là fmap :: (a -> b) -> f a -> f b. Ở đây thiếu mất ràng buộc về lớp (Functor f) =>, như ta đã lược bỏ nó cho gọn, vì dù sao ta cũng đang nói về các functor vì vậy ta biết là f đại diện cho thứ gì. Khi lần đầu ta biết về hàm curry, ta đã nói rằng tất cả mọi hàm trong Haskell thực ra đều chỉ nhận một tham số. Một hàm a -> b -> c thực ra chỉ nhận một tham số có kiểu a rồi trả lại một hàm b -> c, hàm mới này nhận một tham số và trả lại một c. Đó là cách mà nếu ta gọi một hàm với không đủ tham số (nghĩa là áp dụng [hàm] từng phần), thì ta sẽ thu được một hàm nhận vào số còn lại những tham số còn thiếu (nếu ta vẫn nghĩ về hàm số nhận vào nhiều tham số). Vì vậy a -> b -> c có thể được viết là a -> (b -> c), để làm cho đặc tính curry được rõ ràng hơn.

Theo tinh thần tương tự, nếu viết fmap :: (a -> b) -> (f a -> f b), ta có thể hình dung fmap không phải là một hàm nhận một hàm khác và một functor rồi trả về một functor mới, mà là một hàm nhận vào một hàm rồi trả lại một hàm mới gần giống như cũ, chỉ khác là nó nhận tham số là một functor rồi trả lại kết quả là một functor. fmap nhận một hàm a -> b rồi trả lại một hàm f a -> f b. Việc này được gọi là nâng hàm lên. Ta hãy nghịch chơi ý tưởng này bằng cách dùng lệnh :t của GHCI:

ghci> :t fmap (*2)
fmap (*2) :: (Num a, Functor f) => f a -> f a
ghci> :t fmap (replicate 3)
fmap (replicate 3) :: (Functor f) => f a -> f [a]

Biểu thức fmap (*2) là một hàm nhận vào một functor f trên các số rồi trả về một functor cũng trên các số. Functor này có thể là một danh sách, một Maybe , một Either String, hoặc bất kì thứ gì. Biểu thức fmap (replicate 3) sẽ nhận một functor với bất kì kiểu nào và trả lại một functor trên một danh sách các phần tử có cùng kiểu đó.

Khi nói đến một functor trên các con số, bạn có thể hình dung như là một functor có chứa các con số trong đó. Thuật ngữ đầu thì hay hơn một chút và cũng chính xác về mặt kĩ thuật hơn, nhưng cách nói thứ hai thì dễ hiểu hơn.

Điều này thậm chí còn dễ thấy hơn nếu ta áp dụng từng phần, chẳng hạn fmap (++"!") rồi gắn nó vào một tên gọi trong GHCI.

Bạn có thể hình dung fmap như là một hàm nhận một hàm khác và một functor rồi ánh xạ hàm khác đó lên functor, hoặc cũng có thể hình dung nó như một hàm nhận một hàm khác rồi nâng hàm đó lên để nó hoạt động được với các functor. Cả hai quan điểm này đều đúng và tương đương nhau trong Haskell.

Kiểu fmap (replicate 3) :: (Functor f) => f a -> f [a] có nghĩa là hàm này sẽ hoạt động với bất kì functor nào. Điều cụ thể mà nó làm phụ thuộc vào functor mà ta dùng nó với. Nếu ta dùng fmap (replicate 3) với một danh sách, thì dạng fmap thực hiện với danh sách sẽ được chọn lấy, vốn đơn giản chỉ là map. Nếu ta dùng với Maybe a, thì nó sẽ áp dụng replicate 3 đối với giá trị chứa trong Just, hoặc nếu là Nothing, thì nó sẽ chỉ là Nothing.

ghci> fmap (replicate 3) [1,2,3,4]
[[1,1,1],[2,2,2],[3,3,3],[4,4,4]]
ghci> fmap (replicate 3) (Just 4)
Just [4,4,4]
ghci> fmap (replicate 3) (Right "blah")
Right ["blah","blah","blah"]
ghci> fmap (replicate 3) Nothing
Nothing
ghci> fmap (replicate 3) (Left "foo")
Left "foo"

Tiếp theo, ta sẽ xét đến các định luật functor. Một thứ muốn là functor thì nó phải tuân theo một số định luật nhất định. Tất cả functor đều được trông đợi phải bộc lộ những hành vi và thuộc tính nhất định có ở functor. Chúng phải biểu hiện, một cách tin cậy, dưới dạng những thứ có thể ánh xạ lên được. Việc gọi fmap lên một functor sẽ chỉ ánh xạ một làm lên functor, không hơn không kém. Hành vi này được định nghĩa trong các định luật đối với functor. Có hai định luật mà tất cả mọi thực thể Functor đều phải tuân theo. Các định luật này không phải do Haskell kiểm soát, vì vậy bạn phải tự tay kiểm tra lấy.

Định luật thứ nhất đối với functor phát biểu rằng nếu ta ánh xạ hàm id lên một functor, thì functor mà ta thu được phải giống với functor ban đầu. Nếu ta viết theo cách toán học, thì fmap id = id. Như vậy căn bản nhất, điều này có nghĩa là nếu ta viết fmap id lên một functor, thì nó sẽ phải giống như là chỉ gọi id lên functor đó. Hãy nhớ rằng, id là hàm đồng nhất (identity); hàm này đơn giản là trả về y nguyên tham số được đưa vào. Nó cũng có thể được viết thành \x -> x. Nếu ta xem functor như là thứ có thể được ánh xạ lên, thì định luật fmap id = id có vẻ quá tầm thường, hoặc dễ thấy.

Ta hãy xem rằng liệu định luật này có đúng với một số giá trị các functor hay không.

ghci> fmap id (Just 3)
Just 3
ghci> id (Just 3)
Just 3
ghci> fmap id [1..5]
[1,2,3,4,5]
ghci> id [1..5]
[1,2,3,4,5]
ghci> fmap id []
[]
ghci> fmap id Nothing
Nothing

Nếu nhìn vào cách thực hiện của fmap đối với chẳng hạn, Maybe, thì ta có thể hình dung ra vì sao định luật thứ nhất thỏa mãn.

instance Functor Maybe where
    fmap f (Just x) = Just (f x)
    fmap f Nothing = Nothing

Ta hình dung được là id đóng vai trò của tham số f trong cách thực hiện nói trên. Ta thấy rằng nếu fmap id lên Just x, kết quả sẽ là Just (id x), và vì id chỉ việc trả lại tham số của nó, nên ta có thể suy ra Just (id x) bằng Just x. Như vậy bây giờ ta biết rằng nếu ánh xạ id lên một giá trị Maybe với một constructor giá trị Just, thì ta sẽ nhận lại cùng giá trị đó.

Thấy được rằng việc ánh xạ id lên một giá trị Nothing trả lại cùng giá trị này là điều nhỏ nhặt. Như vậy từ hai phương trình trong phần thực hiện của fmap, ta đã thấy rằng định luật fmap id = id được thỏa mãn.

justice is blind, but so is my dog

Định luật thứ hai phát biểu rằng việc hợp hai hàm rồi ánh xạ hàm kết quả lên một functor phải giống như với việc đầu tiên là ánh xạ một hàm lên functor rồi ánh xạ hàm thứ hai. Viết theo cách hình thức, điều đó nghĩa là fmap (f . g) = fmap f . fmap g. Hoặc viết theo cách khác, với một functor F bất kì, điều sau đây phải thỏa mãn: fmap (f . g) F = fmap f (fmap g F).

Nếu cho thấy được rằng một kiểu nào đó khác tuân theo cả hai định luật functor, thì ta có thể dựa vào việc kiểu này có cùng những biểu hiện cơ bản như những functor khác, khi thực hiện ánh xạ. Ta có thể biết rằng khi dùng fmap lên nó, sẽ không có gì ngoài ánh xạ được diễn ra phía sau và nó sẽ đóng vai trò như một thứ có thể được ánh xạ lên, nghĩa là một functor. Bạn hình dung ra cách mà định luật thứ hai thỏa mãn với một kiểu dữ liệu nào đó bằng cách xem xét đoạn mã lệnh thực hiện của fmap đối với kiểu này rồi dùng phương pháp mà ta đã dùng để kiểm tra xem liệu Maybe có tuân theo định luật thứ nhất không.

Nếu bạn muốn, ta có thể kiểm tra xem bằng cách nào mà định luật functor thứ hai thỏa mãn với Maybe. Nếu thực hiện fmap (f . g) lên Nothing, ta sẽ nhận được Nothing, vì thực hiện một fmap với bất kì hàm nào lên Nothing sẽ trả lại Nothing. Nếu viết fmap f (fmap g Nothing), ta sẽ thu được Nothing, với cùng lý do. Được rồi, việc thấy được làm thế nào mà định luật thứ hai thỏa mãn được Maybe nếu nó là một giá trị Nothing thật dễ dàng, gần như là điều tầm thường.

Nhưng nếu nó là một giá trị Just something thì sao? À, nếu ta viết fmap (f . g) (Just x), thì căn cứ vào phần mã lệnh thực hiện, ta sẽ thấy rằng biểu thức trên sẽ được viết thành Just ((f . g) x), tức là, Just (f (g x)). Nếu ta viết fmap f (fmap g (Just x)), thì căn cứ vào phần mã lệnh thực hiện, ta sẽ thấy rằng fmap g (Just x)Just (g x). Ergo, fmap f (fmap g (Just x)) bằng fmap f (Just (g x)) và từ đoạn mã lệnh thực hiện, ta thấy biểu thức này bằng Just (f (g x)).

Nếu bạn còn bối rối với phần chứng minh nói trên, đừng lo lắng. Hãy chắc rằng bạn hiểu được cách hoạt động của hàm hợp. Nhiều khi bạn có thể dùng trực giác để thấy được những định luật này thỏa mãn vì các kiểu dữ liệu đóng vai trò như những hộp chứa hoặc các hàm. Bạn cũng có thể chỉ thử dùng chúng với một loạt giá trị thuộc một kiểu dữ liệu để đánh giá được ở mức độ nhất định, là kiểu dữ liệu đó đúng là tuân theo các định luật hay không.

Ta hãy xem xét một ví dụ ấn tượng về một constructor kiểu là thực thể của lớp Functor nhưng lại không thực sự là một functor, vì nó không thỏa mãn các định luật. Giả dụ rằng ta có một kiểu:

data CMaybe a = CNothing | CJust Int a deriving (Show)

Ở đây, C đóng vai trò biến đếm. Nó là một kiểu dữ liệu rất giống với Maybe a, chỉ khác là phần Just có chứa hai trường thay vì một. Trường thứ nhất trong constructor giá trị CJust sẽ luôn có kiểu là Int, và nó sẽ như là một biến đếm còn trường thứ hai có kiểu a, vốn bắt nguồn từ tham số kiểu và kiểu của nó dĩ nhên sẽ phụ thuộc vào kiểu cụ thể mà ta chọn cho CMaybe a. Ta hãy nghịch chơi với kiểu dữ liệu mới lập để hiểu thêm về nó.

ghci> CNothing
CNothing
ghci> CJust 0 "haha"
CJust 0 "haha"
ghci> :t CNothing
CNothing :: CMaybe a
ghci> :t CJust 0 "haha"
CJust 0 "haha" :: CMaybe [Char]
ghci> CJust 100 [1,2,3]
CJust 100 [1,2,3]

Nếu ta dùng constructor CNothing, thì sẽ không có trường nào, còn nếu dùng constructor CJust, thì trường thứ nhất là một số nguyên và trường thứ hai có kiểu bất kì. Ta hãy làm cho constructor này trở thành một thực thể của Functor để mỗi khi dùng đến fmap, hàm sẽ được áp dụng cho trường thứ hai, trong khi trường thứ nhất được tăng thêm 1.

instance Functor CMaybe where
    fmap f CNothing = CNothing
    fmap f (CJust counter x) = CJust (counter+1) (f x)

Điều này cũng giống như cách tạo lập thực thể cho Maybe, nhưng có khác là khi ta thực hiện fmap lên một giá trị mà không biểu thị một hộp rỗng (tức là biểu thị một giá trị CJust), ta không chỉ áp dụng hàm đối với nội dung, mà còn tăng biến đếm thêm 1. Mọi thứ đến giờ vẫn ổn, và ta thậm chí còn có thể nghịch chơi với fmap thêm một chút nữa:

ghci> fmap (++"ha") (CJust 0 "ho")
CJust 1 "hoha"
ghci> fmap (++"he") (fmap (++"ha") (CJust 0 "ho"))
CJust 2 "hohahe"
ghci> fmap (++"blah") CNothing
CNothing

Điều này có tuân theo các định luật functor không? Để thấy được rằng một thứ gì đó không tuân theo định luật, thì ta chỉ cần tìm được một phản ví dụ.

ghci> fmap id (CJust 0 "haha")
CJust 1 "haha"
ghci> id (CJust 0 "haha")
CJust 0 "haha"

A! Ta biết rằng định luật thứ nhất về functor phát biểu rằng nếu ta ánh xạ id lên một functor, kết quả đáng lẽ phải giống như ta chỉ gọi id với cùng functor, nhưng như ta đã thấy từ ví dụ này, điều đó không còn đúng đối với functor CMaybe đang xét. Ngay cả khi nó thuộc về lớp Functor, nhưng nó không tuân theo các định luật functor, và vì vậy không phải là một functor. Nếu ai đó dùng kiểu CMaybe đang xét làm một functor, họ sẽ trông đợi rằng nó phải tuân theo các định luật về functor, cũng như một functor thực thụ. Nhưng CMaybe đã thất bại trong việc đóng vai một functor ngay cả khi nó đã giả bộ như vậy, vì vậy dùng nó như một functor có thể dẫn đến mã lệnh hỏng. Khi ta dùng một functor, thì chẳng khác gì nếu ta hợp lại các hàm trước rồi mới ánh xạ chúng lên functor, hoặc là ta lần lượt ánh xạ từng hàm lên functor. Nhưng với CMaybe thì khác đấy, vì nó có dõi theo số lần nó được ánh xạ lên. Không hay rồi! Nếu ta muốn CMaybe tuân theo các định luật functor, thì ta phải làm sao cho trường Int vẫn giữ nguyên khi ta dùng fmap.

Thoạt nhìn, các định luật functor dường như dễ gây lầm lẫn và không cần thiết, nhưng rồi ta thấy được rằng nếu biết một kiểu dữ liệu tuân theo cả hai định luật thì ta có thể đặt giả thiết vững chắc về các hành vi của nó. Nếu một kiểu tuân theo các định luật functor, thì ta biết rằng việc gọi fmap lên một giá trị thuộc kiểu đó sẽ chỉ ánh xạ hàm lên giá trị này, không hơn không kém. Điều đó dẫn đến mã lệnh được viết ra sẽ trừu tượng và dễ mở rộng hơn, vì ta có thể dùng các định luật để suy diễn về các hành vi mà bất kì functor nào cũng phải có, và tạo ra các hàm hoạt động một cách đáng tin cậy trên bất kì functor nào.

Tất cả những thực thể Functor trong thư viện chuẩn đều tuân theo các định luật này, nhưng bạn có thể tự tay kiểm tra nếu chưa tin hẳn. Và lần tới đây nếu có tạo ra một thực thể của Functor, thì hãy dành một phút để dảm bảo chắc rằng nó tuân theo các định luật functor. Một khi bạn đã có đủ kinh nghiệm với functor, thì bạn sẽ như có một trực giác thấy được những thuộc tính và hành vi có chung ở những functor đó, và sẽ không khó gì để dùng trực giác thấy được liệu một kiểu có tuân theo các định luật functor không. Nhưng ngay cả khi không dùng trực giác, bạn luôn có thể chỉ lần theo từng dòng lệnh để xem liệu các định luật được thỏa mãn không, hoặc tìm ra một phản ví dụ.

Ta cũng có thể xem hàm như những thứ cho ra giá trị trong từng ngữ cảnh cụ thể. Chẳng hạn, Just 3 cho ra giá trị 3 trong ngữ cảnh có thể, hoặc không cho ra giá trị nào hết. [1,2,3] cho ra ba giá trị —1, 2, và 3, ngữ cảnh này là có thể có nhiều giá trị, hoặc không có giá trị nào. Hàm (+3) sẽ cho ra một giá trị, tùy theo tham số nào được truyền đến.

Nếu bạn nghĩ functor là những thứ để trả lại giá trị, thì bạn có thể hình dung việc ánh xạ lên các functor cũng như gắn một phép biến đổi với đầu ra của functor mà đầu ra này làm thay đổi giá trị. Khi viết fmap (+3) [1,2,3], ta gắn phép biến đổi (+3) với đầu ra của [1,2,3], vì vậy mỗi khi nhìn vào một con số mà danh sách này xuất ra, thì hàm (+3) sẽ được áp dụng với nó. Một ví dụ khác là việc ánh xạ lên các hàm. Khi viết fmap (+3) (*3), ta đi gắn phép chuyển đổi (+3) với kết quả đầu ra cuối cùng của (*3). Bằng cách nhìn theo khía cạnh này đã cho ta nhận thức trực giác là tại sao dùng fmap lên các hàm cũng chính là hợp các hàm (fmap (+3) (*3) bằng với (+3) . (*3), vốn dĩ lại bằng với \x -> ((x*3)+3)), vì ta lấy một hàm như (*3) rồi gắn phép chuyển đổi (+3) vào kết quả đầu ra của nó. Kết quả thì vẫn là một hàm, chỉ khi ta cho nó một con số, thì nó sẽ được nhân lên 3 rồi sẽ được đưa qua phép chuyển đổi kèm theo, qua đó kết quả sẽ được cộng thêm 3. Đây là điều xảy ra đối với việc hợp các hàm.

Các functor áp dụng

disregard this analogy

Ở mục này, ta sẽ xét đến những functor áp dụng, vốn là functor được tăng cường; trong Haskell chúng được biểu diễn bằng lớp Applicative, thuộc module Control.Applicative.

Bạn biết đấy, trong Haskell các hàm được curry theo mặc định; điều đó nghĩa là một hàm dường như nhận nhiều tham số thì thực ra sẽ chỉ nhận một tham số rồi trả lại một hàm nhận tham số tiếp theo, và cứ như vậy. Nếu một hàm có kiểu a -> b -> c, ta thường nói rằng nó nhận hai tham số rồi trả lại một giá trị c, nhưng thực ra nó nhận một giá trị a rồi trả lại một hàm b -> c. Điều này lý giải tại sao ta có thể gọi một hàm là f x y hoặc là (f x) y. Cơ chế này cho phép ta áp dụng hàm theo từng phần bằng cách chỉ gọi chúng với không đủ tham số, để tạo ra các hàm mà sau đó ta có thể truyền vào các hàm khác.

Cho đến giờ, khi ta ánh xạ các hàm lên các hàm, ta thường ánh xạ hàm chỉ nhận một tham số. Nhưng điều gì sẽ xảy ra khi ta ánh xạ một hàm như *, vốn nhận vào hai tham số, lên một functor? Hãy xét một vài ví dụ cụ thể. Nếu ta có Just 3 và viết fmap (*) (Just 3), thì ta sẽ nhận được gì? Từ cách lập trình cho thực thể của Maybe cho Functor, ta biết rằng nếu nó là một giá trị Just something, thì hàm sẽ được áp dụng cho cái something bên trong Just. Vì vậy, việc viết fmap (*) (Just 3) sẽ cho kết quả là Just ((*) 3), vốn cũng có thể được viết thành Just (* 3) nếu ta dùng đến các đoạn. Thật thú vị! Ta nhận được một hàm gói trong Just!

ghci> :t fmap (++) (Just "hey")
fmap (++) (Just "hey") :: Maybe ([Char] -> [Char])
ghci> :t fmap compare (Just 'a')
fmap compare (Just 'a') :: Maybe (Char -> Ordering)
ghci> :t fmap compare "A LIST OF CHARS"
fmap compare "A LIST OF CHARS" :: [Char -> Ordering]
ghci> :t fmap (\x y z -> x + y / z) [3,4,5,6]
fmap (\x y z -> x + y / z) [3,4,5,6] :: (Fractional a) => [a -> a -> a]

Nếu ta ánh xạ compare, vốn có kiểu (Ord a) => a -> a -> Ordering, lên một danh sách các kí tự thì sẽ nhận được một danh sách các hàm có kiểu Char -> Ordering, vì hàm compare được áp dụng từng phần với các kí tự trong danh sách. Nó không phải là một danh sách của hàm (Ord a) => a -> Ordering, vì cái a thứ nhất được áp dụng là một Char và vì vậy cái a thứ hai phải được quyết định là mang kiểu Char.

Ta đã thấy bằng cách nào mà ánh xạ các hàm “nhiều tham số” lên các functor sẽ thu được các functor chứa trong đó các hàm. Vậy bây giờ ta có thể làm gì với các hàm này? Ồ, một mặt, ta có thể ánh xạ các hàm nhận những hàm này làm tham số lên chúng, vì bất kì thứ gì bên trong một functor sẽ được trao cho hàm mà ta ánh xạ lên nó như là tham số.

ghci> let a = fmap (*) [1,2,3,4]
ghci> :t a
a :: [Integer -> Integer]
ghci> fmap (\f -> f 9) a
[9,18,27,36]

Nhưng sẽ thế nào nếu ta có một giá trị functor là Just (3 *) và một giá trị functor Just 5, đồng thời muốn lấy hàm ra khỏi Just (3 *) để ánh xạ nó lên Just 5? Với các functor thông thường, ta không thể làm được, vì tất cả những gì chúng cho phép chỉ là ánh xạ các hàm thường lên các functor có sẵn. Ngay cả khi ta đã ánh xạ \f -> f 9 lên một functor có chứa các hàm trong nó, thì ta mới chỉ ánh xạ một hàm thông thường lên nó. Nhưng ta không thể ánh xạ một hàm nằm trong một functor lên một functor khác với những thứ mà fmap cung cấp. Ta có thể khớp mẫu với constructor Just để lấy hàm khỏi nó rồi ánh xạ hàm này lên Just 5, nhưng ta đang tìm kiếm một cách tổng quát và trừu tượng hơn để làm việc này; cách này phải hoạt động được giữa các functor.

Hãy làm quen với lớp Applicative. Nằm trong module Control.Applicative, nó định nghĩa hai phương thức, pure<*>. Lớp này chẳng có mã lệnh chương trình thực hiện mặc định cho phương thức nào ở trên, vì vậy ta phải định nghĩa cả hai, nếu muốn tạo được functor áp dụng. Lớp này được định nghĩa như sau:

class (Functor f) => Applicative f where
    pure :: a -> f a
    (<*>) :: f (a -> b) -> f a -> f b

Lời định nghĩa đơn giản gồm 3 dòng trên đã cho ta biết nhiều điều! Hãy bắt đầu bằng dòng thứ nhất. Nó khởi đầu lời định nghĩa lớp Applicative và cũng giới thiệu một ràng buộc lớp. Nó phát biểu rằng nếu ta muốn làm cho một constructor kiểu thuộc về lớp Applicative, thì trước hết nó phải nằm trong Functor. Điều này lý giải tại sao nếu ta biết rằng một constructor kiểu thuộc về lớp Applicative, thì nó cũng thuộc về Functor, vì vậy ta có thể dùng fmap lên nó.

Phương thức thứ nhất được định nghĩa có tên là pure. Lời khai báo kiểu của nó là pure :: a -> f a. f đóng vai trò của thực thể functor áp dụng của ta ở đây. Vì Haskell có một hệ thống kiểu rất tốt và vì tất cả những gì mà một hàm có thể làm là nhận một vài tham số nào đó rồi trả về một giá trị nào đó, nên ta có thể nói được nhiều điều từ lời khai báo hàm, và điều này không phải ngoại lệ. pure cần phải chấp nhận giá trị có kiểu bất kì rồi trả lại một functor áp dụng chứa giá trị đó bên trong. Khi nói bên trong, là một lần nữa ta dùng đến cách so sánh với cái hộp, ngay cả khi cách này không phải luôn đúng dưới cái nhìn khắt khe nhất. Nhưng lời khai báo kiểu a -> f a rất có tính biểu tả. Ta nhận một giá trị rồi đựng nó trong một functor áp dụng có một giá trị làm kết quả bên trong nó.

Một cách hay hơn khi nghĩ về pure sẽ là nói rằng nó nhận một giá trị rồi đưa vào một ngữ cảnh mặc định (hoặc thuần túy) nào đó—một ngữ cảnh tối thiểu mà vẫn cho ra giá trị đó.

Hàm <*> thật là thú vị. Nó có dấu ấn kiểu là f (a -> b) -> f a -> f b. Điều này có gợi nhớ cho bạn thấy gì không? Dĩ nhiên rồi, fmap :: (a -> b) -> f a -> f b. Đó là một kiểu fmap được tăng cường. Nếu như fmap nhận một hàm và một functor và áp dụng hàm bên trong functor, thì <*> lại nhận một functor có chứa một hàm cùng một functor khác rồi kết xuất hàm đó ra khỏi functor đầu tiên, sau đó ánh xạ hàm lên functor thứ hai. Khi nói kết xuất, ý tôi muốn nói là chạy rồi mới kết xuất, thậm chí có thể xâu chuỗi. Ta sẽ sớm thấy được lý do.

Hãy nhìn vào đoạn mã tạo lập thực thể Applicative của Maybe.

instance Applicative Maybe where
    pure = Just
    Nothing <*> _ = Nothing
    (Just f) <*> something = fmap f something

Một lần nữa, từ định nghĩa lớp ta thấy được rằng f đóng vai trò của functor áp dụng cần phải nhận tham số là một kiểu cụ thể, vì vậy ta viết instance Applicative Maybe where thay vì instance Applicative (Maybe a) where.

Trước hết, pure. Trước đây ta đã nói rằng kiểu dữ liệu này được thiết kế để nhận vào thứ gì đó rồi đựng nó trong một functor áp dụng. Ta viết pure = Just, vì các constructors giá trị như Just là các hàm thông thường. Ta cũng đã có thể viết là pure x = Just x.

Tiếp theo, ta có định nghĩa cho <*>. Ta không thể kết xuất một hàm từ Nothing, vì nó không chứa hàm nào. Vì vậy ta nói rằng nếu ta cố thử kết xuất một hàm từ Nothing, kết quả sẽ là Nothing. Nếu bạn nhìn vào lời định nghĩa lớp cho Applicative, bạn sẽ thấy rằng có một ràng buộc lớp Functor, có nghĩa là ta có thể giả sử rằng cả hai tham số của <*> đều là functor. Nếu tham số thứ nhất không phải là Nothing, mà là một Just với một hàm nào đó bên trong, thì ta nói rằng khi đó ta muốn ánh xạ hàm này lên tham số thứ hai. Việc này cũng đảm nhiệm luôn cả trường hợp mà tham số thứ hai là Nothing, vì việc fmap với bất kì hàm nào lên Nothing sẽ trả lại Nothing.

Như vậy đối với Maybe, <*> kết xuất hàm từ giá trị bên trái nếu nó là Just rồi ánh xạ hàm này lên giá trị bên phải. Nếu bất kì tham số nào là Nothing, thì kết quả sẽ là Nothing.

Hay thật. Ta hãy nhanh chóng thử xem sao.

ghci> Just (+3) <*> Just 9
Just 12
ghci> pure (+3) <*> Just 10
Just 13
ghci> pure (+3) <*> Just 9
Just 12
ghci> Just (++"hahah") <*> Nothing
Nothing
ghci> Nothing <*> Just "woot"
Nothing

Ta thấy rằng việc viết pure (+3)Just (+3) giống nhau như thế nào trong trường hợp này. Hãy dùng pure nếu bạn đang làm việc với các giá trị Maybe trong một ngữ cảnh áp dụng (nghĩa là dùng chúng với <*>), còn nếu không thì gắn bó với Just. Bốn dòng nhập vào đầu tiên cho thấy hàm được kết xuất rồi ánh xạ như thế nào; nhưng trong trường hợp này ta có thực hiện mục đích bằng việc chỉ cần ánh xạ các hàm chưa được bao bọc lên các functor. Dòng cuối cùng rất thú vị: ta cố gắng kết xuất một hàm từ Nothing rồi ánh xạ nó lên một thứ nào đó, và dĩ nhiên việc làm này sẽ cho kết quả là Nothing.

Với các functor thông thường, bạn có thể chỉ cần ánh xạ hàm lên một functor và rồi bạn không thể lấy được giá trị ra bằng bất kì cách thông thường nào, ngay cả khi két quả là một hàm áp dụng từng phần. functor áp dụng thì khác, nó cho phép bạn thao tác trên nhiều functor chỉ bằng một hàm duy nhất. Hãy thử chạy mã lệnh sau để kiểm tra:

ghci> pure (+) <*> Just 3 <*> Just 5
Just 8
ghci> pure (+) <*> Just 3 <*> Nothing
Nothing
ghci> pure (+) <*> Nothing <*> Just 5
Nothing

whaale

Điều gì đang xảy ra ở đây? Hãy xem xét theo từng bước. <*> có tính kết hợp trái, nghĩa là pure (+) <*> Just 3 <*> Just 5 cũng như (pure (+) <*> Just 3) <*> Just 5. Đàu tiên, hàm + được đặt trong một functor, trong trường hợp này là một giá trị Maybe có chứa hàm. Vì vậy đầu tiên, ta có pure (+), vốn là Just (+). Tiếp theo, Just (+) <*> Just 3 xảy ra. Kết quả của điều này là Just (3+). Đó là vì áp dụng hàm từng phần. Việc chỉ áp dụng 3 cho hàm + sẽ dẫn đến một hàm nhận vào một tham số và cộng 3 vào cho nó. Sau cùng, Just (3+) <*> Just 5 được thực hiện, và cho kết quả là Just 8.

Tuyệt đấy chứ nhỉ?! Các functor áp dụng và phong cách áp dụng từng phần trong cách viết pure f <*> x <*> y <*> ... cho phép ta lấy một hàm vốn trông đợi những tham số mà không nhất thiết phải được bọc trong các functor rồi dùng hàm đó để thao tác trên mnhieefu giá trị thuộc ngữ cảnh functor. Hàm này có thể nhận bao nhiêu tham số tùy ý, vì nó luôn được áp dụng từng phần theo từng bước một giữa các lần xuất hiện của <*>.

Điều này còn trở nên tiện lợi và rõ ràng hơn nữa nếu ta xét thấy pure f <*> x bằng với fmap f x. Đây là một trong các định luật áp dụng. Sau này ta sẽ xem xét kĩ hơn, nhưng tạm thời bây giờ ta có thể nhận thấy bằng trực giác. Hãy nghĩ xem, đẳng thức nói trên có lý chứ. Như ta đã nói từ trước, pure đặt một giá trị vào rong một ngữ cảnh mặc định. Nếu ta chỉ đặt một hàm vào trong một ngữ cảnh mặc định rồi kết xuất và áp dụng nó cho một giá tị bên trong một functor áp dụng khác, thì ta đã làm việc giống như là ánh xạ hàm đó lên functor áp dụng nêu trên. Thay vì viết pure f <*> x <*> y <*> ..., ta có thể viết fmap f x <*> y <*> .... Đó là lý do tại sao Control.Applicative xuất khẩu một hàm có tên <$>, vốn chỉ là toán tử trung tố fmap. Sau đây là cách định nghĩa nó:

(<$>) :: (Functor f) => (a -> b) -> f a -> f b
f <$> x = fmap f x
Yo! Lưu ý vắn tắt: các biến kiểu thì không phụ thuộc vào tên tham số hoặc các tên giá trị khác. Cái f trong lời khai báo hàm trên đây là một biến kiểu với ràng buộc về lớp, phát biểu rằng bất kì constructor kiểu nào thay thế f phải nằm trong lớp Functor. Còn f trong phần thân hàm kí hiệu cho một hàm mà ta ánh xạ lên x. Việc ta dùng f để biểu diễn cho cả hai cái trên không có nghĩa là chúng biểu thị cho cùng một thứ.

Bằng cách dùng <$>, phong cách áp dụng đã phát huy tác dụng, vì bây giờ nếu ta muốn áp dụng một hàm f đối với ba functor áp dụng, thì ta có thể viết f <$> x <*> y <*> z. Nếu các tham số không phải là functor áp dụng mà là giá trị bình thường, thì ta đã viết là f x y z.

Hãy xem xét kĩ hơn cách hoạt động của mã lệnh này. Ta có một giá trị Just "johntra" và một giá trị Just "volta" và muốn kết nối chúng thành một String bên trong một functor Maybe. Ta làm như sau:

ghci> (++) <$> Just "johntra" <*> Just "volta"
Just "johntravolta"

Trước khi thấy được tại sao ta có được kết quả này, hãy so sánh dòng lệnh trên với:

ghci> (++) "johntra" "volta"
"johntravolta"

Tuyệt! Để dùng một hàm thông thường đối với các functor áp dụng, ta chỉ cần điểm thêm <$><*> lên hàm đó và rồi nó sẽ hoạt động với các functor áp dụng rồi trả lại một functor áp dụng. Tuyệt đấy nhỉ?

Dù sao, khi ta viết (++) <$> Just "johntra" <*> Just "volta", thì trước hết (++), vốn có kiểu (++) :: [a] -> [a] -> [a] sẽ được ánh xạ lên Just "johntra", kết quả cho ra một giá trị cũng giống như Just ("johntra"++) và có kiểu là Maybe ([Char] -> [Char]). Lưu ý bằng cách nào mà tham số thứ nhất của (++) đã bị “tiêu thụ” và các a đã biến thành các Char. Và bây giờ Just ("johntra"++) <*> Just "volta" xảy ra, nó lấy hàm ra khỏi Just rồi ánh xạ nó lên Just "volta", cho kết quả là Just "johntravolta". Nếu bất kì một trong hai giá trị này là Nothing, thì kết quả sẽ là Nothing.

Cho đến giờ, ta mới chỉ dùng Maybe trong các ví dụ và bạn có thể nghĩ rằng các functor áp dụng đều hướng về Maybe. Nhưng còn hàng tá những thực thể khác cũng trong lớp Applicative, vì vậy ta hãy làm quen với chúng!

Danh sách (thực ra là constructor kiểu danh sách, []) là functor áp dụng. Ngạc nhiên làm sao! Và sau đây là [] trong vai trò thực thể của Applicative:

instance Applicative [] where
    pure x = [x]
    fs <*> xs = [f x | f <- fs, x <- xs]

Trước đây, ta đã nói rằng pure nhận một giá trị và đặt nó vào trong một ngữ cảnh mặc định. Nói cách khác, một ngữ cảnh tối thiểu mà vẫn trả lại được giá trị như vậy. Ngữ cảnh tối thiểu, trong trường hợp với danh sách, thì là danh sách rỗng, [], nhưng danh sách rỗng biểu thị một sự thiếu vắng giá trị, vì vậy nó không thể tự nắm giữ một giá trị mà ta đã dùng pure lên đó. Điều này lý giải tại sao pure nhận một giá trị rồi đặt nó vào trong danh sách một phần tử. Tương tự, ngữ cảnh tối thiểu cho functor áp dụng Maybe sẽ là Nothing, nhưng nó biểu thị sự khuyết thiếu của giá trị chứ không phải có giá trị, nên pure được tạo lập dưới dạng Just trong phần tạo lập thực thể cho Maybe.

ghci> pure "Hey" :: [String]
["Hey"]
ghci> pure "Hey" :: Maybe String
Just "Hey"

Thế còn về <*>? Nếu ta nhìn vào kiểu mà <*> sẽ nhận nếu nó chỉ hạn chế trong phạm vi danh sách, thì ta có (<*>) :: [a -> b] -> [a] -> [b]. Nó được thiết lập bởi dạng gộp danh sách. <*> bằng cách nào đó đã kết xuất hàm khỏi tham số bên trái của nó rồi ánh xạ lên tham số bên phải. Nhưng vấn đề ở đây là danh sách bên trái có thể không chứa hàm nào, một hàm, hoặc nhiều hàm. Danh sách bên phải cũng có thể chứa ít nhiều hàm khác nhau. Điều này lý giải tại sao ta dùng một dạng gộp danh sách để rút các thứ từ cả hai danh sách. Ta áp dụng từng hàm có trong danh sách bên trái cho từng giá trị ở danh sách bên phải. Danh sách thu được sẽ có đủ mọi tổ hợp giữa việc áp dụng hàm trong danh sách bên trái với giá trị trong danh sách bên phải.

ghci> [(*0),(+100),(^2)] <*> [1,2,3]
[0,0,0,101,102,103,1,4,9]

Danh sách bên trái có ba hàm và danh sách bên phải có ba giá trị, vì vậy danh sách kết quả sẽ có 9 phần tử. Mỗi hàm trong danh sách bên trái sẽ được áp dụng cho một phần tử ở bên phải. Nếu ta có một danh sách các hàm nhận vào hai tham số, thì ta có thể áp dụng các hàm đó cho các phần tử giữa hai danh sách.

ghci> [(+),(*)] <*> [1,2] <*> [3,4]
[4,5,5,6,3,4,6,8]

<*> có tính kết hợp trái, nên [(+),(*)] <*> [1,2] sẽ xảy ra trước, kết quả là có một danh sách [(1+),(2+),(1*),(2*)], vì mỗi hàm bên trái được áp dụng cho một giá trị bên phải. Sau đó, [(1+),(2+),(1*),(2*)] <*> [3,4] xảy ra, và tạo nên kết quả cuối cùng.

Viết mã lệnh theo phong cách áp dụng đối với danh sách thật là thú vị! Hãy xem này:

ghci> (++) <$> ["ha","heh","hmm"] <*> ["?","!","."]
["ha?","ha!","ha.","heh?","heh!","heh.","hmm?","hmm!","hmm."]

Một lần nữa, bạn thấy cách mà ta đã dùng một hàm thông thường nhận vào hai chuỗi đứng giữa hai functor áp dụng cho chuỗi chỉ bằng cách chèn thêm các toán tử áp dụng phù hợp.

Bạn có thể coi danh sách như những đại lượng (hay đại lượng tính toán) không tất định. Một giá trị như 100 hơặc "what" có thể được coi là một đại lượng tất định nếu như nó chỉ có một kết quả, còn một danh sách như [1,2,3] có thể được coi là một đại lượng không thể tự quyết định được kết quả mong muốn, vì vậy nó biểu diễn cho ta thấy mọi kết quả có thể. Vì vậy, chẳng hạn khi bạn viết (+) <$> [1,2,3] <*> [4,5,6], thì bạn có thể coi nó như là cộng hai đại lượng không tất định bằng +, chỉ là để tạo ra một đại lượng không tất định khác mà thậm chí còn ít khả năng quyết định kết quả hơn nữa.

Việc dùng phong cách áp dụng đối với danh sách thường là cách làm thay thế tốt cho dạng gộp danh sách. Ở Chương 2, khi muốn biết tất cả những tích số giữa các phần tử của [2,5,10][8,10,11], ta đã viết:

ghci> [ x*y | x <- [2,5,10], y <- [8,10,11]]   
[16,20,22,40,50,55,80,100,110]

Đơn giản là ta đã rút các phần tử từ hai danh sách này rồi áp dụng một hàm giữa hai phần tử trong tổ hợp đó. Việc này cũng có thể được làm theo phong cách áp dụng:

ghci> (*) <$> [2,5,10] <*> [8,10,11]
[16,20,22,40,50,55,80,100,110]

Như thế này đối với tôi có vẻ rõ ràng hơn, vì sẽ dễ thấy hơn nếu ta chỉ gọi * giữa hai đại lượng không tất định. Nếu ta muốn tất cả những tích số lớn hơn 50 giữa hai phần tử của hai danh sách, thì ta chỉ cần viết:

ghci> filter (>50) $ (*) <$> [2,5,10] <*> [8,10,11]
[55,80,100,110]

Thật dễ thấy rằng pure f <*> xs bằng fmap f xs đối với trường hợp các danh sách. pure f chỉ đơn giản là [f] còn [f] <*> xs sẽ áp dụng từng hàm ở danh sách bến trái cho từng giá trị thuộc danh sách bên phải; nhưng vì chỉ có một hàm trong danh sách bên trái, nên lệnh này sẽ giống như phép ánh xạ.

Một thực thể khác của lớp Applicative mà ta đã gặp là IO. Sau đây là cách tạo lập thực thể này:

instance Applicative IO where
    pure = return
    a <*> b = do
        f <- a
        x <- b
        return (f x)

ahahahah!

pure dành trọn cho việc đặt một giá trị trong một ngữ cảnh tối thiểu mà còn giữ được kết quả là giá trị đó; nên việc pure chỉ là return là hoàn toàn hợp lý, vì return thực hiện chính điều đó; nó tạo ra một thao tác I/O không làm việc gì cả, mà chỉ cho kết quả là một giá trị nào đó, nhưng nó không làm bất kì thao tác I/O nào như in ra màn hình hoặc đọc vào từ tập tin.

Nếu <*> là để dành riêng cho IO thì nó sẽ có kiểu là (<*>) :: IO (a -> b) -> IO a -> IO b. Nó sẽ nhận một thao tác I/O nhằm cho ra kết quả là một hàm và một thao tác I/O khác và tạo ra một thao tác I/O mới sao cho khi được thực hiện, thì trước hết là thực hiện thao tác I/O đầu tiên để thu được hàm rồi thực hiện thao tác thứ hai để lấy giá trị, sau đó trả lại kết quả là giá trị tìm được khi áp dụng hàm cho giá trị. Ở đây ta dùng cú pháp do để tạo lập. Hãy nhớ rằng, cú pháp do nhận vào nhiều thao tác I/O rồi dính chúng lại với nhau làm một, hệt như ta đã làm ở đây.

Với Maybe[], ta có thể hình dung <*> đơn giản như là kết xuất một hàm từ tham số bên trái rồi áp dụng hàm này vào tham số bên phải. Với IO, việc kết xuất vẫn còn đó, nhưng bây giờ ta cũng có khái niệm về xâu chuỗi, vì ta đang lấy hai thao tác I/O và đang xâu chuỗi, hay dính chúng, làm một. Ta phải kết xuất hàm từ thao tác I/O thứ nhất, nhưng để kết xuất được kết quả từ một thao tác I/O, thì nó phải được thực hiện.

Xét đoạn mã lệnh sau:

myAction :: IO String
myAction = do
    a <- getLine
    b <- getLine
    return $ a ++ b

Đây là thao tác I/O mà sẽ nhắc người dùng nhập vào hai dòng chữ và cho ra kết quả là hai dòng được nối lại làm một. Ta thực hiện bằng cách kết dính hai thao tác I/O là getLine và một lệnh return, vì ta muốn thao tác I/O mới được kết dính có chứa kết quả của a ++ b. Một cách viết khác sẽ là theo phong cách áp dụng.

myAction :: IO String
myAction = (++) <$> getLine <*> getLine

Điều mà ta vừa làm là tạo một thao tác I/O để áp dụng một hàm với các kết quả của hai thao tác I/O khác, và đây cũng là thứ như vậy. Hãy nhớ rằng, getLine là một thao tác I/O với kiểu getLine :: IO String. Khi ta dùng <*> giữa hai functor áp dụng hiện có, thì kết quả cũng là một functor áp dụng, vì thế tất cả những việc làm trên đều có nghĩa.

Liên hệ với ví dụ cái hộp, ta có thể hình dung getLine như một cái hộp mà sẽ chạy ra môi trường bên ngoài để thu lượm một chuỗi kí tự. Việc viết (++) <$> getLine <*> getLine sẽ tạo nên một cái họp mới lớn hơn để phân công hai cái hộp đó ra ngoài thu lượm hai dòng chữ từ cửa sổ lệnh, rồi sau đó trình bày kết quả là hai dòng chữ được nối làm một.

Kiểu của biểu thức (++) <$> getLine <*> getLineIO String, có nghĩa rằng biểu thức này là một thao tác I/O bình thường như bất kì thao tác I/O nào khác, mà cũng chứa trong nó một giá trị kết quả, như những thao tác I/O khác. Điều này lý giải tại sao ta có thể viết những lệnh như:

main = do
    a <- (++) <$> getLine <*> getLine
    putStrLn $ "The two lines concatenated turn out to be: " ++ a

Nếu bạn đã rơi vào trường hợp phải gắn những thao tác I/O nào đó với những cái tên rồi gọi một hàm nào đó lên chúng, sau đó biểu diễn kết quả bằng return, thì hãy tính đến việc viết theo phong cách áp dụng vì có lẽ nó ngắn gọn hơn.

Một thực thể khác của Applicative(->) r, các hàm. Dù hiếm khi được dùng theo phong cách áp dụng ngoài mục đích viết mã lệnh càng ngắn càng tốt, nhưng chúng vẫn thú vị như các thực thể áp dụng khác; vì vậy ta hãy cùng xem cách tạo lập thực thể hàm này.

Nếu bạn bị rối trí về ý nghĩa của (->) r, hãy đọc lại mục trước trong đó chúng tôi đã giải thích (->) r là một functor như thế nào.
instance Applicative ((->) r) where
    pure x = (\_ -> x)
    f <*> g = \x -> f x (g x)

Khi ta gói một giá trị vào bên trong một functor áp dụng bằng pure, thì kết quả mà functor áp dụng cho ra sẽ luôn luôn là giá trị đó. Một ngữ cảnh mặc định nhỏ nhất mà vẫn cho ra kết quả là giá trị đó. Điều này lý giải tại sao mà trong phần tạo lập thực thể hàm, pure nhận một giá trị rồi tạo ra một hàm phớt lờ đi các tham số được cấp và luôn trả lại giá trị đó. Nếu ta nhìn vào kiểu của pure, dành riêng cho thực thể (->) r, thì nó là pure :: a -> (r -> a).

ghci> (pure 3) "blah"
3

Vì tính chất curry mà việc áp dụng hàm luôn có tính kết hợp trái, vì vậy ta có thể bỏ qua cặp ngoặc tròn.

ghci> pure 3 "blah"
3

Cách tạo lập hàm cho <*> trông hơi bí hiểm, vì vậy tốt nhất là ta chỉ nhìn vào cách dùng các hàm với vai trò functor áp dụng theo phong cách áp dụng.

ghci> :t (+) <$> (+3) <*> (*100)
(+) <$> (+3) <*> (*100) :: (Num a) => a -> a
ghci> (+) <$> (+3) <*> (*100) $ 5
508

Việc gọi <*> với hai functor áp dụng sẽ cho kết quả là một functor áp dụng, vì vậy nếu ta dùng nó với hai hàm thì ta sẽ thu lại được một hàm. Như vậy điều gì đang diễn ra ở đây? Khi viết (+) <$> (+3) <*> (*100), ta đang lập nên một hàm sẽ dùng + đối với các kết quả của (+3)(*100) rồi trả lại kết quả đó. Lấy một ví dụ cụ thể, khi viết (+) <$> (+3) <*> (*100) $ 5, thì ban đầu số 5 được áp dụng vào (+3)(*100), cho ra 8500. Sau đó, + được gọi với 8500, cho ra kết quả 508.

ghci> (\x y z -> [x,y,z]) <$> (+3) <*> (*2) <*> (/2) $ 5
[8.0,10.0,2.5]

SLAP

Ở đây cũng vậy. Ta tạo ra một hàm để gọi hàm \x y z -> [x,y,z] cùng với các két quả cuối cùng từ (+3), (*2)(/2). Số 5 được đưa vào từng hàm trong số ba hàm này và rồi \x y z -> [x, y, z] được gọi với các kết quả đó.

Bạn có thể hình dung các hàm như những chiếc họp có chứa kết quả cuối cùng của chúng, vì vậy việc viết k <$> f <*> g tạo ra một hàm mà sẽ gọi k với các kết quả cuối cùng từ fg. Khi ta viết, chẳng hạn (+) <$> Just 3 <*> Just 5, thì ta đang dùng + với các giá trị mà có thể có hoặc không tồn tại ở đó, và cho kết quả có thể là một giá trị hoặc không là gì cả. Khi viết (+) <$> (+10) <*> (+5), là ta đang dùng + với những giá trị được trả về trong tương lai của (+10)(+5) đồng thời kết quả sẽ là một thứ gì đó mà sẽ tạo ra một giá trị khi được gọi với một tham số.

Ta không thường dùng các hàm với vai trò functor ứng dụng, nhưng điều này vẫn rất hay. Chẳng quan trọng lắm nếu bạn không hiểu được cách hoạt động của thực thể (->) r trong Applicative, vì vậy đừng nên thất vọng nếu bây giờ bạn chưa hiểu được. Hãy thử nghịch chơi bằng phong cách áp dụng và với các hàm để hình thành một trực giác dành cho hàm với vai trò functor áp dụng.

Một thực thể của Applicative mà ta vẫn chưa gặp là ZipList, và nó tần tại ở Control.Applicative.

Hóa ra là còn có những cách khác để biến danh sách trở thành functor áp dụng. Một cách mà ta đã đề cập đến, theo đó việc gọi <*> với một danh sách các hàm và một danh sách các giá trị sẽ trả lại kết quả là một danh sách có tất cả những tổ hợp có thể của việc áp dụng hàm ở danh sách bên trái lên các giá trị ở danh sách bên phải. Nếu ta viết [(+3),(*2)] <*> [1,2], thì (+3) sẽ được áp dụng cho cả 12 còn (*2) sẽ được áp dụng cho cả 12, kết quả là được một danh sách gồm 4 phần tử, [4,5,2,4].

Tuy nhiên, [(+3),(*2)] <*> [1,2] cũng có thể hoạt động theo cách mà hàm thứ nhất trong danh sách bên trái được áp dụng vào giá trị thứ nhất trong danh sách bên phải, hàm thứ hai áp dụng vào giá trị thứ hai, và cứ như vaajy. Kết quả sẽ là một danh sách có hai giá trị, [4,4]. Bạn có thể coi nó như là [1 + 3, 2 * 2].

Vì một kiểu dữ liệu không thể có hai thực thể trong cùng một lớp, nên kiểu ZipList a được giới thiệu, nó có một constructor ZipList chỉ gồm một trường, và trường đó là một danh sách. Sau đây là thực thể này:

instance Applicative ZipList where
        pure x = ZipList (repeat x)
        ZipList fs <*> ZipList xs = ZipList (zipWith (\f x -> f x) fs xs)

<*> thực hiện đúng như điều mà tôi đã nói. Nó áp dụng hàm thứ nhất vào cho giá trị thứ nhất, hàm thứ hai vào giá trị thứ hai, v.v. Điều này được zipWith (\f x -> f x) fs xs đảm nhiệm. Vì cách hoạt động của zipWith mà danh sách kết quả sẽ dài bằng với danh sách ngắn hơn trong số hai danh sách.

Ở đây, pure cũng rất thú vị. Nó nhận một giá trị rồi đặt nó vào trong một danh sách có giá trị đó lặp lại vô tận. pure "haha" sẽ cho kết quả ZipList (["haha","haha","haha".... Điều này có thể gây đôi chút nhầm lẫn vì ta đã nói rằng pure cần đặt một giá trị vào trong ngữ cảnh tối thiểu sao cho vẫn có thể cho ra giá trị. Và bạn có thể nghĩa rằng một danh sách vô tận thì không thể là “tối thiểu” được. Nhưng với những danh sách đan cài (zip) thì hoàn toàn có nghĩa, bởi nó phải tạo ra một giá trị tại mọi vị trí. Như vậy cũng thỏa mãn định luật phát biểu rằng pure f <*> xs phải bằng fmap f xs. Nếu pure 3 chỉ trả lại ZipList [3], thì pure (*2) <*> ZipList [1,5,10] sẽ cho kết quả là ZipList [2], vì danh sách kết quả gồm hai danh sách đan cài thì chỉ dài bằng danh sách ngắn hơn. Nếu ta đan cài một danh sách hữu hạn với một danh sách vô hạn, thì chiều dài của danh sách kết quả sẽ bằng của danh sách hữu hạn.

Như vậy danh sách đan cài hoạt động thế nào theo phong cách áp dụng? Hãy xem nhé. Ồ, kiểu dữ liệu ZipList a không có một thực thể Show, vì vậy ta phải dùng hàm getZipList để kết xuất một danh sách nguyên gốc ra khỏi một danh sách đan cài.

ghci> getZipList $ (+) <$> ZipList [1,2,3] <*> ZipList [100,100,100]
[101,102,103]
ghci> getZipList $ (+) <$> ZipList [1,2,3] <*> ZipList [100,100..]
[101,102,103]
ghci> getZipList $ max <$> ZipList [1,2,3,4,5,3] <*> ZipList [5,3,1,2]
[5,3,3,4]
ghci> getZipList $ (,,) <$> ZipList "dog" <*> ZipList "cat" <*> ZipList "rat"
[('d','c','r'),('o','a','a'),('g','t','t')]
Hàm (,,) cũng giống như \x y z -> (x,y,z). Đồng thời, hàm (,) cũng giống như \x y -> (x,y).

Bên cạnh zipWith, thư viện chuẩn còn có các hàm như zipWith3, zipWith4, cứ như vậy lên đến số 7. zipWith nhận một hàm mà hàm này nhận hai tham số, rồi dùng hàm để đan cài hai danh sách. zipWith3 nhận một hàm mà hàm này nhận ba tham số, rồi dùng hàm để đan cài ba danh sách, và cứ như vậy. Bằng cách dùng danh sách đan cài với phong cách áp dụng, ta sẽ không cần có một hàm zip (đan cài) riêng cho mỗi trường hợp một số cụ thể các danh sách cần đan cài. Ta chỉ việc dùng phong cách áp dụng để đan cài lại một số lượng tùy ý các danh sách chỉ bằng một hàm duy nhất, điều này thật tuyệt.

Control.Applicative định nghĩa một hàm có tên liftA2, vốn có kiểu liftA2 :: (Applicative f) => (a -> b -> c) -> f a -> f b -> f c . Nó được định nghĩa như sau:

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

Không có gì đặc biệt, nó chỉ áp dụng một hàm giữa hai functor áp dụng, ẩn giấu đi phong cách áp dụng mà ta vừa làm quen. Nguyên nhân khiến ta xem xét hàm này là vì nó cho thấy rõ ràng tại sao các functor áp dụng lại mạnh hơn nhiều các functor thông thường. Với functors thông thường, ta chỉ có thể ánh xạ các hàm lên một functor. Nhưng với functor áp dụng, ta có thể áp dụng một hàm giữa nhiều functor. Cũng thú vị khi thấy kiểu của hàm này là (a -> b -> c) -> (f a -> f b -> f c). Khi ta nhìn vào nó trên khía cạnh này, ta có thể nói rằng liftA2 nhận vào một hàm hai ngôi bình thường và nâng cấp nó thành một hàm hoạt động với hai functor.

Sau đây là một khái niệm quan trọng: ta có thể lấy hai functor áp dụng rồi kết hợp chúng thành một functor áp dụng chứa trong đó kết quả của hai functor áp dụng này trong một danh sách. Chẳng hạn, ta có Just 3Just 4. Hãy giả sử rằng cái thứ hai chứa trong nó một danh sách đơn phần tử, vì thật dễ đạt được điều này:

ghci> fmap (\x -> [x]) (Just 4)
Just [4]

Được rồi, vậy giả dụ như ta có Just 3Just [4]. Bằng cách nào ta có được Just [3,4]? Thật đơn giản.

ghci> liftA2 (:) (Just 3) (Just [4])
Just [3,4]
ghci> (:) <$> Just 3 <*> Just [4]
Just [3,4]

Hãy nhớ rằng, : là một hàm nhận vào một phần tử và một danh sách rồi trả về một danh sách mới có phần tử đó đứng đầu. Bây giờ khi ta đã có Just [3,4], liệu ta có thể kết hợp nó với Just 2 để tạo ra Just [2,3,4]? Dĩ nhiên là được chứ. Dường như ta có thể kết hợp một số lượng bât kì các functor áp dụng vào làm một, trong đó chứa một danh sách tất cả kết quả của những functor áp dụng ban đầu. Ta hãy thử lập một hàm nhận vào một danh sách các functor áp dụng rồi trả lại một functor áp dụng có giá trị kết quả là một danh sách. Ta sẽ gọi hàm này là sequenceA.

sequenceA :: (Applicative f) => [f a] -> f [a]
sequenceA [] = pure []
sequenceA (x:xs) = (:) <$> x <*> sequenceA xs

A, đệ quy! Đầu tiên, ta hãy nhìn vào kiểu dữ liệu. Nó sẽ chuyển đổi một danh sách các functor áp dụng thành một functor áp dụng với một danh sách. Từ đó, ta có thể đặt nền móng cho điều kiện biên. Nếu ta muốn chuyển đổi một danh sách rỗng thành một functor áp dụng với một danh sách các kết quả, thì ta chỉ việc đặt danh sách rỗng vào trong một ngữ cảnh mặc định. Bây giờ thì đến đệ quy. Nếu ta có một danh sách với phần tử đầu và đoạn cuối (hãy nhớ rằng, x là một functor áp dụng và xs là một danh sách của chúng), thì ta gọi sequenceA đối với phần đuôi danh sách, từ đó sẽ trả lại một functor áp dụng với một danh sách. Sau đó, ta chỉ việc đặt cái giá trị bên trong của (functor áp dụng) x trước functor áp dụng với danh sách nói trên, và thế là xong!

Như vậy, nếu ta viết sequenceA [Just 1, Just 2], thì đó là (:) <$> Just 1 <*> sequenceA [Just 2] . Tức là bằng với (:) <$> Just 1 <*> ((:) <$> Just 2 <*> sequenceA []). A! Ta biết rằng sequenceA [] cuối cùng sẽ là Just [], vì vậy biểu thức này bây giờ là (:) <$> Just 1 <*> ((:) <$> Just 2 <*> Just []), vốn là (:) <$> Just 1 <*> Just [2], hay là Just [1,2]!

Một cách khác để lập sequenceA là dùng một hàm gấp. Hãy nhớ rằng, hầu như bất kì hàm nòa mà ta dùng để duyệt qua từng phần tử một danh sách đồng thời tích lũy kết quả, thì đều có thể lập được bằng hàm gấp.

sequenceA :: (Applicative f) => [f a] -> f [a]
sequenceA = foldr (liftA2 (:)) (pure [])

Ta tiếp cận danh sách từ phía phải và khởi đầu với một giá trị pure []. Sau đó thực hiện liftA2 (:) giữa biến tích lũy và phần tử cuối danh sách, việc này cho kết quả là một functor áp dụng có một phần tử trong đó. Tiếp theo là liftA2 (:) với phần tử cuối lúc này và biến tích lũy hiện thời và cứ nhự vậy, đến khi ta chỉ còn lại biến tích lũy, biến này nắm giữ danh sách các kết quả của toàn bộ những functor áp dụng.

Ta hãy thử lướt qua hàm vừa viết với những functor áp dụng khác nhau.

ghci> sequenceA [Just 3, Just 2, Just 1]
Just [3,2,1]
ghci> sequenceA [Just 3, Nothing, Just 1]
Nothing
ghci> sequenceA [(+3),(+2),(+1)] 3
[6,5,4]
ghci> sequenceA [[1,2,3],[4,5,6]]
[[1,4],[1,5],[1,6],[2,4],[2,5],[2,6],[3,4],[3,5],[3,6]]
ghci> sequenceA [[1,2,3],[4,5,6],[3,4,4],[]]
[]

A! Hay đấy. Khi dùng với các giá trị Maybe, hàm sequenceA tạo ra một giá trị Maybe với tất cả những kết quả trong nó dưới dạng danh sách. Nếu một trong các giá trị là Nothing, thì kết quả cũng là Nothing. Điều này hay vì khi bạn có một danh sách các giá trị Maybe và bạn quan tâm đến các giá trị trong đó chỉ khi không có giá trị nào là Nothing.

Khi dùng với các hàm, sequenceA sẽ nhận một danh sách các hàm rồi trả lại một hàm mà hàm này trả lại một danh sách. Trong ví dụ đang xét, ta tạo ra một hàm nhận tham số là một con số rồi áp dụng hàm này cho từng hàm trong danh sách, và trả lại một danh sách các kết quả. sequenceA [(+3),(+2),(+1)] 3 sẽ gọi (+3) với 3, (+2) với 3(+1) với 3 rồi biểu thị tất cả những kết quả đó dưới dạng một danh sách.

Việc viết (+) <$> (+3) <*> (*2) sẽ tạo ra một hàm nhận vào một tham số, để cung cấp cho cả (+3)(*2) rội gọi + đối với cả hai kết quả đó. Cũng theo tinh thần đó, sẽ hoàn toàn hợp lý khi sequenceA [(+3),(*2)] tạo ra một hàm nhận một tham số rồi cung cấp cho toàn bộ các hàm trong danh sách. Thay vì gọi + với các kết quả trong hàm, thì sự kết hợp giữa :pure [] được dùng để thu gom các kết quả đó trong danh sách, vốn là kết quả của hàm được xét.

Việc dùng sequenceA sẽ rất hay khi ta có một danh sách các hàm và muốn cung cấp cùng một dữ liệu đầu vào cho tất cả những hàm đó rồi xem danh sách các kết quả. Chẳng hạn, ta có một con số và muốn biết xem nó có thỏa mãn tất cả những vị từ trong một danh sách hay không. Một cách thực hiện điều này như sau:

ghci> map (\f -> f 7) [(>4),(<10),odd]
[True,True,True]
ghci> and $ map (\f -> f 7) [(>4),(<10),odd]
True

Hãy nhớ rằng, and nhận một danh sách các giá trị boole rồi trả lại True nếu chúng đều là True. Một cách khác để thu được kết quả tương tự là dùng sequenceA:

ghci> sequenceA [(>4),(<10),odd] 7
[True,True,True]
ghci> and $ sequenceA [(>4),(<10),odd] 7
True

sequenceA [(>4),(<10),odd] tạo ra một hàm nhận một con số rồi cung cấp nó cho tất cả những vị từ trong [(>4),(<10),odd] rồi trả lại một danh sách các giá trị boole. Nó trả lại một danh sách có kiểu (Num a) => [a -> Bool] vào trong một hàm có kiểu (Num a) => a -> [Bool]. Khá là gọn gàng nhỉ?

Vì danh sách có tnhs đồng nhất, nên dĩ nhiên các hàm trong danh sách phải có cùng kiểu với nhau. Bạn không thể có một danh sách như [ord, (+3)], vì ord nhận một kí tự rồi trả lại một con số, còn (+3) nhận một con số rồi trả lại một con số.

Khi được dùng với [], hàm sequenceA nhận một danh sách chứa các danh sách rồi trả lại một danh sách cũng chứa các danh sách. Hừm, thú vị đấy. Hàm này tạo ra các tất cả các danh sách là tổ hợp có thể từ những phần tử trong từng danh sách ban đầu. Để minh họa, sau đây là mã lệnh thực hiện với sequenceA và dạng gộp danh sách:

ghci> sequenceA [[1,2,3],[4,5,6]]
[[1,4],[1,5],[1,6],[2,4],[2,5],[2,6],[3,4],[3,5],[3,6]]
ghci> [[x,y] | x <- [1,2,3], y <- [4,5,6]]
[[1,4],[1,5],[1,6],[2,4],[2,5],[2,6],[3,4],[3,5],[3,6]]
ghci> sequenceA [[1,2],[3,4]]
[[1,3],[1,4],[2,3],[2,4]]
ghci> [[x,y] | x <- [1,2], y <- [3,4]]
[[1,3],[1,4],[2,3],[2,4]]
ghci> sequenceA [[1,2],[3,4],[5,6]]
[[1,3,5],[1,3,6],[1,4,5],[1,4,6],[2,3,5],[2,3,6],[2,4,5],[2,4,6]]
ghci> [[x,y,z] | x <- [1,2], y <- [3,4], z <- [5,6]]
[[1,3,5],[1,3,6],[1,4,5],[1,4,6],[2,3,5],[2,3,6],[2,4,5],[2,4,6]]

Dường như hơi khó hiểu, nhưng nếu bạn nghịch chơi một lúc thì sẽ thấy được cách làm trên hoạt động ra sao. Chẳng hạn, hãy xét sequenceA [[1,2],[3,4]]. Để thấy được những gì xảy ra, hãy dùng định nghĩa sequenceA (x:xs) = (:) <$> x <*> sequenceA xs của sequenceA và điều kiện biên sequenceA [] = pure []. Bạn không cần phải theo sát cách lượng giá này, nhưng có thể nó sẽ giúp bạn nếu bạn gặp vấn đề trong việc hình dung cách mà sequenceA làm việc với danh sách các danh sách, vì để hiểu được có thể sẽ cần phải vắt óc một lúc.

  • Ta bắt đầu với sequenceA [[1,2],[3,4]]
  • Nó được định giá là (:) <$> [1,2] <*> sequenceA [[3,4]]
  • Tiếp tục định giá biểu thức sequenceA bên trong, ta thu được (:) <$> [1,2] <*> ((:) <$> [3,4] <*> sequenceA [])
  • Ta đã đạt tới điều kiện biên, vì vậy bây giờ ta có (:) <$> [1,2] <*> ((:) <$> [3,4] <*> [[]])
  • Bây giờ ta định giá phần (:) <$> [3,4] <*> [[]], vốn sẽ dùng đến : với từng giá trị có thể trong danh sách bên trái (những giá trị có thể bao gồm 34) với từng giá trị có thể trong danh sách bên phải (giá trị duy nhất là []), từ đó cho kết quả [3:[], 4:[]], hay là [[3],[4]]. Như vậy bây giờ ta có (:) <$> [1,2] <*> [[3],[4]]
  • Đến đây, : được dùng với từng giá trị có thể ở danh sách bên trái (12) và từng giá trị của danh sách bên phải ([3][4]), từ đó cho kết quả [1:[3], 1:[4], 2:[3], 2:[4]], hay là [[1,3],[1,4],[2,3],[2,4]

Việc viết (+) <$> [1,2] <*> [4,5,6]sẽ cho kết quả là một đại lượng không tất định x + y trong đó x nhận từng giá trị từ [1,2]y nhận từng giá trị từ [4,5,6]. Ta biểu diễn điều này bằng một danh sách chứa tất cả những kết quả có thể. Tương tự, khi viết sequence [[1,2],[3,4],[5,6],[7,8]], kết quả là một đại lượng không tất định [x,y,z,w], trong đó x nhận từng giá trị từ [1,2], y nhận từng giá trị từ [3,4] và cứ như vậy. Để biểu diễn kết quả của đại lượng không tất định này, ta dùng một danh sách trong đó mỗi phần tử trong danh sách lại là một danh sách có thể xuất hiện. Điều này lý giải tại sao kết quả là một danh sách chứa các danh sách.

Khi dùng với thao tác I/O, sequenceA cũng giống như sequence! Nó nhận vào một danh sách các thao tác I/O rồi trả lại một thao tác I/O để thực hiện từng hành động trong số đó rồi có kết quả là một danh sách những kết quả các thao tác I/O thành phần. Đó là vì để biến một giá trị [IO a] thành một giá trị IO [a], một thao tác I/O để cho danh sách các kết quả khi được thực hiện, thì tất cả những thao tác I/O đó phải được xâu chuỗi sao chó chúng có thể được thực hiện lần lượt khi bắt buộc phải lượng giá. Bạn không thể thu được kết quả của một thao tác I/O mà không thực hiện nó.

ghci> sequenceA [getLine, getLine, getLine]
heyh
ho
woo
["heyh","ho","woo"]

Như các functor thông thường, functor áp dụng cũng có một số định luật. Quan trọng nhất là định luật mà ta đã đề cập đến, pure f <*> x = fmap f x phải thỏa mãn. Bạn hãy chứng minh định luật này cho một số functor áp dụng mà ta đã gặp trong chương; coi như đây là một bài tập. Các định luật khác gồm có:

  • pure id <*> v = v
  • pure (.) <*> u <*> v <*> w = u <*> (v <*> w)
  • pure f <*> pure x = pure (f x)
  • u <*> pure y = pure ($ y) <*> u

Ngay bây giờ, ta sẽ không đi sâu vào chi tiết vì như vậy sẽ phải trình bày quá dài dòng và có thể sẽ nhàm chán, nhưng nếu bạn sẵn sàng đương đầu với thử thách, thì hãy xem kĩ những định luật trên và kiểm tra xem chúng có thỏa mãn với một số các thực thể hay không.

Tóm lại, functor áp dụng không chỉ thú vị, mà nó còn hữu ích, vì cho phép ta kết hợp các đại lượng khác nhau, như đại lượng I/O, đại lượng không tất định, đại lượng có thể thất bại trong quá trình tính toán, v.v. qua việc dùng phong cách áp dụng. Chỉ bằng cách dùng <$><*> ta có thể dùng những hàm thông thường để thao tác một cách đồng đều lên bao nhiêu functor áp dụng cũng được, đồng thời tận dụng ưu điểm ngữ nghĩa của từng functor.

Từ khóa newtype

why_ so serious?

Đến giờ, ta đã học cách tự tạo ra kiểu dữ liệu đại số riêng qua việc dùng từ khóa data. Chúng ta cũng đã học cách tạo kiểu tương đồng với những kiểu dữ liệu có sẵn bằng từ khóa type. Trong mục này, ta sẽ xét cách tạo nên những kiểu dữ liệu mới dựa trên những kiểu có sẵn bằng cách dùng từ khoá newtype và biết lý do trước hết tại sao ta muốn làm cách này.

Ở mục trước, ta đã thây rằng thực ra muốn danh sách trở thành functor áp dụng thì còn có nhiều cách. Một cách là để <*> lấy vào từng hàm ra khỏi danh sách tham số bên trái rồi áp dụng cho từng giá trị ở danh sách bên tay phải, kết quả thu được là tất cả những tổ hợp có thể trong phép áp dụng một hàm từ danh sách phía trái đối với một kết quả trong danh sách phía phải.

ghci> [(+1),(*100),(*5)]  [1,2,3]
[2,3,4,100,200,300,5,10,15]

Cách làm thứ hai là lấy hàm thứ nhất ra khỏi phía trái của <*> rồi áp dụng nó cho giá trị đầu tiên
bên phải, sau đó lấy hàm thứ hai trong danh sách bên trái đem áp dụng cho giá trị thứ hai bên phải, và cứ như vậy. Rốt cuộc, việc này tựa như ta đan cài hai danh sách với nhau. Nhưng vì danh sách đã là thực thể của Applicative, nên làm sao để ta có thể cũng làm cho danh sách là thực thể của Applicative theo cách thứ hai này? Nếu bạn còn nhớ, chúng ta đã nói rằng kiểu ZipList a được giới thiệu với mục đích như vậy, kiểu dữ liệu này có một constructor giá trị, ZipList, vốn chỉ chứa một trường. Ta đặt danh sách được bao bọc vào trong trường đó. Khi này, ZipList được tạo thành thực thể của Applicative, để cho khi ta cần dùng danh sách như những functor áp dụng theo cách đan cài, thì ta chỉ việc bọc chúng bằng constructor ZipList và khi đã xong việc, thì gỡ bọc bằng getZipList:

ghci> getZipList $ ZipList [(+1),(*100),(*5)]  ZipList [1,2,3]
[2,200,15]

Như vậy, trường hợp này ta có thể dùng newtype như thế nào? Ồ, hãy hình dung cách mà ta viết lời khai báo dữ liệu cho kiểu ZipList a. Một cách viết có thể là:

data ZipList a = ZipList [a]

Kiểu dữ liệu chỉ gồm một constructor giá trị và constructor giá trị đó chỉ có một trường thì chính là kiểu danh sách. Có thể ta cũng muốn dùng cú pháp bản ghi để tự động có được một hàm nhằm mục đích kết xuất một danh sách từ ZipList:

data ZipList a = ZipList { getZipList :: [a] }

Các này trông cũng được và thực chất hoạt động khá tốt. Ta đã có hai cách làm cho một kiểu có sẵn trở thành thực thể của một lớp, và trong cách thứ hai ta dùng từ khóa data để chỉ việc bọc kiểu dữ liệu này đặt vào trong một kiểu khác rồi làm cho kiểu bên ngoài trở thành một thực thể.

Trong Haskell, từ khóa newtype được dành riêng cho những trường hợp như vậy khi ta chỉ cần đem một kiểu bọc vào trong thứ gì đó để biểu diễn đưới dạng một kiểu khác. Trong các thư viện, ZipList a được định nghĩa như sau:

newtype ZipList a = ZipList { getZipList :: [a] }

Thay vì từ khóa data, từ khóa newtype được dùng đến. Tại sao ư? À, một lý do là newtype nhanh hơn. Nếu bạn dùng từ khóa data để gói một kiểu dữ liệu, thì sẽ có những chi phí phụ trội khi bao bọc và gỡ bọc trong quá trình chạy chương trình. Nhưng nếu bạn dùng newtype, Haskell sẽ biết rằng bạn chỉ dùng nó để bọc một kiểu có sẵn vào trong một kiểu mới (như tên gọi đã gợi ý), vì bạn muốn nó có cùng nội dung nhưng khác kiểu. Theo tinh thần như vậy, Haskell sẽ tránh việc bao bọc và gỡ bọc một khi nó phân giải được giá trị nào thuộc về kiểu nào.

Thế thì tại sao ta lại không dùng hẳn newtype thay vì data? À, khi bạn tạo một kiểu mới từ một kiểu sẵn có bằng từ khóa newtype, bạn chỉ có thể có một constructor giá trị và constructor giá trị đó chỉ được phép có một trường. Nhưng với data, bạn có thể tạo những kiểu dữ liệu có nhiều constructor giá trị và mỗi constructor được phép có nhiều trường hoặc không có:

data Profession = Fighter | Archer | Accountant

data Race = Human | Elf | Orc | Goblin

data PlayerCharacter = PlayerCharacter Race Profession

Khi dùng newtype, bạn bị hạn chế với đúng một constructor chứa một trường.

Ta cũng có thể dùng từ khóa deriving với newtype giống như dùng với data. Ta có thể suy diễn những thực thể cho Eq, Ord, Enum, Bounded, ShowRead. Nếu ta suy diễn thực thể cho một lớp, thì trước hết kiểu dữ liệu mà ta đang bao gói phải thuộc về lớp đó. Điều này có lý, bởi newtype chỉ đơn giản là bao gói một kiểu đã có sẵn. Vì vậy nếu viết như sau, thì ta có thể in ra và so sánh ngang bằng những giá trị thuộc kiểu dữ liệu mới:

newtype CharList = CharList { getCharList :: [Char] } deriving (Eq, Show)

Hãy thử nhé:

ghci> CharList "this will be shown!"
CharList {getCharList = "this will be shown!"}
ghci> CharList "benny" == CharList "benny"
True
ghci> CharList "benny" == CharList "oisters"
False

Trong newtype cụ thể này, constructor giá trị có kiểu như sau:

CharList :: [Char] -> CharList

Nó nhận một giá trị [Char], chẳng hạn "my sharona" rồi trả lại một giá trị CharList. Từ những ví dụ trên, mà ta đã sử dụng constructor giá trị CharList, ta thấy rõ được điều này. Ngược lại, hàm getCharList, vốn được tạo ra vì ta dùng cú pháp bản ghi trong newtype đang xét, thì có kiểu này:

getCharList :: CharList -> [Char]

Nó nhận một giá trị CharList rồi chuyển đổi thành một giá trị [Char]. Bạn có thể hình dung điều này như việc bao bọc và gỡ bọc, nhưng cũng có thể hình dung như việc chuyển đổi giá trị từ một kiểu dữ liệu này sang một kiểu khác.

Dùng newtype để tạo ra các thực thể lớp

Nhiều lúc ta muốn tạo riêng những thực thể kiểu của các lớp nhất định, nhưng tham số kiểu lại không hợp với điều ta định làm. Thật dễ làm cho Maybe trở thành một thực thể của Functor, vì lớp Functor được định nghĩa như sau:

class Functor f where
    fmap :: (a -> b) -> f a -> f b

Vì vậy ta có thể bắt đầu bằng:

instance Functor Maybe where

Rồi tạo lập fmap. Tất cả các tham số kiểu được dồn lại vì Maybe thay thế cho f trong lời định nghĩa của lớp Functor và vì vậy nếu ta nhìn vào fmap như thể nó chỉ hoạt động được với Maybe, thì rút cục nó sẽ có biểu hiện sau:

fmap :: (a -> b) -> Maybe a -> Maybe b

wow, very evil

Thế chẳng phải là đẹp sao? Bây giờ sẽ ra sao nếu bạn muốn khiến một bộ trở thành thực thể của Functor theo cách mà khi ta fmap một hàm lên một bộ, nó sẽ được áp dụng cho phần tử đầu tiên của bộ? Theo đó, khi viết fmap (+3) (1,1) ta có kết quả là (4,1). Hoá ra là việc viết thực thể như vậy rất khó. Bằng Maybe, ta chỉ cần viết rằng instance Functor Maybe where vì chỉ có constructor kiểu nào mà nhận đúng một tham số mới có thể làm thực thể của Functor. Nhưng dường như không có cách nào để làm điều tựa như vậy với (a,b) vì vậy tham số kiểu a rút cục sẽ là thứ thay đổi khi ta dùng fmap. Để khắc phục điều này, ta có thể tạo newtype cho bộ được xét theo cách mà tham số kiểu thứ hai biểu diễn cho thành phần thứ nhất trong bộ:

newtype Pair b a = Pair { getPair :: (a,b) }

Và bây giờ, ta có thể khiến cho nó thành thực thể Functor để cho hàm được ánh xạ lên phần tử thứ nhất:

instance Functor (Pair c) where
    fmap f (Pair (x,y)) = Pair (f x, y)

Bạn thấy đấy, ta có thể khớp mẫu với các kiểu dữ liệu được định nghĩa bằng newtype. Ta khớp mẫu để lấy được bộ dữ liệu ẩn bên trong, tiếp theo là áp dụng hàm f đối với phần tử thứ nhất trong bộ rồi sau đó dùng constructor giá trị có tên Pair để chuyển đổi bộ trở lại thành Pair b a. Nếu thử hình dung xem kiểu fmap sẽ là gì nếu nó chỉ hoạt động với những cặp đôi mới trong trường hợp này, thì kiểu của nó chính là:

fmap :: (a -> b) -> Pair c a -> Pair c b

Một lần nữa, ta viết instance Functor (Pair c) where và như vậy Pair c chiếm chỗ của f trong lời định nghĩa lớp cho Functor:

class Functor f where
    fmap :: (a -> b) -> f a -> f b

Như vậy bây giờ, nếu chuyển đổi một bộ trở thành Pair b a, thì ta sẽ dùng được fmap lên nó và hàm sẽ được ánh xạ lên phần tử thứ nhất:

ghci> getPair $ fmap (*100) (Pair (2,3))
(200,3)
ghci> getPair $ fmap reverse (Pair ("london calling", 3))
("gnillac nodnol",3)

Về tính lười biếng của newtype

Ta đã đề cập rằng newtype thường nhanh hơn là data. Việc duy nhât mà newtype có thể làm là chuyển một kiểu sẵn có thành một kiểu mới; cho nên ở bên trong, Haskell có thể biểu diễn các giá trị có kiểu được định nghĩa bằng newtype cũng như các giá trị gốc, chỉ khác là ta cần lưu ý rằng kiểu của chúng bây giờ là riêng biệt. Điều này nghĩa là newtype không chỉ nhanh hơn, mà còn lười biếng hơn. Hãy xét xem điều đó là sao.

Như ta đã nói, theo mặc định, Haskell có tính lười biếng, nghĩa rằng chỉ khi ta cố thử in ra kết quả của các hàm được viết, thì việc tính toán mới được tiến hành. Hơn nữa, chỉ những tính toán nào cần thiết để hàm cho ra kết quả thì tính toán đó mới được thực hiện. Giá trị undefined trong Haskell biểu diễn một kết quả tính toán có lỗi. Nếu ta thử lượng giá nó (nghĩa là buộc Haskell phải thực hiện) bằng cách in nó ra màn hình, thì Haskell sẽ ném trả một sự bực bội (theo thuật ngữ thì đó là một biệt lệ):

ghci> undefined
*** Exception: Prelude.undefined

Tuy nhiên, nếu ta tạo một danh sách có một số giá trị undefined trong đó, nhưng chỉ yêu cầu mỗi phần tử đầu của danh sách, vốn không phải là undefined, thì mọi thứ vẫn diễn ra thuận lợi vì Haskell không cần phải lượng giá bất cứ phần tử nào khác trong danh sách nếu ta cần biết mỗi phần tử đầu danh sách là gì:

ghci> head [3,4,5,undefined,2,undefined]
3

Bây giờ hãy xét kiểu dữ liệu sau:

data CoolBool = CoolBool { getCoolBool :: Bool }

Đó là kiểu dữ liệu đại số được định nghĩa bằng từ khoá data. Nó gồm một constructor giá trị, vốn chỉ có một trường với kiểu là Bool. Ta hãy lập một hàm để khớp mẫu với CoolBool rồi trả về giá trị "hello" bất kể việc Bool ở bên trong CoolBoolTrue hay False:

helloMe :: CoolBool -> String
helloMe (CoolBool _) = "hello"

Thay vì việc áp dụng hàm này lên một CoolBool thông thường, ta hãy ném cho nó một cú bóng xoáy rồi áp dụng nó vào undefined!

ghci> helloMe undefined
"*** Exception: Prelude.undefined

Ối! Một biệt lệ! Tại sao biệt lệ này xảy ra? nhưng kiểu được định nghĩa với từ khoá data có thể gồm nhiều constructor giá trị (ngay cả khi CoolBool chỉ có một)> Vì vậy để thấy được liệu rằng giá trị được cấp cho hàm đang xét có tuân theo dạng mẫu (CoolBool _) hay không, Haskell phải lượng giá cái giá trị này đủ để thấy được constructor giá trị nào được dùng đến khi ta lập giá trị này. Và trong quá trình lượng giá undefined, dù chỉ một chút thôi, thì biệt lệ đã được ném ra rồi.

Thay vì dùng từ khoá data cho CoolBool, ta hãy thử dùng newtype:

newtype CoolBool = CoolBool { getCoolBool :: Bool }

Ta không phải sửa đổi hàm helloMe đang xét, vì cú pháp khớp mẫu vẫn như vậy nếu ta dùng newtype hoặc data để định nghĩa kiểu dữ liệu. Bây giờ hãy làm điều này rồi áp dụng helloMe đối với một giá trị undefined :

ghci> helloMe undefined
"hello"

top of the mornin to ya!!!

Nó hoạt động được! Hừm, tại sao vậy? À, như ta đã nói, khi ta dùng newtype, thì ở bên trong, Haskell có thể biểu diễn giá trị của kiểu dữ liệu mới theo cách giống như các giá trị gốc. Nó không cần phải đóng thêm một hộp nào nữa, mà chỉ cần hiểu rằng các giá trị thuộc kiểu khác. Và vì Haskell biết rằng những kiểu được lập bằng từ khóa newtype có thể chỉ có một constructor, nên nó không cần lượng giá cho giá trị nào được truyền vào hàm để khẳng định rằng nó tuân theo dạng mẫu (CoolBool _) vì các kiểu newtype chỉ có thể có một constructor giá trị và một trường!

Sự khác biệt này về biểu hiện dường như nhỏ nhặt, song thực ra lại rất quan trọng; nó giúp ta nhận thấy rằng mặc dù những kiểu được định nghĩa bằng datanewtype đều biểu hiện giống nhau theo quan điểm của người lập trình vì chúng đều có constructor giá trị và các trường, nhưng chúng thực ra là hai cơ chế khác nhau. Nếu như data có thể được dùng để tạo ra những kiểu riêng từ đầu, thì newtype được dùng để tạo ra một kiểu mới bắt nguồn từ kiểu sẵn có. Việc khớp mẫu trên các giá trị newtype khác với việc lấy một thứ khỏi hộp (như làm với data), mà giống hơn là việc chuyển đổi trực tiếp từ một kiểu này sang kiểu khác.

So sánh type, newtypedata

Đến đây, bạn có thể hơi nhầm lẫn về điểm khác biệt giữa type, datanewtype, vì vậy ta hãy cùng
ôn lại một chút.

Từ khoá type được dùng để tạo ra các kiểu tương đồng. Điều đó có nghĩa là ta chỉ việc đặt một tên khác cho một kiểu dữ liệu sẵn có sao cho kiểu mới tiện cho việc tham chiếu đến hơn. Chẳng hạn, ta viết:

type IntList = [Int]

Tất cả những điều này là để cho phép ta tham chiếu đến kiểu [Int] với tên IntList. Chúng có thể được dùng thay thế nhau được. Sẽ không có cái gọi là constructor giá trị IntList hoặc thứ tương tự. Vì chỉ có hai cách, [Int]IntList để tham chiếu đến cùng kiểu đang xét, nên việc ta dùng tên nào trong chú thích kiểu là không quan trọng:

ghci> ([1,2,3] :: IntList) ++ ([1,2,3] :: [Int])
[1,2,3,1,2,3]

Ta dùng kiểu tương đồng khi muốn làm cho dấu ấn kiểu được đặc tả hơn bằng cách cung cấp những tên gọi kiểu gợi cho ta hình dung được công dụng của chúng trong ngữ cảnh các hàm được sử dụng. Chẳng hạn, khi ta dùng một danh sách kết hợp có kiểu [(String,String)] để biểu diễn một danh bạ điện thoại, thì ta đặt cho kiểu tương đồng là PhoneBook để cho dấu ấn kiểu, trong các hàm ta viết, được dễ đọc hơn.

Từ khoá newtype được dùng cho những kiểu có sẵn để gói chúng trong những kiểu dữ liệu mới, với mục đích chủ yếu là để dễ biến chúng thành thực thể của những lớp nhất định. Khi ta dùng newtype để bọc một kiểu có sẵn, thì kiểu dữ liệu mà ta thu được sẽ tách biệt khỏi kiểu ban đầu. Nếu ta tạo lập newtype mới sau đây:

newtype CharList = CharList { getCharList :: [Char] }

Thì ta sẽ không thể dùng ++ để hợp lại một CharList và một danh sách các kiểu dữ liệu
[Char]. Thậm chí ta còn không thể dùng ++ để hợp lại hai CharList,
++ chỉ làm việc với danh sách còn kiểu CharList không phải là một danh sách, dù ta có thể nói ràng nó chứa một danh sách. Dù vậy, ta có thể biến đổi hai CharList thành danh sách, rồi ++ chúng lại, sau đó chuyển đổi kết quả ngược về một CharList.

Khi ta dùng cú pháp dạng bản ghi trong lời khai báo newtype, ta nhận được các hàm để chuyển đổi qua lại giữa kiểu mới và kiểu gốc: đó là constructor giá trị của newtype đang xét và hàm kết xuất giá trị từ trường của nó. Kiểu dữ liệu mới không được tự động tạo lập là thực thể của lớp chứa kiểu ban đầu, vì vậy ta phải suy diễn hoặc tự tay viết chúng.

Thực tế là bạn có thể hình dung lời khai báo newtype như là khai báo data nhưng chỉ được phép có một constructor và một trường. Nếu bạn bắt gặp tình huống phải viết một lời khai báo data như vậy, thì hãy xem xét việc dùng newtype.

Từ khoá data được dùng để tự tạo ra những kiểu dữ liệu và với chúng, bạn có thể tự do phóng tác. Chúng có thể có số các constructor và số trường tuỳ ý, và bạn có thể dùng để tự tạo lập các kiểu dữ liệu đại số. Mọi thứ từ danh sách và những kiểu tựa như Maybe, cho đến dữ liệu cây.

Nếu chỉ cần dấu ấn kiểu được rõ ràng và đặc tả hơn, thì bạn có thể dùng kiểu tương đồng. Nếu bạn muốn lấy một kiểu sẵn có rồi gói nó vào một kiểu mới để làm cho nó trở thành thực thể của một lớp, thì có thể bạn cần đến newtype. Và nếu muốn tạo ra một thứ hoàn toàn mới, thì nhiều khả năng bạn phải tìm đến từ khoádata.

Monoid

wow this is pretty much the gayest pirate ship<br />
ever

Trong Haskell, các lớp chứa kiểu được dùng để biểu diễn một giao diện cho các kiểu có chung một hành vi nào đó. Ta bắt đầu với những lớp đơn giản như Eq, vốn dành cho những kiểu mà giá trị có thể so sánh ngang bằng, và Ord, dành cho những thứ có thể được xếp thứ tự; tiếp theo là đến những lớp thú vị hơn như FunctorApplicative.

Khi lập ra một kiểu, ta nghĩ về những hành vi mà kiểu dữ liệu này cho phép, nghĩa là nó có thể hoạt động như thế nào rồi dựa vào đó để quyết định xem sẽ đặt kiểu dữ liệu này vào lớp nào. Nếu các giá trị trong kiểu đang xét so sánh ngang bằng được, ta sẽ để kiểu này là thực thể của lớp Eq. Nếu ta thấy rằng kiểu đang xét tựa như functor, thì ta sẽ cho nó là thực thể của Functor, v.v.

Bây giờ xét điều sau đây: * là một hàm nhận vào hai số rồi nhân chúng với nhau. Nếu ta nhân một số với 1, kết quả sẽ luôn là số ban đầu. Chẳng ảnh hưởng gì nếu ta viết 1 * x hay x * 1, kết quả vẫn luôn là x. Tương tự, ++ cũng là một hàm nhận vào hai thứ rồi trả lại một thứ khác. Chỉ có điều là thay vì nhân hai số, nó nhận vào hai danh sách rồi nối chúng lại. Và rất giống với *, nó cũng có một giá trị nhất định mà không làm thay đổi cái còn lại khi được dùng với ++. Giá trị đó là danh sách rỗng: [].

ghci> 4 * 1
4
ghci> 1 * 9
9
ghci> [1,2,3] ++ []
[1,2,3]
ghci> [] ++ [0.5, 2.5]
[0.5,2.5]

Dường như cả hai * cùng 1, và ++ cùng [] đều có chung một số thuộc tính:

  • Hàm nhận vào hai tham số.
  • Các tham số và giá trị được trả lại có cùng kiểu.
  • Có một giá trị mà khi ta dùng với hàm hai ngôi, thì không làm thay đổi giá trị còn lại.

Còn một điểm chung nữa giữa hai hàm trên mà có thể không dễ thấy như những điều đã nêu; đó là khi ta có nhiều (hơn hai) giá trị và muốn dùng hàm hai ngôi để rút gọn về một kết quả, thì thứ tự mà ta áp dụng hàm này cho các giá trị là tuỳ ý. Sẽ chẳng khác gì khi ta viết (3 * 4) * 5 hay 3 * (4 * 5). Theo cách nào chăng nữa, kết quả đều là 60. Cũng tương tự đối với ++:

ghci> (3 * 2) * (8 * 5)
240
ghci> 3 * (2 * (8 * 5))
240
ghci> "la" ++ ("di" ++ "da")
"ladida"
ghci> ("la" ++ "di") ++ "da"
"ladida"

Ta gọi tính chất này là tính kết hợp. * có tính kết hợp, ++ cũng vậy, nhưng -, chẳng hạn, thì không. Hai biểu thức (5 - 3) - 45 - (3 - 4) cho kết quả khác nhau.

Qua việc nhận biết và ghi lại những thuộc tính này, ta đã có cơ hội gặp được monoids! Monoid xuất hiện khi bạn có một hàm nhị phân, có tính kết hợp, và một giá trị đóng vai trò như một “phần tử trung hoà” của hàm. Phàn tử trung hoà của một hàm có nghĩa là khi ta gọi hàm đó với phần tử này cùng một giá trị nào đó khác, thì kết quả sẽ luôn bằng giá trị kia. 1 là phần tử trung hoà đối với * còn [] là phần tử trung hoà đối với ++. Trong Haskell còn có nhiều monoid nữa, đó là lý do tồn tại của lớp Monoid. Nó được dành cho những kiểu dữ liệu có thể đóng vai trò của monoids. Ta hãy xem lớp này được định nghĩa như thế nào:

class Monoid m where
    mempty :: m
    mappend :: m -> m -> m
    mconcat :: [m] -> m
    mconcat = foldr mappend mempty

woof dee do!!!

Lớp Monoid được định nghĩa trong import Data.Monoid. Ta hãy dành chút thời gian để làm quen với nó.

Trước hết, ta thấy được rằng chỉ có những kiểu cụ thể mới có thể làm thành thực thể của Monoid, vì m trong lời khai báo lớp không nhận bất kì tham số kiểu nào. Điều này khác với FunctorApplicative; những lớp này yêu cầu thực thể của chúng phải là constructor kiểu và nhận vào một tham số.

Hàm thứ nhất là mempty. Nó không hẳn là một hàm, vì nó hông nhận vào tham số, vì vậy nó là một hằng số đa hình, tựa như minBound trong Bounded. mempty biểu diễn giá trị trung hoà của một monoid cụ thể.

Tiếp theo, ta có mappend, mà, có lẽ bạn đã đoán được, là hàm hai ngôi. Nó nhận vào hai giá trị thuộc cùng kiểu rồi trả lại một giá trị cũng cùng kiểu đó. Cần lưu ý rằng quyết định đặt tên mappend như trên phần nào bất tiện, vì nó gợi cho ý nghĩ rằng ta đang kết nối hai thứ theo cách nào đó. Nếu như ++ đúng là nhận hai danh sách rồi nối tiếp danh sách này sau danh sách kia, thì * không thực hiện kết nối gì, nó chỉ nhân hai số với nhau. Khi gặp những thực thể khác của Monoid, ta sẽ thấy rằng hầu hết chúng đều không nối thêm các giá trị, vì vậy hãy tránh việc hình dung về nối dài mà hãy nghĩ đơn giản là mappend là một hàm hai ngôi nhận vào hai monoid rồi trả về một monoid khác.

Hàm sau cùng trong lời khai báo lớp này là mconcat. Nó nhận vào một danh sách các giá trị monoid rồi rút gọn chúng về một giá trị bằng cách viết mappend giữa các phần tử của danh sách. nó có một cách tạo lập mặc định, trong đó chỉ nhận giá trị khởi đầu là mempty rồi gấp danh sách từ phía phải bằng mappend. Vì cách tạo lập mặc định hoạt động được với hầu hết các thực thể, nên từ giờ ta sẽ không quan tâm đến mconcat. Khi tạo ra một thực thể của Monoid, chỉ cần tạo lập memptymappend là đủ. Lý do mà mconcat có mặt ở đây là vì đối với một số thực thể, có thể sẽ có một cách hiệu quả hơn để tạo lập mconcat, nhưng với hầu hết các thực thể thì cách tạo lập theo mặc định là tốt rồi.

Trước khi chuyển đến những thực thể cụ thể của Monoid, ta hãy điểm qua các định luật monoid. Ta đã đệ cập rằng phải có một giá trị đóng vai trò phần tử trung hoà ứng với hàm hai ngôi và hàm hai ngôi này phải có tính kết hợp. Có thể tạo các thực thể của Monoid mà không tuân theo những quy luật này, nhưng các thực thể đó chẳng có ích gì bởi khi dùng lớp Monoid, ta đã dựa vào các thực thể của nó với vai trò monoid. Nếu không, thì mục đích của việc đó là gì? Điều này lý giải tại sao khi tạo ra thực thể, ta phải đảm bảo rằng chúng tuân theo các định luật sau:

  • mempty `mappend` x = x
  • x `mappend` mempty = x
  • (x `mappend` y) `mappend` z = x `mappend` (y
    `mappend` z)

Hai định luật đầu phát biểu rằng mempty phải đóng vai trò của phần tử trung hoà trong mappend và định luật thứ ba phát biểu rằng mappend phải có tính kết hợp, nghĩa là thứ tự mà ta dùng mappend để rút gọn nhiều giá trị monoid về một giá trị phải không quan trọng. Haskell không áp đặt các định luật này, vì vậy người lập trình phải cẩn thận đảm bảo rằng những thực thể hiện có phải tuân theo các định luật này.

Danh sách là monoid

Đúng vậy, danh sách là monoid! Như ta đã thấy, hàm ++ và danh sách rỗng [] hình thành một monoid. Thực thể này rất đơn giản:

instance Monoid [a] where
    mempty = []
    mappend = (++)

Danh sách là một thực thể của lớp Monoid bất kể kiểu các phần tử chứa trong đó có là gì đi nữa. Lưu ý rằng ta viết instance Monoid [a] chứ không phải instance Monoid [], vì Monoid yêu cầu thực thể phải có kiểu cụ thể.

Chạy thử thực thể này, ta bắt gặp hai điều bất ngờ:

ghci> [1,2,3] `mappend` [4,5,6]
[1,2,3,4,5,6]
ghci> ("one" `mappend` "two") `mappend` "tree"
"onetwotree"
ghci> "one" `mappend` ("two" `mappend` "tree")
"onetwotree"
ghci> "one" `mappend` "two" `mappend` "tree"
"onetwotree"
ghci> "pang" `mappend` mempty
"pang"
ghci> mconcat [[1,2],[3,6],[9]]
[1,2,3,6,9]
ghci> mempty :: [a]
[]

smug as hell

Lưu ý rằng ở dòng cuối cùng, ta đã phải viết một chú thích kiểu minh bạch, vì nếu ta chỉ viết mempty thôi, thì GHCi sẽ không biết phải dùng thực thể nào, vì vậy ta nói rằng ta cần thực thể danh sách. Ta đã có thể dùng kiểu tổng quát [a] (thay vì viết rõ là [Int] hay [String]) vì danh sách rỗng có thể đóng vai trò vật chứa đựng bất kì kiểu nào.

mconcat có một cách thực hiện mặc định, nên nó luôn sẵn có mỗi khi ta làm cho một thứ bất kì thành thực thể của Monoid. Với trường hợp của đanh sách, mconcat hoá ra lại là concat. Nó nhận một danh sách chứa các danh sách rồi duỗi thẳng danh sách lớn này vì điều đó tương đương với việc dùng ++ giữa tất cả các phần tử liền kề trong một danh sách.

Định luật monoid thật ra không đúng cho thực thể danh sách. Khi ta có nhiều danh sách và thực hiện mappend (hoặc ++) chúng lại, thì việc thực hiện danh sách nào trước cũng không ảnh hưởng, vì sau cùng thì đằng nào chúng cũng được nối lại. Đồng thời, danh sách rỗng đóng vai trò phần tử trung hòa nên mọi thứ đều ổn. Lưu ý rằng các monoid không yêu cầu rằng a `mappend` b phải bằng b `mappend` a. Trong trường hợp với danh sách, chúng rõ ràng là không như nhau:

ghci> "one" `mappend` "two"
"onetwo"
ghci> "two" `mappend` "one"
"twoone"

Và như vậy lại được. Việc mà các phép nhân 3 * 55 * 3 cho kết quả như nhau chỉ là một thuộc tính của phép nhân, nhưng thuộc tính này không dùng được cho tất cả (thực tế là không dùng được cho hầu hết) các monoid.

ProductSum

Ta đã xét một cách để cho các số trở nhành monoid. Chỉ cần hàm hai ngôi là * và giá trị trung hòa bằng 1. Hóa ra rằng đây không phải là cách duy nhất để các số trở thành monoid. Một cách khác là lấy hàm hai ngôi + và giá trị trung hòa bằng 0:

ghci> 0 + 4
4
ghci> 5 + 0
5
ghci> (1 + 3) + 5
9
ghci> 1 + (3 + 5)
9

Định luật monoid được thỏa mãn, bởi nếu bạn cộng 0 vào với số bất kì, thì kết quả chính là số đó. Và phép cộng cũng có tính kết hợp vì vậy ta không gặp rắc rối gì ở đây. Vì vậy bây giờ có hai cách hợp lệ như nhau để giúp các số trở thành monoid, bạn chọn cách nào? Ồ, không cần phải chọn đâu. Hãy nhớ rằng đã có vài cách để một kiểu trở thành thực thể của lớp, ta có thể gói kiểu đó vào trong một newtype và rồi làm cho kiểu mới tạo thành trở nên thực thể của lớp theo một cách khác. Ta có thể vừa ăn bánh lại vừa còn bánh.

Module Data.Monoid xuất khẩu hai kiểu dữ liệu cho lớp này, có tên là ProductSum. Product được định nghĩa như sau:

newtype Product a =  Product { getProduct :: a }
    deriving (Eq, Ord, Read, Show, Bounded)

Đơn giản, chỉ là một gói bọc newtype cùng một tham số kiểu bên cạnh những thực thể suy diễn nào đó. Thực thể của nó đối với Monoid có dạng như sau:

instance Num a => Monoid (Product a) where
    mempty = Product 1
    Product x `mappend` Product y = Product (x * y)

mempty chỉ là 1 được gói trong một constructor là Product. Dạng mẫu mappend khớp với constructor Product, nhân hai số lại với nhau rồi bọc con số kết quả lại. Bạn thấy đấy, có một ràng buộc lớp Num a. Như vậy điều này có nghĩa là Product a là thực thể của Monoid với mọi a nào đã thực thể của Num. Để dùng được Producta a như là một monoid, ta phải thực hiện việc gói bọc và tháo gỡ nhất định đối với newtype:

ghci> getProduct $ Product 3 `mappend` Product 9
27
ghci> getProduct $ Product 3 `mappend` mempty
3
ghci> getProduct $ Product 3 `mappend` Product 4 `mappend` Product 2
24
ghci> getProduct . mconcat . map Product $ [3,4,2]
24

Đây là một trường hợp biểu diễn khá hay của lớp Monoid, nhưng chẳng có ai khi suy nghĩ bình thường sẽ chọn cách này để tính nhân thay vì chỉ viết 3 * 93 * 1. Song không còn lâu nữa, ta sẽ bắt gặp những thực thể Monoid mà hiện giờ có vẻ quá đỗi tầm thường lại trở nên thuận tiện thế nào.

Sum được định nghĩa tương tự như Product và thực thì cũng tương tự. Cách dùng cũng giống trước:

ghci> getSum $ Sum 2 `mappend` Sum 9
11
ghci> getSum $ mempty `mappend` Sum 3
3
ghci> getSum . mconcat . map Sum $ [1,2,3]
6

AnyAll

Một kiểu khác có thể đóng vai trò như monoid theo hai cách riêng biệt nhưng tương đương và đều hợp lệ, đó là kiểu Bool. Cách thứ nhất là dùng hàm or || đóng vai trò như hàm hai ngôi cùng với False làm giá trị trung hòa. Cách hoạt động của or về mặt logic là nếu bất kì tham số nào trong hai tham số là True, thì nó sẽ trả lại True, còn không thì trả lại False. Vì vậy, nếu ta dùng False làm phần tử trung hòa thì nó sẽ trả lại False khi được or với FalseTrue khi được or với True. Cái constructor newtype có tên Any là một thực thể của Monoid theo cách này. Constructor này được định nghĩa như sau:

newtype Any = Any { getAny :: Bool }
    deriving (Eq, Ord, Read, Show, Bounded)

Và thực thể của nó thì như sau:

instance Monoid Any where
        mempty = Any False
        Any x `mappend` Any y = Any (x || y)

Lý do nó mang tên Any là vì x `mappend` y sẽ là True nếu bất kì cái nào trong hai cái (x,y) là True. Ngay cả khi có nhiều Bool gói trong Any được mappend lại với nhau, thì kết quả cũng sẽ True nếu bất kì cái nào trong số chúng là True:

ghci> getAny $ Any True `mappend` Any False
True
ghci> getAny $ mempty `mappend` Any True
True
ghci> getAny . mconcat . map Any $ [False, False, False, True]
True
ghci> getAny $ mempty `mappend` mempty
False

Một cách khác để Bool trở thành thực thể của Monoid sẽ tựa như kiểu ngược lại: dùng && làm hàm hai ngôi và đặt True là giá trị trung hòa. Phép and logic sẽ trả lại True chỉ khi cả hai tham số đều là True. Sau đây là lời khai báo newtype, chẳng có gì cầu kì cả:

newtype All = All { getAll :: Bool }
        deriving (Eq, Ord, Read, Show, Bounded)

Và sau đây là thực thể:

instance Monoid All where
        mempty = All True
        All x `mappend` All y = All (x && y)

Khi ta mappend các giá trị của kiểu All thì kết quả sẽ là True chỉ khi tất cả các giá trị dùng trong các phép mappend đều là True:

ghci> getAll $ mempty `mappend` All True
True
ghci> getAll $ mempty `mappend` All False
False
ghci> getAll . mconcat . map All $ [True, True, True]
True
ghci> getAll . mconcat . map All $ [True, True, False]
False

Cũng giống như với phép nhân và phép cộng, ta thường phát biểu rõ các hàm hai ngôi thay vì gói chúng vào trong các newtype rồi mới dùng mappendmempty. mconcat có vẻ hữu ích đối với AnyAll, nhưng thường sẽ dễ hơn nếu ta dùng các hàm orand, vốn nhận những danh sách Bool và trả lại True nếu (đối với or) bất kì phần tử nào trong đó là True hoặc (đối với and) tất cả trong số đó đều là True.

Monoid có tên Ordering

Ê, bạn còn nhớ kiểu Ordering chứ? Nó được dùng làm kết quả khi ta so sánh các thứ với nhau và nó có thể mang ba giá trị: LT, EQGT, vốn lần lượt là viết tắt của less than (nhỏ hơn), equal (bằng) và greater than (lớn hơn):

ghci> 1 `compare` 2
LT
ghci> 2 `compare` 2
EQ
ghci> 3 `compare` 2
GT

Đối với danh sách, giá trị số hoặc giá trị boole thì việc tìm ra monoid chỉ đơn giản là nhìn vào những hàm thông dụng và xem liệu chúng có biểu hiện gì như monoid không. Đối với Ordering, ta phải nhìn kĩ hơn một chút mới phát hiện ra monoid, nhưng hóa ra là thực thể Monoid của nó cũng trực quan như những cái ta đã bắt gặp, và nó cũng khá hữu dụng:

instance Monoid Ordering where
    mempty = EQ
    LT `mappend` _ = LT
    EQ `mappend` y = y
    GT `mappend` _ = GT

did anyone ORDER pizza?!?! I can't BEAR these puns!

Thực thể được lập nên như sau: khi ta mappend hai giá trị Ordering, thì cái ở bên vế trái được giữ lại, trừ khi giá trị bên trái là EQ, lúc này thì giá trị bên phải sẽ là khết quả. Giá trị trung hòa ở đây là EQ. Thoạt đầu, dường như cách định nghĩa này có vẻ tùy tiện, song thực ra nó giống với cách mà ta so sánh các từ theo bảng chữ cái. Ta so sánh hai chữ cái đầu tiên và nếu chúng khác nhau thì sẽ biết được ngay rằng từ nào sẽ đứng trước trong từ điển. Tuy nhiên, nếu hai chữ cái đầu tiên bằng nhau, thì ta chuyển đến cặp chữ cái tiếp theo rồi lặp lại quá trình.

Chẳng hạn, nếu ta phải dựa vào bảng chữ cái để so sánh hai từ "ox""on", trước hết ta so sánh hai chữ cái đứng đầu mỗi từ, để thấy được chúng bằng nhau và tiếp đến là so sánh các chữ cái thứ hai. Ta thấy rằng trong bảng chữ cái 'x' lớn hơn (tức là đứng sau) 'n', và vì thế việc so sánh hai từ đã rõ. Để đạt được trực giác trong việc coi EQ là phần tử trung hòa, ta có thể nhận thấy rằng nếu phải nhồi cùng một chữ cái vào trong cùng vị trí ở cả hai từ thì sẽ chẳng làm thay đổi thứ tự sắp xếp của hai từ đó. "oix" vẫn lớn hơn (xếp sau) "oin".

Cần lưu ý rằng trong thực thể Monoid đối với Ordering, x `mappend` y không bằng y `mappend` x. Vì tham số thứ nhất được giữ nguyên trừ khi nó là EQ, LT `mappend` GT sẽ cho kết quả là LT, còn GT `mappend` LT sẽ cho kết quả là GT:

ghci> LT `mappend` GT
LT
ghci> GT `mappend` LT
GT
ghci> mempty `mappend` LT
LT
ghci> mempty `mappend` GT
GT

Được rồi, thế cái monoid này có ích ở điểm nào? Giả sử rằng bạn phải viết một hàm nhận vào hai chuỗi, so sánh chiều dài của chúng, rồi trả lại một Ordering. Nhưng nếu chuỗi có cùng độ dài thì thay vì trả lại ngay EQ ta muốn so sánh thứ tự xếp theo bảng chữ cái. Một cách làm sẽ là như sau:

lengthCompare :: String -> String -> Ordering
lengthCompare x y = let a = length x `compare` length y 
                        b = x `compare` y
                    in  if a == EQ then b else a

Ta đặt kết quả của việc so sánh các chiều dài là a và kết quả so sánh thứ tự sắp xếp là b rồi nếu hai chiều dài bằng nhau thì hàm sẽ trả lại thứ tự sắp xếp theo bảng chữ cái.

Nhưng bằng cách dùng hiểu biết về việc Ordering là một monoid thì ta có thể viết lại hàm này theo cách đơn giản hơn:

import Data.Monoid

lengthCompare :: String -> String -> Ordering
lengthCompare x y = (length x `compare` length y) `mappend`
                    (x `compare` y)

Có thể thử gọi hàm vừa viết:

ghci> lengthCompare "zen" "ants"
LT
ghci> lengthCompare "zen" "ant"
GT

Hãy nhớ rằng, khi ta dùngmappend, tham số vế trái của nó luôn được giữ lại trừ khi tham số này là EQ, với trường hợp như vậy thì tham số vế phải được giữ. Điều này lý giải tại sao ta lại đặt phép so sánh mà ta xét trước tiên, cái tiêu chuẩn quan trọng hơn, vào vị trí tham số vế trái. Nếu ta muốn mở rộng hàm này để so sánh với số các nguyên âm nữa và coi đây là tiêu chí quan trọng thứ hai để so sánh, thì có thể sửa hàm lại thành như sau:

import Data.Monoid

lengthCompare :: String -> String -> Ordering
lengthCompare x y = (length x `compare` length y) `mappend`
                    (vowels x `compare` vowels y) `mappend`
                    (x `compare` y)
    where vowels = length . filter (`elem` "aeiou")

Ta đã lập một hàm phụ trợ, để nhận một chuỗi và báo cho ta biết có bao nhiêu nguyên âm trong đó bằng cách trước hết là lọc chỉ lấy những kí tự trong chuỗi "aeiou" rồi đem áp dụng length vào kết quả.

ghci> lengthCompare "zen" "anna"
LT
ghci> lengthCompare "zen" "ana"
LT
ghci> lengthCompare "zen" "ann"
GT

Rất hay. Ở đây, ta đã thấy được bằng cách nào mà trong ví dụ thứ nhất, hai chiều dài đã được phát hiện là khác nhau và vì vậy LT đã được trả lại, bởi chiều dài của "zen" thì kém chiều dài của "anna". Ở ví dụ thứ hai, các chiều dài là bằng nhau, nhưng chuỗi thứ hai có nhiều nguyên âm hơn, vì vậy LT một lần nữa được trả lại. Ở ví dụ thứ ba, cả hai chuỗi đều cùng độ dài và cùng số nguyên âm, vì vậy chúng được so sánh theo thứ tự bảng chứ cái và "zen" thắng cuộc.

Monoid Ordering rất hay vì nó cho phép ta dễ dàng so sánh các thứ theo nhiều tiêu chí khác nhau và lại đặt các tiêu chí đó theo một trật tự riêng, từ tiêu chí quan trọng nhất đến ít quan trọng.

Maybe cũng là monoid

Ta hãy xem một loạt những cách để Maybe a trở thành thực thể của Monoid và các thực thể đó dùng được vào việc gì.

Một cách để coi Maybe a là một monoid chỉ khi tham số kiểu a của nó cũng là một monoid, sau đó thực hiện mappend theo cách mà nó dùng thao tác mappend với các giá trị được gói trong Just. Ta dùng Nothing làm giá trị trung hòa, và vì vậy nếu một trong hai giá trị đang được mappendNothing, thì ta sẽ giữ giá trị còn lại. Sau đây là lời khai báo thực thể:

instance Monoid a => Monoid (Maybe a) where
    mempty = Nothing
    Nothing `mappend` m = m
    m `mappend` Nothing = m
    Just m1 `mappend` Just m2 = Just (m1 `mappend` m2)

Hãy chú ý ràng buộc về lớp. Ràng buộc này phát biểu rằng Maybe a là một thực thể của Monoid chỉ khi a là thực thể của Monoid. Nếu ta mappend một thứ gì đó với một Nothing, thì kết quả sẽ là chính thứ đó. Nếu ta mappend hai giá trị Just, thì kết quả của các Just được mappend lại rồi được gói trở lại vào một Just. Ta có thể làm được điều này vì ràng buộc về lớp đảm bảo rằng kiểu của thứ bên trong Just là một thực thể của Monoid.

ghci> Nothing `mappend` Just "andy"
Just "andy"
ghci> Just LT `mappend` Nothing
Just LT
ghci> Just (Sum 3) `mappend` Just (Sum 4)
Just (Sum {getSum = 7})

Cách này phát huy tác dụng khi bạn phải xử lý những monoid với vai trò kết quả tính toán có thể thất bại. Nhờ thực thể này, ta không cần kiểm tra xem liệu các tính toán có thất bại không bằng cách xem liệu chúng có phải là một Nothing hay giá trị Just không; ta chỉ việc tiếp tục coi chúng như những monoid thông thường.

Nhưng điều gì sẽ xảy ra nếu kiểu của giá trị trong Maybe không phải là thực thể của Monoid? Lưu ý rằng trong lời khai báo thực thể trước đây, trường hợp duy nhất mà ta phải dựa vào các giá trị monoid là khi cả hai tham số củamappend đều là giá trị Just. Nhưng nếu không biết rằng liệu các giá trị này là monoid, thì ta sẽ không thể dùng mappend giữa chúng, vì vậy ta phải làm gì? À, một điều có thể làm là chỉ việc bỏ giá trị thứ hai đi và giữ lại giá trị thứ nhất. Theo đó, kiểu First a có tồn tại và sau đây là lời định nghĩa:

newtype First a = First { getFirst :: Maybe a }
    deriving (Eq, Ord, Read, Show)

Ta lấy một Maybe a rồi bọc nó lại bằng một newtype. Thực thể Monoid là như sau:

instance Monoid (First a) where
    mempty = First Nothing
    First (Just x) `mappend` _ = First (Just x)
    First Nothing `mappend` x = x

Như ta đã nói, mempty chỉ là một Nothing được gói với một constructor newtype có tên First. Nếu tham số thứ nhất của mappend là một giá trị Just, thì ta bỏ qua tham số thứ hai. Nếu tham số thứ nhất là Nothing, thì ta lấy tham số thứ hai làm kết quả, bất kể nó có là Just hay Nothing đi nữa:

ghci> getFirst $ First (Just 'a') `mappend` First (Just 'b')
Just 'a'
ghci> getFirst $ First Nothing `mappend` First (Just 'b')
Just 'b'
ghci> getFirst $ First (Just 'a') `mappend` First Nothing
Just 'a'

First có ích khi ta có một loạt những giá trị Maybe và muốn biết xem trong số chúng có cái nào là Just không. Hàm mconcat trở nên có ích:

ghci> getFirst . mconcat . map First $ [Nothing, Just 9, Just 10]
Just 9

Nếu ta muốn một monoid của Maybe a sao cho tham số thứ hai được giữ lại nếu cả hai tham số của mappend đều là giá trị Just, thì Data.Monoid cung cấp kiểu Last a, vốn hoạt động như First a, chỉ khác là giá trị khác Nothing cuối cùng được giữ lại trong quá trình dùng mappendmconcat:

ghci> getLast . mconcat . map Last $ [Nothing, Just 9, Just 10]
Just 10
ghci> getLast $ Last (Just "one") `mappend` Last (Just "two")
Just "two"

Dùng monoid để gấp cấu trúc dữ liệu

Một trong những cách hay hơn để bắt monoid làm việc là để chúng giúp ta định nghĩa các phép gấp đối với những cấu trúc dữ liệu khác nhau. Cho đến giờ, ta mới chỉ gấp các danh sách, nhưng danh sách không phải là cấu trúc dữ liệu duy nhất có thể gấp được. Ta có thể gấp qua hầu hết bất kì cấu trúc dữ liệu nào. Cây dữ liệu thì đặc biệt thích hợp với phép gấp.

Vì có quá nhiều cấu trúc dữ liệu hoạt động tốt với phép gấp nên lớp Foldable đã được giới thiệu. Rất giống với Functor dành cho những thứ có thể ánh xạ được, Foldable thì dành cho những thứ có thể gấp được! Bạn có thể tìm thấy lớp này ở Data.Foldable và vì nó xuất khẩu các hàm có tên xung đột với những tên có sẵn ở Prelude, tốt nhất là ta nên nhập có lựa chọn (thêm rau thơm gia vị vào nhé):

import qualified Foldable as F

Để đỡ phải gõ phím, ta đã quyết định nhập nó có chọn lựa với tên gọi F. Được rồi, vậy đâu là một số hàm mà lớp này định nghĩa? À, trong số chúng có foldr, foldl, foldr1foldl1. Gì thế? Nhưng ta đã biết những hàm này rồi, có gì mới lạ nữa đâu? Ta hãy so sánh kiểu của các hàm foldr thuộc Foldable và hàm foldr thuộc Prelude để xem chúng khác nhau thế nào:

ghci> :t foldr
foldr :: (a -> b -> b) -> b -> [a] -> b
ghci> :t F.foldr
F.foldr :: (F.Foldable t) => (a -> b -> b) -> b -> t a -> b

A! Như vậy trong khi foldr nhận một danh sách rồi gấp nó lại, thì foldr thuộc Data.Foldable chấp nhận bất kể kiểu dữ liệu nào gấp được, không chỉ là danh sách! Như ta dự đoán, cả foldr đều hoạt động như nhau đối với danh sách:

ghci> foldr (*) 1 [1,2,3]
6
ghci> F.foldr (*) 1 [1,2,3]
6

Được rồi, thế còn những cấu trúc dữ liệu nào nữa dùng được với phép gấp? À, có cả Maybe mà tất cả chúng ta đều biết và yêu mến!

ghci> F.foldl (+) 2 (Just 9)
11
ghci> F.foldr (||) False (Just True)
True

Nhưng thực hiện gấp đối với giá trị Maybe không thật hay, vì khi gấp, nó chỉ hoạt động như là một danh sách với đúng một phần tử nếu nó là giá trị Just và như một danh sách rỗng nếu nó là Nothing. Vì vậy ta hãy xem xét một cấu trúc dữ liệu phức tạp hơn đôi chút.

Bạn còn nhớ cấu trúc dữ liệu cây từ Chương Tự lập nên các kiểu và lớp riêng chứ? Ta đã định nghĩa nó như sau:

data Tree a = Empty | Node a (Tree a) (Tree a) deriving (Show, Read, Eq)

Ta nói rằng một cây hoặc là rỗng nếu không chứa giá trị nào, hoặc là một nút nếu chứa một giá trị và chứa luôn cả hai cây khác. Sau khi định nghĩa nó, ta biến nó thành một thực thể của Functor và bằng việc này đã giành khả năng fmap hàm đối với cây. Bây giờ, ta sẽ biến nó thành một thực thể của Foldable để có thể gấp nó lại. Một cách khiến cho một constructor kiểu trở thành thực thể của Foldable là chỉ việc trực tiếp thực hiện foldr đối với nó. Nhưng còn một cách khác, thường là dễ hơn nhiều, là lập hàm foldMap, vốn cũng thuộc về lớp Foldable. Hàm foldMap có kiểu như sau:

foldMap :: (Monoid m, Foldable t) => (a -> m) -> t a -> m

Tham số thứ nhất của nó là một hàm nhận vào một giá trị kiểu mà cấu trúc dữ liệu có chứa giá trị (ở đây kí hiệu bằng a) và rồi trả lại một giá trị monoid. Tham số thứ hai là một cấu trúc dữ liệu chứa các giá trị của a. Nó ánh xạ hàm này lên cấu trúc dữ liệu, từ đó tạo ra một cấu trúc gấp được có chứa các giá trị monoid. Sau đó, bằng cách mappend giữa những giá trị monoid này, nó nối tất cả chúng lại thành một giá trị monoid duy nhất. Bây giờ thì hàm này có vẻ kì quặc, nhưng sau này ta sẽ thấy rằng nó rất dễ lập. Còn hay ở chỗ là việc lập hàm này lầ tất cả những gì ta phải làm để kiểu dữ liệu có trong tay trở thành một thực thể của Foldable. Vì vậy nếu ta chỉ lập foldMap cho một kiểu nào đó, thì ta tự nhiên sẽ thu được foldrfoldl đối với kiểu đó!

Đây là cách mà ta biến Tree thành thực thể của Foldable:

instance F.Foldable Tree where
    foldMap f Empty = mempty
    foldMap f (Node x l r) = F.foldMap f l `mappend`
                             f x           `mappend`
                             F.foldMap f r

find the visual pun or whatever

Ta hình dung như sau: nếu được cấp cho một hàm nhận vào một phần tử của cây đang xét và trả lại một giá trị monoid, thì ta sẽ làm thế nào để rút gọn cả cây về một giá trị monoid duy nhất? Khi đang fmap lên cây, cũng có nghĩa là ta áp dụng hàm lên một điểm nút rồi bằng cách đệ quy, ánh xạ hàm lên cây con bến trái cũng như cây con bên phải. Ở đây, ta có nhiệm vụ không chỉ ánh xạ một hàm, mà còn phải nối các kết quả lại thành một giá trị monoid bằng cách dùng mappend. Đầu tiên, ta xét trường hợp cây rỗng — một cây chỏng trơ không có giá trị lẫn cây con nào. Nó không chưa giá trị nào mà ta có thể cấp cho hàm tạo monoid hiện có được, vì vậy ta chỉ nói rằng nếu có cây rỗng, thì giá trị monoid mà nó trở thành sẽ là mempty.

Trường hợp cây không rỗng thì hay hơn một chút. Nó chứa hai cây con và cả một giá trị nữa. Trong trường hợp này, bằng cách đệ quy ta foldMap cùng hàm f lên các cây con bên trái và bên phải. Hãy nhớ rằng, foldMap đang xét sẽ cho kết quả là một giá trị monoid. Ta cũng áp dụng hàm f cho giá trị ở điểm nút. Bây giờ ta có ba giá trị monoid (hai giá trị ở cây con và một sau khi áp dụng f lên giá trị điểm nút) và ta chỉ việc dồn chúng lại vào một giá trị duy nhất. Để làm điều này ta dùng mappend, và tự nhiên là cây con bên trái sẽ ra trước tiên, sau đó đến điểm nút và tiếp theo là cây con bên phải.

Lưu ý rằng ta không phải cung cấp hàm nhận vào gái trị và trả lại một giá trị monoid. Ta nhận hàm đó như một tham số cho foldMap và tất cả việc mà ta cần quyết định là áp dụng hàm này ở đây và kết nối các monoid thu được như thế nào.

Bây giờ khi đã có một thực thể Foldable cho kiểu dữ liệu cây, ta tự nhiên lại thu được cả foldrfoldl! Hãy xét cây sau đây:

testTree = Node 5
            (Node 3
                (Node 1 Empty Empty)
                (Node 6 Empty Empty)
            )
            (Node 9
                (Node 8 Empty Empty)
                (Node 10 Empty Empty)
            )

Nó có giá trị 5 tại gốc và ở nút trái là 3 với 1 phía trái và 6 phía phải. Nút phải của gốc có giá trị 9 à tiếp theo là 8 ở phía trái và 10 ở ngoài cùng bên phải. Với một thực thể Foldable, ta có thể thực hiện tất cả những phép gấp từng làm được với danh sách:

ghci> F.foldl (+) 0 testTree
42
ghci> F.foldl (*) 1 testTree
64800

Đồng thời, foldMap không chỉ có ích trong việc tạo những thực thể Foldable mới; mà còn tiện dụng để rút gọn cấu trúc dữ liệu hiện có về một giá trị monoid. Chẳng hạn, nếu muốn biết liệu có số nào trong cây bằng 3 hay không, thì ta có thể làm như sau:

ghci> getAny $ F.foldMap (\x -> Any $ x == 3) testTree
True

Ở đây, \x -> Any $ x == 3 là hàm nhận vào một số rồi trả lại một giá trị monoid, cụ thể là một Bool được gói trong Any. foldMap áp dụng hàm này cho từng phần tử trong cây rồi rút gọn các monoid thu được về một monoid duy nhất bằng mappend. Nếu ta làm như sau:

ghci> getAny $ F.foldMap (\x -> Any $ x > 15) testTree
False

thì tất cả các nút trên cây sẽ chứa giá trị Any False sau khi để hàm trong lambda áp dụng lên. Nhưng để có kết quả True, mappend với Any phải có ít nhất là một giá trị True làm tham số. Điều này lý giải tại sao kết quả cuối cùng là False, và có lý, vì không có giá trị nào trên cây lớn hơn 15.

Ta cũng dễ dàng biến cây này thành danh sách bằng cách lập một hàm foldMap với hàm \x -> [x]. Bằng việc trước hết là phóng hàm này lên cây hiện có, mỗi phần tử sẽ trở nên một danh sách đơn phần tử. Thao tác mappend giữa các danh sách đơn này sẽ cho kết quả một danh sách duy nhất chứa tất cả những phần tử có trong cây:

ghci> F.foldMap (\x -> [x]) testTree
[1,3,6,5,8,9,10]

Điều hay là tất cả những mẹo trên đây không chỉ giới hạn đối với cây, mà còn có tác dụng với bất kì thực thể Foldable nào.

Advertisements

2 phản hồi

Filed under Haskell

2 responses to “Chương 11: Functor, Functor áp dụng và Monoid

  1. Pingback: Tự học lấy Haskell | Blog của Chiến

  2. Pingback: Một số vấn đề về Monad | Blog của Chiến

Trả lời

Mời bạn điền thông tin vào ô dưới đây hoặc kích vào một biểu tượng để đăng nhập:

WordPress.com Logo

Bạn đang bình luận bằng tài khoản WordPress.com Đăng xuất / Thay đổi )

Twitter picture

Bạn đang bình luận bằng tài khoản Twitter Đăng xuất / Thay đổi )

Facebook photo

Bạn đang bình luận bằng tài khoản Facebook Đăng xuất / Thay đổi )

Google+ photo

Bạn đang bình luận bằng tài khoản Google+ Đăng xuất / Thay đổi )

Connecting to %s