Go có con trỏ, và đó là điều làm người đến từ Java hơi hoảng. Nhưng con trỏ trong Go an toàn hơn nhiều so với C, và bài này chỉ ra ranh giới.
Cơ bản
x := 10
p := &x // p là *int, trỏ tới x
*p = 20 // sửa x qua p
x sau khi sửa qua con trỏ: 20
& lấy địa chỉ, * truy cập giá trị tại địa chỉ đó. Giống C về cú pháp.
Nhưng có ba thứ Go không cho, và chúng xoá gần hết rủi ro:
Không có số học con trỏ. p++ là lỗi biên dịch. Bạn không thể đi lang thang trong bộ nhớ.
Không có con trỏ tới bộ nhớ đã giải phóng. Bộ thu gom rác giữ đối tượng sống chừng nào còn con trỏ tới nó. Trả về địa chỉ của biến cục bộ là hợp lệ trong Go — trình biên dịch tự đưa biến đó lên heap:
func tao() *int { x := 5; return &x } // hoàn toàn đúng
Ở C, đây là lỗi kinh điển. Ở Go, nó là cách viết bình thường.
Không có ép kiểu con trỏ tuỳ tiện. Muốn làm thế phải qua unsafe, và cái tên gói đã nói hết.
Con trỏ nil vẫn panic khi truy cập:
con trỏ nil: <nil> | dereference sẽ panic
Đây là nil pointer dereference, tương đương NullPointerException của Java, và nó vẫn là lỗi lúc chạy hay gặp nhất.
Vì sao cần con trỏ: struct là bản sao
c := To{N: 100}
func(v To) { v.N = 999 }(c)
sau khi hàm sửa bản sao: N=100
Truyền struct vào hàm là sao chép toàn bộ struct. Hàm sửa bản sao, bản gốc không đổi.
Đây là điểm khác Java rất căn bản: ở Java, mọi đối tượng đều truyền qua tham chiếu; ở Go, struct truyền theo giá trị trừ khi bạn dùng con trỏ.
Hệ quả: với struct lớn, truyền theo giá trị tốn công sao chép. Với struct nhỏ (vài trường), sao chép lại nhanh hơn vì tránh được một lần truy cập gián tiếp và giữ dữ liệu trong cache CPU.
Receiver: giá trị hay con trỏ
Đây là quyết định bạn phải đưa ra ở mọi method, và nó có hệ quả thật:
func (t To) ThemGiaTri(x int) { t.N += x } // nhận BẢN SAO
func (t *To) ThemConTro(x int) { t.N += x } // nhận CON TRỎ
sau ThemGiaTri(10) : N=1 <- KHÔNG đổi
sau ThemConTro(10) : N=11 <- có đổi
Method với receiver giá trị không sửa được đối tượng gốc. Đây là lỗi người mới hay mắc, và trình biên dịch không cản vì mã hoàn toàn hợp lệ.
Quy tắc chọn, theo thứ tự ưu tiên:
Cần sửa đối tượng → con trỏ. Không có lựa chọn khác.
Struct lớn → con trỏ, để tránh sao chép.
Struct chứa sync.Mutex hay bất cứ thứ gì không được sao chép → con trỏ bắt buộc. go vet bắt được lỗi này.
Còn lại → chọn một và dùng nhất quán cho cả kiểu. Trộn lẫn receiver giá trị và con trỏ trên cùng một kiểu là nguồn nhầm lẫn, và nó còn ảnh hưởng tới việc kiểu đó có thoả mãn interface hay không — bài 13 sẽ nói.
Go tự lấy địa chỉ giúp bạn
b := To{N: 5} // b là GIÁ TRỊ, không phải con trỏ
b.ThemConTro(1) // vẫn gọi được method có receiver *To
b.ThemConTro(1) tuy b là giá trị: N=6 (Go viết hộ (&b).ThemConTro)
Trình biên dịch tự chèn &. Chiều ngược lại cũng vậy: gọi method receiver giá trị trên một con trỏ thì Go tự chèn *.
Điều này làm mã dễ đọc hơn nhiều, nhưng nó cũng che mất khác biệt giá trị/con trỏ — và đó là lý do lỗi "method không sửa được gì" khó nhận ra.
Một giới hạn quan trọng: Go chỉ tự lấy địa chỉ khi biến có địa chỉ. Gọi method con trỏ trên một giá trị trả về trực tiếp từ hàm hay trên phần tử của map là lỗi biên dịch:
m := map[string]To{"a": {}}
m["a"].ThemConTro(1) // lỗi: cannot call pointer method
new và &T{}
p1 := new(To) // con trỏ tới To zero value
p2 := &To{N: 5} // con trỏ tới To đã khởi tạo
Hai cách cùng cho *To. Trong thực tế gần như ai cũng dùng &To{} vì nó khai giá trị luôn được; new hiếm khi xuất hiện.
Không có delete hay free. Bộ thu gom rác lo phần đó.
Thử ba mươi giây
type Dem struct{ N int }
func (d Dem) Tang() { d.N++ }
d := Dem{}
for i := 0; i < 100; i++ { d.Tang() }
fmt.Println(d.N)
In ra 0. Không cảnh báo, không lỗi. Ba mươi giây đó là bài học đắt nhất về receiver trong Go — và đổi (d Dem) thành (d *Dem) là toàn bộ cách chữa.
Ngày mai: mảng và slice — cùng cái bẫy chia sẻ mảng nền.