Haskell #9: Kế thừa & Đồng kiểu
Haskell #8: Record & Type Paramater
Kế thừa
Trong phần trước, tôi đã trình bày những điều cơ bản về typeclass. Tôi có giải thích rằng typeclass là interface định nghĩa một hành vi nào đó. Một kiểu dữ liệu có thể kế thừa typeclass nếu như typeclass này định nghĩa hành vi của kiểu dữ liệu. Lấy ví dụ: kiểu Int là kế thừa của typeclass Eq vì typeclass Eq có định nghĩa hành vi so sánh ngang bằng được. Và vì số nguyên có thể so sánh ngang bằng nên Int là thuộc typeclass Eq. Lợi ích thực sự đến từ các hàm tạo nên interface cho Eq, cụ thể là == và /=. Nếu một kiểu dữ liệu thuộc typeclass Eq, ta có thể dùng hàm ==. Điều này lý giải tại sao những biểu thức như 4 == 4 và “foo” /= “bar” lại kiểm tra về kiểu.
Tôi cũng nhắc lại rằng typeclass chứa kiểu thường hay bị nhầm với typeclass trong OOP, vốn từng làm rối nhiều người. Ở OOP, typeclass là “bí kíp” để tạo ra các đối tượng chứa trạng thái, có thể kèm theo hành động như bản năng. typeclass chứa kiểu thì giống interface hơn. Ta không thể tạo ra dữ liệu từ typeclass chứa kiểu (để cho gọn tôi gọi chúng là typeclass). 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ó so sánh ngang bằng được, ta sẽ cho kế thừa từ typeclass Eq. Nếu nó có thể sắp xếp, ta sẽ cho kế thừa từ typeclass Ord. Trong mục kế tiếp, ta sẽ xem cách tạo ra chính các typeclass, bằng cách code những hàm định nghĩa typeclass. Nhưng ngay bây giờ, hãy xem Haskell có thể tự động tạo ra kiểu nào trong danh sách sau đây: Eq, Ord, Enum, Bounded, Show, Read.
Haskell có thể suy diễn 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 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ờ, hãy 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 trên lại thuộc về typeclass Eq. Ta sử dụng kế thừa như sau.
data Person = Person {
firstName :: String,
lastName :: String,
age :: Int
} deriving Eq
Khi ta kế thừa Eq cho một kiểu và rồi thử so sánh hai giá trị thuộc kiểu đó bằng phép == hoặc /=, Haskell sẽ xem rằng constructor value có khớp không (mặc dù trong trường hợp hiện tại chỉ có một constructor value) và sau đó kiểm tra từng cặp trường bằng phép ==. Tuy vậy, có một điều kiện cần lưu ý: “Kiểu của tất cả các trường cũng phải thuộc typeclass Eq”. Song vì String và Int đều thỏa mãn nên mọi việc sẽ ổn. Ta hãy kiểm tra thể hiện của Eq.
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ó ràng buộc typeclass Eq a
trong type signature của chúng, chẳng hạn như elem.
ghci>:t elem
elem :: (Foldable t, Eq a) => a -> t a -> Bool
ghci> let beastieBoys = [mca, adRock, mikeD]
ghci> mikeD `elem` beastieBoys
True
Typeclass Show và Read lần lượt dùng cho những kiểu có thể chuyển thành chuỗi hoặc ngược lại. Cũng như Eq, nếu một kiểu có constructor chứa các trường, thì các trường này phải thuộc Show hoặc Read nếu ta muốn kiểu của ta kế thừa các typeclass đó.
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}"
Nếu thử in thông tin về Person lên màn hình trước khi kế thừa Show, Haskell hẳn đã “gào rú” rằng nó không biết làm thế nào để biểu diễn thông tin về Person dưới dạng String. Nhưng khi ta kế thừa Show, thì Haskell đã hiểu.
Read là typeclass ngược với Show. Show được dùng để chuyển giá trị từ kiểu dữ liệu ta định nghĩa sang String, Read thì dùng để chuyển từ String sang kiểu dữ liệu ta định nghĩa. 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 ta muốn lấy kết quả kiểu gì. Nếu ta không ghi rõ, Haskell sẽ đần ra.
ghci> read "Person {firstName =\"Michael\", lastName =\"Diamond\", age = 43}" :: Person
Person {firstName = "Michael", lastName = "Diamond", age = 43}
Nếu ta dùng kết quả của hàm read theo cách Haskell suy diễn thì không phải code chú thích về kiểu nữa.
ghci> read "Person {firstName =\"Michael\", lastName =\"Diamond\", age = 43}" == mikeD
True
Ta cũng có thể read các kiểu được tham số hóa, nhưng phải điền tham số kiểu. Vì vậy ta không thể code read "Just 't'" :: Maybe a
, nhưng lại có thể code read "Just 't'" :: Maybe Char
.
Ta có thể kế thừa typeclass Ord. Nếu ta so sánh hai giá trị cùng một kiểu nhưng đượ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 là nhỏ hơn.
Chẳng hạn, hãy xét kiểu Bool, có thể có giá trị là False hoặc True. Nhằm mục đích xét hành vi của kiểu dữ liệu này khi được so sánh, ta có thể hình dung kiểu Bool được định nghĩa như sau:
data Bool = False | True deriving (Ord)
Vì constructor value False được chỉ định trước và constructor value True sau, nên ta có thể coi 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 value Nothing được chỉ định trước constructor value Just, vì vậy một giá trị Nothing luôn nhỏ hơn một giá trị Just something, ngay cả khi something là -1000. Nhưng khi so sánh hai giá trị Just, thì Haskell sẽ quy về việc so sánh bên trong hai giá trị đó.
ghci> Nothing < Just 100
True
ghci> Nothing < Just (-49999)
True
ghci> Just 3 `compare` Just 2
GT
ghci> Just 100 > Just 50
True
Lưu ý rằng chúng ta không thể so sánh kiểu Just (*3) > Just (*2)
, vì (*3)
và (*2)
là hàm, và không được kế thừa typeclass Ord.
Ta có thể dùng kiểu dữ liệu đại số để thực hiện việc liệt kê, typeclass Enum và Bounded 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 value đều không nhận tham số (tức là trường) nên ta có thể cho nó thuộc về typeclass Enum. typeclass 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ề typeclass Bounded (kiểu có biên trên và biên dưới). Xong việc.
data Day = Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday deriving (Eq, Ord, Show, Read, Bounded, Enum)
Vì kiểu này nằm trong typeclass Show và Read nên ta có thể chuyển những giá trị thuộc kiểu này sang String và ngược lại.
ghci> Wednesday
Wednesday
ghci> show Wednesday
"Wednesday"
ghci> read "Saturday" :: Day
Saturday
Vì Day
nằm trong typeclass Eq và Ord, 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ó nằm trong typeclass 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ó thuộc 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 List 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. Nai xừ
Đồng Kiểu
Trước đây, tôi có nói rằng khi [Char] và String là hai kiểu tương đương và có thể thay thế nhau. Điều này được thực hiện bằng đồng kiểu. Đồng kiểu thực ra chẳng là gì, chúng chỉ tên gọi khác nhau cho cùng một kiểu dữ liệu, mục đích là tạo ý nghĩa đối với người đọc code và document. Sau đây là cách mà thư viện chuẩn định nghĩa String là đồng kiểu với [Char].
type String = [Char]
Tôi đã giới thiệu từ khóa type. Từ khóa này có thể khiến 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 đồng kiểu với kiểu dữ liệu sẵn có.
Nếu ta code một hàm chuyển String sang dạng in hoa và gọi nó là toUpperString hoặc gì đó, ta có thể viết type signature như sau: toUpperString :: [Char] -> [Char]
hay toUpperString :: String -> String
. Cả hai cách đều như nhau, cách thứ hai chỉ dễ đọc hơn.
Khi thao tác với module Data.Map
, đầu tiên ta định nghĩa danh bạ điện thoại bằng List tuple (String, String)
trước khi chuyển nó thành map. Như đã phát hiện từ trước, List này chứa cặp key - value.
phoneBook :: [(String,String)]
phoneBook = [
("betty","555-2938")
,("bonnie","452-2928")
,("patsy","493-2928")]
Ta thấy rằng kiểu của phoneBook là [(String,String)]. Tức nó là một association List map từ String sang String, không hơn không kém. Hãy tạo ra đồng kiểu để có thêm thông tin trong type signature.
type PhoneBook = [(String,String)]
Bây giờ thì kiểu của danh bạ điện thoại là phoneBook :: PhoneBook
. Hãy tạo một đồng kiểu với String nữa.
PhoneNumber = String
Name = String
PhoneBook = [(Name,PhoneNumber)]
Đồng kiểu với String là cách mà code khi bạn muốn truyền đạt thêm rằng String trong hàm được dùng với vai trò gì và chúng biểu diễn gì. Vì vậy bây giờ, khi ta code một hàm nhận vào Name và PhoneNumber rồi xem thử tổ hợp Name và PhoneNumber có nằm trong danh bạ không thì ta type signature rất đẹp 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 đồng kiểu thì hàm sẽ có kiểu String -> String -> [(String,String)] -> Bool
. Trong trường hợp này ưu thế của đồng kiểu sẽ dễ hiểu hơn. Tuy nhiên bạn không nên lạm dụng. Ta sử dụng đồng kiểu để miêu tả kiểu có sẵn nào đó biểu thị trong hàm (và vì vậy type signature trở thành 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 (như [(String,String)])
nhưng biểu thị cho thứ gì đó cụ thể hơn trong ngữ cảnh đang được sử dụng.
Đồng kiểu cũng có thể tham số hóa. Nếu ta muốn kiểu biểu diễn cho association List nhưng vẫn muốn nó tổng quát để key và value có thể thuộc bất kì kiểu nào, thì ta có thể code như sau:
type AssocList k v = [(k,v)]
Bây giờ, hàm nhận vào giá trị bằng key trong association List có thể mang kiểu (Eq k) => k -> AssocList k v -> Maybe v. AssocList là một type constructor 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.
Khi tôi nói về kiểu cụ thể, ý tôi là kiểu được áp dụng trọn vẹn như Map Int String
hoặc nếu ta xử lý với hàm đa hình, [a]
hoặc (Ord a) => Maybe a
. Và đôi khi Maybe là một kiểu, nhưng không có ý như vậy, bởi ai cũng biết Maybe là type constructor. Khi áp dụng kiểu phụ vào Maybe, như Maybe String
, thì ta có 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!
Cũng như việc ta có thể áp dụng từng phần để thu được hàm mới, ta có thể áp dụng tham số kiểu theo từng phần và thu được type constructor. Cũng như việc gọi hàm không đủ tham số nhằm tạo ra hàm mới, ta có thể tùy chỉnh type constructor thiếu tham số kiểu và nhận lại type constructor áp dụng từng phần. Nếu muốn một kiểu biểu thị cho map (từ Data.Map
) từ số nguyên đến một thứ nào đó, ta có thể code:
type IntMap v = Map Int v
hoặc:
type IntMap = Map Int
Hai cách trên sẽ giúp type constructor 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 thử thực thi đoạn trên, bạn có thể sẽ phải import qualified Data.Map
. Khi import qualified
, type constructor cũng phải có tên module đứng phía trước. Vì vậy bạn cần gõ type IntMap = Map.Map Int
.
Hãy chắc rằng bạn thật sự hiểu được khác biệt giữa type constructor và constructor value. Chỉ vì ta đã tạo nên đồng kiểu có tên IntMap hay AssocList thì điều đó không có nghĩa rằng ta có thể code kiểu AssocList [(1,2),(4,5),(7,9)]
. Tất cả việc tạo đồng kiểu 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ể code [(1,2),(3,5),(8,9)] :: AssocList Int Int
; code này sẽ làm cho các giá trị bên trong giả định rằng chúng nhận Int, nhưng ta vẫn có thể dùng List đó như List chứa các cặp số nguyên bình thường. Đồng kiểu (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 kiểu mới (bằng từ khóa data và type) hoặc khi được chỉ định sau dấu ::. Dấu :: xuất hiện trong 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à Either a b
. Nó được định nghĩa như sau:
data Either a b = Left a | Right b deriving (Eq, Ord, Read, Show)
Nó có hai constructor value. Nếu Left được dùng 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 Either để gói hai giá trị thuộc hai kiểu khác nhau sau đó dùng pattern matching 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ị kết quả tính toán thành hoặc bại. Nhưng đôi khi Maybe a
vẫn không đủ vì Nothing
thực sự chưa truyền đạt nhiều thông tin ngoài việc báo có gì không ổn. Điều này phù hợp với các hàm có thể chỉ bất ổn do duy nhất nguyên nhân nào đó, hoặc chỉ đơn giản là ta không quan tâm đến nguyên nhân thất bại. Hàm tìm kiếm trong Data.Map thất bại khi key không tồn tại trong map, vì vậy ta đã biết chắc điều gì đã xảy ra. Trường hợp phức tạp hơn, khi muốn biết lý do cụ thể để tìm cách khắc phục, hãy dùng kiểu Either a b
, trong đó a
là kiểu cho biết nguyên nhân thất bại còn b
là kết quả trả về thành công. Vì vậy, thông báo lỗi dùng đến constructor value 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 cho học sinh để đồ cá nhân. Mỗi ngăn có khóa là tổ hợp các số. Khi một học sinh muốn có ngăn mới, học sinh đó phải báo cho thầy quản lý biết mã số mong muốn và thầy sẽ giao số khóa cho học sinh. Tuy nhiên, nếu đã có học sinh khác dùng khóa này, thì không thể dùng mã số cũ mà phải lựa lấy mã số khác. Ta sẽ dùng map từ Data.Map để biểu thị các ngăn ($m:\;$số khóa $\rightarrow$ {Bool, Mã số 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 tạo một kiểu dữ liệu mới biểu diễn việc ngăn được dùng rồi hay còn trống và ta tạo đồng kiểu cho số khóa. Ta cũng tạo đồng kiểu với map từ Int đến pair LockerState và Code. Bây giờ, ta sẽ code hàm tìm kiếm số khóa trong map. Ta sẽ dùng kiểu Either String Code
để biểu diễn cho kết quả, vì việc tìm có thể thất bại theo hai cách — ngăn khóa có thể bị chiếm rồi, trong trường hợp đó ta không thể xuất số khóa hoặc số khóa có thể hoàn toàn không tồn tại. Nếu việc tìm thất bại, ta sẽ dùng String để báo cáo.
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 tìm thông thường trong map. Nếu nhận được Nothing, ta trả về giá trị kiểu Left String, nói rằng ngăn hoàn toàn không tồn tại. Nếu tìm thấy thì ta sẽ kiểm tra thêm xem ngăn bị chiếm chưa. Nếu đúng, thì trả về Left nói rằng nó đã bị chiếm rồi. Nếu không, trả về giá trị kiểu Right Code, trong đó ta đưa học sinh mã số đúng của ngăn khóa. Thực ra đó là Right String, nhưng ta đã giới thiệu đồng kiểu để có thêm thông tin về khai báo kiểu. Sau đây là một ví dụ:
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ử tìm vài mã số.
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 Maybe a để biểu thị 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.
Kết thúc bài 9
Tác giả
Vũ Tuấn Hải - Monadotory