Skip to main content

Bài 08 - Tạo Kiểu không tham số hóa

Chúng ta đã đề cập đến Kiểu là gì và tại sao chúng hữu ích trong các bài học trước. Vì vậy, trong phần này, chúng ta sẽ học cách tạo Kiểu của riêng mình.

TÓM TẮT
Video bài giảng
Chúng tôi đang dịch thuyết minh bài giảng sang tiếng Việt

Kiểu tương đồng (Synonyms)

Ngay từ đầu, khi tìm hiểu về Strings Haskell, bạn đã phát hiện ra rằng đó String là đường cú pháp cho [Char]. Điều này có nghĩa là String và [Char] tương đương nhau và bạn có thể sử dụng chúng thay thế cho nhau .

Đó là bởi vì String là một Kiểu tương đồng với [Char].

Cách định nghĩa Kiểu tương đồng

Để định nghĩa một Kiểu tương đồng, bạn sử dụng từ khóa type, theo sau là tên mới cho Kiểu và Kiểu tồn tại trước đó tương đương.

type String = [Char]

Bạn có thể đặt tên cho Kiểu tương đồng theo cách bạn muốn, miễn là nó bắt đầu bằng chữ in hoa.

Khi bạn định nghĩa một Kiểu tương đồng, bạn sẽ không tạo một Kiểu mới! Bạn chỉ nói với Haskell rằng một Kiểu hiện có được gọi bằng một tên khác (một từ tương đồng)!

Tại sao nên sử dụng Kiểu tương đồng

Tại sao bạn lại làm phức tạp hơn mà không thêm nhiều tính năng hơn?

Bởi vì Kiểu tương đồng cho phép chúng ta truyền đạt nhiều thông tin hơn! Hãy xem một ví dụ.

Hãy tưởng tượng bạn bắt đầu làm việc với một văn phòng cho phép bạn tạo các giao dịch tiền tệ.

Bạn muốn tạo một giao dịch mới, vì vậy bạn hãy xem khai báo Kiểu của hàm mà bạn cần sử dụng:

generateTx :: String -> String -> Int -> String 

Không phải là một khai báo hữu ích cho lắm. Bạn có thể suy luận rằng đó Int là giá trị cần chuyển, nhưng đó là những thông tin Strings gì? Và cái String mà nó trả về chứa gì?

Bây giờ, hãy so sánh khai báo Kiểu đó với khai báo này:

generateTx :: Address -> Address -> Value -> Id

Rõ ràng, khai báo thứ hai truyền tải ngữ cảnh tốt hơn! Hai tham số đầu tiên là địa chỉ, tham số thứ ba là giá trị của giao dịch và có vẻ như nó trả về id của giao dịch.

Tất cả điều đó chỉ từ khai báo Kiểu. Sự khác biệt? Chỉ cần một vài Kiểu tương đồng.

Hãy xem những gì chúng ta đã làm để cải thiện bối cảnh rất nhiều. Bắt đầu bằng cách tạo lại hàm được gọi generateTx sẽ lấy địa chỉ và giá trị của giao dịch và tạo id cho giao dịch đó:

generateTx :: String -> String -> Int -> String 
generateTx from to value = from ++ to ++ show value

Bây giờ, chúng ta chỉ cần thêm một số Kiểu tương đồng và thay thế chúng trong khai báo:

type Address = String
type Value = Int
type Id = String

generateTx :: Address -> Address -> Value -> Id
generateTx from to value = from ++ to ++ show value

Siêu dễ dàng! Và nếu bạn muốn kiểm tra xem AddressValue, hoặc Id Kiểu là gì, bạn có thể mở GHCi, tải tệp và kiểm tra thông tin của nó:

:i Address 

type Address :: *
type Address = String -- Defined at :1:1

Và, tất nhiên, chúng ta có thể xây dựng trên các Kiểu tương đồng trước đó để tạo Kiểu phức tạp hơn. Đây là một ví dụ:

type Name = String
type Address = (String, Int)
type Person = (Name, Address)

bob = ("Bob Smith", ("Main St.", 555)) :: Person
:t bob
:t fst bob

bob :: Person

fst bob :: Name

Các Kiểu tương đồng là điều tuyệt vời, nhưng chúng chỉ là các tên khác nhau cho cùng một thứ. Nhưng điều gì sẽ xảy ra nếu chúng ta cần tạo một Kiểu hoàn toàn mới? Từ khóa data là giải pháp!

