Haskell #7: Hàm hợp

Ở những chương trước, ta đã tìm hiểu một số kiểu và class có sẵn trong Haskell. Trong chương này, ta sẽ học cách tự tạo kiểu và class, đồng thời khiến chúng hoạt động!

Kiểu đại số

Tới giờ, chúng ta đã gặp nhiều kiểu: Bool, Int, Char, Maybe, … Nhưng làm thế nào để tự tạo kiểu riêng? Một cách làm là dùng từ khóa data để định nghĩa. Ví dụ về kiểu Bool trong thư viện chuẩn.

data Bool = False | True

Phần đứng trước = là tên kiểu, ở đây là Bool. Phần đứng sau = là value constructor. Trong Haskell, constructor (“hàm khởi tạo”) là khái niệm riêng, không liên quan đến constructor trong OOP. Trong trường hợp đang xét không có hàm, chỉ có giá trị nên gọi là “value constructor”. Chúng chỉ định những giá trị khác nhau mà kiểu này có thể chứa. Dấu “|” là hoặc vì vậy ta có thể đọc câu lệnh này là kiểu Bool có thể nhận giá trị là True hoặc False. Cả tên kiểu và constructor value đều phải được bắt đầu bằng chữ in.

Tương tự, ta có thể hình dung kiểu Int được định nghĩa như sau:

data Int = -2147483648 | -2147483647 | ... | -1 | 0 | 1 | 2 | ... | 2147483647

Constructor value đầu và cuối là giá trị nhỏ nhất và lớn nhất mà Int có thể nhận. Thực ra thì kiểu này không được định nghĩa như vậy, những dấu ba chấm chỉ được dùng với mục đích minh họa.

Bây giờ, hãy hình dung xem ta có thể biểu diễn hình phẳng trong Haskell thế nào. Một cách là dùng tuple. Đường tròn có thể được kí hiệu bởi (43.1, 55.0, 10.4) trong đó trường thứ nhất và thứ hai là tọa độ tâm đường tròn còn trường thứ ba là bán kính.

image info

Nghe có vẻ ổn, nhưng cách code này cũng có thể bị hiểu nhầm là vector 3 chiều hoặc thứ nào đó khác. Cách làm tốt hơn là tự code kiểu riêng. Giả sử hình phẳng này có thể là đường tròn hoặc hình chữ nhật. Ở đây nó là:

data Shape = Circle Float Float Float | Rectangle Float Float Float Float

Hãy hình dung nó như sau: constructor value Circle có ba trường, nhận float. Vì vậy khi ta code constructor value, ta được tự chọn việc thêm kiểu vào sau nó và những kiểu này sẽ định nghĩa các giá trị mà nó chứa. Ở đây, hai trường đầu tiên là tọa độ tâm, trường thứ ba là bán kính. constructor value Rectangle có bốn trường đều là float. Hai giá trị đầu là các tọa độ góc trái trên và hai giá trị sau là các tọa độ góc phải dưới.

Ở đây khi tôi nói trường, ý của tôi là tham số. Constructor value thực ra là hàm trả về giá trị của kiểu. Ta hãy xem type signature của hai constructor value này.

ghci> :t Circle
Circle :: Float -> Float -> Float -> Shape
ghci> :t Rectangle
Rectangle :: Float -> Float -> Float -> Float -> Shape

Hay đấy, vậy là constructor value cũng là hàm. Ai mà nghĩ được như vậy? Ví dụ về một hàm nhận vào Shape và trả về diện tích của hình đó.

surface :: Shape -> Float
surface (Circle _ _ r) = pi * r ^ 2
surface (Rectangle x1 y1 x2 y2) = (abs $ x2 - x1) * (abs $ y2 - y1)

Điều thứ nhất đáng chú ý ở đây là khai báo. Nó nói rằng hàm nhận vào Shape và trả về Float. Ta không thể khai báo là Circle -> FloatCircle không phải là kiểu, ở đây Shape mới là kiểu. Cũng như ta không thể code một hàm với type signature là True -> Int. Điều kế tiếp mà ta nhận thấy ở đây là có thể pattern matching với constructor. Ta matching với constructor trước (thật ra là mọi lúc) khi ta matching với những giá trị như [] hoặc False hoặc 5, chỉ khác là các giá trị đó không chứa trường nào cả. Ta chỉ cần code constructor rồi gắn các trường của nó với các tên gọi. Vì ta quan tâm đến bán kính nên thực ra chẳng cần để ý đến hai trường đầu.

