Chương 4: Cú pháp dùng trong hàm

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

Khớp mẫu

Chương này sẽ đề cập đến một số cấu trúc cú pháp đẹp mắt của Haskell, và khớp mẫu sẽ là cách thức đầu tiên được xét đến. Khớp mẫu bao gồm việc chỉ định các mẫu mà dựa vào đó, số liệu phải tuân theo; tiếp đến là kiểm tra việc tuân thủ này rồi gỡ các thành phần của số liệu dựa theo mẫu đã cho.

four!

Khi định nghĩa hàm, bạn có thể định nghĩa các phần thân hàm riêng cho từng mẫu. Điều này dẫn đến mã lệnh rất gọn gàng, đơn giản, và dễ đọc. Bạn có thể khớp mẫu với bất kì kiểu dữ liệu nào — số, kí tự, danh sách, bộ, v.v. Ta hãy tạo một hàm nhỏ để kiểm tra xem số nhập vào có phải là 7 hay không.

lucky :: (Integral a) => a -> String
lucky 7 = "LUCKY NUMBER SEVEN!"
lucky x = "Sorry, you're out of luck, pal!"

Khi bạn gọi lucky, các dạng mẫu sẽ được kiểm tra từ trên xuống dưới và khi nó phù hợp với một mẫu thì phần thân hàm tương ứng sẽ được dùng. Cách duy nhất để một số phù hợp với mẫu thứ nhất trên đây là nó phải bằng 7. Nếu không, nó rơi vào mẫu thứ hai, vốn khớp với bất cứ thứ gì và gán giá trị này vào x. Hàm này cũng có thể được thực hiện bằng một lệnh if. Nhưng sẽ thế nào nếu ta muốn một hàm để đọc ra con số từ 1 đến 5 và nói "Not between 1 and 5" với bất kể số nào khác? Nếu không dùng khớp mẫu, ta sẽ phải lập một cây if-then-else lồng nhau chằng chịt. Tuy nhiên, nếu có dùng thì:

sayMe :: (Integral a) => a -> String
sayMe 1 = "One!"
sayMe 2 = "Two!"
sayMe 3 = "Three!"
sayMe 4 = "Four!"
sayMe 5 = "Five!"
sayMe x = "Not between 1 and 5"

Lưu ý rằng nếu ta chuyển mẫu cuối cùng (trường hợp bắt-tất cả) lên đầu, thì nó sẽ luôn nói rằng "Not between 1 and 5", vì nó sẽ bắt mọi số và không còn cơ hội rơi xuống dưới để kiểm tra các mẫu còn lại nữa.

Bạn còn nhớ hàm giai thừa mà ta đã viết trước đây chứ? Ta đã định nghĩa giai thừa của một số n tích [1..n]. Ta cũng định nghĩa một hàm giai thừa theo cách đệ quy, cách thường dùng trong toán học. Ta bắt đầu bằng cách nói giai thừa của 0 là 1. Sau đó ta nói rằng giai thừa của bất kì số nguyên nào thì bằng số nguyên đó nhân với giai thừa của số liền trước. Sau đây là cách viết trong Haskell:

factorial :: (Integral a) => a -> a
factorial 0 = 1
factorial n = n * factorial (n - 1)

Đây là lần đầu tiên ta định nghĩa giai thừa một cách đệ quy. Đệ quy rất quan trọng trong Haskell và sau này ta sẽ xét nó kĩ hơn. Nhưng nói chung, đây là những điều sẽ xảy ra khi ta thử lấy giai thừa của 3 chẳng hạn. Nó sẽ đi tính 3 * factorial 2. Giai thừa của 2 là 2 * factorial 1, vì vậy bây giờ ta có 3 * (2 * factorial 1). factorial 11 * factorial 0, vì vậy ta có 3 * (2 * (1 * factorial 0)). Bây giờ có một mẹo — vì ta đã định nghĩa giai thừa của 0 là 1 và vì nó đã gặp mẫu này từ trước mẫu bắt-tất cả, nên nó trả lại 1. Vì vậy kết quả cuối cùng thì bằng 3 * (2 * (1 * 1)). Nếu ta mà viết mẫu thứ hai lên trước mẫu thứ nhất thì nó sẽ bắt tất cả các số, kể cả 0, và phép tính sẽ không bao giờ kết thúc. Đó là lý do tại sao thứ tự lại quan trọng trong việc chỉ định các mẫu và tốt nhất là ta luôn chỉ định trường hợp cụ thể nhất đầu tiên rồi sau đó mới đến các trường hợp chung.

