Struct embedding no Go em vez de herança

1. Visão geral

Se você está acostumado com Java ou C#, a primeira pergunta ao escrever Go geralmente é: “extends cadê?”. Go não possui herança. Em vez disso, Go possui struct embedding: embutir um tipo em outra struct para reutilizar fields e methods.

Este artigo explica como embedding funciona, as diferenças em relação à herança, e as armadilhas comuns. Depois de ler, você será capaz de usar embedding para compartilhar comportamento, escrever decorators elegantes e evitar seus erros típicos.

2. Struct embedding básico

Embedding é uma declaração de um field apenas com tipo, sem nome:

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 embutido
}

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)  // acesso explícito
}
Huế
12 Lê Lợi, Huế
Huế

Análise:

  • Address está dentro de Customer sem nome de field. Go automaticamente define o nome do field como Address, que é o nome do tipo.
  • O field City e o method FullAddress() de Address são promovidos para Customer. Por isso você pode escrever c.City em vez de c.Address.City.
  • Quando inicializa com composite literal, você ainda precisa usar o nome do field Address:. Não há sintaxe para “preencher diretamente” City em Customer.

Em essência, Customer contém um Address. Esta é uma relação has-a (tem-um), apenas com sintaxe adicional fornecida pelo compilador. Customer não é um Address. Você não pode passar um Customer para uma função que espera um 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. “Sobrescrita” de method via shadowing

Se a struct externa declara um method com o mesmo nome do method do tipo embutido, o method da struct externa obscurece o método 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) // chama o method do tipo embutido
}

func main() {
	s := OrderService{Logger: Logger{Prefix: "> "}}
	s.Log("created #1001")
	s.Logger.Log("raw message")
}
> [order] created #1001
> raw message
  • s.Log(...) chama o method de OrderService, porque tem precedência.
  • s.Logger.Log(...) ainda consegue chamar o original. Isso é equivalente a super.log(...) em Java.

À primeira vista parece override. Mas este é exatamente o lugar onde muitas pessoas tropeçam, como a próxima seção mostra.

4. A maior armadilha: sem virtual dispatch

Em Java, se a classe pai chama this.channel() e a classe filha faz override de channel(), a versão da classe filha será executada. Go não faz isso:

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(...) na verdade é s.Notifier.Send(...). Dentro de Send, o receiver n tem tipo Notifier. Ele não sabe que está dentro de um SMSNotifier, então n.Channel() sempre retorna "email".

Essa é a diferença essencial. O tipo embutido não tem referência de volta para a struct que o contém. O padrão “template method” ao estilo Java não se aplica diretamente.

4.1. Solução: extrair a parte variável em interface

Se você precisa de comportamento que possa variar, passe-o explicitamente:

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

Agora a parte variável (Channel) é uma dependency explícita. Este é o jeito mais “Go” de fazer, em vez de tentar simular override.

5. Embedding e interface

Interfaces em Go são satisfeitas implicitamente. Methods promovidos também são contabilizados no method set da struct externa. Portanto, embedding ajuda a struct satisfazer uma interface sem ter de reescrever o 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 satisfaz Describer graças ao method promovido
}
KCN Thăng Long, Hà Nội

5.1. Nota sobre receiver com ponteiro

A regra do method set ao embutir é como segue:

Declarado em S Method set de S Method set de *S
embutir T method receiver T method receiver T e *T
embutir *T method receiver T e *T method receiver T e *T

Por exemplo, se Counter possui func (c *Counter) Inc() e você embutir Counter (não um ponteiro) em Stats, apenas *Stats satisfaz uma interface que tem Inc(). O valor Stats não. Erros de compilação do tipo “Stats does not implement Incrementer (method Inc has pointer receiver)” geralmente vêm disso.

6. Embutir interface em struct: decorator elegante

Você pode embutir uma interface em uma struct. A struct terá automaticamente todos os methods da interface, cada method delegado para o valor interno. Você só precisa sobrescrever os methods que quer mudar:

package payment

import "fmt"

type PaymentService interface {
	Charge(orderID string, amount int) error
	Refund(orderID string) error
}