ghci> surface $ Circle 10 20 10
314.15927
ghci> surface $ Rectangle 0 0 100 100
10000.0

A, được rồi! Nhưng nếu ta chỉ in ra Circle 10 20 5, ta sẽ gặp lỗi. Đó là vì Haskell không (đúng hơn là chưa) biết làm thế nào để hiển thị dữ liệu ta cần dưới dạng String. Hãy nhớ rằng khi ta thử in một giá trị từ console, Haskell sẽ chạy hàm show để lấy hiển thị của giá trị này, rồi in nó ra màn hình. Để làm cho kiểu Shape của có thể Show (hay thuộc class Show) thì phải điều chỉnh lại:

data Shape = Circle Float Float Float | Rectangle Float Float Float Float deriving (Show)

Bây giờ hãy tạm coi như việc thêm deriving (Show) vào cuối thì Haskell sẽ tự động làm cho kiểu đó trở thành một phần của class Show. Vì vậy bây giờ ta đã có thể code:

ghci> Circle 10 20 5
Circle 10.0 20.0 5.0

ghci> Rectangle 50 230 60 90
Rectangle 50.0 230.0 60.0 90.0

constructor value là hàm, vì vậy ta có thể map và áp dụng từng phần. Nếu muốn có List các đường tròn đồng tâm với bán kính khác nhau, ta có thể code như sau.

ghci> map (Circle 10 20) [4,5,6,6]
[Circle 10.0 20.0 4.0,Circle 10.0 20.0 5.0,Circle 10.0 20.0 6.0,Circle 10.0 20.0 6.0]

Kiểu vừa code là tốt rồi, dù nó có thể còn tốt nữa. Ta sẽ code một kiểu trung gian để định nghĩa điểm trong không gian hai chiều. Sau đó ta có thể sử dụng kiểu này để giúp cho hình phẳng trở nên dễ hiểu với người đọc code.

data Point = Point Float Float deriving (Show)
data Shape = Circle Point Float | Rectangle Point Point deriving (Show)

Lưu ý rằng khi định nghĩa Point, ta lấy cùng tên để đặt cho kiểu lẫn constructor value. Điều này không có ý nghĩa đặc biệt gì, thông thường chúng ta lấy trùng tên khi chỉ có một constructor value. Vì vậy, bây giờ Circle có hai trường, một là Point và kia là Float. Việc này giúp dễ dàng phân biệt được các thứ với nhau. Tương tự với đối tượng hình chữ nhật. Ta cần chỉnh lại hàm surface vừa code để phản ánh những thay đổi này.

surface :: Shape -> Float
surface (Circle _ r) = pi * r ^ 2
surface (Rectangle (Point x1 y1) (Point x2 y2)) = (abs $ x2 - x1) * (abs $ y2 - y1)

Thứ duy nhất ta phải thay đổi là pattern. Ta không xét đến điểm trong pattern hình tròn. Ở pattern hình chữ nhật, ta dùng pattern matching lồng để thu được trường các điểm. Nếu, vì một lý do nào đó, ta muốn tham chiếu đến các điểm đó thì có thể dùng pattern “as”.

ghci> surface (Rectangle (Point 0 0) (Point 100 100))
10000.0
ghci> surface (Circle (Point 0 0) 24)
1809.5574

Bạn nghĩ sao về một hàm thực thi phép dịch hình? Hàm này nhận vào hình, độ dịch theo trục x và y, rồi trả về một hình mới nằm ở vị trí khác.

nudge :: Shape -> Float -> Float -> Shape
nudge (Circle (Point x y) r) a b = Circle (Point (x+a) (y+b)) r
nudge (Rectangle (Point x1 y1) (Point x2 y2)) a b = Rectangle (Point (x1+a) (y1+b)) (Point (x2+a) (y2+b))

Khá rõ ràng. Ta cộng độ dịch vào các điểm chỉ định vị trí của hình.

ghci> nudge (Circle (Point 34 34) 10) 5 10
Circle (Point 39.0 44.0) 10.0

