trilha

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

Abstração

Perguntas-guia
  • Qual a diferença entre abstração e indireção? (uma reduz complexidade, a outra move)
  • Quando uma abstração está no nível errado — vazando ou escondendo demais?
  • Por que "abstração prematura" custa mais que duplicação?
  • Você consegue nomear a abstração sem usar o nome da implementação?

Conceito

Abstração é remover detalhe para revelar a essência. Indireção é apenas acrescentar um nível. As duas parecem iguais no diagrama e são opostas no efeito.

O teste que separa as duas

Depois de você introduzir isso, quem lê o código precisa saber menos?

  • Sim → abstração. io.Reader te libera de saber se é arquivo, socket ou string.
  • Não, precisa saber o mesmo mais um nome → indireção. UserServiceImpl atrás de UserService atrás de IUserService não removeu nada.

Camada que não reduz o que você precisa saber é custo puro.

Toda abstração vaza (Joel Spolsky). Não é falha de projeto, é propriedade do conceito:

  • um ORM esconde SQL — até você precisar do plano de execução (5. Índices)
  • TCP esconde perda de pacote — até a latência importar
  • memória virtual esconde o disco — até o swap começar
  • float esconde a representação — até 0.1 + 0.2 != 0.3

A consequência prática: você precisa entender um nível abaixo da abstração que usa. Não para usá-la no dia normal, mas para o dia em que ela vazar — e ela vai.

Nível errado, nos dois sentidos:

  • vazando — a interface expõe conceitos da implementação: BuscaUsuarioDoCacheRedis(). O nome do mecanismo está no contrato, então trocar o mecanismo quebra o contrato.
  • escondendo demais — você não consegue fazer o que precisa e tem que furar a camada. Toda API que exige um "escape hatch" (getRawConnection()) escondeu demais.

Abstração prematura custa mais que duplicação, e o motivo é preciso: com um caso de uso você não sabe qual é o eixo de variação. Você escolhe um eixo, quase sempre o errado, e uma abstração errada é mais difícil de remover que duplicação — porque código duplicado é local e visível, e abstração errada está espalhada e parece intencional.

Daí a regra dos três: espere o terceiro caso. Com dois você vê uma diferença; com três você vê o padrão.

O teste do nome: se você não consegue nomear a abstração sem usar o nome da implementação, você não abstraiu — só embrulhou.

Em Go

A cultura de Go é explicitamente hostil a abstração especulativa, e isso está nos provérbios da própria comunidade:

"The bigger the interface, the weaker the abstraction." "A little copying is better than a little dependency." — Rob Pike, Go Proverbs

O estudo de caso é io.Reader, e vale olhar com aten, porque é possivelmente a melhor abstração já escrita numa biblioteca padrão:

type Reader interface {
	Read(p []byte) (n int, err error)
}

Um método. E ele unifica: arquivo, conexão de rede, string em memória, buffer, descompressor gzip, decifrador, corpo de requisição HTTP, saída de um comando, gerador aleatório. Qualquer código que aceite io.Reader funciona com todos eles, e com os que ainda não existem.

Note o que ela remove: você não sabe se há disco, rede, memória, latência, buffer ou criptografia. O que ela preserva: leitura sequencial, contagem de bytes, e o fim (io.EOF). É exatamente a essência de "fonte de bytes", e nada mais.

E note o que ela não tenta esconder: não há Seek, não há Close, não há tamanho. Quem precisa disso pede outra interface (io.Seeker, io.Closer) — e a composição (io.ReadCloser) monta o que faltar. Interfaces pequenas compõem; interfaces grandes excluem.

"Aceite interfaces, devolva structs." Aceitar interface deixa o chamador escolher a implementação; devolver struct concreto não obriga o chamador a adivinhar o que aquela interface esconde e não amarra você a um contrato mais amplo do que precisa.

Go não tem herança, então abstração em Go é sempre interface + composição. Não existe a tentação de criar uma hierarquia abstrata de três níveis — a linguagem não oferece o mecanismo.

Respostas às perguntas-guia

1. Qual a diferença entre abstração e indireção?

Conceito: abstração reduz o que você precisa saber; indireção só move de lugar. O teste é se o leitor sabe menos depois.

Em Go: o exemplo canônico de indireção disfarçada é a interface com um implementador só criada "para desacoplar". Se ninguém mais vai implementar e você não usa para teste, ela é um nome extra no caminho.

2. Quando uma abstração está no nível errado — vazando ou escondendo demais?

