Skip to main content

Bài 06- Đệ quy và Fold

TÓM TẮT
  • Tại sao lại cần Đệ quy?
  • Tư duy đệ quy
    • sumproduct
  • Các bước để tạo hàm đệ quy của riêng bạn
  • Ví dụ về đệ quy
    • andlengthreversedroptakemapfilter
  • Trích xuất mẫu foldr
  • Hàm foldl
  • Hàm foldl'
  • Khi nào thì sử dụng foldrfoldl, và foldl'
Video bài giảng
Chúng tôi đang dịch thuyết minh bài giảng sang tiếng Việt

Tại sao đệ quy (Recursion)?

Một trong những hàm cơ bản cần thiết trong bất kỳ ngôn ngữ lập trình nào là sự lặp lại. Ví dụ:

  • Bạn có một danh sách các đối tượng và muốn làm điều gì đó với tất cả chúng. Từng cái một.
  • Bạn muốn thực hiện một số phép tính 5 lần với các giá trị khác nhau.
  • Vân vân.

Trong các ngôn ngữ lập trình mệnh lệnh, các tác vụ lặp đi lặp lại này được xử lý bằng cách sử dụng các vòng lặp lặp đi lặp lại. Ví dụ: trong JavaScript, bạn có thể có:

for (i = 0; i < 5; i = i + 1) {
// Làm cái gì đó
}

let i = 0;
while (i < 5) {
// Làm cái gì đó
i = i + 1;
}

Tuy nhiên, nếu chúng ta cố gắng tạo một cái gì đó như thế này trong Haskell, chúng ta sẽ gặp vấn đề lớn. Và đó là biến i.

Như chúng ta đã đề cập trong bài một, Haskell là một ngôn ngữ hàm thuần túy (purely functional language). Nhưng hai khối mã này dựa vào sự thay đổi i trên mỗi lần lặp lại. Điều đó có nghĩa là chúng có tác dụng phụ (side effect) là cập nhật trạng thái chung khi chương trình tiến triển.

Vì vậy, trong Haskell, chúng ta không có các hàm lặp tích hợp sẵn này. Thay vào đó, chúng ta có đệ quy!

Và làm thế nào đệ quy lại có thể tốt hơn vòng lặp? Dưới đây là một vài lý do:

Lý do tại sao đệ quy lại hữu ích:

  • Mọi thứ bạn có thể làm với vòng lặp, bạn có thể làm điều đó bằng cách sử dụng đệ quy. Và trên hết, thậm chí có những chương trình mà bạn có thể định nghĩa đệ quy mà không thể viết bằng vòng lặp for.
  • Nhiều hàm (nếu không muốn nói là hầu hết) có thể được định nghĩa một cách tự nhiên bằng cách sử dụng đệ quy. Điều này có nghĩa là cách bạn nghĩ một cách trừu tượng về hàm và cách bạn viết nó bằng cách sử dụng đệ quy là rất giống nhau.
  • Một số hàm rõ ràng hơn và ngắn gọn hơn nếu được định nghĩa bằng cách sử dụng đệ quy.
  • Bạn có thể sử dụng phương pháp quy nạp để lập luận toán học và chứng minh các thuộc tính của các hàm được định nghĩa bằng cách sử dụng đệ quy. (Cao cấp hơn, nhưng vô cùng mạnh mẽ.)

Bây giờ bạn đã biết mình sắp học một khái niệm khá mạnh mẽ, hãy bắt đầu đi sâu vào!

Tư duy đệ quy

Đệ quy xảy ra khi một thứ được định nghĩa theo chính nó. Vì vậy, một hàm đệ quy là một hàm được định nghĩa theo chính nó.

Khái niệm này thực sự đơn giản. Việc thực hiện là những gì gây ra hầu hết các rắc rối. Vì vậy, chúng ta sẽ bắt đầu bằng cách định nghĩa một hàm sử dụng cả vòng lặp for (sử dụng Python) và đệ quy (sử dụng Haskell) để làm nổi bật sự khác biệt trong cách suy nghĩ về vấn đề.

Giả sử chúng ta muốn tính tổng của một danh sách các số.

Cả Python và Haskell đều có hàm sum đã làm điều đó. Nhưng lần này, chúng ta sẽ tạo nó từ đầu. Trong các ngôn ngữ mệnh lệnh, bạn sẽ viết như sau:

def sum(list):
total = 0
for i in list:
total = total + i
return total

Ở đây, bạn đang mô tả từng bước những gì chương trình nên làm:

  1. Chúng ta tạo một hàm có tên sum lấy phần mở rộng list.
  2. Sau đó, chúng ta tạo một biến được gọi total với giá trị ban đầu là 0.
  3. Sau đó, đối với mỗi phần tử trong danh sách, chúng ta lấy total, thêm phần tử vào phần tử đó và ghi đè phép gán cho total bằng giá trị mới.
  4. Sau khi vòng lặp kết thúc, hàm trả về biến total.

Như bạn có thể thấy, trong các ngôn ngữ mệnh lệnh, chúng ta sử dụng một chuỗi các câu lệnh để định nghĩa CÁCH để đạt được mục tiêu. Trong trường hợp này, tổng của các phần tử trong danh sách.

Để dễ dàng viết các hàm đệ quy, bạn phải loại bỏ lối suy nghĩ đó và chuyển sang lập trình khai báo. Nơi bạn tuyên bố những thứ LÀ thay vì làm thế nào để có được chúng từng bước.

Bây giờ, hãy định nghĩa hàm tương tự trong Haskell.

Như mọi khi, điều đầu tiên chúng ta cần làm là viết khai báo kiểu:

sum :: [Int] -> Int

Vì vậy, chúng ta biết nó lấy một danh sách các số nguyên và trả về một số nguyên.

Bây giờ, dựa trên hàm LÀ: Hàm lấy danh sách các số và trả về tổng của nó, bước tiếp theo là tìm các trường hợp còn lại.

Chúng ta lấy một danh sách làm đầu vào. Điều gì xảy ra nếu danh sách trống chẳng hạn? Chà, trong trường hợp đó, chúng ta biết rằng tổng của một danh sách trống LÀ 0. Vì vậy, chúng ta có thể bắt đầu bằng cách định nghĩa rằng:

sum :: [Int] -> Int
sum [] = 0

Bây giờ là trường hợp khi có các phần tử bên trong danh sách:

