Chương 8: Tự lập nên các kiểu và lớp riêng

Trở về Mục lục cuốn sách

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

Giới thiệu những kiểu dữ liệu đại số

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

data Bool = False | True

data nghĩa là ta đang định nghĩa một kiểu dữ liệu mới. Phần đứng trước = để chỉ kiểu, ở đây là Bool. Phần đứng sau là =constructor giá trị. [Trong Haskell, hàm constructor (“hàm dựng”) là khái niệm riêng, chắc không liên quan đến constructor thường thấy trong lập trình hướng đối tượng. Trong trường hợp đang xét không có mặt hàm, chỉ có các giá trị nên gọi là “constructor giá trị”.] Chúng chỉ định những giá trị khác nhau mà kiểu này có thể chứa. Dấu | được đọc 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 giá trị đều phải được viết 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

caveman

Các constructor giá trị đầu và cuối là những 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, vì ta muốn tránh viết một loạt những con số.

Bây giờ, hãy hình dung xem ta có thể biểu diễn một hình phẳng trong Haskell thế nào. Một cách là dùng các bộ. Đường tròn có thể được kí hiệu bởi (43.1, 55.0, 10.4) trong đó các 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. Nghe có vẻ ổn, nhưng cách viết này cũng có thể biểu diễn véc-tơ 3 chiều hoặc một thứ nào đó khác. Một cách làm tốt hơn là tự ta lập một kiểu dùng dể biểu diễn hình dạng. Giả sử rằng 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

Giờ thì cái thì thế này? Hãy hình dung nó như sau: constructor giá trị Circle có ba trường, nhận các số dấu chấm động. Vì vậy khi ta viết một constructor giá trị, ta được tự chọn việc thêm một số 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à các tọa độ của tâm hình, còn trường thứ ba là bán kính. constructor giá trị Rectangle có bốn trường đều chấp nhận các số có dấu chấm động. Hai số đầu là các tọa độ của góc trái phía trên và hai giá trị sau là các tọa độ của góc phải phía dưới.

Ở đây khi tôi nói trường, ý của tôi muốn nói là tham số. Các constructor giá trị thực ra là những hàm mà cuối cùng trả lại một giá trị của một kiểu dữ liệu. Ta hãy xem dấu ấn kiểu của hai constructor giá trị này.

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

Hay đấy, vậy là các constructor giá trị cũng là hàm như những thứ khác. Ai mà nghĩ được như vậy? Ta hãy lập một hàm nhận vào một hình phẳng và trả lại 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 kiểu. Nó nói rằng hàm nhận vào một hình dạng và trả lại một số kiểu chấm động. Ta không thể viết khai báo kiểu như Circle -> FloatCircle không phải là một kiểu, mà ở đây Shape mới là kiểu. Cũng như ta không thể viết một hàm với khai báo kiểu là True -> Int. Điều kế tiếp mà ta nhận thấy ở đây là có thể khớp mẫu với các thành tố dựng. Ta khớp mẫu với các thành tố dựng trước đó (thật ra là mọi lục) khi ta khớp mẫu 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 viết một dựng kiểu 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, vốn chứa thông tin về vị trí đường tròn.

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 thử chỉ in ra Circle 10 20 5 từ dấu nhắc, ta sẽ gặp phải lỗi. Đó là vì Haskell không (đúng hơn là chưa) biết làm thế nào để hiển thị kiểu dữ liệu ta cần dưới dạng chuỗi. Hãy nhớ rằng khi ta thử in một giá trị từ dấ nhắc lệnh, ban đầu Haskell sẽ chạy hàm show để lấy dạng hiển thị chuỗi của giá trị này, rồi in nó ra màn hình. Để làm cho kiểu Shape của ta trở thành một phần của lớp Show thì cần phải điều chỉnh lại như sau:

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

Bây giờ tạm thời ta không bận tâm suy luận nhiều. Hãy coi như là nếu ta thêm deriving (Show) vào cuối lời khai báo data thì Haskell sẽ tự động làm cho kiểu đó trở thành một phần của lớp Show. Vì vậy bây giờ ta đã có thể viết:

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 giá trị là các hàm, vì vậy ta có thể ánh xạ và áp dụng từng phần với mọi thứ. Nếu muốn có danh sách các đường tròn đồng tâm với bán kính khác nhau, ta có thể viết 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 dữ liệu vừa lập là tốt rồi, dù nó có thể còn tốt nữa. Ta hãy lập một kiểu dữ liệu trung gian để định nghĩa một điểm trong không gian hai chiều. Sau đó ta có thể sử dụng kiểu này để giúp cho các loại hình phẳng trở nên dễ hiểu với người đọc mã lệnh hơn.

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 một điểm, ta lấy cùng một tên để đặt cho cả kiểu dữ liệu lẫn constructor giá trị. Điều này không có ý nghĩa đặc biệt gì, mặc dù thông thường người ta lấy trùng tên như vậy khi chỉ có một constructor giá trị. Vì vậy, bây giờ Circle có hai trường, một thuộc vào kiểu Point và trường kia có kiểu 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 viết để 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à các mẫu. Ta không xét đến điểm trong mẫu hình tròn. Ở mẫu hình chữ nhật, ta chỉ dùng một dạng khớp mẫu lồng ghép để thu được trường các điểm. Nếu, vì một lý do nào đó, ta muốn tham chiếu đến bản thân các điểm đó thì có thể dùng mẫu “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 để dịch chuyển hình? Hàm này nhận vào một hình, độ dịch chuyển theo trục x và độ dịch chuyển theo trục y, rồi trả lại một hình mới có cùng kích thước hình ban đầu nhưng nằm ở vị trí nào đó 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 các độ dịch chuyển 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ể lập một số hàm phụ trợ để 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ể xuấ khẩu các kiểu dữ liệu trong module bạn viết. Để làm được điều này, chỉ cần viết kiểu dữ liệu đi theo các hàm được xuất khẩu, sau đó viết thêm cặp ngoặc đơn trong đó chỉ định những constructor giá trị nào cần được xuất khẩu, phân cách bởi dấu phẩy. Nếu bạn muốn xuất khẩu toàn bộ các constructor giá trị trong một kiểu nhât định thì chỉ cần viết ...

Nếu ta muốn xuất khẩu các hàm và kiểu giá trị được định nghĩa ở đây trong một module, ta có thể bắt đầu viết như sau:

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

Bằng cách viết Shape(..), ta đã xuất khẩu toàn bộ các constructor giá trị cho Shape, vì vậy điều này nghĩa là bất cứ ai nhập module ta viết đều có thể tạo các hình bằng những constructor giá trị RectangleCircle. Nó giống như viết Shape (Rectangle, Circle).

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

Việc không xuất khẩu các constructor giá trị của một kiểu dữ liệu sẽ làm cho chúng trở nên 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 viết đều không thể khớp mẫu với các constructor giá trị được.

Cú pháp dạng bản ghi

record

Vậy là ta đã được giao nhiệm vụ tạo một kiểu dữ liệu để miêu tả một người. Thông tin mà ta muốn lưu trữ về người đó bao gồm: họ, tên, tuổi, chiều cao, số điện thoại, và vị kem được ưa thích. 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. Ta hãy viết mã lệnh nào!

data Person = Person String String Int Float String String deriving (Show)

Đượ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. Ta hãy tạo ra dữ liệu về người mới.

ghci> let guy = Person "Buddy" "Finklestein" 43 184.2 "526-2928" "Chocolate"
ghci> guy
Person "Buddy" "Finklestein" 43 184.2 "526-2928" "Chocolate"

Vậy cũng được, dù hơi khó đọc. Sẽ thế nào nếu ta muốn tạo ra một hàm để lấy thông tin riêng từ 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 đó kiểu như sau.

firstName :: Person -> String
firstName (Person firstname _ _ _ _ _) = firstname

lastName :: Person -> String
lastName (Person _ lastname _ _ _ _) = lastname

age :: Person -> Int
age (Person _ _ age _ _ _) = age

height :: Person -> Float
height (Person _ _ _ height _ _) = height

phoneNumber :: Person -> String
phoneNumber (Person _ _ _ _ number _) = number

flavor :: Person -> String
flavor (Person _ _ _ _ _ flavor) = flavor

Phù! Thật sự tôi không thích viết mã lệnh như vậy! Tuy viết như vậy rất lủng củng và NHÀM CHÁN, song cách này hoạt động được.

ghci> let guy = Person "Buddy" "Finklestein" 43 184.2 "526-2928" "Chocolate"
ghci> firstName guy
"Buddy"
ghci> height guy
184.2
ghci> flavor guy
"Chocolate"

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

Đùa chút thôi, có đấy. Ha ha ha! Những người lập nên Haskell đều rất thông minh và đã lường trước tình huống này. Họ đã kèm theo một cách khác để viết những kiểu dữ liệu. Sau đây là cách để ta đạt được tính năng nói trên dùng cú pháp dạng bản ghi (record).

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

Như vậy thay vì chỉ đặt tên các kiểu của trường lần lượt và phân biệt bởi những dấu cách thì lần này ta dùng cặp ngoặc nhọn. Đầu tiên, ta viết tên của trường, chẳng hạn firstName rồi viết hai dấu hai chấm :: (còn được gọi là Paamayim Nekudotayim, ha ha) tiếp theo là chỉ định kiểu dữ liệu. Đối với kiểu dữ liệu của kết quả cũng tương tự. Ưu điểm chủ yếu của điều này là nó tạo ra các hàm để tra tìm các trường trong kiểu dữ liệu. Bằng cách dùng cú pháp kiểu bản ghi để tạo ra kiểu dữ liệu này, Haskell tự động lập ra các hàm: firstName, lastName, age, height, phoneNumberflavor.

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

Còn có một ích lợi khác khi dùng dạng cú pháp kiểu bản ghi. Khi ta suy diễn (derive) Show cho kiểu dữ liệu thì nó sẽ hiển thị theo cách khác với khi ta dùng cú pháp bản ghi để định nghĩa và tạo kiểu. Chẳng hạn ta có một kiểu biểu diễn cho xe hơi. Ta cần phải theo dõi công ty sản xuất, mẫu mã và năm sản xuất xe hơi này. 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 cú pháp kiểu bản ghi, thì ta có thể tạo ra một xe mới như thế này.

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 ra xe hơi mới, ta không nhất thiết 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 cú pháp dạng bản ghi thì ta phải nêu các trường theo thứ tự.

Hãy dùng cú pháp dạng bản ghi khi constructor có nhiều trường mà không rõ ý nghĩa của từng trường. Nếu ta tạo một kiểu dữ liệu véc-tơ 3 chiều bằng cách viết data Vector = Vector Int Int Int, thì khá dễ thấy rằng các trường đều là thành phần của véc-tơ. Nhưng ở hai kiểu PersonCar vừa rồi, điều đó không rõ ràng, và bằng việc dùng cú pháp dạng bản ghi thật là có lợi.

Tham số kiểu

Constructor giá trị 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 một giá trị “xe hơi”. Tương tự, constructor kiểu có thể nhận vào tham số là các kiểu dữ liệu để tạo ra những kiểu mới. Điều này thoạt nghe thì rất chung chung nhưng nó không đến nỗi phức tạp. Nếu bạn đã quen với các template trong C++, thì có thể liên hệ 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 xem một kiểu dữ liệu ta đã gặp được lập ra sao.

data Maybe a = Nothing | Just a

yeti

Ở đây, a là tham số kiểu. Và chính vì có sự tham gia của tham số kiểu mà ta gọi Maybe là constructor kiểu. Tùy ý ta muốn kiểu dữ liệu này chứa thứ gì khi không phải Nothing, constructor kiểu có thể kết thúc bằng việc tạo ra một kiểu Maybe Int, Maybe Car, Maybe String, v.v. Không có giá trị nào có kiểu chỉ mỗi là Maybe, và bản thân nó không phải là một kiểu, mà là một constructor kiểu. Để cho nó là một kiểu thực thụ và chứa các giá trị, thì tất cả những tham số kiểu của nó cần được điền đủ các giá trị vào.

Vì vậy nếu ta truyền Char với vai trò tham số vào Maybe,thì 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 chưa biết rằng ta đã dùng đến một kiểu có một tham số kiểu trước khi gặp Maybe. Kiểu đó là danh sách. Tuy có mặt của cách viết tiện lợi (syntactic sugar), song cơ bản là kiểu danh sách nhận một tham số để tạo ra một kiểu cụ thể. Các giá trị có thể mang kiểu [Int], một kiểu [Char], một kiểu [[String]], nhưng bạn không thể có một giá trị chỉ có kiểu là [].

Ta hãy thử nghịch chơ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 ó ích vì với chúng, ta có thể tạo ra những kiểu khác nhau tùy theo những dạng nào ta muốn bao gồm trong kiểu dữ liệu đang xét. Khi ta viết :t Just "Haha", cơ chế suy luận kiểu sẽ hình dung ra được đó là kiểu Maybe [Char], vì nếu a có trong Just a là một chuỗi, thì a trong Maybe a cũng phải là một chuỗi.

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à một Maybe Int thì ta có thể đưa cho nó Nothing, vì Nothing đằng nào cũng không chứa giá trị gì, do đó việc làm trên cũng 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 danh sách rỗng là [a]. Một danh sách rỗng có thể đóng vai trò của danh sách bất cứ thứ gì. Đó là lý do tại sao ta có thể viết [1,2,3] ++ []["ha","ha","ha"] ++ [].

