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. Hình dung một kiểu tốt như món đồ dùng được ngay khi lấy ra khỏi hộp: không cần lắp ráp, không có bước "khởi động" bắt buộc; còn kiểu kiểu-Java thì như đồ lắp ghép — phải ráp xong mới xài được.
"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: mở hộp ra là chạy.
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 — cái kệ trông đã ráp xong nhưng thiếu một con ốc, nhìn thì đứng vững mà đặt đồ lên là sập. 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 KieuCuaToirồi dùng ngay — có chạy không?
Có → 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.
Nếu chỉ làm một thứ sau bài này, mở hộp một struct trong dự án ra thử trong ba mươi giây:
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.
Mẫu số chung
Trạng thái rỗng, mặc định của một thứ nên là một trạng thái chạy được — Go gọi là "make the zero value useful", nhưng nó là một mục tiêu thiết kế phổ quát: một đối tượng dùng được ngay khoảnh khắc nó tồn tại, không có bước khởi tạo bắt buộc, nên không có khe "đã dựng mà chưa sẵn sàng". Cùng bản năng với "hợp lệ ngay lúc sinh" của tiêm-qua-hàm-khởi-tạo, với RAII của C++, với "biến trạng-thái-bất-hợp-lệ thành không-thể-biểu-diễn", và với cấu hình mà zero của mỗi trường là mặc định an toàn (sợi chỉ mặc-định-rẻ-và-an-toàn). Phần thưởng là không có gì để nhớ: không thứ tự gọi bắt buộc, không đối tượng ráp dở. Một kiểu bắt phải múa New()/Init() mới chịu làm việc đã biến một mặc định thành một cái bẫy.
Điều thứ hai: khi trạng thái rỗng thật sự không thể hợp lệ — một phụ thuộc bắt buộc không có mặc định, một tài nguyên phải cấp, một bất biến phải giữ — thì hãy làm cho dựng lên là cửa duy nhất (một hàm khởi tạo có thể trả lỗi), để một thực thể không hợp lệ đơn giản không thể tồn tại; đó là hợp-lệ-ngay-lúc-dựng, cùng nguyên tắc nhìn từ phía kia. Và cú hỏng sắc nhất là cái sai-trong-im-lặng ở giữa: một giá trị trông sẵn sàng mà không phải (map nil của Go đọc ngon rồi panic lúc ghi; một zero âm thầm mang nghĩa sai). Câu hỏi một dòng — tạo nó rỗng, dùng thử: chạy, panic, hay sai lặng lẽ? — phân loại chúng, và quy tắc là làm cho zero đúng, hoặc làm cho nó không thể chạm tới, đừng bao giờ để lại một kiểu trông dùng được mà không phải.
Ngày mai: đóng gói bằng chữ hoa chữ thường — Go không có public và private.
Bài tập làm thử
Bài 1 (đọc hiểu — code Go). Đoạn mã sau chạy được không cần gọi hàm khởi tạo nào. Giải thích vì sao, dựa trên nguyên tắc "make the zero value useful":
type Bo struct {
mu sync.Mutex
buf bytes.Buffer
ds []string
}
var b Bo
b.Them("a")
Đáp án
Chạy được vì mọi trường bên trong Bo đều có zero value hữu dụng: sync.Mutex rỗng đã khoá được ngay (không cần khởi tạo), bytes.Buffer rỗng đã ghi được ngay, và slice ds (nil) vẫn append được (nil slice là slice hợp lệ có độ dài 0). Vì cả ba trường thành phần đều tuân theo nguyên tắc "zero value hữu dụng", struct Bo bao bọc chúng cũng tự động thừa hưởng tính chất đó — không cần một dòng khởi tạo nào, đúng tinh thần "mở hộp ra là chạy" của thiết kế Go.
Bài 2 (sửa lỗi — code Go). Struct sau panic khi dùng ở trạng thái zero value. Sửa để nó tuân theo nguyên tắc "make the zero value useful" mà không bắt buộc người dùng gọi New():
type Cache struct {
m map[string]int
}
func (c *Cache) Set(k string, v int) {
c.m[k] = v // panic nếu m là nil
}
Đáp án
func (c *Cache) Set(k string, v int) {
if c.m == nil {
c.m = make(map[string]int)
}
c.m[k] = v
}
Đây là mẫu "make lười" (lazy make): chỉ tạo map thật sự khi cần ghi lần đầu, ngay trong method, thay vì bắt người dùng gọi hàm khởi tạo chỉ để make vài trường nội bộ. Với var c Cache rồi gọi c.Set(...) ngay, giờ đã chạy đúng mà không panic.
Bài 3 (vận dụng — code Go). Áp dụng "câu hỏi kiểm tra thiết kế" của bài viết (var x KieuCuaToi rồi dùng ngay — có chạy không?) để viết một test đơn giản kiểm tra zero value của một kiểu BoDem tự định nghĩa (giả sử có method Tang() và GiaTri() int).
Đáp án
func TestZeroValue(t *testing.T) {
var d BoDem
d.Tang()
d.Tang()
if got := d.GiaTri(); got != 2 {
t.Errorf("GiaTri() = %d, mong 2", got)
}
}
Nếu test này panic hoặc trả về giá trị sai, đó là dấu hiệu BoDem không tuân theo nguyên tắc "zero value hữu dụng" và cần sửa (hoặc cần bắt buộc dùng New() và ghi rõ tài liệu). Nếu chạy đúng, BoDem đang được thiết kế đúng phong cách thư viện chuẩn Go.
Bài 4 (bẫy/đánh đổi). Bài viết gọi map nil là "ngoại lệ duy nhất của zero value dùng được" — nó đọc được nhưng ghi thì panic. Với struct công khai sau, giải thích rủi ro cụ thể cho người dùng thư viện, và đề xuất sửa:
type Cau struct {
Data map[string]int // trường xuất khẩu
}
Đáp án
Người dùng thư viện tạo var c Cau (hoặc c := Cau{}) sẽ thấy struct "trông có vẻ dùng được ngay" (giống mọi kiểu khác trong Go tuân theo nguyên tắc zero-value-hữu-dụng), nhưng nếu họ ghi trực tiếp c.Data["x"] = 1, chương trình sẽ panic vì Data là map nil — "cái kệ trông đã ráp xong nhưng thiếu một con ốc, đặt đồ lên là sập". Đây chính là kiểu lỗi "sai trong im lặng" nguy hiểm nhất theo bài viết vì nó phá vỡ kỳ vọng chung của hệ sinh thái Go.
Đề xuất sửa: hoặc (a) không xuất khẩu trường map trực tiếp, cung cấp method Set/Get có make lười bên trong (như Bài 2), hoặc (b) nếu bắt buộc phải xuất khẩu, cung cấp New() khởi tạo Data sẵn và ghi rõ trong tài liệu rằng không được dùng zero value trực tiếp.
Bài 5 (đọc hiểu/khái niệm). Bài viết nêu ba trường hợp "chính đáng" để vẫn cần một hàm khởi tạo (New()) thay vì dựa vào zero value. Liệt kê ba trường hợp đó, và cho một ví dụ cụ thể (không lấy lại ví dụ trong bài) cho mỗi trường hợp.
Đáp án
Ba trường hợp: (1) có trường bắt buộc không có mặc định hợp lý — ví dụ một type ApiClient struct cần apiKey string bắt buộc, không có giá trị mặc định hợp lý cho một API key rỗng; (2) cần chạy goroutine nền hoặc cấp tài nguyên — ví dụ một type Poller struct cần khởi động một goroutine định kỳ gọi API ngay khi tạo; (3) cần kiểm tra hợp lệ ngay lúc tạo — ví dụ một type CauHinh struct cần validate rằng MaxConn > 0 trước khi được dùng, nếu không phải trả lỗi ngay tại thời điểm khởi tạo thay vì để lỗi xuất hiện muộn hơn khi dùng.
Trong cả ba trường hợp, New(...) trả về (*T, error) là thiết kế phù hợp — zero value không đủ khả năng thoả mãn các ràng buộc này.