Disponible la versión 6 de OrganiZATOR
Descubre un nuevo concepto en el manejo de la información.
La mejor ayuda para sobrevivir en la moderna jungla de datos la tienes aquí.

Curso C++

[Home]  [Inicio]  [Índice]


2.2.6 Almacenamiento

Recordemos que al describir la estructura de un programa, se dedicó un capítulo a explicar las formas de almacenamiento de algoritmos y datos ( 1.3.2). Aquí nos referimos exclusivamente al almacenamiento de datos. En especial a aquellos aspectos del soporte físico que tienen repercusiones de interés para el programador.

§1 Sinopsis

El almacenamiento de los datos de un programa puede ser considerado desde varios puntos de vista; trataremos aquí dos de ellos: uno físico, y otro lógico. Desde el punto de vista físico existen cinco zonas de almacenamiento:  los registros, el segmento de datos, el montón y la pila.

 Pila  ("Stack")

 Montón  ("Heap")

 Segmento de datos ("Data segment" en el PC)

 Registros ("Registers")

§2 Características físicas

Cada zona tiene unas características propias que imprimen carácter a la información almacenada en ellas.

  • Pila: a menos que se especifique lo contrario las variables locales se almacenan aquí, también los parámetros, es decir: las variables automáticas ( 1.3.2).

    Los elementos almacenados en esta zona son de naturaleza automática; esto significa que el compilador se encarga de crearlas y destruirlas automáticamente cuando salen de ámbito.

  • Montón: es utilizado para asignación dinámica de bloques de memoria de tamaño variable ( 1.3.2). Muchas estructuras de datos, como árboles y listas, lo utilizan como sitio de almacenamiento. Esta zona está bajo el control del programador con new, malloc y free.

    Los elementos almacenados en esta zona se asocian a una existencia persistente [3].  Esto significa que se crean y destruyen bajo directo control del programador que debe preocuparse de su destrucción cuando ya no son necesarios para liberar la memoria y permitir que pueda ser usada por otros objetos.

  • Segmento de datos: es una zona de memoria utilizada generalmente por las variables estáticas y globales.
  • Registros: son espacios de almacenamiento en el interior del procesador, por lo que su número depende de la arquitectura del mismo. Los programas C++ no pueden garantizar que una variable se almacene en un registro (variable de registro), aunque podemos solicitarlo ( 4.1.8b).

    Es la zona de memoria de más rápido acceso, por lo que se utiliza para guardar contadores de bucle y usos parecidos en los que la velocidad sea determinante, sin embargo son un recurso escaso (hay pocos). Los objetos almacenados aquí son también de naturaleza automática; generalmente de tipos asimilables a entero ( 2.2.1).

Nota: los términos automático versus persistente, que en la práctica son respectivamente sinónimos de existencia en la pila/registros o en el montón, son conceptos que se utilizan constantemente en C++, por lo que es vital entender sus diferencias y las consecuencias que de ello se derivan.

Tema relacionado: formas de representación binaria de las magnitudes numéricas ( 2.2.4a)

Nota: en lo que sigue, el término identificador  ( 3.2.2) se refiere al nombre arbitrario (dentro de ciertas reglas) que se da a una entidad (clase, objeto, función, variable, etc) en el código de un programa.  Posteriormente pueden ser transformados por la acción del compilador y enlazador hasta quedar total o parcialmente irreconocibles en el ejecutable.

§3 Características lógicas

Desde el punto de vista lógico, existen tres aspectos básicos a tener en cuenta en el almacenamiento de los objetos: ámbito, visibilidad (scope) y duración (lifetime).

  • Ámbito o campo de acción de un identificador es la parte del programa en que es conocido por el compilador ( 4.1.3).

  • Visibilidad de un identificador es la región de código fuente desde la que se puede acceder al objeto asociado al identificador sin utilizar especificadores adicionales de acceso (simplemente con el identificador 4.1.4).

  • Duración:  define el periodo durante el que la entidad relacionada con el identificador tiene existencia real, es decir, un objeto físicamente alojado en memoria ( 4.1.5).

