trilha

Fundamentos de Programação/02 - Código que Presta/Semana 05 - POO de Verdade9 min

Composição vs Herança

Perguntas-guia
  • Por que herança acopla você à implementação da classe pai, não só à interface?
  • Qual o teste honesto de "é um" vs "tem um"?
  • O que é o problema da classe base frágil?
  • Que herança do seu código atual viraria composição sem perder nada?

Conceito

A frase "prefira composição a herança" é repetida sem o motivo. O motivo é preciso:

Herança te acopla à implementação da superclasse, não apenas à sua interface.

Quando você herda, você depende de como os métodos do pai chamam uns aos outros — e isso é detalhe interno que o autor do pai pode mudar em qualquer versão.

O problema da classe base frágil, com o exemplo clássico: você estende uma lista para contar quantos elementos foram adicionados. Sobrescreve add para incrementar o contador. Só que addAll do pai chama add internamente — então adicionar 3 itens conta 6. Ou você sobrescreve os dois, e uma versão futura do pai muda addAll para não chamar mais add, e seu contador silenciosamente para de funcionar.

Você não fez nada errado. Você dependeu de um detalhe interno, e ele mudou.

O teste "é um" vs "tem um" é insuficiente. Quadrado é um retângulo em geometria, e herdar Retangulo em Quadrado produz um bug: setLargura(5) num quadrado precisa mudar a altura também, o que viola o contrato de Retangulo (largura e altura são independentes).

O teste melhor é o Princípio de Substituição de Liskov: todo lugar que aceita o pai pode receber o filho sem saber, e continuar correto? Se há qualquer condição — "desde que não chame setLargura", "desde que verifique o tipo" — a herança está errada.

Composição é delegação explícita: você guarda o outro objeto e chama o que precisa.

Herança Composição
Acoplamento à implementação do pai à interface do colaborador
Verbosidade menor (métodos vêm de graça) maior (delegar é escrever)
Trocar em runtime impossível trivial
Quantos "pais" um (na maioria das linguagens) quantos quiser

Onde herança realmente serve: hierarquias verdadeiramente fechadas e estáveis (nós de uma AST, tipos de exceção) e reúso de interface em vez de implementação — que é o que interface pura faz melhor.

Em Go

Go não tem herança. Nenhuma. Não existe subclasse, não existe super, não existe método virtual.

O que existe é embedding (incorporação): um campo anônimo cujos métodos e campos são promovidos para o tipo externo.

type Servidor struct {
	*log.Logger // campo anônimo: métodos promovidos
	porta int
}

s.Printf("subindo na porta %d", s.porta) // Printf veio do Logger

Parece herança. Não é, e a diferença mais importante é esta:

Embedding não tem despacho virtual

Se um método do tipo embutido chama outro método do tipo embutido, ele chama a versão do embutido — nunca a sua "sobrescrita".

type Base struct{}
func (b Base) Nome() string     { return "Base" }
func (b Base) Saudacao() string { return "olá, " + b.Nome() }

type Derivado struct{ Base }
func (d Derivado) Nome() string { return "Derivado" }

Derivado{}.Nome()     // "Derivado"  <- a sua versão
Derivado{}.Saudacao() // "olá, Base" <- NÃO "olá, Derivado"

Em Java isso imprimiria "olá, Derivado". Em Go, o receiver de Saudacao é Base, e b.Nome() resolve estaticamente para Base.Nome.

Isso elimina o problema da classe base frágil: as chamadas internas do tipo embutido não podem ser sequestradas por você. O preço é que o padrão template method (pai define o esqueleto, filho preenche os passos) simplesmente não existe em Go.

Como se faz polimorfismo em Go, então: com 3. Interfaces e passagem explícita. Se Saudacao precisa da versão de quem chama, ela recebe uma interface:

type Nomeavel interface{ Nome() string }
func Saudacao(n Nomeavel) string { return "olá, " + n.Nome() }

Agora funciona para qualquer tipo, e a dependência é explícita na assinatura.