Conceito: vazando quando o contrato menciona o mecanismo; escondendo demais quando você precisa de uma saída de emergência para fazer o trabalho.

Em Go: o sinal de "escondeu demais" é a função que devolve any ou aceita map[string]any — ela virou genérica ao ponto de não abstrair nada. O sinal de "vazou" é o nome do driver na assinatura.

3. Por que "abstração prematura" custa mais que duplicação?

Conceito: com um caso você não conhece o eixo de variação, escolhe errado, e abstração errada é mais difícil de remover que duplicação — duplicação é local e visível.

Em Go: é literalmente um provérbio da linguagem — a little copying is better than a little dependency. E há uma razão técnica extra: em Go, duplicar 20 linhas custa 20 linhas; criar um pacote para compartilhá-las custa uma dependência no grafo de importação, que é acíclico e imposto pelo compilador (4. Grafos).

4. Você consegue nomear a abstração sem usar o nome da implementação?

Conceito: se não consegue, não abstraiu.

Em Go, a convenção de nomes ajuda a testar isso: interface de um método se chama <Verbo>erReader, Writer, Stringer, Formatter, Closer. Se o nome natural do seu tipo é RedisUserRepository, o Redis no nome é a implementação avisando que ela não foi abstraída.

Trade-offs

Do conceito:

  • Abstração compra liberdade de trocar a implementação; cobra um nível de indireção e a chance de escolher o eixo errado.
  • Abstração pequena compõe e cobre menos; abstração grande cobre mais e é mais difícil de satisfazer.
  • Esperar o terceiro caso compra informação e cobra duplicação temporária — que é visível e barata de remover.

Em Go:

  • Interfaces pequenas + satisfação implícita tornam a abstração quase gratuita depois que você conhece o eixo — outra razão para não antecipar.
  • Devolver struct concreto compra clareza e cobra que o chamador dependa do seu tipo (o que é bom se o tipo é estável).
  • Não ter herança remove uma ferramenta e uma classe inteira de erros (4. Composição vs Herança).

Exemplo prático

io.Reader em ação: a mesma função consumindo cinco fontes diferentes, sem saber de nenhuma delas.

package main

import (
	"bytes"
	"compress/gzip"
	"fmt"
	"io"
	"strings"
)

// Não sabe se é arquivo, rede, memória, gzip. Sabe apenas "fonte de bytes".
func contaLinhas(r io.Reader) (int, error) {
	buf := make([]byte, 4096)
	n := 0
	for {
		lidos, err := r.Read(buf)
		n += bytes.Count(buf[:lidos], []byte("\n"))
		if err == io.EOF {
			return n, nil
		}
		if err != nil {
			return n, err
		}
	}
}

// Um io.Reader nosso: gera N linhas sem alocar nada.
type geradorDeLinhas struct{ faltam int }

func (g *geradorDeLinhas) Read(p []byte) (int, error) {
	if g.faltam == 0 {
		return 0, io.EOF
	}
	n := 0
	for n < len(p) && g.faltam > 0 {
		p[n] = '\n'
		n++
		g.faltam--
	}
	return n, nil
}

func gzipDe(texto string) io.Reader {
	var b bytes.Buffer
	w := gzip.NewWriter(&b)
	w.Write([]byte(texto))
	w.Close()
	r, _ := gzip.NewReader(&b)
	return r
}

func main() {
	texto := "a\nb\nc\n"

	fontes := []struct {
		nome string
		r    io.Reader
	}{
		{"string em memória", strings.NewReader(texto)},
		{"buffer de bytes", bytes.NewBufferString(texto)},
		{"gzip descomprimindo", gzipDe(texto)},
		{"nosso gerador", &geradorDeLinhas{faltam: 3}},
		{"dois readers concatenados", io.MultiReader(
			strings.NewReader("x\n"), strings.NewReader("y\n"))},
	}

	for _, f := range fontes {
		n, err := contaLinhas(f.r)
		fmt.Printf("%-26s -> %d linhas (err=%v)\n", f.nome, n, err)
	}

	// composição: interfaces pequenas se somam
	var rc io.ReadCloser = io.NopCloser(strings.NewReader(texto))
	n, _ := contaLinhas(rc) // ReadCloser satisfaz Reader
	rc.Close()
	fmt.Printf("%-26s -> %d linhas\n", "ReadCloser (composto)", n)
}

contaLinhas tem 12 linhas, nunca vai mudar, e funciona com fontes que não existiam quando ela foi escrita. Isso é o que abstração entrega quando está no nível certo.

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