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.

Ảnh chụp đoạn mã Go nền tối minh hoạ iterator range-over-func for range trên một hàm cơ chế yield, iterator là hàm nhận yield trả bool func Dem n int trả về func yield func int bool bên trong vòng for i chạy nếu không yield i thì return nghĩa là yield trả false thì dừng, dùng y như for range thường for v range Dem 5 in ra 0 1 2 3 4, compiler khử đường desugar thế nào for v range Dem 5 với body biến thành Dem 5 gọi với func v int bool chạy body rồi return true chạy tiếp phần tử sau, break biến yield trả false iterator thấy false thì return, continue biến yield trả true bỏ qua phần còn lại của body, return biến yield trả false cộng cờ để hàm ngoài return thật, cốt lõi iterator là func yield func T bool hàm điều khiển vòng yield đưa một phần tử cho body trả true false false là body muốn dừng break return thì iterator return for range là cú pháp compiler dịch thành lời gọi cộng closure 0 cấp phát khi iterator dùng trực tiếp yield không thoát heap

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 → yield trả false. Iterator thấy false thì return, kết thúc.
  • continue → yield trả true ngay tại điểm đó (bỏ qua phần còn lại của body).
  • return từ hàm ngoài → yield trả false và đặ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):

Ảnh chụp bảng kết quả đo thật nền tối break continue return đều xuyên qua iterator chậm hơn 1,6x nhưng 0 cấp phát, go run cộng go test bench cộng escape analysis Go 1.23 arm64 10 core, semantics chạy thật điều khiển luồng nguyên vẹn range in 0 1 2 3 4 duyệt bình thường, break tại 3 in 0 1 2 break biến yield false thì dừng, continue chẵn in 0 2 4 continue bỏ qua lẻ, tổng tới khi lớn hơn 10 bằng 15 return xuyên qua iterator, break continue return trong body hoạt động đúng như vòng lặp thường compiler lo phần truyền tín hiệu qua yield, benchmark cái giá của lớp yield tổng 1000 phần tử range over func DemSeq 376,6 ns 0 alloc vòng for thường 233,5 ns 0 alloc range trên slice 251,6 ns 0 alloc iterator chậm hơn vòng thường 1,6x vì mỗi phần tử là một lời gọi yield nhưng 0 cấp phát compiler nội tuyến closure khi iterator dùng trực tiếp, escape analysis yield does not escape body closure does not escape closure Seq trả về escapes to heap khi phân tích riêng lẻ nhưng dùng trực tiếp thì bench đo 0 alloc nội tuyến khử được

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ề

  1. Range-over-func cho for range chạy trên hàm func(yield func(T) bool): iterator chủ động đẩy phần tử qua yield, còn thân vòng lặp chính là closure yield đó — kiểm if !yield(x) { return } là cách tôn trọng lệnh dừng.
  2. Compiler khử đường lúc biên dịch, không phải runtime: break → yield trả false, continue → trả true, return → trả false cộ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).
  3. 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.