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:

  • Address está dentro de Customer sin tener nombre de field. Go automáticamente establece el nombre del field como Address, es decir, el nombre del type.
  • El field City y el method FullAddress() de Address se promocionan hacia Customer. Por lo tanto, puedes escribir c.City en lugar de c.Address.City.
  • Al inicializar con composite literal, aún debes usar el nombre del field Address:. No hay sintaxis para “rellenar directamente” City en Customer.

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 de OrderService porque tiene una profundidad menos anidada.
  • s.Logger.Log(...) aún puede llamar la versión original. Esto es equivalente a super.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)
}
  • LoggingPayment solo sobrescribe Charge. Refund se delega automáticamente a PaymentService dentro.
  • *LoggingPayment satisface PaymentService, 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 ejemplo Refund) causará un panic nil pointer dereference en runtime. Siempre inicialízalo a través de un constructor, por ejemplo NewLoggingPayment(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.Handler o io.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.

golangstruct embeddingcompositioninterfaceoop