Structs: definição, inicialização e campos embutidos

Structs: definição, inicialização e campos embutidos

Structs em Go: literais com nome e posicionais, valor zero, ponteiros, structs anônimas, embedding e a regra de profundidade que decide qual campo é promovido, tags JSON com omitempty e omitzero, campos minúsculos que o JSON ignora e quando duas structs podem ser comparadas com ==.
Golang

• • 14 min de leitura

Go não é uma linguagem orientada a objetos no sentido tradicional. Não existem classes, não existe herança, não existe construtor obrigatório. O que existe é a struct — uma estrutura que agrupa campos relacionados sob um único tipo nomeado. Combinada com métodos, interfaces e composição, a struct é o bloco fundamental com o qual todo design em Go é construído.

Quem vem de Java ou C# pode estranhar a ausência de classes no início, mas rapidamente percebe que structs com métodos oferecem o mesmo poder com menos cerimônia e mais clareza.

Definindo uma struct

A palavra-chave type define um novo tipo nomeado. Combinada com struct, cria um tipo composto com campos:

type Pessoa struct {
    Nome  string
    Idade int
    Email string
}

Por convenção, nomes de tipos em Go usam PascalCase. Campos que começam com letra maiúscula são exportados — visíveis fora do pacote. Campos com letra minúscula são privados ao pacote onde foram definidos.

Inicializando structs

Há três formas de criar um valor do tipo struct.

Literal com nomes de campos — forma recomendada:

p := Pessoa{
    Nome:  "Ricardo",
    Idade: 35,
    Email: "ricardo@exemplo.com",
}

Essa forma é preferida porque é explícita, resistente a mudanças na ordem dos campos e auto-documentada.

Literal posicional — evite em código real:

p := Pessoa{"Ricardo", 35, "ricardo@exemplo.com"}

Funciona, mas amarra o código à ordem dos campos. Se um campo for acrescentado, o literal deixa de compilar (too few values in struct literal), o que é incômodo mas seguro. O perigo real é a reordenação de campos do mesmo tipo: trocar Nome e Email de lugar, ambos string, continua compilando e grava cada valor no campo errado, sem aviso. Use apenas para structs muito pequenas e estáveis, como um Ponto{X, Y}; para tipos de outros pacotes, o go vet já reclama de literal sem nomes de campo.

Declaração com valor zero:

var p Pessoa
fmt.Println(p) // { 0 }

Todos os campos recebem seus valores zero: strings viram "", ints viram 0, bools viram false. Go garante que toda struct é sempre inicializada, nunca contém lixo de memória.

Acessando e modificando campos

O acesso usa a notação de ponto:

p := Pessoa{Nome: "Ana", Idade: 28, Email: "ana@exemplo.com"}

fmt.Println(p.Nome)   // Ana
fmt.Println(p.Idade)  // 28

p.Idade = 29
p.Email = "ana.silva@exemplo.com"

fmt.Println(p) // {Ana 29 ana.silva@exemplo.com}

Structs e ponteiros

Como visto no artigo sobre ponteiros, structs são passadas por valor. Para modificar uma struct dentro de uma função ou evitar cópias custosas, use ponteiros:

func aniversario(p *Pessoa) {
    p.Idade++
}

func main() {
    p := Pessoa{Nome: "Carlos", Idade: 40}
    aniversario(&p)
    fmt.Println(p.Idade) // 41
}

Go permite criar um ponteiro diretamente com o operador & em um literal de struct:

p := &Pessoa{
    Nome:  "Diana",
    Idade: 32,
    Email: "diana@exemplo.com",
}

fmt.Println(p.Nome)  // Diana — Go desreferencia automaticamente

Structs anônimas

Go permite declarar structs sem dar um nome ao tipo. São úteis para dados temporários, testes e respostas JSON pontuais:

endereco := struct {
    Rua    string
    Cidade string
    CEP    string
}{
    Rua:    "Av. Paulista, 1000",
    Cidade: "São Paulo",
    CEP:    "01310-100",
}

fmt.Println(endereco.Cidade) // São Paulo

Campos embutidos: composição em vez de herança

Go não tem herança, mas tem embedding — a capacidade de embutir um tipo dentro de outro. Quando um tipo é embutido, seus campos e métodos são promovidos ao tipo externo, ficando acessíveis como se fossem seus próprios:

