Skip to main content

Bài 02: Type and Type Class

Kiến thức về Kiểu và Lớp của kiểu

Type là gì?

Trước đây tôi đã đề cập rằng Haskell có hệ thống static type. Type của mỗi biểu thức đều được biết ngay từ lúc compile, vì vậy khiến cho code an toàn hơn. Nếu viết chương trình, trong đó chia biến boolean cho một số nào đó, thì chương trình sẽ không compile được. Điều này có lợi vì bug được bắt lúc compile time tốt hơn runtime. Mọi thứ trong Haskell đều có type, vì vậy compiler có thể suy luận được rất nhiều từ chương trình bạn viết trước khi compile.

Khác với Java hay C++, Haskell có type inference (suy luận kiểu). Nếu ta gán một số, ta không cần khai báo. Nó sẽ tự suy luận được, vì thế ta không cần ghi rõ type các hàm và biểu thức. Tuy vậy, việc hiểu được hệ thống type là phần rất quan trọng trong quá trình học Haskell.

Type cũng như label (nhãn) cho tất cả các biểu thức. Nó cho chúng ta biết loại của biểu thức đó là gì. Biểu thức True có type boolean, "hello" là string, v.v.

Bây giờ ta hãy dùng GHCI để kiểm tra type của biểu thức. Ta sẽ làm điều đó bằng cách dùng lệnh :t theo sau là biểu thức hợp lệ. Hãy thử xem sao.

ghci> :t 'a'
'a' :: Char
ghci> :t True
True :: Bool
ghci> :t "HELLO!"
"HELLO!" :: [Char]
ghci> :t (True, 'a')
(True, 'a') :: (Bool, Char)
ghci> :t 4 == 5
4 == 5 :: Bool

Ở đây ta thấy rằng viết :t trước một biểu thức sẽ in ra tên biểu thức đó, tiếp theo là dấu :: rồi đến type của nó. :: được hiểu là has type of (có type). Những type tường minh luôn được viết chữ cái đầu in hoa. ‘a’, như ta đoán được có type Char. Không khó để nhận ra nó là chữ viết tắt cho character (kí tự). True thuộc về type Bool. Như thế cũng có nghĩa. Nhưng còn cái gì đây? Kiểm tra type của "HELLO!", ta nhận được [Char]. Cặp ngoặc vuông biểu thị cho một List. Như vậy nó là List các kí tự. Khác với List, các tuple dài ngắn khác nhau thì có type riêng. Vì vậy, biểu thức (True, 'a') có type (Bool, Char), trong khi một biểu thức như ('a','b','c') sẽ có type (Char, Char, Char)4 == 5 luôn trả về False, vì vậy type của nó là Bool.

Các hàm cũng có type. Khi viết ra các hàm, ta có thể lựa chọn có hoặc không khai báo cụ thể type. Việc khai báo thường là tốt trừ trường hợp bạn viết hàm rất ngắn. Từ nay trở đi, tất cả những hàm ta viết sẽ có khai báo type. Bạn còn nhớ rằng List comprehension trước đây ta viết để filter những chữ cái thường chứ? Cùng với khai báo type, nó sẽ trông như sau:

removeNonUppercase :: [Char] -> [Char]
removeNonUppercase st = [ c | c <- st, c `elem` ['A'..'Z']]

removeNonUppercase có type [Char] -> [Char], nghĩa là nó ánh xạ từ string đến string. Hay nói cách khác, nó nhận vào một tham số là string rồi trả lại kết quả là một string khác. Type [Char] thì tương đương với String vì vậy sẽ rõ hơn nếu ta viết removeNonUppercase :: String -> String. Ta không cần phải khai báo type cho hàm này vì compiler có thể tự suy luận, tuy nhiên vẫn nên code để cho quen. Đến đây, có lẽ bạn sẽ thắc mắc rằng nếu hàm nhận nhiều tham số thì khai báo kiểu gì? Sau đây là một hàm nhận vào 3 số nguyên rồi cộng chúng lại:

addThree :: Int -> Int -> Int -> Int
addThree x y z = x + y + z

Các tham số được ngăn cách bởi dấu -> và không có sự phân biệt giữa tham số và type trả về. Lý do tất cả chúng đều được phân tách bởi -> thay vì có ký tự để phân biệt rõ ràng hơn giữa type trả về và các tham số, chẳng hạn Int, Int, Int -> Int hay một cách khác là vì nó liên quan đến giải tích Lambda - cơ sở toán cho lập trình hàm.

Sau đây là cái nhìn khái quát về những type thường gặp.

