Fundamentos de Programação/02 - Código que Presta/Semana 05 - POO de Verdade9 min
Composição vs Herança
- 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:
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.Mutexpara ganharLock/Unlock - satisfazer interface por delegação — embutir
io.Readere o tipo externo já é umio.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.ResponseWritere 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.Mutexe 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
- 3. Interfaces — como Go faz polimorfismo sem herança
- 2. Abstração — reúso de contrato contra reúso de implementação
- 1. Encapsulamento — embedding promove para a API pública: cuidado com o que embute
- 3. Passagem por Valor vs por Referência — semana 1, receiver por valor copia o mutex
Parte de Semana 05 - POO de Verdade · 00 - MOC Fundamentos de Programação