Go cố tình không có macro, không có metaprogramming lúc chạy. Triết lý là "mã tường minh, đọc là hiểu". Nhưng có những mã lặp đi lặp lại đến phát chán: viết String() cho mỗi enum, viết mock cho mỗi interface, viết marshal cho mỗi struct. Câu trả lời của Go không phải macro mà là sinh mã lúc build-time qua go:generate — và mã sinh ra là mã Go thường, đọc được, kiểm tra được. Bài này mổ xẻ cơ chế go:generate, dùng stringer làm ví dụ kinh điển, và đo thật vì sao mã sinh ra lại nhanh hơn cách viết tay thông thường.

//go:generate chỉ là một chỉ thị

//go:generate là một chỉ thị đặt trong comment, gắn một lệnh shell vào mã nguồn. Điểm quan trọng nhất, và hay bị hiểu nhầm: nó KHÔNG chạy lúc go build. Bạn phải gọi go generate một cách chủ động (thường trong CI hoặc khi sửa enum), và nó quét mọi //go:generate rồi chạy lệnh tương ứng.

//go:generate stringer -type=TrangThai

type TrangThai int
const (
	Cho      TrangThai = iota // 0
	DangChay                  // 1
	HoanTat                   // 2
	Loi                       // 3
)

go generate ./... đọc dòng chỉ thị, thấy stringer -type=TrangThai, chạy công cụ stringer, và nó sinh ra một tệp trangthai_string.go chứa phương thức String() cho kiểu TrangThai. Enum trong Go chỉ là int — không có String() thì fmt.Println(DangChay) in ra 1, vô nghĩa khi debug. stringer sinh mã để in ra DangChay.

Ảnh chụp đoạn mã Go nền tối minh hoạ go generate và stringer sinh mã lặp lại thay vì viết tay, chỉ thị go generate gắn lệnh vào mã nguồn go generate stringer type TrangThai, type TrangThai int const Cho TrangThai iota 0 DangChay 1 HoanTat 2 Loi 3, go generate không chạy lúc build bạn gọi tay hoặc CI go generate ba chấm quét go generate chạy lệnh, tệp sinh ra trangthai string go không sửa tay Code generated by stringer DO NOT EDIT, chuỗi ghép cộng mảng index offset O 1 0 cấp phát const gạch dưới TrangThai name bằng ChoDangChayHoanTatLoi var gạch dưới TrangThai index bằng mảng uint8 0 3 11 18 21, func i TrangThai String string nếu i nhỏ hơn 0 hoặc lớn hơn bằng độ dài index trừ 1 trả TrangThai cộng strconv FormatInt trả gạch dưới TrangThai name cắt lát theo index, bảo vệ khỏi enum lệch drift guard func gạch dưới var x mảng 1 struct rỗng gán x Cho trừ 0 DangChay trừ 1 HoanTat trừ 2 Loi trừ 3 đổi thứ tự giá trị const mà quên re-generate index lệch lỗi biên dịch index out of bounds không lỗi lặng

Hình 1: //go:generate stringer -type=TrangThai gắn lệnh vào mã. Chạy go generate sinh trangthai_string.go với String(). Mã sinh dùng chuỗi ghép + mảng index và có một hàm _() làm drift guard.

Mã sinh ra: tối ưu bất ngờ

Nhìn vào tệp stringer sinh ra, ta thấy nó không dùng cách ngây thơ (một switch hay map). Nó dùng một kỹ thuật gọn hơn:

const _TrangThai_name = "ChoDangChayHoanTatLoi"
var _TrangThai_index = [...]uint8{0, 3, 11, 18, 21}

func (i TrangThai) String() string {
	if i < 0 || i >= TrangThai(len(_TrangThai_index)-1) {
		return "TrangThai(" + strconv.FormatInt(int64(i), 10) + ")"
	}
	return _TrangThai_name[_TrangThai_index[i]:_TrangThai_index[i+1]]
}

Tất cả tên nối thành một chuỗi "ChoDangChayHoanTatLoi", và một mảng index đánh dấu ranh giới {0, 3, 11, 18, 21}. String() chỉ cắt một lát (slice) của chuỗi gốc theo offset — không băm, không cấp phát (slice của string chia sẻ bộ nhớ chuỗi gốc). Đây là O(1) và 0 byte cấp phát.

Đo thật: nhanh hơn map, an toàn hơn viết tay

Sinh mã xong, %v giờ in tên thay vì số:

giá trị:    1              // int(DangChay)
Stringer:   DangChay       // %v gọi String()
tất cả:     Cho DangChay HoanTat Loi
dạng chuỗi: Loi            // %s cũng dùng String()

So mã sinh với cách viết tay phổ biến nhất — một map[TrangThai]string:

