Métodos em Structs: receivers por valor e por ponteiro

Métodos em Structs: receivers por valor e por ponteiro

Métodos em Go: receiver por valor e por ponteiro, a conversão automática para &v e quando ela não acontece, a convenção de não misturar receivers, String() e o fmt, métodos em tipos não-struct, method values, encadeamento e a promoção de métodos por embedding.
Golang

• • 15 min de leitura

No artigo Structs: definição, inicialização e campos embutidos, structs foram apresentadas como agrupamentos de dados. Métodos são o complemento natural — eles associam comportamento a esses tipos. Em Go, um método é simplesmente uma função com um argumento especial chamado receiver, que aparece entre a palavra-chave func e o nome do método.

Não existe uma palavra-chave especial como class ou object. Qualquer tipo definido no pacote atual pode ter métodos — não apenas structs, mas também tipos baseados em int, string ou qualquer outro tipo primitivo.

Declarando um método

A diferença sintática entre uma função e um método é o receiver:

package main

import "fmt"

type Retangulo struct {
    Largura float64
    Altura  float64
}

// Função comum — recebe Retangulo como parâmetro
func areaFuncao(r Retangulo) float64 {
    return r.Largura * r.Altura
}

// Método — receiver aparece antes do nome
func (r Retangulo) Area() float64 {
    return r.Largura * r.Altura
}

func main() {
    r := Retangulo{Largura: 5.0, Altura: 3.0}

    fmt.Println(areaFuncao(r)) // 15 — chamada de função
    fmt.Println(r.Area())      // 15 — chamada de método
}

O receiver (r Retangulo) declara que Area pertence ao tipo Retangulo. A variável r dentro do método é uma cópia do valor sobre o qual o método foi chamado — exatamente como um parâmetro de função normal.

Receiver por valor

Quando o receiver é declarado por valor, o método trabalha com uma cópia do valor original. Modificações feitas dentro do método não afetam o valor original:

type Contador struct {
    valor int
}

func (c Contador) Incrementar() {
    c.valor++  // modifica apenas a cópia local
}

func (c Contador) Valor() int {
    return c.valor
}

func main() {
    c := Contador{valor: 0}
    c.Incrementar()
    fmt.Println(c.Valor()) // 0 — não foi modificado
}

Receivers por valor são adequados quando o método apenas lê os dados da struct sem precisar modificá-los, e quando a struct é pequena o suficiente para que a cópia não seja custosa.

Receiver por ponteiro

Quando o receiver é declarado por ponteiro, o método recebe o endereço do valor original e pode modificá-lo diretamente:

func (c *Contador) Incrementar() {
    c.valor++  // modifica o valor original
}

func main() {
    c := Contador{valor: 0}
    c.Incrementar()
    c.Incrementar()
    c.Incrementar()
    fmt.Println(c.Valor()) // 3
}

A chamada c.Incrementar() funciona mesmo que c seja um valor, não um ponteiro. Go converte automaticamente para (&c).Incrementar() quando necessário — desde que c seja endereçável, ou seja, uma variável, não um valor literal ou retorno de função.

A regra de ouro: consistência no tipo de receiver

A comunidade Go segue uma convenção importante — o compilador não a impõe, e misturar os dois compila normalmente: se qualquer método de um tipo usa receiver por ponteiro, os demais também devem usar. A mistura confunde quem lê e cria problemas sutis com interfaces: um String() com receiver de ponteiro, por exemplo, é ignorado quando se imprime o valor — fmt.Println(t) mostra os campos crus, {7}, enquanto fmt.Println(&t) usa o método —, e o go vet não avisa. A exceção que se vê na prática é justamente o String() com receiver por valor num tipo cujos outros métodos são de ponteiro, como no exemplo da conta bancária mais abaixo: assim ele funciona para valores e para ponteiros.

package main

import (
    "fmt"
    "math"
)

type Circulo struct {
    Raio float64
}

func (c *Circulo) Area() float64 {
    return math.Pi * c.Raio * c.Raio
}

func (c *Circulo) Perimetro() float64 {
    return 2 * math.Pi * c.Raio
}

func (c *Circulo) Escalar(fator float64) {
    c.Raio *= fator
}

func main() {
    c := Circulo{Raio: 5.0}

    fmt.Printf("Área: %.2f\n", c.Area())
    fmt.Printf("Perímetro: %.2f\n", c.Perimetro())

    c.Escalar(2.0)
    fmt.Printf("Raio após escala: %.2f\n", c.Raio) // 10.00
    fmt.Printf("Nova área: %.2f\n", c.Area())
}

Quando usar cada tipo de receiver

