10.4. Dependencia en la inicialización de variables estáticas

Dentro de una unidad de traducción específica, está garantizado que el orden de inicialización de los objetos estáticos será el mismo que el de aparición de sus definiciones en la unidad de traducción.

No obstante, no hay garantías sobre el orden en que se inicializan los objetos estáticos entre diferentes unidades de traducción, y el lenguaje no proporciona ninguna forma de averiguarlo. Esto puede producir problemas significativos. Como ejemplo de desastre posible (que provocará el cuelgue de sistemas operativos primitivos o la necesidad de matar el proceso en otros más sofisticados), si un archivo contiene:

//: C10:Out.cpp {O}
// First file
#include <fstream>
std::ofstream out("out.txt"); ///:~

Listado 10.25. C10/Out.cpp


y otro archivo utiliza el objeto out en uno de sus inicializadores

//: C10:Oof.cpp
// Second file
//{L} Out
#include <fstream>
extern std::ofstream out;
class Oof {
public:
  Oof() { out << "ouch"; }
} oof;
int main() {} ///:~

Listado 10.26. C10/Oof.cpp


el programa puede funcionar, o puede que no. Si el entorno de programación construye el programa de forma que el primer archivo sea inicializado después del segundo, no habrá problemas. Pero si el segundo archivo se inicializa antes que el primero, el constructor para Oof se sustenta en la existencia de out, que todavía no ha sido construido, lo que causa el caos.

Este problema sólo ocurre con inicializadores de objetos estáticos que dependen el uno del otro. Los estáticos dentro de cada unidad de traducción son inicializados antes de la primera invocación a cualquier función de esa unidad, aunque puede que después de main(). No puede estar seguro del orden de inicialización de objetos estáticos si están en archivos diferentes.

Un ejemplo sutil puede encontrarse en ARM.[68] en un archivo que aparece en el rango global:

extern int y;
int x = y + 1;

y en un segundo archivo también en el ámbitoglobal:

extern int x;
int y = x + 1;

Para todos los objetos estáticos, el mecanismo de carga-enlazado garantiza una inicialización estática a cero antes de la inicialización dinámica especificada por el programador. En el ejemplo anterior, la inicialización a cero de la zona de memoria ocupada por el objeto fstream out no tiene especial relevancia, por lo que realmente no está definido hasta que se llama al constructor. Pese a ello, en el caso de los tipos predefinidos, la inicialización a cero sí tiene importancia, y si los archivos son inicializados en el orden mostrado arriba, y empieza estáticamente inicializada a cero, por lo que x se convierte en uno, e y es dinámicamente inicializada a dos. Pero si los archivos fuesen inicializados en orden opuesto, x sería estáticamente inicializada a cero, y dinámicamente inicializada a uno y después, x pasaría a valer dos.

Los programadores deben estar al tanto de esto porque puede darse el caso de crear un programa con dependencias de inicialización estáticas que funcionen en una plataforma determinada y, de golpe y misteriosamente, compilarlo en otro entorno y que deje de funcionar.

10.4.1. Qué hacer

Existen tres aproximaciones para tratar con este problema:

  1. No hacerlo. Evitar las dependencias de inicialización estática es la mejor solución.

  2. Si debe hacerlo, coloque las definiciones de objetos estáticos críticos en un único fichero, de forma que pueda controlar, de forma portable, su inicialización colocándolos en el orden correcto.

  3. Si está convencido que es inevitable dispersar objetos estáticos entre unidades de traducción diferentes (como en el caso de una librería, donde no puede controlar el programa que la usa), hay dos técnicas de programación para solventar el problema.

Técnica uno

El pionero de esta técnica fue Jerry Schwarz mientras creaba la librería iostream (puesto que las definiciones para cin, cout y cerr son static y residen en archivos diferentes). Realmente es inferior a la segunda técnica pero ha pululado durante mucho tiempo por lo que puede encontrarse con código que la utilice; así pues, es importante que entienda como funciona.