Định nghĩa Kiểu mới với data

Chúng ta có thể tạo Kiểu mới như thế này:

data PaymentMethod = Cash | Card | Cryptocurrency

data Color = Red | Green | Blue

data Bool = True | False -- Real definition of Bool

data Ordering = LT | EQ | GT -- Real definition of Ordering

Chúng ta bắt đầu với từ khóa data. Sau đó, phần trước dấu bằng là tên Kiểu mới của chúng ta và phần sau dấu bằng là các hàm tạo giá trị (value constructors).

Các hàm tạo giá trị định nghĩa các giá trị khác nhau mà Kiểu có thể có.

Trong ngữ cảnh này, dấu | (dấu gạch đứng) được đọc là "hoặc". Vì vậy, chúng ta có thể đọc Kiểu đầu tiên là:

Kiểu PaymentMethod có thể có giá trị là CashCard hoặc Cryptocurrency.

danger

Tên Kiểu và Hàm tạo giá trị (value constructors) phải bắt đầu bằng một chữ cái viết hoa!

Sử dụng Kiểu mới của chúng ta

Và bây giờ, làm thế nào chúng ta có thể sử dụng Kiểu mới này?

Bằng cách sử dụng các giá trị của nó! Ví dụ: hãy thêm phương thức thanh toán cho người của chúng ta:

type Name = String
type Address = (String, Int)

data PaymentMethod = Cash | Card | Cryptocurrency deriving (Show)

type Person = (Name, Address, PaymentMethod)

bob = ("Bob Smith", ("Main St.", 555), Cash) :: Person
bob

("Bob Smith",("Main St.",555),Cash)

Chúng ta sẽ thêm deriving (Show) vào cuối phần khai báo dữ liệu của mình.

Bằng cách thêm phần này, Haskell sẽ tự động biến Kiểu đó thành một Instance của Lớp Kiểu Show. Cho phép chúng ta in chúng trên thiết bị đầu cuối.

Chúng ta sẽ giải thích chi tiết cách thức hoạt động của tính năng này trong bài học "Tạo Lớp Kiểu và Instance".

Và, tất nhiên, chúng ta có thể kiểm tra các thuộc tính của nó bằng lệnh :i trong ghci:

:i PaymentMethod

type PaymentMethod :: *
data PaymentMethod = Cash | Card | Cryptocurrency
-- Defined at :3:1
instance [safe] Show PaymentMethod -- Defined at :3:61

Chúng ta có thể khớp mẫu cho các giá trị của nó:

howItPays :: Person -> String
howItPays (_, _, Cash) = "Pays in cash"
howItPays (_, _, Card) = "Pays with card"
howItPays (_, _, Cryptocurrency) = "Pays with cryptocurrency"

howItPays bob

"Pays in cash"

Và sử dụng nó như bất kỳ Kiểu nào khác.

Nhưng đó chỉ là phần nổi của tảng băng chìm. Chúng ta nên làm gì nếu cần nhiều hơn một vài giá trị?

Ví dụ, nếu tôi muốn một Kiểu đại diện cho một hình có thể là bất kỳ hình tròn hoặc hình chữ nhật nào thì sao?

Chúng ta có thể bắt đầu bằng cách định nghĩa một cái gì đó như:

data Shape = Circle | Rectangle

Nhưng vấn đề là, điều này không được sử dụng nhiều.

Tôi muốn có thể thực hiện các công việc với các giá trị này, chẳng hạn như tính chu vi và diện tích. Và tôi không thể làm điều đó nếu không có các thuộc tính thực tế của Shape!

Không có vấn đề gì cả! Chúng ta chỉ có thể truyền một số tham số cho hàm tạo!

Tham số giá trị (Value Parameters)

Hãy suy nghĩ về những gì chúng ta cần để đại diện cho bất kỳ hình tròn hoặc hình chữ nhật nào:

  • Để tính diện tích một hình tròn, chúng ta chỉ cần bán kính của nó. Vì vậy, chỉ cần một giá trị số.
  • Để tính diện tích một hình chữ nhật, chúng ta cần độ dài của hai cạnh của nó. Vì vậy, hai giá trị số.

Để dịch các yêu cầu đó thành mã, điều duy nhất chúng ta cần làm là thêm Kiểu khác làm đối số cho hàm tạo giá trị của chúng ta khi định nghĩa Kiểu, như sau:

data Shape = Circle Float | Rectangle Float Float

Và đây là một ví dụ về Kiểu dữ liệu đại số (algebraic data types) nổi tiếng mà mọi người nói đến. Một trong nhiều thuộc tính của Haskell.

Chúng được gọi là "Đại số" vì chúng ta có thể tạo Kiểu mới bằng cách kết hợp Kiểu trước đó bằng cách xen kẽ (A | B có nghĩa A hoặc B nhưng không phải cả hai) hoặc bằng cách kết hợp ( A B, có nghĩa A và B cùng nhau).

Và làm thế nào để sự kết hợp này hoạt động? Nếu chúng ta kiểm tra Kiểu Circle hàm tạo:

-- data Shape = Circle Float | Rectangle Float Float
:t Circle

Hình tròn :: Float -> Shape

Chúng ta thấy đó Circle là một hàm!!. Hàm này nhận một giá trị Kiểu Float và trả về một giá trị Kiểu Shape! Vì vậy, để có được một giá trị của Kiểu Shape, tất cả những gì chúng ta phải làm là truyền vào bán kính của nó:

smallCircle = Circle 3

hugeCircle = Circle 100

:t smallCircle

smallCircle :: Shape

Và nó giống nhau đối với Rectangle các giá trị:

-- data Shape = Circle Float | Rectangle Float Float
:t Rectangle

Rectangle là một hàm nhận hai giá trị Kiểu Float và trả về một giá trị Kiểu Shape. Vì vậy, để có được một hình chữ nhật Kiểu Shape, tất cả những gì chúng ta phải làm là chuyển độ dài các cạnh của nó:

rect1 = Rectangle 10 5

rect2 = Rectangle 256 128

:t rect1

rect1 :: Shape

Vậy đấy! Chúng ta đã tạo một số giá trị của Kiểu mới Shape của chúng ta. Bây giờ chúng ta hãy sử dụng chúng!

Chúng ta có thể định nghĩa một hàm tính diện tích của bất kỳ giá trị Kiểu Shape nào như sau:

area :: Shape -> Float
area (Circle r) = pi * r^2 -- We pattern match on value constructors
area (Rectangle l1 l2) = l1 * l2

area smallCircle
area rect2

28.274334

32768.0

Chúng ta vừa tạo ra một Kiểu thực sự hữu ích! Nhưng Tôi muốn nhiều hơn nữa! Tôi muốn thêm màu sắc! Và các điểm trong không gian 2D cho bạn biết vị trí tâm của hình!

Đối với điều đó, chúng ta có thể làm điều gì đó quái dị như thế này:

data Shape
= Circle (Float, Float) Float String
| Rectangle (Float, Float) Float Float String

Nơi chúng ta thêm các điểm trong không gian dưới dạng bộ  giá trị Floatvà màu sắc làm giá trị String.

Chúng ta có thể dễ dàng định nghĩa lại area hàm cho Kiểu mới này như sau:

area :: Shape -> Float
area (Circle _ r _) = pi * r^2
area (Rectangle _ l1 l2 _) = l1 * l2

Nhưng sau đó, nếu chúng ta muốn trích xuất các trường cụ thể của Kiểu Shape, chúng ta phải tạo một hàm tùy chỉnh cho từng trường trong số chúng:

color :: Shape -> String
color (Circle _ _ c) = c
color (Rectangle _ _ _ c) = c

point :: Shape -> (Float, Float)
point (Circle p _ _) = p
point (Rectangle p _ _ _) = p

--- Etc...

type Point = (Float,Float)
type Radius = Float
type Width = Float
type Height = Float
type Color = String

data Shape
= Circle Point Radius Color
| Rectangle Point Width Height Color

Kiểu thực tế này là cách dễ đọc hơn. Tôi sẽ đưa bạn cái đó.

Nhưng đó là rất nhiều Kiểu tương đồng để cải thiện sự hiểu biết về khai báo. Và trên hết, nó không giải quyết được các vấn đề khác—cấp bách hơn—!

Nhưng đừng lo lắng, Haskell có sự hỗ trợ của chúng ta! Nhập cú pháp bản ghi!

Cú pháp ghi (Record Syntax)

Cú pháp bản ghi là một cách khác để định nghĩa Kiểu dữ liệu (data types) đi kèm với một số đặc quyền.