Dùng tham số kiểu rất có lợi, nhưng chỉ khi việc dùng chúng có ý nghĩa. Thông thường ta dùng chúng 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ự là ta sẽ được lợi không? Câu trả lời là: có thể không, vì rồi ta sẽ phải định nghĩa các hàm chỉ hoạt động được với kiểu Car String String Int. Chẳng hạn, với định nghĩa đầu tiên về Car, ta có thể lập một hàm để hiển thị các thuộc tính của chiếc xe hơi dưới dạng một câu nói tóm tắt.

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! Lời khai báo kiểu 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 vào một kiểu Car(Show a) => Car String String a. Bạn có thể thấy rằng dấu ấn kiểu đã phức tạp hơn và ích lợi duy nhất mà ta thu được chỉ là giờ ta được phép lấy bất kì kiểu nào là thực thể trong lớp Show dùng 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]

meekrat

Dù vậy trên thực tế thì đại đa số trường hợp ta sẽ dùng Car String String Int, nên dường như việc tham số hóa kiểu Car thật không bõ công. Ta thường dùng những tham số kiểu khi mà kiểu chứa trong những constructor kiểu không thật quan trọng đối với kiểu dữ liệu để có thể hoạt động được. Một danh sách các thứ, bản thân nó là nó, vẫn hoạt động được mà chẳng can hệ gì đến kiểu của các thứ trong đó. Nếu ta muốn tính tổng một danh sách các số, ta có thể làm rõ sau trong trong hàm tính tổng, là ta muốn cụ thể một danh sách số. Với Maybe cũng tương tự như vậy. 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ì đó” có thể thuộc kiểu dữ liệu 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 các khóa trong ánh xạ còn v là kiểu của các giá trị. Đây là một ví dụ hay, trong đó các tham số kiểu rất có ích. Ánh xạ khi được tham số hóa sẽ cho phép ta thực hiện ánh xạ từ kiểu này sang kiểu khác, miễn là kiểu của khóa thuộc về lớp Ord. Nếu đang định nghĩa một kiểu ánh xạ, ta có thể thêm vào một ràng buộc về lớp kiểu trong lời 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 các ràng buộc lớp vào trong lời khai báo data. Tại sao ư? À, bởi vì ta không được lợi nhiều, mà lại phải viết thêm các ràng buộc lớp ngay cả khi không cần đến chúng. Nếu ta đặt ràng buộc Ord k vào trong lời khai báo data của Map k v, ta sẽ phải đặt ràng buộc vào các hàm giả sử khóa trong ánh xạ có thể xếp thứ tự. Nhưng nếu ta không đặt ràng buộc vào trong lời khai báo dữ liệu, thì ta sẽ không phải đặt (Ord k) => trong lời khai báo kiểu của hàm mà không quan tâm đến việc liệu các khóa có thể sắp thứ tự hay không. Một ví dụ về hàm dạng này là toList; nó nhận vào một phép ánh xạ rồi chuyển đổi ánh xạ này thành một danh sách liên kết. Dấu ấn kiểu của hàm là toList :: Map k a -> [(k, a)]. Nếu Map k v có ràng buộc kiểu trong lời 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ù hàm không thực hiện bất kì việc so sánh khóa nào theo thứ tự.

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

Ta hãy lập kiểu véc-tơ 3 chiều và thêm vào một vài phép toán. Ta sẽ dùng một kiểu tham số hóa vì mặc dù nó sẽ thường dùng để chứa những kiểu số, nó vẫn sẽ cho phép thao tác một số phép toán với chúng.

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 được dùng để cộng hai véc-tơ với nhau. Hai véc-tơ được cộng lại chỉ bằng cách cộng những thành phần tương ứng. scalarMult được dùng cho tích vô hướng của hai véc-tơ còn vectMult dùng để nhân một véc-tơ vwosi một số vô hướng. Các hàm này có thể hoạt động được với những kiểu Vector Int, Vector Integer, Vector Float bất kì, miễn là a trong Vector a thuộc về lớp Num. Đồng thời, nếu bạn kiểm tra lời 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 những véc-tơ có cùng kiểu và các số tham gia vào phép tính cũng phải cùng kiểu với những số trong véc-tơ. Lưu ý rằng ta không đưa ràng buộc lớp Num vào trong khai báo data, vì dù sao ta vẫn phải lặp lại nó trong các hàm.

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

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

Các thực thể suy diễn

gob

Trong Mục Lớp chứa kiểu (nhập môn), chúng tôi đã trình bày những điều cơ bản về lớp (chứa kiểu). Chúng tôi có giải thích rằng một lớp là một dạng giao diện trong đó định nghĩa một hành vi nào đó. Một kiểu dữ liệu có thể được lập thành thực thể của một lớp nếu như lớp này cho phép hành vi của kiểu dữ liệu đó. Lấy ví dụ: kiểu Int là một thực thể của lớp Eq vì lớp Eq có định nghĩa hành vi của những thứ có thể so sánh ngang bằng được. [Ở đây chỉ xét so sánh bằng hoặc khác nhau chứ không kể so sánh hơn kém.] Và vì số nguyên có thể được so sánh ngang bằng nên Int là một phần thuộc về lớp Eq. Ích lợi thực sự đến từ các hàm đóng vai trò là giao diện cho Eq, cụ thể là ==/=. Nếu có một kiểu dữ liệu thuộc về lớp Eq, thì ta có thể dùng các hàm == với những giá trị theo kiểu đó. Điều này lý giải tại sao những biểu thức như 4 == 4"foo" /= "bar" lại kiểm tra về kiểu.

Chúng tôi cũng nhắc đến rằng lớp chứa kiểu thường hay bị nhầm với khái niệm lớp trong những ngôn ngữ như Java, Python, C++ và tương tự, vốn từng làm rối trí nhiều người. Ở những ngôn nguex này, lớp là một “bí kíp” từ đó ta chế ra các đối tượng có chứa trạng thái, các đối tượng này có thể kèm theo hành động. Lớp chứa kiểu thì lại giống các giao diện hơn. Ta không tạo ra dữ liệu từ lớp chứa kiểu [sau này, để cho gọn, ta sẽ gọi chúng là các lớp]. Mà trước hết, ta tạo ra kiểu dữ liệu rồi sau đó nghĩ xem nó có thể hoạt động thế nào. Nếu nó hoạt động được như thứ gì mà so sánh ngang bằng được, thì ta sẽ đặt nó là một thực thể của lớp Eq. Nếu nó hoạt động được như thể một thứ có thể sắp thứ tự, ta sẽ đặt nó là một thực thể của lớp Ord.

Trong mục kế tiếp, ta sẽ xem cách thủ công tạo ra các thực thể kiểu thuộc lớp, bằng cách lập những hàm định nghĩa bởi lớp. Nhưng ngay bây giờ, hãy xem Haskell có thể tự động tạo ra kiểu đang xét là một thực thể của bất kì lớp nào trong số sau đây: Eq, Ord, Enum, Bounded, Show, Read. Haskell có thể suy diễn ra hành vi của kiểu dữ liệu trong ngữ cảnh này nếu ta dùng từ khóa deriving trong khi tạo ra kiểu dữ liệu.

Xét kiểu dữ liệu sau:

data Person = Person { firstName :: String
                     , lastName :: String
                     , age :: Int
                     }

Kiểu dữ liệu này mô tả một người. Giả định rằng không có hai người nào cùng chung tổ hợp họ, tên, và tuổi. Bây giờ, nếu ta có hai bản ghi thì liệu có thể xét được rằng đó có phải cùng chỉ về một người không? Được chứ. Ta có thể thử so sánh ngang bằng xem chúng có bằng nhau không. Điều này lý giải tại sao kiểu dữ liệu trên lại thuộc về lớp Eq. Ta sẽ suy diễn ra thực thể như sau.

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

Khi suy diễn thực thể Eq đối với một kiểu và rồi thử so sánh hai giá trị thuộc kiểu đó bằng các phép == hoặc /=, Haskell sẽ xem liệu rằng các constructor giá trị có khớp không (mặc dù trong trường hợp hiện tại chỉ có một constructor giá trị) và sau đó sẽ kiểm tra xem liệu tất cả dữ liệu chứa bên trong có khớp nhau không, bằng cách kiểm tra từng cặp trường bằng phép ==. Tuy vậy, chỉ có một điều kiện cần lưu ý: kiểu của tất cả các trường bên trong cũng phải thuộc về lớp Eq. Song vì cả StringInt đều thỏa mãn yêu cầu này, nên mọi việc sẽ ổn. Ta hãy kiểm tra thực thể Eq đang xét.

ghci> let mikeD = Person {firstName = "Michael", lastName = "Diamond", age = 43}
ghci> let adRock = Person {firstName = "Adam", lastName = "Horovitz", age = 41}
ghci> let mca = Person {firstName = "Adam", lastName = "Yauch", age = 44}
ghci> mca == adRock
False
ghci> mikeD == adRock
False
ghci> mikeD == mikeD
True
ghci> mikeD == Person {firstName = "Michael", lastName = "Diamond", age = 43}
True

Dĩ nhiên, vì Person hiện giờ ở trong Eq, ta có thể dùng nó như là a với mọi hàm có một ràng buộc lớp Eq a trong dấu ấn kiểu của chúng, chẳng hạn như elem.

ghci> let beastieBoys = [mca, adRock, mikeD]
ghci> mikeD `elem` beastieBoys
True

Các lớp ShowRead được lần lượt dùng cho những thứ có thể chuyển được thành, và chuyển từ chuỗi. Cũng như với Eq, nếu một kiểu có các constructor chứa các trường, thì kiểu này phải thuộc về Show hoặc Read nếu ta muốn làm cho kiểu của ta trở thành thực thể của các lớp đó. Hãy làm cho kiểu dữ liệu Person trên thuộc về cả Show lẫn Read.

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

Bây giờ ta có thể in thông tin của một người lên màn hình.

ghci> let mikeD = Person {firstName = "Michael", lastName = "Diamond", age = 43}
ghci> mikeD
Person {firstName = "Michael", lastName = "Diamond", age = 43}
ghci> "mikeD is: " ++ show mikeD
"mikeD is: Person {firstName = \"Michael\", lastName = \"Diamond\", age = 43}"

Giá như ta đã thử in thông tin về người lên mành hình trước khi làm cho kiểu dữ liệu Person thuộc về Show, Haskell hẳn đã “phàn nàn”, nói rằng nó không biết cách làm thế nào để biểu diễn thông tin về người dưới dạng chuỗi. Nhưng bây giờ khi ta đã suy diễn một thực thể Show cho nó, thì Haskell đã biết cách biểu diễn này.

Read chính là lớp ngược với Show. Show được dùng để chuyển các giá trị từ kiểu dữ liệu ta lập sang kiểu chuỗi, Read thì dùng để chuyển từ chuỗi sang kiểu dữ liệu ta lập. Tuy vậy cần nhớ rằng, khi sử dụng hàm read, ta cần ghi chú cụ thể về kiểu để Haskell biết được ta muốn lấy kết quả là kiểu gì. Nếu ta không ghi rõ kiểu cần thu được là gì, Haskell sẽ không tự nhận biết được.

ghci> read "Person {firstName =\"Michael\", lastName =\"Diamond\", age = 43}" :: Person
Person {firstName = "Michael", lastName = "Diamond", age = 43}

Nếu sau này ta dùng kết quả của hàm read của ta theo cách Haskell suy diễn là cần phải hiểu như dữ liệu về một người, thì sẽ không phải viết chú thích về kiểu nữa.

ghci> read "Person {firstName =\"Michael\", lastName =\"Diamond\", age = 43}" == mikeD
True

Ta cũng có thể đọc các kiểu được tham số hóa, nhưng phải điền vào những tham số kiểu. Vì vậy ta không thẻ viết read "Just 't'" :: Maybe a, nhưng lại có thể viết read "Just 't'" :: Maybe Char.

Ta có thể suy diễn những thực thể cho lớp Ord, vốn dành cho những kiểu có giá trị xếp được theo thứ tự. Nếu ta so sánh hai giá trị thuộc cùng một kiểu nhưng lại được tạo thành từ các constructor khác nhau thì giá trị được tạo bởi constructor định nghĩa trước sẽ được coi là nhỏ hơn.
Chẳng hạn, hãy xét kiểu Bool, vốn có thể có giá trị là False hoặc True. Nhằm mục đích xét xem hành vi của kiểu dữ liệu này khi được so sánh ra sao, ta có thể hình dung rằng kiểu Bool được lập như sau:

data Bool = False | True deriving (Ord)

Vì constructor giá trị False được chỉ định trước và constructor giá trị True được chỉ định sau đó, nên ta có thể coi như True lớn hơn False.

ghci> True `compare` False
GT
ghci> True > False
True
ghci> True < False
False