Nota: observe que los dos primeros, ámbito y visibilidad, se refieren al identificador y al fuente, decimos que son propiedades de tiempo de compilación. El tercero, la duración, se refiere a objetos reales en memoria. Decimos que es una propiedad de runtime.


Tanto las características físicas (donde se almacena) como lógicas (ámbito, visibilidad y duración), están determinadas por dos atributos de los objetos: clase de almacenamiento y tipo de dato (abreviadamente conocido como tipo 2.1). El compilador C++ deduce estos atributos a partir del código, bien de forma implícita bien mediante declaraciones explícitas.

Las declaraciones explícitas de clase de almacenamiento son: auto; register; static;   extern; typedef y mutable ( 4.1.8 Especificadores de clase de almacenamiento).

Las declaraciones explícitas de tipo de dato son: charintfloat;   double y void ( 2.2.1 Tipos básicos), a estos se pueden añadir matices utilizando ciertos modificadores opcionales: signed; unsigned; long  y short ( 2.2.3 Modificadores de tipo).

§4 El concepto "estático"

El concepto estático ("Static") tiene en C++ varias connotaciones distintas; algunas de ellas son herencia del C clásico; otras son significados añadidos en la parte POO del lenguaje. Desafortunadamente (sobre todo para el principiante) algunos de los significados no tienen absolutamente ninguna relación entre si y se refieren a conceptos distintos.

Las diversas connotaciones del concepto, podríamos resumirlas del siguiente modo:

  • Relativa al conocimiento o no del compilador de los valores de un objeto en tiempo de compilación, y como consecuencia directa de esto, el lugar de almacenamiento del objeto, ya que los objetos cuyos valores son conocidos por el compilador se almacenan en sitio distinto que los que solo son conocidos en tiempo de ejecución ( 1.3.2).
  • Relativa al enlazado de funciones; cuando una llamada a función puede traducirse en una dirección concreta en tiempo de compilación ( 1.4.4), el enlazado (estático) es diferente del que se realiza cuando esta dirección solo es conocida en tiempo de ejecución (dinámico).
  • Relativa a la duración o permanencia de un objeto.
  • Relativa a la visibilidad de un objeto, lo que está relacionado directamente con otro concepto: el tipo de enlazado ( 1.4.4), que se refiere a las variables que puede ver el enlazador.


Refiriéndonos a la primera de ellas, estático (versus dinámico), significa que el compilador conoce los valores en tiempo de compilación (frente a tiempo de ejecución -runtime-). Por tanto, puede asignar zonas predeterminadas de memoria para estos objetos (variables y constantes). Por el contrario, para los objetos dinámicos se asigna y desecha espacio de memoria en tiempo de ejecución, lo que significa que se crean y se destruyen con cada llamada de la función en que han sido declaradas. Esto explica por ejemplo, que cada llamada recursiva a una función pueda generar su propio conjunto de variables locales (dinámicas). Si el espacio fuese asignado de forma fija en tiempo de compilación, la recursión sería imposible, pues cada nueva invocación de la función machacaría los valores anteriores.

Nota:  Si la "profundidad" de la recursión se pudiese conocer en tiempo de compilación, el compilador podría asignar espacio a los sucesivos juegos de variables, pero téngase en cuenta que este es precisamente un valor que a veces solo se conoce en tiempo de ejecución. Por ejemplo, no es lo mismo calcular el factorial de 5 que el de 50 [2].


En principio las variables globales (definidas fuera de una función) son estáticas (en este sentido) y las locales son dinámicas (de la variedad llamada automática) es decir: las primeras pueden conservar su valor entre llamadas y las segundas no.

En este orden de cosas, la declaración como static de una variable local, definida dentro de una función, le confiere permanencia entre las sucesivas llamadas a dicha función (igual que las globales). Desafortunadamente [1], la declaración static de una variable global (que debería ser redundante e innecesaria), supone una declaración de visibilidad, en el sentido de que dicha variable global (aparte de su “estaticidad”), solo será conocida por las funciones dentro del fichero en que se ha declarado.

