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.