Ảnh chụp bảng kết quả đo thật nền tối generate xong phần trăm v in tên stringer nhanh hơn map 1,7x drift là lỗi biên dịch, go generate cộng go run cộng go test bench Go 1.23 arm64 10 core, trước sau go generate trước trangthai go 1 tệp chưa có String sau go generate trangthai go trangthai string go stringer sinh thêm, go run giá trị 1 int DangChay Stringer DangChay phần trăm v gọi String sinh ra tất cả Cho DangChay HoanTat Loi dạng chuỗi Loi phần trăm s cũng dùng String, benchmark cách stringer chuỗi cộng index vs map viết tay stringer String chuỗi ghép cộng index 1,284 ns 0 alloc map TrangThai string tra bảng băm 2,223 ns 0 alloc stringer nhanh hơn 1,7x cắt lát chuỗi theo offset là O 1 không băm không cấp phát mã sinh còn tối ưu hơn map viết tay, drift guard chạy thật đổi enum quên re-generate go build trangthai string go dòng 11 invalid argument index 1 out of bounds 0 1 dòng 12 invalid argument index 1 out of bounds hàm gạch dưới với x Cho trừ 0 phát hiện const lệch ngay lúc build không có chuyện String trả tên sai âm thầm trên production, cốt lõi go generate chỉ thị gắn lệnh go generate chạy tay CI không lúc build stringer sinh String cho enum từ khai báo const nhanh chuỗi ghép cộng index O 1 nhanh hơn map 1,7x 0 alloc an toàn drift guard biến enum lệch thành lỗi biên dịch DO NOT EDIT tệp sinh ra không sửa tay chỉ re-generate

Hình 2: %v in tên sau khi generate. Benchmark: String() sinh bởi stringer 1,284 ns so với map 2,223 ns — nhanh hơn ~1,7x, cả hai 0 cấp phát. Drift guard: đổi enum mà quên re-generate → lỗi biên dịch index out of bounds.

  • stringer String(): 1,284 ns/op, 0 cấp phát.
  • map tra bảng: 2,223 ns/op, 0 cấp phát.

Mã sinh nhanh hơn ~1,7 lần cách viết tay bằng map. Cắt lát chuỗi theo offset (số học con trỏ đơn giản) rẻ hơn băm khóa rồi tra bucket. Đây là điểm phản trực giác: sinh mã không chỉ tiết kiệm công viết mà còn cho mã tốt hơn cách đa số người tự viết.

Bảo vệ khỏi enum lệch

Điểm tinh tế nhất trong mã stringer sinh ra là một hàm rỗng tên _():

func _() {
	var x [1]struct{}
	_ = x[Cho-0]; _ = x[DangChay-1]; _ = x[HoanTat-2]; _ = x[Loi-3]
}

Nó không bao giờ được gọi, nhưng nó là một drift guard (bảo vệ chống lệch). Nếu bạn thêm một hằng mới vào giữa enum hoặc đổi thứ tự mà quên chạy lại go generate, các giá trị Cho, DangChay... đổi, khiến biểu thức như x[DangChay-1] truy cập ngoài mảng [1]struct{} → lỗi biên dịch index out of bounds. Đo thật: chèn một hằng mới rồi build, compiler báo ngay invalid argument: index 1 out of bounds [0:1]. Nhờ vậy không bao giờ có chuyện String() trả tên sai âm thầm trên production — sự không nhất quán bị bắt lúc build.

Đánh đổi cần cân nhắc

Mã sinh phải commit vào repo, và không sửa tay. Tệp *_string.go có dòng // Code generated ... DO NOT EDIT. — sửa tay là mất hết ở lần generate sau. Commit nó vào git (không phải gitignore) để người build không cần cài stringer, và CI nên chạy go generate rồi kiểm git diff sạch — nếu ai đó sửa enum mà quên generate, CI bắt được.

go generate không tự chạy — kỷ luật là ở người. Vì nó tách khỏi go build, quên chạy lại là rủi ro thật. Drift guard của stringer đỡ được ca đổi enum, nhưng với công cụ sinh mã khác (mock, protobuf) không phải lúc nào cũng có guard. Đưa go generate vào Makefile/CI là cách kỷ luật hóa.

Sinh mã đúng chỗ, đừng lạm dụng. stringer, mockgen, protoc-gen-go giải bài toán mã cơ học lặp lại — dùng chúng là đúng. Nhưng đừng viết generator cho logic nghiệp vụ thay đổi thường xuyên; mã sinh khó đọc diff và khó debug hơn mã viết tay. Sinh mã hợp với thứ máy móc và ổn định, không hợp với thứ hay đổi và cần suy nghĩ.

Ba ý mang về

  1. //go:generate gắn lệnh sinh mã vào mã nguồn nhưng KHÔNG chạy lúc build — bạn gọi go generate chủ động (thường trong CI); stringer -type= sinh String() cho enum để %v in tên thay vì số vô nghĩa.
  2. Mã sinh ra nhanh hơn cách viết tay: đo thật, String() của stringer (chuỗi ghép + mảng index, cắt lát O(1)) chạy 1,284 ns so với map 2,223 ns — nhanh hơn ~1,7x, cả hai 0 cấp phát; sinh mã cho mã tốt hơn đa số tự viết.
  3. Drift guard biến enum lệch thành lỗi biên dịch: hàm _() với x[Cho-0]... bắt ngay index out of bounds khi đổi enum mà quên re-generate — nhưng phải commit tệp sinh và đưa go generate vào CI để kỷ luật hóa.

Phần sau ta bước qua ranh giới ngôn ngữ để gọi mã C: Phần sau mổ xẻ cgo — cách Go gọi hàm C, và cái giá thật của mỗi lần vượt biên giới Go↔C mà nhiều người đánh giá thấp.