Resulta así que, desgraciadamente, la palabra clave static tiene un doble sentido (y uso), el primero está relacionado con la duración ( 4.1.5), el segundo con la visibilidad ( 4.1.4).

Finalmente, cuando el modificador static se utiliza para miembros de clase adquiere una peculiaridades específicas ( 4.11.7 Miembros estáticos).

§5 Resumen:

Con el fin de aclarar un poco este pequeño galimatías semántico, resumimos lo dicho:

  • Automático versus Persistente

    Propiedad de los objetos de crearse/destruirse automáticamente (al entrar y salir del bloque de código) o bajo control directo del programador mediante sentencias específicas de creación y destrucción (new y delete). Existen respectivamente en la Pila/Montón. Tanto los objetos automáticos como los persistentes son de naturaleza dinámica.
  • Estático versus Dinámico

    Característica de ser conocido en tiempo de compilación o en tiempo de ejecución, lo que significa que el compilador puede reservar almacenamiento desde el principio o este debe ser creado y destruido en tiempo de ejecución.
§6 Ejemplo:

Intentaremos aclarar los conceptos anteriores comentando el ciclo vital de los elementos en un sencillo programita:

#include <iostream.h>

void func(int);             // prototipo
char* version = "V.0.0";    // L.4:

int main() {                // =============
int x = 1;
char* mensaje = "Programa demo ";
cout << mensaje << endl;
cout << "Introduzca numero de salidas (0 para terminar): ";
while ( x != 0) {
   cin >> x ;
   func(x);
   cout << "Otra vez? (numero): " << endl;
}
return 0;                   // L.15:
}
void func(int i) {          // L.17: definicion
static int j = 1;
cout << "Se han solicitado: " << i << " salidas." << endl;
int* v = new int;           // L.20:
*v = 1;
register int n;             // L.22:
for (n = 1; n <= i; n++) {
   cout << " - " << *v << "/" << i << " total efectuadas: " << j <<
    " salidas." << endl;
   j++; (*v)++;             // L.26:
}
cout << version << endl;    // L.28:
delete v;                   // L.29:
}

Volcado de pantalla con la salida del programa después de marcar 3 y 2 como valores de entrada:

Programa demo
Introduzca numero de salidas (0 para terminar): 3
Se han solicitado: 3 salidas.
- 1/3 total efectuadas: 1 salidas.
- 2/3 total efectuadas: 2 salidas.
- 3/3 total efectuadas: 3 salidas.
V.0.0
Otra vez? (numero):
2
Se han solicitado: 2 salidas.
- 1/2 total efectuadas: 4 salidas.
- 2/2 total efectuadas: 5 salidas.
V.0.0

Comentario

Cuando se inicia el programa, el SO reserva un número determinado de bloques del total de memoria disponible para uso del nuevo ejecutable [4].  Este espacio es exclusivo del programa y no puede ser violado por otra aplicación ni aún intencionadamente; de esto se encarga el propio SO. Por ejemplo, si un puntero de una aplicación se descontrola y señala una zona de memoria que no le pertenece, surge el conocido mensaje Windows: "La aplicación ha efectuado una operación no válida y será detenido...". Si es Linux, el clásico error fatal con volcado de memoria.

Si el programa lo necesita, el espacio destinado inicialmente puede crecer; el SO puede seguir asignando nuevos bloques de memoria. Cuando se acaba la memoria física disponible, los modernos SO empiezan a asignar memoria virtual ( H5.1) haciendo constante intercambio con el disco de las partes que no pueden estar simultáneamente en la memoria central (RAM). Este proceso ("Swapping") es totalmente transparente para el programa usuario, y puede crecer hasta el límite del almacenamiento disponible en disco.  Por supuesto, antes que se alcance este punto, el programa se muestra especialmente perezoso, ya que estos intercambios entre el disco y la RAM son comparativamente lentos.