sum :: [Int] -> Int
sum [] = 0
sum (x:xs) =

Nếu chúng ta nghĩ về hàm sum LÀ gì trong định nghĩa thứ hai, thì đó là một hàm lấy danh sách Int không trống và cộng chúng lại. Điều này giống như việc thêm x (phần tử đầu tiên) vào kết quả của việc thêm tất cả các Int trong xs (danh sách các phần từ còn lại). Vì vậy, chúng ta có thể làm một cái gì đó như:

sum :: [Int] -> Int
sum [] = 0
sum (x:xs) = x + ...

Và bây giờ, chúng ta cần tìm tổng của tất cả các phần tử trong xs. Nhưng đợi một chút… chúng ta đã có hàm để làm điều đó! nó giống như chúng ta đang định nghĩa ngay bây giờ! Vì vậy, chúng ta chỉ có thể sử dụng nó!:

sum :: [Int] -> Int
sum [] = 0
sum (x:xs) = x + sum xs

Như vậy đó! Chúng ta đã triển khai hàm đệ quy đầu tiên của mình! Tại sao? Bởi vì chúng ta đã định nghĩa sum bằng cách sử dụng chính nó!

Hãy xem điều gì sẽ xảy ra khi chúng ta sử dụng hàm này. Ví dụ: hãy tính tổng của một danh sách chứa tất cả các số nguyên từ 1 đến 5 :

sum [1,2,3,4,5] = 1 + sum [2,3,4,5]
= 1 + 2 + sum [3,4,5]
= 1 + 2 + 3 + sum [4,5]
= 1 + 2 + 3 + 4 + sum [5]
= 1 + 2 + 3 + 4 + 5 + sum []
= 1 + 2 + 3 + 4 + 5 + 0
= 15

Và đó là cách Haskell đánh giá hàm của chúng ta.

Lưu ý rằng trường hợp cơ sở (base case, trong ví dụ này là sum [] = 0) là trường hợp cho phép chúng ta dừng đệ quy và có kết quả. Nếu chúng ta định nghĩa hàm đệ quy mà không có trường hợp cơ sở, thì hàm đó sẽ bị lỗi hoặc chạy vĩnh viễn.

Vì vậy, ngắn gọn lại là:

Với các vòng lặp, bạn thay đổi ngữ cảnh bằng bộ tích lũy đột biến bao gồm các bước để định nghĩa CÁCH đạt được mục tiêu.

Với đệ quy, bạn bọc hàm với chính nó, điều này tạo ra một ngữ cảnh mới với đột biến mong muốn. Và hàm đó, đến lượt nó, lại gọi chính nó, thiết lập bối cảnh riêng của nó, v.v.

Bây giờ, mặc dù đây là hướng dẫn đầy đủ về cách tạo sum hàm đệ quy, lý do có thể hơi quá cụ thể để áp dụng cho các hàm khác.

Để giúp bạn dễ dàng tạo các hàm đệ quy của riêng mình, chúng ta sẽ viết một hướng dẫn chung từng bước để bạn có thể áp dụng cho mọi trường hợp. Tiếp tục nào!

Các bước để tạo hàm đệ quy của riêng bạn

Tôi đã chuẩn bị một phiên bản sửa đổi đôi chút của các bước do Tiến sĩ Graham Hutton duy nhất tạo ra. Nhà nghiên cứu, giáo viên và thành viên hội đồng quản trị nổi tiếng của Haskell Foundation. Vì vậy… bạn biết đấy… đây là vấn đề thực sự:

  1. Viết ra Kiểu (type): Điều này sẽ giúp bạn định nghĩa hàm sau này. (Bạn phải luôn định nghĩa Kiểu trước, ngay cả khi bạn không định nghĩa hàm đệ quy.)
  2. Liệt kê các trường hợp có thể xảy ra mà bạn có thể có dựa trên đầu vào của nó. (Bắt đầu với những cái "tiêu chuẩn" và thay đổi hoặc tinh chỉnh chúng nếu cần.)
  3. Giữa tất cả các trường hợp đã khởi tạo trước đó, hãy định nghĩa trường hợp nào đơn giản nhất và định nghĩa chúng. (Đây thường là các trường hợp cơ sở (hoặc mở rộng).)
  4. Hãy suy nghĩ về những gì bạn có sẵn (tham số, hàm, toán tử, giá trị khác, toán tử cho Kiểu đó, v.v.).
  5. Xác định các trường hợp còn lại.
  6. Mường tượng về hàm. Định nghĩa có thể được đơn giản hóa hay không? Khai báo có thể được khái quát hóa không? (chúng ta sẽ xem cách thực hiện trong các bài học sau) Nó có làm được những gì bạn dự định không?

Không phải lúc nào bạn cũng phải trải qua các bước này. Khi bạn cảm thấy thoải mái hơn, bạn có thể bỏ qua một số hoặc thậm chí viết hàm ngay lập tức.

Nói chung, trường hợp cơ sở (hoặc mở rộng) thường là trường hợp "nhận dạng". Một trường hợp không sửa đổi kết quả mà chỉ dừng đệ quy. Ở đây chúng ta có một vài ví dụ:

Hai mẫu tiêu chuẩn phổ biến:
  • Đối với các hàm đệ quy lấy các số không âm làm đầu vào, bạn thường (không phải luôn luôn) có trường hợp cơ sở (hoặc mở rộng) 0 hoặc 1 (tùy thuộc vào thao tác) và trường hợp đệ quy là n.
  • Đối với các hàm đệ quy lấy danh sách làm đầu vào, bạn thường (không phải luôn luôn) có trường hợp cơ sở (hoặc mở rộng) của [] (danh sách trống) và trường hợp đệ quy của (x:xs) (danh sách không trống).

Vì vậy, nếu chúng ta muốn sửa đổi sum hàm để tính tích của các phần tử trong danh sách và chúng ta chỉ cần thay đổi trường hợp đệ quy như thế này:

product :: [Int] -> Int
product [] = 0
product (x:xs) = x * product xs -- Chỉ thay đổi dấu + thành dấu *

Chúng ta gặp vấn đề là hàm này luôn trả về 0. Bởi vì tất cả các phần tử của danh sách được nhân lên 0 ở cuối đệ quy do trường hợp cơ sở!

Vì vậy, thay vào đó, cách chính xác để định nghĩa trường hợp cơ sở product là cung cấp "danh tính" cho * hàm sản phẩm ( ), đó là 1 :

