Bài viết này được lược dịch lại từ bản tiếng Anh: Functors, Applicatives, And Monads In Pictures, giải thích khá dễ hiểu các khái niệm căn bản trong lập trình hàm(với ví dụ minh hoạ của ngôn ngữ Haskell)

Để bắt đầu trước tiên ta làm quen với khái niệm value và context

Value and Context

Trước hết, ta có một giá trị kiểu số, chẳng hạn như:

a value of 2

Khi áp dụng một function, chẳng hạn function cộng vào số trên ta được kết quả là một giá trị số khác.

(+3) 2

Kí hiệu (+3) là kí hiệu cho hàm f(x) = x + 3.

Do đó (+3) 2 tương đương với f(2) = 2 + 3 = 5

Ta mở rộng khái niệm giá trị trên, cho nó vào một ngữ cảnh(context), có thể tưởng tượng như lấy giá trị đó bỏ vào một cái hộp

value and context

Khi áp dụng một function vào trong chiếc hộp này, ta nhận được kết quả khác nhau, tùy theo chiếc hộp và giá trị chứa bên trong nó.

Trước khi tiếp tục, ta làm quen với kiểu dữ liệu Maybe. Định nghĩa của Maybe trong Haskell như sau:

data Maybe a = Nothing | Just a

a ở đây là một kiểu dữ liệu bất kì(Int, Float, Function,…). Chẳng hạn:

Maybe Int, Maybe Float, Maybe String

Maybe Int sẽ có các giá trị: Nothing, Just -1, Just 0,Just 1, Just 100

Maybe Float sẽ có các giá trị: Nothing, Just 1.1, Just 100.25, …

Maybe

Nothing gần giống như khái niệm null trong các ngôn ngữ lập trình khác.

Sau đây ta sẽ làm quen với khái niệm Functor

Functor

Khi một giá trị được đặt trong một context(chẳng hạng Just 2), ta không thể áp dụng hàm (+3) như trên được.

> (Just 2) + 3
ERROR!!!

(+3) Just 2

Vì thế, xuất hiện hàm fmap giúp ta làm được việc không thể trên. fmap sẽ lấy giá trị trong chiếc hộp đó ra và áp dụng function (+3), lấy kết quả bỏ vào hộp trở lại.

fmap (+3) (Just 2)

Thế nhưng, làm cách nào fmap biết cách áp hàm (+3) vào giá trị Just 2?

Functor là một typeclass. Đây là định nghĩa của Functor

Để implement một kiểu dữ liệu f có typeclass là Functor ta định nghĩa một hàm fmap trên kiểu dữ liệu đó, thõa mãn:

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

Có thể hiểu khái niệm typeclass giống như khái niệm abstract class/interface trong Java. Một kiểu dữ liệu có thể implement nhiều typeclass khác nhau.

Vì thế, một kiểu dữ liệu được gọi là Functor nếu kiểu dữ liệu đó định nghĩa một hàm fmap

fmap explanation

Maybe là một functor, do đó ta có thể gọi fmap cho Maybe như sau:

> fmap (+3) (Just 2)
Just 5

Maybe định nghĩa fmap như sau:

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

fmap Maybe

Trước khi tiếp tục, thử trả lời câu hỏi sau:

> fmap (+3) Nothing
???

fmap Nothing

> fmap (+3) Nothing
Nothing

Áp một hàm vào Nothing sẽ nhận được Nothing, không còn gì hợp lí hơn!

Nothing goes in, nothing goes out

Việc này giúp ta đỡ phải xét giá trị null, chẳng hạn như câu if sau trong Ruby:

post = Post.find_by(id: 1)
if post
  return post.title
else
  return nil
end

chỉ cần 1 dòng trong Haskell:

fmap getPostTitle (findPost 1)

<$> là phiên bản trung tố của fmap, ta có thể viết lại ví dụ trên như sau:

getPostTitle <$> (findPost 1)

Trong Haskell, tên một function có thể được tạo thành từ các kí tự bất kì. Ví dụ ở trên ta đã biết + là một function nhận 2 tham số

  (+) 2 3 == 5

Hỏi thêm 1 câu hỏi nhỏ trước khi tiếp tục: khi áp dụng một function vào trong một List thông qua fmap, ta sẽ được gì?

> fmap (+3) [1,2,3,4]
???

fmap over a list

List cũng là một Functor:

instance Functor [] where
  fmap = map

Như vậy kết quả fmap của một function và một List chính là hàm map của function trên List đó.

> fmap (+3) [1,2,3,4]
[4, 5, 6, 7]

