Hình dung truyền một struct theo giá trị như đưa cho ai đó một bản photocopy tài liệu của bạn. Họ tha hồ gạch xoá, ghi chú, sửa số — nhưng bản gốc trên bàn bạn không suy suyển một nét. Con trỏ thì ngược lại: nó là tấm giấy ghi địa chỉ của bản gốc, hay cái chìa khoá ngăn kéo cất nó — đưa cái đó đi thì nét bút của người kia đập thẳng vào bản thật. Gần như mọi bất ngờ về con trỏ trong Go, kể cả lỗi "gọi method cả trăm lần mà chẳng đổi gì" ở cuối bài, đều tan ra khi bạn hỏi đúng một câu: mình đang cầm bản gốc hay bản photocopy? 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 lần sang được ngăn kéo nhà bên.

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 — phát cho hàm một bản photocopy. 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 — nó được phát một bản photocopy, sửa rồi bản đó bị vứt đi. Đâ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 đó.

Nếu muốn thấy cái bẫy receiver trong một cái chớp mắt, thử đúng 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 — vì mỗi lần gọi Tang() là phát một bản photocopy mới để cộng rồi vứt. Đổi (d Dem) thành (d *Dem) là toàn bộ cách chữa.

Mẫu số chung

Câu hỏi "mình đang cầm bản gốc hay bản photocopy" là một trong hai trục lớn mà mọi ngôn ngữ phải chọn, và chỗ đứng của Go trên cả hai đều đáng để đối chiếu.

Trục thứ nhất — ngữ nghĩa giá trị hay tham chiếu: gán và truyền thì sao chép hay chia sẻ?

  • Java chia sẻ gần như mọi thứ: đối tượng luôn là tham chiếu, nên dân Java quen với việc "sửa là ăn ngay" và bị chính cái photocopy của Go làm bất ngờ.
  • C# là anh em sát nhất với Go: class là tham chiếu, struct là giá trị (sao chép), cộng ref/out để truyền tham chiếu tường minh — đúng y sự phân đôi giá trị/con trỏ.
  • C/C++ mặc định sao chép, con trỏ và tham chiếu phải khai tay. Cả hai lỗi "sửa không ăn" (cầm nhầm photocopy) và "sửa rò ra ngoài" (tưởng photocopy mà là bản gốc) đều bắt nguồn từ việc nhầm mình đang cầm cái nào.

Trục thứ hai — con trỏ được làm an toàn ở đâu:

  • C tin bạn hoàn toàn: có số học con trỏ, có free tay — hai khẩu súng tự bắn vào chân.
  • Go thuần hoá ở lúc chạy: GC giữ bộ nhớ sống nên không còn use-after-free, không số học nên không đi lạc — nhưng nil thì vẫn panic khi chạy.
  • Rust thuần hoá ở lúc biên dịch: bộ kiểm mượn (&T dùng chung, &mut T độc quyền) cộng Option<T> khiến cả null lẫn use-after-free không biên dịch nổi.

Sợi chỉ chung đáng mang theo: Go đứng ở một điểm giữa có chủ đích — an toàn hơn C nhiều, nhưng vẫn để lại một cú panic nil lúc chạy, và nó lặng lẽ tự-lấy-địa-chỉ để khâu liền cái đường nối giá trị/con trỏ, mà đó đúng là lý do cái bẫy receiver giá trị dễ lọt. Dù viết ngôn ngữ nào, bản năng cần luyện vẫn là một: trước khi sửa một thứ, biết chắc mình đang cầm bản gốc hay bản photocopy — và biết ngôn ngữ của mình bảo vệ con trỏ ở lúc biên dịch hay để dành cú ngã cho lúc chạy.

Ngày mai: mảng và slice — cùng cái bẫy chia sẻ mảng nền.