Hình dung method như những khả năng bạn ghim thêm vào một kiểu từ bên ngoài. Receiver giá trị là khả năng chỉ cần đọc tài liệu — bản photo cũng làm được. Receiver con trỏ là khả năng ký và sửa tài liệu — phải có bản gốc. Bài 8 đã nói receiver giá trị không sửa được đối tượng; bài này về hệ quả thứ hai, tinh vi hơn, và chỉ lộ ra khi bạn dùng interface.
Method nằm ngoài kiểu
type Cho struct{ Ten string }
func (c Cho) Noi() string { return c.Ten + ": gâu" }
Method khai bên ngoài struct, không nằm trong dấu ngoặc nhọn như Java. Điều này cho phép:
Gắn method vào bất kỳ kiểu nào bạn định nghĩa, kể cả kiểu không phải struct:
type Nhiet float64
func (n Nhiet) String() string { return fmt.Sprintf("%.1f°C", float64(n)) }
Nhưng không gắn được vào kiểu của package khác. func (s string) Hoa() string là lỗi. Muốn thêm method cho time.Time thì phải định nghĩa kiểu riêng bọc nó lại. Đây là ràng buộc cố ý — nó ngăn hai package cùng thêm method trùng tên vào một kiểu.
Method set: khái niệm quyết định
Đây là phần chính của bài.
Method set của kiểu T gồm mọi method có receiver T. Method set của kiểu *T gồm mọi method có receiver T và *T.
Con trỏ có nhiều method hơn giá trị. Và điều này chỉ quan trọng ở đúng một chỗ: gán vào interface.
type Noi interface{ Noi() string }
type Meo struct{ Ten string }
func (m *Meo) Noi() string { ... } // receiver CON TRỎ
var n Noi = &Meo{"Mimi"} -> OK
var n Noi = Meo{"Mimi"} -> LỖI BIÊN DỊCH
Thông báo lỗi của Go rất rõ ràng:
cannot use Meo{} (value of type Meo) as Noi value:
Meo does not implement Noi (method Noi has pointer receiver)
Vì sao lại thế? Bài 8 đã đo: Go tự chèn & khi bạn gọi b.ThemConTro(1) trên một biến giá trị — nó tự chạy tới tủ hồ sơ lấy bản gốc cho bạn. Nhưng nó chỉ làm được vậy khi biến có địa chỉ. Một giá trị nằm trong interface thì không — nó là bản photo niêm trong một chiếc phong bì, không có chỗ trong tủ hồ sơ để mà lấy bản gốc. Nên khả năng "ký sửa" không với tới được.
Hệ quả thực tế
Bạn sẽ gặp lỗi này nhiều nhất ở ba chỗ:
Truyền vào hàm nhận interface:
func XuLy(n Noi) { }
XuLy(Meo{}) // lỗi
XuLy(&Meo{}) // đúng
Slice của interface:
ds := []Noi{Cho{"Milu"}, &Meo{"Mimi"}} // Cho dùng receiver giá trị nên OK
Phần tử của map: như bài 8 đã nói, m["a"].MethodConTro() là lỗi vì phần tử map không có địa chỉ.
Quy tắc chọn receiver
Từ bài 8 và bài này, gộp thành một quy tắc thực dụng:
Dùng receiver con trỏ làm mặc định cho kiểu bạn định nghĩa, trừ khi có lý do rõ ràng để dùng giá trị.
Lý do: nó tránh cả hai bẫy — method sửa được đối tượng, và kiểu thoả mãn interface theo cách bạn mong đợi khi truyền con trỏ.
Dùng receiver giá trị khi kiểu là bất biến và nhỏ — time.Time, Nhiet ở trên, các kiểu giá trị kiểu Diem{X, Y int}.
Đừng trộn lẫn trên cùng một kiểu. Nếu một method cần con trỏ thì cho tất cả dùng con trỏ. Trộn lẫn làm method set khó đoán và go vet sẽ nhắc bạn.
String() và các interface ngầm
func (n Nhiet) String() string { return fmt.Sprintf("%.1f°C", float64(n)) }
Định nghĩa method String() string là kiểu của bạn tự động thoả mãn fmt.Stringer, và fmt.Println sẽ gọi nó. Không cần khai gì — đó là chủ đề của bài mai.
Cẩn thận một cái bẫy: nếu String() của bạn gọi fmt.Sprintf("%v", n) với n là chính kiểu đó, bạn có đệ quy vô hạn và tràn ngăn xếp. Phải ép về kiểu nền trước, như float64(n) ở trên.
Nếu chỉ nhớ một thứ, để nó là quy tắc method set — thử trong ba mươi giây:
type T struct{}
func (t *T) M() {}
var _ interface{ M() } = T{} // lỗi
var _ interface{ M() } = &T{} // OK
Đổi *T thành T trong khai báo method rồi thử lại — lúc đó cả hai dòng đều chạy, vì method set của *T luôn chứa method của T. Ba mươi giây đó giải thích 90% các lỗi "does not implement" bạn sẽ gặp.
Mẫu số chung
Hai ý trong bài này vang xa hơn Go. Một: method khai ngoài thân kiểu, ghim thêm từ nơi khác — đây là "mở rộng kiểu", và nó có ở khắp nơi: extension method của C#, extension function của Kotlin, extension của Swift, khối impl của Rust. Mà cái ràng buộc "không thêm method vào kiểu của package khác" của Go chính là quy tắc mồ côi (orphan rule) của Rust nói cùng một điều: bạn chỉ cài được một method khi sở hữu kiểu hoặc sở hữu giao diện. Lý do giống hệt nhau — mở rộng tự do mà không có luật sở hữu là công thức cho hai thư viện định nghĩa đè nhau.
Hai, sâu hơn: đóng hộp một giá trị (vào interface, vào object) là sao chép nó và bỏ mất địa chỉ/danh tính của nó — nên mọi method cần bản gốc (receiver con trỏ, method sửa struct tại chỗ) lặng lẽ trở nên vô dụng hoặc không dùng được. Đây đúng là lý do C# khuyên "struct khả biến là điều ác" (mutable struct is evil): đóng hộp một struct vào object rồi sửa thì thay đổi mất hút, y như giá trị Go trong interface. Sợi chỉ chung: quyết định một kiểu theo ngữ nghĩa giá trị hay tham chiếu ngay khi bạn gắn method cho nó — method nào sửa hoặc kiểu to thì dùng receiver con trỏ nhất quán; giá trị chỉ để dành cho kiểu nhỏ, bất biến. Và nhớ rằng bỏ một giá trị vào interface tạo ra một bản sao không-địa-chỉ — đó đúng là khoảnh khắc lựa chọn giá-trị-hay-con-trỏ thôi vô hình.
Ngày mai: interface — thoả mãn ngầm định, và vì sao đó là ý tưởng hay nhất của Go.