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.

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:

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ề
//go:generategắn lệnh sinh mã vào mã nguồn nhưng KHÔNG chạy lúc build — bạn gọigo generatechủ động (thường trong CI);stringer -type=sinhString()cho enum để%vin tên thay vì số vô nghĩa.- 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ớimap2,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. - Drift guard biến enum lệch thành lỗi biên dịch: hàm
_()vớix[Cho-0]...bắt ngayindex out of boundskhi đổi enum mà quên re-generate — nhưng phải commit tệp sinh và đưago generatevà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.