Trong kiểu dữ liệu Maybe a, constructor giá trị Nothing được chỉ định trước constructor giá trị Just, vì vậy một giá trị Nothing luôn nhỏ hơn một giá trị Just something, ngay cả khi cái thứ something có bằng âm một tỷ tỷ đi nữa. Nhưng khi so sánh hai giá trị Just, thì sẽ quy về việc so sánh các thứ bên trong hai giá trị đó.

ghci> Nothing < Just 100
True
ghci> Nothing > Just (-49999)
False
ghci> Just 3 `compare` Just 2
GT
ghci> Just 100 > Just 50
True

Nhưng ta không thể viết kiểu như Just (*3) > Just (*2), vì (*3)(*2) là các hàm, và không phải là thực thể của lớp Ord.

Ta có thể dễ dàng dùng những kiểu dữ liệu đại số để thực hiện việc liệt kê; các lớp EnumBounded sẽ giúp ta thực hiện điều đó. Xết kiểu dữ liệu sau đây:

data Day = Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday

Vì tất cả những constructor giá trị đều không nhận tham số (tức là trường) nên ta có thể cho nó thuộc về lớp Enum. Lớp Enum dành cho những thứ có các giá trị liền trước và liền sau. Ta cũng có thể cho nó thuộc về lớp Bounded, vốn dành cho những thứ có giá trị thấp nhất và giá trị cao nhất. Xong việc, hãy làm cho nó thành một thực thể thuộc về tất cả những lớp suy diễn khác và xem ta có thể thao tác gì với nó.

data Day = Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday 
           deriving (Eq, Ord, Show, Read, Bounded, Enum)

Vì kiểu dữ liệu này đã nằm trong các lớp ShowRead nên ta có thể chuyển những giá trị thuộc kiểu này sang kiểu chuỗi và từ kiểu chuỗi.

ghci> Wednesday
Wednesday
ghci> show Wednesday
"Wednesday"
ghci> read "Saturday" :: Day
Saturday

Vì kiểu dữ liệu nằm trong các lớp EqOrd, nên ta có thể so sánh hơn kém hoặc ngang bằng các ngày với nhau.

ghci> Saturday == Sunday
False
ghci> Saturday == Saturday
True
ghci> Saturday > Friday
True
ghci> Monday `compare` Wednesday
LT

Nó cũng nằm trong lớp Bounded, vì vậy ta có thể lấy ngày “thấp nhất” và “cao nhất”.

ghci> minBound :: Day
Monday
ghci> maxBound :: Day
Sunday

Nó cũng là một thực thể của Enum. Ta có thể lấy các giá trị liền trước và liền sau của một ngày và có thể tạo danh sách khoảng từ hai giá trị ngày!

ghci> succ Monday
Tuesday
ghci> pred Saturday
Friday
ghci> [Thursday .. Sunday]
[Thursday,Friday,Saturday,Sunday]
ghci> [minBound .. maxBound] :: [Day]
[Monday,Tuesday,Wednesday,Thursday,Friday,Saturday,Sunday]

Thật là tuyệt.

Sự tương đồng kiểu dữ liệu

Trước đây, chúng tôi có nói rằng khi viết các kiểu dữ liệu thì [Char]String là hai kiểu tương đương và dùng thay cho nhau được. Điều này được thực hiện bằng sự tương đồng kiểu. Tương đồng kiểu dữ liệu thực ra chẳng thực hiện bất cứ điều gì, chúng chỉ đặt các tên khác nhau cho cùng một kiểu dữ liệu để ý nghĩa hơn đối với người đọc mã lệnh và tài liệu hướng dẫn. Sau đây là cách mà thư viện chuẩn định nghĩa String là kiểu tương đồng với [Char].

 type String = [Char]

chicken

Chúng tôi đã giới thiệu từ khóa type. Từ khóa này có thể khiến bạn đọc hiểu nhầm, vì thực ra ta không tạo ra thứ gì mới (ta tạo mới bằng từ khóa data), mà chỉ tạo một kiểu tương đồng với kiểu dữ liệu sẵn có.

Nếu ta tạo một hàm để chuyển đổi chuỗi sang dạng chữ in và gọi nó là toUpperStringhoặc gì đó, thì ta có thể khai báo kiểu như sau: toUpperString :: [Char] -> [Char] hay toUpperString :: String -> String. Cả hai cách này đều như nhau, cách viết thứ hai chỉ dễ đọc hơn.

Khi thao tác với module Data.Map, đầu tiên là ta biểu diễn danh bạ điện thoại bằng một danh sách liên kết trước khi chuyển đổi nó thành một ánh xạ. Như ta đã phát hiện từ trước, danh sách liên kết là một danh sách chứa các cặp khóa-trị. Hãy xem một danh bạ điện thoại mà ta đã có.

phoneBook :: [(String,String)]
phoneBook =    
    [("betty","555-2938")   
    ,("bonnie","452-2928")   
    ,("patsy","493-2928")   
    ,("lucille","205-2928")   
    ,("wendy","939-8282")   
    ,("penny","853-2492")   
    ]

Ta thấy rằng kiểu của phoneBook[(String,String)]. Điều này cho thấy nó là một danh sách liên kết ánh xạ từ chuỗi đến chuỗi, không hơn không kém. Hãy tạo ra một tương đồng kiểu để truyền đạt thêm thông tin trong lời khai báo kiểu.

type PhoneBook = [(String,String)]

Bây giờ thì lời khai báo kiểu của danh bạ điện thoại mà ta lập đã là phoneBook :: PhoneBook. Hãy tạo một kiểu tương đồng cho String nữa.

type PhoneNumber = String
type Name = String
type PhoneBook = [(Name,PhoneNumber)]

Cho kiểu tương đồng với String là cách mà lập trình viên Haskell thực hiện khi họ muốn truyền đạt thêm thông tin rằng những chuỗi trong hàm họ viết được dùng với vai trò gì và chúng biểu diễn những gì.

Vì vậy bây giờ, khi ta lập một hàm nhận vào một tên và một con số rồi xem nếu tổ hợp tên và số đó có nằm trong danh bạ không thì ta có thể viết lời khai báo kiểu rất đẹp và đủ ý nghĩa như sau.

inPhoneBook :: Name -> PhoneNumber -> PhoneBook -> Bool
inPhoneBook name pnumber pbook = (name,pnumber) `elem` pbook

Nếu ta quyết định không dùng kiểu tương đồng thì hàm của ta sẽ có kiểu String -> String -> [(String,String)] -> Bool. Trong trường hợp này, lời khai báo kiểu mà tận dụng được ưu thế của kiểu tương đồng thì sẽ dễ hiểu hơn. Tuy nhiên bạn không nên lạm dụng. Ta giới thiệu kiểu tương đồng để miêu tả những gì mà một kiểu có sẵn nào đó biểu thị trong hàm (và vì vậy lời khai báo kiểu trở nên lời hướng dẫn cụ thể hơn) hoặc khi một thứ nào đó có kiểu dài ngoằng được lặp lại rất nhiều (như [(String,String)]) nhưng biểu thị cho thứ gì đó cụ thể hơn trong ngữ cảnh của hàm đang được sử dụng.

Kiểu tương đồng cũng có thể được tham số hóa. Nếu ta muốn một kiểu để biểu diễn cho danh sách liên kết nhưng vẫn muốn nó tổng quát để nó có thể dùng được các khóa và giá trị thuộc bất kì kiểu nào, thì ta có thể viết như sau:

type AssocList k v = [(k,v)]

Bây giờ, một hàm nhận vào giá trị bằng một khóa trong danh sách liên kết có thể mang kiểu (Eq k) => k -> AssocList k v -> Maybe v. AssocList là một constructor kiểu nhận vào hai kiểu dữ liệu và tạo ra một kiểu cụ thể, chẳng hạn như AssocList Int String.

Fonzie nói: Ấy! Khi tôi nói về kiểu cụ thể, ý tôi là những kiểu được áp dụng trọn vẹn như Map Int String hoặc nếu ta xử lý với mộ trong số các hàm đa hình, [a] hoặc (Ord a) => Maybe a và tương tự như vậy. Và đôi khi chúng tôi có nói Maybe là một kiểu, nhưng không có ý như vậy, bởi ai cũng biết Maybe là một constructor kiểu. Khi áp dụng một kiểu phụ thêm vào Maybe, như Maybe String, thì ta có một kiểu cụ thể. Bạn biết đó, các giá trị chỉ có thể thuộc những kiểu cụ thể! Vì vậy chốt lại là, hãy sống “gấp”, yêu nhiệt tình và đừng để ai dùng chung đồ của bạn! [don’t let anybody else use your comb!]

Cũng như việc ta có thể áp dụng hàm theo từng phần để thu được hàm mới, ta có thể áp dụng các tham số kiểu theo từng phần và từ đó thu được các constructor kiểu. Cũng như việc gọi một hàm với không đủ tham số nhằm tạo ra một hàm mới, ta có thể chỉ định một constructor kiểu với không đủ tham số kiểu và nhận lại một constructor kiểu áp dụng theo từng phần. Nếu muốn một kiểu biểu thị cho ánh xạ (từ Data.Map) từ số nguyên đến một thứ nào đó, ta có thể viết thế này:

type IntMap v = Map Int v

hoặc thế này:

type IntMap = Map Int

Bằng cách nào trong số hai cách trên thì constructor kiểu IntMap cũng sẽ nhận một tham số và đó là kiểu mà các số nguyên sẽ chỉ tới.

Được rồi. Nếu bạn định thử viết mã lệnh minh họa tính năng trên, bạn có thể sẽ phải nhập chọn lọc Data.Map. Khi bạn viết lệnh nhập chọn lọc, các constructor kiểu cũng phải có tên module đứng phía trước. Vì vậy bạn cần viết type IntMap = Map.Map Int.

Hãy đảm bảo chắc rằng bạn thật sự hiểu được khác biệt giữa constructor kiểu và constructor giá trị. Chỉ vì ta đã tạo nên một kiểu tương đồng có tên IntMap hay AssocList thì điều đó không có nghĩa rằng ta có thể viết kiểu như AssocList [(1,2),(4,5),(7,9)]. Tất cả việc tạo kiểu tương đồng nghĩa là ta có thẻ tham chiếu đến kiểu của nó bằng những tên gọi khác nhau. Ta có thể viết [(1,2),(3,5),(8,9)] :: AssocList Int Int; mã lệnh này sẽ làm cho các số bên trong giả định rằng chúng nhận kiểu Int, nhưng ta vẫn có thể dùng danh sách đó như mọi danh sách bình thường chứa các cặp số nguyên. Kiểu tương đồng (và các kiểu dữ liệu nói chung) chỉ có thể được dùng trong phần kiểu của Haskell, tức là mỗi khi ta định nghĩa cá kiểu mới (trong các lời khai báo datatype) hoặc khi được định vị sau dấu ::. Dấu :: xuất hiện trong lời khai báo kiểu hoặc chú thích kiểu.

Một kiểu dữ liệu đẹp khác mà nhận vào tham số là hai kiểu chính là kiểu Either a b. Nó được định nghĩa gần như sau:

data Either a b = Left a | Right b deriving (Eq, Ord, Read, Show)

Nó có hai constructor giá trị. Nếu Left được dùng đến thì nội dung của nó sẽ thuộc kiểu a và nếu Right được dùng đến thì nội dung của nó sẽ thuộc kiểu b. Vì vậy ta có thể dùng kiểu này để bao bọc một giá trị thuộc kiểu này hoặc kiểu khác rồi sau đó khi ta nhận một giá trị thuộc kiểu Either a b, ta thường khớp mẫu với cả Left lẫn Right và nhận các thứ khác nhau tùy theo nó là cái nào.

ghci> Right 20
Right 20
ghci> Left "w00t"
Left "w00t"
ghci> :t Right 'a'
Right 'a' :: Either a Char
ghci> :t Left True
Left True :: Either Bool b

Đến giờ, ta đã thấy rằng Maybe a chủ yếu được dùng để biểu thị cho kết quả tính toán thành hoặc bại. Nhưng đôi khi Maybe a không vẫn là chưa đủ vì Nothing thực sự chưa truyền đạt nhiều thông tin ngoài việc báo rằng có gì đã hỏng hóc. Điều này phù hợp với các hàm có thể thất bại theo một cách duy nhất, hoặc chỉ đơn giản là ta không quan tâm đến nguyên nhân hàm đó thất bại. Một hàm tra tìm Data.Map thất bại chỉ khi các khóa được tìm không nằm trong ánh xạ, vì vậy ta biết chắc điều gì đã xảy ra. Tuy nhiên, khi ta muốn biết một hàm thất bại theo cách nào, hoặc tại sao, thì ta thường dùng kiểu kết quả là Either a b, trong đó a là một kiểu dữ liệu nào đó mà cho ta biết nguyên nhân thất bại có thể xảy ra còn b là một dạng tính toán thành công. Vì vậy, các lỗi dùng đến constructor giá trị Left còn kết quả thì dùng đến Right.

