Haskell #11: Functor

Sự kết hợp của chủ nghĩa thuần túy, hàm bậc cao, kiểu và dạng đã cho phép chúng ta giải quyết bài toán theo cách rất trừu tượng, chúng 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.

Vì Haskell có hệ thống kiểu rất tốt nên chúng ta, người sử dụng có thể biết được nhiều thông tin vè một hàm chỉ qua việc khai báo kiểu (type signature) của hàm đó. Chúng ta đã gặp những typeclass định nghĩa phép so sánh rất trừu tượng và “đẹp”, nhưng bạn đừng nghĩ rằng chúng quá đặc biệt, vì đó vẫn là vấn đề hết sức cơ bản trong Haskell. Gần đây ta gặp Functor, là một thuộc tính trừu tượng hữu dụng và khá đẹp mà typeclass có thể mô tả. Ở phần 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à Applicative và Monoid.

Functor IO

Tôi xin được nhắc lại: Functor là kiểu mà chúng ta có thể định nghĩa hành vi ánh xạ lên nó (hàm fmap) như: List, Maybe, Tree, … Hàm fmap có kiểu :: (a -> b) -> f a -> f b. Nếu diễn đạt bằng lời: “Hãy cho tôi một hàm nhận a trả b, một cái hộp chứa a và rồi tôi sẽ trả lại một cái hộp chứa b”. Đạ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.

Nhiều khi cái hộp giúp cho ta hình tượng hóa tác dụng của Functor dễ hơn, và sau này, ta sẽ có thể dùng cách nói tương tự như thế đối với Applicative và Monad. Tuy nhiên khái niệm “hộp” phải được trừu tượng hóa, đó chính là ngữ cảnh (context).

Khái niệm ngữ cảnh có thể khiến bạn khó nhận biết. Đầu tiên, ta phải xác định rằng ngữ cảnh sinh ra từ typeclass, những typeclass khác nhau thì có thể sinh ra những ngữ cảnh khác nhau. Ví dụ: Maybe sinh ra ngữ cảnh rằng Nothing sẽ không biến đổi trong tương lai. Thứ hai, ngữ cảnh là một đối tượng trừu tượng, chúng ta không thể lấy chúng ra để tác động, truyền vào, … như giá trị, hàm, …

Nếu chúng ta muốn tạo một Functor (chính xác hơn là typeclass kế thừa Functor) thì nó phải có type constructor dạng * -> *, 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, vì nó có thể nhận một kiểu để tạo kiểu cụ thể). Nếu typeclass có type constructor nhận hai tham số (như Either) thì ta có thể sử dụng kĩ thuật áp dụng từng phần. Vì vậy, instance Functor Either where sẽ không đúng, nhưng instance Functor (Either a) where thì chính xác. Khi ta định nghĩa fmap trên Either, nó sẽ có kiểu fmap :: (b -> c) -> Either a b -> Either a c.

Đến giờ ta đã biết rất nhiều fmap trên List, Maybe, Either a, …Trong mục này, ta sẽ xem hai Functor khác, IO và $(\rightarrow)$r.

Nếu giá trị nào đó có kiểu là IO String chẳng hạn thì nó sẽ nhảy ra môi trường bên ngoài, lấy cho ta một chuỗi nào đó. Ta có thể dùng <- trong khối do để gắn kết quả đó vào một giá trị. 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ể xem chúng đã lấy được gì, nhưng sau khi xem ta 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.

Hãy xem xét đến fmap khi IO kế thừa Functor:

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 thao tác I/O sẽ là một thao tác I/O, vì vậy phải dùng từ khóa do để dính hai thao tác với nhau thành một thao tác mới. Dòng đầu tiên là tạo một thao tác I/O mới để là thực hiện thao tác I/O gốc và rồi gán kết quả vào result. Tiếp theo, lệnh return (f result) sẽ tạo ra một thao tác I/O không làm gì cả mà chỉ hiển thị kết quả. 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. Đ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ì.

