Trong C, một biến cục bộ vừa khai ra giống một phòng khách sạn người trước vừa trả mà chưa ai dọn: bên trong là đủ thứ rác của lần dùng trước, và bạn phải tự dọn sạch trước khi dùng, nếu không thì lãnh hậu quả khó lường. Go quyết định khác hẳn: mọi phòng đều được dọn sẵn trước khi bạn nhận. Giường trải, bàn lau, nhận phòng là dùng được ngay. Cái "trạng thái dọn sẵn" đó gọi là zero value, và như bạn sẽ thấy, Go còn đi xa hơn một bước mà ít ngôn ngữ làm: nó khiến cái phòng rỗng ấy dùng được luôn. Go có ít kiểu hơn Java và ít cách khai báo hơn, nhưng có vài quyết định thiết kế rất riêng. Bài này về những quyết định đó, và cái nào sẽ cắn bạn.
Hai cách khai báo
var tuoi int = 30 // đầy đủ
var tuoi = 30 // suy ra kiểu
tuoi := 30 // ngắn gọn, chỉ dùng được TRONG hàm
var tuoi int // chỉ khai, nhận zero value
:= là thứ bạn sẽ gõ nhiều nhất. Hai giới hạn: nó chỉ dùng trong hàm (biến ở mức package phải dùng var), và vế trái phải có ít nhất một biến mới.
Cái thứ hai là nguồn của một lỗi tinh vi:
a, err := f()
b, err := g() // OK — b là mới, err được GÁN LẠI
Nhưng trong một scope lồng, err := tạo biến mới che biến ngoài, và lỗi bạn gán vào đó không bao giờ ra tới ngoài. go vet bắt được một số trường hợp; phần còn lại phải tự để ý.
Zero value: dùng được ngay
Đây là điều tôi cho là quyết định thiết kế quan trọng nhất trong bài.
int=0 float64=0 string="" bool=false ptr=<nil>
slice=[] (nil? true, len=0) map=map[] (nil? true)
đọc map nil vẫn được: 0 | append vào slice nil vẫn được: [1]
Go không có biến chưa khởi tạo. Mọi biến đều có giá trị ngay khi khai báo, và giá trị đó được chọn sao cho dùng được luôn — đúng cái phòng dọn sẵn:
Đọc một map nil trả về zero value chứ không panic. append vào một slice nil hoạt động bình thường. len() của cả hai đều là 0. Một sync.Mutex chưa khởi tạo đã khoá được. Một bytes.Buffer rỗng đã ghi được.
Hệ quả cho cách bạn thiết kế API: struct rỗng của bạn nên dùng được ngay, không cần hàm khởi tạo. Đó là kỳ vọng của mọi người viết Go, và nó là chủ đề của bài 18.
Một ngoại lệ phải nhớ: ghi vào map nil thì panic. Đọc được, ghi không được — nhìn quanh phòng thì thoải mái, nhưng muốn cất đồ vào thì phải make nó trước.
Kích thước thật của các kiểu
int=8 byte int64=8 float64=8 string=16 slice=24 map=8
Ba con số cuối đáng chú ý vì chúng lộ ra cấu trúc bên trong:
string = 16 byte — một con trỏ tới mảng byte cộng một độ dài. Chuỗi trong Go bất biến, nên sao chép một string chỉ sao chép 16 byte đó chứ không sao chép nội dung.
slice = 24 byte — con trỏ, độ dài, sức chứa. Đây là lý do bài 9 sẽ dành trọn cho slice: truyền slice vào hàm là sao chép 24 byte, nhưng mảng nền thì dùng chung.
map = 8 byte — chỉ là một con trỏ. Nên map luôn được truyền theo tham chiếu trên thực tế.
int là 8 byte trên máy 64-bit, 4 byte trên 32-bit. Cần chắc chắn thì dùng int64 hay int32.
Không có ép kiểu ngầm
var a int32 = 5
var c int64 = a // LỖI BIÊN DỊCH
var c int64 = int64(a) // đúng
cannot use a (variable of type int32) as int64 value
Ngay cả giữa int và int64 — hai kiểu có thể cùng kích thước — bạn vẫn phải viết tường minh.
Nghe phiền, nhưng nó xoá một họ lỗi: không có chuyện int bị nâng thành float64 rồi mất độ chính xác mà không ai thấy, không có bảng quy tắc nâng kiểu phải thuộc lòng. Mọi chỗ đổi kiểu đều nhìn thấy được trong mã.
Cùng lý do, Go không có nạp chồng toán tử: + chỉ làm đúng một việc cho mỗi kiểu.
Tràn số: Go im lặng cuộn vòng
int8 127 + 1 = -128
uint8 0 - 1 = 255
Không panic, không lỗi. Đây là điểm Go không bảo vệ bạn, và nó giống hệt Java.
Với số đếm hay chỉ số thì dùng int (64-bit) là gần như không lo. Nhưng khi đọc dữ liệu từ mạng hay tệp vào int32, uint16, hãy kiểm biên tường minh — trình biên dịch sẽ không nhắc.
Chia số nguyên cũng cắt phần dư như Java:
7/2 = 3 7.0/2.0 = 3.5
Khác biệt với Java: chia số nguyên cho 0 làm panic thay vì ném exception, còn chia float cho 0 cho +Inf như chuẩn IEEE.
Hằng không kiểu: chính xác tuỳ ý
Đây là thứ Go làm hay hơn hầu hết ngôn ngữ khác.
const pi = 3.14159265358979323846264338327950288
const lon = 1 << 62
1<<62 gán vào float64 : 4.611686018427388e+18
hằng pi 35 chữ số -> float64: 3.14159265358979311600
Hằng không có kiểu được giữ với độ chính xác tuỳ ý lúc biên dịch, và chỉ bị làm tròn về kiểu đích khi bạn gán nó vào một biến. Nên bạn viết được biểu thức hằng vượt quá int64 miễn là kết quả cuối vừa.
Điều này làm hằng trong Go hành xử như toán học chứ không như một biến. const x = 1/3 khác hẳn var x float64 = 1/3 — cái sau cho 0 vì Go chia số nguyên trước rồi mới gán.
iota: enum của người nghèo
Go không có enum. Nó có iota, một bộ đếm tự tăng trong khối const:
type Mau int
const (
Do Mau = iota // 0
Xanh // 1
Vang // 2
)
func (m Mau) String() string { return [...]string{"Đỏ", "Xanh", "Vàng"}[m] }
Đỏ Xanh Vàng (giá trị 0 1 2)
Định nghĩa String() là quy ước quan trọng: fmt tự gọi nó, nên in ra được chữ thay vì số.
Hạn chế phải biết: đây không phải enum thật. Mau(99) vẫn hợp lệ về kiểu và sẽ panic khi gọi String(). Không có kiểm tra đầy đủ nhánh trong switch. Muốn an toàn thì phải tự viết hàm kiểm hợp lệ — và đó là cái giá của một ngôn ngữ cố tình nhỏ.
Nếu chỉ nhớ đúng một thứ từ bài này, hãy nhớ hai dòng sau — chúng tóm tắt trọn zero value:
var m map[string]int
fmt.Println(m["chua-co"]) // 0, không panic
m["a"] = 1 // panic: assignment to entry in nil map
Hai hành vi khác hẳn nhau trên cùng một biến nil: đọc thì luôn an toàn, ghi thì phải khởi tạo. Cái phòng được dọn sẵn để bạn nhìn ngắm, nhưng cất đồ vào thì phải gọi lễ tân trước.
Mẫu số chung
"Một biến vừa khai ra thì chứa gì" nghe như chuyện cú pháp vặt, nhưng nó là một trong những quyết định triết lý sâu nhất của một ngôn ngữ — và mỗi ngôn ngữ đứng một chỗ khác nhau trên trục tiện-lợi ↔ an-toàn.
- C/C++ nói: rác. Biến cục bộ chưa gán mang giá trị bất kỳ còn sót trong bộ nhớ — nhanh, nhưng là nguồn vô tận của lỗi khó lần. Đây chính là cái phòng bừa bộn Go quyết tâm dọn.
- Rust đi cực ngược lại: không có gì cả, và trình biên dịch bắt buộc bạn gán trước khi dùng, sai là lỗi biên dịch. An toàn tuyệt đối, đổi lại nghiêm khắc;
Defaultlà thứ bạn phải chủ động xin. - Java nằm giữa và... chia đôi: trường của đối tượng tự nhận 0/null (giống zero value của Go cho struct), nhưng biến cục bộ thì bắt buộc gán như Rust. Và
nullchính là zero value của Java cho tham chiếu — nhưng đọc mộtnullthì némNullPointerException, trong khi đọc một map nil của Go lại an toàn.
Khác biệt cuối cùng đó mới là nước đi đặc trưng của Go: không chỉ chọn một zero value hợp lý, mà còn khiến zero value ấy dùng được. map nil đọc ra số 0, slice nil append được, Mutex rỗng khoá được — nhờ vậy "rỗng mà vẫn chạy" trở thành một nguyên tắc thiết kế API, không phải cái bẫy. Và trên mặt trận ép kiểu, Go đứng cùng phe Rust — cấm nâng kiểu ngầm — đổi vài dòng int64(x) rườm rà lấy việc xoá sạch họ lỗi mất-độ-chính-xác-âm-thầm mà C và Java vẫn để ngỏ. Sợi chỉ chung đáng mang theo: mỗi ngôn ngữ đều phải trả lời "biến mới chứa gì" và "đổi kiểu có cần nói rõ không", và hai câu trả lời đó tiết lộ nó đặt niềm tin vào lập trình viên hay vào trình biên dịch. Go chọn một lối riêng — tin trình biên dịch ở việc đổi kiểu, nhưng hào phóng với bạn ở zero value — và hiểu được lựa chọn đó thì nil trong Go thôi đáng sợ như null ở nơi khác.
Ngày mai: toán tử và chuyển kiểu — những chỗ hay sai khi đến từ Java hay Python.
Bài tập làm thử
Bài 1 (đọc hiểu). Đoạn mã sau in ra gì, và tại sao dòng thứ hai không panic mà dòng thứ ba lại panic?
var m map[string]int
fmt.Println(m["chua-co"])
fmt.Println(len(m))
m["a"] = 1
Đáp án
In ra 0, rồi 0, rồi panic ở dòng thứ ba với thông báo assignment to entry in nil map. Đây là triết lý zero value của Go: map nil vẫn đọc được an toàn (trả zero value của kiểu giá trị, ở đây là int → 0) và len() cũng hoạt động bình thường (trả 0). Nhưng ghi vào map nil thì panic vì chưa có vùng nhớ nào được cấp phát để lưu — phải make(map[string]int) trước khi ghi.
Bài 2 (sửa lỗi). Đoạn mã sau biên dịch lỗi. Giải thích vì sao và sửa lại đúng theo quy tắc Go về ép kiểu.
var a int32 = 5
var c int64 = a
fmt.Println(c)
Đáp án
Lỗi biên dịch: cannot use a (variable of type int32) as int64 value. Go không có ép kiểu ngầm, kể cả giữa hai kiểu số nguyên có kích thước có thể trùng nhau như int32 và int64. Phải ép kiểu tường minh:
var a int32 = 5
var c int64 = int64(a)
fmt.Println(c)
Bài 3 (đọc hiểu). Cho đoạn mã sau, err ở dòng thứ hai trong khối if có ảnh hưởng gì tới err ở scope ngoài không? Giải thích bằng đúng khái niệm "biến che" (shadowing) mà bài viết nhắc tới.
a, err := hamMot()
if a > 0 {
b, err := hamHai()
fmt.Println(b, err)
}
fmt.Println(err)
Đáp án
Không ảnh hưởng. Trong khối if lồng, err := tạo ra một biến mới che (shadow) biến err ở scope ngoài, vì := yêu cầu ít nhất một biến mới ở vế trái và b là biến mới nên toàn bộ dòng đó được coi là khai báo mới, kể cả err. Dòng fmt.Println(err) cuối cùng vẫn in ra giá trị err từ hamMot(), không phải giá trị từ hamHai(). Đây là nguồn lỗi tinh vi: nếu hamHai() trả lỗi, lỗi đó không bao giờ "thoát" ra khỏi khối if. go vet bắt được một số trường hợp nhưng không phải tất cả.
Bài 4 (vận dụng thực tế). Bạn cần định nghĩa một kiểu liệt kê ba mức độ ưu tiên (Thấp, Vừa, Cao) có thể in ra chữ tiếng Việt khi dùng fmt.Println. Viết mã theo đúng mẫu iota mà bài viết giới thiệu, và nêu một hạn chế của cách làm này so với enum thật.
Đáp án
type MucDoUuTien int
const (
Thap MucDoUuTien = iota // 0
Vua // 1
Cao // 2
)
func (m MucDoUuTien) String() string {
return [...]string{"Thấp", "Vừa", "Cao"}[m]
}
Định nghĩa String() khiến fmt tự gọi nó khi in ra, nên hiện chữ thay vì số. Hạn chế: đây không phải enum thật — MucDoUuTien(99) vẫn hợp lệ về kiểu và sẽ panic khi gọi String() (index ngoài mảng), và không có kiểm tra đầy đủ nhánh trong switch. Muốn an toàn tuyệt đối phải tự viết hàm kiểm tra giá trị hợp lệ.
Bài 5 (bẫy/đánh đổi). Đoạn mã sau đọc hai byte đầu từ một gói tin mạng vào kiểu uint8. Nó có phát hiện được lỗi khi giá trị vượt quá 255 không? Giải thích theo đúng hành vi tràn số của Go.
var soLuong uint8 = 250
soLuong += 10
fmt.Println(soLuong)
Đáp án
In ra 4, không phải 260 và không có lỗi hay cảnh báo nào — Go im lặng cuộn vòng khi tràn số, giống hệt cách int8(127) + 1 cho ra -128 và uint8(0) - 1 cho ra 255 trong bài viết. Đây là điểm Go không bảo vệ bạn (giống Java): với số đếm hay chỉ số thông thường thì dùng int (64-bit) gần như không lo tràn, nhưng khi nhận dữ liệu từ mạng hay tệp vào kiểu hẹp như uint8/int32, phải tự kiểm tra biên tường minh trước khi cộng/trừ — trình biên dịch sẽ không nhắc bạn.