Nếu không muốn trực tiếp xử lý các điểm, ta có thể code hàm phụ để tạo các hình với kích thước nào đó đặt tại gốc tọa độ rồi dịch chuyển chúng.

baseCircle :: Float -> Shape
baseCircle r = Circle (Point 0 0) r

baseRect :: Float -> Float -> Shape
baseRect width height = Rectangle (Point 0 0) (Point width height)
ghci> nudge (baseRect 40 100) 60 23
Rectangle (Point 60.0 23.0) (Point 100.0 123.0)

Dĩ nhiên là bạn có thể export kiểu trong module. Để làm được điều này, chỉ cần code khai náo theo các hàm được export, sau đó code thêm cặp ngoặc đơn để chỉ định constructor value nào cần được export, phân cách bởi dấu phẩy. Nếu bạn muốn export tất cả constructor value trong một kiểu nhất định thì chỉ cần code … Nếu ta muốn export các hàm và kiểu được định nghĩa trong một module, ta có thể code như sau:

module Shapes 
( Point(..)
, Shape(..)
, surface
, nudge
, baseCircle
, baseRect
) where

Bằng cách code Shape(..), ta đã export toàn bộ constructor value của Shape, vì vậy điều này nghĩa là bất cứ ai nhập module ta code đều có thể tạo các hình bằng constructor value Rectangle và Circle. Nó giống như code Shape (Rectangle, Circle).

Ta cũng có thể chọn cách không export bất cứ constructor value nào của Shape bằng cách chỉ code Shape trong câu lệnh export. Bằng cách này, một người nhập module của ta chỉ có thể tạo hình bằng những hàm phụ baseCircle và baseRect. Data.Map đã dùng phương pháp như vậy. Bạn không thể tạo map bằng cách code Map.Map [(1,2),(3,4)] vì nó không export constructor value đó. Tuy nhiên bạn có thể tạo một ánh xạ bằng cách dùng một trong các hàm phụ như Map.fromList. Hãy nhớ rằng constructor value chỉ là hàm nhận tham số là các trường và trả về kết quả thuộc kiểu nào đó (như Shape). Vì vậy khi ta quyết định không export thì tương đương với việc ngăn người khác dùng khi import module, nhưng nếu có hàm khác đã export mà trả về kiểu, thì ta có thể dùng đó để khởi tạo giá trị thuộc kiểu mà ta tạo ra.

Việc không export các constructor value của kiểu sẽ làm cho chúng trừu tượng hơn, theo cách mà ta giấu cơ chế thực hiện của chúng. Hơn nữa, bất cứ người nào dùng module ta code đều không thể pattern matching với constructor value.

Record syntax

Vậy là ta có thể tạo một kiểu dữ liệu miêu tả người. Thông tin mà ta muốn lưu trữ về người đó bao gồm: họ, tên và tuổi. Không rõ bạn nghĩ sao, nhưng đó là tất cả những gì tôi muốn biết ở một người.

data Person = Person String String Int deriving (Show)

image info

Được rồi. Trường thứ nhất là họ, trường thứ hai là tên, trường thứ ba là tuổi, và cứ như vậy.

ghci> let guy = Person "A" "B" 173
ghci> guy
Person "A" "B" 173

Vậy cũng được, dù hơi khó đọc. Sẽ thế nào nếu ta muốn tạo ra hàm lấy thông tin một người? Một hàm lấy tên của người nào đó, một hàm lấy họ của người nào đó, v.v. À, ta sẽ phải định nghĩa các hàm đó như sau.

firstName :: Person -> String
firstName (Person firstname _ _ ) = firstname
lastName :: Person -> String
lastName (Person _ lastname _ ) = lastname
age :: Person -> Int
age (Person _ _ age) = age    

Phù! Thật sự tôi không thích code như vậy! Rất lủng củng và NHÀM CHÁN, tuy nhiên cách này hoạt động được.

ghci> let guy = Person "A" "B" 173
ghci> firstName guy
"A"
ghci> height guy
"B"
ghci> flavor guy
173

Chắc phải có cách hay hơn chứ nhỉ? Ồ, không có đâu, rất tiếc.