Ví dụ: một trường học nọ có các ngăn khóa để cho học sinh để các poster “Guns’n’Roses”. Mỗi ngăn khóa có một tổ hợp mã số. Khi một học sinh muốn có ngăn khóa mới, học sinh đó phải báo cho thầy quản lý biết số ngăn khóa mong muốn và người thầy sẽ giao mã số cho học sinh. Tuy nhiên, nếu đã có học sinh khác dùng ngăn khóa này, thì không thể dùng mã số cũ mà phải lựa lấy một mã số khác đi. Ta sẽ dùng một ánh xạ từ Data.Map để biểu thị các ngăn khóa. Nó sẽ ánh xạ từ số ngăn khóa đến một cặp gồm thông tin rằng ngăn khóa được dùng chưa và mã số của ngăn.

import qualified Data.Map as Map

data LockerState = Taken | Free deriving (Show, Eq)

type Code = String

type LockerMap = Map.Map Int (LockerState, Code)

Đơn giản. Ta giới thiệu một kiểu dữ liệu mới biểu diễn cho việc ngăn kéo được dùng rồi hay còn trống và ta tạo một kiểu tương đồng cho mã số ngăn khóa. Ta cũng tạo kiểu tương đồng với kiểu ánh xạ từ số nguyên đến cặp tình trạng ngăn khóa và mã số. Còn bây giờ, ta sẽ lập một hàm tìm kiếm mã số trong ánh xạ. Ta sẽ dùng một kiểu Either String Code để biểu diễn cho kết quả, vì việc tra tìm có thể thất bại theo hai cách — ngăn khóa có thể bị chiếm giữ rồi, trong trường hợp đó ta không thể nói mã số hoặc số ngăn khóa có thể hoàn toàn không tồn tại. Nếu việc tra tìm thất bại, ta sẽ chỉ dùng một String để báo điều gì đã xảy ra.

lockerLookup :: Int -> LockerMap -> Either String Code
lockerLookup lockerNumber map = 
    case Map.lookup lockerNumber map of 
        Nothing -> Left $ "Locker number " ++ show lockerNumber ++ " doesn't exist!"
        Just (state, code) -> if state /= Taken 
                                then Right code
                                else Left $ "Locker " ++ show lockerNumber ++ " is already taken!"

Ta thực hiện việc tra tìm thông thường trong ánh xạ. Nếu nhận được một Nothing, ta trả lại một giá trị có kiểu Left String, nói rằng ngăn khóa hoàn toàn không tồn tại. Còn nếu ta tìm thấy thì ta sẽ kiểm tra thêm xem ngăn khóa bị chiếm giữ chưa. Nếu đúng, thì trả lại Left nói rằng nó đã bị chiếm rồi. Nếu không, thì trả lại giá trị thuộc kiểu Right Code, grong đó ta đưa học sinh mã số đúng của ngăn khóa. Thực ra đó là một Right String, nhưng ta đã giới thiệu kiểu tương đồng đó để đưa ra những thông tin thêm vào lời khai báo kiểu. Sau đây là một ví dụ về ánh xạ này:

lockers :: LockerMap
lockers = Map.fromList 
    [(100,(Taken,"ZD39I"))
    ,(101,(Free,"JAH3I"))
    ,(103,(Free,"IQSA9"))
    ,(105,(Free,"QOTSA"))
    ,(109,(Taken,"893JJ"))
    ,(110,(Taken,"99292"))
    ]

Bây giờ hãy thử tra tìm một vài mã số ngăn khóa.

ghci> lockerLookup 101 lockers
Right "JAH3I"
ghci> lockerLookup 100 lockers
Left "Locker 100 is already taken!"
ghci> lockerLookup 102 lockers
Left "Locker number 102 doesn't exist!"
ghci> lockerLookup 110 lockers
Left "Locker 110 is already taken!"
ghci> lockerLookup 105 lockers
Right "QOTSA"

Ta đã có thể dùng một Maybe a để biểu thị cho kết quả nhưng khi đó ta sẽ không biết được lý do không nhận được mã số. Song bây giờ, ta có được thông tin về việc thất bại trong kết quả tra tìm.

Cấu trúc dữ liệu đệ quy

the fonz

Như ta đã thấy, một constructor trong một kiểu dữ liệu đại số có thể chứa vài (hoặc không có) trường và mỗi trường phải là kiểu dữ liệu cụ thể nào đó. Trên quan điểm này, ta có thể tạo ra các kiểu dữ liệu mà constructor có các trường cùng kiểu với nhau! Dùng kiểu này, ta có thể tạo ra những kiểu dữ liệu có tính đệ quy, trong đó mỗi giá trị thuộc kiểu nào đó sẽ chứa các giá trị cùng kiểu, đến lượt mình các giá trị con này lại chứa những giá trị khác cùng kiểu và cứ như vậy.

Hãy hình dung danh sách sau: [5]. Đó là cách viết tiện dụng cho 5:[]. Vế trái của : có một giá trị và vế phải có một danh sách. Và trong trường hợp này là danh sách rỗng. Bây giờ, danh sách [4,5] thì thế nào? À, một cách viết tiện dụng khác mà ta phân tích được thành 4:(5:[]). Nhìn vào dấu : thứ nhất, ta thấy rằng nó cũng có một phần tử ở vế trái và một danh sách (5:[]) ở vế phải. Tương tự với một danh sách như 3:(4:(5:6:[])), vốn có thể viết hoặc như vậy hoặc là như 3:4:5:6:[] (vì phép toán : có tính kết hợp về bên phải) hoặc [3,4,5,6].

Ta có thể nói rằng một danh sách có thể là danh sách rỗng hoặc là một phần tử nối bằng một : đến danh sách khác (bản thân danh sách này có thể rỗng hoặc không).

Ta hãy dùng kiểu dữ liệu đại số để lập ra danh sách cho riêng mình!

data List a = Empty | Cons a (List a) deriving (Show, Read, Eq, Ord)

Mã lệnh này đọc như lời định nghĩa ta viết với danh sách ở đoạn trên. Nó hoặc là danh sách rỗng, hoặc một tổ hợp của phần đầu gồm một giá trị và một danh sách. Nếu cảm thấy lúng túng về vấn đề này, có thể bạn sẽ dễ hiểu hơn theo cách bản ghi.

data List a = Empty | Cons { listHead :: a, listTail :: List a} deriving (Show, Read, Eq, Ord)

Bạn cũng có thể thấy lúng túng về constructor Cons ở đây. cons là một tên gọi khác của :. Bạn thấy đấy, trong danh sách, : thực ra chỉ là một constructor nhận vào một giá trị và một danh sách khác rồi trả lại một dánh ách. Ta có thể dùng được kiểu danh sách mới lập rồi! Nói cách khác, nó gồm có hai trường. Một trường có kiểu a còn trường kia có kiểu [a].

ghci> Empty
Empty
ghci> 5 `Cons` Empty
Cons 5 Empty
ghci> 4 `Cons` (5 `Cons` Empty)
Cons 4 (Cons 5 Empty)
ghci> 3 `Cons` (4 `Cons` (5 `Cons` Empty))
Cons 3 (Cons 4 (Cons 5 Empty))

Ta gọi constructor Cons theo cách toán tử trung tố vì vậy bạn có thể thấy bằng cách nào mà nó chỉ giống như :. Empty thì giống []4 `Cons` (5 `Cons` Empty) thì giống 4:(5:[]).

Ta có thể định nghĩa hàm tự động theo kiểu trung tố bằng cách đạt tên nó chỉ bằng những kí tự đặc biệt. Ta cũng có thể làm điều tương tự với các constructor, vì chúng chỉ là các hàm trả về một kiểu dữ liệu. Vì vậy hãy kiểm tra đoạn mã lệnh này.

infixr 5 :-:
data List a = Empty | a :-: (List a) deriving (Show, Read, Eq, Ord)

Trước hết, ta nhận thấy một cấu trúc cú pháp mới, đó là lời khai báo định tố. Khi ta định nghĩa các hàm dưới dạng toán tử, thì ta có thể dùng khai báo đó để gán cho hàm này một kiểu định tố (nhưng không nhất thiết phải làm điều này). Một định tố quy định xem toán tử có mức ưu tiên tính toán đến đâu và xem phép tính kết hợp về phía trái hay phải. Chẳng hạn, định tố của *infixl 7 * và của +infixl 6. Điều này nghĩa là cả hai phép toán đều kết hợp trái (4 * 3 * 2 bằng (4 * 3) * 2) nhưng * có mức độ ưu tiên cao hơn là +, và vì vậy, 5 * 4 + 3 bằng (5 * 4) + 3.

Quay về ví dụ hiện tại, ta chỉ cần viết a :-: (List a) thay vì Cons a (List a). Bây giờ, ta có thể viết các danh sách thuộc kiểu danh sách mới lập như sau:

ghci> 3 :-: 4 :-: 5 :-: Empty
(:-:) 3 ((:-:) 4 ((:-:) 5 Empty))
ghci> let a = 3 :-: 4 :-: 5 :-: Empty
ghci> 100 :-: a
(:-:) 100 ((:-:) 3 ((:-:) 4 ((:-:) 5 Empty)))

Khi suy diễn Show từ kiểu hiện tại, Haskell sẽ vẫn sẽ hiển thị nó như thể constructor là một hàm tiền tố, vì vậy có cặp ngoặc tròn quanh toán tử (hãy nhớ lại rằng, 4 + 3 cũng như (+) 4 3).

Ta hãy tạo một hàm để nối hai danh sách ta vừa lập lại với nhau. Với các danh sách thường, ta có toán tử ++ đảm nhiệm việc này:

infixr 5  ++
(++) :: [a] -> [a] -> [a]
[]     ++ ys = ys
(x:xs) ++ ys = x : (xs ++ ys)

Như vậy ta sẽ chỉ mượn mã lệnh đó để viết cho danh sách ta vừa lập. Ta sẽ đặt tên hàm mới là .++.

infixr 5  .++
(.++) :: List a -> List a -> List a 
Empty .++ ys = ys
(x :-: xs) .++ ys = x :-: (xs .++ ys)

Và xem nó làm việc ra sao …

ghci> let a = 3 :-: 4 :-: 5 :-: Empty
ghci> let b = 6 :-: 7 :-: Empty
ghci> a .++ b
(:-:) 3 ((:-:) 4 ((:-:) 5 ((:-:) 6 ((:-:) 7 Empty))))

Rất đẹp. Nếu muốn, ta có thể lập tất cả các hàm hoạt động với danh sách thường để hoạt động với kiểu danh sách ta vừa tạo.

Lưu ý cách mà ta khớp mẫu với (x :-: xs). Cách này phát huy tác dụng vì khớp mẫu thực ra là khớp các constructor. Ta có thể khớp với :-: vì nó là một constructor dành cho kiểu danh sách của ta, đồng thời ta cũng có thể khớp với : vì nó là constructor dành cho kiểu danh sách có sẵn. Tương tự với []. Bởi vì khớp mẫu (chỉ) làm việc với các constructor, nên ta có thể khớp với tất cả những thứ như vậy, các constructor tiền tố thông thường hoặc kiểu như 8 hoặc 'a', vốn về cơ bản lần lượt là các constructor đối với những kiểu số và kí tự.

binary search tree

Bây giờ, ta sẽ lập một cây tìm kiếm nhị phân. Nếu bạn thạo dùng cây tìm kiếm nhị phân trong những ngôn ngữ như C, thì sau đây sẽ là lời giải thích: cây tìm kiếm gồm một phần tử chỉ đến hai phần tử khác, một bên trái nó và một bên phải nó. Phần tử bên trái thì nhỏ hơn, phần tử bên phải thì lớn hơn. Mỗi phần tử trong số này lại có thể chỉ đến hai phần tử khác (hoặc một phần tử, hoặc không chỉ đến phần tử nào). Hệ quả là mỗi phần tử có tới hai cây con. Và một điều hay về cây tìm kiếm nhị phân là ta biết rằng tất ccar những phần tử trên cây con bên trái của, chẳng hạn như, 5 thì sẽ nhỏ hơn 5. Các phần tử ở cây con bên phải sẽ lớn hơn. Vì vậy nếu ta cần tìm xem liệu 8 có trên cây đang xét không, thì ta có thể bắt đầu từ 5 và vì 8 thì lớn hơn 5, ta sẽ đi về phía phải. Bây giờ ta đang ở 7 và vì 8 vẫn lớn hơn 7 nên ta lại đi về phía phải. Và ta đã tìm thấy phần tử chỉ sau ba lần nhảy! Bây giờ, giả sử như việc tìm kiếm được thực hiện trên danh sách (hoặc một cây không cân đối) thì ta sẽ mất bảy lần nhảy thay vì chỉ ba lần để tìm xem liệu 8 có nằm trên cây không.

Tập hợp và ánh xạ từ Data.SetData.Map được lập bằng các cây, chỉ khác ở chỗ thay vì cây tìm kiếm nhị phân thông thường, chúng sử dụng các cây tìm kiếm nhị phân cân đối. Nhưng hiện giờ ta sẽ chỉ lập cây tìm kiếm nhị phân thông thường.

Sau đây là điều chúng tôi muốn nói: một cây hoặc là một cây rỗng, hoặc là một phần tử có chứa một giá trị nào đó cùng với hai cây. Nghe thật phù hợp với một kiểu dữ liệu đại số!

data Tree a = EmptyTree | Node a (Tree a) (Tree a) deriving (Show, Read, Eq)

