Bài 4 đã đo: mọi biến trong Go đều có giá trị ngay khi khai báo. Bài này về hệ quả thiết kế — và đây là thứ phân biệt mã Go viết như Go với mã Go viết như Java.

"Make the zero value useful"

Đây là câu châm ngôn chính thức của cộng đồng, và thư viện chuẩn theo nó rất nghiêm:

type Bo struct {
	mu  sync.Mutex
	buf bytes.Buffer
	ds  []string
}

var b Bo          // KHÔNG khởi tạo gì
b.Them("a")
b.Them("b")
  Bo{} chưa khởi tạo gì: ds=[a b] buf="ab"

sync.Mutex rỗng đã khoá được. bytes.Buffer rỗng đã ghi được. Slice nil đã append được.

Nên Bo cũng dùng được ngay — mà tôi không viết dòng khởi tạo nào. Đó là điều người viết Go mong đợi khi nhìn thấy kiểu của bạn.

Thư viện chuẩn làm gương

var buf bytes.Buffer          // ghi được ngay
var mu sync.Mutex             // khoá được ngay
var wg sync.WaitGroup         // dùng được ngay
var once sync.Once            // dùng được ngay
var t time.Time               // là thời điểm zero, IsZero() = true
var sb strings.Builder        // ghi được ngay
http.Client{}                 // gọi được ngay, dùng mặc định

Không cái nào cần New(). So sánh với Java, nơi gần như mọi thứ đều new trước khi dùng — khác biệt này định hình cách viết mã.

Thiết kế theo zero value

Ba nguyên tắc thực dụng:

Chọn zero value là trạng thái mặc định hợp lý. Nếu Timeout bằng 0 nghĩa là "không giới hạn", hãy để 0 mang nghĩa đó. Nếu 0 phải nghĩa là "mặc định 30 giây" thì diễn giải nó lúc dùng:

func (c *Client) timeout() time.Duration {
	if c.Timeout == 0 { return 30 * time.Second }
	return c.Timeout
}

Dùng slice và map nil được ở đường đọc. Đừng bắt người dùng gọi New() chỉ để make vài trường. Chỉ make khi thật sự cần ghi — và làm điều đó lười, ngay trong method:

func (c *Cache) Set(k string, v int) {
	if c.m == nil { c.m = make(map[string]int) }
	c.m[k] = v
}

Đừng đòi hỏi thứ tự gọi. Kiểu buộc phải Init() trước khi dùng là thiết kế dễ sai — và người dùng sẽ quên.

Khi nào vẫn cần hàm khởi tạo

Zero value không phải lúc nào cũng đủ. Ba trường hợp chính đáng:

Có trường bắt buộc không có mặc định hợp lý — kết nối cơ sở dữ liệu, khoá API. Lúc đó New(...) trả về (*T, error) là đúng.

Cần chạy goroutine nền hoặc cấp tài nguyên. Zero value không khởi động được gì.

Cần kiểm tra hợp lệ ngay lúc tạo.

Quy ước đặt tên: New() cho kiểu chính của package, NewXxx() cho các kiểu khác. Package tên kho thì kho.New() chứ không phải kho.NewKho() — tránh lặp tên.

Cái bẫy còn lại: map nil

Đây là ngoại lệ duy nhất của "zero value dùng được", và bài 10 đã đo:

var m map[string]int
m["a"] = 1        // panic

Đọc được, ghi panic. Nên nếu struct của bạn có trường map mà người dùng sẽ ghi vào, bạn phải hoặc make trong New(), hoặc make lười trong method như ví dụ ở trên.

Cùng lý do, đừng để trường map lộ ra ngoài (Data map[string]int) nếu người dùng có thể ghi trực tiếp vào nó — họ sẽ nhận panic từ một struct trông có vẻ dùng được ngay.

Kiểm tra thiết kế của bạn

Một câu hỏi duy nhất, hỏi cho mọi kiểu bạn viết:

var x KieuCuaToi rồi dùng ngay — có chạy không?

→ tốt, bạn đang viết Go đúng cách.

Không, panic → hoặc sửa để chạy được, hoặc bắt buộc phải có New() và ghi rõ trong tài liệu.

Không, nhưng im lặng sai → đây là trường hợp tệ nhất, phải sửa.

Thử ba mươi giây

Chọn một struct trong dự án của bạn và viết:

func TestZeroValue(t *testing.T) {
	var x KieuCuaBan
	_ = x    // rồi gọi vài method
}

Nếu nó panic, bạn vừa tìm ra một chỗ người dùng thư viện của bạn sẽ vấp. Nếu nó chạy, kiểu của bạn hành xử như thư viện chuẩn — và đó là lời khen trong thế giới Go.

Ngày mai: đóng gói bằng chữ hoa chữ thường — Go không có publicprivate.