Punteros en Go sin aritmética: & y *, cuándo usarlos y cuándo no

DUGLAS MORENODUGLAS MORENO 👁 14

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 dereference

Cuá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 // panic

Siempre 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 // panic

Inicializa 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!

Comentarios (0)

Sé el primero en comentar.

Dejá tu comentario