La ejecución del programa comienza por el módulo de inicio ( 1.5), que crea e inicia las variables estáticas y globales. En este caso, la cadena de caracteres, "V.0.0", accesible mediante el puntero version, y la variable j de la función func. Salvo indicación en contrario, j se habría inicializado a cero, pero en este caso se instruye al compilador (L.18) que se inicialice a 1, que es el valor inicial que queremos para este contador. Observe que esta asignación solo ocurre una vez durante la vida del programa (en el módulo de inicio), no con cada invocación de func.  A partir de este momento, esta variable conserva su valor entre cada invocación sucesiva a la función, aunque va siendo incrementado progresivamente en L.26.

Tanto el puntero version como la cadena señalada por él, permanecen constantes a lo largo de toda la vida del programa, además este nemónico es visible desde todos los puntos (tiene visibilidad global), por eso puede ser utilizado desde el interior de func, en L.28. La variable j, el puntero version y la propia cadena "V.0.0" son creados en el segmento ().

Al llegar a L.15, se inicia la secuencia de finalización ( 1.5). En este momento se destruyan las variables globales anteriormente descritas, así como las locales de la propia función main. El SO recibe un entero como valor devuelto por el programa que termina. Generalmente el valor 0 es sinónimo de terminación correcta; cualquier otro valor significa terminación anormal. En este momento, el SO recupera el espacio de memoria asignada al programa que queda disponible para nuevas aplicaciones, y borra del disco el posible fichero imagen de memoria virtual que hubiera utilizado.

Observe que además de las constantes literales ( 3.2.3f) señaladas por los punteros version y mensaje, el programa utiliza otra serie de literales: "Introduzca numero..."; "Otra vez..."; "Se han solicitado:"; etc.  Todas ellas son constantes conocidas en tiempo de compilación [5]; se trata por tanto de objetos estáticos, mientras que el resto son dinámicos, ya que sus valores solo son conocidos durante la ejecución.

Al ejecutarse la función main, se van creando e iniciando sucesivamente las variables (dinámicas); en este caso el entero x que recibe un valor inicial 1, y una constante de valor cero [5] en la sentencia return (L.15).

Cada invocación a func provoca la creación de un juego de variables dinámicas. En este caso el entero i (argumento recibido por la función), variable local de func que recibe el mismo valor que tiene la variable x de main; el puntero-a-int v y el entero n.

Preste atención a que (suponiendo que el compilador atienda la petición en L.22 4.1.8b), n se crea en el registro (), mientras que i se crea en la pila (). Ambas son de naturaleza automática, por lo que son destruidas al salir de ámbito la función, cosa que ocurre al llegar al corchete de cierre ( } ) en L.30. Sin embargo, observe que el entero señalado por el puntero v, se crea en el montón (), lo que le confiere existencia persistente; esto hace que el espacio reservado (4 bytes en este caso 2.2.4) tenga que ser específicamente desasignado (en L.29), pues de lo contrario cada invocación de func supondría la pérdida irrecuperable (para el programa) de 4 bytes de memoria. Suponiendo que estuviésemos corriendo el programa en un servidor, seríamos directamente responsables de una progresiva ralentización del sistema (posiblemente hasta que el "Sysmanager" descubriera una utilización inusual de recursos por nuestra parte y nos desconectara).

  Inicio.


[1] Decimos "desafortunadamente" porque en este punto el estándar es un poco arbitrario, utiliza un criterio no demasiado homogéneo desde el punto de vista semántico.

[2] El cálculo del factorial de un número es el ejemplo clásico de aplicación elegante de una función recursiva ( 4.4.6b).

[3] En la literatura inglesa, incluyendo los propios manuales de Borland, a los objetos almacenados en el montón ("Heap") se les denomina "heap-objects" o "dynamic objects". Si embargo esta última designación es bastante desafortunada y puede inducir a confusión, dado que los objetos de la pila ("Stack") también son de naturaleza dinámica.

[4]  Suponemos un entorno de ejecución moderno del tipo Windows-32, es decir, un Sistema Operativo multiprograma.

[5]  No está garantizado que el compilador asigne un Lvalue a este tipo de constantes ( 3.2.3).