Struct embedding trong Go thay cho kế thừa
1. Tổng quan
Nếu bạn quen Java hay C#, câu hỏi đầu tiên khi viết Go thường là: “extends đâu rồi?”. Go không có kế thừa. Thay vào đó, Go có struct embedding: nhúng một type vào struct khác để tái sử dụng field và method.
Bài này giải thích cách embedding hoạt động, điểm khác nhau so với kế thừa, và các bẫy hay gặp. Sau khi đọc, bạn sẽ dùng được embedding để chia sẻ hành vi, viết decorator gọn gàng và tránh được các lỗi đặc trưng của nó.
2. Struct embedding cơ bản
Embedding là khai báo một field chỉ có type, không có tên:
package main
import "fmt"
type Address struct {
Street string
City string
}
func (a Address) FullAddress() string {
return a.Street + ", " + a.City
}
type Customer struct {
Name string
Address // embedded field
}
func main() {
c := Customer{
Name: "Lan",
Address: Address{Street: "12 Lê Lợi", City: "Huế"},
}
fmt.Println(c.City) // field được promote
fmt.Println(c.FullAddress()) // method được promote
fmt.Println(c.Address.City) // truy cập tường minh
}
Huế
12 Lê Lợi, Huế
Huế
Phân tích:
Addressnằm trongCustomermà không có tên field. Go tự đặt tên field làAddress, tức tên của type.- Field
Cityvà methodFullAddress()củaAddressđược promote (thăng cấp) lênCustomer. Vì vậy bạn viết đượcc.Citythay vìc.Address.City. - Khi khởi tạo bằng composite literal, bạn vẫn phải dùng tên field
Address:. Không có cú pháp “điền thẳng”CityvàoCustomer.
Về bản chất, Customer chứa một Address. Đây là quan hệ has-a, chỉ được trình biên dịch thêm phần cú pháp cho tiện. Customer không phải là Address. Bạn không thể truyền Customer vào hàm nhận Address:
func printCity(a Address) { fmt.Println(a.City) }
// printCity(c) // compile error: cannot use c (type Customer) as Address
printCity(c.Address) // OK
3. “Ghi đè” method bằng shadowing
Nếu struct ngoài khai báo method trùng tên với method của type nhúng, method của struct ngoài sẽ che (shadow) method bên trong:
package main
import "fmt"
type Logger struct {
Prefix string
}
func (l Logger) Log(msg string) {
fmt.Println(l.Prefix + msg)
}
type OrderService struct {
Logger
}
func (s OrderService) Log(msg string) {
s.Logger.Log("[order] " + msg) // gọi method của type nhúng
}
func main() {
s := OrderService{Logger: Logger{Prefix: "> "}}
s.Log("created #1001")
s.Logger.Log("raw message")
}
> [order] created #1001
> raw message
s.Log(...)gọi method củaOrderService, vì nó ở độ sâu nông hơn.s.Logger.Log(...)vẫn gọi được bản gốc. Cách này tương đươngsuper.log(...)trong Java.
Nhìn qua thì giống override. Nhưng đây chính là chỗ nhiều người vấp ngã, như phần tiếp theo cho thấy.
4. Bẫy lớn nhất: không có virtual dispatch
Trong Java, nếu lớp cha gọi this.channel() và lớp con override channel(), bản của lớp con sẽ chạy. Go không làm vậy:
package main
import "fmt"
type Notifier struct{}
func (n Notifier) Channel() string { return "email" }
func (n Notifier) Send(msg string) string {
return "[" + n.Channel() + "] " + msg
}
type SMSNotifier struct {
Notifier
}
func (s SMSNotifier) Channel() string { return "sms" }
func main() {
s := SMSNotifier{}
fmt.Println(s.Channel())
fmt.Println(s.Send("Đơn hàng đã giao"))
}
sms
[email] Đơn hàng đã giao
s.Send(...) thực chất là s.Notifier.Send(...). Bên trong Send, receiver n có type Notifier. Nó không hề biết mình đang nằm trong một SMSNotifier, nên n.Channel() luôn trả về "email".
Đây là khác biệt cốt lõi. Type nhúng không có tham chiếu ngược tới struct chứa nó. Mẫu “template method” kiểu Java không áp dụng trực tiếp được.
4.1. Cách sửa: tách phần biến đổi thành interface
Nếu bạn cần hành vi thay đổi được, hãy truyền nó vào một cách tường minh:
package main
import "fmt"
type Channel interface {
Name() string
}
type Email struct{}
func (Email) Name() string { return "email" }
type SMS struct{}
func (SMS) Name() string { return "sms" }
type Notifier struct {
Channel Channel
}
func (n Notifier) Send(msg string) string {
return "[" + n.Channel.Name() + "] " + msg
}
func main() {
fmt.Println(Notifier{Channel: SMS{}}.Send("Đơn hàng đã giao"))
fmt.Println(Notifier{Channel: Email{}}.Send("Hóa đơn tháng 10"))
}
[sms] Đơn hàng đã giao
[email] Hóa đơn tháng 10
Bây giờ phần thay đổi (Channel) là một dependency rõ ràng. Đây là cách “Go” hơn so với việc cố mô phỏng override.
5. Embedding và interface
Interface trong Go được thỏa mãn ngầm định. Method được promote cũng được tính vào method set của struct ngoài. Vì vậy embedding giúp struct thỏa mãn interface mà không phải viết lại method:
package main
import "fmt"
type Describer interface {
FullAddress() string
}
type Address struct{ Street, City string }
func (a Address) FullAddress() string { return a.Street + ", " + a.City }
type Warehouse struct {
Code string
Address
}
func describe(d Describer) { fmt.Println(d.FullAddress()) }
func main() {
w := Warehouse{Code: "HN-01", Address: Address{"KCN Thăng Long", "Hà Nội"}}
describe(w) // Warehouse thỏa mãn Describer nhờ method được promote
}
KCN Thăng Long, Hà Nội
5.1. Lưu ý về pointer receiver
Quy tắc method set khi nhúng như sau:
Khai báo trong S |
Method set của S |
Method set của *S |
|---|---|---|
nhúng T |
method receiver T |
method receiver T và *T |
nhúng *T |
method receiver T và *T |
method receiver T và *T |
Ví dụ, nếu Counter có func (c *Counter) Inc() và bạn nhúng Counter (không phải con trỏ) vào Stats, thì chỉ *Stats thỏa mãn interface có Inc(). Giá trị Stats thì không. Lỗi compile kiểu “Stats does not implement Incrementer (method Inc has pointer receiver)” thường đến từ đây.
6. Nhúng interface vào struct: decorator gọn gàng
Bạn có thể nhúng một interface vào struct. Struct sẽ tự có toàn bộ method của interface, mỗi method được ủy quyền cho giá trị bên trong. Bạn chỉ cần override những method muốn thay đổi:
package payment
import "fmt"
type PaymentService interface {
Charge(orderID string, amount int) error
Refund(orderID string) error
}
type LoggingPayment struct {
PaymentService // ủy quyền mọi method
Logs []string
}
func (l *LoggingPayment) Charge(orderID string, amount int) error {
l.Logs = append(l.Logs, fmt.Sprintf("charge %s: %d", orderID, amount))
return l.PaymentService.Charge(orderID, amount)
}
LoggingPaymentchỉ viết lạiCharge.Refundđược ủy quyền tự động choPaymentServicebên trong.*LoggingPaymentthỏa mãnPaymentService, nên bạn bọc nó quanh bất kỳ implementation nào mà phía gọi không cần biết.
Khi interface có mười method mà bạn chỉ cần chặn một, kỹ thuật này tiết kiệm rất nhiều code lặp.
Lưu ý: Nếu field interface nhúng là
nil, code vẫn compile bình thường. Nhưng gọi một method không được override (ví dụRefund) sẽ gây panic nil pointer dereference lúc runtime. Hãy luôn khởi tạo nó qua constructor, ví dụNewLoggingPayment(inner PaymentService).
7. Kiểm thử hành vi ủy quyền
Go dùng package testing chuẩn thay cho JUnit. Test dưới đây kiểm tra hai điều: Charge được ghi log và vẫn gọi xuống service gốc, còn Refund được ủy quyền mà không ghi log.
package payment
import "testing"
type fakePayment struct {
charged []string
refunded []string
}
func (f *fakePayment) Charge(orderID string, amount int) error {
f.charged = append(f.charged, orderID)
return nil
}
func (f *fakePayment) Refund(orderID string) error {
f.refunded = append(f.refunded, orderID)
return nil
}
func TestLoggingPayment(t *testing.T) {
inner := &fakePayment{}
var svc PaymentService = &LoggingPayment{PaymentService: inner}
if err := svc.Charge("ORD-1", 250000); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if err := svc.Refund("ORD-1"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
lp := svc.(*LoggingPayment)
if len(lp.Logs) != 1 || lp.Logs[0] != "charge ORD-1: 250000" {
t.Errorf("logs = %v, want [charge ORD-1: 250000]", lp.Logs)
}
if len(inner.charged) != 1 || len(inner.refunded) != 1 {
t.Errorf("inner not called: charged=%v refunded=%v", inner.charged, inner.refunded)
}
}
Chạy test:
go test ./...
ok example.com/shop/payment 0.002s
Dòng var svc PaymentService = &LoggingPayment{...} cũng là một kiểm tra lúc compile: nếu *LoggingPayment không thỏa mãn interface, test sẽ không build được. Trong code production, bạn có thể dùng cách quen thuộc var _ PaymentService = (*LoggingPayment)(nil) cho mục đích tương tự.
8. Xung đột tên và các lỗi hay gặp
8.1. Ambiguous selector
Khi hai type nhúng ở cùng độ sâu có cùng tên field hoặc method, truy cập qua struct ngoài sẽ bị lỗi compile:
type Billing struct{ Email string }
type Shipping struct{ Email string }
type Order struct {
Billing
Shipping
}
func main() {
o := Order{}
// fmt.Println(o.Email) // compile error: ambiguous selector o.Email
fmt.Println(o.Billing.Email) // OK
}
Struct Order vẫn hợp lệ. Lỗi chỉ xuất hiện khi bạn truy cập o.Email. Cách xử lý là gọi tường minh, hoặc khai báo Email ngay trên Order để che cả hai.
8.2. Vô tình lộ API: ví dụ sync.Mutex
type Inventory struct {
sync.Mutex
stock map[string]int
}
Code này chạy được. Nhưng Lock() và Unlock() giờ là method public của Inventory, vì tên field nhúng Mutex được export. Code bên ngoài package có thể khóa inventory của bạn. Nên viết:
type Inventory struct {
mu sync.Mutex
stock map[string]int
}
Nguyên tắc chung: embedding đưa toàn bộ method của type nhúng vào API của bạn. Nếu bạn không muốn công khai chúng, hãy dùng field có tên.
8.3. Nhúng con trỏ bị nil
Nhúng *Address thay vì Address có nghĩa là zero value của struct chứa con trỏ nil. Khi đó c.City sẽ panic. Chỉ nhúng con trỏ khi bạn thật sự cần chia sẻ instance hoặc cần method set của *T, và nhớ khởi tạo nó.
9. Khi nào nên và không nên dùng embedding
Nên dùng khi:
- Bạn muốn struct ngoài thật sự phơi bày toàn bộ hành vi của type nhúng, ví dụ nhúng
http.Handlerhayio.Reader. - Bạn viết decorator hoặc wrapper chỉ thay đổi vài method của một interface lớn.
- Bạn gom các field dùng chung như
Audit{CreatedAt, UpdatedAt}vào nhiều entity.
Không nên dùng khi:
- Bạn đang cố tái tạo cây kế thừa nhiều tầng với “lớp cha gọi method lớp con”. Hãy dùng interface và dependency tường minh (mục 4.1).
- Type nhúng có method mà bạn không muốn công khai (mục 8.2).
- Bạn chỉ cần một hai method của type kia. Một field có tên kèm vài method ủy quyền viết tay sẽ rõ ràng hơn.
Lưu ý: Embedding không tạo quan hệ is-a. Nếu bạn cần đa hình, hãy định nghĩa interface rồi để nhiều type cùng thỏa mãn nó. Đừng trông chờ vào việc “struct con là struct cha”.
10. Kết luận
Struct embedding là công cụ composition của Go, kèm theo một chút cú pháp tiện lợi: field và method của type nhúng được promote lên struct ngoài. Nó giúp bạn tái sử dụng code, thỏa mãn interface và viết decorator rất gọn.
Tuy vậy, embedding không phải kế thừa. Không có virtual dispatch, type nhúng không biết struct chứa nó, và mọi method được promote đều trở thành một phần API của bạn. Hãy nhớ ba điều: dùng interface cho phần hành vi thay đổi, khởi tạo đầy đủ các field interface hoặc con trỏ được nhúng, và chuyển sang field có tên khi không muốn lộ method ra ngoài.