Ponteiros são um dos tópicos que mais intimidam iniciantes vindos de linguagens como Python, JavaScript ou Java — onde o programador raramente precisa pensar em endereços de memória. Em C e C++, ponteiros são poderosos mas perigosos: aritmética de ponteiros, ponteiros soltos e corrupção de memória são problemas reais.
Go encontra um meio-termo deliberado. Ponteiros existem e são necessários, mas a aritmética de ponteiros não é permitida, e o garbage collector cuida da memória automaticamente. O resultado é um sistema de ponteiros seguro e útil, sem as armadilhas clássicas do C.
O que é um ponteiro
Toda variável em um programa ocupa um espaço na memória, e esse espaço tem um endereço — um número que identifica sua localização. Um ponteiro é simplesmente uma variável que armazena esse endereço em vez de um valor direto.
Em Go, o tipo de um ponteiro para int é escrito como *int. O tipo de um ponteiro para string é *string. A regra é sempre *Tipo.
Dois operadores são fundamentais para trabalhar com ponteiros:
&— operador de endereço: obtém o endereço de uma variável*— operador de desreferência: acessa o valor no endereço armazenado
package main
import "fmt"
func main() {
x := 42
p := &x // p é um ponteiro para x; seu tipo é *int
fmt.Println(x) // 42 — valor de x
fmt.Println(p) // 0xc000... — endereço de x
fmt.Println(*p) // 42 — valor no endereço apontado por p
*p = 100 // modifica x através do ponteiro
fmt.Println(x) // 100
}
Alterar *p altera x diretamente, pois ambos apontam para o mesmo espaço na memória.
O valor zero de um ponteiro
O valor zero de qualquer tipo ponteiro em Go é nil. Um ponteiro nil não aponta para nenhum endereço válido. Tentar desreferenciar um ponteiro nil causa um pânico em tempo de execução:
var p *int
fmt.Println(p) // <nil>
fmt.Println(*p) // PANIC: runtime error: invalid memory address or nil pointer dereference
Sempre verifique se um ponteiro é nil antes de desreferenciá-lo quando sua origem não é garantida:
func imprimirValor(p *int) {
if p == nil {
fmt.Println("ponteiro nulo, nada a imprimir")
return
}
fmt.Println(*p)
}
Por que ponteiros existem: passagem por valor vs por referência
Em Go, todos os argumentos de função são passados por valor — o que significa que a função recebe uma cópia do argumento, não o original. Modificações dentro da função não afetam a variável original:
package main
import "fmt"
func tentarDobrar(n int) {
n = n * 2 // modifica apenas a cópia local
}
func main() {
x := 10
tentarDobrar(x)
fmt.Println(x) // 10 — x não foi alterado
}
Para que a função modifique o valor original, é necessário passar um ponteiro:
func dobrar(n *int) {
*n = *n * 2 // modifica o valor no endereço recebido
}
func main() {
x := 10
dobrar(&x)
fmt.Println(x) // 20 — x foi modificado
}
Ponteiros com structs
O uso mais comum de ponteiros em Go é com structs. Passar uma struct grande por valor copia todos os seus campos — o que pode ser custoso. Passar um ponteiro para a struct é mais eficiente e permite que a função modifique os campos originais:
package main
import "fmt"
type Retangulo struct {
Largura float64
Altura float64
}
func escalar(r *Retangulo, fator float64) {
r.Largura *= fator
r.Altura *= fator
}
func area(r *Retangulo) float64 {
return r.Largura * r.Altura
}
func main() {
r := Retangulo{Largura: 5.0, Altura: 3.0}
fmt.Println(area(&r)) // 15
escalar(&r, 2.0)
fmt.Println(area(&r)) // 60
}
Go oferece uma conveniência importante: ao acessar campos de uma struct através de um ponteiro, não é necessário desreferenciar manualmente. Em vez de escrever (*r).Largura, Go permite simplesmente r.Largura — o compilador faz a desreferência automaticamente.
Criando ponteiros com new
Além do operador &, Go oferece a função embutida new para alocar memória para um tipo e retornar um ponteiro para ela. O valor alocado é inicializado com o valor zero do tipo:
p := new(int) // aloca um int zerado, retorna *int
fmt.Println(*p) // 0
*p = 99
fmt.Println(*p) // 99
Na prática, new é menos comum do que o uso de & com um literal de struct. As duas formas abaixo são equivalentes:
// Com new
r1 := new(Retangulo)
r1.Largura = 5.0
// Com literal e &
r2 := &Retangulo{Largura: 5.0}
A segunda forma é preferida pela comunidade Go por ser mais explícita sobre o valor inicial.
Ponteiros e interfaces
Um padrão importante que aparecerá com frequência nos módulos seguintes é a distinção entre implementar uma interface com um receiver de valor versus um receiver de ponteiro. Por ora, é suficiente saber que um método com receiver de ponteiro pode ser chamado direto numa variável comum — em c.Inc(), o compilador toma o endereço de c sozinho —, mas ele não conta quando o valor é guardado numa interface. Se Inc tem receiver *Contador, a atribuição var i Incrementavel = c é recusada com method Inc has pointer receiver, e é preciso passar &c.
Esse tópico será explorado em detalhe no artigo sobre métodos e interfaces.
Quando usar ponteiros
A decisão de usar ou não ponteiros segue algumas diretrizes práticas que a comunidade Go consolidou ao longo dos anos.
Use ponteiros quando:
A função precisa modificar o valor original do argumento. Structs grandes são passadas como argumento com frequência e o custo de cópia importa. O tipo representa um recurso com identidade única — como uma conexão de banco de dados, um arquivo aberto ou um mutex — onde cópias não fazem sentido semântico. O valor pode ser nil para representar ausência.
Prefira valores quando:
O tipo é pequeno — inteiros, floats, bools, structs com poucos campos simples. A função não precisa modificar o argumento. O código fica mais simples e legível sem ponteiros. Tipos como time.Time e netip.Addr, por exemplo, são feitos para circular por valor. Outros, como url.URL, circulam como ponteiro — o url.Parse devolve *url.URL —, e a própria assinatura da biblioteca costuma indicar a forma esperada.
// Preferir por valor — tipo pequeno, sem necessidade de modificação
func formatarNome(nome string) string {
return strings.TrimSpace(nome)
}
// Preferir por ponteiro — struct grande, função modifica o estado
func (s *Servidor) iniciar(porta int) error {
s.porta = porta
s.ativo = true
return s.escutar()
}
O que Go não permite: aritmética de ponteiros
Em C, é possível incrementar um ponteiro para navegar por posições consecutivas na memória. Em Go, isso é proibido:
x := 10
p := &x
p++ // erro de compilação: invalid operation: p++ (non-numeric type *int)
Essa restrição elimina uma classe inteira de bugs de memória. O pacote unsafe da biblioteca padrão oferece operações de ponteiro de baixo nível para casos extremamente específicos, mas seu nome é um aviso explícito — seu uso em código de aplicação comum é fortemente desencorajado.
Exemplo prático: lista encadeada simples
Para consolidar o conceito, abaixo está uma implementação simples de lista encadeada — uma estrutura de dados que depende fundamentalmente de ponteiros para existir:
package main
import "fmt"
type No struct {
Valor int
Proximo *No
}
func imprimirLista(inicio *No) {
for n := inicio; n != nil; n = n.Proximo {
fmt.Printf("%d ", n.Valor)
}
fmt.Println()
}
func main() {
terceiro := &No{Valor: 30, Proximo: nil}
segundo := &No{Valor: 20, Proximo: terceiro}
primeiro := &No{Valor: 10, Proximo: segundo}
imprimirLista(primeiro) // 10 20 30
}
Cada nó armazena um ponteiro para o próximo nó. O último nó aponta para nil, sinalizando o fim da lista. Esse padrão de *No dentro da própria struct No é possível porque o Go sabe que um ponteiro tem tamanho fixo, independentemente do tipo para o qual aponta.
Ponteiros em Go fazem o que se espera deles — compartilhar um dado em vez de copiá-lo — sem aritmética e sem o risco de apontar para memória que já foi liberada, porque a análise de escape leva para o heap tudo o que sobrevive à função. Na hora de escolher, a pergunta útil é menos "qual é mais rápido" e mais "este valor pode ser copiado sem perder o sentido?": um time.Time pode, uma struct com sync.Mutex não pode, e o go vet avisa quando a cópia acontece por engano.
Fontes e leituras recomendadas
-
Especificação da linguagem Go — Pointer types — Definição formal do tipo ponteiro. https://go.dev/ref/spec#Pointer_types
-
A Tour of Go — Pointers — Introdução interativa ao sistema de ponteiros. https://go.dev/tour/moretypes/1
-
Go by Example: Pointers — Exemplos práticos comentados. https://gobyexample.com/pointers
-
Effective Go — Allocation with new — Discussão sobre new e make na alocação de memória. https://go.dev/doc/effective_go#allocation_new
-
Understanding Pointers in Go — DigitalOcean — Tutorial detalhado com diagramas de memória. https://www.digitalocean.com/community/tutorials/understanding-pointers-in-go
-
Go Data Structures — Russ Cox — Artigo técnico sobre como Go representa tipos na memória. https://research.swtch.com/godata
Exercícios
Exercício 1
Um programador vindo do C revisa a função func novo() *int { x := 42; return &x } e marca como bug grave: "devolve o endereço de uma variável local, que deixa de existir quando a função retorna". Em C ele estaria certo. E em Go?
Ver resposta
✓ Resposta: Em Go o código é correto e idiomático, e *novo() vale 42 com segurança. O compilador faz análise de escape: ao perceber que o endereço de x sai da função, ele aloca x no heap em vez da pilha, e o garbage collector o mantém vivo enquanto houver ponteiro para ele. Dá para ver a decisão com go build -gcflags=-m, que imprime moved to heap: x exatamente para essa variável. É por isso que construtores como func NovoServidor() *Servidor { return &Servidor{...} } são o padrão da linguagem. O custo que sobra é de desempenho, não de correção: alocação no heap é mais cara que na pilha e dá trabalho ao coletor. Em código muito quente vale olhar a saída do -m; no resto, a regra é escrever o que é claro e deixar o compilador decidir onde a variável mora.
Exercício 2
O artigo diz que tudo em Go é passado por valor. Uma função mexe(s []int) faz s[0] = 99, depois s = append(s, 4) e depois s[1] = 77. Chamada com uma fatia [1 2 3] criada por literal, o que o chamador vê depois? Por que só uma das três alterações chega até ele?
Ver resposta
✓ Resposta: O chamador vê [99 2 3], com tamanho 3. A fatia é passada por valor, sim, mas o valor de uma fatia é um pequeno cabeçalho com um ponteiro para o array, o tamanho e a capacidade. A cópia aponta para o mesmo array, então s[0] = 99 altera um dado que o chamador também enxerga. O append encontra capacidade 3 já ocupada, aloca um array novo, copia os elementos e devolve um cabeçalho que aponta para ele; a partir daí o s local e o do chamador estão separados. O s[1] = 77 altera o array novo, e o 4 acrescentado também fica só lá, porque o tamanho do chamador continua 3. O comportamento depende da capacidade, e é isso que o torna traiçoeiro: com espaço sobrando, o append escreveria no mesmo array. A regra prática é que uma função que precisa aumentar uma fatia deve devolvê-la, como o próprio append faz, e o chamador deve reatribuir: s = mexe(s).
Exercício 3
Na lista encadeada do artigo, um colega quer um método Tamanho() que funcione também para a lista vazia, representada por um *No nulo. Ele acha impossível: "chamar método em ponteiro nil dá pânico". Como escrever o método, e em que condição o pânico de fato aconteceria?
Ver resposta
✓ Resposta: Com receiver de ponteiro e uma checagem de nil na primeira linha: func (n *No) Tamanho() int { if n == nil { return 0 }; return 1 + n.prox.Tamanho() }. Chamar um método num ponteiro nulo não causa pânico por si só: o método é só uma função que recebe o ponteiro como primeiro argumento, e nil é um argumento válido. Num teste, var lista *No seguido de lista.Tamanho() devolve 0, e uma lista de dois nós devolve 2, com a recursão terminando justamente no nil do último nó. O pânico só aparece quando o método tenta acessar um campo pelo ponteiro nulo, como n.v sem a checagem, e aí vem o nil pointer dereference. Com receiver de valor seria diferente: lista.Tamanho() precisaria copiar o *lista antes da chamada e entraria em pânico ali mesmo. Esse "nil que funciona" é um padrão aceito em Go para estruturas recursivas, e evita espalhar if lista != nil por todo o código que as usa.
Exercício 4
Uma struct Conta tem um campo mu sync.Mutex para proteger o saldo. Uma função auxiliar de relatório recebe c Conta por valor, "já que só vai ler". O programa compila e roda. O que o go vet diz, e por que essa é uma das situações em que o ponteiro é obrigatório, e não mera otimização?
Ver resposta
✓ Resposta: O go vet acusa as duas pontas: passes lock by value: x.Conta contains sync.Mutex na declaração da função e call of mostrar copies lock value na chamada. Passar a Conta por valor copia o mutex junto com o resto, e o mutex copiado é outro cadeado, independente do original. Se a cópia for feita enquanto outra goroutine segura o lock, ela nasce trancada, e a leitura do saldo sai de uma cópia sem nenhuma proteção. É o caso que o artigo descreve como "recurso com identidade única, em que cópias não fazem sentido semântico": um mutex só funciona se todo mundo usar o mesmo. A correção é receber *Conta e, dentro da função, travar c.mu antes de ler. Vale o mesmo para sync.WaitGroup, sync.Once e qualquer struct que contenha um deles, e é por isso que o go vet, que roda junto com o go test, merece estar no CI.
Exercício 5
Uma API de busca recebe em JSON um campo limite, e a regra é: ausente significa "use o padrão, 20"; 0 significa "só a contagem, sem itens". Com o campo declarado como Limite int, as duas situações ficam iguais. Como os ponteiros resolvem, e qual é o custo dessa solução?
Ver resposta
✓ Resposta: Com int, a ausência vira o valor zero e é indistinguível de um 0 enviado de propósito. Declarando Limite *int com a tag json:"limite", o json.Unmarshal deixa o ponteiro nil quando o campo não vem e aponta para um 0 quando vem {"limite":0}. Num teste, o primeiro caso dá Limite == nil e o segundo dá um ponteiro não nulo com valor 0, e o código decide: nil vira 20, qualquer valor presente é respeitado. É a aplicação direta da regra do artigo, a de usar ponteiro quando "o valor pode ser nil para representar ausência". O custo é que toda leitura passa a exigir a checagem de nil, e esquecê-la troca um erro de regra de negócio por um pânico. Por isso a prática comum é resolver a ausência numa camada só, logo depois do decode, convertendo para uma struct interna de campos comuns com os padrões já aplicados, em vez de deixar o *int circular pelo sistema.