Đùa chút thôi, có đấy. Những vị thánh tạo nên Haskell rất thông minh và đã lường trước tình huống này. Họ đã có một cách khác để giải quyết vấn đề bằng cú pháp bản ghi (record).

image info

data Person = Person { 
    firstName :: String,
    lastName :: String, 
    age :: Int
} deriving (Show)

Như vậy, thay vì chỉ đặt kiểu cho các trường và phân biệt bởi dấu cách thì lần này ta dùng cặp ngoặc nhọn. Đầu tiên là tên của trường, chẳng hạn firstName rồi xài toán tử truy cập phạm vi ::, tiếp theo là chỉ định kiểu. Đối với kiểu của kết quả cũng tương tự. Ưu điểm chủ yếu của cách này là nó tạo các hàm có thể truy cập các trường. Bằng cách dùng record syntax để tạo ra kiểu, Haskell tự động code ra các hàm : firstName, lastNameage.

ghci> :t age
flavor :: Person -> Int
ghci> :t firstName
firstName :: Person -> String

Có một lợi ích khác khi dùng dạng record syntax. Khi ta kế thừa (derive) Show để hiển thị, nó sẽ show kiểu khác. Chẳng hạn ta có kiểu xe hơi. Ta cần có dõi công ty sản xuất, mẫu mã và năm sản xuất xe hơi. Hãy xem đây.

data Car = Car String String Int deriving (Show)
ghci> Car "Ford" "Mustang" 1967
Car "Ford" "Mustang" 1967

Nếu ta định nghĩa nó bằng record syntax.

data Car = Car {company :: String, model :: String, year :: Int} deriving (Show)
ghci> Car {company = "Ford", model = "Mustang", year = 1967}
Car {company = "Ford", model = "Mustang", year = 1967}

Khi tạo xe mới, ta không cần phải xếp các trường theo đúng thứ tự, miễn là liệt kê đủ. Nhưng nếu không dùng record syntax thì phải nêu các trường theo thứ tự.

Nếu ta tạo kiểu vector 3 chiều bằng data Vector = Vector Int Int Int, thì dễ thấy các trường đều là thành phần của vector. Nhưng ở kiểu Person và Car vừa rồi, có cái gì đó không rõ ràng, và việc dùng record syntax rất có lợi.

Tham số kiểu

image info

Constructor value có thể nhận vài giá trị làm tham số và sau đó tạo ra một giá trị mới. Chẳng hạn, constructor Car nhận vào ba giá trị và tạo ra “xe hơi”. Tương tự, type constructor có thể nhận vào tham số là kiểu để tạo ra kiểu mới. Điều này thoạt nghe rất chung chung nhưng nó không đến nỗi phức tạp. Nếu bạn đã quen với template trong C++, thì có mối tương đồng với chúng. Để có cái nhìn rõ rệt về cách hoạt động của tham số kiểu, ta hãy xét một kiểu dữ liệu ta đã gặp được thực thi ra sao.

data Maybe a = Nothing | Just a

Ở đây, a là tham số kiểu. Và chính vì có tham số kiểu mà ta gọi Maybe là type constructor. Maybe có thể tạo ra bất kỳ kiểu dữ liệu nào (trong trường hợp không phải Nothing), ví dụ Maybe Int, Maybe Car, Maybe String, … Không có giá trị nào có kiểu Maybe, vì bản thân Maybe không phải là kiểu, mà là type constructor. Để nó là một kiểu đích thực và chứa giá trị, thì tất cả tham số kiểu của nó phải được xác định.

Vì vậy nếu ta truyền Char vào Maybe, ta sẽ có kiểu Maybe Char. Giá trị Just 'a', chẳng hạn, sẽ có kiểu là Maybe Char.

Có thể bạn đã dùng đến kiểu có tham số kiểu trước khi gặp Maybe nhưng chưa nhận ra. Đó là List. Tuy tiện lợi, song cơ bản List nhận một tham số để tạo kiểu cụ thể. List có thể là [Int], [Char], hoặc [[Char]], nhưng bạn không thể có một giá trị chỉ có kiểu []. Hãy thử nghịch với kiểu Maybe.

ghci> Just "Haha"
Just "Haha"
ghci> Just 84
Just 84
ghci> :t Just "Haha"
Just "Haha" :: Maybe [Char]
ghci> :t Just 84
Just 84 :: (Num t) => Maybe t
ghci> :t Nothing
Nothing :: Maybe a
ghci> Just 10 :: Maybe Double
Just 10.0

