Tema 4 - Memoria dinámica
En este tema veremos cómo se organiza la memoria asignada a las variables de un programa. Aprenderemos las diferencias entre memoria estática y memoria dinámica, y cómo usar la memoria dinámica para almacenar datos en memoria cuyo tamaño es variable y/o se desconoce en el momento de escribir el programa.
Organización de la memoria
Cada variable que usamos en nuestros programas se usa para contener información que se almacena en la memoria asignada al programa en tiempo de ejecución. La cantidad de memoria que ocupa cada variable viene determinada por el tipo de datos al que pertenece. Además, usamos una variable simple cuando queremos almacenar un único valor (por ejemplo, un número entero o un carácter), o un array cuando queremos almacenar varios datos del mismo tipo bajo el mismo nombre de variable, especificando la cantidad máxima de valores que podemos guardar en el array en su declaración.
Sin embargo, no siempre podemos conocer la cantidad de información que necesitamos almacenar en memoria cuando escribimos el código. En ocasiones la cantidad de memoria necesaria sólo se conoce una vez que el programa se está ejecutando, por ejemplo si preguntamos al usuario el tamaño de una matriz y a continuación le pedimos que introduzca los valores a guardar dentro de la matriz.
Memoria estática
Se denomina memoria estática al conjunto de todas las variables cuyo tamaño es fijo y se conoce al implementar el programa.
Se incluyen en esta categoría todas las variables y parámetros locales de las funciones (simples o arrays), así como las variables globales, declaradas tal como hemos estado haciendo hasta ahora. Por ejemplo:
int i = 0;
char c;
float vf[3] = { 1.0, 2.0, 3.0 };
En el siguiente gráfico podemos ver una representación de una posible asignación de direcciones de memoria a cada una de las variables del ejemplo anterior. Las celdas representan el valor que contiene la variable y los números debajo de cada celda la posición que ocupa en memoria (en realidad las direcciones de memoria suelen ser muy distintas, pero lo hemos representado así por simplicidad).
Memoria dinámica
Además de la memoria estática, un programa puede hacer uso de otra zona de memoria para almacenar información. Normalmente se utiliza para almacenar grandes volúmenes de datos, cuya cantidad exacta se desconoce al implementar el programa. La cantidad de datos a almacenar se calcula durante la ejecución del programa, y además puede cambiar mientras el programa esté en ejecución.
Para hacer uso de esta memoria dinámica en C++ se usan unas variables especiales llamadas punteros.
Punteros
Definición y declaración
Un puntero es un número (entero largo) que se corresponde con una dirección de memoria. Para usar un puntero es necesario declarar una variable especificando que se trata de un puntero, además del tipo de dato que contendrá esa dirección de memoria, de manera que cuando accedamos a ella se pueda interpretar correctamente su valor.
Los punteros se declaran usando el carácter * antes del nombre de la variable. Por ejemplo:
int *punteroAEntero;
char *punteroAChar;
int *VectorPunterosAEntero[tVECTOR];
double **punteroAPunteroAReal;
Como puedes ver en el último ejemplo, un puntero puede apuntar también a una dirección de memoria donde hay almacenado otro puntero (indicado en el ejemplo como **).
Dirección y contenido
En C++ hay dos operadores que podemos usar para trabajar con punteros:
*x |
Contenido de la dirección apuntada por x |
---|---|
&x |
Dirección de memoria de la variable x |
Usaremos el operador * para acceder al valor almacenado en la dirección de memoria a la que apunta un puntero, tanto para obtener su valor como para modificarlo. Sin embargo, antes de poder acceder a un puntero tenemos que asegurarnos de que apunta a una dirección de memoria válida.
La forma más sencilla de asignar una dirección de memoria a un puntero es asignarle la dirección de una variable estática usando el operador &. De esta forma podemos usar el puntero para leer o modificar su valor como si se tratara de la misma variable. Por ejemplo:
int i=3;
int *pi; // El puntero todavía no está inicializado
pi = &i; // Inicializamos el puntero pi a la direccion de memoria de i
*pi = 11; // Contenido de pi = 11. Por lo tanto, i = 11
En el siguiente gráfico puedes ver cómo quedaría el valor de las variables después de ejecutar el código anterior.
Como puedes observar, al ejecutar pi = &i
el valor de pi (el puntero) es 1000, es decir, la dirección donde está almacenada la variable i. Al ejecutar la instrucción *pi = 11
se accede a esta posición de memoria y se almacena el valor 11, por lo que en realidad es como si hubiésemos modificado directamente la variable i.
Declaración con inicialización
Igual que sucede con el resto de variables, es recomendable inicializar siempre un puntero en el momento de su declaración. Por ejemplo:
int *pi = &i; // pi contiene la direccion de i
Cuando declaramos un puntero pero todavía no sabemos a qué dirección tiene que apuntar es muy conveniente usar el valor especial NULL. El puntero NULL es aquel que no apunta a ninguna variable:
int *pi = NULL;
Precaución: siempre que un puntero no tenga memoria asignada debe valer NULL. Ésto nos permitirá comprobar antes de acceder a un puntero si apunta a una dirección de memoria válida. Por ejemplo:
if (pi != NULL) {
*pi = 11;
}
Ejercicios
Ejercicio 1
Indica cuál sería la salida de los siguientes fragmentos de código:
int e1;
int *p1, *p2;
e1 = 7;
p1 = &e1;
p2 = p1;
e1++;
(*p2) += e1;
cout << *p1;
int a=7;
int *p=&a;
int **pp=&p;
cout << **pp;
Uso de punteros
Hasta ahora hemos visto cómo usar punteros para acceder a variables estáticas. Sin embargo, la principal utilidad de los punteros es poder reservar dinámicamente memoria cada vez que necesitamos almacenar nueva información, sin tener que recurrir a variables estáticas.
Reserva y liberación de memoria
Para asignar una nueva posición de memoria dinámica a un puntero tenemos que reservar memoria con el operador new. En el momento en que esta memoria ya no es necesaria debemos liberarla usando el operador delete. Por ejemplo:
double *pd;
pd = new double; // Reserva memoria
*pd = 4.75;
cout << *pd << endl; // Muestra el valor apuntado por pd (4.75)
delete pd; // Libera la memoria
Como puedes ver en la segunda línea, al reservar memoria hay que especificar de nuevo el tipo de dato que contendrá dicha memoria. Esto es necesario para que el programa reserve la cantidad de memoria necesaria (en bytes) para almacenar la nueva información. En el siguiente gráfico puedes ver la representación de las variables del ejemplo anterior.
Las variables locales y las reservadas con new
van a zonas de memoria distintas. Cuando se usa new
, el programa reserva memoria y devuelve la dirección de inicio de ese espacio reservado, esta dirección se almacena en el puntero. Si no hubiera suficiente memoria disponible no se podrá completar la reserva, en este caso el operador new
devuelve NULL
.
Siempre que se reserva memoria con new
hay que liberarla con delete
cuando ya no es necesario seguir usándola, de manera que ese espacio queda disponible para futuras reservas. Si un programa no libera correctamente la memoria dinámica reservada puede llegar a agotar toda la memoria disponible si realiza muchas reservas o se ejecuta ininterrumpidamente durante mucho tiempo.
¡ATENCIÓN! Tras hacer delete
, el puntero no vale NULL por defecto. Si necesitas seguir usando ese puntero, es una buena práctica asignarle el valor NULL manualmente para evitar acceder a una zona de memoria que ya no está disponible para el programa. Recuerda que en programas más complejos deberías comprobar siempre si el puntero es distinto de NULL antes de acceder a su valor, especialmente cuando los punteros se usan en un lugar del código alejado de su declaración. También puedes volver a asignarle otra dirección de memoria usando new
.
Punteros y vectores
En el ejemplo anterior hemos usado un puntero para reservar memoria que almacenará una única variable. Sin embargo, los punteros pueden usarse también para crear vectores o matrices. Para reservar memoria para un vector hay que especificar el tamaño del vector entre corchetes []
. También será necesario usar los corchetes para liberar la memoria del vector al usar delete
. Por ejemplo:
int *pv;
int n = 10;
pv = new int[n]; // Reserva memoria para n enteros
pv[0] = 585; // Usamos el puntero como si fuera un vector
delete [] pv; // Libera la memoria reservada, no hay que indicar el tamaño entre corchetes
Como los punteros pueden almacenar la dirección de memoria de cualquier variable, también se pueden usar para acceder directamente a una posición de un vector. Por ejemplo:
int v[tVECTOR];
int *pv;
pv = &(v[7]); // pv contiene la posición de memoria del octavo elemento del vector v
*pv = 117; // v[7] = 117
Punteros definidos con typedef
Como ya vimos en el Tema 1, podemos usar el operador typedef
para definir nuevos tipos de datos:
typedef int entero; // entero es un tipo, como int
entero a,b; // equivale a: int a,b;
Para facilitar la claridad en el código, pueden definirse los punteros con typedef:
typedef int *punteroAEntero;
int i = 0;
punteroAEntero pi = &i; // Equivale a: int *pi;
Punteros y registros
Los punteros se pueden usar también para contener registros, igual que si se tratara de un tipo de datos simple. Una vez se le ha asignado memoria, podemos acceder a los campos del registro usando el operador punto (.) como si se tratara de un registro normal, aunque para esto es necesario acceder primero a la dirección del puntero con el operador *. Por ejemplo:
struct Registro {
char c;
int i;
};
Registro *pr;
pr = new Registro;
(*pr).c = 'a'; // Asigna un valor al campo c del registro
¡ATENCIÓN! En el ejemplo anterior los paréntesis alrededor de la expresión *pr
son necesarios, ya que si no interpretaría como un puntero el valor de c (como si hubiésemos escrito *(pr.c)
).
Para evitar confusiones, para acceder a los campos de un registro referenciado por un puntero podemos usar el operador ->
. Por ejemplo:
struct Registro {
char c;
int i;
};
Registro *pr;
pr = new Registro;
pr->c = ’a’; // (*pr).c = ’a’;
Parámetros de funciones
Las variables de tipo puntero se pueden pasar también como parámetro a una función. Esto permite pasar variables reservadas dinámicamente a otra función. Por ejemplo:
void f (int *p) { // El puntero se pasa por valor
*p = 2;
}
int main() {
int *q = new int;
f(q); // La función modifica el valor referenciado por el puntero
cout << *q << endl; // Imprimirá el valor 2
}
¡ATENCIÓN! Aunque un puntero se pase por valor, se puede modificar el contenido de la dirección de memoria a la que apunta. Ten en cuenta que si pasamos un puntero por referencia, estamos permitiendo que se modifique la dirección de memoria que contiene, pudiendo hacer que apunte a otra zona de memoria distinta. Por ejemplo:
void f2 (int *&p) { // El puntero se pasa por referencia
int num = 10;
p = # // Ahora el puntero apunta a la dirección de la variable num
}
int main() {
int i = 0;
int *p = &i;
f2(p); // La función modifica la dirección de memoria contenida en p
cout << *p << endl; // ¡ERROR! num ya no existe, no podemos acceder a su dirección
}
En el ejemplo anterior hemos hecho que el puntero apunte a una variable local de la función. Al terminar la ejecución de la función desaparecen de la memoria todas sus variables locales, por lo que la dirección de memoria contenida en el puntero ha dejado de ser válida. Si intentamos acceder a su contenido podemos tener un error de violación de segmento y el programa terminaría de forma abrupta.
Finalmente, si usamos typedef
para definir tipos de datos con punteros, podemos usar estos nuevos tipos para pasar punteros como parámetros a funciones. Por ejemplo:
typedef int* PInt;
void f (PInt p){ // Por valor
*p = 2;
}
void f2 (PInt &p) { // Por referencia
int num = 10;
p = #
}
int main() {
int i = 0;
PInt p = &i;
f(p);
f2(p);
}
Errores comunes
A continuación se detallan los errores más comunes cuando se usan punteros en un programa:
- Utilizar un puntero sin haberle asignado memoria. Igual que sucede con las variables de tipos de datos simples sin inicializar, un puntero puede contener una dirección de memoria arbitraria si se crea y no se inicializa. Tampoco se debe acceder a punteros que contienen el valor NULL.
int *pEntero;
*pEntero = 7; // Error !!!
- Usar un puntero tras haberlo liberado. Igual que en el caso anterior, una vez liberada la memoria de un puntero ya no es seguro acceder a su contenido.
punteroREGISTRO p,q;
p = new REGISTRO;
...
q = p;
delete p;
q->num = 7; // ¡¡¡Error!!!
- Liberar memoria no reservada. Si se intenta liberar la memoria de un puntero que guarda una referencia a una variable estática el programa terminará con un error de ejecución.
int *p = &i;
delete p;
Ejercicios
Ejercicio 2
Dado el siguiente registro:
struct Cliente {
char nombre[32];
int edad;
};
Realiza un programa que lea un cliente (sólo uno) de un fichero binario, lo almacene en memoria dinámica usando un puntero, imprima su contenido y finalmente libere la memoria reservada.