Int là viết tắt của integer, số nguyên. 7 có thể là Int nhưng 7.2 thì không. Int bị chặn, có nghĩa là nó có một giá trị nhỏ nhất và một giá trị lớn nhất. Thường thì ở hệ 32 bit, số lớn nhất mà Int có thể nhận là 2147483647 và số nhỏ nhất là -2147483648.

Integer cũng là số nguyên. Sự khác biệt chủ yếu giữa nó và Int là Integer không bị chặn nên nó có thể dùng để biểu diễn số thật lớn. Ý tôi là rất lớn. Dù vậy thì dùng Int có thể hiệu quả hơn.

factorial :: Integer -> Integer
factorial n = product [1..n]
ghci> factorial 50
30414093201713378043612608166064768844377641568960512000000000000

Float là số thực (có dấu chấm động).

circumference :: Float -> Float
circumference r = 2 * pi * r
ghci> circumference 4.0
25.132742

Double cũng như Float, nhưng số bit dành cho dấu chấm động nhiều hơn.

circumference' :: Double -> Double
circumference' r = 2 * pi * r
ghci> circumference' 4.0
25.132741228718345

Bool là boolean. Nó chỉ có hai giá trị: True và False.

Char biểu diễn một ký tự. Nó được kí hiệu bởi cặp dấu nháy đơn. Một List các kí tự hợp thành String.

Tuple cũng là type nhưng nó lại tùy thuộc vào độ dài cũng như từng thành phần, cho nên về lý thuyết sẽ có vô số type tuple khác nhau, và ta không thể đề cập trong này. Lưu ý rằng một tuple rỗng () cũng là một type nhưng chỉ có một giá trị duy nhất: ()

Biến type

Theo bạn thì type của hàm head là gì? Vì head nhận vào một List phần tử có type bất kì rồi trả lại phần tử thứ nhất, nên có lẽ sẽ có một chút rắc rối ở đây. Ta hãy cùng kiểm tra!

ghci> :t head
head :: [a] -> a

Hmmm! a là gì đây? Có phải nó là một type không? Hãy nhớ rằng trước đây chúng ta chỉ nói rằng tên type được viết in hoa chữ cái đầu, vì vậy nó không hẳn là một type mà là biến type. Điều này có nghĩa là a có thể là type bất kì. Nó cũng giống như khái niệm generic trong các ngôn ngữ lập trình khác; trong Haskell nó mạnh hơn rất nhiều vì cho phép ta dễ dàng viết những hàm rất tổng quát. Những hàm có chứa biến type được gọi là hàm đa hình (polymorphic functions). Khai báo type của head nói rằng nó nhận vào một List có type bất kì rồi trả lại một phần tử có type đó.

Mặc dù biến type có thể được đặt tên dài hơn một ký tự, nhưng ta thường đặt tên cho chúng là a, b, c, d …

Bạn còn nhớ fst không? Nó trả lại phần tử đầu tiên của một pair. Ta hãy cùng kiểm tra type này.

ghci> :t fst
fst :: (a, b) -> a

Có thể thấy rằng fst nhận vào một tuple bao gồm hai type rồi trả lại phần tử có cùng type với thành phần thứ nhất trong cặp. Đó là lý do tại sao ta có thể dùng fst cho một pair có type bất kì. Lưu ý rằng dù a và b là hai biến type khác nhau, nhưng không nhất thiết là chúng phải khác nhau. Chỉ có thể khẳng định rẳng phần tử thứ nhất và giá trị trả lại có cùng type với nhau.

Typeclasses 101

Typeclass là một interface định nghĩa một hành vi nào đó. Nếu một type thuộc về một type class, có nghĩa là nó implement (thực thi hóa) hành vi của mình theo sự chỉ định của typeclass. Nhiều người từ trường phái hướng đối tượng (OOP) bị các type class gây nhầm lẫn vì cứ nghĩ rằng chúng giống như class. Không phải vậy. Bạn nên hình dung chúng giống interface của Java sẽ tốt hơn.

Ví dụ, type signature của hàm == là gì?

ghci> :t (==)
(==) :: (Eq a) => a -> a -> Bool

Lưu ý: toán tử đẳng thức, == là hàm. Các toán tử +, *, -, /, cũng vậy, như đa số các toán tử khác. Nếu một hàm có tên chỉ bằng những kí tự đặc biệt, thì mặc định nó là hàm trung tố. Nếu ta muốn kiểm tra type của nó, hãy truyền nó đến một hàm khác hoặc gọi nó dưới dạng hàm tiền tố, khi đó nhớ bọc tên hàm trong cặp ngoặc tròn.

