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:
Đọ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. Map phải make trước khi ghi.
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ỏ.
Thử ba mươi giây
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 dòng, hai hành vi khác hẳn nhau trên cùng một biến nil. Ba mươi giây đó là tóm tắt tốt nhất về zero value trong Go: đọc thì luôn an toàn, ghi thì phải khởi tạo.
Ngày mai: toán tử và chuyển kiểu — những chỗ hay sai khi đến từ Java hay Python.