product :: [Int] -> Int
product [] = 1
product (x:xs) = x * product xs

Vậy là xong. Chúng ta đã định nghĩa hàm đệ quy thứ hai.

Thực hành là thứ sẽ cung cấp cho bạn trực giác cần thiết để nhanh chóng định nghĩa các hàm đệ quy. Vì vậy, hãy định nghĩa một loạt các hàm để làm cho trực giác đó hoạt động! 💪

Ví dụ đệ quy

Lưu ý: Tôi đã thêm dấu ' vào tất cả các tên vì tất cả các hàm này đã tồn tại trong Haskell.

and' : Hàm trả về trả về True khi và chỉ khi tất cả các phần tử của danh sách là True.

Vì vậy, nó lấy một danh sách các phép toán luận và trả về một phép toán luận. Dịch nó sang Kiểu:

and' :: [Bool] -> Bool

Bây giờ, vì cần một danh sách, chúng ta sẽ định nghĩa các trường hợp tiêu chuẩn cho danh sách:

and' :: [Bool] -> Bool
and' [] =
and' (x:xs) =

Trường hợp cơ bản có thể không rõ ràng. Chắc chắn, chỉ có hai giá trị để chọn vì đó là tệp Bool. Nhưng cái nào? Vì vậy, chúng ta sẽ bắt đầu với trường hợp đệ quy.

Bây giờ, hãy nghĩ về những gì chúng ta có sẵn cho chúng ta. Bởi vì chúng ta đang xử lý Bool, nên chúng ta có quyền truy cập vào tất cả các hoạt động boolean. Và có một cái làm những gì chúng ta cần nhưng chỉ giữa hai giá trị. Toán tử && (và).

Vì vậy, phần tử đầu tiên kết hợp sử dụng && với kết quả xử lý phần còn lại của danh sách sẽ cho chúng ta kết quả mong muốn:

and' :: [Bool] -> Bool
and' [] =
and' (x:xs) = x && ...

Và, bây giờ chúng ta phải trả về True nếu và chỉ khi tất cả các phần tử của danh sách xs là True. Có nghĩa là chúng ta cần hàm tương tự mà chúng ta đang định nghĩa ngay bây giờ. Vì vậy, chúng ta áp dụng nó cho xs:

and' :: [Bool] -> Bool
and' [] =
and' (x:xs) = x && and' xs

Và bây giờ, trường hợp cơ bản là rõ ràng! Nếu chúng ta sử dụng False, thì việc chúng ta xử lý danh sách nào không quan trọng, chúng ta sẽ luôn nhận được False vì && False luôn bằng False.

Nhưng nếu chúng ta sử dụng True, chúng ta sẽ không sửa đổi kết quả! Vì kết quả của && True phụ thuộc vào vế trái còn thiếu. Nếu có một phần tử không có True trong danh sách, nó sẽ đưa chúng ta là False cho đến khi kết thúc. Còn không, nó sẽ cho chúng ta True !

Một cách khác để tìm ra điều này là tìm ra đó True là định danh cho && :

and' :: [Bool] -> Bool
and' [] = True
and' (x:xs) = x && and xs

and' [True, False, True]
and' [2 < 3, 4 == 4]

False

True

length' : Hàm cung cấp thông tin độ dài của danh sách

Để tính độ dài của một danh sách, chúng ta phải lấy một danh sách và trả về một số nguyên. Và bởi vì, về nguyên tắc, chúng ta sẽ không thao tác trên các phần tử của danh sách, nên chúng ta có thể sử dụng một kiểu đa hình như sau:

length' :: [a] -> Int

Bây giờ, vì cần một danh sách, chúng ta sẽ định nghĩa các trường hợp tiêu chuẩn cho danh sách:

length' :: [a] -> Int
length' [] =
length' (x:xs) =

Bây giờ, tìm kiếm các trường hợp dễ dàng, chúng ta có thể định nghĩa rằng độ dài của một danh sách trống tất nhiên là 0 các phần tử. Vì vậy, chúng ta thay thế rằng:

length' :: [a] -> Int
length' [] = 0
length' (x:xs) =

Và bây giờ, chúng ta có thể tính toán độ dài của danh sách nếu chúng ta cộng 1 cho từng phần tử của danh sách, phải không? Và bởi vì chúng ta có phần tử đầu tiên (x) được chọn ra bằng cách khớp mẫu, nên chúng ta có thể thêm 1 vào phần tử đó và tính toán đệ quy độ dài của phần còn lại của danh sách (xs):

length' :: [a] -> Int
length' [] = 0
length' (x:xs) = 1 + length' xs

Đó có thể là hàm cuối cùng. Nhưng vì chúng ta không thực sự sử dụng x, nên chúng ta có thể bỏ qua nó trong mẫu của mình:

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

length' [1,2,3,4,5]
length' ['a'..'z']

5

26

Và đó là định nghĩa cuối cùng của chúng ta.

reverse' : Hàm đảo ngược một danh sách.

Để đảo ngược một danh sách, chúng ta lấy một danh sách các phần tử và trả về một danh sách các phần tử. Và bởi vì, về nguyên tắc, chúng ta sẽ không thao tác trên các phần tử của danh sách, nên chúng ta có thể sử dụng một kiểu đa hình như sau:

reverse' :: [a] -> [a]

Bây giờ, vì cần một danh sách, chúng ta sẽ định nghĩa các trường hợp tiêu chuẩn cho danh sách:

reverse' :: [a] -> [a]
reverse' [] =
reverse' (x:xs) =

Mặt trái của danh sách trống nó chỉ là danh sách trống. Vì vậy, đó là một trong những dễ dàng. Và đó cũng là một trường hợp cơ bản vì nó không đệ quy:

reverse' :: [a] -> [a]
reverse' [] = []
reverse' (x:xs) =

Và bây giờ, nếu chúng ta lấy phần tử đầu tiên, đặt nó ở cuối và tiếp tục làm như vậy cho đến khi chúng ta đến cuối danh sách ban đầu, nó sẽ bị đảo ngược! Vì vậy, chúng ta chỉ cần lấy x, đặt nó ở cuối và thực hiện đệ quy tương tự cho đến khi hết phần tử, đây là trường hợp cơ bản của chúng ta:

reverse' :: [a] -> [a]
reverse' [] = []
reverse' (x:xs) = reverse' xs ++ [x]