A decisão entre receiver por valor e por ponteiro segue critérios claros:

Use receiver por ponteiro quando:

O método precisa modificar o estado da struct. A struct é grande e a cópia seria cara em termos de memória e tempo. A struct contém campos que não devem ser copiados, como sync.Mutex.

Use receiver por valor quando:

O método apenas lê dados, sem modificar nada. O tipo é pequeno e simples — poucos campos de tipos básicos. A semântica de cópia é desejável, como em tipos que representam valores imutáveis.

Na prática, a maioria dos tipos Go com comportamento significativo usa receivers por ponteiro. A biblioteca padrão segue essa convenção extensivamente.

Métodos em tipos não-struct

Um detalhe poderoso do Go é que métodos podem ser definidos em qualquer tipo nomeado do pacote, não apenas em structs. Isso permite estender tipos simples com comportamento específico do domínio:

package main

import (
    "fmt"
    "strings"
)

type DiaDaSemana int

const (
    Domingo DiaDaSemana = iota
    Segunda
    Terca
    Quarta
    Quinta
    Sexta
    Sabado
)

func (d DiaDaSemana) String() string {
    nomes := []string{
        "Domingo", "Segunda", "Terça",
        "Quarta", "Quinta", "Sexta", "Sábado",
    }
    if d < Domingo || d > Sabado {
        return "Desconhecido"
    }
    return nomes[d]
}

func (d DiaDaSemana) EFimDeSemana() bool {
    return d == Domingo || d == Sabado
}

func main() {
    hoje := Quarta
    fmt.Println(hoje)                   // Quarta
    fmt.Println(hoje.EFimDeSemana())    // false

    fmt.Println(Sabado)                 // Sábado
    fmt.Println(Sabado.EFimDeSemana())  // true
}

O método String() tem um papel especial: quando um tipo implementa String() string, o pacote fmt o chama automaticamente ao imprimir o valor. Esse é o primeiro contato com o conceito de interfaces, que será o tema do próximo artigo, Interfaces: contratos implícitos e polimorfismo.

Encadeamento de métodos

Um padrão útil em Go é o method chaining — cada método retorna um ponteiro para o receptor, permitindo encadear chamadas:

package main

import "fmt"

type Consulta struct {
    tabela    string
    condicoes []string
    limite    int
}

func (q *Consulta) Da(tabela string) *Consulta {
    q.tabela = tabela
    return q
}

func (q *Consulta) Onde(condicao string) *Consulta {
    q.condicoes = append(q.condicoes, condicao)
    return q
}

func (q *Consulta) Limite(n int) *Consulta {
    q.limite = n
    return q
}

func (q *Consulta) Construir() string {
    sql := fmt.Sprintf("SELECT * FROM %s", q.tabela)
    if len(q.condicoes) > 0 {
        sql += " WHERE " + q.condicoes[0]
        for _, c := range q.condicoes[1:] {
            sql += " AND " + c
        }
    }
    if q.limite > 0 {
        sql += fmt.Sprintf(" LIMIT %d", q.limite)
    }
    return sql
}

func main() {
    sql := (&Consulta{}).
        Da("usuarios").
        Onde("ativo = true").
        Onde("idade >= 18").
        Limite(10).
        Construir()

    fmt.Println(sql)
    // SELECT * FROM usuarios WHERE ativo = true AND idade >= 18 LIMIT 10
}

O método String() e a interface Stringer

Como mencionado, implementar String() string faz com que o fmt use esse método automaticamente. Esse é o padrão idiomático para representação textual de tipos personalizados:

package main

import "fmt"

type Temperatura struct {
    Celsius float64
}

func (t Temperatura) String() string {
    return fmt.Sprintf("%.1f°C (%.1f°F)", t.Celsius, t.Celsius*9/5+32)
}

func main() {
    t := Temperatura{Celsius: 36.6}
    fmt.Println(t)               // 36.6°C (97.9°F)
    fmt.Printf("Temp: %v\n", t) // Temp: 36.6°C (97.9°F)
    fmt.Printf("Temp: %s\n", t) // Temp: 36.6°C (97.9°F)
}

Promoted methods: métodos de campos embutidos

Quando uma struct embutida tem métodos, esses métodos também são promovidos ao tipo externo — o mesmo mecanismo que ocorre com campos:

package main

import "fmt"

type Motor struct {
    Potencia int
}

func (m *Motor) Ligar() {
    fmt.Printf("Motor de %d CV ligado.\n", m.Potencia)
}

func (m *Motor) Desligar() {
    fmt.Println("Motor desligado.")
}

type Carro struct {
    Modelo string
    Motor         // Motor embutido
}