Được, điều này rất tốt. Thay vì dựng một cây bằng cách thủ công, ta sẽ tạo một hàm nhận vào một câ và một phần tử rồi gài phần tử lên cây. Ta có thể làm điều này bằng cách so sánh giá trị cần gài vào với giá trị tại điểm nút gốc cây và nếu nó nhỏ hơn, ta sẽ đi về phía trái; nếu lớn hơn, ta sẽ đi về phía phải. Tương tự với từng giá trị tiếp theo tới khi ta đạt đến cây rỗng. Một khi đến được cây rỗng, ta chỉ việc gài điểm nút với giá trị đó thay vào cây rỗng này.

Trong các ngôn ngữ như C, ta làm điều này bằng cách thay đổi con trỏ và giá trị của cây. Trong Haskell, thực ra thì ta không thể sửa đổi cây được, vì vậy ta phải tạo ra một cây con mới mỗi lần quyết định xem cần đi về phái trái hoặc phải và cuối cùng, hàm đảm nhiệm việc gài phần tử sẽ trả lại một cây hoàn toán mới, vì Haskell thực sự không có khái niệm con trỏ, mà chỉ có giá trị. Vì vậy kiểu của hàm gài sẽ có dạng như a -> Tree a - > Tree a. Nó nhận một phần tử và một cầy rồi trả lại một cây mới có phần tử này ở trên đó. Điều này nghe có vẻ không hiệu quả nhưng tính lười biếng của Haskell sẽ phụ trách vấn đề.

Vậy sau đây là hai hàm cần viết. Một là hàm ứng dụng để tạo ra một cây đơn lẻ (cây chỉ có một điểm nút) và một hàm để gài một phần tử lên cây.

singleton :: a -> Tree a
singleton x = Node x EmptyTree EmptyTree

treeInsert :: (Ord a) => a -> Tree a -> Tree a
treeInsert x EmptyTree = singleton x
treeInsert x (Node a left right) 
    | x == a = Node x left right
    | x < a  = Node a (treeInsert x left) right
    | x > a  = Node a left (treeInsert x right)

Hàm singleton chỉ là một cách viết tắt để tạo ra một nút có chứa thứ gì đó và rồi trả lại hai cây con rỗng. Trong hàm gài, đầu tiên là ta có điều kiện biên viết dưới dạng mẫu. Nếu ta đã đạt tới một cây con rỗng, điều này có nghĩa là ta đã ở nơi cần đến và chèn một cây đơn lẻ với phần tử quan tâm thay vào chỗ cây rống. Nếu không chèn vào một cây rỗng, thì ta phải kiểm tra điều gì đó. Trước hết, nếu phần tử định chèn bằng đúng phần tử ở điểm gốc cây, thì trả lại đúng cây ban đầu. Nếu nó nhỏ hơn, thì trả lại một cây có cùng giá trị điểm gốc, cùng cây con bên phải nhưng thay vì cây con bên trái, thì gài giá trị quan tâm vào nó. Tương tự (nhưng theo chều ngược lại) nếu giá trị quan tâm lớn hơn giá trị phần tử gốc.

Tiếp theo, ta sẽ tạo một hàm kiểm tra xem nếu phần tử nào đó có trên cây hay không. Trước hết, hãy định nghĩa điều kiện biên. Nếu ta cần tìm một phần tử trên cây rỗng, thì rõ ràng không có. Được rồi. Ta nhận thấy cách này giống với điều kiện biên khi tìm kiếm phần tử trong danh sách. Nếu ta tìm moojot phần tử trong danh sách rỗng, nó sẽ không có ở đó. Dù sao chăng nữa, nếu ta không tìm kiếm một phần tử trên một cây rỗng thì ta sẽ kiểm tra một số thứ. Nếu phần tử ở điểm gốc cây chính là giá trị ta cần tìm thì quá tốt! Còn nếu không thì sao? À, ta có thể lợi dụng thông tin là các phần tử về phía trái đều nhỏ hơn phần tử ở điểm gốc. Vì vậy nếu phần tử ta cần tìm nhỏ hơn giá trị ở điểm gốc thì hãy kiểm tra để xem nó có ở cây con bên trái không. Nếu lớn hơn thì hãy kiểm tra xem nó có ở cây con bên phải không.

treeElem :: (Ord a) => a -> Tree a -> Bool
treeElem x EmptyTree = False
treeElem x (Node a left right)
    | x == a = True
    | x < a  = treeElem x left
    | x > a  = treeElem x right

Tất cả những điều ta phải làm là viết mã lệnh cho cách làm được trình bày ở trên. Hãy nghịch chơi với cây ta vừa dựng nên! Thay vì dựng theo cách thủ công (dù ta có thể làm được), ta sẽ dùng một hàm gấp để dựng cây từ một danh sách. Hãy nhớ rằng, gần như mọi thứ với duyệt danh sách theo phần tử rồi trả lại một giá trị đều có thể lập được bằng một hàm gấp! Ta sẽ bắt đầu với một cây rỗng và rồi tiếp cận một danh sách từ bên phải và chỉ việc liền tiếp cài những phần tử lên cây tích lũy.

ghci> let nums = [8,6,4,1,7,3,5]
ghci> let numsTree = foldr treeInsert EmptyTree nums
ghci> numsTree
Node 5 (Node 3 (Node 1 EmptyTree EmptyTree) (Node 4 EmptyTree EmptyTree)) (Node 7 (Node 6 EmptyTree EmptyTree) (Node 8 EmptyTree EmptyTree))

Trong hàm foldr đó, treeInsert là hàm gấp (nó nhận vào một cây và một phần tử danh sách rồi tạo ra một cây mới) và EmptyTree là cây tích lũy ban đầu. nums dĩ nhiên là danh sách mà ta sẽ thực hiện gấp.

Khi ta in nội dung cây ra màn hình thì kết qủa sẽ không dễ đọc, nhưng nếu cố gắng, ta sẽ hình dung được cấu trúc của nó. Ta thấy rằng giá trị ở điểm gốc bằng 5 và nó có 2 cây con, trong đó một có điểm gốc bằng 3 và một có điểm gốc bằng 7, v.v.

ghci> 8 `treeElem` numsTree
True
ghci> 100 `treeElem` numsTree
False
ghci> 1 `treeElem` numsTree
True
ghci> 10 `treeElem` numsTree
False

Việc kiểm tra xem phần tử có thuộc cây hay không cũng diễn ra trôi chảy. Tuyệt.

Vậy như bạn có thể thấy, các cấu trúc dữ liệu đại số thực sự là một khái niệm mạnh và hay trong Haskell. Ta có thể dùng chúng để tạo ra bất kì thứ gì từ những giá trị boole và liệt kê các ngày thứ trong tuần cho đến các cây tìm kiếm nhị phân và còn hơn thế nữa!

Lớp chứa kiểu (nâng cao)

tweet

Đến giờ, ta đã học được một số lớp chuẩn của Haskell và đã thấy có những kiểu nào có trong lớp đó. Ta cũng học cách tự động tjao ra những thực thể kiểu riêng trong các lớp chuẩn bằng cách đề nghị Haskell giúp ta suy diễn các thực thể. Trong mục này, ta sẽ học cách tạo nên những lớp riêng và cách thủ công để lập ra các thực thể thuộc lớp đó.

Ôn lại về khái niệm lớp: Lớp (hay lớp chứa kiểu) giống như các giao diện. Một lớp định nghĩa hành vi nào đó (như so sánh ngang bằng, so sánh hơn kém/thứ tự, liệt kê) rồi đến những kiểu có hành vi như vậy được lập thành thực thể của lớp đó. Hành vi của lớp đạt được bằng cách định nghĩa hàm hoặc chỉ là định nghĩa kiểm mà chúng lập nên. Vì vậy khi ta nói một kiểu là thực thể của một lớp, ý ta muốn nói là có thể dùng các hàm mà lớp đó định nghĩa với kiểu nói trên.

Lớp trong Haskell chẳng có gì liên quan đến lớp trong những ngôn ngữ như Java hoặc Python. Điều này có thể khiến nhiều người nhầm lẫn, vì vậy lúc này tôi muốn bạn quên đi tất cả những gì bạn đã biết về lớp trong những ngôn ngữ mệnh lệnh.

Chẳng hạn, lớp Eq dành cho những thứ có thể so sánh ngang bằng được. Lớp này định nghĩa các hàm ==/=. nếu ta có một kiểu (chẳng hạn như Car [xe hơi]) và việc so sánh hai xe hơi bằng một hàm ngang bằng == là có lý, thì việc coi Car là thực thể của Eq cũng là hợp lý.

Dưới đây là cách mà lớp Eq được định nghĩa trong module Prelude chuẩn:

class Eq a where
    (==) :: a -> a -> Bool
    (/=) :: a -> a -> Bool
    x == y = not (x /= y)
    x /= y = not (x == y)

Oa! Ở đây có kiểu cú pháp và từ khóa mới! Đừng lo, tất cả sẽ rõ ngay thôi. Trước hết, khi viết class Eq a where, điều này nghĩa là ta định nghĩa một lớp mới và nó có tên là Eq. Ở đây a là một biến kiểu và nó có nghĩa là a sẽ đóng vai trò kiểu mà ta sẽ sớm khiến nó thành một thực thể của lớp Eq. Nó không nhất thiết phải được gọi là a, nó không nhất thiết phải là một chữ cái, chỉ cần là một từ viết chữ thường. Sau đó, ta định nghĩa một vài hàm. Không cần thiết phải viết mã lệnh cho phần thân hàm, mà ta chỉ cần chỉ định khai báo kiểu cho các hàm này.

Có người thấy dễ hiểu hơn khi ta viết class Eq equatable where và rồi viết những khai báo kiểu dữ liệu như (==) :: equatable -> equatable -> Bool.

Dù sao chúng ta đã lập các phần thân của các hàm mà lớp Eq định nghĩa, chỉ khác là ta định nghĩa chúng dưới dạng đệ quy tương hỗ. Ta nói rằng hai thực thể của Eq bằng nhau nếu chúng không khác và chúng sẽ khác nhau nếu không bằng. Thực ra không nhất thiết phải viết như vậy nhưng ta đã làm, và sẽ sớm thấy được ích lợi của cách viết này.

Nếu ta có, chẳng hạn, class Eq a where và rồi định nghĩa một khai báo kiểu bên trong lớp đó như (==) :: a -> -a -> Bool, thì sau này khi ta kiểm tra kiểu của hàm trên, nó sẽ có kiểu (Eq a) => a -> a -> Bool.

Vì vậy một khi ta đã có một lớp, thì ta có thể làm gì với nó? À, thực sự không làm được gì nhiều. Nhưng một khi ta bắt đầu tạo các thực thể kiểu thuộc lớp đó, thì ta bắt đầu thu nhận được những tính năng hay. Vì vậy, hãy kiểm tra kiểu sau đây:

data TrafficLight = Red | Yellow | Green

Nó xác định các trạng thái của một ngọn đèn giao thông. Có thể nhận thấy rằng cách mà ta không suy diễn bất kì thực thể kiểu gì từ nó. Đó là vì ta sẽ tự dựng nên một số các thực thể, mặc dù có thể suy diễn ra chúng như trong trường hợp các kiểu EqShow. Sau đây là cách mà ta khiến nó trở thành thực thể của lớp Eq.

instance Eq TrafficLight where
    Red == Red = True
    Green == Green = True
    Yellow == Yellow = True
    _ == _ = False

Ta thực hiện điều này bằng cách dùng từ khóa instance keyword. Như vậy class được dùng để định nghĩa các lớp mới và instance dùng để khiến cho các kiểu ta lập ra trở thành thực thể của lớp. Khi định nghĩa Eq, ta đã viết class Eq a where và nói rằng a đóng vai trò của bất cứ kiều gì sẽ được trở thành thực thể về sau này. Ở đây, ta có thể thấy điều đó rõ hơn, vì khi tạo ra một thực thể, ta viết instance Eq TrafficLight where. Ta thay thế a bằng kiểu dữ liệu thực sự.

== được định nghĩa theo /= và ngược lại trong lời khai báo lớp, nên ta chỉ phải thay đổi một trong hai định nghĩa trên trong lời khai báo thực thể. Việc này được gọi là định nghĩa trọn vẹn tối thiểu cho lớp đang xét — một số tối thiểu các hàm mà ta cần lập nên sao cho kiểu được thiết lập có hành vi giống như đã được lớp giới thiệu. Để hoàn thành định nghĩa trọn vẹn tối thiểu cho Eq, ta phải viết mã lệnh thay thế cho một trong hai == hoặc /=. Nếu Eq đơn giản được định nghĩa như sau:

class Eq a where
    (==) :: a -> a -> Bool
    (/=) :: a -> a -> Bool

thì ta sẽ phải viết mã lệnh cho cả hai hàm này khi làm một kiểu trở thành thực thể của lớp, vì nếu không thì Haskell sẽ không biết là hai hàm nói trên có liên hệ ra sao. Cách định nghĩa trọn vẹn tối thiểu sẽ là: cả == lẫn /=.

