Haskell #13: Dạng

Chương 14. Applicative

14.1. Dẫn nhập

Ở chương này, chúng ta sẽ làm quen với khái niệm trừu tượng có tên Applicative, về bản chất chúng cũng chính là Functor nhưng có thêm một số định luật và hàm.

Như đã biết, trong Haskell chúng ta có khái niệm curry. Một hàm $f$ có nhiều tham số, giả sử $a$ và $b$ thì chỉ nhận một tham số $a$ rồi trả về $f’$, $f’$ nhận $b$ làm tham số và trả về giá trị $c$ (bản chất cũng là hàm nhưng không còn nhận tham số). Điều này lý giải tại sao ta không dùng dấu ngoặc trong lời gọi hàm bởi vì f x y chính là (f x) y. Cơ chế này cho phép ta áp dụng từng phần để tạo ra các hàm có thể truyền vào hàm khác. Nếu chúng ta sử dụng ngoặc thì sẽ vô tình làm giảm sức mạnh của hàm.

Với trường hợp phức tạp hơn, khi ánh xạ $f$ lên $g$, ví dụ: fmap (*) (Just 3) với f :: (*) sẽ cho kết quả là Just ((*) 3) hay Just (* 3), $f$ được gói trong ngữ cảnh Just!

Ví dụ 14.1:

ghci> :t fmap (++) (Just "abc")
fmap (++) (Just "abc") :: Maybe ([Char] -> [Char])

Như vậy, ánh xạ hàm $f$ (nhiều tham số) lên Functor $F$ sẽ thu được Functor $G$ chứa $f$. Khả năng này giúp chúng ta có thể ánh xạ hàm $g$ (có số lượng tham số nhỏ hơn $f$) lên $G$.

Ví dụ 14.2:

ghci> let xs = fmap (*) [1,2,3]
ghci> :t xs
xs :: [Integer -> Integer]
ghci> fmap (\f -> f 9) xs
[2,4,6]

Một trường hợp khác, ta có Functor $F$ Just (* 3) và Functor $G$ Just 5, giả sử chúng ta muốn ánh xạ (* 3) lên 5 nhưng với Functor thông thường, thao tác này không thể thực thị được vì Functor chỉ định nghĩa fmap là cách ánh xạ hàm lên Functor. Ngay cả khi ánh xạ hàm \f -> f 9 lên Functor có chứa hàm thì ta vẫn chỉ ánh xạ một hàm bình thường. Ta không thể ánh xạ hàm nằm trong Functor lên một Functor khác (có thể sử dụng pattern matching, nhưng phương pháp tổng quát và trừu tượng vẫn là lựa chọn lâu dài).

14.2. Định nghĩa Applicative

Nằm trong module Control.Applicative, Applicative là một khái niệm trừu tượng mà bất cứ thực thể nào kế thừa từ nó phải định nghĩa hai hàm pure<*>:

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

Trước hết, một Applicative cũng chính là một Functor, ta có thể dùng fmap lên Applicative.

Hình 14.2.1. Hàm pure nhận vào một Functor và một giá trị
Hình 14.2.1. Hàm pure nhận vào một Functor và một giá trị

Hàm thứ nhất có ý nghĩa theo đúng tên của nó, pure nhận giá trị kiểu bất kì rồi trả về Functor chứa giá trị bên trong mà không thay đổi giá trị đó. Một cách hay hơn khi nghĩ về pure là nó nhận một giá trị rồi đưa vào một ngữ cảnh nào đó - một ngữ cảnh tối thiểu mà vẫn cho ra giá trị đó.

Hàm <*> có kiểu gần tương đương với fmap :: (a -> b) -> f a -> f b, tuy nhiên là một phiên bản rắc rối hơn. Nếu fmap nhận một hàm $f$ và một Functor $F$ và trả về Functor $G$, thì <*> nhận một Functor $F’$ chứa hàm $f$ và một Functor $G$, trích xuất $f$ ra $F’$ sau đó ánh xạ lên giá trị trong $G$.

Với Maybe:

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

Trước hết, pure, phương thức này đựng một giá trị nào đó trong Applicative. pure = Just hay viết đầy đủ là (pure x = Just x) có ý nghĩa là ta đóng gói x trong Just, tạo thành Just x.