func main() {
    c := Carro{
        Modelo: "Sedan",
        Motor:  Motor{Potencia: 150},
    }

    c.Ligar()    // método promovido de Motor
    fmt.Println("Modelo:", c.Modelo)
    c.Desligar()
}

Se Carro definir seu próprio método Ligar(), ele sobrescreve o método promovido de Motor — o método do tipo externo tem precedência.

Exemplo completo: conta bancária

Um exemplo que consolida receivers por ponteiro, encapsulamento e o método String():

package main

import (
    "errors"
    "fmt"
)

type ContaBancaria struct {
    titular string
    saldo   float64
}

func NovaConta(titular string, deposito float64) (*ContaBancaria, error) {
    if deposito < 0 {
        return nil, errors.New("depósito inicial não pode ser negativo")
    }
    return &ContaBancaria{titular: titular, saldo: deposito}, nil
}

func (c *ContaBancaria) Depositar(valor float64) error {
    if valor <= 0 {
        return errors.New("valor de depósito deve ser positivo")
    }
    c.saldo += valor
    return nil
}

func (c *ContaBancaria) Sacar(valor float64) error {
    if valor <= 0 {
        return errors.New("valor de saque deve ser positivo")
    }
    if valor > c.saldo {
        return errors.New("saldo insuficiente")
    }
    c.saldo -= valor
    return nil
}

func (c *ContaBancaria) Saldo() float64 {
    return c.saldo
}

func (c ContaBancaria) String() string {
    return fmt.Sprintf("Conta[%s] Saldo: R$ %.2f", c.titular, c.saldo)
}

func main() {
    conta, err := NovaConta("Ricardo", 1000.00)
    if err != nil {
        fmt.Println("Erro:", err)
        return
    }

    fmt.Println(conta)

    conta.Depositar(500.00)
    fmt.Println(conta)

    if err := conta.Sacar(200.00); err != nil {
        fmt.Println("Erro:", err)
    }
    fmt.Println(conta)

    if err := conta.Sacar(5000.00); err != nil {
        fmt.Println("Erro:", err) // Erro: saldo insuficiente
    }
}

Um método em Go é uma função com o receiver em destaque, e quase tudo o que importa sobre ele se decide nessa escolha entre valor e ponteiro: se o método pode alterar o original, se o tipo satisfaz uma interface, se o fmt enxerga o String(), se a chamada funciona num elemento de map. A regra prática da comunidade — ponteiro quando algum método precisa de ponteiro, valor em tipos pequenos e imutáveis — resolve a maior parte dos casos, e o String() por valor é a exceção que vale conhecer.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um tipo Pedido tem todos os métodos com receiver de ponteiro, inclusive String(). Nos logs, pedidos aparecem às vezes como Pedido #123 (pago) e às vezes como uma sequência crua de campos entre chaves. O código de log é sempre log.Println(p). De onde vem a diferença?

Ver resposta

✓ Resposta: Vem do tipo de p em cada chamada. O fmt, usado por baixo pelo log, só chama String() se o valor que recebeu tiver esse método, e um método com receiver *Pedido pertence ao conjunto de métodos do ponteiro, não ao do valor. Onde p é *Pedido, o texto formatado aparece; onde p é um Pedido, como numa cópia tirada de uma fatia ou de um map, o fmt não enxerga o método e imprime os campos crus. Num teste com type T struct{ n int } e String() em *T, fmt.Println(t) imprime {7} e fmt.Println(&t) imprime T(7), e o go vet não avisa de nada. A correção habitual é declarar String() com receiver por valor, que vale tanto para Pedido quanto para *Pedido. É a exceção aceita à regra de não misturar receivers, desde que String() só leia campos e a struct não contenha um mutex, que seria copiado a cada impressão.

Exercício 2

Um servidor registra um handler com http.HandleFunc("/status", srv.Status), passando o método como valor. Explique o que srv.Status guarda nesse momento. E se Status tivesse receiver por valor e o srv fosse alterado depois do registro, o handler veria a alteração?

Ver resposta

✓ Resposta: A expressão srv.Status, sem parênteses, é um method value: uma função que já carrega o receiver, avaliado no momento em que a expressão é escrita. Com receiver de ponteiro, o que fica guardado é o endereço, e o handler enxerga o estado atual do servidor a cada requisição. Com receiver por valor, o que fica guardado é uma cópia tirada naquele instante, e alterações posteriores não chegam ao handler. Num teste com c := C{1}, f := c.Valor (receiver por valor) e depois c.v = 5, a chamada f() devolve 1; já g := c.Inc (receiver de ponteiro) altera o c original, que passa de 5 a 6. É a mesma regra do defer, que avalia os argumentos na hora do registro. Para handlers, que precisam de configuração, conexões e contadores atualizados, o receiver de ponteiro é o que produz o comportamento esperado.