Tham số kiểu rất có ích vì với chúng, ta có thể tạo nhiều kiểu khác nhau tùy dạng nào ta muốn. Khi code :t Just "Haha", cơ chế type interfere sẽ hình dung đó là Maybe [Char], vì nếu a có trong Just aString, thì a trong Maybe a cũng phải là String.

Lưu ý rằng kiểu của NothingMaybe a. Kiểu của nó có tính đa hình. Nếu một hàm nào đó yêu cầu tham số là Maybe Int thì ta có thể đưa nó Nothing, vì Nothing đằng nào cũng không chứa giá trị gì, do đó việc làm trên không ảnh hưởng. Kiểu Maybe a có thể đóng vài trò là Maybe Int nếu cần, cũng như 5 có thể đóng vai trò Int hoặc Double. Tương tự, kiểu của List rỗng là [a] vì một List rỗng có thể đóng vai trò là List bất cứ thứ gì. Đó là lý do tại sao ta có thể code [1,2,3] ++ []["ha","ha","ha"] ++ [].

Dùng tham số kiểu có lợi nhưng chỉ khi việc dùng chúng có ý nghĩa. Thông thường là khi kiểu dữ liệu đang dùng sẽ phát huy tác dụng bất kể giá trị nó chứa có kiểu gì, giống như ở Maybe a. Nếu kiểu dữ liệu đang xét đóng vai trò như một cái hộp thì ta nên dùng chúng. Ta có thể sửa lại kiểu dữ liệu Car từ thế này:

data Car = Car { company :: String
               , model :: String
               , year :: Int
               } deriving (Show)

Sang thế này:

data Car a b c = Car { company :: a
                     , model :: b
                     , year :: c 
                     } deriving (Show)

Nhưng thực sự có ích không? Câu trả lời là: có thể không, vì ta chỉ định nghĩa hàm nhận kiểu Car String String Int. Chẳng hạn, với định nghĩa đầu tiên về Car, ta có thể có một hàm hiển thị các thuộc tính của chiếc xe.

tellCar :: Car -> String
tellCar (Car {company = c, model = m, year = y}) = "This " ++ c ++ " " ++ m ++ " was made in " ++ show y
ghci> let stang = Car {company = "Ford", model = "Mustang", year = 1967}
ghci> tellCar stang
"This Ford Mustang was made in 1967"

Thật là một hàm xinh xắn! Type signature rất đẹp và hoạt động tốt. Bây giờ nếu không phải là Car mà là Car a b c thì sao?

tellCar :: (Show a) => Car String String a -> String
tellCar (Car {company = c, model = m, year = y}) = "This " ++ c ++ " " ++ m ++ " was made in " ++ show y

Ta phải buộc hàm này nhận Car(Show a) => Car String String a. Bạn có thể thấy type signature đã phức tạp hơn và lợi duy nhất mà ta thu được là được phép lấy bất kì kiểu nào là trong class Show làm kiểu cho c.

ghci> tellCar (Car "Ford" "Mustang" 1967)
"This Ford Mustang was made in 1967"
ghci> tellCar (Car "Ford" "Mustang" "nineteen sixty seven")
"This Ford Mustang was made in \"nineteen sixty seven\""

ghci> :t Car "Ford" "Mustang" 1967
Car "Ford" "Mustang" 1967 :: (Num t) => Car [Char] [Char] t
ghci> :t Car "Ford" "Mustang" "nineteen sixty seven"
Car "Ford" "Mustang" "nineteen sixty seven" :: Car [Char] [Char] [Char]

Dù vậy, trên thực tế thì đa số trường hợp ta sẽ dùng Car String String Int, dường như việc tham số hóa Car thật không bõ công. Ta thường dùng những tham số kiểu khi kiểu trong type constructor không ảnh hưởng đến hoạt động của dữ liệu. Một List các thứ, bản thân nó vẫn là nó, vẫn hoạt động được mà chẳng liên hệ gì đến kiểu. Nếu ta muốn tính tổng List các số, ta có thể định nghĩa trong trong hàm tính tổng. Với Maybe cũng tương tự, Maybe biểu thị một lựa chọn hoặc là không có gì, hoặc là có một thứ gì đó. Còn “thứ gì đó” là gì cũng được.