Xét xét đoạn code sau:

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

Khi ta muốn reverse chuỗi nhập vào ngay lúc nhập, sử dụng fmap:

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

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. Khi chúng ta ánh xạ hàm reverse lên getLine (kiểu IO String), chương trình sẽ trả về một thao tác I/O, thao tác I/O này có chức năng đi ra môi trường bên ngoài để lấy một dòng chữ và reverse kết quả. Giống như ta có thể áp dụng một hàm lên giá trị bên trong Maybe, ta cũng có thể áp dụng hàm lên thứ bên trong IO, chỉ khác là nó phải đi ra môi trường ngoài để lấy.

Ví dụ khác, fmap (++ "!") getLine sẽ gán dấu “!” ở đuôi kết quả.

Khi xem xét fmap trong IO, 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ỗ kết quả được chứa bên trong đã được thay đổi.

Nếu muốn áp dụng nhiều hàm trên một giá trị kiểuFunctor thì chúng ta tạo lambda, hoặc lý tưởng nhất là 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

intersperse ‘-‘ . reverse . map toUpper là hàm nhận vào String, ánh xạ toUpper, áp dụng reverse rồi áp dụng intersperse ‘-‘ lên kết quả mới tìm được. Cách viết này giống như (\xs -> intersperse '-' (reverse (map toUpper xs))), chỉ khác là đẹp hơn.

$(\rightarrow)r$

Một trường hợp đặc biệt của Functor là Functor có dạng $(\rightarrow) r$. $(\rightarrow) r$ đơn giản chỉ là cách viết ngắn gọn, như $r\rightarrow a$ có thể được viết lại thành $(\rightarrow) r \;a$, rất giống với định nghĩa 2 + 3 chính là (+) 2 3. Khi coi kiểu đặc biệt trên là $(\rightarrow) r \;a$, có thể suy ra rằng $(\rightarrow)$ chính là một type constructor có hai tham số kiểu (đồng dạng với Either). Nhưng Functor thì chỉ nhận một tham số kiểu, nên $(\rightarrow)$ phải áp dụng từng phần thành $(\rightarrow) r$. Tiếp theo hãy xét đến fmap của $(\rightarrow)$ trong module Control.Monad.Instances.

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

Trước hết, hãy nghĩ về fmap:

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

Sau đó thay toàn bộ f bằng $(\rightarrow) r$.

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

Viết lại theo kiểu trung tố, ta thu được:

fmap :: (a -> b) -> (r -> a) -> (r -> b).

Nếu phát biểu bằng lờ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, 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, hoàn toàn giống với hàm hợp.

Như vậy:

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

Như vậy, việc áp dụng fmap lên các giá trị kiểu $(\rightarrow)r$ tương đương với việc dùng hàm hợp

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"

Lưu ý rằng vẫn tồn tại ngữ cảnh ở đây, khi ta dùng fmap (+3) lên Just 3, có thể dễ dàng tưởng tượng Maybe là một cái hộp chứa gì đó mà ta sẽ áp dụng hàm (+3). Nhưng fmap (*3) (+100), (+100) vẫn là cái hộp, nhưng nó có khả năng 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) (+100) ta sẽ tạo ra một hàm khác tương đương với (+100), nhưng trước khi đưa ra kết quả, kết quả sẽ bị phủ thêm một lớp (*3). Như vậy, fmap đóng vai trò như toán tử . với các hàm.

Trước khi ta tiếp tục tìm hiểu những định luật 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.

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

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 c. 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.

Tương tự, nếu chúng ta viết lại fmap:

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

Ta có thể suy luận rằng fmap là hàm nhận 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à Functor rồi trả lại kết quả là Functor. Ý tưởng này được gọi là Lifting.

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à List, Maybe hoặc Either String, … Biểu thức fmap (replicate 3) sẽ nhận một Functor kiểu bất kì và trả lại một Functor.