Exercício 3

Um jogo guarda os jogadores em map[string]Jogador, e Jogador tem o método (j *Jogador) PontuarGol(). A linha jogadores["ana"].PontuarGol() não compila, e Jogador{}.PontuarGol() também não. Qual é a mensagem, por que o Go não faz a conversão automática para ponteiro que o artigo descreve, e como resolver?

Ver resposta

✓ Resposta: As duas linhas falham com cannot call pointer method PontuarGol on Jogador. A conversão automática de j.Metodo() para (&j).Metodo() só acontece quando o valor é endereçável, e nenhum dos dois é: um literal é um valor temporário, sem endereço, e o elemento de um map também não tem endereço estável, já que a tabela pode reorganizar a memória ao crescer. Se o Go permitisse, o método alteraria uma cópia provisória e o gol se perderia sem erro, e a proibição evita exatamente isso. Há duas saídas. Uma é guardar ponteiros, com map[string]*Jogador, e aí a chamada funciona e altera o jogador de verdade. A outra é o ciclo de copiar, chamar e gravar: j := jogadores["ana"]; j.PontuarGol(); jogadores["ana"] = j. Com fatias, o problema não aparece, porque jogadores[0].PontuarGol() é endereçável e funciona.

Exercício 4

Querendo formatar datas no padrão brasileiro, alguém tenta escrever func (t time.Time) BR() string e recebe um erro. Tenta então type Data time.Time com o método BR, mas descobre que d.Format(...) deixou de existir. Explique os dois comportamentos e mostre a forma que preserva os métodos de time.Time.

Ver resposta

✓ Resposta: A primeira tentativa esbarra na regra do artigo, a de que só se definem métodos em tipos do próprio pacote. time.Time pertence ao pacote time, e o erro é o mesmo que aparece ao tentar um método em int: cannot define new methods on non-local type. A segunda cria um tipo novo, que herda a estrutura de time.Time, mas não seus métodos: d.Format dá type Data has no field or method Format. Um tipo definido começa com o conjunto de métodos vazio, e é essa a razão de DiaDaSemana, no artigo, poder ter o seu próprio String() sem herdar nada de int. Para acrescentar comportamento sem perder o que já existe, a ferramenta é o embedding: type Evento struct{ time.Time } promove todos os métodos de time.Time (e.Format("2006") compila) e aceita métodos novos, como BR(). A alternativa mais simples, muitas vezes suficiente, é uma função comum, como func DataBR(t time.Time) string, sem tipo novo nenhum.

Exercício 5

O construtor de consultas do artigo, com Da, Onde e Limite encadeados, vai para produção num endpoint de busca, com Onde("nome = '" + termo + "'") montado a partir do que o usuário digita. Aponte os dois problemas desse uso e como o padrão de encadeamento pode acomodar as correções.

Ver resposta

✓ Resposta: O primeiro problema é grave: injeção de SQL. Um termo como x' OR '1'='1 transforma o filtro numa condição sempre verdadeira, e variações do mesmo truque apagam ou leem dados de outras tabelas. O construtor concatena texto cru, e nenhum método encadeado protege contra isso. A correção é separar o texto da condição dos valores: Onde("nome = ?", termo) guarda o valor numa fatia de argumentos, e Construir() devolve o SQL com os marcadores e os argumentos separados, para que o database/sql envie os valores como parâmetros, nunca como parte do comando. O segundo problema é que métodos encadeados não têm onde devolver erro no meio da cadeia, como um Limite(-5) ou uma tabela vazia. O padrão idiomático é acumular o primeiro erro num campo da struct, fazer os métodos seguintes não fazerem nada quando ele já existe, e devolver (sql, args, err) no Construir(). Bibliotecas de construção de consultas em Go costumam seguir exatamente esses dois princípios.

Comentários

Mais em Golang

Testes em Go: testing, tabelas de casos e benchmarks
Testes em Go: testing, tabelas de casos e benchmarks

Testes em Go: arquivos _test.go, Error e Fatal, tabelas de casos com t.Run…

Estudando e se aprofundando em Go
Estudando e se aprofundando em Go

Por que aprender Go hoje: a linguagem de Docker e Kubernetes, o que ela…

Variáveis de ambiente e configuração com Viper
Variáveis de ambiente e configuração com Viper

Configuração em Go com os.LookupEnv, godotenv e Viper: arquivo YAML, variáveis…