Khớp mẫu cũng có thể thất bại. Nếu ta định nghĩa một hàm như sau:

charName :: Char -> String
charName 'a' = "Albert"
charName 'b' = "Broseph"
charName 'c' = "Cecil"

rồi thử gọi nó với một đầu vào mà ta không lường trước, thì đây là điều sẽ xảy ra:

ghci> charName 'a'
"Albert"
ghci> charName 'b'
"Broseph"
ghci> charName 'h'
"*** Exception: tut.hs:(53,0)-(55,21): Non-exhaustive patterns in function charName

Nó phàn nàn rằng ta có tập hợp mẫu không triệt để, và quả thực như vậy. Khi biên soạn các mẫu, ta luôn phải kèm theo một mẫu bắt-tất cả để cho chương trình không bị đổ vỡ nếu có dữ liệu đầu vào không lường trước.

Khớp mẫu cũng có thể dùng cho các bộ. Điều gì sẽ xảy ra nếu ta muốn tạo một hàm nhận vào 2 véc-tơ trong không gian 2 chiều (cho dưới dạng các cặp) rồi cộng chúng lại với nhau? Để cộng hai véc-tơ, ta cộng hai thành phần x và rồi hai thành phần y một cách riêng biệt. Đây là cách làm nếu ta không biết gì về khớp mẫu:

addVectors :: (Num a) => (a, a) -> (a, a) -> (a, a)
addVectors a b = (fst a + fst b, snd a + snd b)

Ừ, được rồi, nhưng còn một cách khác hay hơn. Ta hãy sửa lại hàm sao cho nó dùng đến khớp mẫu.

addVectors :: (Num a) => (a, a) -> (a, a) -> (a, a)
addVectors (x1, y1) (x2, y2) = (x1 + x2, y1 + y2)

Đó! Thế này hay hơn nhiều. Lưu ý rằng đây đã là một mẫu bắt-tất cả. Kiểu của addVectors (trong cả hai trường hợp) là addVectors :: (Num a) => (a, a) -> (a, a) - > (a, a), vì vậy ta được đảm bảo sẽ nhận lấy hai cặp làm tham số.

fstsnd tách lấy các thành phần của một cặp. Nhưng còn bộ ba thì sao? Ồ, không có hàm nào lập sẵn giúp ta việc đó nhưng ta có thể tự tạo ra.

first :: (a, b, c) -> a
first (x, _, _) = x

second :: (a, b, c) -> b
second (_, y, _) = y

third :: (a, b, c) -> c
third (_, _, z) = z

Dấu _ nghĩa là “cùng thứ đó”, giống như vai trò của nó ở trong dạng gộp danh sách. Nghĩa là ta thực sự không quan tâm rằng phần nào, chỉ cần viết một dấu _.

Điều này làm tôi nhớ ra rằng, bạn cũng có thể khớp mẫu trong dạng gộp danh sách. Hãy kiểm tra nhé:

ghci> let xs = [(1,3), (4,3), (2,4), (5,3), (5,6), (3,1)]
ghci> [a+b | (a,b) <- xs]
[4,7,6,8,11,4]

Nếu một phép khớp bị thất bại, nó sẽ tự nhảy sang phần tử kế tiếp.

Bản thân danh sách cũng có thể được dùng để khớp mẫu. Bạn có thể khớp với một danh sách rỗng [] hay bất kì mẫu nào có liên quan đến : và danh sách rỗng. Nhưng vì [1,2,3] chỉ là cách viết tiện lợi cho 1:2:3:[], bạn có thể dùng cách viết đầu. Một mẫu kiểu như x:xs sẽ gắn đầu của danh sách cho x và phần còn lại của nó với xs, ngay cả khi chỉ có một phần tử vì vậy cuối cùng xs sẽ là danh sách rỗng.

Lưu ý: Mẫu x:xs được dùng đến rất nhiều, đặc biệt là với các hàm đệ quy. Nhưng các mẫu có chứa : chỉ khớp với các danh sách có độ dài 1 hoặc hơn.

Nếu bạn muốn gắn, chẳng hạn, 3 phần tử đầu với các biến và phần còn lại của danh sách cho một biến khác, bạn có thể dùng cách viết kiểu như x:y:z:zs. Nó sẽ chỉ khớp với danh sách nào có 3 phần tử hoặc nhiều hơn.

