Métodos e interfaces em Go: receivers, polimorfismo e design idiomático

Capítulo 9

Tempo estimado de leitura: 8 minutos

+ Exercício

Métodos em Go: functions com receiver

Em Go, um método é uma função associada a um tipo (geralmente um struct). A diferença prática é que o método ganha um parâmetro especial chamado receiver, que aparece antes do nome do método.

type Counter struct { n int }

func (c Counter) Value() int {
	return c.n
}

func (c *Counter) Inc() {
	c.n++
}

O receiver pode ser por valor (c Counter) ou por ponteiro (c *Counter). Essa escolha afeta mutação, custo de cópia e quais métodos ficam disponíveis em cada forma do tipo.

Receiver por valor: leitura, imutabilidade e cópias

Quando o receiver é por valor, o método recebe uma cópia do valor. Isso é ótimo para métodos que apenas consultam dados e não precisam modificar o estado.

type Point struct { X, Y float64 }

func (p Point) Norm() float64 {
	return p.X*p.X + p.Y*p.Y
}

Critérios comuns para preferir receiver por valor:

  • Não há mutação do estado do objeto.
  • O tipo é pequeno (cópia barata) e/ou imutável por convenção.
  • Você quer que o método funcione naturalmente tanto em valores quanto em ponteiros (o compilador ajuda em várias situações, mas a escolha ainda importa para interfaces e consistência).

Receiver por ponteiro: mutação e evitar custo de cópia

Com receiver por ponteiro, o método opera sobre o mesmo objeto, permitindo alterar o estado e evitando copiar estruturas grandes.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

type Buffer struct {
	data [1024]byte
	used int
}

func (b *Buffer) Reset() {
	b.used = 0
}

Critérios comuns para preferir receiver por ponteiro:

  • O método precisa modificar o receiver (mutação).
  • O tipo é grande e copiar seria caro.
  • Você quer consistência: se um método do tipo precisa ser por ponteiro, costuma-se padronizar a maioria dos métodos como ponteiro para evitar confusão e problemas com interfaces.

Consistência e o “conjunto de métodos” (method set)

Em Go, existe a ideia de conjunto de métodos disponível para um tipo e para o ponteiro desse tipo:

  • Um valor T tem os métodos com receiver (t T).
  • Um ponteiro *T tem os métodos com receiver (t T) e (t *T).

Isso impacta interfaces: se uma interface exige um método que você definiu com receiver por ponteiro, então apenas *T satisfaz essa interface (não T).

type Resetter interface { Reset() }

type Thing struct{}
func (t *Thing) Reset() {}

func Use(r Resetter) {}

func demo() {
	var v Thing
	Use(&v) // ok
	// Use(v) // não compila: Thing não tem método Reset() no method set de valor
}

Interfaces em Go: pequenas, comportamentais e satisfação implícita

Uma interface define um conjunto de métodos (comportamentos). Em Go, interfaces são mais idiomáticas quando são pequenas e descrevem o que algo faz, não “o que algo é”.

type Stringer interface {
	String() string
}

Satisfação implícita

Um tipo satisfaz uma interface implicitamente ao implementar seus métodos. Não existe implements. Isso reduz acoplamento: você pode criar tipos que “passam a funcionar” com APIs existentes apenas por terem os métodos necessários.

type Notifier interface {
	Notify(to, msg string) error
}

type Email struct{}
func (Email) Notify(to, msg string) error { return nil }

func Send(n Notifier, to, msg string) error {
	return n.Notify(to, msg)
}

Design idiomático: prefira interfaces no consumidor

Uma prática comum é: quem usa define a interface mínima que precisa, em vez de o produtor criar interfaces grandes e genéricas. Isso mantém o design flexível e facilita testes.

// O consumidor só precisa disso:
type Saver interface {
	Save(key string, value []byte) error
}

Composição de interfaces

Interfaces podem ser compostas a partir de outras, formando contratos maiores quando necessário, sem perder a granularidade.

type Reader interface {
	Read(key string) ([]byte, error)
}

type Writer interface {
	Write(key string, value []byte) error
}

type ReadWriter interface {
	Reader
	Writer
}

Isso permite que funções aceitem o menor contrato possível:

func Load(r Reader, key string) ([]byte, error) {
	return r.Read(key)
}

func Store(w Writer, key string, v []byte) error {
	return w.Write(key, v)
}

Como “testar implementações” de interface

Às vezes você quer garantir em tempo de compilação que um tipo implementa uma interface. Um padrão idiomático é a atribuição para o identificador em branco (_).

type Notifier interface {
	Notify(to, msg string) error
}

type EmailNotifier struct{}
func (EmailNotifier) Notify(to, msg string) error { return nil }

var _ Notifier = (*EmailNotifier)(nil) // ou EmailNotifier{}, dependendo dos receivers

Se a assinatura não bater (ou se o receiver estiver errado), o código não compila, e você descobre cedo.

Passo a passo prático: mini-projeto de notificações com duas implementações

Objetivo: criar uma interface pequena (Notifier) e duas implementações: uma que “envia” por e-mail (simulada) e outra que registra em memória (útil para testes). Depois, compor um serviço que depende apenas da interface.

1) Defina a interface orientada a comportamento

