Go đưa cho bạn một hộp đồ nghề điều khiển luồng cố tình nhỏ: chỉ if, switch, for. Một cái for làm trọn việc của while, do-while và foreach; switch không cần break; if không cần ngoặc. Ít thứ để nhớ nghĩa là ít chỗ để sai — đúng tinh thần "nhỏ để đọc nhanh" của Go. Nhưng phần cuối bài là một ngoại lệ hiếm hoi tới mức đáng kể: một dòng trong go.mod âm thầm đổi nghĩa của mã đã viết sẵn, và tôi đo được nó chạy khác nhau trên ba phiên bản. Go có ba cấu trúc điều khiển, và mỗi cái có vài điểm riêng đáng biết.
if có câu lệnh khởi tạo
if v, ok := m["khoa"]; ok {
fmt.Println(v)
}
Biến khai trong if chỉ sống trong if và else của nó. Đây là mẫu bạn sẽ gõ hàng ngày, nhất là với xử lý lỗi:
if err := lam(); err != nil {
return err
}
err không rò ra ngoài, nên bạn dùng lại tên đó ở dòng sau mà không sợ nhầm.
Hai điểm khác Java: không có ngoặc quanh điều kiện, và điều kiện phải là bool. Không có if x với x là số hay con trỏ — phải viết if x != 0, if p != nil.
switch: mặc định không rơi xuống
switch d := 2; d {
case 1, 2, 3:
fmt.Println("khớp")
fallthrough // phải viết TƯỜNG MINH mới rơi xuống
case 99:
fmt.Println("rơi xuống đây")
}
Ba khác biệt so với Java:
Không cần break. Mỗi case tự dừng. Muốn rơi xuống thì viết fallthrough — và nó rơi vô điều kiện, không kiểm giá trị của case tiếp theo.
Một case nhận nhiều giá trị, ngăn bởi dấu phẩy.
case không cần là hằng. Đây là điểm mạnh nhất: bỏ hẳn biểu thức sau switch thì mỗi case là một điều kiện bool:
switch {
case x < 3:
fmt.Println("nhỏ")
case x < 10:
fmt.Println("vừa")
default:
fmt.Println("lớn")
}
Đây là cách viết chuỗi if-else if cho gọn, và là dạng switch tôi dùng nhiều nhất trong Go.
Còn một dạng thứ ba — switch trên kiểu — sẽ nói ở bài 14.
for: một từ khoá cho tất cả
Go không có while, do-while, hay foreach. Chỉ for, với bốn dạng:
for i := 0; i < 3; i++ { } // ba phần, như Java
for j < 3 { j++ } // như while
for { } // vô hạn
for i, v := range slice { } // duyệt
for i := range 3 { } // duyệt số nguyên — Go 1.22+
Dạng cuối mới có từ Go 1.22 và rất tiện: for i := range 3 lặp với i = 0, 1, 2.
range hoạt động trên slice, mảng, chuỗi, map, channel. Với map, thứ tự duyệt là ngẫu nhiên có chủ đích — Go cố tình xáo trộn để bạn không viết mã dựa vào thứ tự. Cần thứ tự thì lấy khoá ra, sắp xếp, rồi duyệt.
Bỏ giá trị không cần bằng _:
for _, v := range slice { } // chỉ cần giá trị
for i := range slice { } // chỉ cần chỉ số
Thay đổi lớn nhất: biến vòng lặp ở Go 1.22
var fs []func()
for i := 0; i < 3; i++ {
fs = append(fs, func() { fmt.Printf("%d ", i) })
}
for _, f := range fs { f() }
Đoạn này in ra gì? Tôi chạy trên ba phiên bản:
Go 1.21 -> 3 3 3
Go 1.22 -> 0 1 2
Go 1.23 -> 0 1 2
Cùng một mã, hai kết quả khác nhau.
Trước Go 1.22, biến vòng lặp i được tạo một lần cho cả vòng lặp — ba closure cùng chỉ vào một tấm bảng trắng, và khi chúng chạy thì bảng đã ghi 3. Từ Go 1.22, mỗi vòng lặp có biến riêng — mỗi closure được phát một mẩu giấy riêng, đóng băng giá trị ngay lúc đó.
Đây là lỗi hay gặp tới mức nhóm Go quyết định phá vỡ tương thích ngược để sửa — chuyện gần như chưa từng xảy ra với Go. Cách họ làm cho an toàn: hành vi mới chỉ áp dụng khi go.mod khai go 1.22 trở lên. Module cũ giữ nguyên ngữ nghĩa cũ.
Hệ quả thực tế cho bạn:
Nếu go.mod khai dưới 1.22, mã của bạn vẫn chạy theo kiểu cũ — kể cả khi bạn build bằng Go 1.23. Đây là chỗ dễ nhầm khi đọc mã: hành vi phụ thuộc vào go.mod, không phụ thuộc vào trình biên dịch.
Mã cũ có i := i — dòng "sao chép biến vòng lặp" mà mọi hướng dẫn Go trước 2024 đều dạy — giờ thành thừa. Vô hại, nhưng có thể xoá.
Khi nâng go.mod lên 1.22, hãy chạy lại test. Rất hiếm nhưng có mã dựa vào hành vi cũ.
break và continue có nhãn
ngoai:
for i := range 3 {
for j := range 3 {
if j == 1 { continue ngoai }
if i == 2 { break ngoai }
}
}
Nhãn cho phép thoát khỏi vòng lặp ngoài. Go không có goto tuỳ tiện — thực ra nó có goto nhưng bị giới hạn chặt và gần như không ai dùng.
break cũng dùng được trong switch để thoát khỏi switch, và đây là chỗ dễ nhầm: break trong switch nằm trong for sẽ thoát switch chứ không thoát for. Cần thoát vòng lặp thì phải có nhãn.
Muốn tận mắt thấy một dòng go.mod đổi nghĩa cả ngôn ngữ, thử đúng ba mươi giây: mở go.mod của dự án và xem dòng go. Nếu nó dưới 1.22, chạy đoạn closure ở trên — bạn sẽ thấy 3 3 3 dù đang dùng Go mới nhất. Đổi dòng đó thành go 1.22 rồi chạy lại, ra 0 1 2.
Mẫu số chung
Cái bẫy "closure trong vòng lặp bắt nhầm biến" phổ biến tới mức nó buộc nhiều ngôn ngữ làm điều họ rất ngại: đổi ngữ nghĩa của mã đã tồn tại.
- C# làm đúng chuyện này ở phiên bản 5.0 — biến vòng lặp
foreachđổi từ "dùng chung" sang "mỗi vòng một biến", y hệt Go 1.22, và cũng vì cùng một lý do. - JavaScript chữa bằng cách thêm
let(ES6): vòngfor (let i…)cho mỗi vòng một biến, cònfor (var i…)giữ cái bẫy cũ — nênsetTimeouttrong vòngvarvẫn in ra toàn số cuối. - Python thì chưa sửa: closure trong vòng lặp vẫn bắt biến dùng chung tới tận hôm nay.
Bài học tầng một giống hệt bài defer: luôn biết biến vòng lặp của bạn là một ô nhớ dùng chung hay một bản mới mỗi vòng — trả lời sai là in ra 3 3 3 thay vì 0 1 2.
Nhưng tầng hai mới là thứ đặc trưng của bài này, và đáng mang theo: cơ chế Go dùng để tiến hoá mà không phá vỡ thế giới. Hành vi được khoá theo phiên bản khai trong go.mod, nên module cũ giữ nguyên nghĩa cũ dù chạy bằng trình biên dịch mới. Đây là câu trả lời phổ quát cho bài toán "nâng cấp ngôn ngữ mà không làm gãy mã cũ": Rust có edition (2015/2018/2021) khoá theo từng crate, C# có <LangVersion>, Python có from __future__ import, TypeScript có target. Một dòng khai phiên bản không phải thủ tục hành chính — nó là một hợp đồng ngữ nghĩa: mã của bạn vẫn có nghĩa đúng như ngày bạn ghim nó. Và hệ quả thực dụng ở mọi ngôn ngữ có cơ chế này: khi một đoạn mã chạy lạ, đừng chỉ hỏi "trình biên dịch nào" — hỏi luôn "phiên bản ngôn ngữ khai ở đâu", vì đôi khi chính dòng đó mới là thứ quyết định.
Ngày mai: hàm và nhiều giá trị trả về — nền tảng của cách Go xử lý lỗi.