Bây giờ khi đã biết cách khớp mẫu với danh sách, ta hãy tự lập lấy hàm head.

head' :: [a] -> a
head' [] = error "Can't call head on an empty list, dummy!"
head' (x:_) = x

KIểm tra xem nó có hoạt động không:

ghci> head' [4,5,6]
4
ghci> head' "Hello"
'H'

Tốt rồi! Lưu ý rằng nếu bạn muốn gắn vài biến khác nhau (ngay cả khi một trong số chúng chỉ là _ và thực ra không gắn gì), ta phải kẹp chúng vào giữa cặp ngoặc tròn. Cũng lưu ý hàm error mà ta đã dùng. Nó nhận vào một chuỗi và phát sinh ra lỗi thực thi, dùng chuỗi đó như là lời thông báo cho biết lỗi xảy ra thuộc loại gì. Nó khiến cho chương trình đổ vỡ, vì vậy dùng nhiều quá sẽ không tốt. Nhưng nếu gọi head với danh sách rỗng sẽ không có ý nghĩa gì.

Ta hãy tạo một hàm nhỏ để báo cho biết một vài phần tử đầu tiên thuộc danh sách, theo cách nói tiếng Anh (dễ hiểu).

tell :: (Show a) => [a] -> String
tell [] = "The list is empty"
tell (x:[]) = "The list has one element: " ++ show x
tell (x:y:[]) = "The list has two elements: " ++ show x ++ " and " ++ show y
tell (x:y:_) = "This list is long. The first two elements are: " ++ show x ++ " and " ++ show y

Hàm này an toàn vì nó xử lý được cả danh sách rỗng, danh sách một phần tử, danh sách với hai phần tử, và nhiều hơn hai phần tử. Lưu ý rằng (x:[])(x:y:[]) có thể được viết lại thành [x][x,y] (nhờ cách viết tiện lợi, ta không cần đến cặp ngoặc tròn). Ta không thể viết lại (x:y:_) với cặp ngoặc vuông vì nó cần phải khớp với một danh sách có độ dài hai phần tử hoặc hơn.

Ta đã tự lập lấy hàm length bằng dạng gộp danh sách. Bây giờ ta hãy lập nó bằng cách dùng khớp mẫu và một chút đệ quy:

length' :: (Num b) => [a] -> b
length' [] = 0
length' (_:xs) = 1 + length' xs

Cách này cũng tương tự như hàm giai thừa mà ta viết trước đây. Đầu tiên ta định nghĩa kết quả cho một danh sách đầu vào đã biết — danh sách rỗng. Đây cũng được gọi là điều kiện biên. Sau đó trong mẫu thứ hai ta lấy tách rời danh sách thành một phần tử đầu và một đoạn đuôi. Ta nói rằng chiều dài thì bằng 1 cộng với chiều dài của đuôi. Ta dùng _ để khớp với phần tử đầu vì thực ra không cần biết nó là gì. Cũng lưu ý rằng ta đã xử lý tất cả các mẫu có thể đối với danh sách. Mẫu thứ nhất khớp với một danh sách rỗng và mẫu thứ hai khớp với tất cả những thứ không phải danh sách rỗng.

Hãy xem điều gì xảy ra khi ta gọi length' đối với "ham". Trước hết, nó sẽ kiểm tra xem đây có phải là danh sách rỗng không. Vì không phải, nên nó lọt vào mẫu thứ hai, và khớp với mẫu này, theo đó chiều dài sẽ bằng 1 + length' "am", vì ta chia nó thành đầu và đoạn đuôi sau đó bỏ phần tử đầu đi. Được rồi. length' của "am" cũng tương tự, và bằng 1 + length' "m". Như thế hiện giờ ta có 1 + (1 + length' "m"). length' "m" bằng 1 + length' "" (cũng có thể được viết là 1 + length' []). Và ta đã định nghĩa length' [] bằng với 0. Vì vậy cuối cùng ta có 1 + (1 + (1 + 0)).

Ta hãy tự lập lấy hàm sum [tổng]. Ta biết rằng tổng của một danh sách rỗng thì bằng 0. Ta viết điều này dưới dạng một mẫu. Và ta cũng biết rằng tổng của danh sách thì bằng phần tử đầu cộng với tổng của phần còn lại trong danh sách. Như vậy nếu viết hẳn ra, ta có:

sum' :: (Num a) => [a] -> a
sum' [] = 0
sum' (x:xs) = x + sum' xs