type Endereco struct {
    Rua    string
    Cidade string
    Estado string
}

type Empresa struct {
    Nome    string
    CNPJ    string
    Endereco           // campo embutido — sem nome explícito
}

func main() {
    e := Empresa{
        Nome: "Tech Ltda",
        CNPJ: "12.345.678/0001-99",
        Endereco: Endereco{
            Rua:    "Rua das Flores, 42",
            Cidade: "Curitiba",
            Estado: "PR",
        },
    }

    // Acesso promovido — como se fossem campos de Empresa
    fmt.Println(e.Cidade)  // Curitiba
    fmt.Println(e.Estado)  // PR

    // Acesso explícito — também funciona
    fmt.Println(e.Endereco.Rua) // Rua das Flores, 42
}

O campo embutido pode ser acessado pelo nome do tipo (e.Endereco) ou diretamente pelos campos promovidos (e.Cidade). A promoção é apenas sintática — internamente, Cidade ainda pertence a Endereco.

Embedding com múltiplos tipos

Um tipo pode embutir vários outros tipos simultaneamente:

type Auditoria struct {
    CriadoEm     string
    AtualizadoEm string
    CriadoPor    string
}

type Produto struct {
    ID    int
    Nome  string
    Preco float64
    Auditoria
}

func main() {
    p := Produto{
        ID:    1,
        Nome:  "Notebook",
        Preco: 4500.00,
        Auditoria: Auditoria{
            CriadoEm:     "2024-01-15",
            AtualizadoEm: "2024-03-10",
            CriadoPor:    "admin",
        },
    }

    fmt.Println(p.Nome)       // Notebook
    fmt.Println(p.CriadoEm)   // 2024-01-15 — promovido de Auditoria
    fmt.Println(p.CriadoPor)  // admin
}

Esse padrão é muito usado para adicionar campos de auditoria, metadados ou comportamentos reutilizáveis a múltiplos tipos sem duplicação.

Conflito de nomes em embedding

Quando dois tipos embutidos têm campos com o mesmo nome, o acesso pelo nome promovido torna-se ambíguo e o compilador exige o caminho completo:

type A struct{ Nome string }
type B struct{ Nome string }

type C struct {
    A
    B
}

func main() {
    c := C{
        A: A{Nome: "nome de A"},
        B: B{Nome: "nome de B"},
    }

    // fmt.Println(c.Nome) — erro: ambiguous selector c.Nome
    fmt.Println(c.A.Nome) // nome de A
    fmt.Println(c.B.Nome) // nome de B
}

Tags de struct

Go permite adicionar metadados aos campos de uma struct usando tags — strings literais colocadas após o tipo do campo. As tags são ignoradas pelo compilador mas são acessíveis via reflection, e pacotes como encoding/json, gorm e validate as utilizam extensivamente:

type Usuario struct {
    ID    int    `json:"id"`
    Nome  string `json:"nome"`
    Email string `json:"email,omitempty"`
    Senha string `json:"-"`
}

Nesse exemplo:

  • json:"id" instrui o pacote encoding/json a usar "id" como chave no JSON em vez de "ID"
  • omitempty omite o campo do JSON se seu valor for “vazio” — zero, false, string, fatia ou map vazios, ponteiro nil. Struct nunca conta como vazia: um time.Time zerado com omitempty sai como "0001-01-01T00:00:00Z". Para esses casos, o Go 1.24 trouxe omitzero, que omite qualquer valor zero, structs inclusive
  • json:"-" exclui o campo completamente da serialização — útil para campos sensíveis como senhas
import (
    "encoding/json"
    "fmt"
)

func main() {
    u := Usuario{ID: 1, Nome: "Ricardo", Email: ""}

    dados, _ := json.Marshal(u)
    fmt.Println(string(dados))
    // {"id":1,"nome":"Ricardo"}
    // Email omitido (omitempty + valor vazio), Senha excluída
}

Comparando structs

Duas structs do mesmo tipo são comparáveis com == se todos os seus campos forem comparáveis. A comparação verifica campo a campo:

type Ponto struct {
    X, Y int
}

p1 := Ponto{1, 2}
p2 := Ponto{1, 2}
p3 := Ponto{3, 4}

fmt.Println(p1 == p2) // true
fmt.Println(p1 == p3) // false