Embedding ainda é útil e idiomático para:

  • reúso de implementação sem hierarquia — embutir sync.Mutex para ganhar Lock/Unlock
  • satisfazer interface por delegação — embutir io.Reader e o tipo externo já é um io.Reader
  • implementação parcial em dublês — embutir a interface (não a struct) e implementar só os métodos usados; os outros existem e dão panic se chamados
  • wrappers — embutir http.ResponseWriter e sobrescrever só WriteHeader

A armadilha do embedding de sync.Mutex exportado: struct { sync.Mutex } promove Lock e Unlock para a API pública do seu tipo. Qualquer um de fora pode travar seu mutex. O idioma correto é campo nomeado e minúsculo: mu sync.Mutex.

Respostas às perguntas-guia

1. Por que herança acopla você à implementação da classe pai, não só à interface?

Conceito: porque você depende de como os métodos do pai chamam uns aos outros — detalhe interno que pode mudar sem aviso. É a classe base frágil.

Em Go: não se aplica, porque não há despacho virtual. As chamadas internas do tipo embutido resolvem estaticamente para os métodos dele. Go tornou o problema impossível ao remover o mecanismo.

2. Qual o teste honesto de "é um" vs "tem um"?

Conceito: "é um" não basta — use Liskov: todo lugar que aceita o pai pode receber o filho, sem saber, e continuar correto? Qualquer ressalva reprova a herança.

Em Go: a pergunta se reformula naturalmente: "esse tipo deveria satisfazer a mesma interface?" Se sim, declare a interface. Se você só quer os métodos prontos, embuta — e note que embutir não cria relação de subtipo com a struct, só faz o tipo externo satisfazer as mesmas interfaces.

3. O que é o problema da classe base frágil?

Conceito: mudanças internas no pai quebram subclasses que não fizeram nada errado, porque as subclasses dependiam do padrão de chamadas internas.

Em Go: eliminado por construção. O custo é que template method não existe — o esqueleto tem que ser uma função que recebe interface, não um método de classe base.

4. Que herança do seu código atual viraria composição sem perder nada?

Conceito: toda que existe apenas para reúso de código (não de contrato). Se a superclasse nunca é usada como tipo, ela não é uma abstração — é uma biblioteca com sintaxe errada.

Em Go: se você veio de Java e está embutindo struct para "herdar", verifique se você não precisa, na verdade, de um campo nomeado e três métodos de delegação. Delegação explícita é mais verbosa e não te engana sobre o despacho.

Trade-offs

Do conceito:

  • Herança compra brevidade e cobra acoplamento à implementação e uma hierarquia rígida.
  • Composição compra flexibilidade (trocar em runtime, múltiplos colaboradores) e cobra código de delegação.
  • Interface pura compra o reúso de contrato sem nenhum acoplamento de implementação — e não reusa código.

Em Go:

  • Sem despacho virtual: a classe base frágil é impossível, e template method também. Você troca um padrão por uma classe de bugs eliminada.
  • Embedding é conciso e promove para a API pública o que você embutiu — cuidado com sync.Mutex e com tipos de terceiros.
  • Delegação explícita é verbosa e nunca te surpreende. Em Go, verbosidade previsível é considerada preferível a concisão com regras sutis.

Exemplo prático

package main

import (
	"fmt"
	"strings"
	"sync"
)

// ============ 1. Embedding NÃO é herança: sem despacho virtual ============
type Base struct{}

func (b Base) Nome() string     { return "Base" }
func (b Base) Saudacao() string { return "olá, " + b.Nome() } // resolve ESTATICAMENTE

type Derivado struct{ Base }

func (d Derivado) Nome() string { return "Derivado" } // NÃO sobrescreve de verdade

// ============ 2. Polimorfismo em Go: interface + passagem explícita ============
type Nomeavel interface{ Nome() string }

func SaudacaoPolimorfica(n Nomeavel) string { return "olá, " + n.Nome() }

// ============ 3. A classe base frágil, que em Go não acontece ============
type ListaBase struct{ itens []string }

func (l *ListaBase) Add(s string)          { l.itens = append(l.itens, s) }
func (l *ListaBase) AddAll(ss ...string)   { for _, s := range ss { l.Add(s) } } // chama Add
func (l *ListaBase) Len() int              { return len(l.itens) }