Khi nói đến Functor trên số, bạn có thể hình dung như là một Functor 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, nhưng cách nói thứ hai thì dễ hiểu hơn.

Hay một ý tưởng khác, fmap là một hàm nhận hàm $f$ và một Functor $F$, ánh xạ $f$ lên $F$ để thu về $F’$, hoặc cũng có thể hình dung nó là một hàm nhận hàm khác rồi lift hàm đó để nó hoạt động được với các Functor. Cả hai quan điểm này đều đúng.

Kiểu fmap (replicate 3) :: (Functor f) => f a -> f [a] sẽ áp dụng tốt với bất kì Functor nào. Nếu ta dùng fmap (replicate 3) với List, thì fmap tương đương với 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 những định luật trong Functor. Không phải Typeclass nào cũng có thể kế thừa Functor, chúng phải là những thứ có thể ánh xạ lên được. Việc gọi fmap lên Functor sẽ chỉ ánh xạ một hàm lên Functor, không hơn không kém. Có hai luật mà tất cả Functor đều phải tuân theo, các luật này không bị ràng buộc nên chúng ta phải tự kiểm soát.

Định luật 1: ánh xạ hàm id lên Functor phải trả về Functor ban đầu (hay fmap id = id). Điều này có nghĩa là Functor phải là thứ gì đó có thể được ánh xạ lên. Ví dụ:

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

Với fmap trong Maybe, ta có thể dễ dàng kiểm chứng định luật 1:

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

id đóng vai trò là f, dễ thấy fmap id Just x trả về 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) chính là Just x. Còn ánh xạ id lên Nothing đương nhiên sẽ trả về Nothing. Như vậy từ hai pattern trong định nghĩa fmap, định luật 1 được thỏa mãn.

Định luật 2: hàm hợp khi ánh xạ lên một Functor phải tương đương với việc ánh xạ từng hàm theo đúng thứ tự. Ngắn gọn, fmap (f . g) = fmap f . fmap g hoặc (F là functor bất kì) fmap (f . g) F = fmap f (fmap g F).

Tiếp tục với Maybe:

fmap (f . g) Nothing trả về Nothing

Đối với:

Just x` (`fmap (f . g) (Just x)`)

trả về:

Just ((f . g) x) (hay Just (f (g x)) nếu viết theo kiểu toán)

Còn fmap f (fmap g (Just x)) sẽ phức tạp hơn một chút:

fmap g (Just x)

trả về:

Just (g x)

Suy ra fmap f (fmap g (Just x)) tương đương fmap f (Just (g x)) và trả về Just (f (g x)).

Hãy xem xét một ví dụ về typeclass có thể kế thừa 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ả sử ta có typeclass:

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

Ở đây, CMaybe viết tắt của counter Maybe, đó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 luôn có kiểu Int (biến đếm) còn trường thứ hai có kiểu phụ thuộc vào kiểu cụ thể mà ta chọn cho CMaybe a.

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]

Tại fmap của CMaybe, ta định nghĩa rằng mỗi khi dùng đến fmap, hàm sẽ được áp dụng cho trường thứ hai và trường thứ nhất được tăng thêm 1.

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

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

Dễ thấy rằng CMaybe không tuân theo định luật 1:

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

Lẫn định luật 2:

ghci> fmap ((++"he") . (++"ha")) (CJust 0 "ho")
CJust 1 "hohahe"
ghci> fmap (++"he") (fmap (++"ha") (CJust 0 "ho"))
CJust 2 "hohahe"

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 chúng ta có thể đặt giả thiết vững chắc về các hành vi của nó. Điều đó dẫn đến chương trình trừu tượng và dễ mở rộng hơn.

Trong quá trình áp dụng, hãy dành một phút để dảm bảo chắc rằng Functor vừa tạo ra tuân theo các định luật, bạn có thể đọc kỹ hoặc đơn giản hơn là tìm ra một phản ví dụ.

Kết thúc bài 12

Haskell #13: Dạng