Một ví dụ khác về kiểu được tham số hóa mà ta đã gặp là Map k v trong Data.Map. Ở đây k là kiểu của key còn v là kiểu của value trong map. Đây là một ví dụ hay, trong đó tham số kiểu rất có ích. Map khi được tham số hóa sẽ cho phép ta ánh xạ từ kiểu này sang kiểu khác, miễn là kiểu của key thuộc về class Ord.

Nếu định nghĩa một map, ta có thể thêm một ràng buộc về kiểu trong khai báo data:

data (Ord k) => Map k v = ...

Tuy vậy, một quy tắc phổ biến trong Haskell là không bao giờ thêm ràng buộc class vào trong khai báo data. Tại sao ư? Bởi vì sẽ không có lợi ích gì khi thêm ràng buộc class. Nếu ta ràng buộc Ord k trong khai báo data của Map k v, ta sẽ phải ràng buộc key thuộc lớp Order. Một ví dụ về hàm dạng này là toList, nhận vào map rồi chuyển map này thành List. Type signature của hàm là toList :: Map k a -> [(k, a)]. Nếu Map k v có ràng buộc kiểu trong khai báo data, thì kiểu của toList sẽ phải là toList :: (Ord k) => Map k a -> [(k, a)], mặc dù toList không quan tâm đến việc so sánh key.

Bởi vậy, đừng đưa ràng buộc kiểu vào khai báo data ngay cả khi hợp lý, vì dù gì đi nữa bạn cũng phải đặt chúng trong khai báo hàm.

Hãy code kiểu vector 3 chiều và một vài phép toán.

data Vector a = Vector a a a deriving (Show)

vplus :: (Num t) => Vector t -> Vector t -> Vector t
(Vector i j k) `vplus` (Vector l m n) = Vector (i+l) (j+m) (k+n)

vectMult :: (Num t) => Vector t -> t -> Vector t
(Vector i j k) `vectMult` m = Vector (i*m) (j*m) (k*m)

scalarMult :: (Num t) => Vector t -> Vector t -> t
(Vector i j k) `scalarMult` (Vector l m n) = i*l + j*m + k*n

vplus dùng để cộng hai vector với nhau. Hai vector cộng lại bằng cách cộng thành phần tương ứng. scalarMult dùng cho tích vô hướng còn vectMult dùng để nhân vector với vô hướng. Các hàm này có thể hoạt động với kiểu Vector Int, Vector Integer, Vector Float, miễn a trong Vector a thuộc về class Num. Đồng thời, nếu bạn kiểm tra khai báo kiểu của những hàm này, bạn sẽ thấy rằng chúng chỉ có thể hoạt động trên vector có cùng kiểu và phần tử tham gia vào phép tính cũng phải cùng kiểu với phần tử trong vector. Lưu ý rằng ta không ràng buộc class Num vào khai báo data, vì ta sẽ làm việc đó trong khai báo hàm.

Một lần nữa, cần phân biệt rõ type constructor và constructor value. Khi khai báo một kiểu dữ liệu, phần phía trước dấu = là type constructor và những constructor đi sau dấu bằng (có thể có dấu | ngăn cách) là constructor value. Việc trao kiểu Vector t t t -> Vector t t t -> t cho một hàm là sai, vì ta phải đặt kiểu vào trong khai báo kiểu và type constructor của vector chỉ nhận một tham số, còn constructor value thì nhận ba tham số. Ta hãy thử nghịch với kiểu vector vừa code.

ghci> Vector 3 5 8 `vplus` Vector 9 2 8
Vector 12 7 16
ghci> Vector 3 5 8 `vplus` Vector 9 2 8 `vplus` Vector 0 2 3
Vector 12 9 19
ghci> Vector 3 9 7 `vectMult` 10
Vector 30 90 70
ghci> Vector 4 9 5 `scalarMult` Vector 9.0 2.0 4.0
74.0
ghci> Vector 2 9 3 `vectMult` (Vector 4 9 5 `scalarMult` Vector 9 2 4)
Vector 148 666 222

Kết thúc bài 8

Haskell #9: Kế thừa & Đồng kiểu