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.

Bài tập làm thử

Bài 1 (đọc hiểu). Cho iterator:

func Dem(n int) func(yield func(int) bool) {
	return func(yield func(int) bool) {
		for i := 0; i < n; i++ {
			if !yield(i) {
				return
			}
		}
	}
}

Đoạn code sau in ra gì?

for v := range Dem(6) {
	if v == 3 {
		break
	}
	fmt.Print(v, " ")
}
Đáp án

In ra 0 1 2 . Khi v == 3, lệnh break được compiler dịch thành yield trả về false tại điểm đó; iterator Dem thấy yield(3) trả false (do phần thân trước khi break không kịp Print giá trị 3 — thực chất break chạy trước dòng Print) nên return ngay, dừng vòng lặp. Kết quả: các giá trị 0, 1, 2 được in trước khi gặp 3 và dừng.

Bài 2 (sửa lỗi/hoàn thiện). Viết một iterator ChanLe(n int) func(yield func(int) bool) sinh ra các số từ 0 đến n-1 nhưng chỉ những số chẵn, tuân theo đúng chữ ký và quy tắc kiểm tra yield đã học.

Đáp án
func ChanLe(n int) func(yield func(int) bool) {
	return func(yield func(int) bool) {
		for i := 0; i < n; i++ {
			if i%2 != 0 {
				continue // số lẻ: bỏ qua, không gọi yield
			}
			if !yield(i) {
				return
			}
		}
	}
}

Điểm quan trọng: chỉ gọi yield cho số chẵn; continue trong thân iterator ở đây là Go thuần (bỏ qua vòng lặp for bên trong iterator), khác với continue mà người dùng viết trong thân for range (cái đó được compiler dịch thành yield trả true). Luôn kiểm if !yield(i) { return } sau mỗi lần gọi yield để tôn trọng lệnh dừng từ phía người dùng.

Bài 3 (vận dụng thực tế). Bạn đang viết một hàm đọc file rất lớn theo từng dòng và muốn cung cấp API cho người dùng duyệt qua các dòng đó bằng for range. Nêu ưu điểm chính của việc dùng iterator (range-over-func) so với việc trả về []string chứa toàn bộ các dòng.

Đáp án

Ưu điểm chính là tính lười (lazy): iterator không cần cụ thể hóa toàn bộ dữ liệu trong bộ nhớ cùng lúc — nó chỉ giữ một dòng tại một thời điểm và sinh dòng tiếp theo khi được yêu cầu (khi vòng for range tiếp tục). Với file rất lớn (có thể hàng triệu dòng), trả về []string sẽ tốn rất nhiều bộ nhớ để chứa toàn bộ nội dung, trong khi iterator giữ bộ nhớ ở mức tối thiểu và cho phép người dùng break sớm nếu chỉ cần vài dòng đầu mà không phải đọc/giữ toàn bộ file.

Bài 4 (đọc số liệu đo thật). Bài đo 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). Giải thích tại sao range-over-func chậm hơn nhưng vẫn đạt 0 cấp phát, và điều gì có thể phá vỡ tính "0 cấp phát" đó.

Đáp án

Range-over-func chậm hơn (~1,6x) vì mỗi phần tử sinh ra tương ứng với một lời gọi hàm yield, tốn hơn một phép gán/tăng chỉ số đơn thuần của vòng for thường. Tuy nhiên nó vẫn đạt 0 cấp phát vì khi iterator được dùng trực tiếp ngay trong for range (cùng phạm vi), compiler có thể nội tuyến (inline) cả closure yield lẫn closure của iterator, nên không có gì phải "thoát" (escape) lên heap. Điều có thể phá vỡ điều này: nếu bạn lưu iterator vào một biến rồi truyền nó lòng vòng qua nhiều hàm khác nhau (không dùng trực tiếp tại chỗ), compiler khó/không thể nội tuyến nữa, và escape analysis có thể buộc closure phải cấp phát lên heap — bài viết khuyến cáo phải tự đo lại trong trường hợp cụ thể đó.

Bài 5 (bẫy/đánh đổi). Một lập trình viên nói: "Iterator luôn tốt hơn vòng for thường vì nó tổng quát hơn, nên tôi sẽ đổi hết mọi vòng lặp trong code sang range-over-func." Nêu điểm cần cân nhắc lại trong quyết định này dựa theo bài học.

Đáp án

Bài học nói rõ: "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) mức chênh ~143 ns trên 1.000 phần tử không đáng kể so với lợi ích về API thống nhất và tiết kiệm bộ nhớ. Nhưng "trong vòng lặp cực nóng đếm từng nano giây, vòng thẳng vẫn thắng" — nghĩa là không nên đổi mọi vòng lặp một cách máy móc; những vòng lặp hiệu năng cực kỳ nhạy cảm (hot path đã được xác định qua profiling) nên giữ nguyên dạng vòng for thường thay vì đổi sang iterator chỉ vì tính tổng quát.

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.