type LoggingPayment struct {
	PaymentService           // delega todos os 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 só reescreve Charge. Refund é delegado automaticamente para o PaymentService interno.
  • *LoggingPayment satisfaz PaymentService, então você pode envolver qualquer implementação sem que o chamador precise saber.

Quando uma interface tem dez methods e você só precisa interceptar um, essa técnica economiza muito código repetitivo.

Nota: Se o field de interface embutido é nil, o código ainda compila normalmente. Mas chamar um method que não foi sobrescrito (ex: Refund) causará um panic de nil pointer dereference em runtime. Sempre inicialize através de um construtor, ex: NewLoggingPayment(inner PaymentService).

7. Testando comportamento delegado

Go usa o package testing padrão em vez de JUnit. O teste abaixo verifica duas coisas: Charge é registrado em log e ainda chama o serviço original, enquanto Refund é delegado sem logging.

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)
	}
}

Execute o teste:

go test ./...
ok  	example.com/shop/payment	0.002s

A linha var svc PaymentService = &LoggingPayment{...} também é uma verificação em tempo de compilação: se *LoggingPayment não satisfaz a interface, o teste não será compilado. No código de produção, você pode usar a forma conhecida var _ PaymentService = (*LoggingPayment)(nil) para o mesmo propósito.

8. Conflitos de nomes e erros comuns

8.1. Ambiguous selector

Quando dois tipos embutidos na mesma profundidade compartilham o mesmo nome de field ou method, acessar através da struct externa resultará em erro de compilação:

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
}

A struct Order ainda é válida. O erro só aparece quando você acessa o.Email. A forma de lidar é chamar explicitamente, ou declarar Email diretamente em Order para obscurecer ambos.

8.2. Expor API acidentalmente: exemplo com sync.Mutex

type Inventory struct {
	sync.Mutex
	stock map[string]int
}

Este código funciona. Mas Lock() e Unlock() agora são methods públicos de Inventory, porque o nome do field embutido Mutex é exportado. Código fora do package pode travar seu inventário. Melhor escrever:

type Inventory struct {
	mu    sync.Mutex
	stock map[string]int
}

A regra geral: embedding traz todos os methods do tipo embutido para a API pública. Se você não quer expô-los, use um field com nome.

8.3. Embutir ponteiro como nil

Embutir *Address em vez de Address significa que o valor zero da struct contém um ponteiro nil. Quando isso acontece, c.City causará panic. Só embutir ponteiro quando você realmente precisa compartilhar a instância ou precisa do method set de *T, e lembre-se de inicializá-lo.

9. Quando usar e quando não usar embedding

Usar quando:

  • Você quer que a struct externa realmente exponha todo o comportamento do tipo embutido, ex: embutir http.Handler ou io.Reader.
  • Você está escrevendo um decorator ou wrapper que muda apenas alguns methods de uma interface grande.
  • Você está agrupando fields comuns como Audit{CreatedAt, UpdatedAt} em várias entidades.

Não usar quando:

  • Você está tentando recriar uma árvore de herança multilevel com “classe pai chamando método da classe filha”. Use interface e dependency explícita (seção 4.1).
  • O tipo embutido tem methods que você não quer expor (seção 8.2).
  • Você só precisa de um ou dois methods do outro tipo. Um field com nome mais alguns methods de delegação escritos manualmente será mais claro.

Nota: Embedding não cria uma relação is-a. Se você precisa de polimorfismo, defina uma interface e deixe vários tipos satisfazê-la. Não conte com “struct filha é struct pai”.

10. Conclusão

Struct embedding é a ferramenta de composição do Go, acompanhada por um pouco de açúcar sintático: fields e methods do tipo embutido são promovidos para a struct externa. Ajuda você a reutilizar código, satisfazer interfaces e escrever decorators muito elegantes.

No entanto, embedding não é herança. Não há virtual dispatch, o tipo embutido não sabe da struct que o contém, e cada method promovido vira parte da sua API pública. Lembre-se de três coisas: use interface para a parte comportamental que varia, inicialize completamente os fields de interface ou ponteiros embutidos, e mude para fields com nome quando não quer expor methods para fora.

golangstruct embeddingcompositioninterfaceoop