type ListaContadora struct {
	ListaBase
	adicoes int
}

func (l *ListaContadora) Add(s string) { // em Java, AddAll chamaria ESTA versão
	l.adicoes++
	l.ListaBase.Add(s)
}

// ============ 4. Composição explícita: delegação, sem surpresa ============
type ListaComposta struct {
	base    ListaBase // campo NOMEADO: nada é promovido
	adicoes int
}

func (l *ListaComposta) Add(s string) { l.adicoes++; l.base.Add(s) }
func (l *ListaComposta) AddAll(ss ...string) {
	for _, s := range ss {
		l.Add(s) // eu controlo qual Add é chamado
	}
}
func (l *ListaComposta) Len() int { return l.base.Len() }

// ============ 5. Embedding de INTERFACE: implementação parcial em dublê ============
type Repositorio interface {
	Busca(int) (string, error)
	Salva(string) error
	Remove(int) error
	Conta() int
}

type dubleParcial struct {
	Repositorio // embutida: os métodos não implementados existem (e dão panic)
	valor string
}

func (d dubleParcial) Busca(int) (string, error) { return d.valor, nil }

// ============ 6. Mutex embutido vaza Lock/Unlock para a API pública ============
type ContadorVazando struct {
	sync.Mutex // RUIM: Lock e Unlock ficam públicos
	n int
}

type ContadorCorreto struct {
	mu sync.Mutex // campo nomeado e minúsculo
	n  int
}

func (c *ContadorCorreto) Inc() { c.mu.Lock(); defer c.mu.Unlock(); c.n++ }
func (c *ContadorCorreto) N() int { c.mu.Lock(); defer c.mu.Unlock(); return c.n }

func main() {
	d := Derivado{}
	fmt.Println("d.Nome()     =", d.Nome())     // Derivado
	fmt.Println("d.Saudacao() =", d.Saudacao()) // olá, Base  <- SEM despacho virtual
	fmt.Println("polimórfico  =", SaudacaoPolimorfica(d))
	fmt.Println()

	// AddAll do pai chama o Add DO PAI: o contador não conta
	lc := &ListaContadora{}
	lc.AddAll("a", "b", "c")
	fmt.Printf("embedding:  Len=%d adicoes=%d  <- o contador nao viu nada\n", lc.Len(), lc.adicoes)

	// composição: eu escrevi AddAll, eu sei qual Add ele chama
	comp := &ListaComposta{}
	comp.AddAll("a", "b", "c")
	fmt.Printf("composição: Len=%d adicoes=%d  <- correto, e sem magia\n", comp.Len(), comp.adicoes)
	fmt.Println()

	// dublê parcial: só o método usado precisa existir
	var repo Repositorio = dubleParcial{valor: "fake"}
	v, _ := repo.Busca(1)
	fmt.Println("dublê parcial devolveu:", v)
	func() {
		defer func() { fmt.Println("método não implementado:", recover() != nil) }()
		repo.Conta() // panic: Repositorio embutida é nil
	}()
	fmt.Println()

	// mutex vazando
	vaz := &ContadorVazando{}
	vaz.Lock() // qualquer um de fora pode travar seu tipo
	vaz.Unlock()
	fmt.Println("ContadorVazando expõe Lock/Unlock na API pública")

	cc := &ContadorCorreto{}
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() { defer wg.Done(); cc.Inc() }()
	}
	wg.Wait()
	fmt.Println("ContadorCorreto após 100 goroutines:", cc.N())

	_ = strings.TrimSpace("")
}

As duas linhas centrais são d.Saudacao() = olá, Base e a comparação embedding: adicoes=0 contra composição: adicoes=3. A primeira mostra que embedding não é herança; a segunda mostra que isso é uma proteção, não uma limitação — o contador quebrado do ListaContadora é exatamente o bug da classe base frágil, e em Go ele falha de forma visível e imediata em vez de silenciosamente três versões depois.

Relacionado


Parte de Semana 05 - POO de Verdade · 00 - MOC Fundamentos de Programação

Buscar

Busca por título, seção e texto das notas