Bạn có thể thấy rằng chúng ta viết lệnh cho == chỉ đơn giản là khớp mẫu. Vì còn nhiều trường hợp nữa trong đó hai ngọn đèn giao thông không sáng giống nhau, ta đã chỉ định những trường hợp nào thì bằng nhau và sau đó đơn giản là một mẫu “bắt tất cả”, nói rằng nếu không thuộc vào các tổ hợp trên thì hai ngọn đèn sẽ không như nhau.

Đồng thời bằng cách thủ công, ta hãy làm cho kiểu này trở thành thực thể của Show. Để thỏa mãn định nghĩa trọn vẹn tối thiểu cho Show, ta chỉ phải viết mã lệnh cho hàm show của nó, hàm này nhận một giá trị và biến nó thành chuỗi.

instance Show TrafficLight where
    show Red = "Red light"
    show Yellow = "Yellow light"
    show Green = "Green light"

Một lần nữa, ta đã đạt được mục đích bằng cách dùng khớp mẫu. Hãy xem nó hoạt động ra sao:

ghci> Red == Red
True
ghci> Red == Yellow
False
ghci> Red `elem` [Red, Yellow, Green]
True
ghci> [Red, Yellow, Green]
[Red light,Yellow light,Green light]

Tuyệt. Đáng ra có thể chỉ cần suy diễn Eq và rồi nó cũng có hiệu quả tương tự (song vì để bạn tự học nên chúng tôi không làm vậy). Tuy nhiên, việc suy diễn Show đã trực tiếp chuyển đổi constructor giá trị thành chuỗi. Nhưng nếu ta muốn các ngọn đèn được biểu diễn như "Red light", thì ta phải tự tay khai báo thực thể.

Bạn cũng có thể tạo các lớp là lớp con của một lớp khác. Lời khai báo lớp cho Num hơi dài, nhưng đây là phần đầu của nó:

class (Eq a) => Num a where
   ...

Như ta đã đề cập từ trước, có nhiều chỗ mà ta có thể gài ràng buộc vào trong lớp. Vì vậy, điều này chẳng qua là giống như viết class Num a where, chỉ khác là ta nói rằng kiểu dữ liệu mới lập a phải là thực thể của Eq. Điều cốt yếu ta nói là cần phải làm cho kiểu trở thành thực thể của Eq trước khi ta có thể làm nó trở thành thực thể của Num. Trước khi một kiểu nào đó có thể được coi là một con số thì ta phải xác định được là các giá trị thuộc kiểu đó có thể so sánh bằng/khác được không đã: điều này hoàn toàn hợp lý. Đó là tất cả nội dung của việc tạo lớp con, chỉ là một ràng buộc lớp trong lời khai báo lớp! Khi định nghĩa phần thân hàm trong lời khai báo lớp hoặc khi định nghĩa chúng trong lời khai báo thực thể, ta được phép giả thiết rằng a thuộc về Eq và do đó có thể dùng == đối với các giá trị thuộc kiểu này.

Nhưng còn về Maybe hay những kiểu danh sách được làm cho trở thành thực thể của lớp thì sao? Điều khiến cho Maybe trở nên khác so với, chẳng hạn, TrafficLight là ở chỗ bản thân Maybe không phải là một kiểu cụ thể, nó là constructor kiểu vốn nhận vào một tham số kiểu (như Char hoặc thứ gì đó) và tạo ra một kiểu cụ thể (như Maybe Char). Ta hãy xem xét lại lớp Eq:

class Eq a where
    (==) :: a -> a -> Bool
    (/=) :: a -> a -> Bool
    x == y = not (x /= y)
    x /= y = not (x == y)

Từ các lời khai báo kiểu, ta thấy rằng a được dùng như kiểu cụ thể vì tất cả các kiểu trong hàm phải là cụ thể (nhớ lại rằng bạn không thể có hàm thuộc kiểu a -> Maybe nhưng bạn có thể có hàm a -> Maybe a hoặc Maybe Int -> Maybe String). Điều này lý giải tại sao ta không thể viết được như

instance Eq Maybe where
    ...

Vì như ta đã thấy, cái a phải là kiểu cụ thể nhưng ở đây Maybe lại không phải kiểu cụ thể. Nó là một constructor kiểu nhận vào một tham số và tạo ra một kiểu cụ thể. Cũng sẽ rất nhàm chán nếu viết instance Eq (Maybe Int) where, instance Eq (Maybe Char) where, v.v. với mọi kiểu khác nhau. Vì vậy ta có thể viết như sau:

instance Eq (Maybe m) where
    Just x == Just y = x == y
    Nothing == Nothing = True
    _ == _ = False

Điều này giống như nói rằng ta muốn làm cho tất cả những kiểu có dạng Maybe something trở thành thực thể của Eq.Thực chất, ta đã có thể viết (Maybe something), nhưng ta thường chọn dùng một chữ cái để hợp với phong cách Haskell. Ở đây, (Maybe m) đóng vai trò của a từ class Eq a where. Dù Maybe không phải là một kiểu cụ thể, nhưng Maybe m thì đúng vậy. Bằng cách chỉ định một tham số kiểu (m, được viết chữ thường), ta nói rằng ta muốn tất cả những kiểu có dạng Maybe m, trong đó m là kiểu bất kì, trở thành thực thể của Eq.

Tuy vậy, còn một vấn đề với cách làm trên. Bạn có phát hiện được không? Ta đã dùng == đối với nội dung của Maybe mà không hề có đảm bảo rằng thứ chứa trong Maybe dùng được với Eq! Đó là lý do mà ta phải sửa lại lời khai báo thực thể như sau:

instance (Eq m) => Eq (Maybe m) where
    Just x == Just y = x == y
    Nothing == Nothing = True
    _ == _ = False

Ta đã phải thêm vào một ràng buộc lớp! Với lời khai báo thực thể này, ta đã nói rằng: ta muốn tất cả những kiểu có dạng Maybe m thuộc về lớp Eq, nhưng chỉ những kiểu nào mà m (thứ chứa trong Maybe) cũng thuộc về Eq. Thực tế nếu để Haskell đã suy diễn thực thể.

Trong đa số các trường hợp, ràng buộc lớp trong lời khai báo lớp được dùng để làm cho một lớp trở thành lớp con của một lớp khác và và ràng buộc lớp trong lời khai báo thực thể được dùng để thể hiện yêu cầu về nội dung một kiểu nào đó. Chẳng hạn, ở đây ta yêu cầu nội dung của Maybe cũng phải thuộc về lớp Eq.

Khi tạo ra các thực thể, nếu bạn thấy rằng một kiểu được dùng như kiểu cụ thể trong lời khai báo kiểu (như a trong a -> a -> Bool), bạn phải cung cấp những tham số liểu và thêm cặp ngoặc tròn để thu được một kiểu cụ thể.

Hãy tính đến kiểu mà bạn đang cố gắng làm cho thực thể thuộc kiểu này sẽ thay thế tham số trong lời khai báo lớp. Cái a trong class Eq a where sẽ được thay thế bằng một kiểu thực sự khi bạn tạo ra một thực thể, vì vậy cố gắng hình dung đặt cả kiểu đang xét vào trong một lời khai báo kiểu hàm. (==) :: Maybe -> Maybe -> Bool không hợp lý lắm nhưng còn (==) :: (Eq m) => Maybe m -> Maybe m -> Bool thì được. Tuy nhiên đây chỉ là điều cần tính đến, vì == sẽ luôn có kiểu (==) :: (Eq a) => a -> a -> Bool, bất kể các thực thể ta tạo ra có là gì đi nữa.

Ô, còn một điều nữa cần nhớm kiểm tra! Nếu bạn muốn biết các thực thể của một lớp là gì, chỉ cần gõ vào :info YourTypeClass trong GHCI. Vì vậy, việc gõ :info Num sẽ cho thấy những hàm mà lớp đã định nghĩa và nó sẽ cho bạn một danh sách các kiểu trong lớp. :info cũng hoạt động được với các kiểu và constructor kiểu. Nếu bạn gõ vào :info Maybe, GHCI sẽ cho bạn thấy tất cả những lớp mà Maybe là một thực thể trong đó. Ngoài ra, :info còn cho bạn thấy lời khai báo kiểu của một hàm. Tôi nghĩ rằng nó rất hay.

Một lớp yes-no

yesno