reverse' [1,2,3,4,5]
reverse' "stressed" -- What's the reverse of stressed?

[5,4,3,2,1]

"desserts"

Được rồi. Chúng ta đã thấy đủ các ví dụ dễ dàng. Bây giờ hãy làm điều gì đó phức tạp hơn một chút:

drop' : Xóa n các phần tử đầu tiên khỏi danh sách

Vì vậy, nó nhận một số nguyên và một danh sách rồi trả về một danh sách. Và bởi vì, về nguyên tắc, chúng ta sẽ không thao tác trên các phần tử của danh sách, nên chúng ta có thể sử dụng một kiểu đa hình như sau:

drop' :: Int -> [a] -> [a]

ĐƯỢC RỒI! Điều này là mới! Chúng ta có hai đối số khác nhau để tính đến bây giờ.

Cách để làm điều này là trình bày tất cả các kết hợp mẫu tiêu chuẩn có thể có. Bởi vì chúng ta có số, ban đầu chúng ta tính đến mẫu cho 0 và bất kỳ số nào khác. Và bởi vì chúng ta có danh sách, chúng ta phải tính đến mẫu cho danh sách trống và không trống.

Vì vậy chúng ta có:

drop' :: Int -> [a] -> [a]
drop' 0 [] =
drop' 0 (x:xs) =
drop' n [] =
drop' n (x:xs) =

Như bạn có thể thấy, có nhiều thứ cần tính đến hơn. Nhưng nó không nhất thiết phải khó khăn hơn. Hãy suy nghĩ về từng trường hợp riêng lẻ.

  1. Nếu chúng ta loại bỏ 0 các phần tử khỏi một danh sách trống, điều đó có nghĩa là kết quả sẽ là một danh sách trống.
  2. Nếu chúng ta loại bỏ 0 các phần tử khỏi danh sách không trống, chúng ta sẽ trả về danh sách chính xác tương tự.
  3. Nếu chúng ta loại bỏ n các phần tử khỏi danh sách trống, chúng ta có thể trả về lỗi hoặc danh sách trống. Chúng ta chọn trả lại danh sách trống.

Thay thế điều đó trong các định nghĩa:

drop' :: Int -> [a] -> [a]
drop' 0 [] = []
drop' 0 (x:xs) = x:xs
drop' n [] = []
drop' n (x:xs) =

Của bạn đi. Chúng ta đã hoàn thành 3 trong số 4 trường hợp. Bây giờ, còn khi chúng ta muốn loại bỏ n số lượng phần tử khỏi danh sách không trống thì sao?

Chúng ta đã có phần tử đầu tiên được tách ra khỏi danh sách. Vì vậy, nếu chúng ta loại bỏ cái đó thì sẽ bớt một phần tử cần loại bỏ. Nhưng nếu chúng ta chỉ làm điều gì đó như drop n xs, hàm sẽ tiếp tục loại bỏ các phần tử cho đến khi danh sách trống.

May mắn thay, có một giải pháp dễ dàng. Nếu chúng ta gọi đệ quy drop' cùng xs, thì chúng ta sẽ loại bỏ một phần tử trên mỗi lệnh gọi đệ quy. Vì vậy, chúng ta có thể trừ 1 trên n mỗi cuộc gọi để giữ cho cuộc gọi được đồng bộ hóa. Theo cách đó, nếu có nhiều hơn n các phần tử, chúng ta sẽ dừng đệ quy khi chúng ta đạt được n = 0 :

drop' :: Int -> [a] -> [a]
drop' 0 [] = []
drop' 0 (x:xs) = x:xs
drop' n [] = []
drop' n (x:xs) = drop' (n - 1) xs

Được rồi. Chúng ta có một hàm làm việc. Nhưng có một vài điều cần được cải thiện:

  1. Cả hai trường hợp lấy một danh sách trống đều trả về một danh sách trống. Vì vậy, chúng ta có thể bỏ qua the Int trong những trường hợp đó.
  2. Trong trường hợp thứ hai, chúng ta chỉ truyền giá trị đầu vào, do đó không cần khớp mẫu.
  3. Chúng ta không sử dụng x trong định nghĩa đệ quy, vì vậy chúng ta cũng có thể bỏ qua nó.

Thực hiện những thay đổi đó, chúng ta nhận được:

drop' :: Int -> [a] -> [a]
drop' _ [] = []
drop' 0 xs = xs
drop' n (_:xs) = drop' (n - 1) xs

Có vẻ như chúng ta đã đi đến định nghĩa drop cuối cùng của mình. Nhưng chúng ta đã thực sự hoàn thành chưa? Điều gì xảy ra nếu n < 0? Về mặt lý thuyết, nó không có ý nghĩa gì cả. Nhưng trong thực tế, ai đó có thể đủ điên để thử nó!

Trong trường hợp đó, hàm hiện tại của chúng ta sẽ tiếp tục loại bỏ từng phần tử một cho đến khi hết vì chúng ta sẽ không bao giờ đến được n = 0.

Đó có thể là một cách để xử lý trường hợp đó. Nhưng theo trực giác, bạn sẽ nghĩ rằng việc loại bỏ một số phần tử âm sẽ giống như việc loại bỏ các phần tử bằng 0.

Vì vậy, chúng ta phải điều chỉnh định nghĩa của mình để phù hợp với điều đó. Và để làm điều đó, chúng ta có thể thay đổi trường hợp xử lý n == 0 thành xử lý n <= 0 bằng cách liên kết số với biến n và sử dụng bộ guards để kiểm tra thuộc tính mong muốn.

Như thế này:

drop' :: Int -> [a] -> [a]
drop' _ [] = []
drop' n xs | n <= 0 = xs
drop' n (_:xs) = drop' (n - 1) xs


drop' (-3) [1,2,3]

yesYouDo :: String -> String
yesYouDo = ("Ok, I do"++) . drop' 7

yesYouDo "I don't like chocolate."
yesYouDo "I don't like to write silly examples."

[1,2,3]

"Ok, I do like chocolate."

"Ok, I do like to write silly examples."

Và bây giờ hàm này hoạt động như mong muốn!

take' : Lấy (và trả lại) n các phần tử đầu tiên từ danh sách

Chức năng này tương tự một cách kỳ lạ với drop'. Nó nhận một số nguyên và một danh sách và trả về một danh sách. Nhưng lần này, danh sách chứa tất cả các phần tử từ phần tử đầu tiên cho đến phần tử n. Vì chúng ta vừa thấy một trường hợp tương tự nên chúng ta sẽ cùng nhau thực hiện bước đầu tiên và bước thứ hai:

take' :: Int -> [a] -> [a]
take' 0 [] =
take' 0 (x:xs) =
take' n [] =
take' n (x:xs) =

Tương tự như trước đây, hãy nghĩ về từng trường hợp riêng lẻ:

  1. Nếu chúng ta lấy 0 các phần tử từ một danh sách trống, điều đó có nghĩa là kết quả sẽ là một danh sách trống.
  2. Nếu chúng ta lấy 0 các phần tử từ một danh sách không trống, chúng ta không lấy gì cả, vì vậy chúng ta trả về một danh sách trống.
  3. Nếu chúng ta lấy n các phần tử từ danh sách trống, chúng ta có thể trả về lỗi hoặc danh sách trống. Chúng ta chọn trả lại danh sách trống.

Vì vậy, thay thế rằng:

take' :: Int -> [a] -> [a]
take' 0 [] = []
take' 0 (x:xs) = []
take' n [] = []
take' n (x:xs) =

Vâng, đó là dễ dàng. Bây giờ, đối với trường hợp đệ quy. Giống như lần trước, chúng ta cũng cần giảm n một trên mỗi bước. Nhưng, không giống như lần trước, bây giờ chúng ta muốn giữ các phần tử trên mỗi bước. Và có một cách dễ dàng để làm điều đó.

Chúng ta có thể thêm chúng vào một danh sách mới sẽ tiếp tục lớn hơn một cách đệ quy cho đến khi chúng ta tiếp cận n = 0 hoặc hết các phần tử trong danh sách:

take' :: Int -> [a] -> [a]
take' 0 [] = []
take' 0 (x:xs) = []
take' n [] = []
take' n (x:xs) = x : take' (n-1) xs

Bây giờ, chúng ta có thể đơn giản hóa biểu thức:

  1. Nếu n = 0, chúng ta không quan tâm đến danh sách. Dù sao thì chúng ta cũng sẽ trả về một danh sách trống.
  2. Nếu danh sách trống, chúng ta không quan tâm đến số lượng. Dù sao thì chúng ta cũng sẽ trả về một danh sách trống.

Được dịch sang mã:

take' :: Int -> [a] -> [a]
take' 0 _ = []
take' _ [] = []
take' n (x:xs) = x : take' (n-1) xs

Chúng ta có cùng một vấn đề như chúng ta đã làm với drop. Theo trực giác, việc lấy một số phần tử âm sẽ làm giống như việc lấy các phần tử bằng không. Nó không nên trả về toàn bộ danh sách.

May mắn thay, chúng ta đã biết cách giải quyết vấn đề này. Tương tự như với drop định nghĩa của ':

take' :: Int -> [a] -> [a]
take' n _ | n <= 0 = []
take' _ [] = []
take' n (x:xs) = x : take' (n-1) xs

take' 3 [1,2,3,4,5]
take' (-3) [1,2,3,4,5]

[1,2,3]

[]

map' : Một hàm bậc cao áp dụng một hàm cho mọi phần tử trong danh sách

Như mọi khi, hãy bắt đầu với Kiểu. Chúng ta sẽ có một hàm và một danh sách và sẽ trả về một danh sách. Bởi vì chúng ta không biết hàm sẽ được truyền dưới dạng đối số, nên chúng ta sẽ sử dụng các biến kiểu đa hình. Vì vậy, Kiểu là:

map' :: (a -> b) -> [a] -> [b]

Bây giờ, hãy liệt kê các trường hợp. Trong trường hợp của một hàm, chỉ có một trường hợp. Bạn nhận được hàm. Vì vậy, có tính đến các trường hợp "tiêu chuẩn" cho danh sách, chúng ta nhận được:

map' :: (a -> b) -> [a] -> [b]
map' f [] =
map' f (x:xs) =

Nếu chúng ta không có phần tử nào trong danh sách, chúng ta chỉ trả về danh sách trống. Đó sẽ là trường hợp cơ bản của chúng ta. Ngoài ra, chúng ta sẽ không sử dụng hàm này trong trường hợp này, vì vậy chúng ta có thể bỏ qua nó:

map' :: (a -> b) -> [a] -> [b]
map' _ [] = []
map' f (x:xs) =

Bây giờ đối với trường hợp đệ quy, chúng ta phải áp dụng hàm f cho mọi phần tử và trả về danh sách. Vì vậy, chúng ta có thể áp dụng f cho phần tử đầu tiên (x) và thêm nó vào trước việc sử dụng đệ quy của map' áp dụng cho phần còn lại của danh sách (xs):

map' :: (a -> b) -> [a] -> [b]
map' _ [] = []
map' f (x:xs) = f x : map' f xs


map' (+1) [1,2,3,4]
map' (++"!") ["Hey","Ho","Let's go"]

[2,3,4,5]

["Hey!","Ho!","Let's go!"]

Đây là một hàm cực kỳ hữu ích. Bạn sẽ sử dụng nó khá thường xuyên!

Bây giờ, hãy thực hiện một định nghĩa đệ quy cuối cùng trước khi tìm hiểu về folds!

filter' : Lọc các phần tử của danh sách không thỏa mãn vị từ.

Chúng ta đã sử dụng hàm này khá nhiều. Vì vậy, bạn biết làm thế nào nó hoạt động. Nó nhận một vị từ và một danh sách rồi trả về một danh sách chỉ có các phần tử thỏa mãn vị từ đó:

filter' :: (a -> Bool) -> [a] -> [a]

Bây giờ, nếu chúng ta liệt kê các trường hợp, thì tham số đầu tiên là một hàm, vì vậy chỉ có một trường hợp và trường hợp thứ hai là một danh sách, do đó, nó có thể là danh sách trống hoặc danh sách không trống:

filter' :: (a -> Bool) -> [a] -> [a]
filter' p [] =
filter' p (x:xs) =

Bởi vì chúng ta không có các phần tử được lọc trong trường hợp đầu tiên, chúng ta trả về một danh sách trống. Và bởi vì chúng ta sẽ không sử dụng vị ngữ, chúng ta có thể bỏ qua nó. Nó bắt đầu cảm thấy dễ dàng, phải không?

filter' :: (a -> Bool) -> [a] -> [a]
filter' _ [] = []
filter' p (x:xs) =

