Cửa sổ phục vụ của hầu hết ngôn ngữ trả qua khe đúng một món. Cửa sổ của Go thì đưa ra cả một cái khay nhiều ngăn cùng lúc — món chính ở ngăn to, tờ phiếu tình trạng ở ngăn nhỏ. Nghe đơn giản, nhưng đúng một lựa chọn thiết kế đó kéo theo hệ quả lớn cho cả ngôn ngữ: một hàm trả về được nhiều giá trị, nên Go không cần exception, không cần tham số ra, không cần đối tượng bọc.
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 ở ngăn to phía trước, error ở ngăn nhỏ phía sau. 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ả khay. Muốn bỏ qua lỗi thì phải viết _ — tức là phải cố ý vứt tờ phiếu đi:
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.
Nếu muốn thấy ngay vì sao mã Go hiếm khi nuốt lỗi, viết một hàm trả về (int, error) rồi cố 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 thẳng. Bạn không thể vô tình chỉ cầm món chính mà bỏ quên tờ phiếu — ngôn ngữ không cho lờ nó đi một cách tình cờ.
Mẫu số chung
"Trả về nhiều hơn một thứ" là nhu cầu của mọi ngôn ngữ, và lịch sử là một chuỗi cách chắp vá khác nhau cho tới khi có cú pháp thẳng.
- C dùng tham số ra:
int divmod(int a, int b, int *rem)— trả một giá trị, nhét phần còn lại qua con trỏ. - Java bọc vào một lớp/
record/Pair; Python, Rust, Swift trả tuple rồi tháo ra; JavaScript trả object/mảng rồi destructure. - Go cho trả nhiều giá trị thẳng trong cú pháp, nên cái khay nhiều ngăn là mặc định tự nhiên.
Nhưng chỗ sâu hơn là cách báo lỗi đi kèm, và đây là ba trường phái rạch ròi. Go: hai giá trị (value, error) theo quy ước — nhẹ, nhưng dựa vào kỷ luật (phải tự gõ _ mới lờ được). Rust/Haskell: một giá trị kiểu tổng — Result<T, E>, Either — trình biên dịch ép bạn mở ra xử lý. Java/Python: exception — bung ngược ngăn xếp, và lờ đi chỉ là không làm gì. Ba cách xếp trên một thang "dễ lờ lỗi tới mức nào": exception dễ nhất (không làm gì), Go ở giữa (một ký tự), kiểu tổng khó nhất (phải khớp mẫu).
Còn chuyện Go không có nạp chồng và không có tham số mặc định là một nét tối giản hiếm — Java, C++, Kotlin đều có nạp chồng; Python, Kotlin, C++ đều có tham số mặc định. Go bỏ cả hai, và "functional options" chính là cộng đồng vá lại chỗ trống đó. Sợi chỉ chung đáng mang theo: khi học một ngôn ngữ mới, hỏi "nó trả hai thứ bằng cách nào, và nó có bắt mình thừa nhận cái thứ-báo-lỗi không" — câu trả lời quyết định phần lớn việc lỗi trong hệ đó bị nuốt lặng hay bị xử lý.
Ngày mai: con trỏ trong Go — có & và *, nhưng không có số học con trỏ.