Ví dụ cuối cùng: kết quả của fmap trên một function là gì?

> fmap (+3) (+2)
???

Trả lời: kết quả là một function khác.

> let f = fmap (+3) (+2)
> f 10
15

fmap over two functions

Đây là định nghĩa fmap trên một function:

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

Trong Haskell, kí hiệu . có nghĩa là hàm hợp của hai function(lấy output của function này làm input của function kia)

Như vậy, fmap của 2 function chẳng qua là phép hợp của 2 function đó.

Applicative

Trở lại với value và context:

value and context

Function của ta cũng được wrap trong một context:

function in context

Như thế làm sao có thể áp dụng function vào value?(rõ ràng fmap không phát huy được tác dụng trong trường hợp này).

Tương tự như Functor, ta có typeclass Applicative để giải quyết vấn đề trên. Applicative định nghĩa hàm <*> nhận vào tham số là một context function và một context value.

Ví dụ cho Just 2Just (+3):

applicative maybe

Hay:

> Just (+3) <*> (Just 2)
Just 5

Câu hỏi: kết quả của biểu thức sau là gì?

> [(*2) (*3)] <*> [1, 2, 3]
???

Applicative and list

Do đó:

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

Với việc kết hợp giữa Functor và Applicative, ta có thể áp dụng một function có 2 tham số vào 2 giá trị, chẳng hạn như ta có (+), Just 3, Just 5, làm sao để được Just 8?

Chú ý trong Haskell, 2 cách viết sau là tương đương:

2 + 3
(+) 2 3
> (+) <$> (Just 5)
Just (+5)
> Just (+5) <*> (Just 3)
Just 8

Haskell cung cấp hàm liftA2 để thực hiện công việc trên:

> liftA2 (+) (Just 3) (Just 5)
Just 8

Monad

Hiện giờ, ta có 2 typeclass:

Functor: áp dụng cho function và context value

Applicative: áp dụng cho context function và context value

Monad áp dụng cho một function có kết quả trả về là một context value và một context value khác.

Ví dụ ta có hàm half được định nghĩa như sau:

half x = if even x
           then Just (x `div` 2)
         else Nothing

half explanation

Thế nếu áp dụng một context value cho hàm half thì sao?

> half (Just 10)
ERROR!!!!

half (Just 10)

typeclass Monad định nghĩa hàm >>= thực hiện yêu cầu trên

> Just 3 >>= half
Nothing
> Just 10 >>= half
Just 5
> Nothing >>= half
Nothing

Signature của >>= như sau:

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

>>= signature

Maybe định nghĩa >>= như sau:

instance Monad Maybe where
  Nothing >>= f = Nothing
  Just a >>= f = f a

Ta có thể kết hợp liên tiếp >>= như sau:

> Just 20 >>= half >>= half >>= half
Nothing

Just 20 >>= half >>= half >>= half

Monad áp dụng cho datatype IO

Trong Haskell ta có hàm getLine: nhận input từ console trả về một String được gói trong context IO

getLine :: IO String

getLine :: IO String

Hàm readFile: input một chuỗi(tên file) và trả về một String được gói trong context IO

readFile :: FilePath -> IO String

Trong Haskell datatype FilePath chính là alias của String

readFile :: FilePath -> IO String

Hàm putStrLn: input một String, in chuỗi đó ra console, trả về IO ()

putStrLn :: IO ()

putStrLn :: IO

Do IO cũng là một Monad nên ta có thể áp dụng >>= vào các function trên như sau:

getLine >>= readFile >>= putStrLn

Kết quả là chuỗi hành động sau:

  • Nhận input file name từ console
  • Đọc file đó
  • In nội dung file đó ra console

monad IO

Haskell cung cấp cú pháp do để làm công việc nối nhiều function IO lại với nhau như trên.

foo = do
  fileName <- getLine
  fileContent <- readFile fileName
  putStrLn fileContent

Tóm lại

  1. Functor là là một kiểu dữ liệu định nghĩa typeclass Functor
  2. Applicative là kiểu dữ liệu định nghĩa typeclass Applicative
  3. Monad là kiểu dữ liệu định nghĩa typeclass Monad
  4. Maybe định nghĩa cả 3 typeclass Functor, Applicative, Monad. Do đó có thể dùng fmap, <$>, <*>, >>= với Maybe

Điểm khác biệt của các typeclass trên là gì?

recap

  • functor: áp dụng một function vào một context value thông qua hàm fmap hoặc <$>
  • applicative: áp dụng một context function vào một context value thông qua hàm <*>
  • monad: áp dụng một function trả về context value vào một context value thông qua hàm >>=