Punteros en Go sin aritmética: & y *, cuándo usarlos y cuándo no
Introducción
En Go, los punteros son una herramienta poderosa que permite acceder directamente a la dirección de memoria de una variable. A diferencia de lenguajes como C o C++, Go no permite aritmética de punteros; solo se pueden obtener la dirección con & y leer o modificar el valor al que apuntan con *. Esto simplifica el uso y reduce errores, pero sigue siendo fundamental entender cuándo es conveniente usar un puntero y cuándo es mejor trabajar con valores.
Este artículo está dirigido a desarrolladores que ya conocen los conceptos básicos de Go (variables, tipos, funciones) y quieren profundizar en el uso correcto de punteros sin caer en trampas comunes. Al finalizar, sabrás:
- Qué significa el operador
&y cómo obtener la dirección de una variable. - Qué hace el operador
*y cómo desreferenciar un puntero. - En qué situaciones pasar un puntero a una función mejora el rendimiento o permite modificar datos.
- Cuándo es preferible pasar un valor y evitar punteros innecesarios.
- Qué errores frecuentes cometen los principiantes y cómo evitarlos.
¿Qué es un puntero en Go?
Un puntero es una variable que almacena la dirección de memoria de otra variable. En Go, el tipo de un puntero se escribe como *T, donde T es el tipo al que apunta. Por ejemplo, *int es un puntero a un entero.
Declaración y valor cero
Cuando declaras un puntero sin inicializarlo, su valor cero es nil. Esto indica que no apunta a ninguna dirección válida.
var p *int // p es nil
fmt.Println(p) // salida: <nil>Si intentas desreferenciar un puntero nil (*p) obtendrás un panic en tiempo de ejecución.
El operador &: obteniendo la dirección
El operador & se coloca delante de una variable y devuelve su dirección de memoria. El resultado es un puntero al tipo de esa variable.
package main
import "fmt"
func main() {
edad := 30
punteroEdad := &edad
fmt.Println("Valor de edad:" , edad)
fmt.Println("Dirección de edad:" , punteroEdad)
fmt.Println("Valor al que apunta punteroEdad:" , *punteroEdad)
}Salida esperada (la dirección será distinta en cada ejecución):
Valor de edad: 30
Dirección de edad: 0x1040a124
Valor al que apunta punteroEdad: 30
Uso con structs
También se puede obtener la dirección de un campo de un struct, siempre que el struct sea direccionable (es decir, esté almacenado en una variable, no sea un valor temporal de una función).
type Persona struct {
Nombre string
Edad int
}
func main() {
p := Persona{Nombre: "Ana", Edad: 28}
fmt.Println(&p.Nombre) // dirección del campo Nombre
fmt.Println(&p.Edad) // dirección del campo Edad
}El operador *: desreferenciando
El operador * colocado delante de un puntero accede al valor almacenado en la dirección que el puntero contiene. Se conoce como desreferencia.
package main
import "fmt"
func main() {
valor := 100
puntero := &valor
fmt.Println("Valor original:" , valor)
fmt.Println("Valor vía puntero:" , *puntero)
*puntero = 200 // modificamos el valor original
fmt.Println("Nuevo valor:" , valor)
}Salida esperada:
Valor original: 100
Valor vía puntero: 200
Nuevo valor: 200
Desreferencia de puntero nil
Como se mencionó, desreferenciar nil provoca un panic:
var p *int
fmt.Println(*p) // panic: runtime error: invalid memory address or nil pointer dereferenceCuándo usar punteros
Pasar argumentos por referencia
Por defecto, Go pasa los argumentos de una función por valor: se copia el contenido de la variable. Para tipos grandes (como structs con muchos campos) esta copia puede ser costosa. Pasar un puntero evita la copia y permite que la función modifique el objeto original.
package main
import "fmt"
type Reporte struct {
ID int
Titulo string
Contenido string
}
func imprimirReporte(r *Reporte) {
fmt.Printf("Reporte %d: %s
", r.ID, r.Titulo)
}
func main() {
r := Reporte{ID: 1, Titulo: "Informe mensual", Contenido: "..."}
imprimirReporte(&r) // pasamos la dirección
}Salida esperada:
Reporte 1: Informe mensual
Modificar valores dentro de funciones
Si una función necesita cambiar el valor de una variable que pertenece al llamador, debe recibir un puntero. Con un simple valor, cualquier cambio afecta solo a la copia local.
package main
import "fmt"
func duplicar(x *int) {
*x = *x * 2
}
func main() {
a := 7
fmt.Println("Antes:" , a)
duplicar(&a)
fmt.Println("Después:" , a)
}Salida esperada:
Antes: 7
Después: 14
Trabajar con estructuras grandes
Imagina un struct que representa un nodo de un árbol con varios campos y punteros a hijos. Copiar ese struct en cada llamada sería ineficiente. Usar punteros mejora el rendimiento y mantiene la semántica de referencia.
package main
import "fmt"
type Nodo struct {
Valor int
Izq *Nodo
Der *Nodo
}
func suma(n *Nodo) int {
if n == nil {
return 0
}
return n.Valor + suma(n.Izq) + suma(n.Der)
}
func main() {
arbol := &Nodo{
Valor: 10,
Izq: &Nodo{Valor: 5},
Der: &Nodo{Valor: 20},
}
fmt.Println("Suma del árbol:" , suma(arbol))
}Salida esperada:
Suma del árbol: 35
Métodos con receptores puntero
En Go, los métodos pueden tener receptores de tipo valor o de tipo puntero. Un receptor puntero permite que el método modifique el receptor y evita copiar el struct en cada llamada.
package main
import "fmt"
type Cuenta struct {
Saldo float64
}
func (c *Cuenta) Depositar(monto float64) {
c.Saldo += monto
}
func (c Cuenta) SaldoActual() float64 { // receptor por valor (solo lectura)
return c.Saldo
}
func main() {
c := Cuenta{Saldo: 100}
c.Depositar(50) // el método modifica c
fmt.Println("Saldo:" , c.SaldoActual())
}Salida esperada:
Saldo: 150
Interfaces y punteros
Algunos tipos solo satisfacen una interfaz cuando se les pasa como puntero. Por ejemplo, si un método tiene receptor puntero, solo el puntero al tipo implementa la interfaz.
package main
import "fmt"
type Descriptor interface {
Describir() string
}
type Producto struct {
Nombre string
Precio float64
}
func (p *Producto) Describir() string {
return fmt.Sprintf("Producto: %s, Precio: %.2f", p.Nombre, p.Precio)
}
func mostrar(d Descriptor) {
fmt.Println(d.Describir())
}
func main() {
p := Producto{Nombre: "Teclado", Precio: 25.99}
// mostrar(p) // error: Producto no implementa Descriptor (necesita puntero)
mostrar(&p) // OK
}Salida esperada:
Producto: Teclado, Precio: 25.99
Cuándo no usar punteros
Tipos básicos y valores pequeños
Para tipos como int, bool, float64 o structs muy pequeños (uno o dos campos), copiar el valor es barato y más claro que manejar un puntero. Además, evitas la posibilidad de nil accidental.
package main
import "fmt"
func incrementar(x int) int {
return x + 1
}
func main() {
n := 5
fmt.Println(incrementar(n)) // 6
fmt.Println(n) // 5 (no cambió)
}Inmutabilidad y seguridad
Si una función solo necesita leer un valor y no pretende modificarlo, pasar por valor comunica claramente que el dato no será alterado. Esto mejora la legibilidad y ayuda al compilador a hacer optimizaciones.
Evitar copias innecesarias de slices y maps
Los slices y los maps son en realidad descriptores que contienen un puntero a un array subyacente. Pasarlos por valor ya implica copiar solo el descriptor (muy pequeño), por lo que no se necesita un puntero adicional.
package main
import "fmt"
func agregar(s []int, v int) []int {
return append(s, v)
}
func main() {
nums := []int{1, 2, 3}
nums = agregar(nums, 4)
fmt.Println(nums) // [1 2 3 4]
}Concurrencia y condiciones de carrera
Cuando varios goroutines acceden a la misma variable, usar punteros aumenta el riesgo de condiciones de carrera si no se sincroniza correctamente. En estos casos, es preferible pasar por valor o usar mecanismos de sincronización (canales, mutex).
package main
import (
"fmt"
"sync"
)
func worker(val *int, wg *sync.WaitGroup) {
defer wg.Done()
*val++ // posible carrera si no hay mutex
}
func main() {
var wg sync.WaitGroup
contador := 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go worker(&contador, &wg)
}
wg.Wait()
fmt.Println("Contador final (puede ser < 1000):" , contador)
}Ejecutando este código varias veces producirá resultados diferentes debido a la carrera. La solución correcta sería usar un mutex o un canal.
Errores comunes
Olvidar inicializar un puntero
Declarar un puntero y usarlo sin asignarle una dirección válida provoca un panic.
var p *int
*p = 10 // panicSiempre inicializa con &var o con new(T) antes de desreferenciar.
Confundir * y &
Es fácil pensar que * obtiene la dirección y * lo lee, pero al revés. Recuerda:
&→ dirección.*→ valor en esa dirección (cuando está delante de un puntero) o tipo puntero (en declaraciones).
Uso de punteros con nil en mapas o slices
Aunque los maps y slices pueden ser nil, acceder a ellos suele estar permitido (por ejemplo, leer longitud de un slice nil da cero). Sin embargo, intentar escribir en un map nil provoca un panic.
var m map[string]int
m["clave" ] = 5 // panicInicializa con make antes de usar.
Retornar punteros a variables locales
Es seguro retornar un puntero a una variable local porque el escapado de variables lo allocate en el heap cuando es necesario. Sin embargo, retornar un puntero a un parámetro de función que es un valor puede ser engañoso si el llamador espera que el dato siga siendo mutable después de que la función termine.
func crearEntero(v int) *int {
return &v // v escapa al heap, seguro
}
func main() {
p := crearEntero(42)
fmt.Println(*p) // 42
}Sobrerreferenciación accidental
Aplicar * demasiadas veces lleva a intentar desreferenciar un valor que no es puntero.
x := 10
fmt.Println(**&x) // error: invalid indirect of &x (type *int) after first *En este caso, &x es *int, aplicar * una vez da int, aplicar nuevamente no es válido.
Buenas prácticas
Usar punteros solo cuando sea necesario
Antes de decidir pasar un puntero, pregúntate:
- ¿La función necesita modificar el argumento?
- ¿El tipo es grande enough que copiarlo sería costoso?
- ¿La API de la biblioteca exige un puntero para cumplir una interfaz?
Si la respuesta a todas es no, pasa por valor.
Preferir valores para tipos pequeños
Los tipos básicos y structs de menos de dos o tres palabras de máquina (por ejemplo, un struct {x, y int}) son más eficientes como valores.
Documentar la intención
Cuando una función recibe un puntero, comenta si lo usa para modificar o solo para evitar copia. Esto ayuda a futuros mantenedores.
// actualiza el registro en la base de datos
func actualizarRegistro(r *Registro) error { ... }Pruebas y benchmarks
Escribe tests que verifiquen tanto el comportamiento funcional como el de rendimiento. Usa el paquete testing y la herramienta go test -bench para comparar versiones con y sin punteros.
func BenchmarkProcesarValor(b *testing.B) {
dato := GranStruct{}
for i := 0; i < b.N; i++ {
ProcesarValor(dato) // pasa por valor
}
}
func BenchmarkProcesarPuntero(b *testing.B) {
dato := GranStruct{}
for i := 0; i < b.N; i++ {
ProcesarPuntero(&dato) // pasa por puntero
}
}Analiza los resultados para decidir cuál enfoque es más rápido en tu caso particular.
Conclusión
Los punteros en Go son una característica esencial que, cuando se usan correctamente, permiten escribir código más eficiente y expresivo. El operador & obtiene la dirección de una variable y el operador * desreferencia ese puntero para leer o modificar su valor. Recuerda que Go no permite aritmética de punteros, lo que simplifica su uso y reduce errores típicos de lenguaje como C o C++.
Utiliza punteros cuando:
- Necesitas modificar el argumento de una función.
- El tipo es grande y copiarlo sería innecesariamente costoso.
- Implementas métodos que deben cambiar el receptor.
- Una interfaz solo se satisface mediante un puntero.
Evita punteros cuando:
- Trabajas con tipos básicos o structs pequeños.
- La función solo necesita leer el valor.
- Estás manejando concurrencia y deseas minimizar riesgos de carrera.
- Los slices o maps ya son descriptores ligeros que se copian sin costo significativo.
Siguiendo estas directrices y prestando atención a los errores comunes (punteros nil, confusión entre & y *, uso incorrecto en maps), podrás aprovechar la potencia de los punteros sin introducir bugs difíciles de rastrear. La práctica constante, junto con pruebas y benchmarks, te ayudará a internalizar cuándo es apropiado recurrir a ellos y cuándo es mejor mantener los valores por valor.
Ahora tenés las herramientas para usar punteros en Go con confianza y escribir código que sea tanto seguro como performante. ¡A codificar!
DUGLAS MORENO
Comentarios (0)
Sé el primero en comentar.