25 Tipos de Variables VII: tipos de almacenamiento

Existen ciertos modificadores de variables que se nos estaban quedando en el tintero y que no habíamos visto todavía. Estos modificadores afectan al modo en que se almacenan las variables y a su ámbito temporal, es decir, la zona de programa desde donde las variables son accesibles.

En el capítulo 6 ya comentamos algo sobre el ámbito de las variables. Los tipos de almacenamiento son los que definen el ámbito de las variables u objetos. Los modificadores, por su parte, permiten alterar el comportamiento de esas variables.

Se distinguen dos aspectos en cuanto al ámbito: el temporal, o duración y el de acceso o alcance o visibilidad.

El ámbito de acceso es la parte de código desde el que un objeto es accesible.

El ámbito temporal se refiere al periodo de ejecución en que existe un objeto.

Ambos quedan definidos por el tipo de almacenamiento, y este tipo, a su vez, queda definido por los modificadores.

C++ dispone de los siguientes especificadores de tipo de almacenamiento: auto, extern, static y register.

Hay además algunos modificadores: const y volatile.

Por último, hay otra palabra reservada: mutable, que se suele considerar un especificador, aunque en mi opinión, es más bien un modificador.

Almacenamento automático

Para especificar el tipo de almacenamiento automático se usa el especificador auto.

Sintaxis:

[auto] <tipo> <nombre_variable>;

Sirve para declarar variables automáticas o locales. Es el modificador por defecto cuando se declaran variables u objetos locales, es decir, si no se especifica ningún modificador, se creará una variable automática.

Estas variables se crean durante la ejecución, y se elige el tipo de memoria a utilizar en función del ámbito temporal de la variable. Una vez cumplido el ámbito, la variable es destruida. Es decir, una variable automática local de una función se creará cuando sea declarada, y se destruirá al terminar la función. Una variable local automática de un bucle será destruida cuando el bucle termine.

Debido a que estos objetos serán creados y destruidos cada vez que sea necesario, usándose, en general, diferentes posiciones de memoria, su valor se perderá cada vez que sean creadas, perdiéndose el valor previo en cada caso.

Por supuesto, no es posible crear variables automáticas globales, ya que son conceptos contradictorios.

Almacenamiento estático

Para especificar este tipo de almacenamiento se usa el especificador static.

Sintaxis:

static <tipo> <nombre_variable>;
static <tipo> <nombre_de_función>(<lista_parámetros>);

Cuando se usa en la declaración de objetos, este especificador hace que se asigne una dirección de memoria fija para el objeto mientras el programa se esté ejecutando. Es decir, su ámbito temporal es total. En cuanto al ámbito de acceso conserva el que le corresponde según el punto del código en que aparezca la declaración.

Debido a que el objeto tiene una posición de memoria fija, su valor permanece, aunque se trate de un objeto declarado de forma local, entre distintas reentradas en el ámbito del objeto. Por ejemplo si se trata de un objeto local a una función, el valor del objeto se mantiene entre distintas llamadas a la función.

Hay que tener en cuenta que los objetos estáticos no inicializados toman un valor nulo.

Por el contrario, si se le da un valor inicial a una variable estática, la asignación sólo afecta a la primera vez que es declarada.

#include <iostream>

using namespace std;

int funcion();

int main() {
    for(int i = 0; i < 10; i++)
        cout << "Llamada " << i+1 << ": " << funcion() << endl;
    return 0;
}

int funcion() {
    static int x=10;

    x++;
    return x;
}

La salida de este programa será:

Llamada 1: 11
Llamada 2: 12
Llamada 3: 13
Llamada 4: 14
Llamada 5: 15
Llamada 6: 16
Llamada 7: 17
Llamada 8: 18
Llamada 9: 19
Llamada 10: 20

Nota: en realidad, el compilador analiza el código fuente y crea el código ejecutable necesario para crear todas las variables estáticas antes de empezar la ejecución el programa, asignando sus valores iniciales, si se indican o cero en caso contrario. De modo que cuando se llama a la funcion no se crea el objeto x, sino que se usa directamente.
En este caso, desde el punto de vista del ámbito temporal, x se comporta como un objeto global, pero para el ámbito de acceso se trata de un objeto local de funcion.

Este tipo de almacenamiento se usa con el fin de que las variables locales de una función conserven su valor entre distintas llamadas sucesivas a la misma. Las variables estáticas tienen un ámbito local con respecto a su accesibilidad, pero temporalmente son como las variables externas.