Structs que contêm slices, maps ou funções não são comparáveis com ==. Para esses casos, use reflect.DeepEqual ou implemente uma função de comparação personalizada — que pode se apoiar em slices.Equal e maps.Equal para os campos que são coleções.

Exemplo prático: modelando um sistema simples

Combinando os conceitos do artigo em um exemplo coeso:

package main

import "fmt"

type Endereco struct {
    Rua    string
    Cidade string
    CEP    string
}

type Cliente struct {
    ID    int
    Nome  string
    Email string
    Endereco
    Ativo bool
}

func novoCliente(id int, nome, email string) *Cliente {
    return &Cliente{
        ID:    id,
        Nome:  nome,
        Email: email,
        Ativo: true,
    }
}

func (c *Cliente) definirEndereco(rua, cidade, cep string) {
    c.Rua    = rua
    c.Cidade = cidade
    c.CEP    = cep
}

func (c Cliente) String() string {
    return fmt.Sprintf("[%d] %s <%s> — %s, %s",
        c.ID, c.Nome, c.Email, c.Rua, c.Cidade)
}

func main() {
    c := novoCliente(1, "Fernanda Lima", "fernanda@exemplo.com")
    c.definirEndereco("Rua XV de Novembro, 500", "Florianópolis", "88010-000")

    fmt.Println(c)
    fmt.Println("Ativo:", c.Ativo)
    fmt.Println("CEP:", c.CEP) // promovido de Endereco
}

A struct é o ponto de partida de quase todo tipo que você vai escrever em Go: agrupa dados, recebe métodos e se compõe com outras por embedding, sem hierarquia de classes. Três hábitos poupam a maior parte dos problemas que ela costuma causar — preferir o literal com nomes de campo ao posicional, lembrar que o nome promovido pode ser escondido por um campo de mesmo nome no tipo externo e tratar as tags como parte do contrato com o mundo de fora, porque é ali que se decide o que entra e o que sai de um JSON.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Uma struct Contato tem os campos Nome string e Email string, e dezenas de testes a criam com literal posicional, como Contato{"Ana", "ana@x.com"}. Numa refatoração, alguém inverte a ordem dos dois campos na declaração. Tudo compila e os testes passam. O que aconteceu com os dados, e por que o compilador não ajudou dessa vez, se ajuda quando um campo é acrescentado?

Ver resposta

✓ Resposta: Cada literal posicional agora grava o nome no campo Email e o e-mail no campo Nome. O literal posicional casa os valores pela ordem, e como os dois campos são string, qualquer ordem é válida para o compilador. Os testes passam porque, na maioria dos casos, conferem algo que não depende do conteúdo, ou porque trocam os valores de um jeito simétrico. Quando um campo é acrescentado, a situação é outra: o número de valores deixa de bater e o compilador recusa com too few values in struct literal, o que força a revisão de cada literal. A lição é que o posicional só é seguro quando nenhum par de campos vizinhos tem o mesmo tipo, ou quando a ordem é parte do significado, como em Ponto{X, Y}. Nos outros casos, o literal com nomes (Contato{Nome: "Ana", Email: "ana@x.com"}) torna a reordenação inofensiva.

Exercício 2

Uma API devolve eventos com o campo CanceladoEm time.Time marcado com json:"cancelado_em,omitempty". Os clientes reclamam que eventos ativos chegam com "cancelado_em":"0001-01-01T00:00:00Z". Por que o omitempty não omitiu, e quais são as duas correções possíveis?

Ver resposta

✓ Resposta: Para o encoding/json, "vazio" é uma lista fechada: zero numérico, false, string, fatia ou map de tamanho zero, ponteiro ou interface nil. Uma struct nunca é considerada vazia, e time.Time é uma struct. Por isso o valor zero, 1º de janeiro do ano 1, é serializado mesmo com omitempty. A primeira correção, disponível desde o Go 1.24, é trocar a opção por omitzero: ela omite qualquer valor zero, structs incluídas, e usa o método IsZero() quando o tipo tem um, como o time.Time. Num teste com os dois campos lado a lado, o de omitempty aparece com a data zero e o de omitzero some. A segunda correção, que funciona em versões mais antigas, é declarar o campo como *time.Time: o ponteiro nil é vazio para o omitempty, e a ausência de data fica explícita no tipo. A segunda ainda tem uma vantagem própria: distingue "não cancelado" de "cancelado na data zero", o que a primeira não faz.

