Hình dung embedding như thuê một trợ lý rồi in số điện thoại của họ lên danh thiếp của chính bạn. Ai gọi tới những dịch vụ đó trên danh thiếp bạn đều được chuyển cho trợ lý — đó là thăng cấp method. Nhưng ba điều quan trọng: bạn không PHẢI là trợ lý đó (không có "là-một"); bạn có thể in số riêng cho một dịch vụ để tự làm thay (che method); và quan trọng nhất — khi công việc nội bộ của trợ lý cần gọi một dịch vụ, nó gọi chính trợ lý, không ngước lên gọi bạn, vì trợ lý không hề biết mình đang nằm trên danh thiếp của ai. Cái điều cuối đó là khác biệt quyết định giữa embedding và kế thừa. Go không có kế thừa; nó có embedding, và bài này chỉ ra ba chỗ chúng khác nhau.
Thăng cấp trường và method
type CoSo struct{ ID int }
func (c CoSo) MoTa() string { return fmt.Sprintf("CoSo#%d", c.ID) }
type Nguoi struct {
CoSo // NHÚNG: không có tên trường
Ten string
}
n.ID = 7 (không cần n.CoSo.ID)
n.MoTa() = "CoSo#7" (method của CoSo gọi được qua Nguoi)
Trường và method của kiểu nhúng được thăng cấp lên ngoài — in lên danh thiếp. Trông y hệt kế thừa.
Nhưng không có quan hệ cha con
var c CoSo = n // LỖI BIÊN DỊCH
Nguoi không phải là CoSo. Không có upcast, không có đa hình qua embedding, không có "gán con vào biến kiểu cha".
Đây là khác biệt cốt lõi. Embedding chỉ là đường tắt cú pháp: trình biên dịch viết hộ bạn n.CoSo.ID thành n.ID. Không có gì hơn thế.
Muốn đa hình thì dùng interface — và đó chính là cách Go tách hai chuyện mà kế thừa gộp làm một: dùng lại mã (embedding) và thay thế được cho nhau (interface).
Method ngoài che method nhúng
func (n Nguoi) MoTa() string { return "Nguoi " + n.Ten }
n.MoTa() = "Nguoi Minh" <- bản của Nguoi
n.CoSo.MoTa() = "CoSo#7" <- bản gốc vẫn gọi được
Method ở tầng ngoài che method cùng tên của kiểu nhúng. Nhìn thì giống @Override, nhưng có một khác biệt quyết định:
Không có dispatch động. Nếu CoSo có một method khác gọi c.MoTa(), nó gọi bản của CoSo, không gọi bản của Nguoi — trợ lý làm việc nội bộ thì gọi chính mình, không ngước lên. Ở Java, đó là điều ngược lại, và mẫu template method dựa hẳn vào hành vi đó.
Nên nếu bạn quen thiết kế "lớp cha gọi method trừu tượng do con cài đặt", mẫu đó không dịch được sang Go. Cách thay thế là truyền một interface hoặc một hàm vào.
Nhúng hai kiểu cùng method
type Ghep struct {
CoSo // có MoTa()
Dong // cũng có MoTa()
}
g.MoTa()
ambiguous selector g.MoTa
Go không chọn hộ — hai trợ lý cùng nhận một dịch vụ thì bạn phải nói rõ gọi ai. Trình biên dịch báo lỗi và bạn phải viết rõ g.CoSo.MoTa() hoặc g.Dong.MoTa().
Đây là cách Go né bài toán kim cương của đa kế thừa: không có quy tắc phân giải phức tạp, chỉ có lỗi biên dịch và một yêu cầu viết rõ ràng.
Nhúng interface vào struct
Mẫu này ít gặp nhưng rất hữu ích:
type LogGhi struct {
io.Writer // nhúng INTERFACE
tienTo string
}
LogGhi tự động thoả mãn io.Writer — nó chuyển tiếp Write cho giá trị bên trong. Bạn chỉ cần ghi đè method nào muốn đổi.
Ứng dụng thực tế nhất: giả lập một phần trong test. Nhúng interface, để nó nil, chỉ cài đặt method bạn cần. Method không cài mà bị gọi thì panic — và đó là cách nhanh để biết test đang chạm vào thứ bạn không ngờ.
Khi nào dùng embedding
Thêm hành vi cho một kiểu có sẵn — bọc io.Writer để thêm đếm byte, bọc http.Handler để thêm log.
Chia sẻ trường chung giữa các struct — CoSo{ID, TaoLuc, SuaLuc} nhúng vào nhiều entity.
Ghép interface nhỏ như bài 13 đã nói.
Đừng dùng nó để mô phỏng cây kế thừa. Nếu bạn thấy mình nhúng ba tầng, hãy dừng lại: gần như luôn có cách phẳng hơn bằng interface và composition thường.
Và nhớ một chi tiết dễ quên: struct nhúng vẫn là một trường, nên json.Marshal sẽ làm phẳng nó ra theo mặc định — thứ đôi khi bạn muốn, đôi khi không.
Nếu muốn thấy "không có bảng ảo nào quyết định hộ" trong một cái chớp mắt, thử đúng ba mươi giây:
type A struct{}
func (A) Chao() string { return "A" }
type B struct{ A }
func (B) Chao() string { return "B" }
var b B
fmt.Println(b.Chao(), b.A.Chao())
In ra B A. Cả hai vẫn tồn tại, và bạn chọn cái nào bằng cách viết đường dẫn — không có dispatch động nào quyết định hộ.
Mẫu số chung
Go đưa ra một lựa chọn mà cả ngành phải học bằng nhiều năm đau thương rồi mới chốt: ưu tiên lắp ráp hơn kế thừa — đúng lời khuyên kinh điển của GoF và của Effective Java (Mục 18). Go cưỡng chế nó bằng cách đơn giản là không có kế thừa: embedding cho bạn dùng-lại-mã (method thăng cấp), interface cho bạn đa hình, và giữ hai việc đó tách nhau chính là điểm mấu chốt — vì kế thừa cổ điển hàn hai việc làm một, và đúng chỗ mối hàn đó là nơi bài toán lớp-cha-mong-manh và bài toán kim cương trú ngụ.
Dấu hiệu cụ thể là dispatch động: ở Java, một method của lớp cha gọi một method ghi-đè-được sẽ nhảy tới bản của lớp con (mẫu template method sống nhờ điều đó); còn kiểu nhúng của Go, khi method của chính nó gọi một method anh em, vẫn ở lại trong chính nó — nó không biết mình bị nhúng, nên template method không dịch được, và bạn truyền vào một interface hay một hàm thay thế.
- Rust chọn giống Go: không có kế thừa, chỉ có trait cộng lắp ráp.
- Kotlin giữ kế thừa nhưng thêm uỷ quyền tường minh (
class X : Y by z) như một mặc định an toàn hơn — gần như chính là embedding nhưng phải khai rõ.
Sợi chỉ chung đáng mang theo: "dùng lại mã này" và "thay thế được cho kiểu này" là hai mong muốn khác nhau — ngôn ngữ nào cho bạn một từ khoá duy nhất cho cả hai (như extends) là đang dúi vào tay bạn một sự ràng buộc mà bạn không hề xin. Go tách bạch chúng, và một khi quen, bạn sẽ thấy phần lớn "cây kế thừa" cũ thật ra chỉ cần một interface nhỏ cộng vài struct phẳng.
Ngày mai: generics — Go 1.18 thêm gì, và khi nào không nên dùng.
Bài tập làm thử
Bài 1 (đọc hiểu). Đoạn mã sau in ra gì?
type A struct{}
func (A) Chao() string { return "A" }
type B struct{ A }
func (B) Chao() string { return "B" }
var b B
fmt.Println(b.Chao(), b.A.Chao())
Đáp án
In ra B A. b.Chao() gọi bản của B (method ngoài che method nhúng). b.A.Chao() gọi thẳng vào trường nhúng A nên lấy bản của A. Cả hai method vẫn tồn tại song song; không có dispatch động nào chọn hộ, bạn chọn bằng đường dẫn viết ra.
Bài 2 (sửa lỗi biên dịch). Đoạn mã sau không biên dịch được. Giải thích lỗi và sửa lại.
type CoSo struct{ ID int }
func (c CoSo) MoTa() string { return fmt.Sprintf("CoSo#%d", c.ID) }
type Nguoi struct {
CoSo
Ten string
}
func laCoSo(c CoSo) string { return c.MoTa() }
func main() {
n := Nguoi{CoSo{7}, "Minh"}
var c CoSo = n
fmt.Println(laCoSo(c))
}
Đáp án
Lỗi ở dòng var c CoSo = n: Nguoi không phải là CoSo — embedding không tạo quan hệ cha con, không có upcast. Sửa bằng cách lấy đúng trường nhúng: var c CoSo = n.CoSo. Embedding chỉ là đường tắt cú pháp cho n.CoSo.ID viết gọn thành n.ID, không có gì hơn.
Bài 3 (vận dụng thực tế). Ở Java, mẫu template method để lớp cha gọi một method trừu tượng do lớp con cài đặt hoạt động nhờ dispatch động. Với struct CoSo và Nguoi ở trên, nếu CoSo có thêm một method GioiThieu() gọi c.MoTa() bên trong, và Nguoi che MoTa() bằng bản riêng, thì gọi n.GioiThieu() sẽ chạy bản MoTa() nào?
Đáp án
Bản của CoSo, không phải bản của Nguoi. Khi method nội bộ của kiểu nhúng (CoSo.GioiThieu) gọi một method anh em (c.MoTa()), nó luôn gọi chính kiểu đó — CoSo không hề biết mình đang bị nhúng trong Nguoi, nên không "ngước lên" gọi bản đã bị che. Mẫu template method không dịch được sang Go theo cách này; muốn có hành vi đó phải truyền vào một interface hoặc một hàm.
Bài 4 (bẫy/đánh đổi). Struct Ghep dưới đây nhúng hai kiểu cùng có method MoTa(). Gọi g.MoTa() có biên dịch được không? Nếu không, sửa thế nào?
type Ghep struct {
CoSo
Dong
}
g := Ghep{}
fmt.Println(g.MoTa())
Đáp án
Không biên dịch được — lỗi ambiguous selector g.MoTa. Go không tự chọn hộ khi hai kiểu nhúng cùng cấp có method trùng tên; phải viết rõ g.CoSo.MoTa() hoặc g.Dong.MoTa(). Đây là cách Go né bài toán kim cương của đa kế thừa bằng lỗi biên dịch thay vì quy tắc phân giải phức tạp.
Bài 5 (đọc hiểu — nhúng interface). Đoạn mã sau dùng để làm gì trong test, và điều gì xảy ra nếu gọi một method của io.Writer mà LogGhi không tự cài đặt lại, trong khi trường nhúng đang là nil?
type LogGhi struct {
io.Writer
tienTo string
}
var lg LogGhi
lg.Write([]byte("test"))
Đáp án
Mẫu này dùng để giả lập một phần trong test: nhúng interface, để nó nil, chỉ cài đặt (che) method nào cần kiểm soát. Vì LogGhi không cài Write riêng, lời gọi lg.Write(...) được chuyển tiếp cho giá trị io.Writer bên trong — mà giá trị đó đang là nil — nên chương trình sẽ panic (gọi method trên interface nil). Đây chính là cách nhanh để phát hiện test đang chạm vào một phần mà bạn chưa cài đặt/mong đợi.