Haskell #14: Applicative

Chương 15. Newtype

15.1. Newtype

Chúng ta có thể tạo ra kiểu dữ liệu đại số mới qua việc dùng từ khóa data, hoặc đồng kiểu bằng từ khóa type. Trong chương này, có một cách khác là sử dụng từ khóa newtype.

Ở chương trước, ta có thể định nghĩa list là Applicative bằng nhiều cách. Một cách là để phương thức <*> lấy từng hàm ra khỏi list bên trái rồi ánh xạ lên từng giá trị ở list bên phải, kết quả thu được là tất cả các tổ hợp có thể.

ghci> [(+1),(*100),(*5)] <*> [1,2,3]
[2,3,4,100,200,300,5,10,15]

Cách làm thứ hai là ánh xạ từng hàm bên trái lên giá trị bên phải, giống như zip 2 list với nhau. Nhưng vì list là Applicative, làm sao để ta có thể cũng làm cho list là instance của Applicative theo cách thứ hai này? Nếu bạn còn nhớ, chúng ta đã nói rằng kiểu ZipList a được giới thiệu với mục đích như vậy, kiểu dữ liệu này có một constructor value. Ta đặt list được gói vào trong trường đó. Khi này, ZipList là Applicative, để cho khi ta cần dùng list như những Applicative theo cách zip, thì ta chỉ việc gói chúng bằng constructor ZipList và khi đã xong việc, thì gỡ gói bằng getZipList:

ghci> getZipList $ ZipList [(+1),(*100),(*5)] <*> ZipList [1,2,3]
[2,200,15]

Như vậy, trường hợp này ta có thể dùng newtype như sau:

data ZipList a = ZipList [a]

Kiểu dữ liệu chỉ gồm một constructor value kiểu list. Có thể ta cũng muốn dùng cú pháp record để tự động có được một hàm nhằm mục đích kết xuất một list từ ZipList:

data ZipList a = ZipList { 
	getZipList :: [a] 
}

Cách này trông cũng được và thực chất khá tốt. Ta đã có hai cách làm cho một kiểu có sẵn thuộc typeclass mới, và trong cách thứ hai ta dùng từ khóa data chỉ để gói kiểu dữ liệu này đặt vào trong một kiểu khác rồi làm cho kiểu bên ngoài trở thành một instance.

Trong Haskell, từ khóa newtype được dành riêng cho những trường hợp như vậy khi ta chỉ cần đem một kiểu gói vào trong thứ gì đó để biểu diễn đưới dạng một kiểu khác. Trong các thư viện, ZipList a được định nghĩa như sau:

newtype ZipList a = ZipList { 
	getZipList :: [a] 
}

Thay vì từ khóa data, từ khóa newtype được dùng đến, một lý do là newtype nhanh hơn. Nếu bạn dùng từ khóa data để gói một kiểu dữ liệu thì sẽ có những chi phí tính toán khi gói và gỡ gói nhưng nếu dùng newtype, Haskell sẽ biết rằng chúng ta chỉ dùng nó để gói một kiểu có sẵn vào trong một kiểu mới (như tên gọi đã gợi ý), vì bạn muốn nó có cùng nội dung nhưng khác kiểu. Theo tinh thần như vậy, Haskell sẽ tránh việc gói và gỡ gói một khi nó phân giải được giá trị nào thuộc về kiểu nào.

Thế thì tại sao ta lại không dùng hẳn newtype thay vì data. Khi bạn tạo một kiểu mới từ một kiểu sẵn có bằng từ khóa newtype, bạn chỉ có thể có một constructor value và constructor value đó chỉ được phép có một trường. Nhưng với data, bạn có thể tạo những kiểu dữ liệu có nhiều constructor value và mỗi constructor được phép có nhiều trường hoặc không có:

data Profession = Fighter | Archer | Accountant

data Race = Human | Elf | Orc | Goblin

data PlayerCharacter = PlayerCharacter Race Profession

Ta cũng có thể dùng từ khóa deriving với newtype giống như dùng với datađể kế thừa từ Eq, Ord, Enum, Bounded, … Nếu ta kế thừa từ một class, thì trước hết kiểu dữ liệu mà ta đang gói phải thuộc về class đó. Điều này có lý, bởi newtype chỉ đơn giản là gói một kiểu đã có sẵn. Ta có thể so sánh ngang bằng những giá trị thuộc kiểu dữ liệu mới và in chúng ra màn hình:

newtype CharList = CharList { 
	getCharList :: [Char] 
} deriving (Eq, Show)

Hãy thử nhé:

ghci> CharList "this will be shown!"
CharList {getCharList = "this will be shown!"}
ghci> CharList "benny" == CharList "benny"
True
ghci> CharList "benny" == CharList "oisters"
False

Trong newtype cụ thể này, constructor value có kiểu như sau:

CharList :: [Char] -> CharList

Nó nhận một giá trị kiểu [Char], chẳng hạn "my sharona" rồi trả lại một giá trị kiểu CharList. Ngược lại, hàm getCharList, vốn được tạo ra vì ta dùng cú pháp record trong newtype đang xét, thì có kiểu này:

getCharList :: CharList -> [Char]

Nó nhận một giá trị CharList rồi trả về giá trị [Char]. Bạn có thể tưởng tượng điều này như việc gói và gỡ gói hoặc chuyển đổi giá trị từ một kiểu dữ liệu này sang một kiểu khác.

Tác dụng newtype

Nhiều lúc ta muốn tạo ra những instance thuộc typeclass nhất định, nhưng tham số kiểu lại không phù hợp. Thật dễ làm cho Maybe trở thành instance của Functor, vì Functor được định nghĩa như sau:

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

instance Functor Maybe where
	fmap :: (a -> b) -> Maybe a -> Maybe b

Nhưng với Tuple gồm 2 giá trị, việc trở thành instance của Functor rất khó khăn vì constructor chỉ nhận đúng một tham số kiểu. Để khắc phục điều này, ta có thể tạo newtype:

newtype Pair b a = Pair { getPair :: (a,b) }

Và bây giờ, ta có thể khiến cho nó thành instance của Functor:

instance Functor (Pair c) where
    fmap f (Pair (x,y)) = Pair (f x, y)

Ta lấy được bộ dữ liệu ẩn bên trong, tiếp theo là áp dụng hàm $f$ đối với phần tử thứ nhất trong bộ rồi sau đó dùng constructor value có tên Pair để chuyển đổi bộ trở lại thành Pair b a.

fmap :: (a -> b) -> Pair c a -> Pair c b

Một lần nữa, ta viết instance Functor (Pair c) where và như vậy Pair c chiếm chỗ của f trong lời định nghĩa lớp cho Functor. Như vậy, nếu chuyển đổi một tuple trở thành Pair b a, thì ta sẽ dùng được fmap lên nó và hàm sẽ được ánh xạ lên phần tử thứ nhất:

ghci> getPair $ fmap (*100) (Pair (2,3)) (200,3)

15.3. Newtype lazy

Ta đã đề cập rằng newtype thường nhanh hơn data. Việc duy nhât mà newtype có thể làm là chuyển một kiểu sẵn có thành một kiểu mới; cho nên ở bên trong, Haskell có thể biểu diễn giá trị có kiểu được định nghĩa bằng newtype như giá trị gốc, chỉ khác là kiểu của chúng riêng biệt. Điều này nghĩa là newtype không chỉ nhanh hơn, mà còn lazy hơn.

Haskell có một tính chất cơ bản là lazy, nghĩa rằng chỉ khi ta yêu cầu kết quả của hàm thì việc tính toán mới được tiến hành. Giá trị undefined trong Haskell biểu diễn một kết quả tính toán có lỗi, rõ ràng là ta không thể sử dụng chúng. Tuy nhiên, nếu list có một số giá trị undefined trong đó (không phải phần tử đầu) thì mọi thứ vẫn diễn ra thuận lợi:

ghci> head [3,4,5,undefined,2,undefined]
3

Bây giờ hãy xét kiểu dữ liệu sau:

data CoolBool = CoolBool { getCoolBool :: Bool }

Đây là kiểu dữ liệu đại số được định nghĩa bằng từ khoá data. Nó gồm một constructor value, vốn chỉ có một trường với kiểu là Bool.

helloMe :: CoolBool -> String
helloMe (CoolBool _) = "hello"

Thay vì sử dụng như thông thường, nếu gọi hàm với undefined:

ghci> helloMe undefined
"*** Exception: Prelude.undefined

Exception xảy ra! Kiểu được định nghĩa với từ khoá data có thể có nhiều constructor value (ngay cả khi CoolBool chỉ có một). Vì vậy để thấy được liệu rằng giá trị được cấp cho hàm đang xét có tuân theo dạng (CoolBool _) hay không, Haskell phải đánh giá giá trị này để thấy được constructor value nào được dùng đến. Và trong quá trình đánh giá undefined, exception đã được ném ra rồi.

Thay vì dùng từ khoá data cho CoolBool, ta hãy thử dùng newtype:

newtype CoolBool = CoolBool { 
	getCoolBool :: Bool 
}

Ta không phải sửa đổi hàm helloMe, vì cú pháp pattern matching vẫn như vậy. Bây giờ hãy làm điều này rồi áp dụng helloMe đối với một giá trị undefined:

ghci> helloMe undefined
"hello"

Nó hoạt động được, như đã nói, khi dùng newtype, thì ở bên trong, Haskell có thể biểu diễn giá trị của kiểu dữ liệu mới giống như giá trị gốc. Vì Haskell biết rằng những kiểu được lập bằng từ khóa newtype có thể chỉ có một constructor, nên nó không cần đánh giá giá trị truyền vào hàm.

Sự khác biệt này về biểu hiện dường như nhỏ nhặt, song thực ra lại rất quan trọng; nó giúp ta nhận thấy rằng mặc dù những kiểu được định nghĩa bằng datanewtype đều biểu hiện giống nhau theo quan điểm của người lập trình vì chúng đều có constructor value và các trường, nhưng chúng thực ra là hai cơ chế khác nhau. Nếu như data có thể được dùng để tạo ra những kiểu riêng từ đầu, thì newtype được dùng để tạo ra một kiểu mới bắt nguồn từ kiểu sẵn có. Việc pattern matching trên các giá trị newtype khác với việc lấy một thứ khỏi hộp (như làm với data), mà giống hơn là việc chuyển đổi trực tiếp từ một kiểu này sang kiểu khác.

Type, newtype & data

Đến đây, bạn có thể hơi nhầm lẫn về điểm khác biệt giữa type, datanewtype, vì vậy ta hãy cùng ôn lại một chút.

Từ khoá type được dùng để tạo ra đồng kiểu, hay tên khác cho một kiểu dữ liệu sẵn có sao cho kiểu mới tiện cho việc sử dụng. Chẳng hạn:

type IntList = [Int]

Khi tham chiếu đến kiểu [Int], IntList có thể sử dụng như một biện pháp thay thế. Sẽ không có cái gọi là constructor value IntList hoặc thứ tương tự. Vì chỉ có hai cách, [Int]IntList để tham chiếu đến cùng kiểu đang xét, nên việc ta dùng tên nào trong chú thích kiểu là không quan trọng:

ghci> ([1,2,3] :: IntList) ++ ([1,2,3] :: [Int])
[1,2,3,1,2,3]

Ta dùng đồng kiểu khi muốn type signature gợi hình hơn. Chẳng hạn, khi dùng list kiểu [(String,String)] để biểu diễn danh bạ điện thoại, ta đặt đồng kiểu là PhoneBook chỉ để dễ đọc hơn.

newtype được dùng cho những kiểu có sẵn để gói chúng trong kiểu mới, với mục đích là biến chúng thành instance của những typeclass nhất định. Khi dùng newtype để gói một kiểu có sẵn, thì kiểu dữ liệu mà ta thu được sẽ tách biệt khỏi kiểu ban đầu. Ví dụ:

newtype CharList = CharList { 
	getCharList :: [Char] 
}

Thì ta sẽ không thể dùng toán tử ++ để kết CharList và list kiểu [Char]. Thậm chí giữa hai CharList, vì ++ chỉ làm việc với list còn kiểu CharList không phải là list, dù rõ ràng nó chứa list. Dù vậy, ta có thể gỡ gói chúng ra, biến hai CharList thành list, thực thi toán tử ++, sau đó gói trong CharList.

Khi dùng cú pháp record trong khai báo newtype, ta có thể sử dụng các hàm dùng để chuyển đổi qua lại giữa kiểu mới và kiểu gốc. Kiểu dữ liệu mới không tự động là instance của typeclass chứa kiểu ban đầu, vì vậy ta phải tự tay viết chúng. Thực tế là bạn có thể hình dung newtype như data nhưng chỉ được phép có một constructor và một trường.

data được dùng để tự do tạo ra những kiểu dữ liệu mới. Chúng có thể có nhiều constructor hoặc nhiều tham số kiểu tuỳ ý. Mọi thứ từ list, Maybe, Tree, Stack, Queue, …

Nếu chỉ cần type signature rõ ràng hơn, hãy dùng đồng kiểu. Nếu muốn lấy một kiểu sẵn có rồi gói nó vào một kiểu mới để làm cho nó trở thành instance của typeclass, hãy dùng newtype. Nếu muốn tạo ra một thứ hoàn toàn mới, hãy dùng đến data.

Kết thúc bài 15

Haskell #16: Monoids