Esta técnica requiere una clase adicional en su archivo de cabecera. Esta clase es la responsable de la inicialización dinámica de sus objetos estáticos de librería. He aquí un ejemplo simple:

//: C10:Initializer.h
// Static initialization technique
#ifndef INITIALIZER_H
#define INITIALIZER_H
#include <iostream>
extern int x; // Declarations, not definitions
extern int y;

class Initializer {
  static int initCount;
public:
  Initializer() {
    std::cout << "Initializer()" << std::endl;
    // Initialize first time only
    if(initCount++ == 0) {
      std::cout << "performing initialization"
                << std::endl;
      x = 100;
      y = 200;
    }
  }
  ~Initializer() {
    std::cout << "~Initializer()" << std::endl;
    // Clean up last time only
    if(--initCount == 0) {
      std::cout << "performing cleanup" 
                << std::endl;
      // Any necessary cleanup here
    }
  }
};

// The following creates one object in each
// file where Initializer.h is included, but that
// object is only visible within that file:
static Initializer init;
#endif // INITIALIZER_H ///:~

Listado 10.27. C10/Initializer.h


Las declaraciones para x e y anuncian tan sólo que esos objetos existen, pero no reservan espacio para los objetos. No obstante, la definición para el Initializer init reserva espacio para ese objeto en cada archivo en que se incluya el archivo de cabecera. Pero como el nombre es static (en esta ocasión controlando la visibilidad, no la forma en la que se almacena; el almacenamiento se produce a nivel de archivo por defecto), sólo es visible en esa unidad de traducción, por lo que el enlazador no se quejará por múltiples errores de definición.

He aquí el archivo con las definiciones para x, y e initCount:

//: C10:InitializerDefs.cpp {O}
// Definitions for Initializer.h
#include "Initializer.h"
// Static initialization will force
// all these values to zero:
int x;
int y;
int Initializer::initCount;
///:~

Listado 10.28. C10/InitializerDefs.cpp


