Elegir entre pasar un argumento por valor (una copia de los datos) o por puntero (la dirección de memoria) no es una decisión de “qué es más rápido”, sino de qué semántica quieres aplicar a tus tipos.
Cuando pasas algo por valor, Go crea una copia completa de la estructura en la pila (stack) para la función. Esto significa que cualquier modificación dentro de la función se pierde al retornar, garantizando la inmutabilidad de la variable original. Cuando pasas un puntero, lo que se copia es la dirección de memoria; por tanto, la función trabaja sobre los datos originales, permitiendo la mutabilidad.
Internamente, esta decisión impacta directamente en el análisis de escape (escape analysis) del compilador. Si pasas un puntero, es muy probable que el compilador decida mover ese objeto al heap para que sobreviva fuera del ámbito de la función actual, lo cual aumenta la carga de trabajo del Garbage Collector (GC). Si pasas por valor, el objeto suele quedarse en la pila, que es extremadamente barata de gestionar.
Debes usar punteros cuando necesites modificar el estado original, cuando el tipo sea una entidad con identidad propia (como un sync.Mutex o una conexión a una base de datos) o cuando el struct sea masivo y el costo de copiar cada uno de sus campos sea prohibitivo. Usa valores para tipos pequeños (como int, time.Time o coordenadas), para tipos que representen un dato puro e inmutable, o cuando quieras asegurarte de que la función trabaje con una “fotografía” estática del estado actual.
Si te equivocas, puedes causar errores silenciosos de lógica (modificas una copia pensando que es el original) o degradar el rendimiento del sistema debido a una presión excesiva sobre el GC por asignaciones innecesarias en el heap.
package main
import (
"fmt"
"math"
)
// User representa una entidad con identidad. Queremos modificarlo.
type User struct {
Name string
Age int
}
// Point representa un valor semántico. No nos interesa modificar el original,
// sino obtener un nuevo punto basado en el anterior.
type Point struct {
X, Y float64
}
// DataPayload es un struct pesado. Copiarlo por valor sería costoso.
type DataPayload struct {
Buffer [4096]byte
}
// ActualizarEdad recibe un puntero para poder mutar el objeto original.
func (u *User) ActualizarEdad(nuevaEdad int) {
u.Age = nuevaEdad
}
// CalcularDistancia recibe valores. Un Point es pequeño y semánticamente
// un valor; no queremos que la función cambie nuestras coordenadas.
func CalcularDistancia(p1, p2 Point) float64 {
return math.Sqrt(math.Pow(p2.X-p1.X, 2) + math.Pow(p2.Y-p1.Y, 2))
}
// ProcesarPayload recibe un puntero para evitar copiar los 4KB de la estructura.
func ProcesarPayload(p *DataPayload) int {
// Simulamos procesar la primera posición del buffer
return int(p.Buffer[0])
}
func main() {
// Caso 1: Mutabilidad con punteros
usuario := User{Name: "Elena", Age: 28}
usuario.ActualizarEdad(29)
fmt.Printf("Usuario actualizado: %+v\n", usuario)
// Caso 2: Semántica de valor con tipos pequeños
puntoA := Point{X: 0, Y: 0}
puntoB := Point{X: 3, Y: 4}
distancia := CalcularDistancia(puntoA, puntoB)
fmt.Printf("Distancia entre puntos: %.2f\n", distancia)
// Caso 3: Eficiencia con structs grandes
payload := DataPayload{}
payload.Buffer[0] = 42
valor := ProcesarPayload(&payload)
fmt.Printf("Valor procesado del payload: %d\n", valor)
}
Desglose del ejemplo
En el método ActualizarEdad, utilizamos un pointer receiver (u *User). Si hubiéramos definido el método como (u User), la modificación de u.Age solo afectaría a la copia local dentro del método, y el usuario en main seguiría teniendo 28 años.
La función CalcularDistancia utiliza paso por valor para Point. Como Point solo contiene dos float64 (16 bytes), el coste de copiar la estructura es insignificante y, de hecho, suele ser más rápido que pasar un puntero, ya que evitamos la indirección de memoria y permitimos que el compilador mantenga los datos en registros de la CPU.
En ProcesarPayload, pasamos *DataPayload porque el struct contiene un array de 4096 bytes. Si lo pasáramos por valor, cada llamada a la función obligaría a Go a copiar esos 4KB en la pila. Al usar un puntero, solo movemos una dirección de 8 bytes (en 64 bits).
Un detalle importante: aunque DataPayload es un struct grande, si lo pasáramos como un slice de bytes ([]byte), estaríamos pasando un “descriptor” (un pequeño struct interno con un puntero, un tamaño y una capacidad). Los slices ya son, por diseño, tipos de referencia; pasar un puntero a un slice *[]byte es casi siempre un error de diseño.
El error frecuente
Un error crítico ocurre cuando intentas pasar por valor un struct que contiene un campo que no debe ser copiado, como un sync.Mutex.
type Counter struct {
mu sync.Mutex
value int
}
// ERROR: Esta función recibe una copia del struct, incluyendo el estado del Mutex.
func (c Counter) Incrementar() {
c.mu.Lock() // Bloqueas la COPIA del mutex, no el original.
c.value++
c.mu.Unlock()
}
Si llamas a Incrementar en un programa con concurrencia, estarás bloqueando una copia local del mutex que se destruirá al terminar la función, dejando el mutex original sin bloquear. Esto genera condiciones de carrera (race conditions) y comportamientos impredecibles que son extremadamente difíciles de depurar. Si un struct contiene un mutex, siempre debe pasarse por puntero.
N° 65