Trong 15 năm, for range của Go chỉ chạy trên vài thứ dựng sẵn: slice, map, chuỗi, channel. Muốn duyệt một cây, một danh sách liên kết, hay kết quả phân trang từ API, bạn phải viết callback thủ công hoặc trả về slice tốn bộ nhớ. Go 1.23 mở khoá điều này: for range giờ chạy được trên một hàm. Cơ chế đứng sau — gọi là range-over-func — tinh tế hơn vẻ ngoài, và hiểu nó là hiểu vì sao break, continue, return đều "vừa vặn" bên trong một cấu trúc mà bạn tự viết. Bài này đo thật cơ chế đó và cái giá của nó.
Iterator là một hàm nhận yield
Một iterator trong Go 1.23 là một hàm có chữ ký đặc biệt: func(yield func(T) bool). Nó nhận vào một hàm yield, và mỗi lần muốn "sinh ra" một phần tử, nó gọi yield(phần_tử). Giá trị bool mà yield trả về là tín hiệu điều khiển: true nghĩa là "tiếp tục", false nghĩa là "vòng lặp muốn dừng" (do break hoặc return).
func Dem(n int) func(yield func(int) bool) {
return func(yield func(int) bool) {
for i := 0; i < n; i++ {
if !yield(i) { // yield trả false -> ngừng sinh
return
}
}
}
}
for v := range Dem(5) { fmt.Print(v) } // 0 1 2 3 4
Điểm mấu chốt: iterator chủ động đẩy từng phần tử qua yield, còn thân vòng lặp for range chính là cái yield đó. Bạn viết logic duyệt một lần trong iterator; người dùng viết for v := range ... quen thuộc. Kiểm tra if !yield(i) { return } là bắt buộc — nó là cách iterator tôn trọng lệnh dừng từ phía người dùng.

Hình 1: Iterator là hàm func(yield func(T) bool). Compiler khử for v := range Dem(5) { BODY } thành lời gọi Dem(5)(func(v int) bool { BODY; return true }); break/continue/return được dịch thành giá trị trả về của yield cộng cờ điều khiển.
Compiler khử đường thế nào
for range trên hàm không phải phép màu runtime — nó là phép biến đổi lúc biên dịch. Compiler dịch:
for v := range Dem(5) { BODY }
thành đại ý:
Dem(5)(func(v int) bool {
BODY
return true // chạy hết body -> tiếp phần tử sau
})
Thân vòng lặp trở thành thân của closure yield. Ba lệnh điều khiển được dịch khéo léo:
break→yieldtrảfalse. Iterator thấyfalsethìreturn, kết thúc.continue→yieldtrảtruengay tại điểm đó (bỏ qua phần còn lại của body).returntừ hàm ngoài →yieldtrảfalsevà đặt một cờ, để sau khi iterator thoát, hàm bao ngoài thực sựreturn.
Đo thật cả bốn hành vi trong go-lab (Go 1.23):

Hình 2: break dừng ở đúng phần tử (0 1 2), continue bỏ qua số lẻ (0 2 4), return xuyên qua iterator trả 15. Benchmark: range-over-func 376,6 ns so với vòng for thường 233,5 ns — chậm ~1,6x nhưng 0 cấp phát.
Kết quả: break tại 3 cho 0 1 2, continue chẵn cho 0 2 4, và return từ trong loop trả về 15 (tổng 0+1+2+3+4+5 vượt ngưỡng 10). Điều khiển luồng hoạt động y hệt vòng lặp thường — đây là điểm thiết kế quan trọng nhất: người dùng iterator không phải học luật mới.
Cái giá: chậm hơn nhưng không cấp phát
Không gì miễn phí. Mỗi phần tử sinh ra là một lời gọi hàm yield, thay vì chỉ một phép gán như vòng lặp thẳng. Benchmark phép tổng 1.000 phần tử:
- range-over-func: 376,6 ns/op, 0 cấp phát.
- vòng for thường: 233,5 ns/op, 0 cấp phát.
- range trên slice: 251,6 ns/op, 0 cấp phát.
Iterator chậm hơn vòng thường khoảng 1,6 lần — đó là chi phí của lớp gọi yield cho mỗi phần tử. Nhưng con số quan trọng không kém là 0 cấp phát. Khi iterator được dùng trực tiếp trong for range, compiler nội tuyến (inline) closure yield và closure iterator, nên không có gì thoát ra heap trong vòng nóng.
Điểm cần thành thật: escape analysis (-gcflags=-m) có báo func literal escapes to heap cho closure mà iterator trả về khi phân tích riêng lẻ — vì một hàm trả về closure thì closure đó thường phải lên heap. Nhưng khi nơi gọi dùng nó ngay trong for range cùng hàm, việc nội tuyến khử được cả cấp phát đó, và benchmark đo 0 allocs/op là bằng chứng runtime cuối cùng. Kết luận: dùng iterator trực tiếp thì rẻ; nếu bạn lưu iterator vào biến rồi truyền lòng vòng qua nhiều hàm, khả năng cấp phát tăng lên — hãy đo chính đường dùng của bạn.
Đánh đổi cần cân nhắc
Dùng iterator khi API duyệt quan trọng hơn 1,6x tốc độ. Với hầu hết code (duyệt cây, phân trang, lọc/biến đổi lười), 143 ns chênh trên 1.000 phần tử là không đáng kể so với lợi ích: một API for range thống nhất cho mọi cấu trúc dữ liệu, không phải trả về slice tốn bộ nhớ hay viết callback thủ công. Trong vòng lặp cực nóng đếm từng nano giây, vòng thẳng vẫn thắng.
Iterator lười (lazy) tiết kiệm bộ nhớ thật sự. Lợi ích lớn nhất không phải tốc độ mà là không phải cụ thể hóa toàn bộ dữ liệu. Một iterator đọc file theo dòng, hay gọi API phân trang, chỉ giữ một phần tử tại một thời điểm — thay vì []string chứa cả triệu dòng. Đây là nơi range-over-func thắng đậm so với "trả về slice".
Iterator có trạng thái phải cẩn thận khi dùng lại. Một iterator gọi hai lần phải cho kết quả nhất quán (hoặc tài liệu rõ là dùng-một-lần). Iterator bọc quanh channel hay con trỏ file là hữu hạn — duyệt lần hai được gì tuỳ nguồn. Đây là hợp đồng bạn phải tự giữ; compiler không kiểm hộ.
Ba ý mang về
- Range-over-func cho
for rangechạy trên hàmfunc(yield func(T) bool): iterator chủ động đẩy phần tử quayield, còn thân vòng lặp chính là closureyieldđó — kiểmif !yield(x) { return }là cách tôn trọng lệnh dừng. - Compiler khử đường lúc biên dịch, không phải runtime:
break→yieldtrảfalse,continue→ trảtrue,return→ trảfalsecộng cờ — nên đo thật cho thấy điều khiển luồng hoạt động y hệt vòng lặp thường (break dừng đúng chỗ, return xuyên qua iterator). - Cái giá là ~1,6x tốc độ nhưng 0 cấp phát khi dùng trực tiếp (376,6 ns so với 233,5 ns, đo thật, compiler nội tuyến closure): đổi lại được API duyệt thống nhất và iterator lười tiết kiệm bộ nhớ — chọn nó khi tính tổng quát quan trọng hơn từng nano giây.
Phần sau ta đi vào hai kiểu chuẩn hoá cơ chế này trong thư viện chuẩn: Phần sau mổ xẻ iter.Seq và iter.Seq2 — hai định danh mà cả hệ sinh thái Go dùng chung để viết và tiêu thụ iterator.