Rất thú vị. Ta đã thấy một điều mới mẻ ở đây, dấu =>. Mọi thứ đặt trước dấu => được gọi là ràng buộc theo class (class constraint). Ta có thể đọc lời khai báo type trên như sau: hàm so sánh nhận vào hai giá trị bất kì có cùng type và trả về Bool. Type của hai giá trị đó phải thuộc class Eq (đây chính là ràng buộc theo class).

Class Eq cho ta một interface để kiểm tra sự bằng nhau. Bất kì type nào có thể so sánh ngang bằng sẽ thuộc class Eq. Tất cả những type tiêu chuẩn của Haskell ngoại trừ type IO (type liên quan tới nhập - xuất) cùng các hàm đều thuộc về class Eq.

Hàm elem có type (Eq a) => a -> [a] -> Bool vì nó dùng == duyệt trên List để kiểm tra xem liệu có giá trị nào cần tìm có trong List không.

Một số type class cơ bản

Picture

Top type class sẽ được giới thiệu phía dưới, còn bot type class trừu tượng hơn sẽ được giới thiệu trong các chương sau.

Eq được dùng cho các type mà phép so sánh ngang bằng. Các type thuộc class này cung cấp hai hàm == và /=. Vì vậy nếu có ràng buộc theo class Eq đối với một biến type trong hàm, thì nó sẽ dùng == hoặc /= ở đâu đó trong định nghĩa của mình. Tất cả những type chúng ta đã đề cập trước đây, trừ các hàm, đều thuộc về Eq, vì vậy chúng có thể được kiểm tra ngang bằng.

ghci> 5 == 5
True
ghci> 5 /= 5
False
ghci> 'a' == 'a'
True
ghci> "Ho Ho" == "Ho Ho"
True
ghci> 3.432 == 3.432
True

Ord được dùng cho type có thứ tự.

ghci> :t (>)
(>) :: (Ord a) => a -> a -> Bool

Tất cả những type ta đã đề cập đến giờ, trừ các hàm, đều thuộc OrdOrd gồm tất cả những hàm so sánh tiêu chuẩn như ><>= và <=. Hàm compare nhận vào hai đối tượng cùng type thuộc class Ord rồi trả lại thứ tự giữa chúng. Ordering là một type, nó có thể là GT, LT or EQ, lần lượt là greater than (lớn hơn), less than (nhỏ hơn) và equal (bằng).

Để thuộc class Ord, type phải là thành viên của Eq.

ghci> "Abrakadabra" < "Zebra"
True
ghci> "Abrakadabra" `compare` "Zebra"
LT
ghci> 5 >= 2
True
ghci> 5 `compare` 3
GT

Các thành viên của Show có thể được biểu diễn dưới dạng chuỗi. Tất cả các type mà ta đã đề cập đến, trừ hàm, đều thuộc Show. Hàm hay được dùng nhất với class Show là show. Hàm này nhận giá trị là thành viên của Show rồi in ra màn hình dưới dạng string.

ghci> show 3
"3"
ghci> show 5.334
"5.334"
ghci> show True
"True"

Read là typeclass đối ngược với Show. Hàm read nhận vào một string rồi trả về type nào đó (phải là thành viên của Read).

ghci> read "True" || False
True
ghci> read "8.2" + 3.8
12.0
ghci> read "5" - 2
3
ghci> read "[1,2,3,4]" ++ [3]
[1,2,3,4,3]

Đến giờ mọi việc vẫn ổn. Một lần nữa, tất cả các type được trình bày đều nằm trong class này. Nhưng điều gì sẽ xảy ra nếu ta chỉ viết read "4"?

ghci> read "4"
<interactive>:1:0:
Ambiguous type variable `a' in the constraint:
`Read a' arising from a use of `read' at <interactive>:1:0-7
Probable fix: add a type signature that fixes these type variable(s)

Điều mà GHCI báo cho ta là nó không biết chúng ta đang muốn trả về thứ gì. Lưu ý rằng ở ví dụ trước, khi dùng read ta đã thực hiện một số hàm kèm theo kết quả. Bằng cách đó thì GHCI suy luận là kết quả mong muốn theo type gì. Nếu ta dùng nó như Boolean thì GHCI biết rằng nó cần trả lại Boolean. Nhưng ở đây, nó chỉ biết rằng ta cần một type nào đó thuộc class Read, không biết cụ thể type nào. Ta hãy xem type signature của read.

ghci> :t read
read :: (Read a) => String -> a

Thấy chưa? Nó trả lại một type thuộc Read nhưng nếu sau đó ta không dùng nó thì ta sẽ không thể biết type nào. Đó là lý do tại sao chúng ta cần dùng chú thích (annotation) một cách tường minh. Type annotation là cách nói rõ rằng type của một biểu thức là gì. Ta làm điều này bằng cách thêm dấu :: vào cuối biểu thức rồi chỉ định. Hãy xem này:

ghci> read "5" :: Int
5
ghci> read "5" :: Float
5.0
ghci> (read "5" :: Float) * 4
20.0
ghci> read "[1,2,3,4]" :: [Int]
[1,2,3,4]
ghci> read "(3, 'a')" :: (Int, Char)
(3, 'a')

Compiler có thể tự suy luận ra đa số type của các biểu. Nhưng đôi khi, compiler không biết phải trả lại giá trị thuộc type Int hay Float cho một biểu thức như read "5". Vì Haskell là ngôn ngữ static typing (đều này là điểm khác biệt chính với dynamic typing của Python) nên nó phải biết tất cả các type trước khi compile. Vì vậy, ta phải bảo Haskell: “Biểu thức phải có type này trong trường hợp cậu chưa biết!”.

Các thành viên của Enum là những type được đánh thứ tự lần lượt - chúng có thể đếm được (enumerate). Ưu điểm chính của class Enum là chúng ta có thể dùng các type của nó làm các dãy trong List. Chúng cũng định nghĩa sẵn các đối tượng liền sau và liền trước mà ta có thể nhận được bằng cách gọi hàm succ và pred. Các type trong class này gồm: ()BoolCharOrderingIntIntegerFloat và Double.

ghci> ['a'..'e']
"abcde"
ghci> [LT .. GT]
[LT,EQ,GT]
ghci> [3 .. 5]
[3,4,5]
ghci> succ 'B'
'C'

Các thành viên thuộc Bounded có cận trên và cận dưới.

ghci> minBound :: Int
-2147483648
ghci> maxBound :: Char
'\1114111'
ghci> maxBound :: Bool
True
ghci> minBound :: Bool
False

minBound và maxBound là hàm có type (Bounded a) => a. Theo nghĩa nào đó, chúng là hằng số đa hình (polymorphic constant).

Tất cả các tuple cũng là thành viên của Bounded nếu như các phần tử trong tuple là thuộc class này.

ghci> maxBound :: (Bool, Int, Char)
(True,2147483647,'\1114111')

Num là một type class dạng số. Các thành viên của nó có thuộc tính là khả năng hoạt động của số. Ta hãy xét type của số.

ghci> :t 20
20 :: (Num t) => t

Dường như các số nguyên cũng là hằng số đa hình. Chúng có thể là bất kì type nào thuộc class Num.

ghci> 20 :: Int
20
ghci> 20 :: Integer
20
ghci> 20 :: Float
20.0
ghci> 20 :: Double
20.0

Đó là các type có trong class Num. Nếu kiểm tra type của *, ta sẽ thấy rằng nó nhận tất cả các type thuộc Num.

ghci> :t (*)
(*) :: (Num a) => a -> a -> a

Nó nhận vào hai số cùng type rồi trả lại một số cũng type đó. Điều này lý giải tại sao (5 :: Int) * (6 :: Integer) sẽ gây ra lỗi về type trong khi 5 * (6 :: Integer) thì lại được và ra kết quả là một Integer vì 5 có thể đóng vai trò là một Integer hoặc Int.

Để thuộc class Num, một type phải thuộc Show và Eq.

Integral cũng là một type class số. Num bao gồm tất cả các số, kể cả số thực lẫn số nguyên, còn Integral chỉ bao gồm số nguyên, trong class này có Int và Integer.

Floating bao gồm số thực, gồm các type Float và Double.

Một hàm hữu ích đối với các số là fromIntegral. Nó có dạng fromIntegral :: (Num b, Integral a) => a -> b. Từ type signature này ta thấy nó nhận một số nguyên rồi biến nó sang một số có dạng “tổng quát” hơn. Điều này có ích khi bạn muốn type số nguyên và type số thực tính toán được với nhau. Chẳng hạn, hàm length có khai báo type là length :: [a] -> Int thay vì có type tổng quát hơn là (Num b) => length :: [a] -> b. Tôi nghĩ rằng có lí do lịch sử hay điều gì đó lý giải cho việc này, song, theo tôi cách khai báo trên khá ngốc nghếch. Dù sao, nếu ta thử lấy chiều dài một List rồi đem cộng nó với 3.2, ta sẽ nhận được lỗi vì ta đã thử cộng một Int với một số thực. Để khắc phục điều này, ta có thể viết fromIntegral (length [1,2,3,4]) + 3.2. Lưu ý rằng fromIntegral có ràng buộc theo class trong type signature của nó. Điều này hoàn toàn hợp lệ và như bạn sẽ thấy, các ràng buộc theo class được phân cách bởi các dấu phẩy bên trong cặp ngoặc tròn.

Nguồn bài viết