¿Cómo funciona la memoria?
Antes de empezar a escribir código, o empezar a definir qué es un pointer, vamos primero a echarle un vistazo a cómo funciona la memoria, para que más adelante sea más fácil de entender.
Como ya sabemos, en C podemos declarar variables de ciertos tipos, podemos declarar ints, floats, chars, etc. Cada uno de esos datatipos toma cierta cantidad de memoria para almacenar su valor, qué tanta cantidad de memoria toma depende principalmente del sistema operativo y la arquitectura del procesador, pero generalmente un int son 4 bytes, un char es 1 byte, y así con los demás tipos de datos.
Cuando declaramos una variable así:
int num = 15;
char car = 'A';
Lenguaje del código: C++ (cpp)
Estamos realmente asignándole nombres a ciertos espacios en memoria. Así es como – un modelo ficticio y de bajo detalle – la memoría se vería con esas dos variables:
Nombre | Dirección |
---|---|
num | 0x00 |
car | 0x04 |
Podemos ver las variables como direcciones de una casa. Una variable, de nuevo, es solamente un nombre para una dirección, porque es muchísimo más fácil hacer num = 4, que acceder a la memoria y aprenderse cada dirección y hacer 0x00 = 4.
Como la memoria es secuencial, si definimos un array, por ejemplo, cada uno de sus elementos estarán almacenados en una dirección que incrementará en la cantidad de bytes que su tipo de dato ocupa, por ejemplo, supongamos que tenemos este array:
int arr[3] = { 0 };
Lenguaje del código: C++ (cpp)
Ese array se guardaría en memoria de la siguiente forma:
Nombre | Dirección |
---|---|
arr[0] | 0x05 |
arr[1] | 0x09 |
arr[2] | 0x0d |
Para recapitular, una variable es solo un nombre que le asignamos a cierto espacio en memoria que tiene suficiente espacio para almacenar un valor.
Pointers
C nos permite indirectamente1 acceder el valor de cierto item usando la dirección de memoria donde está almacenado.
Cuando definimos un pointer,d ebemos inicializarlo con el datatipo de el espacio en la memoria al que va a acceder, de esa forma podemos tener pointers a enteros, caracteres, y todos los datatipos que C nos ofrece. Esta es la síntaxis para crear un pointer:
int number = 99;
int *pnumber = &number;
Lenguaje del código: C++ (cpp)
Esta síntaxis nos introduce dos nuevos operadores: el operador de dereferencia/operador de pointer *, y el operador address-of &. El operador de dereferencia nos permite tanto definir un pointer, como dereferenciarlo2. Y el address-of operator, nos permite obtener la dirección de memoria de cualquier variable que tengamos en nuestro programa.
En un snippet de código pasado, definimos dos variables: number, que es un entero, y pnumber que es un pointer a un entero, y en este caso, está apuntando a number (decimos que está apuntando a alguna variable cuando su valor es la dirección de dicha variable)
Entonces, en memoria, estas dos variables se verían de la siguiente forma:
Nombre | Dirección | Valor |
---|---|---|
number | 0x11 | 99 |
pnumber | 0x15 | 0x11 |
El valor de la variable number es 99, y el valor de pnumber es la dirección de number, que en este caso es 0x11. Ten en cuenta que como el resto de variables, el compilador va a asignar cierta cantidad de memoria para un pointer, entonces pnumber tendría un espacio de 8 en memoria (de nuevo, esto depende del procesador y el sistema operativo)
¿Para qué usaríamos los pointers? ¿Para qué necesitaríamos acceder a la ubicación de memoria de una variable? Aquí hay algunas razones:
- Acceder datos solamente con variables es limitante.
- Podemos acceder a cualquier ubicación en memoria usando pointers (aunque esto es un arma de doble filo, ya que puede ser sumamente útil, como peligroso)
- Podemos realizar operaciones aritméticas con los pointers (veremos de esto más adelante)
- Podemos crear arrays y strings usando pointers.
- Podemos modificar variables pasadas a funciones, ya que los pointers nos permites simular pass by reference3
- Podemos asignar y liberar memoria dinámicamente.
Usando Pointers
Como se mencionó anteriormente, para crear un pointer usamos el operador de dereferencia *, y para establecer su valor con la dirección de una variable, usamos el operador address-of &.
Como con cualquier variable que declaramos en C, si no initializamos su valor a un valor en específico va a contener valores basura. ¿Por qué pasa eso? Porque de nuevo, cuando declaramos una variable, le estamos asignando un espacio libre en la memoria para que pueda almacenar un valor, sin embargo, cualquier espacio libre será asignado, lo que quiere decir que en ese lugar puede que ya haya un valor de alguna variable que ya haya estado en esa dirección. Para evitar esto, siempre que declaremos variables hay que inicializarlas con algún valor, y para hacer eso con pointers los podemos inicializar a 0x00 o a NULL.
Para los siguientes ejemplos usaremos las variables number y pnumber que fueron creadas en los snippets de código anteriores.
Ya sabemos que hay una variable pnumber que apunta a number, cuyo valor es 99. Pero el valor de pnumber no es 99, sino que es la dirección de number. Incluso si este es el caso, podemos acceder y modificar el valor al que pnumber apunta, en este caso 99. Para hacer eso, usamos el operador de dereferencia. Por ejemplo, si hacemos:
*pnumber += 29;
int number2 = *pnumber;
Lenguaje del código: C++ (cpp)
Como se dijo en el inicio, estamos indirectamente cambiando el valor de number usando pnumber, en este caso le estamos agregando 29, entonces ahora el valor de number es 128. Y creamos una nueva variable que se llama number2 y le asignamos el valor al que pnumber apunta, lo que quiere decir que el valor de number2 es 128.
Ahora vamos a hacer un programa entero donde podamos ver esto en acción:
#include <stdio.h>
int
main (void)
{
int count = 0;
int *pcount = &count;
int count2 = 0;
printf ("El valor de count es: %d\n", count);
*pcount += 10;
count2 = *pcount;
printf ("count = count2 = %d, pcount = %p, *pcount = %d\n", count2, pcount,
*pcount);
return 0;
}
Lenguaje del código: C++ (cpp)
Y si compilamos y ejecutamos ese programa, su salida sería:
El valor de count es: 0
count = count2 = 10, pcount = 0x7ffca2352f18, *pcount = 10
Como puedes ver, el valor de count era 0, pero indirectamente le incrementamos 10, luego le pusimos como el valor al que pcount estaba apuntando a count2 y, finalmente, imprimimos todos los valores.
Ten mucho cuidado, porque nunca, NUNCA debes dereferenciar o realizar alguna operación con un pointer al que no ha sido inicializado con un valor, una de las razones son los valores basura que originalmente tienen todas las variables no inicializadas, y por este motivo pueden haber comportamientos indefinidos, como un segmentation fault.
Como inicializamos pointers a NULL, podemos checar si un pointer tiene un valor diferente a NULL antes de realizar cualquier operación con este:
if (pcount)
*pcount += 10;
Lenguaje del código: C++ (cpp)
Ten en cuenta que podemos usar NULL como si fuera un booleano ya que su valor es 0, y según la especificación de C, cualquier valor distinto de cero será considerado positivo. Es por eso que podemos hacer if (pcount) y if (!pcount).
Pointers Constantes
Algo más o menos complicado de los pointers es cuando empezamos a utilizar const para designar que puede o no puede ser cambiado en un pointer. Hay dos tipos de pointers constantes:
- Un pointer constante.
- Un pointer a una constante.
Pointer Constante
Un pointer constante, es un pointer cuyo valor no puede ser cambiado, es decir, no podemos cambiar la dirección a la que apunta. La diferencia entre las declaraciones entre un pointer constante y un pointer a una constante es la posición de const:
Vamos a ver un ejemplo de un pointer constante:
int number = 100;
int *const pnumber = &number;
*pnumber = 200; /* esto es valido */
pnumber = NULL; /* esto es invalido */
Lenguaje del código: PHP (php)
Ten en cuenta que como esto es un pointer constante podemos cambiar el valor de la variable a la que apunta, ya que number no es una constante, sin embargo, como nuestro pointer es constante no podemos cambiar la dirección a la que apunta, es decir, no podemos cambiar el valor de un pointer constante.
Pointer a una constante
Esto es lo opuesto a un pointer constante, ene ste tipo de pointer podemos cambiar su valor, pero no podemos cambiar el valor de la variable a la que apunta. Como se mencionó antes, esto puede ser un poco complejo, ya que todo depende de la posición de const, para declarar un pointer constante tenemos que poner el keyword const después del operador de dereferencia, y para declarar un pointer a una constante, lo tenemos que poner antes del datatype:
int number = 20;
const int *pnumber = &number;
*pnumber += 20; /* esto es invalido */
pnumber = NULL; /* esto es valido */
Lenguaje del código: C++ (cpp)
En un pointer a una constante, no pdoemos cambiar el valor al que está apuntando, incluso si la variable a la que apunta no es una constante.
Como un pequeño resumen:
- Si el keyword const está después del operador de dereferencia, significa que es un pointer constante.
- Si el keyword const está antes del datatipo del pointer, significa que es un pointer a una constante.
Ten en cuenta que podemos mezclar ambos tipos de pointers constantes y crear un pointer constante que apunta a una constante: const int *const pnumber = &number; En ese pointer no podemos cambiar ni su valor, ni el valor al que apunta.
Void Pointers
Como ya sabemos, para declarar un pointer debemos especificar el tipo de dato al que apunta, sin embargo, podemos definir un pointer con tipo void para apuntar a cualquier tipo de dato. Pero es muy importante saber que, antes de poder usarlo o dereferenciarlo hay que castearlo:
int number = 200;
void *ptr = &number;
*(int *)ptr += 20;
Lenguaje del código: HTML, XML (xml)
Ten en cuenta que primero tenemos que castear ptr para ser un int * antes de poder dereferenciarlo. Esto es útil cuando empezamos a trabajar con pointers como argumentos para aceptar múltiples datatipos. Eso combinado con structs (como sus elementos están almacenados en memoria de forma consecutiva) puede ser demasiado poderoso ya que podemos tener un struct general que nos puede decir más sobre la variable que pasamos, para así castearla de manera correcta.
Arrays
Los Arrays están muy relacionados con los pointers, en realidad, detrás de las cortinas, un array es un pointer.
También podemos tener pointers a arrays, pero estos funcionan un poco diferente. Como un Array ya es un pointer, cuando definimos un pointer a un array, no necesitamos utilizar el operador address of (&). Por ejemplo:
int values [100] = { 0 };
int *pvalues = values;
En ese código la variable pvalues va a apuntar al primer elemento de values, lo que quiere decir que no estamos apuntando al array entero. Saber esto puede ser increíblemente útil cuando empezamos a hacer pointer arithmetic.
Si queremos apuntar a cierto elemento de un array, necesitaríamos acceder a el usando su índice y el operador address of, así:
int *pvalue = &values[2];
De esa forma, pvalue apunta al tercer elemento del array de valores.
Pass By Reference vs Pass By Value
Hay dos formas de pasar argumentos a una función: pass by value y pass by reference. Por defecto, siempre que pasamos una variable a una función, estamos pasando su valor, en lugar de pasar la variable misma, lo que quiere decir que todos los valores funcionarán como variables locales y todos los cambios que hagamos no se verán reflejados fuera de la función. Vamos a ver un ejemplo:
#include <stdio.h>
void
swap (int a, int b)
{
int temp = a;
a = b;
b = temp;
}
int
main (void)
{
int a = 10, b = 20;
printf ("Valores de a y b antes de swappearlos, a = %d, b = %d\n", a, b);
swap (a, b);
printf ("Valores de a y b despues de swappearlos, a = %d, b = %d\n", a, b);
return 0;
}
Lenguaje del código: C++ (cpp)
El resultado de ese programa será el siguiente:
Valores de a y b antes de swappearlos, a = 10, b = 20
Valores de a y b despues de swappearlos, a = 10, b = 20
Como puedes ver, ninguno de los valores cambiaron incluso después de llamar a la función swap. Eso apsa porque cuando pasamos variables como argumentos, estamos pasando su valor en lugar de la variable en sí misma. Entonces esa función en realidad está ejecutando swap (10, 20), estamos cambiando sus valores, si, pero no estamos almacenando estos nuevos valores en alguna parte.
Incluso si esto pasa, podemos simular un llamado pass by reference en C usando pointers, porque incluso si solamente pasamos el valor de la variable, si pasamos una dirección, estamos pasando de forma indirecta una referencia al valor, así podemos modificar ese espacio en memoria. Así es como quedaría el programa ahora usando pointers:
#include <stdio.h>
void
swap (int *a, int *b)
{
int temp = *a;
*a = *b;
*b = temp;
}
int
main (void)
{
int a = 10, b = 20;
printf ("Valores de a y b antes de swappearlos, a = %d, b = %d\n", a, b);
swap (&a, &b);
printf ("Valores de a y b despues de swappearlos, a = %d, b = %d\n", a, b);
return 0;
}
Lenguaje del código: PHP (php)
Ahora la salida del programa sería:
Valores de a y b antes de swappearlos, a = 10, b = 20
Valores de a y b despues de swappearlos, a = 20, b = 10
Recuerda: siempre que quieras cambiar el valor de una variable dentro de una función, y quieres que este cambio sea reflejado fuera de esta, vas a necesitar pasar pointers como argumentos para simular pass by reference.
Ten en cuenta que también podemos declarar funciones para devolver pointers, usando el operador de dereferencia * cuando definimos su tipo, la siguiente sería una función que devuelve un void pointer:
void *
my_function (void)
{
/* contents of the function go here */
return NULL;
}
Lenguaje del código: C++ (cpp)
Podemos hacer esto si queremos devolver múltiples valores de una función, sin embargo, esto introduce un nuevo riesgo y es que puede que devuelvas un pointer que está ubicado en el stack en lugar del heap.
Pointer Arithmetic
Como lo he estado mencionando en este post, hay algo que se llama Pointer Arithmetic que nos permite realizar operaciones aritméticas básicas (adición y substracción) en pointers.
¿Por qué querríamos hacer esto? Bueno, esto se vuelve demasiado útil cuando usamos arrays y strings. Como ya sabemos, la memoria es secuencial, lo que significa que todos los elementos de un array se encuentran uno después del otro separado por x bytes de memoria (x representa el tamañod el datatipo de los elementos del array) como se muestra en ese “dibujito” que hice al inicio del post.
Teniendo esto en cuenta, podemos hacer adición y substracción para ir por cada uno de estos elementos secuenciales. Por ejemplo, supongamos que tenemos las siguientes variables:
int values[] = { 10, 20, 30 };
int *pvalues = values;
Tenemos un array de enteros llamado values y un pointer llamado pvalues que apunta al primer elemento de values. Si dereferenciamos pvalues obtendremos 10. Como ya sabemos el tamaño de un int es 4 bytes (al menos, vamos a suponer que lo es por este ejemplo), si hacemos pvalues + 1 incrementaríamos 1 * sizeof (int) a la dirección a la que pvalues apunta, entonces si ahora dereferenciamos pvalues obtendremos el valor de 20 ya que está apuntando al segundo valor de values.
También podemos checar si una locación en memoria es mayor o menor que otra usando los operadores con el mismo nombre. Lo que significa que podemos hacer:
int values[] = { 10, 20, 30 };
int *values_start = values;
int *values_end = values + 2; /* equivalente de values[2] */
if (values_start < values_end)
printf ("El inicio del array está primero en memoria duh.\n");
Lenguaje del código: JavaScript (javascript)
Y la salida de ese programa sería:
El inicio del array está primero en memoria duh.
Lenguaje del código: PHP (php)
Podemos usar estas dos cosas que recién aprendimos, para implementar una funciónq ue sume todos los elementos de un array en una forma mucho más simple y limpia:
int
array_sum (int *arr, int arr_len)
{
int sum = 0;
int *arr_end = arr + arr_len;
while (arr < arr_end)
{
sum += *arr;
arr++;
}
return sum;
}
int
main (void)
{
int arr[] = { 10, 20, 30 };
printf ("La suma del array es: %d\n",
array_sum (arr, (sizeof (arr) / sizeof (int))));
return 0;
}
Lenguaje del código: C++ (cpp)
Y como esperaríamos, el resultado del programa es:
La suma del array es: 60
Lenguaje del código: PHP (php)
Gestión de Memoria
Como ya sabemos, cuando creamos una variable, el compilador automáticamente nos asigna la memoria necesaria para almacenar su valor en el stack, lo que significa que todas las variables que creamos son locales a un scope, y cuando estamos fuera de este no podemos acceder su ubicación en memoria. Por ejemplo, si declaramos una variable como int a = 10; e intentamos devolver &a de una función, su memoria va a estar reescrita para entonces, ya que ese espacio en memoria va a ser marcado como libre.
La gestión de memoria dinámica se vuelve extremadamente útil ya que podemos cambiar la cantidad de memoria de una variable a mitad de ejecución del programa, lo que significa que podemos crear arrays y strings que pueden cambiar de tamaño, para agregar o remover elementos que ya no necesitamos.
C nos provee algunas funciones para asignar memoria, pero vamos a ver solamente dos: malloc y calloc (estas dos funciones están definidas en el header stdlib.h, así que deberás incluirlo antes de poder usarlas).
Para asignarle n cantidad de memoria a una variable, podemos ejecutar malloc (n) y nos retornará la dirección del inicio de memoria que contiene n bytes. Podemos, por ejemplo, definir un array de 10 enteros usando malloc:
int *arr = malloc (sizeof (int) * 10);
Lenguaje del código: C++ (cpp)
En ese código estamos definiendo un pointer entero llamado arr que tiene la capacidad de almacenar 10 valores enteros. Una vez hayamos asignado la memoria necesaria, podemos acceder cada uno de estos elementos usando la notación de array a la que ya estamos más que acostumbrados:
for (int i = 0; i < 10; i++)
arr[i] = i * 2;
Lenguaje del código: HTML, XML (xml)
Ten en cuenta que todas estas funciones que manejan memoria van a devolver NULL en caso de que el programa tenga algún problema asignando la memoria necesaria, por ejemplo, si no hay memoria disponible. Entonces es una buena práctica checar si una variable que allocamos manualmente es NULL o no antes de empezar a usarla:
int *arr = malloc (sizeof (int) * 10);
if (!arr)
exit (EXIT_FAILURE);
for (int i = 0; i < 10; i++)
arr [i] = i * 2;
Lenguaje del código: HTML, XML (xml)
Sin embargo, a diferencia del stack, la memoria en el heap no es liberada automáticamente después de que no es necesaria, por lo que es la responsibilidad del programador de rastrear y liberar la memoria cuando ya no es necesaria. Para hacer eso, podemos usar la función free para liberar memoria innecesaria:
free (arr);
Lenguaje del código: C++ (cpp)
Si no liberamos la memoria que hemos asignado podemos tener memory leaks, que es básicamente cuando nuestro programa usa memoria que ya no necesitamos.
La función calloc también asigna memoria como malloc, pero hay dos diferencias entre estas funciones:
- calloc toma dos argumentos: el número de elementos que queremos alocar y el tamaño de cada elemento individual.
- calloc inicializa la memoria creada a ceros.
Siguiendo el ejemplo anterior, si queremos crear un array de 10 enteros usando calloc haríamos: int *arr = calloc (10, sizeof (integer));. Vamos a ver un ejemplo donde accedemos explicitamente a memoria que no se ha inicializado usando malloc y calloc:
#include <stdio.h>
#include <stdlib.h>
int
main (void)
{
int *arr = malloc (sizeof (int) * 3);
for (int i = 0; i < 3; i++)
printf ("arr = %p, *arr = %d\n", arr, *arr);
free (arr);
arr = calloc (3, sizeof (int));
for (int i = 0; i < 3; i++)
printf ("arr = %p, *arr = %d\n", arr, *arr);
free (arr);
return 0;
}
Lenguaje del código: PHP (php)
Cuando usamos malloc sin inicializar cada uno de sus elementos podríamos tener salidas como la siguiente:
arr = 0x55a14e24e2a0, *arr = 94604934820512
arr = 0x55a14e24e2a0, *arr = -18360493482051
arr = 0x55a14e24e2a0, *arr = 18460493482158
arr = 0x55a14e24f2d0, *arr = 0
arr = 0x55a14e24f2d0, *arr = 0