Haskell #6: Lambda

Hàm hợp

Trong toán học, hàm hợp được định nghĩa như sau: hàm hợp của hai hàm $f$ và $g$ là một hàm $h$, sao cho khi được gọi hàm với tham số, chẳng hạn $x$, thì $h(x)=f(g(x))$. Trong Haskell, hàm hợp cũng giống như vậy. Ta gọi hàm hợp bằng hàm ., hàm này được định nghĩa như sau:

(.) :: (b -> c) -> (a -> b) -> a -> c
f . g = \x -> f (g x)

Hãy để ý type signature. f nhận tham số cùng kiểu trả về từ g. Như vậy hàm hợp nhận vào tham số kiểu g nhận rồi trả về giá trị cùng kiểu với giá trị f trả về. Ví dụ, negate . (* 3) trả về một hàm nhận vào một số, nhân nó với 3 rồi đảo dấu của nó.

Một trong số những tác dụng của hàm hợp là tạo ra các hàm khi cần để truyền đến hàm khác. Dĩ nhiên bạn có thể dùng lambda, nhưng nhiều khi dùng hàm hợp thì rõ ràng và gọn hơn. Chẳng hạn ta có List số và muốn chuyển tất cả chúng thành số âm. Một cách làm là lấy giá trị tuyệt đối rồi đảo dấu, như sau:

ghci> map (\x -> negate (abs x)) [5,-3,-6,7,-3,2,-19,24]
[-5,-3,-6,-7,-3,-2,-19,-24]

Lưu ý kí hiệu lambda và vai trò của hàm hợp. Dùng hàm hợp, ta có thể viết lại như sau:

ghci> map (negate . abs) [5,-3,-6,7,-3,2,-19,24]
[-5,-3,-6,-7,-3,-2,-19,-24]

Tuyệt! Hàm hợp có tính kết hợp phải, vì vậy ta có thể hợp nhiều hàm cùng lúc.

Biểu thức f (g (z x)) tương đương với (f . g . z) x. Ghi nhớ điều này, ta có thể viết lại:

ghci> map (\xs -> negate (sum (tail xs))) [[1..5],[3..6],[1..7]]
[-14,-15,-27]

thành:

ghci> map (negate . sum . tail) [[1..5],[3..6],[1..7]]
[-14,-15,-27]

Nhưng còn hàm nhận nhiều tham số thì sao? À, nếu ta muốn dùng chúng trong hàm hợp thì phải áp dụng từng phần nhiều đến mức mỗi hàm chỉ được nhận một tham số. Ví dụ: sum (replicate 5 (max 6.7 8.9)) có thể được viết lại thành (sum . replicate 5 . max 6.7) 8.9 hoặc sum . replicate 5 . max 6.7 $ 8.9.

Nếu giải thích bằng lời: max 6.7 nhận vào tham số và so sánh nó với 6.7 rồi áp dụng replicate 5 cho số vừa tìm ra. Tiếp theo, sum nhận kết quả này và tính tổng. Cuối cùng, hàm đó được gọi với tham số 8.9. Nhưng thông thường bạn chỉ cần đọc như sau: áp dụng 8.9 vào max 6.7, rồi áp dụng replicate 5 cho kết quả thu được, tiếp theo áp dụng sum cho kết quả vừa rồi. Nếu bạn phải viết biểu thức với nhiều dấu ngoặc tròn thì bằng việc dùng hàm hợp, bạn có thể bắt đầu bằng cách đặt tham số cuối cùng của hàm trong cùng sau dấu $ rồi chỉ cần hợp tất cả hàm khác, viết chúng dưới dạng không có tham số cuối rồi đặt . giữa chúng. Giả dụ:

replicate 100 (product (map (*3) (zipWith max [1,2,3,4,5] [4,5,6,7,8]))), 

có thể viết lại thành:

replicate 100 . product . map (*3) . zipWith max [1,2,3,4,5] $ [4,5,6,7,8]. 

Nếu biểu thức kết thúc với ba dấu ngoặc tròn, thì nhiều khả năng khi chuyển thành hàm hợp, nó sẽ chứa ba toán tử trong hàm hợp.

Một cách dùng hàm hợp thường gặp là định nghĩa các hàm theo cách được gọi là không có dấu chấm (point-free style, hay pointless style). Lấy ví dụ hàm mà ta đã viết từ trước:

sum' :: (Num a) => [a] -> a   
sum' xs = foldl (+) 0 xs

Ở cả hai vế, xs đều có mặt. Do tính curry, ta có thể lược bỏ xs ở hai vế, vì việc gọi foldl (+) 0 tạo ra hàm nhận vào List. Viết hàm này dưới dạng sum' = foldl (+) 0 được gọi là viết theo kiểu không có dấu chấm. Áp dụng cho ví dụ sau:

fn x = ceiling (negate (tan (cos (max 50 x))))

Ở đây, ta không thể lược bỏ x ở cả hai vế. x trong thân hàm có dấu ngoặc tròn ngay sau nó. cos (max 50) sẽ không có nghĩa. Bạn không thể lấy cosin của một hàm. Điều ta có thể làm là biểu diễn $fn$ theo kiểu hàm hợp.

fn = ceiling . negate . tan . cos . max 50

Tuyệt vời! Nhiều khi, kiểu không dấu chấm thì dễ đọc và gọn hơn, vì nó khiến bạn nghĩ về các hàm và các hình thức kết hợp hàm thay vì nghĩ về dữ liệu và cách nó biến đổi lung tung. Bạn có thể gom các hàm đơn giản và kết chúng lại để tạo thành hàm phức tạp hơn. Tuy vậy, nhiều khi viết hàm theo kiểu không dấu chấm sẽ khó đọc hơn nếu hàm quá phức tạp. Đó là lý do tại sao hàm hợp quá dài không được khuyến khích, mặc dù đôi lúc tôi cảm thấy có lỗi khi quá lạm dụng hàm hợp. Cách viết được ưa chuộng là dùng let để gán cho kết quả trung gian hoặc chia nhỏ bài toán thành những vấn đề con rồi sau đó gộp lại, để cho hàm dễ đọc, hơn là tạo ra một chuỗi hàm hợp dài lòng thòng.

Trong phần về map và filter, ta đã giải quyết bài toán tính tổng của tất cả những bình phương nhỏ hơn 10000 của các số lẻ. Sau đây là lời giải khi ta đặt nó vào trong một hàm.

oddSquareSum :: Integer
oddSquareSum = sum (takeWhile (<10000) (filter odd (map (^2) [1..])))

Là người cuồng hàm hợp, tôi có thể viết như sau:

oddSquareSum :: Integer
oddSquareSum = sum . takeWhile (<10000) . filter odd . map (^2) $ [1..]

Nhưng nếu có cơ hội được người nào đó đọc code mình viết, thì tôi sẽ viết lại nó thành:

oddSquareSum :: Integer
oddSquareSum = 
    let oddSquares = filter odd $ map (^2) [1..]
        belowLimit = takeWhile (<10000) oddSquares
    in sum belowLimit

Cách viết mới này chẳng nhằm mục đích ganh đua với ai, mà chỉ giúp người đọc dễ hiểu hơn so với hàm hợp.

Kết thúc bài 7

Haskell #8: Lambda