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.
Bài tập làm thử
Bài 1 (đọc hiểu). Với go.mod khai go 1.23, đoạn mã sau in ra gì?
var fs []func()
for i := 0; i < 3; i++ {
fs = append(fs, func() { fmt.Printf("%d ", i) })
}
for _, f := range fs {
f()
}
Đáp án
In ra 0 1 2. Từ Go 1.22 trở đi (và mã này khai go 1.23 trong go.mod, nên áp dụng ngữ nghĩa mới), mỗi vòng lặp for tạo ra một biến i riêng — mỗi closure được "phát một mẩu giấy riêng", đóng băng đúng giá trị của i tại lần lặp đó. Nếu go.mod khai dưới go 1.22, kết quả sẽ là 3 3 3 vì cả ba closure trước đây cùng chia sẻ một biến i duy nhất, và khi chúng chạy thì vòng lặp đã kết thúc với i = 3.
Bài 2 (sửa lỗi). Đoạn mã sau muốn dùng switch để rơi từ case này sang case tiếp theo dựa trên điều kiện của case sau, nhưng logic sai. Giải thích tại sao sai và cách Go xử lý fallthrough.
switch x := 5; {
case x > 0:
fmt.Println("dương")
fallthrough
case x > 100:
fmt.Println("rất lớn") // luôn in ra dù x=5, sai logic mong muốn
}
Đáp án
Đây không hẳn là "lỗi" cú pháp, nhưng là hiểu sai hành vi của fallthrough: nó rơi xuống case tiếp theo vô điều kiện, không kiểm tra điều kiện của case đó có đúng hay không. Vì vậy dù x = 5 không thoả x > 100, dòng "rất lớn" vẫn được in ra khi fallthrough được gọi từ case trước. Muốn logic chỉ in "rất lớn" khi thực sự x > 100, phải bỏ fallthrough và viết case riêng biệt độc lập:
switch {
case x > 100:
fmt.Println("rất lớn")
case x > 0:
fmt.Println("dương")
}
Bài 3 (đọc hiểu). Đoạn mã sau có break bên trong switch, lồng trong for. Nó thoát khỏi switch hay thoát khỏi for? Giải thích.
for i := 0; i < 5; i++ {
switch {
case i == 2:
break
}
fmt.Println(i)
}
Đáp án
In ra 0 1 2 3 4 — break chỉ thoát khỏi switch, không thoát khỏi for. Đây là chỗ dễ nhầm mà bài viết cảnh báo: break trong switch nằm trong for sẽ thoát switch chứ không thoát vòng lặp bên ngoài. Muốn thoát hẳn vòng lặp for từ bên trong switch, phải dùng break có nhãn (label), ví dụ break ngoai với nhãn ngoai: đặt trước for.
Bài 4 (vận dụng thực tế). Viết một vòng lặp lồng nhau duyệt qua ma trận 3x3 (dùng cú pháp for i := range 3 của Go 1.22+), thoát hẳn cả hai vòng lặp ngay khi tìm thấy phần tử bằng 0, dùng nhãn.
Đáp án
matrix := [3][3]int{{1, 2, 3}, {4, 0, 6}, {7, 8, 9}}
timThay:
for i := range 3 {
for j := range 3 {
if matrix[i][j] == 0 {
fmt.Println("tìm thấy tại", i, j)
break timThay
}
}
}
for i := range 3 là cú pháp duyệt số nguyên mới từ Go 1.22, tương đương for i := 0; i < 3; i++. Nhãn timThay: cho phép break timThay thoát hẳn cả hai vòng lặp lồng nhau cùng lúc.
Bài 5 (bẫy/đánh đổi). Bài viết nói việc Go 1.22 thay đổi ngữ nghĩa biến vòng lặp là "chuyện gần như chưa từng xảy ra với Go" (phá vỡ tương thích ngược), nhưng vẫn làm an toàn nhờ cơ chế khoá theo go.mod. Giải thích cơ chế đó hoạt động thế nào, và nêu hệ quả thực tế cho một dự án cũ có go.mod khai go 1.19 khi được build bằng Go 1.23.
Đáp án
Cơ chế: hành vi mới (mỗi vòng lặp có biến riêng) chỉ áp dụng khi go.mod của module khai phiên bản ngôn ngữ từ go 1.22 trở lên. Module khai phiên bản thấp hơn giữ nguyên ngữ nghĩa cũ, bất kể trình biên dịch Go thực tế đang dùng là phiên bản nào. Đây là hợp đồng ngữ nghĩa: mã của bạn vẫn có nghĩa đúng như ngày bạn ghim phiên bản, dù công cụ build được nâng cấp.
Hệ quả thực tế: một dự án có go.mod khai go 1.19 khi build bằng trình biên dịch Go 1.23 vẫn chạy theo ngữ nghĩa cũ (in 3 3 3 cho ví dụ closure), không phải ngữ nghĩa mới của 1.22 — dù trình biên dịch dùng để build hoàn toàn có khả năng làm điều đó. Đây là chỗ dễ gây nhầm lẫn khi đọc mã: hành vi phụ thuộc vào dòng go trong go.mod, không phụ thuộc vào phiên bản trình biên dịch cài trên máy. Muốn hưởng ngữ nghĩa mới, phải chủ động nâng dòng khai trong go.mod lên 1.22 trở lên, và nên chạy lại toàn bộ test sau khi nâng vì có thể có mã (hiếm) dựa vào hành vi cũ.