Parecería lógico que, análogamente a lo que sucede con el especificador auto, no tenga sentido declarar objetos globales como estáticos, ya que lo son por defecto. Sin embargo, el especificador static tiene un significado distinto cuando se aplica a objetos globales. En ese caso indica que el objeto no es accesible desde otros ficheros fuente del programa.

En el caso de las funciones, el significado es el mismo, las funciones estáticas sólo son accesibles desde el fichero en que están declaradas.

Nota: Veremos en el capítulo siguiente que esto se puede conseguir en C++ de una forma más clara usando espacios con nombre.

Almacenamiento externo

Para especificar este tipo de almacenamiento se usa la palabra extern.

Sintaxis:

extern <tipo> <nombre_variable>;
[extern] <tipo> <nombre_de_función>(<lista_parámetros>);

De nuevo tenemos un especificador que se puede aplicar a funciones y a objetos. O más precisamente, a prototipos de funciones y a declaraciones de objetos.

Este especificador se usa para indicar que el almacenamiento y valor de una variable o la definición de una función están definidos en otro módulo o fichero fuente. Las funciones declaradas con extern son visibles por todos los ficheros fuente del programa, salvo que (como vimos más arriba) se defina la función como static.

El especificador extern sólo puede usarse con objetos y funciones globales.

En el caso de las funciones prototipo, el especificador extern es opcional. Las declaraciones de prototipos son externas por defecto.

Este especificador lo usaremos con programas que usen varios ficheros fuente, que será lo más normal con aplicaciones que no sean ejemplos o aplicaciones simples.

Veamos un ejemplo de un programa con dos ficheros fuente:

// principal.cpp
#include <iostream>

using namespace std;

int x=100; // Declaración e inicialización

int funcion(); // extern por defecto

int main() {
    cout << funcion() << endl;
    return 0;
}

Fichero fuente de "modulo.cpp":

// modulo.cpp

extern int x; // x es externa

int funcion() { // definición de la función
    return x;
}

En este ejemplo vemos que no es necesario declarar el prototipo como extern. En el fichero "modulo.cpp" declaramos x como externa, ya que está declarada y definida en el fichero "principal.cpp", y porque necesitamos usar su varlor.

Se puede usar extern "c" con el fin de prevenir que algún nombre de función escrita en C pueda ser ocultado por funciones de programas C++. Este especificador no se refiere al tipo de almacenamiento, ya que sabemos que en el caso de prototipos de funciones es el especificador por defecto. En realidad es una directiva que está destinada al enlazador, y le instruye para que haga un enlazado "C", distinto del que se usa para funciones en C++.

Almacenamiento en registro

Para especificar este tipo de almacenamiento se usa el especificador register.

Sintaxis:

register <tipo> <nombre_variable>;

Indica al compilador una preferencia para que el objeto se almacene en un registro de la CPU, si es posible, con el fin de optimizar su acceso, consiguiendo una mayor velocidad de ejecución.

Los datos declarados con el especificador register tienen el mismo ámbito que las automáticas. De hecho, sólo se puede usar este especificador con parámetros y con objetos locales.

El compilador puede ignorar la petición de almacenamiento en registro, que se acepte o no estará basado en el análisis que realice el compilador sobre cómo se usa la variable.

Un objeto de este tipo no reside en memoria, y por lo tanto no tiene una dirección de memoria, es decir, no es posible obtener una referencia a un objeto declarado con el tipo de almacenamiento en registro.

Se puede usar un registro para almacenar objetos de tipo char, int, float, punteros. En general, objetos que quepan en un registro.

#include <iostream>

using namespace std;

void funcion(register int *x);

int main() {
    int s[10] = {1, 2, 1, 5, 2, 7, 3, 1, 3, 0};
    funcion(s);
    return 0;
}

void funcion(register int *x) {
    register char a = 'a';

    for(register int i=0; i < 10; i++) {
        cout << *x++ << " " << a++ << endl;
    }
}

Modificador de almacenamiento constante

El modificador const crea nuevos tipos de objetos, e indica que el valor de tales objetos no puede ser modificado por el programa. Los tipos son nuevos en el sentido de que const int es un tipo diferente de int. Veremos que en algunos casos no son intercambiables.

Sintaxis:

const <tipo> <variable> = <inicialización>;
const <tipo> <variable_agregada> = {<lista_inicialización>};
<tipo> <nombre_de_función> (const <tipo>*<nombre-de-variable> );
const <tipo> <nombre_de_función>(<lista_parámetros>);
<tipo> <nombre_de_función_miembro>(<lista_parámetros>) const;

Lo primero que llama la atención es que este modificador se puede aparecer en muchas partes diferentes de un programa C++. En cada una de las sintaxis expuestas el significado tiene matices diferentes, que explicaremos a continuación.

De las dos primeras se deduce que es necesario inicializar siempre, los objetos declarados como constantes. Puesto que el valor de tales objetos no puede ser modificado por el programa posteriormente, será imprescindible asignar un valor en la declaración. C++ no permite dejar una constante indefinida.

Cuando se trata de un objeto de un tipo agregado: array, estructura o unión, se usa la segunda forma.

En C++ es preferible usar este tipo de constantes en lugar de constantes simbólicas (macros definidas con #define). El motivo es que estas constantes tienen un tipo declarado, y el compilador puede encontrar errores por el uso inapropiado de constantes que no podría detectar si se usan constantes simbólicas.

Hay una excepción, que en realidad no es tal, y es que la declaración tenga además el especificador extern. En ese caso, como vimos antes, no estamos haciendo una definición, no se crea espacio para el objeto, y por lo tanto, no necesitamos asignar un valor. Una declaración extern indica al compilador que la definición del objeto está en otra parte del programa, así como su inicialización.

Cuando se usa con parámetros de funciones, como en el caso tercero, impide que el valor de los parámetros sea modificado por la función.

Sabemos que los parámetros son pasados por valor, y por lo tanto, aunque la función modifique sus valores, estos cambios no afectan al resto del programa fuera de la función, salvo que se trate de punteros, arrays o referencias. Es precisamente en estos tres casos cuando el modificador tiene aplicación, impidiendo que el código de la función pueda modificar el valor de los objetos referenciados por los parámetros.

Los intentos de hacer estas modificaciones se detectan en la fase de compilación, de modo que en realidad, a quien se impide que haga estas modificaciones es al programador. En ese sentido, la declaración de un parámetro constante nos compromete como programadores a no intentar modificar el valor de los objetos referenciados.

#include <iostream>

using namespace std;

void funcion(const int *x);

int main() {
    int s = 100;
    funcion(&s);
    return 0;
}

void funcion(const int *x) {
    (*x)++; // ERROR: intento de modificar un valor constante
}

El compilador dará un error al intentar compilar este ejemplo.

Nota: El compilador puede referirse a estos objetos como de "sólo lectura", o "read-only". Viene a significar lo mismo: ya que podemos leer el valor de una constante, pero no escribirlo (o modificarlo).

Con las referencias pasa algo similar:

void funcion(const int &x);

En este caso no podremos modificar el valor del objeto referenciado por x.

Esto tiene varias utilidades prácticas:

  • Imaginemos que sólo disponemos del prototipo de la función. Por ejemplo, la función strlen tiene el siguiente prototipo: int strlen(const char*). Esto nos dice que podemos estar seguros de que la función no alterará el valor de la cadena que pasamos como parámetro, y por lo tanto no tendremos que tomar ninguna medida extraordinaria para preservar ese valor. Hay que tener en cuenta que no podemos pasar arrays por valor.
  • Otro caso es cuando queremos pasar como parámetros objetos que no queremos que sean modificados por la función. En ese caso tenemos la opción de pasarlos por valor, y de este modo protegerlos. Pero si se trata de objetos de gran tamaño, resulta muy costoso (en términos de memoria y tiempo de proceso) hacer copias de tales objetos, y es preferible usar una referencia. Si queremos dejar patente que la función no modificará el valor del objeto declararemos el parámetro como una referencia constante.

Cuando se aplica al valor de retorno de una variable, como en el cuarto caso, el significado es análogo. Evidentemente, cuando el valor de retorno no es una referencia, no tiene sentido declararlo como constante, ya que lo será siempre. Pero cuando se trate de referencias, este modificador impide que la variable referenciada sea modificada.

#include <iostream>
using namespace std;
 
int y;

const int &funcion();
 
int main() {
    // funcion()++; // Ilegal (1)
    cout << ", " << y << endl;
     return 0; 
}
 
const int &funcion() { 
   return y;
}

Como vemos en (1) no nos es posible modificar el valor de la referencia devuelta por "funcion".

El último caso, cuando el modificador se añade al final de un prototipo de función de una clase o estructura, indica que tal función no modifica el valor de ningun dato miembro del objeto. Cuando veamos con detalle clases, veremos que tiene gran utilidad.

Punteros constantes y punteros a constantes

Este matiz es importante, no es lo mismo un puntero constante que un puntero a una constante.

Declaración de un puntero constante:

<tipo> *const <identificador>=<valor inicial>

Declaración de un puntero a una constante:

const <tipo> *<identificador>[=<valor inicial>]

En el primero caso estaremos declarando un objeto constante de tipo puntero. Por lo tanto, deberemos proporcionar un valor inicial, y este puntero no podrá apuntar a otros objetos durante toda la vida del programa.

En el segundo caso estaremos declarando un puntero a un objeto constante. El puntero podrá apuntar a otros objetos, pero ningún objeto apuntado mediate este puntero podrá ser modificado.

A un puntero a constante se le pueden asignar tanto direcciones de objetos constantes como de objetos no constantes:

...
    int x = 100;       // Objeto entero
    const int y = 200; // Objeto entero constante

    const int *cp;     // Puntero a entero constante
    cp = &x;           // Asignamos a cp la dirección de un objeto no constante
    cp = &y;           // Asigmanos a cp la dirección de un objeto constante
...

Lo que está claro es que cualquier objeto referenciado por cp nunca podrá ser modificado mediante ese puntero:

...
    int x = 100;       // Objeto entero
    const int y = 200; // Objeto entero constante

    const int *cp;     // Puntero a entero constante
    cp = &x;           // Asignamos a cp la dirección de un objeto no constante
    (*cp)++;           // Ilegal, cp apunta a un objeto constante
    x++;               // Legal, x no es constante, ahora *cp contendrá el valor 101
...

Modificador de almacenamiento volatile

Sintaxis:

volatile <tipo> <nombre_variable>;
<identificador_función> ( volatile <tipo> <nombre_variable> );
<identificador_función> volatile;

Este modificador se usa con objetos que pueden ser modificados desde el exterior del programa, mediante procesos externos. Esta situación es común en programas multihilo o cuando el valor de ciertos objetos puede ser modificado mediante interrupciones o por hardware.

El compilador usa este modificador para omitir optimizaciones de la variable, por ejemplo, si se declara una variable sin usar el modificador volatile, el compilador o el sistema operativo puede almacenar el valor leído la primera vez que se accede a ella, bien en un registro o en la memoria caché. O incluso, si el compilador sabe que no ha modificado su valor, no actualizarla en la memoria normal. Si su valor se modifica externamente, sin que el programa sea notificado, se pueden producir errores, ya que estaremos trabajando con un valor no válido.

Usando el modificador volatile obligamos al compilador a consultar el valor de la variable en memoria cada vez que se deba acceder a ella.

Por esta misma razón es frecuente encontrar juntos los modificadores volatile y const: si la variable se modifica por un proceso externo, no tiene mucho sentido que el programa la modifique.

Las formas segunda y tercera de la sintaxis expuesta sólo se aplica a clases, y las veremos más adelante.

Modificador de almacenamiento mutable

Sintaxis:

class <identificador_clase> {
   ...
   mutable <tipo> <nombre_variable>;
   ...
};

struct <identificador_estructura> {
   ...
   mutable <tipo> <nombre_variable>;
   ...
};

Sirve para que determinados miembros de un objeto de una estructura o clase declarado como constante, puedan ser modificados.

#include <iostream>

using namespace std;
 
struct stA {
  int y;
  int x;
};

struct stB {
   int a;
   mutable int b;
};

int main() {
   const stA A = {1, 2}; // Obligatorio inicializar
   const stB B = {3, 4}; // Obligatorio inicializar
   
//   A.x = 0; // Ilegal (1)
//   A.y = 0;
//   B.a = 0;
   B.b = 0;  // Legal (2)
   return 0; 
}

Como se ve en (2), es posible modificar el miembro "b" del objeto "B", a pesar de haber sido declarado como constante. Ninguno de los otros campos, ni en "A", ni en "B", puede ser modificado.

Palabras reservadas usadas en este capítulo

auto, const, extern, mutable, register, static y volatile.