Hàm trong Go trông quen thuộc, nhưng có một điểm khác biệt kéo theo hệ quả lớn cho cả ngôn ngữ: một hàm trả về được nhiều giá trị.
Cú pháp
func chia(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("chia cho 0")
}
return a / b, nil
}
Kiểu đứng sau tên — a int chứ không phải int a. Tham số cùng kiểu gộp được: func f(a, b int).
chia(7,2) = 3, err=<nil>
chia(1,0) -> err=chia cho 0
Đây là mẫu bạn sẽ thấy trong mọi thư viện Go: giá trị thật ở vị trí đầu, error ở vị trí cuối. Không có exception, không có try. Bài 23 sẽ nói vì sao đây là lựa chọn có chủ đích chứ không phải thiếu sót.
Người gọi buộc phải nhận cả hai. Muốn bỏ qua lỗi thì phải viết _ — tức là phải cố ý:
v, _ := chia(7, 2) // rõ ràng là đang bỏ qua lỗi
Đây là khác biệt lớn với Java: ở Java, bỏ qua ngoại lệ là không làm gì; ở Go, bỏ qua lỗi là gõ thêm một ký tự. Chênh lệch nhỏ đó đổi hẳn thói quen của cả cộng đồng.
Named return: dùng ít thôi
func namedRet(a, b int) (thuong, du int) {
thuong = a / b
du = a % b
return // "naked return"
}
named return: thương=3 dư=2
Giá trị trả về được đặt tên và tự khai như biến. return trần trả về giá trị hiện tại của chúng.
Lời khuyên thẳng: đặt tên thì tốt, return trần thì tránh. Tên có ích vì nó thành tài liệu — người đọc thấy (thuong, du int) là hiểu ngay thứ tự. Nhưng return trần trong hàm dài làm người đọc phải cuộn lên tìm xem đang trả gì.
Có đúng một chỗ named return thật sự cần: sửa giá trị trả về trong defer, thường để bọc lỗi. Bài 26 sẽ nói.
Variadic
func tong(ns ...int) int { ... }
tong(1,2,3) = 6
tong([]int{4,5}...) = 9
ns bên trong hàm là một []int. Truyền một slice sẵn có thì thêm ... phía sau.
Một chi tiết đáng biết: gọi tong() không đối số cho ns là slice nil, không phải slice rỗng — và như bài 4 đã nói, len() và range trên nil vẫn chạy bình thường.
Hàm là giá trị hạng nhất
var f func(int) int = func(x int) int { return x * 2 }
Hàm gán được vào biến, truyền được vào hàm khác, trả về được từ hàm. Đây là nền của sort.Slice, http.HandlerFunc, và gần như mọi API nhận callback trong thư viện chuẩn.
Closure bắt biến theo tham chiếu, không phải theo giá trị:
func demTang() func() int {
i := 0
return func() int { i++; return i }
}
Mỗi lần gọi hàm trả về sẽ tăng i, và i sống lâu hơn demTang — Go tự đưa nó lên heap. Bạn không phải nghĩ về chuyện đó, nhưng nó là lý do closure trong Go không cần biến final như Java.
Cái bẫy closure trong vòng lặp đã nói ở bài 6, và Go 1.22 đã sửa.
Không có nạp chồng, không có tham số mặc định
Go không cho hai hàm cùng tên khác chữ ký, và không có giá trị mặc định cho tham số.
Điều này làm người đến từ Java thấy thiếu thốn. Ba cách xử lý thường dùng:
Đặt tên khác nhau — Parse và ParseWithLocation. Đây là cách thư viện chuẩn hay làm.
Struct tuỳ chọn — gom các tham số phụ vào một struct, và nhờ zero value ở bài 4, struct rỗng đã là cấu hình mặc định hợp lý.
Functional options — hàm variadic nhận các hàm cấu hình:
func New(opts ...Option) *Client
New() // mặc định
New(WithTimeout(5*time.Second))
Mẫu thứ ba rất phổ biến trong thư viện Go, và nó là câu trả lời của cộng đồng cho việc thiếu tham số mặc định.
Thử ba mươi giây
Viết một hàm trả về (int, error) rồi gọi nó mà chỉ nhận một giá trị:
v := chia(7, 2)
assignment mismatch: 1 variable but chia returns 2 values
Trình biên dịch chặn. Ba mươi giây đó cho thấy vì sao mã Go hiếm khi nuốt lỗi: ngôn ngữ không cho bạn lờ nó đi một cách vô tình.
Ngày mai: con trỏ trong Go — có & và *, nhưng không có số học con trỏ.