Bây giờ hãy giải quyết trường hợp đệ quy.

Trong trường hợp này, chúng ta có hai tình huống. Một là phần tử thỏa mãn vị từ, và hai là nó không thỏa mãn. Chúng ta có thể truyền đạt điều này theo những cách khác nhau. Tôi thích sử dụng guards hơn:

filter' :: (a -> Bool) -> [a] -> [a]
filter' _ [] = []
filter' p (x:xs)
| p x =
| otherwise =

Vì vậy, nếu vị từ p áp dụng cho phần tử đầu tiên x trả về True, thì chúng ta sẽ thêm phần tử đó vào danh sách mà chúng ta sẽ trả về ở cuối. Nếu không, chúng ta không thêm vào danh sách mới. Và trong cả hai trường hợp, chúng ta áp dụng đệ quy filter' cho các phần tử còn lại (xs).

Dịch mã này sang mã, chúng ta nhận được:

filter' :: (a -> Bool) -> [a] -> [a]
filter' _ [] = []
filter' p (x:xs)
| p x = x : filter' p xs
| otherwise = filter' p xs


filter' (==True) [True,False,True,True,False]
filter' ('!' `elem`) ["Hey!", "How are you?"]
filter' (\x -> x**2 < 37) [1,2,3,4,5,6,7,8,9,10]

[True,True,True]

["Hey!"]

[1.0,2.0,3.0,4.0,5.0,6.0]

Và thế là xong! Được rồi. Chúng ta đã tạo đủ các hàm đệ quy để bắt đầu nhận thấy một số mẫu. Vì vậy, chúng ta hãy nói về điều đó.

Khám phá hàm foldr

Hãy xem các hàm được định nghĩa trước đó. Xem nếu bạn có thể phát hiện ra một mô hình:

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

product' :: [Int] -> Int
product' [] = 1
product' (x:xs) = x * product' xs

and' :: [Bool] -> Bool
and' [] = True
and' (x:xs) = x && and' xs

Như bạn có thể thấy, có một mẫu lặp lại trong mọi hàm!:

  1. Có một trường hợp cơ sở cho một danh sách trống trả về một giá trị không đệ quy.
  2. Có một trường hợp đệ quy cho một danh sách không trống lấy giá trị đầu tiên của danh sách và áp dụng một hàm để kết hợp nó với một lệnh gọi đệ quy xử lý phần còn lại của danh sách.

Mô hình này có một tên! Nó được gọi là "đệ quy nguyên thủy" (primitive recursion).

Chúng ta sẽ trích xuất mẫu thành hàm riêng của nó! Nhưng trước tiên, hãy lưu ý rằng mẫu này giả định rằng hàm kết hợp các giá trị trong trường hợp đệ quy là một toán tử. Để làm cho nó tổng quát hơn, hãy sửa đổi chúng để sử dụng hàm tiền tố trước khi giải nén nó:

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

product' :: [Int] -> Int
product' [] = 1
product' (x:xs) = (*) x (product' xs)

and' :: [Bool] -> Bool
and' [] = True
and' (x:xs) = (&&) x (and' xs)

Chúng ta sẽ gọi phần rút gọn là foldr vì chúng ta đang gấp (fold) danh sách từ bên phải. Bạn sẽ hiểu tôi muốn nói gì.

Như mọi khi, đầu tiên chúng ta bắt đầu với Kiểu. Vì vậy, chúng ta cần 3 đối số:

  1. Một hàm để kết hợp các phần tử của danh sách. Vì vậy, nó sẽ lấy hai phần tử và tạo một phần tử mới.
  2. Một giá trị cơ sở để bắt đầu.
  3. Một danh sách.

Lưu ý rằng các phần tử bên trong danh sách có thể là bất kỳ thứ gì, nhưng không nhất thiết phải cùng Kiểu với kết quả. (Chúng ta không biết hàm sẽ làm gì). Vì vậy, chúng ta sẽ sử dụng kiểu a cho các phần tử của danh sách và kiểu b cho kết quả. Và từ đó, giá trị cơ sở phải thuộc Kiểu b và hàm phải thuộc Kiểu a -> b -> b.

foldr :: (a -> b -> b) -> b -> [a] -> b

Ok, bây giờ, hãy giải nén mẫu thành hàm riêng của nó. Hãy bắt đầu bằng cách trình bày mẫu và chúng ta sẽ bắt đầu từ đó:

foldr :: (a -> b -> b) -> b -> [a] -> b
foldr f v [] = -- Giá trị cơ sở
foldr f v (x:xs) = --Kết hợp kết quả của hàm và đệ quy

Chúng ta đã có giá trị cơ sở (v). Và cuộc gọi đệ quy chỉ là áp dụng hàm f cho x và một cuộc gọi đệ quy foldr nhưng cùng với xs thay vì danh sách gốc ban đầu. Vì vậy, chúng ta có thể làm điều đó trong định nghĩa:

foldr :: (a -> b -> b) -> b -> [a] -> b
foldr _ v [] = v
foldr f v (x:xs) = f x (foldr f v xs)

Xong! Chúng ta vừa trích xuất mẫu "đệ quy nguyên thủy"!

Để chứng minh rằng nó thực sự giống nhau, chúng ta sẽ chuyển các tham số cần thiết để tạo sum hàm và hoạt động thông qua một ví dụ:

-- same as: sum [1,2,3,4]
foldr (+) 0 [1,2,3,4] = (+) 1 (foldr (+) 0 [2,3,4])
= (+) 1 ((+) 2 (foldr (+) 0 [3,4]))
= (+) 1 ((+) 2 ((+) 3 (foldr (+) 0 [4])))
= (+) 1 ((+) 2 ((+) 3 ((+) 4 (foldr (+) 0 []))))
= (+) 1 ((+) 2 ((+) 3 ((+) 4 0))) -- 1 + ( 2 + ( 3 + ( 4 + 0 )))
= (+) 1 ((+) 2 ((+) 3 4)) -- 1 + ( 2 + ( 3 + 4 ))
= (+) 1 ((+) 2 7) -- 1 + ( 2 + 7 )
= (+) 1 9 -- 1 + 9
= 10

Bây giờ, chúng ta có thể thay thế nó trong các định nghĩa trước đây của mình để có được mã rõ ràng và ngắn gọn hơn nhiều:

sum' :: [Int] -> Int
sum' = foldr (+) 0 -- We partially apply foldr