package notify

type Notifier interface {
	Notify(to, msg string) error
}

Repare que a interface é mínima: um único método, com um propósito claro.

2) Implemente a primeira estratégia: EmailNotifier (simulado)

Vamos usar receiver por valor porque o tipo não precisa manter estado mutável.

package notify

import "fmt"

type EmailNotifier struct {
	From string
}

func (e EmailNotifier) Notify(to, msg string) error {
	// Simulação: em um projeto real, aqui entraria SMTP/API.
	fmt.Printf("EMAIL from=%s to=%s msg=%q\n", e.From, to, msg)
	return nil
}

3) Implemente a segunda estratégia: MemoryNotifier (mutável)

Agora precisamos armazenar as mensagens enviadas. Isso exige mutação, então usamos receiver por ponteiro.

package notify

type Sent struct {
	To  string
	Msg string
}

type MemoryNotifier struct {
	Sent []Sent
}

func (m *MemoryNotifier) Notify(to, msg string) error {
	m.Sent = append(m.Sent, Sent{To: to, Msg: msg})
	return nil
}

Critério aplicado: mutação do estado interno (Sent) → receiver por ponteiro.

4) Garanta em compile-time que ambos satisfazem a interface

package notify

var _ Notifier = EmailNotifier{}
var _ Notifier = (*MemoryNotifier)(nil)

Note a diferença: EmailNotifier{} satisfaz por valor; MemoryNotifier satisfaz apenas como ponteiro.

5) Crie um serviço que depende apenas da interface (polimorfismo)

Polimorfismo em Go aparece quando uma função/struct trabalha com uma interface e pode receber diferentes implementações.

package notify

type Service struct {
	N Notifier
}

func (s Service) Welcome(to string) error {
	return s.N.Notify(to, "Bem-vindo!")
}

func (s Service) PasswordReset(to string) error {
	return s.N.Notify(to, "Aqui está seu link de redefinição")
}

Service não sabe se está enviando e-mail ou gravando em memória. Ele só conhece o comportamento Notify.

6) Use o serviço com implementações diferentes

package main

import "seu/modulo/notify"

func main() {
	s1 := notify.Service{N: notify.EmailNotifier{From: "noreply@site"}}
	_ = s1.Welcome("ana@exemplo")

	mem := &notify.MemoryNotifier{}
	s2 := notify.Service{N: mem}
	_ = s2.PasswordReset("bob@exemplo")

	// mem.Sent agora contém o histórico (útil em testes)
}

Testando o mini-projeto: foco em comportamento

Como o serviço depende de uma interface pequena, testar fica simples: use MemoryNotifier como dublê (test double) e verifique o que foi “enviado”.

package notify_test

import (
	"testing"
	"seu/modulo/notify"
)

func TestWelcomeSendsMessage(t *testing.T) {
	mem := &notify.MemoryNotifier{}
	svc := notify.Service{N: mem}

	if err := svc.Welcome("ana@exemplo"); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(mem.Sent) != 1 {
		t.Fatalf("expected 1 message, got %d", len(mem.Sent))
	}
	if mem.Sent[0].To != "ana@exemplo" {
		t.Fatalf("unexpected to: %s", mem.Sent[0].To)
	}
	if mem.Sent[0].Msg != "Bem-vindo!" {
		t.Fatalf("unexpected msg: %q", mem.Sent[0].Msg)
	}
}

Checklist de escolha: receiver por valor vs ponteiro

CritérioPrefira receiver por valorPrefira receiver por ponteiro
Mutação do estadoNãoSim
Custo de cópiaTipo pequeno, cópia barataTipo grande, cópia cara
Consistência do tipoTodos os métodos são naturalmente “read-only”Alguns métodos precisam de ponteiro; padronize para evitar surpresas
Satisfação de interfaceQuer que T satisfaça a interfaceOk exigir *T (ex.: precisa mutar)

Interfaces pequenas: exemplos de refinamento

Se você perceber que uma interface está crescendo demais, considere quebrá-la em comportamentos menores e compor quando necessário.

// Evite (muito ampla):
type BigNotifier interface {
	Notify(to, msg string) error
	Validate(to string) error
	Format(msg string) string
	Stats() map[string]int
}

// Prefira (pequenas e compostas quando preciso):
type Notifier interface { Notify(to, msg string) error }

type Validator interface { Validate(to string) error }

type Formatter interface { Format(msg string) string }

type NotifierWithValidation interface {
	Notifier
	Validator
}

Agora responda o exercício sobre o conteúdo:

Em Go, ao implementar um método que precisa registrar mensagens em um slice dentro de um struct, qual escolha de receiver é mais adequada e por quê?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Se o método altera campos do struct (ex.: fazer append em um slice), ele precisa operar sobre o mesmo objeto. Por isso, usa-se receiver por ponteiro, evitando mutar apenas uma cópia.

Próximo capitúlo

Erros em Go: padrão error, wrapping, sentinelas e mensagens úteis

Arrow Right Icon
Capa do Ebook gratuito Go (Golang) para Iniciantes: Fundamentos, Concorrência e Estrutura de Projetos
50%

Go (Golang) para Iniciantes: Fundamentos, Concorrência e Estrutura de Projetos

Novo curso

18 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.