Cũng có cái được gọi là mẫu ‘as’. Đó là cách tiện lợi để phá vỡ một đối tượng, dựa theo một mẫu, và gắn nó với các tên trong khi vẫn giữ mối tham chiếu đến tổng thể đối tượng ban đầu. Bạn làm điều này bằng cách đặt một tên và dấu @ phía trước một mẫu. Chẳng hạn, mẫu xs@(x:y:ys). Mẫu này sẽ khớp đúng như x:y:ys nhưng bạn sẽ dễ dàng lấy được cả danh sách ban đầu bằng xs thay vì tự gõ lại cả biểu thức x:y:ys trong phần thân hàm. Sau đây là một ví dụ “mì ăn liền”:

capital :: String -> String
capital "" = "Empty string, whoops!"
capital all@(x:xs) = "The first letter of " ++ all ++ " is " ++ [x]
ghci> capital "Dracula"
"The first letter of Dracula is D"

Thông thường ta dùng mẫu ‘as’ để tránh tự lặp lại khi khớp với một mẫu lớn hơn và vẫn phải dùng lại cả đối tượng trong phần thân hàm.

Một điều nữa — bạn không thể dùng ++ trong việc khớp mẫu. Nếu thử khớp mẫu với (xs ++ ys), thì những cái gì sẽ thuộc về danh sách thứ nhất và danh sách thứ hai? Chẳng có nghĩa lí gì. Có thể sẽ có nghĩa hơn nếu khớp đối tượng với (xs ++ [x,y,z]) hay đơn thuần là (xs ++ [x]), nhưng vì bản chất của danh sách, bạn không thể làm việc đó.

Chốt canh

guards

Trong khi mẫu là một cách để đảm bảo rằng một giá trị phù hợp theo một dạng nào đó rồi tháo rời nó, chốt canh lại là cách kiểm tra xem một thuộc tính nào đó của một giá trị (hoặc vài giá trị) là đúng hay sai. Nghe có vẻ rất giống lệnh if và có nhiều điểm tương đồng với if. Nhưng điều đáng nói là chốt canh dễ đọc hơn nhiều khi bạn có vài điều kiện khác nhau, đồng thời nó cũng kết hợp rất tốt với mẫu.

Thay vì giải thích cú pháp của chốt canh, ta hãy bắt tay vào lập ngay một hàm dùng cách này. Ta sẽ lập một hàm đơn giản để rầy la bạn theo cách khác nhau tùy theo BMI (body mass index, chỉ số khối lượng cơ thể) của bạn. BMI bằng với cân nặng của bạn chia cho chiều cao bình phương. Nếu có BMI thấp hơn 18.5 thì bạn được coi là nhẹ cân. Nếu từ 18.5 đến 25 thì bạn được coi là bình thường. 25 đến 30 đồng nghĩa với nặng cân còn hơn 30 là béo phì. Sau đây là hàm này (giờ thì ta không tính BMI mà hàm chỉ nhận vào số BMI rồi hoạnh họe bạn)

bmiTell :: (RealFloat a) => a -> String
bmiTell bmi
    | bmi <= 18.5 = "You're underweight, you emo, you!"
    | bmi <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"
    | bmi <= 30.0 = "You're fat! Lose some weight, fatty!"
    | otherwise   = "You're a whale, congratulations!"

Chốt canh được kí hiệu bởi các dấu sổ đi theo sau tên hàm cùng các tham số. Thường thì chúng được viết thụt đầu dòng một chút và xếp thẳng hàng. Chốt canh về cơ bản là một biểu thức boole. Nếu nó được định giá bằng True, thì phần thân hàm tương ứng sẽ được dùng đến. Nếu nó định giá bằng False, thì việc kiểm tra được chuyển đến chốt canh tiếp theo và cứ như vậy. Nếu ta gọi hàm này với 24.3, thì đầu tiên nó sẽ kiểm tra xem liệu số này có nhỏ hơn hoặc bằng 18.5 không. Vì không phải, nên nó lọt sang chốt canh sau. Việc kiểm tra được thực hiện với chốt thứ hai và vì 24.3 nhỏ hơn 25.0 nên chuỗi thứ hai được trả lại.

Điều này gợi nhớ đến một cây if else lớn trong các ngôn ngữ lập trình mệnh lệnh, chỉ khác ở chỗ cách này hay hơn và dễ đọc hơn nhiều. Dù cây if else thường dễ bị “ghét” nhưng đôi khi bài toán được xác định theo một cách rời rạc mà việc dùng cấu trúc này là không thể tránh khỏi. Dùng chốt canh là cách làm thay thế rất tiện lợi.