Tiếp theo, phương thức <*>. Ta không thể lấy một hàm ra từ Nothing, vì nó không chứa hàm nào. Vì vậy kết quả sẽ là Nothing. Để ý rằng tham số kiểu của Applicative chính là Functor (hai tham số của <*> đều là Functor, f (a -> b)f a. Nếu tham số thứ nhất là Just f (f là hàm nào đó), thì f sẽ ánh xạ lên tham số thứ hai (lưu ý rằng tham số thứ hai cũng là functor nên ta phải dùng fmap).

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.

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

Dễ thấy rằng pure (+3)Just (+3) giống nhau, tuy nhiên hãy tránh sử dụng pure nếu không làm việc trên Applicative. 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 Functor thông thường, bạn có thể ánh xạ hàm lên Functor nhưng không thể lấy giá trị ra bằng bất kỳ cách thông thường nào. Applicative thì khác, nó cho phép thao tác trên nhiều Functor chỉ bằng một hàm duy nhất.

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

<*> có tính kết hợp trái, nghĩa là pure (+) <*> Just 3 <*> Just 5 tương đương (pure (+) <*> Just 3) <*> Just 5. Đầu tiên, hàm + được đặt trong một Functor, trong trường hợp này là Maybe (pure (+) vốn là Just (+)). Tiếp theo, Just (+) <*> Just 3 sẽ trả về Just ((+) 3) hay (Just (3 +)), là một hàm được gói trong Functor. Sau cùng, Just (3 +) <*> Just 5 được thực hiện, và cho kết quả là Just 8. Cách viết pure f <*> x <*> y <*> ... cho phép ta lấy một hàm vốn được bọc trong Functor rồi dùng hàm đó để thao tác trên nhiều giá trị cũng được bọc trong Functor. Điều này 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 tương đương với fmap f x (đây là một trong những định luật của Applicative).

Như đã nói từ trước, pure đặt một giá trị vào trong một ngữ cảnh mặc định. Nếu ta chỉ đặt hàm $f$ vào trong một ngữ cảnh mặc định, sau đó lấy ra và ánh xạ lên một giá trị bên trong Applicative $A$ thì cũng tương đương với việc ánh xạ $f$ lên $A$. Thay vì pure f <*> x <*> y <*> ..., cách viết sau hoàn toàn tương đương fmap f x <*> y <*> ... Đó là lý do Control.Applicative có một hàm được kí hiệu là <$>, vốn chỉ là fmap dưới dạng trung tố. Sau đây là cách định nghĩa nó:

(<$>) :: (Functor f) => (a -> b) -> f a -> f b
f <$> x = fmap f x

Lưu ý: f trong type signature là một biến kiểu Functor, còn f trong phần thân hàm kí hiệu cho hàm mà ta ánh xạ lên x. Việc ta dùng f để biểu diễn cho cả hai thứ 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 <$>, chúng ta đã có một công cụ mới trong tay vì bây giờ nếu ta muốn ánh xạ hàm f lên 3 Applicative, thì đơn giản là f <$> x <*> y <*> z. Nếu các tham số là giá trị bình thường, biểu thức trên có thể đơn giản hóa là f x y z.

Ví dụ:

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

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

Để ánh xạ $f$ lên Applicative, ta sử dụng <$><*> lên $f$ để nhận về một Applicative khác.

Dù sao, khi viết (++) <$> Just "johntra" <*> Just "volta", thì trước hết (++), vốn có kiểu (++) :: [a] -> [a] -> [a] sẽ ánh xạ lên Just "johntra" và trả về Just ("johntra"++) (kiểu Maybe ([Char] -> [Char])). Khi Just (++ "johntra") <*> Just "volta" xảy ra, nó lấy hàm ++ "johntra" 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ị trong Just là Nothing thì kết quả sẽ là Nothing.

Applicative List

List cũng chính là Applicative.

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

Hãy nhớ rằng pure nhận một giá trị và đặt nó vào trong một ngữ cảnh mặc định (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 List là [], nhưng [] không tự giữ giá trị mà ta đã dùng pure lên đó như Just ở ví dụ trước.

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

Còn về <*>, nó sẽ có kiểu (<*>) :: [a -> b] -> [a] -> [b]. Ở đây <*> bằng cách nào đó đã lấy hàm khỏi tham số bên trái rồi ánh xạ lên tham số bên phải. Nhưng vấn đề là List bên trái ($fs$) có thể không chứa hàm nào, một hàm, hoặc nhiều hàm. List bên phải ($xs$) cũng có thể chứa ít nhiều giá trị. Đố là lý do ta sử dụng List comprehension để rút $f$ và $x$ từ từ ra khỏi $fx$ và $xs$. Kết quả sẽ là tổ hợp giữa việc áp dụng hàm $f$ lên giá trị $x$.

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

Nếu $fx$ là các hàm có hai tham số, ta có thể viết như sau:

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 và trả về [(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 nên [(1+),(2+),(1*),(2*)] <*> [3,4] tạo nên kết quả cuối cùng.

Chúng ta có thể coi List như những đại lượng bất định. Một giá trị như 100 hoặc “what” là một đại lượng tất định vì nó chỉ có một kết quả, còn List [1,2,3] không thể tự quyết định kết quả mong muốn mà biểu diễn mọi kết quả có thể. Chẳng hạn (+) <$> [1,2,3] <*> [4,5,6] là cộng hai đại lượng bất định bằng + để tạo ra một đại lượng bất định khác.

Việc dùng Applicative là cách làm thay thế tốt cho List comprehension. Ở Chương 2, khi muốn biết tất cả tích giữa [2,5,10][8,10,11]:

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 List 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 có vẻ rõ ràng hơn, vì 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 List chẳng hạn, chỉ cần:

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

Dễ thấy rằng pure f <*> xs tương đương với fmap f xspure f đơn giản là [f] còn [f] <*> xs sẽ ánh xạ từng hàm ở List bên trái cho từng giá trị thuộc List bên phải. Nhưng vì [f] chỉ là một hàm duy nhất nên sẽ dẫn đến phép tương đương trên.

Applicative IO

Một Applicative khác mà ta đã gặp là IO:

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

pure đặt một giá trị trong ngữ cảnh tối thiểu nên purereturn hoàn toàn hợp lý vì return tạo ra một thao tác I/O không làm việc gì cả (đọc hoặc xuất) mà chỉ cho kết quả là một giá trị nào đó.

<*>có kiểu là (<*>) :: IO (a -> b) -> IO a -> IO b. Nó sẽ nhận một thao tác I/O 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 . Khi thực hiện, thao tác I/O đầu tiên trả về hàm, thao tác thứ hai trả về giá trị, sau đó trả lại kết quả là giá trị sau khi được ánh xạ. Lưu ý nhỏ là từ khóa 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

Với Maybe[], ta có thể hình dung <*> đơn giản là lấy ra 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, ta có khái niệm sequence (hay xâu chuỗi), hai thao tác I/O được xâu vào nhau làm một. Ta phải lấy hàm ra từ thao tác I/O thứ nhất, nhưng để lấy kết quả từ thao tác I/O thì chính bản thân nó phải được thực thi trước.

Xét đoạn sau:

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

Đây là thao tác I/O yêu cầu người dùng nhập vào hai dòng và cho ra kết quả là hai dòng được nối lại làm một. Nếu áp dụng kiến thức về Applicative.

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

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 Applicative thì kết quả cũng là một Applicative. 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 để lượm một chuỗi ký tự. Việc viết (++) <$> getLine <*> getLine sẽ tạo nên một hộp mới lớn hơn phân công hai hộp nhỏ ra ngoài lấy dữ liệu.

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 chứa một giá trị kết quả. Vậy nên ta có thể thực hiện thao tác sau:

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

$(\rightarrow) r$

Một Applicative đặc biệt khác là là $(\rightarrow) r$ (hay hàm).

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 Applicative bằng pure thì kết quả cho ra sẽ luôn là giá trị đó, đó là lý do pure nhận một giá trị rồi tạo ra một hàm luôn trả về giá trị đó (pure :: a -> (r -> a)).

ghci> (pure 3) "blah"
3

<*> có vẻ hơi khác biệt:

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

Việc gọi <*> với hai Applicative sẽ cho kết quả là một Applicative, vì vậy nếu Applicative là hàm thì ta sẽ thu về một hàm (tương tự như hàm hợp). (+) <$> (+ 3) <*> (* 100) sẽ trả về hàm (+ a) trong đó a là kết quả của (+3)(*100). Ví dụ (+) <$> (+ 3) <*> (* 100) $ 5, thì 5 được ánh xạ vào (+ 3)(* 100), cho ra 8 và 500. Sau đó, hàm + nhận hai tham số này và cho ra kết quả 508.

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

Bạn có thể hình dung hàm như những hộp chứa kết quả, k <$> f <*> g tạo ra hàm k nhận tham số là kết quả trả về từ fg. (+) <$> Just 3 <*> Just 5 nghĩa là ta đang gọi hàm + với tham số là những giá trị được trả về của Just 3Just 5 đồng thời kết quả sẽ được bọc trong ngữ cảnh đã được xác định trước đó.

$(\rightarrow) r$ là một thực thể trừu tượng nên hãy chúng ta cần nhiều ví dụ hơn nữa để hiểu rõ bản chất của chúng.

Applicative ZipList

ZipList là một cách khác để biến List trở thành Applicative. Như đã đề cập, <*> sẽ nhận List hàm và List giá trị và trả lại tổ hợp có thể của việc ánh xạ các hàm ở List bên trái lên các giá trị ở List bên phải. [(+3),(*2)] <*> [1,2] trả về List [4,5,2,4].

Tuy nhiên, [(+3),(*2)] <*> [1,2] cũng có thể hoạt động theo kiểu ánh xạ theo từng cặp, kết quả trả về chỉ là List [4,4] ([1 + 3, 2 * 2]).

Quay trở lại với ZipList:

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

<*> á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, …thông qua hàm (\f x -> f x) fs xs. Như vậy, độ dài List kết quả sẽ bằng với độ dài List ngắn hơn.

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

Ví dụ (ZipList a không thuộc kiểu dữ liệu Show nên phải dùng hàm getZipList lấy ra List nguyên thủy từ ZipList).

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')]

Lưu ý: (,,) tương đương \x y z -> (x,y,z), tương tự (,) cũng tương đương \x y -> (x,y).

Bên cạnh zipWith, chúng ta còn có zipWith3 đến zipWith7, tương ứng với số lượng List mà hàm nhận vào (lưu ý là tổng lượng tham số sẽ (+1) và hàm zip là tham số đầu tiên). Tuy nhiên, ZipList sẽ tổng quát hóa các hàm trên và sẽ không cần xác định hàm zip riêng cho những trường hợp có số lượng tham số khác nhau.

liftA2

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

liftA2 f a b = f <$> a <*> b

Không có gì đặc biệt, nó chỉ ánh xạ một hàm giữa hai Applicative. 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 Applicative lại mạnh hơn Functor thông thường. Với Functors, ta chỉ có thể ánh xạ hàm lên một Functor. Nhưng với Applicative, 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ể thấy liftA2 nhận vào hàm hai ngôi để ánh xạ lên hai Functor.

Khái niệm quan trọng: ta có thể lấy hai Applicative để kết hợp thành một Applicative chứa kết quả của hai Applicative trong một List. Chẳng hạn, Just 3Just [4]:

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

Để có được Just [3,4] (: một hàm nhận vào một phần tử và một List rồi trả về một List mới có phần tử đó đứng đầu):

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

hay tinh tế hơn:

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

Dường như ta có thể kết hợp một số lượng bất kỳ Applicative $A_1, A_2, A_3, …$ vào thành $A$, $A$ chứa List là tất cả kết quả của những Applicative ban đầu như sau:

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

Đầu tiên, hãy nhìn vào kiểu dữ liệu, sequenceA chuyển List Applicative thành một Applicative kiểu List. Từ đó, dễ thấy rằng bài toán này có cấu trúc như những bài toán đệ quy cơ bản. Đầu tiên là điều kiện biên, nếu ta có List rỗng thì chỉ việc đặt chúng vào trong ngữ cảnh mặc định là [], nếu ta có một List tối thiểu một phần tử (bao gồm phần tử đầu và đoạn cuối (x là Applicative) thì ta chỉ cần đệ quy lên phần đuôi xs.

sequenceA [Just 1, Just 2] tương đương (:) <$> Just 1 <*> sequenceA [Just 2], hay (:) <$> Just 1 <*> ((:) <$> Just 2 <*> sequenceA []). sequenceA []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 dùng fold.

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

Ta tiếp cận List từ phía phải và khởi đầu với giá trị tích lũy pure []. Sau đó thực hiện liftA2 (:) giữa biến tích lũy và phần tử cuối List, việc này cho kết quả là một Applicative 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.

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],[]]
[]

Có một chút khó hiểu khi sequenceA nhận List hàm $f_1, f_2, f_3, …$ rồi trả về hàm $f$ ($f$ trả về List). Đầu tiên:

sequenceA [(+1)] = (:) <$> (+1) <*> const []

Sử dụng lambda, ta có:

sequenceA [(+1)] = \b c -> ((:) b c) <$> (\a -> a + 1) <*> (\a -> const [] a)

<$><*> là toán tử trung tố nên ta sẽ thực hiện từ trái sang phải (nhắc lại (<$>) :: Functor f => (a -> b) -> f a -> f b), <$> sẽ lấy (+1) ra khỏi ngữ cảnh (\a -> a + 1) (hay \a->) kiểu $(\rightarrow)r$ và thay vào b:

sequenceA [(+1)] = \a c -> ((:) ((+1) a) c) (\a -> const [] a)

Lưu ý: const :: a -> b -> a là hàm luôn trả về tham số đầu tiên và bỏ qua tham số thứ hai

Với <*> (nhắc lại (<*> :: Applicative f => f (a -> b) -> f a -> f b), nó sẽ thực hiện hai bước, bước một lấy thứ đang được chứa bên trong ngữ cảnh \a ->, biến đổi được:

(\c -> ((:) ((+1) a) c)) (const [] a)
= ((:) ((+1) a)) (const [] a)

Bước hai, bọc nó lại trong ngữ cảnh \a ->:

sequenceA [(+1)] = \a -> (:) ((+1) a) (const [] a)
= sequenceA [(+1)] = \a -> (+1) a : const [] a

Như vậy sequenceA [(+1)] là hàm nhận vào tham số (a) và trả về List gồm hai phần tử (phần tử đầu tiên là kết quả của (+1) a, phần tử thứ hai là []).

sequenceA [(+1)] 2 = (+1) 2 : const [] 2
sequenceA [(+1)] 2 = 3 : [] = [3]

(+) <$> (+3) <*> (*2) sẽ tạo ra một hàm nhận vào một tham số đưa vào cho (+3)(*2), sau đó + trên hai kết quả trả về. Tương tự, sequenceA [(+3),(*2)] tạo ra một hàm nhận một tham số và gọi hàm : giữa hai kết quả trả về và tạo thành List mới.

sequenceA thuận tiện chúng ta có List 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 List các kết quả. Chẳng hạn, ta muốn biết số a có thỏa mãn tất cả những vị từ trong một List hay không.

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

Lưu ý: and nhận List các giá trị Boolean rồi trả về True nếu chúng đều là True.

Khi dùng sequenceA:

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

Tuy nhiên còn một vấn đề sau, vì List có tính đồng nhất nên các hàm trong List phải có cùng kiểu. Ví dụ, [*, (+3)] không hợp lệ vì số lượng tham số khác nhau.

Khi tham số của sequenceA là List các List, chúng vẫn là các List các hàm :, do đó:

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]]
[[1,3],[1,4],[2,3],[2,4]]

Với [[1,2],[3,4]]:

<$> [1,2] <*> sequenceA [[3,4]]
= (:) <$> [1,2] <*> ((:) <$> [3,4] <*> sequenceA [])
= (:) <$> [1,2] <*> ((:) <$> [3,4] <*> [[]])

Đối với (:) <$> [3,4] <*> [[]]:

(:) <$> [3,4] <*> [[]]
= [3:[], 4:[]]

Biểu thức trở thành:

(:) <$> [1,2] <*> [[3],[4]]

Tương tự:

(:) <$> [1,2] <*> [[3],[4]]
= [1:[3],1:[4], 2:[3], 2:[4]]
= [[1,3],[1,4],[2,3],[2,4]]

Khi dùng với thao tác I/O, sequenceA cũng giống như sequence! Nó nhận vào một List 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 trả về List những kết quả mà các thao tác I/O thực hiện.

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

Như Functor, Applicative cũng có một số định luật. Quan trọng nhất là pure f <*> x = fmap f x. Các định luật khác gồm có:

  1. pure id <*> v = v
  2. pure (.) <*> u <*> v <*> w = u <*> (v <*> w) (Tính kết trái)
  3. pure f <*> pure x = pure (f x)
  4. 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.

Tóm lại, Applicative cho phép ta kết hợp các đại lượng khác nhau chỉ bằng cách dùng <$><*>, ta có thể dùng hàm để thao tác lên loạt Applicative đồng thời tận dụng ưu điểm Functor.

Kết thúc bài 14

Haskell #15: New type