product' :: [Int] -> Int
product' = foldr (*) 1


and' :: [Bool] -> Bool
and' = foldr (&&) True

Nếu, trong khi định nghĩa một hàm đệ quy, bạn phát hiện ra rằng mình đang sử dụng mẫu này, hãy sử dụng foldr thay thế! Bằng cách đó, mọi người (bao gồm cả bạn) sẽ hiểu ngay hàm này làm gì mà không cần tìm ra đệ quy.

Nhắc mới nhớ, length' hàm này gần như hoàn toàn phù hợp!:

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

Sự khác biệt duy nhất là chúng ta bỏ qua x và thay vào đó thêm một giá trị không đổi. Nếu chúng ta có thể mã hóa cứng tham số đầu tiên của + toán tử… thì điều đó thật hoàn hảo! Chà, tại sao chúng ta không tạo một hàm thực hiện điều đó và chuyển hàm đó thay vì + ? Chúng ta chỉ cần lấy hai tham số, bỏ qua tham số đầu tiên và thêm 1 vào tham số thứ hai! Chúng ta có thể dễ dàng làm điều đó với hàm lambda nhanh chóng:

length' :: [a] -> Int
length' [] = 0
length' (x:xs) = (\_ n -> 1 + n) x (length' xs) --lambda could be simplified to (\_ -> (+) 1)

length' [1,2,3,4,5]

5

Và bùm! Cứ như vậy, length' hoàn toàn phù hợp với khuôn mẫu! Vì vậy, chúng ta có thể thay thế nó bằng foldr :

length' = foldr (\_ n -> 1 + n) 0

length' [1,2,3,4,5]

5

Như bạn có thể thấy, có sự linh hoạt nhất định. Hãy viết lại reverse but với foldr :

reverse' :: [a] -> [a]
reverse' = foldr (\x xs -> xs ++ [x]) []

reverse' [1,2,3,4,5]

[5,4,3,2,1]

Có vẻ như chúng ta có thể sử dụng foldr dài dài. Nhưng nó không phải là tất cả. Ví dụ: nếu sử dụng reverse' với số một nghìn, mười nghìn hoặc thậm chí nhiều hơn, thì phí sử dụng ++ ngày càng lớn hơn.

Tại sao? Chà… hãy xem cách ++ định nghĩa trong thư viện cơ sở:

(++) :: [a] -> [a] -> [a]
(++) [] ys = ys
(++) (x:xs) ys = x : xs ++ ys

Như bạn có thể thấy trong trường hợp đệ quy, mỗi lần chúng ta muốn thêm hai danh sách, trước tiên, chúng ta duyệt qua tất cả các phần tử của danh sách đầu tiên, sau đó chúng ta thêm danh sách thứ hai vào cuối. Vì vậy, nếu chúng ta có một danh sách lớn hơn gấp 10 lần, chúng ta phải đợi gấp 10 lần để hoàn thành. Có nghĩa là phải mất thời gian tuyến tính trong số phần tử của danh sách đầu tiên.

Điều đó có ý nghĩa gì đối với chúng ta? Điều đó có nghĩa là, trong reverse' lệnh gọi đệ quy, mỗi lần chúng ta muốn di chuyển một phần tử từ phía trước ra phía sau danh sách (mỗi khi chúng ta thực hiện một lệnh gọi đệ quy), chúng ta phải duyệt qua toàn bộ danh sách! Mỗi lần! Nếu danh sách đủ dài, bạn có thể chạy trong khi đợi nó bị đảo ngược!

Nhưng đừng lo lắng. Tôi sẽ không để bạn treo như vậy. Có một giải pháp gọn gàng cho việc này. Nếu chúng ta có thể duyệt danh sách từ trái sang phải thay vì từ phải sang trái, chúng ta có thể sử dụng : toán tử khuyết điểm ( ) thay vì ++ và trong mỗi lệnh gọi đệ quy, chúng ta sẽ thêm phần tử ngay từ đầu. Không cần đi qua toàn bộ danh sách!

Tiếp tục nghiên cứu hàm foldl!

Hàm foldl

foldl về cơ bản giống như foldr nhưng đi qua danh sách từ trái sang phải:

foldr :: (a -> b -> b) -> b -> [a] -> b
foldr f v [] = v
foldr f v (x:xs) = f x (foldr f v xs)


foldl :: (a -> b -> a) -> a -> [b] -> a
foldl f v [] = v
foldl f v (x:xs) = foldl f (f v x) xs

Ví dụ: hãy xem điều gì xảy ra từng bước khi chúng ta thay thế foldr bằng foldl hàm sum :

(Lưu ý cách đối số thứ hai tiếp tục phát triển trong khi đối số thứ ba tiếp tục nhỏ hơn.)

foldl (+) 0 [1,2,3,4] = foldl (+) ((+) 0 1) [2,3,4]
= foldl (+) ((+) ((+) 0 1) 2) [3,4]
= foldl (+) ((+) ((+) ((+) 0 1) 2) 3) [4]
= foldl (+) ((+) ((+) ((+) ((+) 0 1) 2) 3) 4) []
= (+) ((+) ((+) ((+) 0 1) 2) 3) 4 -- ((( 0 + 1 ) + 2 ) + 3 ) + 4
= (+) ((+) ((+) 1 2) 3) 4 -- ((1 + 2 ) + 3 ) + 4
= (+) ((+) 3 3) 4 -- (3 + 3 ) + 4
= (+) 6 4 -- 6 + 4
= 10

Và đó là cách foldl hoạt động.

Và bởi vì bây giờ chúng ta có thể duyệt qua danh sách từ trái sang phải, nên chúng ta có thể sử dụng : toán tử (khuyết điểm) để nối các giá trị thay vì ++.

Tính đến điều đó, chúng ta có thể viết reverse' như thế này:

reverse'' :: [a] -> [a] 
reverse'' = foldl (\x y -> y:x) [] -- Same as: foldl (flip (:)) []

reverse'' [1,2,3,4,5]

[5,4,3,2,1]

Và bây giờ, chúng ta có thể so sánh tốc độ của hai hàm bằng cách đảo ngược danh sách từ 1 đến 10.000! Chạy hai hàm riêng biệt và xem sự khác biệt về tốc độ:

(Chúng ta sử dụng sum để tránh in toàn bộ danh sách)

sum . reverse' $ [1..10000] -- With foldr and (++)