Nhiều khi, chốt canh cuối cùng là otherwise. otherwise được định nghĩa đơn giản là otherwise = True và bắt được mọi đối tượng. Điều này rất giống với mẫu, chỉ khác ở chỗ là với mẫu thì kiểm tra xem đầu vào có thỏa mãn một mẫu hay không, còn chốt canh thì kiểm tra điều kiện boole. Nếu tất cả chốt canh của một hàm đều định giá là False (mà ta chưa đặt chốt otherwise để bắt-tất cả) thì việc định giá sẽ chuyển sang mẫu tiếp theo. Đó là cách kết hợp nhịp nhàng giữa mẫu và chốt canh. Nếu không có chốt canh hay mẫu nào tìm được thì sẽ có lỗi xuất hiện.

Dĩ nhiên ta có thể dùng chốt canh với các hàm nhận vào bao nhiêu tham biến tùy ý. Thay vì để cho người dùng tự tính BMI của mình trước khi gọi hàm, ta hãy sửa lại hàm này sao cho nó nhận vào chiều cao và cân nặng rồi tính BMI giúp ta.

bmiTell :: (RealFloat a) => a -> a -> String
bmiTell weight height
    | weight / height ^ 2 <= 18.5 = "You're underweight, you emo, you!"
    | weight / height ^ 2 <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"
    | weight / height ^ 2 <= 30.0 = "You're fat! Lose some weight, fatty!"
    | otherwise                 = "You're a whale, congratulations!"

Để xem tôi có béo hay không …

ghci> bmiTell 85 1.90
"You're supposedly normal. Pffft, I bet you're ugly!"

A, tôi không béo. Nhưng Haskell bảo tôi xấu xí. Chả sao!

Lưu ý rằng không có dấu = nào ngay sáu tên hàm cùng các tham số của nó, trước chốt canh đầu tiên. Nhiều người mới học mắc phải lỗi cú pháp vì đôi khi họ đặt dấu bằng ở đó.

Một ví dụ rất đơn giản khác: hãy tự viết lấy hàm max. Hẳn bạn còn nhớ, nó nhận hai đối tượng có thể so sánh được rồi trả lại đối tượng lớn hơn trong số chúng.

max' :: (Ord a) => a -> a -> a
max' a b 
    | a > b     = a
    | otherwise = b

Chốt canh cũng có thể viết trên cùng một dòng, mặc dù tôi không khuyến khích bạn làm vậy vì nó khó đọc hơn, ngay cả với hàm ngắn. Nhưng để bạn thử thấy, ta có thể viết max' như thế này:

max' :: (Ord a) => a -> a -> a
max' a b | a > b = a | otherwise = b

Ối! Chẳng hề dễ đọc chút nào! Tiếp tục này: ta hãy lập lấy hàm compare bằng cách dùng chốt canh.

myCompare :: (Ord a) => a -> a -> Ordering
a `myCompare` b
    | a > b     = GT
    | a == b    = EQ
    | otherwise = LT
ghci> 3 `myCompare` 2
GT
Lưu ý: Chẳng những gọi được hàm theo dạng trung tố với các dấu nháy ngược, ta còn có thể định nghĩa chúng dùng dấu nháy ngược. Đôi khi cách này còn dễ đọc hơn.

Where!?

Ở mục trước, ta đã định nghĩa một hàm tính BMI và in ra lời la rầy như sau:

bmiTell :: (RealFloat a) => a -> a -> String
bmiTell weight height
    | weight / height ^ 2 <= 18.5 = "You're underweight, you emo, you!"
    | weight / height ^ 2 <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"
    | weight / height ^ 2 <= 30.0 = "You're fat! Lose some weight, fatty!"
    | otherwise                   = "You're a whale, congratulations!"

Lưu ý rằng ở đây ta đã tự lặp lại 3 lần. Ta tự lặp lại 3 lần. Tự lặp lại (3 lần) trong khi lập trình cũng tức anh ách như bị cú đá vào đầu vậy(!) Vì ta lặp lại biểu thức ba lần, nên tốt nhất là nếu ta tính nó một lần, gắn cho nó một cái tên rồi dùng tên đó thay vì biểu thức. À, ta có thể sửa lại hàm như sau:

bmiTell :: (RealFloat a) => a -> a -> String
bmiTell weight height
    | bmi <= 18.5 = "You're underweight, you emo, you!"
    | bmi <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"
    | bmi <= 30.0 = "You're fat! Lose some weight, fatty!"
    | otherwise   = "You're a whale, congratulations!"
    where bmi = weight / height ^ 2

Ta đã đặt từ khóa where sau các chốt canh (thường thì tốt nhất là viết thụt đầu dòng ‘where’ ngang với các dấu sổ thẳng) và sau đó ta định nghĩa các tên hoặc hàm. Những tên này có hiệu lực cho tất cả chốt canh và cho ta sự tiện lợi không phải viết lặp lại. Nếu có ý định tính BMI khác đi một chút, ta chỉ phải sửa lại một lần. Nó cũng giúp mã lệnh dễ đọc hơn bằng cách đặt tên cho các biểu thức và làm chương trình chạy nhanh hơn vì biến kiểu như bmi ở đây chỉ được tính một lần. Ta cũng có thể đi xa hơn một chút và biểu diễn hàm như sau:

bmiTell :: (RealFloat a) => a -> a -> String
bmiTell weight height
    | bmi <= skinny = "You're underweight, you emo, you!"
    | bmi <= normal = "You're supposedly normal. Pffft, I bet you're ugly!"
    | bmi <= fat    = "You're fat! Lose some weight, fatty!"
    | otherwise     = "You're a whale, congratulations!"
    where bmi = weight / height ^ 2
          skinny = 18.5
          normal = 25.0
          fat = 30.0

Các tên được định nghĩa ở trong phần ‘where’ của một hàm chỉ có tác dụng trong hàm đó, nên ta không phải lo lắng rằng liệu có làm lộn xộn không gian tên của các hàm khác hay không. Lưu ý rằng tất cả các tên đều được xếp thẳng hàng. Nếu ta không xếp như vậy thì Haskell sẽ bị lẫn vì khi đó nó không biết rằng các tên này đều cúng thuộc một khối lệnh.

Giá trị được gắn vào các tên trong đoạn where không được chia sẻ giữa các thân hàm của các mẫu khác nhau. Nếu bạn muốn nhiều mẫu của cùng một hàm chia sẻ một tên nào đó, bạn phải khai báo nó một cách toàn cục.

Bạn cũng có thể gắn các giá trị trong where theo kiểu khớp mẫu! Ta có thể viết lại đoạn lệnh có where trong hàm trên như sau:

    ...
    where bmi = weight / height ^ 2
          (skinny, normal, fat) = (18.5, 25.0, 30.0)

Hãy lập một hàm khá nhỏ trong đó ta nhận vào họ và tên người rồi trả lại chữ viết tắt cho họ tên đó.

initials :: String -> String -> String
initials firstname lastname = [f] ++ ". " ++ [l] ++ "."
    where (f:_) = firstname
          (l:_) = lastname

Ta có thể thực hiện khớp mẫu này trực tiếp ở các tham số hàm (như vậy sẽ ngắn hơn và rõ ràng hơn) nhưng ở đây ta chỉ cho thấy rằng có thể thực hiện thao tác này trong khi gắn giá trị ở khối ‘where’.

Cũng giống như việc ta vừa định nghĩa các hằng số trong khối ‘where’, bạn cũng có thể định nghĩa các hàm. Để nhất quán với chủ đề lập trình của ta, hãy cùng lập một hàm nhận vào một danh sách gồm các cặp cân nặng-chiều cao rồi trả lại một danh sách các chỉ số BMI.

calcBmis :: (RealFloat a) => [(a, a)] -> [a]
calcBmis xs = [bmi w h | (w, h) <- xs]
    where bmi weight height = weight / height ^ 2

Và đó là tất nhả những gì cần làm! Lý do ta phải giới thiệu bmi như một hàm trong ví dụ này là vì ta không thể chỉ tính một BMI từ tham số của hàm. Ta phải kiểm tra danh sách được truyền vào hàm và mỗi cặp trong danh sách thì có một BMI riêng.

Việc gắn tên trong where cũng có thể được lồng ghép. Có một cách viết thông dụng để tạo lập một hàm rồi định nghĩa một hàm trợ giúp nào đó trong vế where của hàm ban đầu, rồi chuyển vào hàm ban đầu này những hàm trợ giúp, mỗi hàm với vế where riêng của nó.

Let it be

