Cómo funcionan realmente los punteros C: direcciones de memoria explicadas

9

La mayoría de los desarrolladores tratan la memoria como una caja negra. Asumen que las variables son sólo nombres adjuntos a valores. Eso funciona hasta que necesites escribir código que manipule datos directamente a nivel de máquina. Luego aciertas en los consejos.

Una variable normal es simple. Es un trozo de memoria con un nombre que usas en tu código. Declare un número entero i y el compilador reservará cuatro bytes. Verás yo. La máquina ve una dirección. El valor se encuentra en esos cuatro bytes.

Los indicadores rompen ese modelo simple.

Un puntero no tiene un valor. Tiene una dirección. Apunta a otra variable. Específicamente, almacena la ubicación de memoria de esa variable. Esta distinción es crítica. Si confundes los dos, tu programa fallará. Duro.

La estructura es de doble capa. Primero, está la propia variable de puntero. Contiene una dirección numérica. En segundo lugar, está la variable objetivo. La dirección en el puntero le lleva a los datos reales. Una vez que comprenda esta separación, desbloqueará un poder significativo en la programación de sistemas.

Considere este ejemplo estándar de C:

La primera línea declara dos números enteros estándar, i y j. La siguiente línea, int *p, declara un puntero. El asterisco es la clave. Le dice al compilador que p no es un número entero. Es un puntero a un número entero. Puedes hacer esto con flotadores, estructuras, personajes o cualquier otra cosa. La sintaxis sigue siendo la misma.

Luego viene la parte que hace tropezar a los principiantes: p = &i;.

En C, el signo comercial & es el operador de dirección. &i no devuelve el valor de i. Devuelve la dirección de memoria donde vive i. La asignación p = &i copia esa dirección en el puntero p. Ahora p apunta a i.

Si omites este paso, p contiene basura. Contiene una dirección aleatoria e indefinida. Eliminar la referencia a él más adelante probablemente provocará un error de segmentación. El programa falla porque le dijiste que accediera a una memoria que no le pertenece.

La visualización ayuda. Imagine la memoria como una cuadrícula.

Antes de la inicialización, i, j y p existen pero contienen valores desconocidos. Los números enteros son cuadros con signos de interrogación. Podrían contener cualquier cosa. El puntero p es un círculo. Tiene flechas que apuntan en direcciones aleatorias. No sabe adónde ir todavía.

Después de ejecutar p = &i, las flechas cambian. Todos apuntan directamente a i. p ahora sabe exactamente dónde vive i.

La relación es clara. p contiene la ubicación. i contiene los datos. Usas p para llegar a i.

Este mecanismo es la forma en que C administra la memoria de manera eficiente. Permite que funciones modifiquen variables pasadas por referencia. Permite la asignación de memoria dinámica. Le permite crear estructuras de datos complejas como listas y árboles vinculados.

Pero también introduce riesgos. Una dirección incorrecta y estarás leyendo o escribiendo en el vacío.

El asterisco en *p = 5 hace algo diferente al de int *p. El segundo asterisco formaba parte de la definición del tipo. Declaró la variable como un puntero. El primer asterisco es el operador de desreferencia. Significa “ir a la dirección almacenada en p y cambiar el valor allí”.

Entonces *p = 5 coloca el número 5 en la ubicación de memoria señalada por p. Dado que p apunta a i, i se convierte en 5. Luego, a la variable j se le asigna el valor de i. El resultado imprime 5, 5 y 5. Los tres reflejan el mismo cambio subyacente.

Comprender cómo funcionan los punteros C requiere cambiar su forma de pensar. Deja de pensar en las variables como contenedores. Empiece a pensar en ellos como ubicaciones. El puntero es el mapa. La variable es el destino. Si el mapa está mal, terminarás perdido. Y en la programación de bajo nivel, perderse sale caro.

Explicación del operador de dirección indirecta

Cuando asignas la dirección de i a p, esencialmente le estás dando a ese único bloque de memoria dos nombres diferentes. La variable i todavía se refiere a ella directamente, pero p ahora sirve como identificador, un punto de referencia para llegar a los mismos datos.

En C, esta dualidad define cómo funcionan las variables de puntero. El puntero en sí, “p”, es simplemente un contenedor que contiene una dirección. No contiene el valor real que le interesa. En cambio, contiene la ubicación donde reside ese valor. Aquí es donde entra en juego el operador de indirección *.

El uso de *p le permite acceder a los datos en la dirección almacenada en p. Piense en ello como una desreferenciación. Si escribe *p = 5, no está cambiando la dirección dentro de p. Vas a la ubicación a la que apunta “p” y sobrescribes cualquier valor que esté allí con el número 5.

Esta distinción es fundamental para comprender la gestión de la memoria. p te dice dónde buscar. *p te dice qué mirar. Confundir los dos conduce a errores que son difíciles de rastrear, porque podría terminar cambiando una dirección en lugar de los datos, o intentando escribir en una ubicación de memoria aleatoria porque “p” no se inicializó correctamente.

Una vez que te sientas cómodo con este modelo mental, los punteros dejan de ser una sintaxis abstracta y se convierten en una línea directa a la memoria. Puede modificar variables en diferentes ámbitos, pasar estructuras grandes por referencia sin copiarlas y crear estructuras de datos dinámicas que crecen y se reducen según sea necesario. Es algo poderoso, una vez que dejas de luchar contra la sintaxis.

El siguiente fragmento de código ilustra este comportamiento exacto, mostrando cómo el valor de “i” cambia solo porque se usa “p” para alcanzarlo.

El valor de “i” se actualiza instantáneamente. La dirección no se ha movido. Sólo el contenido tiene.

Cómo los punteros almacenan valores y direcciones

Parece bastante simple, pero la mecánica puede hacerte tropezar. Dado que a la variable i se le asigna el valor 5, y el puntero p contiene la dirección de i, al desreferenciar p (*p ) se devuelve 5. Cuando ejecutas j = i;, estás copiando ese valor. Entonces j se convierte en 5.

Ejecute la declaración printf ahora. Obtienes “5 5 5”.

Esto sucede debido a la naturaleza dual de los punteros. Piense en ellos como si tuvieran dos roles distintos. Primero, la propia variable de puntero almacena una dirección de memoria. En segundo lugar, apunta a un tipo de datos específico ubicado en esa dirección.

En nuestro ejemplo:
p es el puntero. Mantiene la ubicación de la memoria.
*p es el valor. Son los datos reales almacenados en esa dirección.

Confundir los dos genera errores. Si trata la dirección como datos, obtendrá valores basura. Si trata los datos como la dirección, bloqueará su programa. Saber con cuál estás lidiando lo cambia todo.

¿Por qué es importante esta distinción para la codificación diaria? Porque comprender la diferencia entre dónde residen los datos y qué son los datos le permite manipular la memoria directamente. Esto es útil para estructuras dinámicas, pero peligroso si omite el paso. Un movimiento en falso y el sistema intenta leer desde una ubicación inexistente.

Empiezas a ver por qué C sigue siendo poderoso y peligroso en igual medida.