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:

  • Address nằm trong Customer mà không có tên field. Go tự đặt tên field là Address, tức tên của type.
  • Field City và method FullAddress() của Address được promote (thăng cấp) lên Customer. Vì vậy bạn viết được c.City thay 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” City vào Customer.

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ủa OrderService, 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 đương super.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)
}
  • LoggingPayment chỉ viết lại Charge. Refund được ủy quyền tự động cho PaymentService bên trong.
  • *LoggingPayment thỏa mãn PaymentService, 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.Handler hay io.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.

golangstruct embeddingcompositioninterfaceoop