Trong bài này chúng ta sẽ tìm hiểu về một số đặc điểm thú vị của hàm trong Go: đệ quy, kiểu dữ liệu hàm. Một số đặc điểm khác như hàm vô danh, hàm bất định và lệnh trì hoãn sẽ có trong bài tiếp theo.
Đệ quy
Đệ quy là khái niệm hàm gọi chính hàm đó trong khối lệnh của nó một cách trực tiếp hay gián tiếp qua hàm khác. Đệ quy là một kỹ thuật mạnh mẽ giúp xử lý nhiều vấn đề và nhất là các loại cấu trúc dữ liệu có tính đệ quy. Điểm yếu của đệ quy là tốn nhiều bộ nhớ cho xử lý.
Chúng ta cùng xem xét ví dụ sau để hiểu rõ hơn về đệ quy. Ví dụ này tính giai thừa của một giá trị nhận từ tham số dòng lệnh khi thực thi. Như chúng ta biết giai thừa của một số nguyên là tích của các số nguyên liên tiếp từ 1 đến chính số đó. Như vậy n! =1*2*...*(n-1)*n. Do 1*2*...*(n-1) chính là (n-1)! nên n! = (n-1)!*n. Vì vậy muốn tính giai thừa của n ta phải tính giai thừa của n-1 mà trước đó phải tính được giai thừa của n-2 và cứ vậy cho đến khi ta có sẵn 1! = 1. Theo quy ước thì 0! = 1. Cài đặt thuật toán này với hàm đệ quy như sau:
1 package main 2 3 import ( 4 "fmt" 5 "strconv" 6 "os" 7 ) 8 9 func fact(n int) int { 10 if n == 0 { 11 return 1 12 } 13 14 return n * fact(n-1) 15 } 16 17 func main() { 18 i, _ := strconv.Atoi(os.Args[1]) 19 fmt.Println(fact(i)) 20 }
- Dòng 9 đến 15 khai báo hàm fact, thực hiện thuật toán tính giai thừa. Tham số là biến kiểu int, là giá trị n cần tính giai thừa. Giá trị trả về cũng là kiểu int, là kết quả phép tính giai thừa trong khối hàm từ dòng 10 đến 14.
+ Dòng 10 là biểu thức điều kiện với ý nghĩa nếu n = 0 thì sẽ thực thi lệnh ở dòng 11 là trả về giá trị 1.
+ Dòng 10 là biểu thức điều kiện với ý nghĩa nếu n = 0 thì sẽ thực thi lệnh ở dòng 11 là trả về giá trị 1.
+ Dòng 14 trả về giá trị là tích của n và kết quả của hàm này với tham số n -1. Chính biểu thức này đã biến hàm fact thành hàm đệ quy vì nó gọi đến chính nó.
- Dòng 17 đến 20 là khai báo hàm main.
+ Dòng 18 là dòng lệnh phức tạp với rất nhiều xử lý: đầu tiên là chúng ta lấy giá trị tham số truyền vào khi thực thi qua giá trị của slice Args từ package os. Phần tử 0 của slice Args là tên chương trình thực thi, phần tử 1 là tham số đầu tiên khai báo nếu có. Đó là giá trị ta cần lấy. Giá trị này là 1 chuỗi nên chúng ta cần hàm Atoi của package strconv để chuyển nó thành số và gán vào biến i. Giá trị trả về thứ 2 ở dòng 18 là biến lỗi mô tả lỗi nếu có, thường khi chuỗi này không phải là chuỗi số. Ở đây để đơn giản, tôi bỏ qua biến lỗi này.
+ Dòng 19 sẽ in kết quả gọi hàm fact với tham số là biến i, chính là giá trị i!
- Lưu chương trình trên với fact.go và sau khi biên dịch xong, chúng ta gọi thực thi với giá trị cần tính kèm theo sẽ thấy kết quả giai thừa của giá trị đó. Chúng ta có thể thấy là khi tính đến 21! thì kết quả là số âm do đã vượt quá khả năng chứa của kiểu int. Do giai thừa luôn dùng cho số nguyên dương nên tốt nhất giá trị trả về nên là uint hoặc uint64 để đảm bảo tính được nhiều giá trị hơn.
Mỗi một lần gọi hàm đệ quy mới, toàn bộ thông tin của hàm đang xử lý sẽ được lưu tạm tại vùng nhớ stack, là vùng nhớ lưu theo nguyên tắc lưu vào sau sẽ xuất ra trước vốn rất thuận tiện cho hàm đệ quy. Vùng nhớ này thường giới hạn nên nếu thực hiện quá nhiều bước đệ quy (gọi hàm đệ quy nhiều lần) stack có thể đầy dẫn đến lỗi stack over flow. Go cung cấp cơ chế cấp phát linh hoạt đảm bảo cho stack có thể cấp tối đa 1 GB.
"Kiểu dữ liệu" hàm
Bản thân mỗi hàm trong Go được xem như là giá trị và nó có quyền được gán cho một biến hay trả dữ liệu về từ hàm. Các biến nhận giá trị là hàm sẽ mang kiểu dữ liệu hàm. Ví dụ chúng ta có thể sử dụng: f := fact với fact là hàm tính đệ quy được khai báo ở dòng 9-15 trong ví dụ ở phần đệ quy. Lúc này cách gọi f(4) là hoàn toàn hợp lệ và cũng vẫn cho giá trị 24.
Cách khai báo một biến kiểu hàm như sau:
var <tên biến> func (<danh sách kiểu tham số>) (<danh sách kiểu trả về>)
hay
tên biến := func (danh sách tham số) (danh sách trả về)
Giá trị mặc định khi khởi tạo mà Go dành cho biến kiểu hàm là nil. Khi biến này có giá trị nil, việc gọi hàm thông qua biến này sẽ gây lỗi khi thực thi. Do đó chúng ta cần kiểm tra nó bằng cách so sánh với nil. Tuy vậy bản chất kiểu dữ liệu hàm không phải là kiểu so sánh được nên ta không thể dùng nó làm khóa cho kiểu bản đồ.
Truyền tham số là kiểu dữ liệu hàm giúp cho hàm trở nên linh hoạt hơn khi không chỉ truyền giá trị mà còn có thể truyền cả tính chất. Ví dụ sau mô tả thuật toán Caesar, là thuật toán dùng để mã hóa các ký tự bằng cách dịch chuyển trên bảng chữ cái:
1 if len(os.Args) < 3 { 2 fmt.Println("Vui lòng thêm chuỗi và khóa: caesar <chuỗi> <khóa>") 3 return 4 } 5 6 k, _ := strconv.Atoi(os.Args[2]) 7 caesar := func(r rune) rune { 8 switch { 9 case r >= 'A' && r <= 'Z': 10 return 'A' + (r-'A'+rune(k))%26 11 case r >= 'a' && r <= 'z': 12 return 'a' + (r-'a'+rune(k))%26 13 } 14 return r 15 } 16 fmt.Println(strings.Map(caesar, os.Args[1]))
- Đây là nội dung hàm main. Dòng 1-4 kiểm tra để đảm bảo có truyền 2 tham số dòng lệnh là chuỗi cần chuyển và khóa là độ dịch chuyển ký tự.
- Dòng 6 tạo khóa k mang giá trị kiểu int nhận giá trị từ biến chứa tham số dòng lệnh os.Args[2].
- Dòng 7-15 là khai báo biến kiểu hàm caesar. Hàm này có 1 tham số kiểu rune và cũng trả về kiểu rune. Rune bản chất là kiểu int nhưng được định nghĩa lại dùng cho lưu trữ và xử lý ký tự unicode.
+ Dòng 8-13 là cấu trúc switch với 2 case xử lý cho chữ hoa và chữ thường.
+ Dòng 9-10 xử lý trường hợp chữ hoa. r-'A'+rune(k) sẽ cho khoảng cách mới của ký tự r so với 'A'. %26 đảm bảo khoảng cách này không vượt quá 26, nghĩa là ký tự r mới sẽ chạy vòng vòng từ A (khoảng cách 0) đến Z (khoảng cách 26). Khi cộng thêm với 'A' ta sẽ có giá trị mới của ký tự r.
+ Dòng 11-12 xử lý tương tự 9-10 nhưng dùng với chữ thường.
+ Dòng 14 trả về chính r cho các trường hợp khác. Chúng ta có thể dùng default trong switch cũng được.
- Dòng 16 in kết quả hàm strings.Map trả về ra màn hình. Hàm Map của package strings chính là hàm xử lý áp dụng thuật toán mã hóa cho chuỗi ký tự với 2 tham số vào là hàm cài đặt thuật toán mã hóa và chuỗi ký tự. Ở đây hàm cài đặt mã hóa chính là hàm cài đặt thuật toán Caesar ở trên được truyền thông qua biến caesar. Chuỗi được lấy từ tham số dòng lệnh qua os.Arg[1].
Như vậy ta có thể thấy là biến caesar đã truyền tính chất mới cho ký tự vào hàm strings.Map. Các bạn có thể thử thách mình bằng cách viết lại hàm cài đặt Caesar ở trên nhưng dành cho chuỗi ký tự tiếng Việt.
Tóm tắt:
- Đệ quy:
+ Khái niệm hàm gọi chính nó hoặc gọi hàm có gọi đến nó.
+ Đệ quy tốn nhiều bộ nhớ stack khi thực thi và Go cấp tối đa 1 GB cho phần này.
- Kiểu dữ liệu hàm:
+ Hàm có thể được xem là giá trị và gán cho biến, lúc này biến mang kiểu dữ liệu hàm.
+ Khai báo: var <tên biến> func (<danh sách kiểu tham số>) (<danh sách kiểu trả về>)
+ Có thể dùng biến kiểu hàm làm tham số cho hàm khác.
Hi anh, em làm ví dụ 2 thì chuỗi in ra luôn là chuỗi đầu vào ạ, anh xem lại giúp em được không ạ, em cảm ơn anh.
ReplyDeleteEm có thể gửi file code em làm cho anh xem được không? Em chép nó vào play.golang.org rồi share link cho anh.
ReplyDeleteChào anh, A có thể cho e cách gán ở ví dụ 2 để chạy không được không anh. E gán thử thì không chạy được. Thanks anh nhiều
DeleteEm xem ở đây nhé: https://play.golang.org/p/0t006YrbAAe
DeleteDo khó chạy từ tham số dòng lệnh trên playground nên anh chuyển lại là gán trực tiếp giá trị cho chuỗi và độ lệch k. Không biết em bị lỗi chỗ nào nhỉ?
Dạ. E cảm ơn anh nhiều. E sau khi so sánh giữa của anh và e. E đã hiểu sao sai ạ. Thanks a rất nhiều
Delete