Struct embedding en Go en lugar de herencia
1. Descripción general
Si estás acostumbrado a Java o C#, la primera pregunta al escribir Go suele ser: “¿dónde está extends?”. Go no tiene herencia. En su lugar, Go tiene struct embedding: incrustar un type en otro struct para reutilizar fields y methods.
Este artículo explica cómo funciona embedding, sus diferencias con la herencia y las trampas comunes. Después de leerlo, podrás usar embedding para compartir comportamiento, escribir decorators elegantes y evitar sus errores característicos.
2. Struct embedding básico
Embedding es declarar un field solo con type, sin nombre:
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 // campo incrustado
}
func main() {
c := Customer{
Name: "Lan",
Address: Address{Street: "12 Lê Lợi", City: "Huế"},
}
fmt.Println(c.City) // field promovido
fmt.Println(c.FullAddress()) // method promovido
fmt.Println(c.Address.City) // acceso explícito
}
Huế
12 Lê Lợi, Huế
Huế
Análisis:
Addressestá dentro deCustomersin tener nombre de field. Go automáticamente establece el nombre del field comoAddress, es decir, el nombre del type.- El field
Cityy el methodFullAddress()deAddressse promocionan haciaCustomer. Por lo tanto, puedes escribirc.Cityen lugar dec.Address.City. - Al inicializar con composite literal, aún debes usar el nombre del field
Address:. No hay sintaxis para “rellenar directamente”CityenCustomer.
En esencia, Customer contiene un Address. Esta es una relación has-a, apenas endulzada por el compilador con sintaxis conveniente. Customer no es un Address. No puedes pasar un Customer a una función que espera un Address:
func printCity(a Address) { fmt.Println(a.City) }
// printCity(c) // error de compilación: no se puede usar c (type Customer) como Address
printCity(c.Address) // OK
3. “Sobrescribir” methods mediante shadowing
Si el struct externo declara un method con el mismo nombre que un method del type incrustado, el method del struct externo sombreará (shadow) el method interno:
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) // llamar al method del type incrustado
}
func main() {
s := OrderService{Logger: Logger{Prefix: "> "}}
s.Log("created #1001")
s.Logger.Log("raw message")
}
> [order] created #1001
> raw message
s.Log(...)llama al method deOrderServiceporque tiene una profundidad menos anidada.s.Logger.Log(...)aún puede llamar la versión original. Esto es equivalente asuper.log(...)en Java.
A primera vista parece una sobrescritura. Pero aquí es donde muchos se equivocan, como muestra la siguiente sección.
4. La mayor trampa: sin envío virtual
En Java, si una clase padre llama this.channel() y una clase hija sobrescribe channel(), la versión de la clase hija se ejecutará. Go no hace eso:
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(...) es en realidad s.Notifier.Send(...). Dentro de Send, el receiver n tiene type Notifier. No sabe que está dentro de un SMSNotifier, así que n.Channel() siempre devuelve "email".
Esta es la diferencia fundamental. El type incrustado no tiene una referencia hacia atrás al struct que lo contiene. El patrón “template method” de estilo Java no se puede aplicar directamente.
4.1. Solución: extraer la parte variable en una interface
Si necesitas un comportamiento variable, pásalo explícitamente:
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
Ahora la parte variable (Channel) es una dependencia explícita. Esta es la forma más “Go” en lugar de intentar emular sobrescritura.
5. Embedding e interfaces
Las interfaces en Go se satisfacen implícitamente. Los methods promocionados también se cuentan en el method set del struct externo. Por lo tanto, embedding ayuda a que un struct satisfaga una interface sin necesidad de reescribir methods:
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 satisface Describer gracias al method promovido
}
KCN Thăng Long, Hà Nội
5.1. Nota sobre pointer receivers
Las reglas del method set cuando se incrustan son:
Declarado en S |
Method set de S |
Method set de *S |
|---|---|---|
incrustar T |
method receiver T |
method receiver T y *T |
incrustar *T |
method receiver T y *T |
method receiver T y *T |
Por ejemplo, si Counter tiene func (c *Counter) Inc() e incrustas Counter (no un puntero) en Stats, solo *Stats satisface la interface que tiene Inc(). El valor Stats no lo hace. El error de compilación del tipo “Stats does not implement Incrementer (method Inc has pointer receiver)” generalmente viene de aquí.
6. Incrustar interface en struct: decorators elegantes
Puedes incrustar una interface en un struct. El struct tendrá automáticamente todos los methods de la interface, cada method delegado al valor dentro. Solo necesitas sobrescribir los methods que quieras cambiar:
package payment
import "fmt"
type PaymentService interface {
Charge(orderID string, amount int) error
Refund(orderID string) error
}
type LoggingPayment struct {
PaymentService // delegar todos los methods
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)
}
LoggingPaymentsolo sobrescribeCharge.Refundse delega automáticamente aPaymentServicedentro.*LoggingPaymentsatisfacePaymentService, así que puedes envolverlo alrededor de cualquier implementación sin que quien lo llama lo sepa.
Cuando una interface tiene diez methods y solo necesitas interceptar uno, esta técnica ahorra mucho código duplicado.
Nota: Si el field de interface incrustada es
nil, el código aún compila normalmente. Pero llamar a un method que no se sobrescribe (por ejemploRefund) causará un panic nil pointer dereference en runtime. Siempre inicialízalo a través de un constructor, por ejemploNewLoggingPayment(inner PaymentService).
7. Probando el comportamiento delegado
Go usa el package estándar testing en lugar de JUnit. La prueba a continuación verifica dos cosas: Charge se registra y aún llama al servicio original, mientras que Refund se delega sin registrar.
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("error inesperado: %v", err)
}
if err := svc.Refund("ORD-1"); err != nil {
t.Fatalf("error inesperado: %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)
}
}
Ejecutar la prueba:
go test ./...
ok example.com/shop/payment 0.002s
La línea var svc PaymentService = &LoggingPayment{...} también es una verificación en tiempo de compilación: si *LoggingPayment no satisface la interface, la prueba no se compilará. En código de producción, puedes usar la forma familiar var _ PaymentService = (*LoggingPayment)(nil) para el mismo propósito.
8. Conflictos de nombres y errores comunes
8.1. Selector ambiguo
Cuando dos types incrustados a la misma profundidad tienen el mismo nombre de field o method, acceder a través del struct externo causará un error de compilación:
type Billing struct{ Email string }
type Shipping struct{ Email string }
type Order struct {
Billing
Shipping
}
func main() {
o := Order{}
// fmt.Println(o.Email) // error de compilación: selector ambiguo o.Email
fmt.Println(o.Billing.Email) // OK
}
El struct Order sigue siendo válido. El error solo aparece cuando accedes a o.Email. La solución es llamar explícitamente, o declarar Email directamente en Order para sombrear ambos.
8.2. Exponer la API accidentalmente: ejemplo sync.Mutex
type Inventory struct {
sync.Mutex
stock map[string]int
}
Este código funciona. Pero Lock() y Unlock() ahora son methods públicos de Inventory porque el nombre del field incrustado Mutex se exporta. El código fuera del package puede bloquear tu inventario. Deberías escribir:
type Inventory struct {
mu sync.Mutex
stock map[string]int
}
Principio general: embedding trae todos los methods del type incrustado a tu API. Si no quieres hacerlos públicos, usa un field con nombre.
8.3. Incrustar un puntero nil
Incrustar *Address en lugar de Address significa que el zero value del struct contiene un puntero nil. Entonces c.City hará panic. Solo incrustra punteros cuando realmente necesites compartir la instance o necesites el method set de *T, y recuerda inicializarlo.
9. Cuándo usar y no usar embedding
Deberías usar embedding cuando:
- Quieres que el struct externo realmente exponga todo el comportamiento del type incrustado, por ejemplo incrustar
http.Handleroio.Reader. - Estás escribiendo un decorator o wrapper que solo cambia algunos methods de una interface grande.
- Agrupas fields comunes como
Audit{CreatedAt, UpdatedAt}en múltiples entidades.
No deberías usar embedding cuando:
- Intentas recrear una jerarquía de herencia de múltiples niveles con “la clase padre llamando methods de la clase hija”. Usa interfaces y dependencias explícitas (sección 4.1).
- El type incrustado tiene methods que no quieres hacer públicos (sección 8.2).
- Solo necesitas uno o dos methods de ese type. Un field con nombre junto con algunos methods delegados escritos manualmente será más claro.
Nota: Embedding no crea una relación is-a. Si necesitas polimorfismo, define una interface y deja que múltiples types la satisfagan. No esperes que “el struct hijo sea el struct padre”.
10. Conclusión
Struct embedding es la herramienta de composición de Go, acompañada de un poco de sintaxis conveniente: los fields y methods del type incrustado se promocionan al struct externo. Te ayuda a reutilizar código, satisfacer interfaces y escribir decorators elegantes.
Sin embargo, embedding no es herencia. No hay envío virtual, el type incrustado no sabe que está dentro de un struct, y todos los methods promocionados se convierten en parte de tu API. Recuerda tres cosas: usa interfaces para el comportamiento variable, inicializa completamente los fields de interface o punteros incrustados, y cambia a fields con nombre cuando no quieras exponer methods hacia afuera.