Chúng ta sẽ bắt đầu với một ví dụ dễ dàng hơn và sau đó chúng ta sẽ sửa Kiểu Shape của mình.

Giả sử chúng ta muốn tạo một Kiểu dữ liệu Employee chứa tên và số năm kinh nghiệm của nhân viên.

Nếu không có cú pháp bản ghi, chúng ta sẽ tạo nó như thế này:

data Employee = Employee String Float

Trong trường hợp này, vì Kiểu chỉ có một hàm tạo giá trị, nên thường sử dụng cùng tên với tên của Kiểu. Nó không giống như có gì đặc biệt về nó, nó chỉ là quy ước.

Nhưng với cú pháp bản ghi, chúng ta có thể tạo nó như sau:

data Employee = Employee { name :: String, experienceInYears :: Float } deriving (Show)
Bạn có thể thấy:
  • Các hàm tạo giá trị cú pháp bản ghi có các tham số của chúng, mà chúng ta gọi là các trường, được bao quanh bởi các dấu ngoặc nhọn }.
  • Mỗi trường có một tên bắt đầu bằng một chữ cái viết thường theo sau là Kiểu của nó.
  • Và các trường được phân tách bằng dấu phẩy.

Ok, chúng ta có Kiểu mới Employee của chúng ta. Bây giờ chúng ta hãy sử dụng nó.

Chúng ta có thể tạo các giá trị như thế này:

richard = Employee { name = "Richard", experienceInYears = 7.5 }

:t richard
richard

richard :: Employee

Employee {name = "Richard", experienceInYears = 7.5}

Chúng ta cung cấp hàm tạo và giữa các dấu ngoặc nhọn } của nó, chúng ta chỉ định tên của từng trường với giá trị tương ứng của nó. Có thể theo bất kỳ thứ tự nào!

Ngay lập tức, Kiểu dữ liệu kết quả dễ hiểu hơn và Show Instance rõ ràng hơn khi chúng ta in nó. Nhược điểm duy nhất là chúng ta cần phải viết tất cả mã bổ sung đó.

matt = Employee "Matt" 5
matt

sẽ tương tự như Employee {name = "Matt", experienceInYears = 5.0}

Bạn cũng có thể tạo các giá trị mới của Kiểu Employee bằng cách chuyển các tham số của các hàm tạo giá trị theo cùng thứ tự như định nghĩa của nó để có được kết quả cuối cùng giống nhau! Không cần thêm mã.

Và điều đó thậm chí còn không lọt vào top 3 đặc quyền tốt nhất! Một cách khác là chúng ta có thể cập nhật giá trị của bản ghi bằng cách tạo một giá trị mới từ giá trị trước đó và chỉ định nghĩa các trường đã thay đổi, như sau:

newMatt = matt { experienceInYears = 6 }
newMatt

Employee {name = "Matt", experienceInYears = 6.0}

Một điều tuyệt vời hơn nữa là nó tự động tạo các hàm để tra cứu các trường trong Kiểu dữ liệu!

    :t name
name richard

:t experienceInYears
experienceInYears richard
    name :: Employee -> String

"Richard"

    experienceInYears :: Employee -> Float

7.5

Bởi vì chúng ta có hai trường (trường name và trường experienceInYears), chúng ta nhận được miễn phí hai hàm cùng tên lấy một giá trị của Kiểu Employee và trả về giá trị của trường.

Bây giờ, nếu chúng ta muốn, ví dụ, để tính toán kinh nghiệm kết hợp của nhóm của bạn, bạn có thể làm điều gì đó như:

team = [Employee "John" 4, Employee "Josh" 2, Employee "Matthew" 7]

combinedExp :: [Employee] -> Float
combinedExp = foldr (\e acc -> experienceInYears e + acc) 0

combinedExp team

13.0

Thực sự tiện lợi! Và có nhiều hơn nữa! Nhưng trước khi tiết lộ thuộc tính tuyệt vời cuối cùng của cú pháp bản ghi, hãy sử dụng sức mạnh mới này và định nghĩa lại Kiểu khó hiểu Shape.

Như bạn nhớ lại, không có cú pháp bản ghi, định nghĩa Kiểu dữ liệu là thế này:

data Shape
= Circle (Float, Float) Float String
| Rectangle (Float, Float) Float Float String

Chà, với cú pháp bản ghi là cú pháp này:

data Shape
= Circle
{ position :: (Float, Float)
, radius :: Float
, color :: String
}
| Rectangle
{ position :: (Float, Float)
, width :: Float
, height :: Float
, color :: String
}
deriving (Show)

Như bạn có thể thấy, tất cả những gì chúng ta phải làm là thay thế các tham số của hàm tạo bằng các trường bản ghi và chúng ta có thể sử dụng Kiểu dữ liệu giống như chúng ta đã làm với Kiểu Employee.

Chúng ta có thể tạo các giá trị bằng cách sử dụng cú pháp thông thường và bản ghi và chúng ta có thể cập nhật các giá trị bằng cách chỉ định nghĩa các trường chúng ta cần thay đổi:

circ = Circle { position = (1, 2), radius = 6, color = "Green" }
:t circ
circ

rect1 = Rectangle (9, 3) 7 3 "Yellow"
:t rect1
rect1

rect2 = rect1 {width = 12}
:t rect2
rect2

circ :: Shape

Circle {position = (1.0,2.0), radius = 6.0, color = "Green"}

rect1 :: Shape

Rectangle {position = (9.0,3.0), width = 7.0, height = 3.0, color = "Yellow"}

rect2 :: Shape

Rectangle {position = (9.0,3.0), width = 12.0, height = 3.0, color = "Yellow"}

Và, tất nhiên, chúng ta có thể dễ dàng trích xuất các giá trị mà chúng ta cần bằng các hàm mới được định nghĩa tự động:

    position circ

color rect2

(1.0,2.0)

"Yellow"

Tôi sẽ cho bạn thấy điều tương tự một lần nữa nhưng với một Kiểu khác. Tôi không muốn làm bạn chán, vì vậy hãy xem thứ gì khác đi kèm với hồ sơ.

Hãy sử dụng khớp mẫu (pattern matching) để định nghĩa lại hàm tính diện tích hình cho Kiểu dữ liệu bản ghi mới của chúng ta.

Ngay cả khi chúng ta đang sử dụng cú pháp bản ghi, chúng ta vẫn có thể so khớp mẫu như chúng ta vẫn thường làm:

    area :: Shape -> Float
area (Circle _ r _) = pi * r ^ 2
area (Rectangle _ w h _) = w * h

area circ
area rect1

113.097336

21.0

Nhờ các bản ghi, giờ đây chúng ta có một cú pháp khớp mẫu đặc biệt!:

    area :: Shape -> Float
area Circle {radius=r} = pi * r^2
area Rectangle {width=w,height=h} = w * h

area circ
area rect1

113.097336

21.0

tip

Chúng ta khớp mẫu trên các hàm tạo giá trị cú pháp bản ghi bằng cách viết các trường của hàm tạo giữa các dấu ngoặc nhọn và liên kết chúng với một biến ở bên phải dấu bằng của trường.

Điều thú vị là chúng ta chỉ khớp các mẫu của các trường mà chúng ta cần sử dụng. Và điều này mang lại cho chúng ta một lợi ích tuyệt vời khác của cú pháp bản ghi. Nếu chúng ta thêm một trường khác vào Kiểu dữ liệu, chúng ta không cần thay đổi bất kỳ hàm nào trước đó! Bởi vì chúng ta không tính đến các trường không sử dụng khi đối sánh mẫu của chúng ta!

Tuyệt vời, phải không?

Cú pháp bản ghi đặc biệt hữu ích khi bạn có một Kiểu dữ liệu có thể có hàng chục trường. Giống như một Kiểu chứa các cài đặt của một ứng dụng. Hoặc một trong đó có tất cả các thông tin của một cuộc khảo sát.

Nó cho phép bạn sử dụng Kiểu mà không cần nhớ giá trị nào là gì (vì tất cả chúng đều được đặt tên) và cho phép bạn cập nhật và tham chiếu các trường cụ thể, bỏ qua phần còn lại. Vì vậy, nếu bạn thay đổi Kiểu của mình trong tương lai, chỉ các giá trị và hàm sử dụng trường đã thay đổi bị ảnh hưởng.

Ok, đó là nó cho ngày hôm nay. Trong bài học tiếp theo, chúng ta sẽ dựa trên bài học này để tạo Kiểu phức tạp hơn. Vì vậy, hãy đảm bảo làm bài tập về nhà, và tôi sẽ gặp bạn ở bài tiếp theo!

Nguồn bài viết tại đây


Picture