50,005,000

sum . reverse'' $ [1..10000] -- With foldl and (:)

50,005,000

Một cải tiến ấn tượng! Nhưng không phải là điều duy nhất khác nhau giữa foldr và foldl !

Cho đến giờ, chúng ta chưa gặp trường hợp này bởi vì, chẳng hạn, toán tử cộng (+) trả về cùng một cách:

foldr (+) 0 [4,3,2,1] == foldl (+) 0 [4,3,2,1]

True

Tuy nhiên, đối với một số toán tử, thứ tự của hoạt động có thể cho kết quả khác nhau tùy thuộc vào hướng! Ví dụ: xem xét (-) thay vì (+) :

foldr (-) 0 [4,3,2,1] == foldl (-) 0 [4,3,2,1]

False

Điều này là sai bởi vì nếu chúng ta viết rõ ràng các hoạt động, chúng ta sẽ nhận được:

foldl (-) 0 [4,3,2,1] = (((0-4)-3)-2)-1 = -10

trong khi

foldr (-) 0 [4,3,2,1] = 4-(3-(2-(1-0))) = 2

Vì vậy, đó là một điều khác biệt cần tính đến.

Có một điều cuối cùng tôi muốn nói đến, đó là foldl'.

Hàm foldl'

Tất cả các hàm chúng ta đã định nghĩa cho đến nay đều ở ' cuối vì chúng đã tồn tại trong Haskell và chúng ta không muốn xảy ra xung đột. Nhưng! foldl' cũng là một hàm đi kèm với Haskell và nó hoạt động hơi khác so với foldl.

Trong cả hai trường hợp foldr và foldl, chúng ta thấy rằng chúng ta tiếp tục xếp chồng các biểu thức cho đến khi kết thúc. Và sau đó chúng ta giảm chúng. (Trên thực tế, Haskell làm tất cả công việc, không phải chúng ta. Nhưng bạn biết.)

Điều này có nghĩa là nếu bạn cố gắng gấp một danh sách đủ lớn, bạn sẽ nhận được một stack overflow (ngoại lệ tràn ngăn xếp)!

Nếu chúng ta chọn bất kỳ bước trung gian nào trong foldr' :

-- Same as:             (+) 1 ((+) 2 ((+) 3 (foldr (+) 0 [4])))
foldr (+) 0 [1,2,3,4] = 1 + (2 + (3 + (foldr (+) 0 [4])))

Chúng ta thấy rằng chúng ta không thể làm gì nhiều foldr vì chúng ta không có một toán tử duy nhất với cả hai đối số. Vì vậy, trước tiên chúng ta sẽ cần giải quyết hàm đệ quy.

Nhưng! Nếu chúng ta xem xét bước trung gian tương tự trong foldl :

-- Same as:             foldl (+) ((+) ((+) ((+) 0 1) 2) 3) [4]
foldl (+) 0 [1,2,3,4] = foldl (+) (((0 + 1) + 2) + 3) [4]

Chúng ta hoàn toàn có thể giảm (((0 + 1) + 2) + 3) xuống 6 khi tiếp tục với đệ quy!

Và đó là những gì foldl' làm!

Để rõ ràng: foldl và foldl' trả lại kết quả tương tự! Sự khác biệt là foldl' làm giảm các biểu thức ở các bước trung gian. Vì vậy, nó hiệu quả hơn vì nó không tạo ra một khối lớn!

Vì vậy, nếu chúng ta chạy một cái gì đó như thế này:

foldl (+) 0 [1..1000000] -- Don't run it! I'm warning you!

Bạn sẽ nhận được một ngoại lệ tràn ngăn xếp. Nhưng nếu bạn sử dụng foldl' thay thế:

import Data.List

foldl' (+) 0 [1..1000000] -- No problems!

500,000,500,000

Bạn sẽ không gặp vấn đề gì.

Và điều này đặt ra một câu hỏi. Khi nào bạn nên sử dụng cái nào?

Khi nào thì sử dụng foldrfoldl, và foldl'

Thông thường, sự lựa chọn là giữa foldr và foldl', vì foldl và foldl' giống nhau ngoại trừ các thuộc tính nghiêm ngặt của chúng. Vì vậy, nếu cả hai đều trả về một kết quả, foldl' thì đó là cách hiệu quả hơn để đạt được kết quả đó vì nó không tạo ra một khối lượng lớn.

Tuy nhiên, đó không phải là toàn bộ câu chuyện. Chúng ta sẽ đưa ra một số quy tắc ngón tay cái từ lần đầu tiên được sử dụng ít nhất đến được sử dụng nhiều nhất:

Sử dụng foldl :

  • Hiếm khi.
  • Nếu hàm kết hợp lười biếng trong đối số đầu tiên của nó. (foldl có thể trả về kết quả khi foldl' gặp ngoại lệ.)

Sử dụng foldl' :

  • Khi danh sách mà nó được áp dụng lớn nhưng chắc chắn là hữu hạn, bạn không quan tâm đến sự đảo ngược ngầm định (ví dụ: vì hàm kết hợp của bạn có tính chất giao hoán) và bạn tìm cách cải thiện hiệu suất của mã của mình.
  • Khi bạn thực sự muốn đảo ngược thứ tự của danh sách ngoài việc có thể thực hiện một số phép biến đổi khác đối với các phần tử. (Tận dụng lợi thế của đảo ngược ngầm.)

Sử dụng foldr :

  • Khi chuyển danh sách thành danh sách có các phần tử liên quan theo cùng một thứ tự.
  • Khi chuyển danh sách vô hạn thành danh sách vô hạn khác. (Nếu hàm được truyền là lười biếng trong đối số thứ hai của nó, foldr thì sẽ tạo ra kết quả một cách lười biếng, chỉ tính toán nhiều như được yêu cầu.)
  • Khi hàm gấp có thể ngắn mạch (chấm dứt sớm) bằng cách mang lại kết quả không phụ thuộc vào giá trị của tham số tích lũy.
  • Nếu bạn không chắc chắn.

Những quy tắc ngón tay cái này không nhất thiết phải luôn luôn áp dụng. Và bởi vì việc tìm hiểu tất cả lý do tại sao của những quy tắc này có thể mất nhiều thời gian, nên chúng ta sẽ dành phần này cho những người tò mò hoặc khi bạn cần. Đây là thông tin thêm về chủ đề này.

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


Picture