(Por supuesto, una instancia estática de fichero de init también se incluye en este archivo cuando se incluye el archivo de cabecera. Suponga que otros dos archivos se crean por la librería del usuario:

//: C10:Initializer.cpp {O}
// Static initialization
#include "Initializer.h"
///:~

Listado 10.29. C10/Initializer.cpp


y

//: C10:Initializer2.cpp
//{L} InitializerDefs Initializer
// Static initialization
#include "Initializer.h"
using namespace std;

int main() {
  cout << "inside main()" << endl;
  cout << "leaving main()" << endl;
} ///:~

Listado 10.30. C10/Initializer2.cpp


Ahora no importa en qué unidad de traducción se inicializa primero. La primera vez que una unidad de traducción que contenga Initializer.h se inicialice, initCount será cero por lo que la inicialización será llevada a cabo. (Esto depende en gran medida en el hecho que la zona de almacenamiento estático está a cero antes de que cualquier inicialización dinámica se lleve a cabo). Para el resto de unidades de traducción, initCount no será cero y se eludirá la inicialización. La limpieza ocurre en el orden inverso, y ~Initializer() asegura que sólo ocurrirá una vez.

Este ejemplo utiliza tipos del lenguaje como objetos estáticos globales. Esta técnica también funciona con clases, pero esos objetos deben ser inicializados dinámicamente por la clase Initializer. Una forma de hacer esto es creando clases sin constructores ni destructores, pero sí con métodos de inicialización y limpieza con nombres diferentes. Una aproximación más común, de todas formas, es tener punteros a objetos y crearlos utilizando new dentro de Initializer().

Técnica dos

Bastante después de la aparición de la técnica uno, alguien (no sé quien) llegó con la técnica explicada en esta sección, que es mucho más simple y limpia que la anterior. El hecho de que tardase tanto en descubrirse es un tributo a la complejidad de C++.

Esta técnica se sustenta en el hecho de que los objetos estáticos dentro de funciones (sólo) se inicializan la primera vez que se llama a la función. Tenga presente que el problema que estamos intentando resolver aquí no es cuando se inicializan los objetos estáticos (que se puede controlar separadamente) sino más bien asegurarnos de que la inicialización ocurre en el orden adecuado.

Esta técnica es muy limpia y astuta. Para cualquier dependencia de inicialización, se coloca un objeto estático dentro de una función que devuelve una referencia a ese objeto. De esta forma, la única manera de acceder al objeto estático es llamando a la función, y si ese objeto necesita acceder a otros objetos estáticos de los que depende, debe llamar a sus funciones. Y la primera vez que se llama a una función, se fuerza a llevar a cabo la inicialización. Está garantizado que el orden de la inicialización será correcto debido al diseño del código, no al orden que arbitrariamente decide el enlazador.

Para mostrar un ejemplo, aquí tenemos dos clases que dependen la una de la otra. La primera contiene un bool que sólo se inicializa por el constructor, por lo que se puede decir si se ha llamado el constructor por una instancia estática de la clase (el área de almacenamiento estático se inicializa a cero al inicio del programa, lo que produce un valor false para el bool si el constructor no ha sido llamado).

//: C10:Dependency1.h
#ifndef DEPENDENCY1_H
#define DEPENDENCY1_H
#include <iostream>

class Dependency1 {
  bool init;
public:
  Dependency1() : init(true) {
    std::cout << "Dependency1 construction" 
              << std::endl;
  }
  void print() const {
    std::cout << "Dependency1 init: " 
              << init << std::endl;
  }
};
#endif // DEPENDENCY1_H ///:~

Listado 10.31. C10/Dependency1.h


El constructor también indica cuando ha sido llamado, y es posible el estado del objeto para averiguar si ha sido inicializado.

La segunda clase es inicializada por un objeto de la primera clase, que es lo que causa la dependencia:

//: C10:Dependency2.h
#ifndef DEPENDENCY2_H
#define DEPENDENCY2_H
#include "Dependency1.h"

class Dependency2 {
  Dependency1 d1;
public:
  Dependency2(const Dependency1& dep1): d1(dep1){
    std::cout << "Dependency2 construction ";
    print();
  }
  void print() const { d1.print(); }
};
#endif // DEPENDENCY2_H ///:~

Listado 10.32. C10/Dependency2.h


El constructor se anuncia a si mismo y imprime el estado del objeto d1 por lo que puede ver si éste se ha inicializado cuando se llama al constructor.

Para demostrar lo que puede ir mal, el siguiente archivo primero pone las definiciones de los objetos estáticos en el orden incorrecto, tal y como sucedería si el enlazador inicializase el objeto Dependency2 antes del Dependency1. Después se invierte el orden para mostrar que funciona correctamente si el orden resulta ser el correcto. Finalmente, se muestra la técnica dos.

Para proporcionar una salida más legible, se ha creado la función separator(). El truco está en que usted no puede llamar a la función globalmente a menos que la función sea utilizada para llevar a cabo la inicialización de la variable, por lo que separator() devuelve un valor absurdo que es utilizado para inicializar un par de variables globales.

//: C10:Technique2.cpp
#include "Dependency2.h"
using namespace std;

// Returns a value so it can be called as
// a global initializer:
int separator() {
  cout << "---------------------" << endl;
  return 1;
}

// Simulate the dependency problem:
extern Dependency1 dep1;
Dependency2 dep2(dep1);
Dependency1 dep1;
int x1 = separator();

// But if it happens in this order it works OK:
Dependency1 dep1b;
Dependency2 dep2b(dep1b);
int x2 = separator();

// Wrapping static objects in functions succeeds
Dependency1& d1() {
  static Dependency1 dep1;
  return dep1;
}

Dependency2& d2() {
  static Dependency2 dep2(d1());
  return dep2;
}

int main() {
  Dependency2& dep2 = d2();
} ///:~

Listado 10.33. C10/Technique2.cpp


Las funciones d1() y d2() contienen instancias estáticas de los objetos Dependency1 y Dependency2. Ahora, la única forma de acceder a los objetos estáticos es llamando a las funciones y eso fuerza la inicialización estática en la primera llamada a la función. Esto significa que se garantiza la inicialización correcta, cosa que verá cuando lance el programa y observe la salida.

He aquí como debe organizar el código para usar esta técnica. Ordinariamente, los objetos estáticos deben ser definidos en archivos diferentes (puesto que se ha visto forzado a ello por alguna razón; recuerde que definir objetos estáticos en archivos diferentes es lo que causa el problema), por lo que definirá las funciones envoltorio wrapping functions) en archivos diferentes. Pero éstas necesitan estar declaradas en los archivos de cabecera:

//: C10:Dependency1StatFun.h
#ifndef DEPENDENCY1STATFUN_H
#define DEPENDENCY1STATFUN_H
#include "Dependency1.h"
extern Dependency1& d1();
#endif // DEPENDENCY1STATFUN_H ///:~

Listado 10.34. C10/Dependency1StatFun.h


En realidad, el «extern» es redundante para la declaración de la función. Éste es el segundo archivo de cabecera:

//: C10:Dependency2StatFun.h
#ifndef DEPENDENCY2STATFUN_H
#define DEPENDENCY2STATFUN_H
#include "Dependency2.h"
extern Dependency2& d2();
#endif // DEPENDENCY2STATFUN_H ///:~

Listado 10.35. C10/Dependency2StatFun.h


Ahora, en los archivos de implementación donde previamente habría situado las definiciones de los objetos estáticos, situará las definiciones de las funciones envoltorio:

//: C10:Dependency1StatFun.cpp {O}
#include "Dependency1StatFun.h"
Dependency1& d1() {
  static Dependency1 dep1;
  return dep1;
} ///:~

Listado 10.36. C10/Dependency1StatFun.cpp


Presumiblemente, otro código puede también componer esos archivos. He aquí otro archivo:

//: C10:Dependency2StatFun.cpp {O}
#include "Dependency1StatFun.h"
#include "Dependency2StatFun.h"
Dependency2& d2() {
  static Dependency2 dep2(d1());
  return dep2;
} ///:~

Listado 10.37. C10/Dependency2StatFun.cpp


Ahora hay dos archivos que pueden ser enlazados en cualquier orden y si contuviesen objetos estáticos ordinarios podría producirse cualquier orden de inicialización. Pero como contienen funciones envoltorio, no hay posibilidad de inicialización incorrecta:

//: C10:Technique2b.cpp
//{L} Dependency1StatFun Dependency2StatFun
#include "Dependency2StatFun.h"
int main() { d2(); } ///:~

Listado 10.38. C10/Technique2b.cpp


Cuando ejecute este programa verá que la inicialización del objeto estático Dependency1 siempre se lleva a cabo antes de la inicialización del objeto estático Dependency2. También puede ver que ésta es una solución bastante más simple que la de la uno.

Puede verse tentado a escribir d1() y d2() como funciones inline dentro de sus respectivos archivos de cabecera, pero eso es algo que, definitivamente, no debe hacer. Una función inline puede ser duplicada en cada archivo en el que aparezca y esa duplicación incluye la definición de los objetos estáticos. Puesto que las funciones inline llevan asociado por defecto enlazado interno, esto provocará la aparición de múltiples objetos estáticos entre las diversas unidades de traducción, lo que ciertamente causará problemas. Es por eso que debe asegurarse que sólo existe una única definición para cada función contenedora, y eso significa no hacerlas inline.



[68] Bjarne Stroustrup and Margaret Ellis, The Annotated C++ Reference Manual, Addison-Wesley, 1990, pp. 20-21.