Los structs de Go son valores, así que cada asignación y cada llamada los copia. Los punteros te dejan compartir un solo struct, y los receptores puntero son la forma en que un método cambia el valor sobre el que se llama.
Un struct agrupa valores relacionados bajo un nombre, y los métodos le dan comportamiento a ese grupo. La mayoría de los tipos reales de Go son structs, así que aquí empiezan tus propios tipos.
Lo que confunde a la gente no es la sintaxis. Es que un struct es un valor, y Go copia valores sin pensarlo dos veces. Este post muestra dónde ocurren las copias, cómo los punteros las evitan y cómo eso decide qué tipo de método escribir. Cada programa de abajo se ejecutó en Go 1.26, y su salida está copiada de esa ejecución.
Definir un struct
Un tipo struct lista campos con nombre, cada uno con su propio tipo. Construyes un valor de ese tipo con un literal compuesto:
package main
import "fmt"
type User struct {
Name string
Email string
Age int
}
func main() {
var zero User
ada := User{Name: "Ada", Age: 36}
bob := User{"Bob", "bob@example.com", 41}
fmt.Printf("%+v\n", zero)
fmt.Printf("%+v\n", ada)
fmt.Printf("%+v\n", bob)
ada.Email = "ada@example.com"
fmt.Println(ada.Name, ada.Email)
}
Imprime:
{Name: Email: Age:0}
{Name:Ada Email: Age:36}
{Name:Bob Email:bob@example.com Age:41}
Ada ada@example.com
var zero User da un struct con cada campo en su valor cero: dos strings vacíos y 0. Nunca obtienes un struct a medio construir con basura adentro.
ada usa campos con nombre. Cualquier campo que dejes fuera recibe su valor cero, así que Email es "". bob usa campos posicionales: los valores van en el orden en que se declaran los campos, y tienes que darlos todos.
Prefiere los campos con nombre. Con los posicionales, agregar un campo a User rompe cada literal, e intercambiar dos campos string por error compila sin decir nada. go vet incluso avisa sobre literales posicionales de tipos de otros paquetes. Lees y escribes campos con un punto, como en ada.Email.
Structs anónimos
Un tipo struct no necesita nombre. Para una forma que usas una sola vez, puedes declararlo y llenarlo de una vez:
package main
import "fmt"
func main() {
point := struct {
X, Y int
}{X: 3, Y: 4}
fmt.Printf("%+v\n", point)
}
Imprime:
{X:3 Y:4}
Los vas a ver en tests, como tabla de casos, y para decodificar un poco de JSON que solo necesitas una vez. Cualquier cosa que pases de un lado a otro merece un tipo con nombre.
Los structs son valores, y los valores se copian
Asignar un struct copia cada campo, y pasarlo a una función también:
package main
import "fmt"
type User struct {
Name string
Age int
}
func birthday(u User) {
u.Age++
fmt.Println("inside:", u.Age)
}
func main() {
ada := User{Name: "Ada", Age: 36}
copyOfAda := ada
copyOfAda.Name = "Not Ada"
birthday(ada)
fmt.Println(ada.Name, ada.Age)
fmt.Println(copyOfAda.Name)
}
Imprime:
inside: 37
Ada 36
Not Ada
copyOfAda es un segundo User, separado. Cambiarle el nombre no tocó ada. birthday también recibió su propia copia, así que le sumó un año a la copia, imprimió 37 y descartó la copia al terminar. El ada de quien llama sigue en 36.
Es la misma regla que viste para los arrays en la parte sobre slices. No es un caso especial de los structs: en Go, cada asignación y cada argumento es una copia. Lo que cambia es qué se copia. En un struct, son todos sus campos.
La misma copia se esconde en un bucle range, donde causa un bug silencioso:
package main
import "fmt"
type Player struct {
Name string
Score int
}
func main() {
team := []Player{{Name: "Ada", Score: 10}, {Name: "Bob", Score: 20}}
for _, p := range team {
p.Score += 5 // changes a copy
}
fmt.Println(team)
for i := range team {
team[i].Score += 5 // changes the element
}
fmt.Println(team)
}
Imprime:
[{Ada 10} {Bob 20}]
[{Ada 15} {Bob 25}]
En el primer bucle, p es una copia de cada elemento, así que los puntajes no se mueven. El segundo bucle indexa el slice y cambia los elementos reales.
Punteros: compartir en lugar de copiar
Un puntero guarda la dirección de un valor, y eso deja que dos partes de un programa trabajen sobre el mismo struct. &x te da la dirección de x, y *p te da el valor al que apunta p:
package main
import "fmt"
type User struct {
Name string
Age int
}
func birthday(u *User) {
u.Age++
}
func main() {
ada := User{Name: "Ada", Age: 36}
p := &ada
fmt.Println((*p).Age, p.Age)
birthday(p)
birthday(&ada)
fmt.Println(ada.Age)
q := p
q.Name = "Ada L."
fmt.Println(ada.Name, p == q)
}
Imprime:
36 36
38
Ada L. true
El tipo *User significa «puntero a un User». p := &ada guarda la dirección de ada en p.
(*p).Age sigue el puntero y lee el campo. Nadie lo escribe así, porque Go lo hace por ti: p.Age sobre un puntero a un struct significa exactamente lo mismo.
Ahora birthday recibe un *User. Sigue recibiendo una copia de su argumento, pero la copia es una dirección, y las dos direcciones llevan a ada. Dos llamadas, dos cumpleaños, y ada.Age queda en 38.
q := p copia el puntero, no el struct. Asignar q.Name cambió ada, y p == q es true porque los dos punteros guardan la misma dirección.
Explicado como si tuvieras diez años
Un struct es una casa. Un puntero es un papelito con la dirección de la casa escrita.
Si fotocopias el papelito, tienes dos papelitos. No tienes dos casas. Los dos papelitos llevan a la misma puerta, así que si un amigo sigue el suyo y pinta la puerta de rojo, vas a encontrar una puerta roja cuando sigas el tuyo.
Pasar un struct sin puntero es distinto. Es como construir una copia de toda la casa, ladrillo por ladrillo, y entregar esa. Tu amigo puede pintar esa puerta del color que quiera. Tu casa no cambia.
La versión precisa
Un valor puntero es una dirección de memoria, con el tipo de lo que apunta. Un *User tiene el mismo tamaño, una palabra de máquina, sin importar qué tan grande sea User. Copiar un puntero copia esa palabra, y después las dos copias se refieren a la misma variable.
& toma la dirección de un valor direccionable: una variable, un campo de una variable o un elemento de un slice. Un literal compuesto como &User{} también está permitido, como caso especial. * desreferencia. Para acceder a campos y llamar métodos, Go desreferencia automáticamente un puntero a un struct.
Dónde falla la analogía: las direcciones reales se pueden deducir. La casa de al lado tiene el número siguiente. Go no tiene aritmética de punteros, así que no puedes pasar de una dirección a la siguiente; solo puedes seguir el puntero que te dieron. Y una casa real se puede demoler mientras la gente todavía tiene su dirección. En Go, el recolector de basura mantiene vivo el valor mientras exista algún puntero a él. Por eso una función puede devolver sin peligro un puntero a una de sus propias variables locales. La parte sobre memoria explica dónde viven esos valores.
new, y una novedad de Go 1.26
new(T) reserva un valor cero de tipo T y devuelve un puntero a él. Desde Go 1.26 también acepta una expresión, y te da un puntero a una variable nueva que guarda ese valor:
package main
import "fmt"
type Config struct {
Retries int
Debug *bool
}
func main() {
n := new(int)
fmt.Println(*n)
*n = 7
fmt.Println(*n)
cfg := Config{Retries: 3, Debug: new(true)}
fmt.Println(cfg.Retries, *cfg.Debug)
}
Imprime:
0
7
3 true
new(int) apunta a un 0 nuevo. new(true) es la forma nueva. Antes de 1.26 no podías escribir &true, así que necesitabas una variable temporal o una pequeña función auxiliar solo para obtener un *bool. Los campos opcionales como Debug aparecen mucho en configuración y JSON, donde un puntero distingue «no definido» (nil) de false.
Para structs, vas a seguir escribiendo casi siempre &User{Name: "Ada"}. Hace el mismo trabajo que new y llena los campos al mismo tiempo.
Punteros nil
El valor cero de un puntero es nil, que significa que no apunta a nada. Seguir un puntero nil detiene el programa:
package main
import "fmt"
type User struct {
Name string
}
func main() {
var p *User
fmt.Println(p == nil)
fmt.Println(p.Name)
}
Imprime la primera línea y se detiene:
true
panic: runtime error: invalid memory address or nil pointer dereference
p.Name es en realidad (*p).Name, y no hay ningún *p que leer. Go no devuelve un valor cero aquí, y tampoco lee memoria al azar. Hace panic.
Es el panic más común en los programas de Go. Cuando una función puede devolver un puntero nil, verifícalo antes de usarlo. La forma habitual es el patrón value, err: una función que devuelve un puntero nil también devuelve un error que no es nil, y tú revisas el error primero.
Métodos
Un método es una función con un receptor, el valor sobre el que se llama. El receptor va entre paréntesis antes del nombre del método, y puede ser un valor o un puntero. Esa elección decide si el método puede cambiar algo:
package main
import "fmt"
type Counter struct {
count int
}
func (c Counter) IncValue() {
c.count++
}
func (c *Counter) IncPtr() {
c.count++
}
func main() {
var c Counter
c.IncValue()
fmt.Println("after IncValue:", c.count)
c.IncPtr()
fmt.Println("after IncPtr:", c.count)
}
Imprime:
after IncValue: 0
after IncPtr: 1
Los dos métodos tienen la misma línea, c.count++. Solo uno funcionó. Un receptor es solo un parámetro, así que se copia como cualquier otro. Mira qué recibe realmente cada método.
El programa Counter de arriba. IncValue tiene un receptor valor, así que trabaja sobre una copia de c: el count de la copia pasa a 1 y se descarta, y el programa imprime 0. IncPtr tiene un receptor puntero, así que count++ pasa por el puntero hasta el propio c, y el programa imprime 1.
Estos son los mismos pasos en palabras, por si la animación no se reproduce:
var c Countercrea un contador enmainconcounten 0.c.IncValue()copiacen el receptor.count++cambia la copia, así que el count de la copia es 1.- El método termina y la copia desaparece.
csigue con count 0, y el programa imprimeafter IncValue: 0. c.IncPtr()le da al método un puntero ac.count++sigue el puntero y cambia el propioc.- El método termina.
cconserva count 1, y el programa imprimeafter IncPtr: 1.
Un receptor valor es un método que dice «dame una fotocopia». Un receptor puntero dice «dame la dirección». Si un método necesita cambiar su receptor, necesita un receptor puntero.
Go toma la dirección por ti
Llamaste a c.IncPtr() sobre c, un Counter común, y no sobre un *Counter. Funcionó porque Go reescribe la llamada por ti:
package main
import "fmt"
type Counter struct {
count int
}
func (c *Counter) Inc() {
c.count++
}
func (c Counter) Get() int {
return c.count
}
func main() {
c := Counter{}
c.Inc() // Go writes (&c).Inc() for you
p := &c
p.Inc()
fmt.Println(p.Get()) // and (*p).Get() here
}
Imprime:
2
Cuando c es una variable e Inc quiere un puntero, Go toma &c. Cuando p es un puntero y Get quiere un valor, Go usa *p. Así que en el código de todos los días llamas a los métodos de la misma forma, sea cual sea el receptor.
La reescritura solo funciona cuando hay una dirección que tomar. Un valor que no está guardado en una variable no tiene dirección:
package main
type Counter struct {
count int
}
func (c *Counter) Inc() {
c.count++
}
func main() {
Counter{}.Inc()
}
La compilación falla con:
./main.go:12:12: cannot call pointer method Inc on Counter
Counter{} es un valor temporal, no una variable, así que no hay nada a lo que Inc pueda apuntar. Guárdalo primero en una variable, o escribe (&Counter{}).Inc(). Por el mismo límite no puedes llamar un método puntero directamente sobre un elemento de un map. Va a importar otra vez en la parte sobre interfaces, donde decide qué tipos satisfacen una interfaz.
Cuándo usar un receptor puntero
La regla práctica es corta. Usa un receptor puntero cuando se cumpla cualquiera de estas:
- El método cambia el receptor. Esta no admite discusión. Un receptor valor cambia una copia.
- El struct es grande. Un receptor valor copia cada campo en cada llamada. Un puntero es una palabra. «Grande» no tiene un límite exacto; unos pocos campos pequeños están bien como valor.
- El tipo no se puede copiar sin peligro. Un struct que contiene un
sync.Mutex, por ejemplo, no se debe copiar.go vetreporta esas copias. La parte sobresyncexplica por qué. - Otros métodos del tipo ya usan receptores puntero. Mantén todo el tipo consistente: o todos receptores puntero, o todos receptores valor.
Los receptores valor van bien con tipos pequeños que se comportan como valores, como un Point o un monto Money, donde cada método solo lee y una copia es barata. Si no estás seguro, usa un receptor puntero. Es la elección que hace la mayoría del código Go para los structs.
Incrustación: campos y métodos de otro struct
Go no tiene herencia. En su lugar, un struct puede incrustar otro tipo listándolo sin nombre de campo, y los campos y métodos del tipo incrustado se promueven al struct de afuera:
package main
import "fmt"
type Address struct {
City string
}
func (a Address) Label() string {
return "lives in " + a.City
}
type Customer struct {
Name string
Address
}
func main() {
c := Customer{
Name: "Ada",
Address: Address{City: "London"},
}
fmt.Println(c.City)
fmt.Println(c.Label())
fmt.Println(c.Address.City)
}
Imprime:
London
lives in London
London
c.City y c.Label() funcionan como si Customer los declarara él mismo. Por debajo, el valor incrustado sigue siendo un campo común con el nombre de su tipo, Address, y por eso c.Address.City también funciona.
Esto es composición, no herencia. Un Customer contiene un Address; no es uno. No puedes pasar un Customer donde una función quiere un Address. Y cuando Label se ejecuta, su receptor es el Address de adentro. No sabe nada del Customer que lo rodea. Si Customer declara su propio Label, ese gana, y el incrustado sigue ahí como c.Address.Label().
Comparar structs, y las etiquetas de struct
Puedes comparar dos structs con == cuando todos sus campos son comparables, y la comparación es campo por campo:
package main
import "fmt"
type Point struct {
X, Y int
}
func main() {
a := Point{X: 1, Y: 2}
b := Point{X: 1, Y: 2}
pa, pb := &a, &b
fmt.Println(a == b)
fmt.Println(pa == pb, *pa == *pb)
seen := map[Point]bool{a: true}
fmt.Println(seen[Point{1, 2}])
}
Imprime:
true
false true
true
a == b es verdadero porque los dos campos coinciden. Los punteros, en cambio, comparan direcciones. pa y pb apuntan a dos variables distintas, así que pa == pb es falso aunque los valores a los que apuntan sean iguales. Los structs comparables también sirven como claves de un map, lo que es útil para coordenadas y claves compuestas.
Agrega un campo que no se puede comparar, como un slice, y == deja de compilar:
package main
import "fmt"
type Tagged struct {
Name string
Tags []string
}
func main() {
a := Tagged{Name: "x"}
b := Tagged{Name: "x"}
fmt.Println(a == b)
}
La compilación falla con:
./main.go:13:14: invalid operation: a == b (struct containing []string cannot be compared)
Los slices, los maps y las funciones no se pueden comparar con ==, y tampoco un struct que contenga uno. Esos campos los comparas tú, por ejemplo con slices.Equal.
Los campos también pueden llevar una etiqueta (tag), un string después del tipo, como en Email string `json:"email"`. El lenguaje ignora las etiquetas. Los paquetes las leen, y encoding/json las usa para nombrar los campos en JSON. Las partes sobre construir una API REST las usan mucho.
Los constructores son solo funciones llamadas NewX
Go no tiene constructores, así que por convención una función llamada NewX construye un X y devuelve un puntero a él:
package main
import (
"errors"
"fmt"
)
type Account struct {
Owner string
balance int
}
func NewAccount(owner string) (*Account, error) {
if owner == "" {
return nil, errors.New("owner is required")
}
return &Account{Owner: owner}, nil
}
func (a *Account) Deposit(amount int) {
a.balance += amount
}
func (a *Account) Balance() int {
return a.balance
}
func main() {
acc, err := NewAccount("Ada")
if err != nil {
fmt.Println(err)
return
}
acc.Deposit(50)
acc.Deposit(25)
fmt.Println(acc.Owner, acc.Balance())
_, err = NewAccount("")
fmt.Println(err)
}
Imprime:
Ada 75
owner is required
NewAccount revisa su entrada antes de que exista nada, y devuelve un error en lugar de una cuenta rota. Devuelve &Account{...}, la dirección de un valor que acaba de construir. Eso es seguro: como decía la analogía de la casa, el valor sigue vivo mientras exista un puntero a él.
Fíjate que balance empieza con minúscula. Los nombres en minúscula son privados del paquete, así que el código de otros paquetes tiene que pasar por Deposit y Balance. La parte sobre paquetes cubre esa regla. Todos los métodos aquí tienen receptor puntero, incluido Balance, que solo lee, porque los otros métodos del tipo lo necesitan.
Escribe un NewX cuando un struct necesita validación o preparación. Cuando el valor cero ya es útil, como el Counter de arriba, sáltatelo y deja que la gente escriba var c Counter.
Qué recordar
- Un struct es un valor. Asignarlo, pasarlo y recorrer con range un slice de ellos hacen copias.
- Prefiere campos con nombre en los literales de struct. Los campos que faltan reciben su valor cero.
- Un puntero guarda una dirección. Copiarlo te da dos punteros a un solo valor, y
p.Fieldlo sigue por ti. - Un puntero nil no apunta a nada, y seguirlo provoca un panic.
- Un receptor valor trabaja sobre una copia. Si un método cambia su receptor, necesita un receptor puntero, y los otros métodos del tipo normalmente deberían coincidir.
- La incrustación promueve campos y métodos. Es composición, no herencia.
- Una función
NewXque devuelve*Xes el constructor de Go, solo por convención.
Un receptor valor recibe una copia; un receptor puntero recibe lo real.