Rất giống với gắn tên dùng where là gắn tên dùng let. Gắn tên dùng where là cấu trúc cú pháp cho phép bạn gắn giá trị vào những biến ở cuối một hàm và toàn bộ hàm có thể thấy được các biến đó, kể cả mọi chốt canh. Gắn tên dùng let cho phép bạn gắn biến ở mọi nơi và bản thân let là một biểu thức, nhưng có tính địa phương, và không truy cập được đến từ các chốt canh. Cũng như bất kì cấu trúc nào khác trong Haskell được dùng để gắn gía trị vào tên, let cũng có thể được dùng để khớp mẫu. Ta hãy cùng xem nó hoạt động ra sao. Đây là cách mà ta có thể định nghĩa một hàm cho ta diện tích bề mặt khối trụ tròn cho trước chiều cao và bán kính mặt đáy:

cylinder :: (RealFloat a) => a -> a -> a
cylinder r h =
    let sideArea = 2 * pi * r * h
        topArea = pi * r ^2
    in  sideArea + 2 * topArea

let it be

Dạng cú pháp là let <bindings> in <expression>. Các tên gọi mà bạn định nghĩa trong phần let thì biểu thức ở phần in đều truy cập tới được. Bạn thấy đấy, ta cũng có thể định nghĩa được bằng cách gắn với where. Lưu ý rằng các tên đều được xếp vào cùng một cột. Vậy có gì khác giữa hai cách? Bây giờ dường như có thể thấy rằng let đặt các tên được gắn lên trước, rồi mới đến biểu thức sử dụng chúng, trong khi where thì đặt theo thứ tự ngược lại.

Khác biệt là ở chỗ bản thân các let là những biểu thức, còn where chỉ là cấu trúc cú pháp. Bạn còn nhớ rằng khi ta viết lệnh if và nó được giải thích rằng một lệnh if else là một biểu thức và ta có thể đặt nó vào bất kì nơi đâu chứ?

ghci> [if 5 > 3 then "Woo" else "Boo", if 'a' > 'b' then "Foo" else "Bar"]
["Woo", "Bar"]
ghci> 4 * (if 10 > 5 then 10 else 0) + 2
42

Bạn cũng có thể làm như vậy với let.

ghci> 4 * (let a = 9 in a + 1) + 2
42

Chúng còn có thể được dùng để giới thiệu hàm trong một phạm vi địa phương:

ghci> [let square x = x * x in (square 5, square 3, square 2)]
[(25,9,4)]

Nếu ta muốn gắn nhiều biến trên cùng dòng lệnh, rõ ràng ta không thể xếp chúng thẳng theo cột được. Đó là vì sao ta có thể ngăn cách chúng bằng dấu chấm phẩy.

ghci> (let a = 100; b = 200; c = 300 in a*b*c, let foo="Hey "; bar = "there!" in foo ++ bar)
(6000000,"Hey there!")

Bạn không cần phải đặt dấu chấm phẩy sau lần gắn cuối cùng nhưng nếu muốn thì đặt vào cũng không sao. Như chúng tôi đã nói từ trước, bạn có thể khớp mẫu với let. Chúng rất có ích khi cần nhanh chóng chia rẽ một bộ ra từng thành phần rồi gắn mỗi thành phần với tên hoặc những thứ tương tự.

ghci> (let (a,b,c) = (1,2,3) in a+b+c) * 100
600

Bạn cũng có thể đặt let bên trong dạng gộp danh sách. Hãy viết lại ví dụ trước đây để tính danh sách các cặp cân nặng-chiều cao nhưng dùng let bên trong dạng gộp danh sách thay vì định nghĩa một hàm phụ bằng where.

calcBmis :: (RealFloat a) => [(a, a)] -> [a]
calcBmis xs = [bmi | (w, h) <- xs, let bmi = w / h ^ 2]

Ta có thể kèm let bên trong một dạng gộp danh sách, như vai trò một vị ngữ, chỉ khác rằng nó không lọc danh sách, mà chỉ thực hiện gắn giá trị vào tên. Các tên được định nghĩa ở let bên trong dạng gộp danh sách đều “nhận thấy” được bởi hàm đầu ra (phần phía trước dấu |) và tất cả các vị ngữ và bộ phận đi sau phép gắn. Vì vậy ta có thể làm cho hàm vừa viết chỉ trả lại BMI cho người béo:

calcBmis :: (RealFloat a) => [(a, a)] -> [a]
calcBmis xs = [bmi | (w, h) <- xs, let bmi = w / h ^ 2, bmi >= 25.0]

Ta không thể dùng tên bmi trong phần (w, h) <- xs vì nó được định nghĩa trước let.