Exercício 3

A struct Cliente tem um campo ID e embute Endereco, que também tem um campo ID, o do endereço no banco. Um colega espera que c.ID seja rejeitado como ambíguo, como no exemplo das structs A e B do artigo. O compilador aceita. Qual ID é devolvido, e por que esse caso é diferente?

Ver resposta

✓ Resposta: É devolvido o ID do próprio Cliente. Com Cliente{ID: 1, Endereco: Endereco{ID: 99, Cidade: "Recife"}}, c.ID vale 1, c.Endereco.ID vale 99 e c.Cidade, que não tem concorrente, é promovido normalmente. A regra de promoção olha a profundidade: um campo declarado diretamente na struct, na profundidade zero, esconde qualquer campo de mesmo nome que venha de um tipo embutido, que está na profundidade um. A ambiguidade só existe quando dois candidatos estão na mesma profundidade, como A.Nome e B.Nome, ambos embutidos em C; nesse caso o compilador acusa ambiguous selector c.Nome. O risco prático é o sombreamento silencioso: se Cliente ganhar um campo Cidade no futuro, todo c.Cidade do código passa a ler o novo campo, sem nenhum erro. Por isso, quando os nomes podem colidir, o acesso explícito, como c.Endereco.ID, é a escolha mais segura.

Exercício 4

Um desenvolvedor quer evitar cadastros duplicados comparando structs com ==. Funciona com Ponto{X, Y int}, mas, quando a struct Produto ganha o campo Tags []string, o código deixa de compilar. Qual é a mensagem, e como comparar produtos a partir daí?

Ver resposta

✓ Resposta: O compilador recusa com invalid operation: a == b (struct containing []string cannot be compared). Como o artigo explica, uma struct só é comparável se todos os seus campos forem, e fatias não são. A consequência vai além do ==: a struct também deixa de servir como chave de map, o que quebra qualquer deduplicação por map[Produto]bool. Há três caminhos. O primeiro é uma função Igual(a, b Produto) bool que compara os campos simples com == e as tags com slices.Equal. O segundo é reflect.DeepEqual, que funciona com qualquer estrutura mas é mais lento e tem surpresas, como tratar fatia nil e fatia vazia como diferentes. O terceiro, muitas vezes o melhor, é perguntar o que define a identidade do produto: se for o código de barras ou o SKU, a deduplicação deve usar esse campo como chave, e a igualdade da struct inteira nem entra em cena.

Exercício 5

Uma struct Usuario tem os campos Nome, senha e idade, estes dois com inicial minúscula "para ficarem privados". A API passa a devolver só o nome, e um cadastro recebido com {"Nome":"Bia","idade":40} fica com idade 0. Não há erro em lugar nenhum. O que está acontecendo?

Ver resposta

✓ Resposta: O encoding/json só enxerga campos exportados, porque trabalha por reflexão de fora do pacote, e a regra de visibilidade vale para ele também. Campos com inicial minúscula são ignorados nos dois sentidos, e de forma silenciosa: json.Marshal de Usuario{"Ana", "segredo", 30} produz {"Nome":"Ana"} sem erro, e json.Unmarshal do exemplo preenche Nome com "Bia", deixa idade em 0 e devolve nil como erro. Nem o go vet aponta o problema. A intenção de esconder a senha estava certa, mas pelo mecanismo errado: um campo minúsculo também some da entrada, e não só da saída. A forma idiomática é exportar os campos e controlar a serialização com tags: Idade int `json:"idade"` para os campos que trafegam e Senha string `json:"-"` para o que nunca deve sair, como o artigo mostra. Melhor ainda é ter uma struct de resposta separada da struct interna, para que um campo novo não vaze por padrão.

Comentários

Mais em Golang

Select, timeouts e cancelamento de operações
Select, timeouts e cancelamento de operações

Select, timeouts e cancelamento em Go: select como event loop, time.After e os…

O pacote context: propagação de cancelamento e prazos
O pacote context: propagação de cancelamento e prazos

O pacote context em Go: Background e TODO, WithCancel, WithTimeout e…

Go Modules: go.mod, go.sum e gerenciamento de dependências
Go Modules: go.mod, go.sum e gerenciamento de dependências

Go Modules: go.mod e go.sum, go get e go mod tidy, versionamento semântico e o…