Trong JavaScript và một số ngôn ngữ kiểu yếu khác, gần như là bạn có thể đặt bất kì thứ gì vào trong một biểu thức if. Chẳng hạn, bạn có thể viết tất cả những mã lệnh sau: if (0) alert("YEAH!") else alert("NO!"), if ("") alert ("YEAH!") else alert("NO!"), if (false) alert("YEAH") else alert("NO!), v.v. và chúng đều phát ra thông báo NO!. Nếu bạn viết if ("WHAT") alert ("YEAH") else alert("NO!"), thì nó sẽ phát thông báo "YEAH!" vì JavaScript coi rằng chuỗi không rỗng sẽ có djang một giá trị đúng (true).

Dù việc chỉ dùng Bool trong ngữ cảnh cần biểu thức logic sẽ tốt hơn đối với Haskell, ta hãy thử lập theo hành vi này của JavaScript. Để cho vui thôi! Ta hãy bắt đàu bằng một lời khai báo lớp.

class YesNo a where
    yesno :: a -> Bool

Thật đơn giản. Lớp YesNo chỉ định nghĩa một hàm. Hàm này nhận vào một giá trị có kiểu được coi là mang khái niệm đúng-sai và báo cho ta biết cụ thể là giá trị này đúng hay sai. Có thể nhận thấy rằng, từ cách dùng a trong hàm, thì a phải là một kiểu cụ thể.

Tiếp theo, ta hãy định nghĩa một vài thực thể. Đối với các con số, ta sẽ giả sử rằng (như ở JavaScript) một số khác 0 bất kì là đúng và 0 là sai.

instance YesNo Int where
    yesno 0 = False
    yesno _ = True

Các danh sách rỗng (và mở rộng ra là chuỗi) là giá trị sai, còn danh sách không rỗng là giá trị đúng.

instance YesNo [a] where
    yesno [] = False
    yesno _ = True

Lưu ý cách ta đặt chỉ một tham số kiểu a vào đó để làm cho danh sách trở thành kiểu cụ thể, mặc dù ta không giả định gì
về kiểu của các thành phần chứa trong danh sách. Còn gì nữa nhỉ, hừm, tôi biết rồi, bản thân Bool có giá trị đúng-sai và điều này thì hiển hiên.

instance YesNo Bool where
    yesno = id

Hử? Cái id là gì? Đó chỉ là một hàm trong thư viện chuẩn; hàm này nhận vào một tham số rồi trả lại chính thứ đó, vốn là thứ mà dù sao ta cũng viết vào đây.

Ta hãy làm cho Maybe a cũng là một thực thể.

instance YesNo (Maybe a) where
    yesno (Just _) = True
    yesno Nothing = False

Ta không cần đến một ràng buộc về lớp vfi ta không giả thiết gì về nội dung của Maybe. Ta chỉ nói rằng nó đúng nếu nó là một giá trị Just và sai nếu nó là Nothing. Ta vẫn sẽ phải viết rõ (Maybe a) thay vì chỉ Maybe vì nếu bạn thử nghĩ xem, một hàm Maybe -> Bool không thể tồn tại (vì Maybe không phải là kiểu cụ thể), còn Maybe a -> Bool thì lại được. Dù vậy thì vẫn rất hay bởi bây giờ, bất kì kiểu nào có dạng Maybe something đều thuộc về YesNo và không phụ thuộc vào something đó là cái gì.

Trước đây, ta có định nghĩa một kiểu Tree a biểu diễn cho cây tìm kiếm nhị phân. Ta có thể nói rằng một cây rỗng là “sai” và bất kì cây nào không rỗng đều “đúng”.

instance YesNo (Tree a) where
    yesno EmptyTree = False
    yesno _ = True

Liệu một ngọn đèn giao thông có tương ứng với giá trị đúng hoặc sai không? Được chứ. Nếu đèn đỏ, bạn dừng. Nếu đèn xanh, bạn đi. Còn đèn vàng? À, với tôi thì thường là phóng tiếp vì tôi thuộc kiểu người thích adrenaline.

instance YesNo TrafficLight where
    yesno Red = False
    yesno _ = True

Được rồi, bây giờ ta khi đã có một vài thực thể, ta hãy nghịch chơi!

ghci> yesno $ length []
False
ghci> yesno "haha"
True
ghci> yesno ""
False
ghci> yesno $ Just 0
True
ghci> yesno True
True
ghci> yesno EmptyTree
False
ghci> yesno []
False
ghci> yesno [0,0,0]
True
ghci> :t yesno
yesno :: (YesNo a) => a -> Bool

Được rồi, nó đã hoạt động! Ta hãy lập một hàm phỏng theo câu lệnh if, nhưng hoạt động với các giá trị YesNo.

yesnoIf :: (YesNo y) => y -> a -> a -> a
yesnoIf yesnoVal yesResult noResult = if yesno yesnoVal then yesResult else noResult

Khá là dễ. Nó nhận vào một giá trị kiểu đúng-sai và hai thứ. Nếu cái đúng-sai kia nghiêng về đúng thì nó trả lại cái thứ nhất, còn không thì trả lại cái thứ hai.

ghci> yesnoIf [] "YEAH!" "NO!"
"NO!"
ghci> yesnoIf [2,3,4] "YEAH!" "NO!"
"YEAH!"
ghci> yesnoIf True "YEAH!" "NO!"
"YEAH!"
ghci> yesnoIf (Just 500) "YEAH!" "NO!"
"YEAH!"
ghci> yesnoIf Nothing "YEAH!" "NO!"
"NO!"

Lớp Functor

Đến giờ, ta đã bắt gặp nhiều lớp trong thư viện chuẩn. Ta đã nghịch với Ord, được dùng với những thứ xếp thứ tự được. Ta đã nghịch với Eq, vốn dành cho những thứ so sánh ngang bằng được. Ta đã thấy Show, vốn biểu diễn một giao diện cho những kiểu có thể hiển thị được dưới dạng chuỗi. Người bạn tốt của ta, Read luôn có mặt mỗi khi ta cần chuyển đổi một chuỗi sang thành giá trị thuộc một kiểu gì đó. Và bây giờ, ta sắp sửa xét đến lớp Functor, về cơ bản vốn là dành cho những thứ có thể được ánh xạ trên đó. Có lẽ bạn sẽ nghĩ về danh sách, vì ánh xạ trên danh sách là “tuyệt chiêu” của Haskell. Và bạn đã đúng, kiểu danh sách thuộc về lớp Functor.

Để tìm hiểu về lớp Functor thì còn gì hay hơn là xem nó được thiết lập thế nào? Ta hãy thử ghé xem qua.

class Functor f where
    fmap :: (a -> b) -> f a -> f b

I AM FUNCTOOOOR!!!

Được rồi. Ta thấy rằng nó định nghĩa một hàm, fmap, và không cho ta bất kì cách thực hiện mặc định nào cho nó. Kiểu của fmap rất thú vị. Trong số các định nghĩa lớp ta gặp đến giờ, biến kiểu đóng vai trò là kiểu trong lớp luôn là kiểu cụ thể, như a trong (==) :: (Eq a) => a -> a -> Bool. Nhưng giờ đây, f không phải là một kiểu cụ thể (một kiểu mà giá trị có thể nắm giữ, như Int, Bool hoặc Maybe String), mà là một constructor kiểu nhận vào một tham số kiểu. Một ví dụ nhanh chóng để ôn lại:Maybe Int là một kiểu cụ thể, còn Maybe là một constructor kiểu chỉ nhận vào một kiểu với vai trò tham số. Dù sao thì ta cũng thấy rằng fmap nhận vào một hàm từ một kiểu này sang một kiểu khác và một functor áp dụng với một kiểu rồi trả lại một functor được áp dụng với một kiểu khác.

Đừng lo nếu diều này nghe có vẻ dễ nhầm lẫn. Tất cả sẽ sớm được sáng tỏ khi ta điểm qua một vài ví dụ. Hừm, lời khai báo kiểu vừa rồi với fmap gợi cho tôi nhớ về điều gì đó. Dấu ấn kiểu của mapmap :: (a -> b) -> [a] -> [b], trong trường hợp bạn không biết.

A, hay đấy! Nó nhận vào một hàm từ một kiểu này sang một kiểu khác và một danh sách chứa một kiểu rồi trả lại một danh sách chứa kiểu khác. Bạn ơi, tôi nghĩ rằng ta đã có một functor! Thực ra, map chỉ là một fmap vốn chỉ làm việc trên danh sách. Đây là cách trong đó danh sách đóng vai trò thực thể của lớp Functor.

instance Functor [] where
    fmap = map

Như vậy đó! Lưu ý rằng ta đã không viết instance Functor [a] where, vì từ fmap :: (a -> b) -> f a -> f b, ta thấy được rằng f phải là một constructor kiểu, nhận vào một kiểu. [a] đã sẵn là một kiểu cụ thể (của một danh sách với kiểu bất kì trong đó), còn [] là một constructor kiểu nhận vào một kiểu và tạo ra các kiểu dữ liệu như [Int], [String] hoặc thậm chí cả [[String]].

Bởi vì đối với danh sách, fmap chỉ đơn thuần là map nên ta nhận được kết quả hệt như vậy khi dùng chúng với danh sách.

map :: (a -> b) -> [a] -> [b]
ghci> fmap (*2) [1..3]
[2,4,6]
ghci> map (*2) [1..3]
[2,4,6]

Điều gì sẽ xảy ra khi ta map hoặc fmap lên một danh sách rỗng? Ồ, dĩ nhiên là ta thu được danh sách rỗng. Chỉ có điều là danh sách rỗng có kiểu [a] biến thành danh sách rỗng có kiểu [b].

Những kiểu dữ liệu nào đóng vai trò như cái hộp thì có thể thành functor. Bạn có thể hình dung một danh sách như một cái hộp có vô vàn những ngăn nhỏ và chúng có thể tất cả đều trống rỗng, hoặc là một ngăn đầy còn lại rỗng, hoặc là nhiều ngăn đầy. Như vậy, có những thuộc tinh nào khác ở một cái hộp như vậy? Một thuộc tính là kiểu Maybe a. Ở một chừng mực nào đó, cũng giống như một cái hộp có thể không chứa gì, tương ứng với giá trị Nothing, hoặc chỉ chứa một thứ, như "HAHA", tương ứng với giá trị Just "HAHA". Sau đây là cách mà Maybe đóng vai trò functor.

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

Một lần nữa, hãy chú ý cách ta viết instance Functor Maybe where thay vì instance Functor (Maybe m) where, cũng như ta đã làm với MaybeYesNo. Functor cần một constructor kiểu, constructor này nhận vào một kiểu chứ không phải kiểu cụ thể. Nếu bạn thử tưởng tượng việc thay thế các f với các Maybe thì fmap sẽ đóng vai trò của (a -> b) -> Maybe a -> Maybe b ở kiểu cụ thể này; và cách này có vẻ ổn. Nhưng nếu bạn thay thế f bằng (Maybe m) thì dường như nó sẽ đóng vai trò của (a -> b) -> Maybe m a -> Maybe m b, tức là chẳng có ý nghĩa gì bởi Maybe chỉ nhận duy nhất một tham số kiểu.

Dù sao đi nữa, cách thiết lập fmap là khá đơn giản. Nếu nó là một giá trị rỗng: Nothing, thì chỉ cần trả lại một Nothing. Nếu ta ánh xạ trên một hộp rỗng thì ta thu được một hộp rỗng. Có lý. Cũng như nếu ta ánh xạ trên một danh sách rỗng, ta sẽ nhận được danh sách rỗng. Nếu nó không phải một giá trị rỗng, mà là một giá trị đơn lẻ đặt trong Just, thì ta áp dụng hàm kèm theo đối với nội dung của Just.

ghci> fmap (++ " HEY GUYS IM INSIDE THE JUST") (Just "Something serious.")
Just "Something serious. HEY GUYS IM INSIDE THE JUST"
ghci> fmap (++ " HEY GUYS IM INSIDE THE JUST") Nothing
Nothing
ghci> fmap (*2) (Just 200)
Just 400
ghci> fmap (*2) Nothing
Nothing

Một thứ khác có thể được đặt qua ánh xạ và tạo một thực thể Functor là kiểu Tree a mà ta đã lập. Nó có thể được hình dung như một cái hộp (theo khía cạnh có thể giữ nhiều giá trị hoặc không giữ thứ gì) và constructor kiểu Tree nhận vào đúng một tham số kiểu. Nếu bạn xét fmap như thể nó là một hàm để dành riêng cho Tree thì dấu ấn kiểu của nó sẽ là (a -> b) -> Tree a -> Tree b. Ta sẽ dùng phép đệ quy đối với kiểu dữ liệu này. Việc ánh xạ trên một cây rỗng sẽ tạo ra một cây rỗng. Ánh xạ trên cây không rỗng sẽ tạo ra một cây trong đó hàm kèm theo sẽ áp dụng cho giá trị ở gốc cây, còn các cây con trái và cây con phải vẫn là những cây con trước đây, chỉ khác là sẽ được hàm ánh xạ lên chúng.

instance Functor Tree where
    fmap f EmptyTree = EmptyTree
    fmap f (Node x leftsub rightsub) = Node (f x) (fmap f leftsub) (fmap f rightsub)
ghci> fmap (*2) EmptyTree
EmptyTree
ghci> fmap (*4) (foldr treeInsert EmptyTree [5,7,3,2,1,7])
Node 28 (Node 4 EmptyTree (Node 8 EmptyTree (Node 12 EmptyTree (Node 20 EmptyTree EmptyTree)))) EmptyTree

Hay thật! Thế còn Either a b thì sao? Có thể cho nó thành functor được không? Lớp Functor cần một constructor kiểu chỉ nhận vào một tham số kiểu nhưng Either lại nhận hai tham số. Hừm! Tôi biết rồi, chúng ta sẽ áp dụng Either từng phần bằng cách chỉ cấp cho nó một tham số để nó còn một tham số tự do. Đây là cách làm cho Either a là một functor trong thư viện chuẩn:

instance Functor (Either a) where
    fmap f (Right x) = Right (f x)
    fmap f (Left x) = Left x

Nào nào, chúng ta đã làm gì ở đây vậy? Bạn có thể thấy cách làm cho Either a thành một thực thể thay vì chỉ là Either. Đó là vì Either a là một constructor kiểu nhận vào một tham số, trong khi Either thì nhận vào hai tham số. Nếu fmap được dành riêng cho Either a thì dấu ấn kiểu đã là (b -> c) -> Either a b -> Either a c vì như vậy thì gống với (b -> c) -> (Either a) b -> (Either a) c. Trong đoạn mã lệnh thiết lập trên, ta đã ánh xạ trong trường hợp có constructor kiểu là Right, nhưng lại không ánh xạ trong trường hợp Left. Tại sao vậy? À, nếu nhìn trở lại cách định nghĩa Either a b, ta sẽ thấy nó có dạng:

data Either a b = Left a | Right b

Như vậy, nếu ta muốn ánh xạ một hàm lên cả hai vế, thì ab phải có cùng kiểu. Ý của tôi là nếu ta thử ánh xạ một hàm có nhận vào một chuỗi và trả lại một chuỗi, trong khi b là chuỗi mà a lại là con số thì sẽ không có tác dụng. Hơn nữa, từ việc thấy được kiểu của fmap sẽ là gì khi nó chỉ hoạt động với các giá trị Either, ta thấy rằng tham số thứ nhất phải giữ nguyên còn tham số thứ hai thay đổi được; và tham số thứ nhất thực tế là constructor giá trị Left.

Điều này rất hợp với cách so sánh “cái hộp” nếu ta hình dung phần Left có dạng như một hộp rỗng với một lời thông báo lỗi ghi bên cạnh để cho ta biết tại sao nó lại rỗng.

Các ánh xạ tạo bởi Data.Map cũng có thể được thành functor vì chúng giữ giá trị (hoặc không giữ thứ gì!). Trong trường hợp Map k v, fmapsẽ ánh xạ một hàm v -> v' trên một phép ánh xạ kiểu Map k v rồi trả lại một ánh xạ kiểu Map k v'.

Lưu ý rằng dấu ' không có ý nghĩa gì đặc biệt đối với kiểu, tương tự như khi ta đặt tên các giá trị. Dấu này thường dùng để chỉ những thứ gần giống nhau, chỉ hơn thay đổi chút ít.

Hãy cố gắng tự hình dung xem bằng cách nào mà ta có thể làm cho Map k trở thành thực thể của Functor!

Với lớp Functor, ta đã thấy bằng cách nào mà các lớp có thể biểu diễn khá hay những khái niệm cấp cao. Ta cũng đã thực hành thêm một chút với các kiểu dữ liệu áp dụng từng phần và với cách tạo thực thể. Trong một chương sách sau này, ta cũng sẽ xét đến một vài định luật áp dụng cho functor.

Còn một điều nữa! Functor cần tuân theo một số định luật để chúng được phép có những thuộc tính mà ta có thể dựa vào mà không phải nghĩ nhiều. Nếu dùng fmap (+1) đối với danh sách [1,2,3,4], ta sẽ dự tính kết quả là [2,3,4,5] chứ không phải ngược lại, [5,4,3,2]. Nếu ta dùng fmap (\a -> a) (hàm đồng nhất, trả lại đúng các tham số nhận vào) đối với một danh sách nào đó, ta sẽ dự định nhận về đúng danh sách đó. Chẳng hạn, nếu ta đưa nhầm thực thể functor cho kiểu Tree, dùng fmap đối với một cây trong đó cây con bên trái của một điểm nút chỉ có những phần tử nhỏ hơn nút và cây con bên phải điểm nút chỉ có những phần tử lớn hơn điểm nút đó thì có thể sẽ tạo nên một cây mà tính chất này không còn đảm bảo nữa. Trong chương sau này của quyển sách ta sẽ đề cập chi tiết đến các định luật áp dụng cho functor.

Dạng và kiểu kiếc nào đó

TYPE FOO MASTER

Constructor kiểu nhận vào tham số là những kiểu khác để rồi tạo ra kiểu cụ thể. Điều này làm tôi nhớ đến các hàm, chúng nhận vào tham số là những giá trị để tạo ra giá trị. Ta đã thấy rằng constructor kiểu có thể áp dụng được từng phần (Either String là một kiểu, nó nhận vào một kiểu và tạo ra một kiểu cụ thể, như Either String Int), giống như hàm. Điều đó thật là hay. Trong mục này, ta sẽ xét định nghĩa chính thức cách áp dụng kiểu cho constructor kiểu, cũng như ta xét định nghĩa chính thức cách áp dụng giá trị cho hàm bằng cách khai báo kiểu. Bạn không cần phải đọc mục này mới tiếp tục chinh phục được Haskell và nếu bạn không hiểu nó thì đừng lo. Tuy nhiên, nắm được vấn đề này sẽ cho bạn hiểu biết tường tận về hệ thống kiểu.

Vì vậy những giá trị như 3, "YEAH" hoặc takeWhile (các hàm cũng là giá trị, vì ta có thể truyền chúng thoải mái) mỗi cái đều có kiểu riêng. Kiểu là những nhãn hiệu nhỏ nhắn mà mỗi giá trị mang theo để dựa vào đó ta có thể suy luận về giá trị của chúng. Nhưng bản thân kiểu lại còn có những nhãn hiệu nhỏ của riêng chúng, gọi là dạng (kind). Một dạng thì ít nhiều giống như kiểu của kiểu. Điều này nghe hơi kì quặc và dễ gây nhầm lẫn, song thực ra đó là một khái niệm rất hay.

Dạng là gì và chúng dùng được vào việc gì? À, ta hãy xét dạng của một kiểu bằng cách dùng lệnh :k trong GHCI.

ghci> :k Int
Int :: *

Một dấu sao ư? Kì thật. Nó nghĩa là gì? Dấu * nghĩa là kiểu dữ liệu là một kiểu cụ thể. Một kiểu cụ thể là kiểu mà nó không nhận bất kì tham số kiểu nào và giá trị chỉ có thể có kiểu nằm trong các kiểu cụ thể. Nếu tôi buộc phải đọc hẳn dấu * ra (việc mà đến giờ tôi chưa làm), tôi sẽ đọc là sao hoặc đơn giản chỉ là kiểu.

Được rồi, bây giờ hãy xem dạng của Maybe là gì.

ghci> :k Maybe
Maybe :: * -> *

Constructor kiểu Maybe nhận vào một kiểu cụ thể (chẳng hạn, Int) rồi trả lại một kiểu cụ thể, chẳng hạn Maybe Int. Và đó chính là điều mà dạng này cho ta biết. Cũng như Int -> Int có nghĩa là một hàm nhận vào một Int và trả lại một Int, * -> * có nghĩa là constructor kiểu nhận vào một kiểu cụ thể và trả lại một kiểu cụ thể. Ta hãy áp dụng tham số kiểu cho Maybe và để xem dạng của kiểu đó là gì.

ghci> :k Maybe Int
Maybe Int :: *

Đúng như tôi dự đoán! Chúng ta áp dụng tham số kiểu cho Maybe và thu lại một kiểu cụ thể (đó là điều mà * -> * muốn nó). Một cách so sánh (mặc dù không tương đương, vì kiểu và dạng là khác nhau) với điều này là nếu ta viết :t isUpper:t isUpper 'A'. isUpper có kiểu là Char -> BoolisUpper 'A' có kiểu là Bool, vì giá trị của nó về cơ bản là True. Tuy vậy cả hai kiểu nó trên đều có dạng là *.

Ta đã dùng :k đối với một kiểu để thu được dạng của nó, cũng như khi ta dùng :t đối với một giá trị để thu được kiểu của nó. Như đã nói, kiểu là các nhãn hiệu gắn lên giá trị còn dạng là các nhãn hiệu gắn lên kiểu và có sự tương đồng giữa hai thứ.

Ta hãy xem một dạng khác.

ghci> :k Either
Either :: * -> * -> *

À ha, điều này cho ta biết Either nhận vào hai kiểu cụ thể làm tham số kiểu, để tạo ra một kiểu cụ thể. Nó cũng trông giống như một lời khai báo kiểu của một hàm nhận vào hai giá trị và trả lại một thứ gì đó. Các constructor kiểu được curry (cũng như đối với hàm), vì vậy ta có thể áp dụng chúng từng phần.

ghci> :k Either String
Either String :: * -> *
ghci> :k Either String Int
Either String Int :: *

Khi ta muốn làm cho Either thuộc về lớp Functor, ta phải áp dụng nó từng phần vì Functormuốn kiểu chỉ nhận một tham số trong khi Either thì nhận hai. Nói cách khác, Functor muốn dạng * -> * và vì vậy ta phải áp dụng Either từng phần để thu được một kiểu có dạng * -> * thay vì kiểu ban đầu của nó, * -> * -> *. Nếu ta nhìn vào định nghĩa của Functor lần nữa

class Functor f where 
    fmap :: (a -> b) -> f a -> f b

có thể thấy rằng biến kiểu f được dùng như là kiểu nhận vào một kiểu cụ thể và tạo ra một kiểu cụ thể. Ta biết rằng nó phải tạo ra một kiểu cụ thể vì nó được dùng làm kiểu của một giá trị trong hàm. Và từ đó, ta có thể suy diễn rằng những kiểu nào muốn là bạn với Functor thì phải có dạng * -> *.

Bây giờ, ta hãy viết một kiểu linh tinh. Hãy nhìn vào lớp sau, mà tôi sắp chuẩn bị thiết lập:

class Tofu t where
    tofu :: j a -> t a j

Chà, trông có vẻ lạ. Bằng cách nào mà ta có thể tạo ra một kiểu là thực thể của lớp kì lạ này? À, hãy nhìn xem dạng của nó phải là gì. Bởi j a được dùng làm kiểu của một giá trị mà hàm tofu nhận vào làm tham số, nên j a phải có dạng là *. Ta giả sử rằng a có dạng * và từ đó ta có thể suy luận rằng j phải có dạng * -> *. Ta thấy rằng t cũng phải tạo ra một giá trị cụ thể và rằng nó phải nhận hai kiểu. Đồng thời biết rằng a có dạng *j có dạng * -> *, ta suy ra rằng t phải có dạng * -> (* -> *) -> *. Vì vậy nó nhận một kiểu cụ thể (a), một constructor kiểu nhận vào một kiểu cụ thể (j) rồi tạo ra một kiểu cụ thể. Ôi.

Được rồi, giờ ta hãy tạo một kiểu có dạng là * -> (* -> *) -> *. Sau đây là một cách giải.

data Frank a b  = Frank {frankField :: b a} deriving (Show)

Bằng cách nào mà ta biết được rằng kiểu này có dạng * -> (* -> *) - > *? À, các trường trong kiểu trừu tượng (ADT) sinh ra là để giữ các giá trị, vì vậy hiển nhiên là chúng phải có dạng *. Ta giả sử a có dạng *, điều này có nghĩa là b nhận vào một tham số kiểu và vì vậy dạng của nó là * -> *. Bây giờ khi đã biết dạng của cả hai ab, và bởi vì chúng là các tham số cho Frank, ta thấy rằng Frank có dạng * -> (* -> *) -> * Dấu * thứ nhất biểu diễn cho a còn (* -> *) biểu diễn cho b. Ta hãy tạo một số giá trị Frank rồi kiểm tra kiểu của chúng.

ghci> :t Frank {frankField = Just "HAHA"}
Frank {frankField = Just "HAHA"} :: Frank [Char] Maybe
ghci> :t Frank {frankField = Node 'a' EmptyTree EmptyTree}
Frank {frankField = Node 'a' EmptyTree EmptyTree} :: Frank Char Tree
ghci> :t Frank {frankField = "YES"}
Frank {frankField = "YES"} :: Frank Char []

Hừm. Bởi vì frankField có kiểu theo hình thức là a b, nên những giá trị của nó phải có kiểu cũng với hình thức tương tự. Vì vậy chúng có thể là Just "HAHA", vốn có kiểu Maybe [Char] hoặc nó có thể có giá trị ['Y','E','S'], vốn có kiểu [Char] (còn nếu ta dùng kiểu danh sách tự lập ra, thì sẽ là List Char). Và ta thấy rằng kiểu của các giá trị Frank tương ứng với dạng của Frank. [Char] có dạng là * còn Maybe có dạng là * -> *. Vì để có được một giá trị, nó phải là một kiểu cụ thể và vì vậy phải được áp dụng đầy đủ, mỗi giá trị của Frank blah blaah đều có dạng *.

Việc làm cho Frank trở thành thực thể của Tofu là rất đơn giản. Ta thấy rằng tofu nhận vào một j a (một kiểu ví dụ là Maybe Int) rồi trả lại một t a j. Vì vậy nếu ta thay thế Frank bằng j, kiểu của kết quả sẽ là Frank Int Maybe.

instance Tofu Frank where
    tofu x = Frank x
ghci> tofu (Just 'a') :: Frank Char Maybe
Frank {frankField = Just 'a'}
ghci> tofu ["HELLO"] :: Frank [Char] []
Frank {frankField = ["HELLO"]}

Không có ích lắm, nhưng ta đã cho thấy sức mạnh của kiểu mới lập được. Ta hãy lập thêm một kiểu linh tinh khác. Ta có kiểu dữ liệu này:

data Barry t k p = Barry { yabba :: p, dabba :: t k }

Và bây giờ ta muốn làm cho nó trở thành thực thể của Functor. Functor cần các kiểu có dạng * -> * nhưng dường như Barry không có dạng này. Dạng của Barry là gì? À, ta thấy nó nhận vào ba tham số kiểu, vì vậy nó sẽ là something -> something -> something -> *. An toàn nhất là nói rằng p là một kiểu cụ thể và do đó có dạng *. Đối với k, ta giả định là nó có dạng * và suy ra, t có dạng * -> *. Bây giờ hãy thay thế các dạng này vào chỗ các something mà ta đã tạm viết ở trên; ta thấy được rằng nó có dạng là (* -> *) -> * -> * -> *. Hãy dùng GHCI để kiểm tra.

ghci> :k Barry
Barry :: (* -> *) -> * -> * -> *

À, ta đã nhận định đúng. Thật là thỏa mãn. Bây giờ, để khiến cho kiểu này thuộc về Functor ta phải áp dụng một cách từng phần hai tham số kiểu đầu để cho còn lại * -> *. Điều đó có nghĩa rằng chỗ bắt đầu khai báo thực thể sẽ là: instance Functor (Barry a b) where. Nếu ta coi fmap như thể nó được tạo riêng dành cho Barry, thì nó sẽ có kiểu là fmap :: (a -> b) -> Barry c d a -> Barry c d b, vì ta chỉ thay thế f của Functor bằng Barry c d. Tham số kiểu thứ ba từ Barry sẽ phải thay đổi và ta thấy rằng nó thuận tiện như ở nhà.

instance Functor (Barry a b) where
    fmap f (Barry {yabba = x, dabba = y}) = Barry {yabba = f x, dabba = y}

Được rồi đấy! Ta vừa mới ánh xạ f lên trường thứ nhất.

Trong mục này, ta đã xét kĩ về cách hoạt động của tham số kiểu và phần nào đã giải thích về mặt hình thức cùng với khái niệm “dạng”, như ta đã hình thức hóa tham số hàm với các lời khai báo hàm. Tuy nhiên, chúng là hai thứ hoàn toàn khác nhau. Thực tế, khi làm việc với Haskell, bạn chẳng cần bận tâm với kiểu và suy diễn kiểu một cách thủ công như ta đã làm. Thông thường, bạn chỉ cần áp dụng từng phần kiểu dữ liệu của bạn cho * -> * hoặc * khi làm cho nó thành một thực thể của một trong các lớp chuẩn; nhưng biết được nguyên nhân và cách thức hoạt động như vậy là một điều hay. Cũng thú vị khi thấy rằng bản thân kiểu lại có kiểu nhỏ hơn. Một lần nữa, bạn không cần phải hiểu hết toàn bộ nội dung ở đây trước khi tiếp tục; song nếu bạn đã hiểu cơ chế hoạt động của các dạng thì dường như bạn đã nắm vững hệ thống kiểu của Haskell.

3 phản hồi

Filed under Haskell

3 responses to “Chương 8: Tự lập nên các kiểu và lớp riêng

  1. Pingback: Tự học lấy Haskell | Blog của Chiến

  2. Pingback: Chương 9: Đầu vào và đầu ra | Blog của Chiến

  3. Pingback: Chương 11: Functor, Functor áp dụng và Monoid | Blog của Chiến

Gửi phản hồi

Mời bạn điền thông tin vào ô dưới đây hoặc kích vào một biểu tượng để đăng nhập:

WordPress.com Logo

Bạn đang bình luận bằng tài khoản WordPress.com Log Out / Thay đổi )

Twitter picture

Bạn đang bình luận bằng tài khoản Twitter Log Out / Thay đổi )

Facebook photo

Bạn đang bình luận bằng tài khoản Facebook Log Out / Thay đổi )

Google+ photo

Bạn đang bình luận bằng tài khoản Google+ Log Out / Thay đổi )

Connecting to %s