Ta bỏ qua phần in của let khi dùng chúng trong dạng gộp danh sách vì khả năng nhận thấy tên thì đã được định nghĩa trước ở đó rồi. Tuy vậy, ta có thể dùng let in trong vị ngữ và các tên sẽ chỉ nhìn thấy được trong vị ngữ đó. Phần in cũng có thể bỏ qua được khi dịnh nghĩa hàm và hằng số trực tiếp trong GHCI. Nếu ta làm như vậy, thì các tên sẽ nhìn thấy được trong suốt một phiên tương tác với GHCI.

ghci> let zoot x y z = x * y + z
ghci> zoot 3 9 2
29
ghci> let boot x y z = x * y + z in boot 3 4 2
14
ghci> boot
<interactive>:1:0: Not in scope: `boot'

Nếu gắn giá trị bằng let hay như vậy thì sao ta không dùng nó để thay hẳn cho where, bạn có thể thắc mắc. À, vì let là các biểu thức và về phạm vi thì khá “địa phương” nên chúng không thể dùng chung giữa các chốt canh được. Có người thích gắn bằng where hơn vì các tên gọi đi sau hàm có tên đó. Bằng cách này, phần thân hàm gần sát với tên hơn và tùy theo từng người, cách này có thể dễ đọc hơn.

Biểu thức case

case

Nhiều ngôn ngữ mệnh lệnh (C, C++, Java, v.v.) có cú pháp case và nếu bạn đã từng lập trình dùng cú pháp này thì có lẽ bạn biết rằng nó như thế nào. ĐÓ là cách lấy một biến rồi thực hiện các khối lệnh đối với tùy theo giá trị cụ thể của biến đó, và có thể bao gồm cả khối lệnh bắt buộc cho mọi trường hợp đề phòng trường hợp biến chứa một giá trị nào đó mà chưa có lệnh thực hiện tương ứng.

Haskell tiếp nhận khái niệm này và nâng nó lên một tầm cao mới. Giống như tên gọi đã bao hàm cả ý nghĩa, biểu thức case là những biểu thức, rất giống với biểu thức if else và let. Không những ta có thể định lượng được biểu thức dựa trên những trường hợp có thể xảy ra của giá trị biến, mà ta cũng có thể thực hiện khớp mẫu. Hmmm, lấy một biến, khớp mẫu nó, lượng giá các đoạn mã lệnh dựa theo giá trị của nó, ta đã nghe thấy điều này ở đâu rồi nhỉ? À đúng rồi, khớp mẫu theo các tham số trong lời định nghĩa hàm! Vậy thực ra đó là cách viết tiện lợi cho biểu thức case. Hai đoạn mã lệnh sau cùng thực hiện một công việc và có thể dùng thay thế nhau được:

head' :: [a] -> a
head' [] = error "No head for empty lists!"
head' (x:_) = x
head' :: [a] -> a
head' xs = case xs of [] -> error "No head for empty lists!"
                      (x:_) -> x

Bạn có thể thấy, cú pháp của biểu thức case khá đơn giản:

case bthuc of mau -> kqua
                   mau -> kqua
                   mau -> kqua
                   ...

bthuc được khớp với các mẫu. Hành động khớp mẫu này giống như ta trông đợi: mẫu đầu tiên khớp đúng với biểu thức thì sẽ được dùng. Nếu xuyên suốt cả biểu thức case mà không tìm được mẫu nào thích hợp, thì một lỗi thực thi sẽ xuất hiện.

Trong khi khớp mẫu với tham số của hàm chỉ có thể được thực hiện khi định nghĩa hàm, thì các biểu thức case có thể được dùng gần như mọi nơi. Chẳng hạn:

describeList :: [a] -> String
describeList xs = "The list is " ++ case xs of [] -> "empty."
                                               [x] -> "a singleton list." 
                                               xs -> "a longer list."

Chúng rất có ích cho việc khớp mẫu so với một đối tượng nào đó ở giữa một biểu thức. Vì khớp mẫu trong định nghĩa hàm là cách viết tiện lợi cho biểu thức case, ta cũng có thể định nghĩa hàm trên như sau:

describeList :: [a] -> String
describeList xs = "The list is " ++ what xs
    where what [] = "empty."
          what [x] = "a singleton list."
          what xs = "a longer list."

2 phản hồi

Filed under Haskell

2 responses to “Chương 4: Cú pháp dùng trong hàm

  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

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