À propos des spécifications d'entreposage (Storage Class Specifiers) en C++

Ce qui suit est une adaptation d'une réponse à une question du chic Éric Gagnon, un de mes étudiants à l'Université de Sherbrooke à l'hiver 2012. Sa question portait sur le rôle du mot clé extern, mais pour expliquer ce dernier il faut aussi expliquer l'ensemble des spécifications d'entreposage, du moins à mon humble avis.

Pour des raccourcis vers chacune des qualifications d'entreposage, voir :

Conceptuellement proches des spécifications d'entreposage, on trouve aussi constexpr, consteval et inline entre autres (voir [dcl.constexpr] et [dcl.inline] pour le texte officiel)

Le standard de C++ (version C++ 17) liste quatre spécifications d'entreposage : static, thread_local, extern et mutable (voir [dcl.spc] pour le texte officiel). Ces spécifications sont mutuellement exclusives.

Pour les besoins de la discussion, je couvrirai aussi le mot clé inline dans ce document. Il y a suffisamment de proximité et de recoupement entre ce mot et ceux décrivant des spécifications d'entreposage pour que, d'un point de vue pédagogique, ils soient présentés comme un tout.

Qu'est-ce qu'une spécification d'entreposage?

Une spécification d'entreposage informe le compilateur quant à certaines particularités la durée de vie d'un nom et de sa visibilité lors de l'édition des liens.

Si une variable est déclarée sans spécification d'entreposage, alors sa spécification d'entreposage est « automatique ». Autrefois, il était possible d'utiliser le mot clé auto pour expliciter ceci, mais c'était redondant et personne ne le faisait; le mot clé auto a donc changé de sens depuis C++ 11 et est désormais (très!) pratique. En C++, la durée de vie d'une variable automatique est délimitée par sa portée, ce qui a mené au développement de l'idiome RAII.

Spécification extern

Une variable globale ou une fonction déclarée avec extern sera visible à l'édition des liens. C'est d'ailleurs le comportement par défaut des déclarations de fonctions.

Par exemple, ceci ne compilera pas dû à une violation d'ODR :

a.h a.cpp principal.cpp
#ifndef A_H
#define A_H
int n;
#endif
#include "a.h"
#include "a.h"
int main() {
}

... car la ligne int n; dans a.h est une définition de n, et cette définition existera deux fois (dans a.cpp et dans principal.cpp), alors que ceci compilera (mais demeure une mauvaise idée; les globales ne vous aiment pas, alors rendez-leur bien) :

a.h a.cpp principal.cpp
#ifndef A_H
#define A_H
extern int n;
#endif
#include "a.h"
int n {};
#include "a.h"
int main() {
}

Dans ce cas, il y aura une seule variable globale n de type int. La ligne extern int n; dans a.h est une déclaration, et il est possible de répéter une déclaration en C++, alors que la ligne int n{}; dans a.cpp est une (unique) définition.

Les déclarations extern "C" (entre autres)

Notez que le mot extern sert aussi à des fins connexes, par exemple spécifier qu'une déclaration ou qu'une définition de fonction (typiquement avec extern "C") respecte certaines règles d'un autre langage (habituellement, le langage C) pour faciliter certains interfaçages. En effet, il arrive que l'on voit apparaître dans du code C++ des déclarations telles que la suivante :

extern "C" {
   int f(int);
   float g(int, double);
}

Ici, le mot extern prend un sens légèrement différent : celui d'indiquer au compilateur que les fonctions entre les accolades (f() prenant en paramètre un int et g() prenant en paramètre un int puis un double, dans l'ordre) sont générées selon la convention d'un autre langage que C++ – ici, et presque toujours en pratique, on parle de la convention C, d'où le "C" suivant le mot extern.

Les conventions de nommage exactes varient selon les compilateurs, mais C est le langage de référence pour l'interopérabilité, dû à la simplicité de son modèle.

Pour comparer les noms générés par deux compilateurs, et constater de visu qu'ils ne sont pas portables, voir ce comparatif proposé par Gabriel Aubut-Lussier : https://gcc.godbolt.org/z/YdJ1LX

Notez que C++ permet à plusieurs fonctions d'avoir le même nom mais des signatures différentes, ce qui force le compilateur à générer des noms dits « décorés » dans le code machine, pas des noms « bruts ». Ainsi, une fonction comme int f(int) en C++ pourrait se nommer en pratique ?f@@YAHH@Z (c'est le nom qui était généré par Visual Studio 2010; ce Name Mangling varie d'un compilateur à l'autre et n'est pas portable) au niveau machine, pour être distincte d'autres fonctions f() possibles comme f(double), f(void) ou f(string,int&) par exemple.

En C, dans le code machine généré lors de la compilation, la fonction f(int) se nommera probablement f ou _f, tout simplement, car elle seule pourra porter ce nom.

On utilisera donc typiquement extern "C" { /*...*/ } pour deux raisons :

Spécification inline

Depuis C++ 17, la spécification inline comble un espace entre extern et static.

Par exemple, ceci ne compilera pas dû à une violation d'ODR :

a.h a.cpp principal.cpp
#ifndef A_H
#define A_H
int n;
#endif
#include "a.h"
#include "a.h"
int main() {
}

... car la ligne int n; dans a.h est une définition de n, et cette définition existera deux fois (dans a.cpp et dans principal.cpp), alors que ceci compilera (mais demeure une mauvaise idée; les globales ne vous aiment pas, alors rendez-leur bien) :

a.h a.cpp principal.cpp
#ifndef A_H
#define A_H
inline int n{};
#endif
#include "a.h"
#include "a.h"
int main() {
}

Dans ce cas, il y aura une seule variable globale n de type int. La ligne inline int n; dans a.h est une définition, mais le compilateur est en charge de s'assurer qu'il n'y ait pas de violation d'ODR.

Ceci permet entre autres de remplacer ceci :

perso.h perso.cpp
#ifndef PERSO_H
#define PERSO_H
#include <string>
class Perso {
   static const std::string NOM_DEFAUT;
   std::string nom;
   // ...
};
#endif
#include "perso.h"
#include <string>
using std::string;
const string Perso::NOM_DEFAUT = "Inconnu(e)";
// ...
... par cela :

perso.h perso.cpp
#ifndef PERSO_H
#define PERSO_H
#include <string>
class Perso {
   static inline const std::string NOM_DEFAUT = "Inconnu(e);
   std::string nom;
   // ...
};
#endif
#include "perso.h"
#include <string>
using std::string;
// ...

... ce qui rend sans doute C++ plus facile d'approche pour qui y arrive d'un langage où la compilation séparée n'est pas une réalité.

Spécification mutable

Un attribut d'instance mutable échappera à la qualification const.

D'ailleurs, mutable ne s'applique qu'aux attributs d'instance, pas aux attributs de classe (il est donc incorrect de spécifier un attribut à la fois mutable et static).

Supposons à titre d'exemple la classe X à droite, offrant deux services importants nommés mA() et mB(). Nous souhaitons savoir lequel des deux services sera appelé le plus souvent, mais ces services sont const.

En qualifiant les variables cptA et cptB de mutable, il nous est possible de les modifier même si les services mA() et mB() sont const. Le message que nous exprimons ici est que ces variables ne participent pas au côté const de l'interface de la classe X; au contraire, elles sont des détails d'implémentation, des outils pour fins de gestion interne seulement.

Autres cas types de recours à mutable :

  • Un mutex, en situation multiprogrammée, offre des méthodes lock() et unlock() non-const. Toutefois, il est fréquent qu'un mutex soit mutable pour permettre de synchroniser les accès aux états d'un objet même dans des méthodes const
  • Un système de Caching : une classe pourrait implémenter une Cache interne pour fins d'optimisation, et souhaiter qu'une méthode const mette cette Cache à jour. Le mot-clé mutable est alors tout indiqué

 

class X {
   mutable int cptA = 0;
   mutable int cptB = 0;
public:
   int mA() const {
      // ... calculs complexes
      ++cptA; // ne participe pas à l'interface
      // ... autres calculs complexes
   }
   int mB() const {
      // ... calculs complexes
      ++cptB; // ne participe pas à l'interface
      // ... autres calculs complexes
   }
   int nbAppelsA() const {
      return cptA;
   }
   int nbAppelsB() const {
      return cptB;
   }
};

Spécification static

Une variable static a une durée de vie délimitée par l'exécution du programme. Son initialisation précède sa première utilisation, et si elle est utilisée, sa destruction précèdera la fin de l'exécution du programme.

Dans une classe ou un struct, le mot static signifie aussi membre de classe, au sens où ce membre sera partagé par toutes les instances de la classe.

Une fonction globale ou une variable static n'est pas visible à l'édition des liens. Par exemple, ceci ne compilera pas dû à une violation d'ODR :

a.h a.cpp principal.cpp
#ifndef A_H
#define A_H
int n;
#endif
#include "a.h"
#include "a.h"
int main() {
}

... car la ligne int n; dans a.h est une définition de n, et cette définition existera deux fois (dans a.cpp et dans principal.cpp), alors que ceci compilera (mais demeure une mauvaise idée; les globales ne vous aiment pas, alors rendez-leur bien) :

a.h a.cpp principal.cpp
#ifndef A_H
#define A_H
// initialisation implicite à zéro
static int n;
#endif
#include "a.h"
#include "a.h"
int main() {
}

Dans ce cas, il y aura une variable globale n de type int dans chaque .cpp incluant a.h, mais celles-ci n'apparaissent pas à l'édition des liens (elles sont véritablement locales à chaque fichier objet).

La qualification static est particulièrement utile pour une fonction C destinée à demeurer locale à un fichier source. Puisque ce langage ne permet qu'un nom par fonction, les noms y sont précieux; utiliser une fonction f() dans un fichier source donné rend ce nom inutilisable pour tous les autres fichiers, sauf si la fonction n'est pas visible à l'édition des liens.

Contrôler la visibilité à l'édition des liens – static et extern

Le mot extern, comme le mot static, existe d'abord et avant tout pour contrôler la visibilité d'un nom lors de l'édition des liens :

Pour comprendre le rôle de ces deux qualifications, imaginez ceci :

a.h a.cpp principal.cpp
#ifndef A_H
#define A_H
int x = 3; // très mauvaise idée
#endif
#include "a.h"
#include "a.h"
int main() {
}

Nous aurions ici une erreur d'édition des liens, car l'inclusion lexicale de a.h dans a.cpp insère le texte de a.h dans a.cpp et y définit le nom global x associé à un int, et l'inclusion de a.h dans principal.cpp fait de même à cet endroit. Conséquence, il y a deux int x globaux au moment de l'édition des liens, une violation directe de la règle ODR.

Une « solution » possible serait d'apposer à x la qualification static, en changeant a.h pour que ce fichier devienne :

#ifndef A_H
#define A_H
static int x = 3; // pas fort non plus, sauf exception
#endif

À ce moment, les deux .cpp (a.cpp et principal.cpp) compileraient, mais les variables x vues par l'un et par l'autre seraient des variables distinctes l'une de l'autre (si main() devait incrémenter son x, cela n'aurait pas d'impact sur le x de a.cpp qui est une autre bestiole). Ça peut surprendre, disons.

Une autre « solution » possible serait d'apposer à x la qualification extern. Ceci impliquerait deux changements, soit un à a.h qui deviendrait :

#ifndef A_H
#define A_H
extern int x; // ceci est une déclaration mais n'est pas une définition
#endif

...et un à a.cpp (par exemple) qui deviendrait :

#include "a.h"
int x = 3; // ceci est une définition

Ici, le nom x, associé à un int global, est visible à toutes les unités de traduction (tous les .cpp du projet) mais une seule définition en existe, celle que j'ai placée dans a.cpp pour cet exemple.

Ces définitions sont très informelles...

Une déclaration, c'est un peu comme un prototype de fonction : ça introduit un nom, ça décrit certaines caractéristiques qui lui sont associées (p. ex. : un type, une signature), mais ça ne l'instancie pas. Une définition, c'est (en gros) un appel de constructeur ou le code d'une fonction, et il n'en faut qu'une seule par objet (ici, si un autre .cpp définit un int x global, on aura encore une fois une erreur à l'édition des liens).

Contrairement à l'exemple précédent, qui utilisait un int qualifié static, celui-ci (x qualifié extern) partage un seul et même x entre les divers fichiers sources qui en connaissent la déclaration. C'est beaucoup moins utilisé maintenant qu'à une autre époque parce que les variables globales, ça tend à très mal coexister avec les systèmes à plusieurs processeurs et avec le code multiprogrammé. La qualification extern peut être sympathique avec des constantes, par exemple si nous souhaitons exposer un nom dans un .h mais pas la valeur qui lui est associée.

Les fonctions globales qualifiées static sont surtout utiles en C, où on ne peut avoir deux fonctions du même nom, peu importe le nombre de paramètres, leurs types et ainsi de suite. Si quelqu'un a écrit une petite fonction comme :

int f() {
   return 3;
}

...dans un fichier .c quelque part, cela entraînera des conflits à l'édition des liens avec toute autre fonction f() définie dans un autre .c du même projet, même s'il n'y a pas de prototype pour cette fonction, du moins avec les compilateurs C que j'ai utilisés (je n'ai pas joué avec C99 ou avec C11). C'est agaçant en l'absence d'espaces nommés ou d'autres techniques pour contraindre la portée des noms.

Pour cette raison, en C, on écrira plutôt ceci si f() est un outil interne à un fichier source, plutôt qu'une fonction à partager avec les autres :

static int f() {
   return 3;
}

Cela règle le problème, en général.

Enfin, avec les classes, il y a des subtilités :

//
// inclusions, using, #define... omis par souci d'économie
//
class X {
    static int n = 3; // illégal
    static const int N = 3; // semi-légal
    enum { M = 3 }; // légal
    static float f = 3.14159f; // illégal
    static const float F = 3.14159f; // illégal
    static auto fct0() {
       return "Yo"s; // string
    }
    static string fct1() {
       static const string TXT = "Ya";
       return TXT;
    }
    void exec() {
       static int nappels = 0;
       ++nappels;
    }
};

L'idée générale est qu'un membre (attribut ou méthode) dans une classe (ou un struct) sera un membre d'instance, appartenant en propre à chaque instance de la classe et ayant une existence distincte pour elle. Si on lui accole la qualification static, elle devient un membre de classe, appartenant à la classe en soi plutôt qu'à ses instances. À mon humble avis, ce choix terminologique visait à faire l'économie d'un mot clé, mais je spécule ici.

Dans l'ordre :

Le cas de X::exec() est intéressant, au sens où tel qu'il est écrit, il s'agit sans doute d'une erreur de logique. En effet, techniquement, nappels est une variable globale (tous les X la partagent, même si elle est dans une méthode d'instance). et l'opérateur ++ sur un entier n'est pas atomique. Ainsi, si plusieurs appels à exec() sur diverses instances de X ont lieu concurremment, il est possible que nappels ait par la suite une valeur incorrecte.

Toutefois, dans du code C ou C++ monoprogrammé, on voyait parfois l'idiome suivant :

void f() {
   static bool first_pass = true;
   if (first_pass) {
      // initialisation, à ne faire qu'une seule fois
      first_pass = false;
   }
   // code à faire lors de chaque appel
}

... dans les fonctions pour que du code puisse n'être exécuté que lors du tout premier appel. Ce n'est pas une technique très adaptée au monde contemporain, cependant (depuis C++ 11, préférez std::call_once() qui est Thread-Safe).

Si nous souhaitons revenir à la classe X plus haut, et faire en sorte que cette classe compile correctement, nous pouvons écrire ceci :

X.h X.cpp
#ifndef X_H
#define X_H
#include <string>
class X {
    static int n; // déclaration
    static const int N = 3; // déclaration + « définition »
    enum { M = 3 }; // déclaration + définition
    static float f; // déclaration
    static const float F; // déclaration
    static auto fct0() {
       return "Yo"s;
    }
    static string fct1() {
       static const string TXT = "Ya";
       return TXT;
    }
    void exec() {
       static int nappels = 0;
       ++nappels;
    }
};
#endif
#include "X.h"
int X::n = 3; // définition
float X::f = 3.14159f; // définition
const float X::F = 3.14159f; // définition

...ou encore, si nous souhaitons vraiment une portabilité à toute épreuve, nous pouvons écrire cela :

X.h X.cpp
#ifndef X_H
#define X_H
#include <string>
class X {
    static int n; // déclaration
    static const int N; // déclaration
    enum { M = 3 }; // déclaration + définition
    static float f; // déclaration
    static const float F; // déclaration
    static auto fct0() {
       return "Yo"s;
    }
    static string fct1() {
       static const string TXT = "Ya";
       return TXT;
    }
    void exec() {
       static int nappels = 0;
       ++nappels;
    }
};
#endif
#include "X.h"
int X::n = 3; // définition
const int X::N = 3; // définition
float X::f = 3.14159f; // définition
const float X::F = 3.14159f; // définition

......mais en pratique, c'est probablement abusif car la plupart des compilateurs tolèrent, à ma connaissance, la définition immédiate des constantes de classe entières.

La spécification inline permet plus de souplesse dans plusieurs de ces exemples. J'enrichirai la gamme d'exemples éventuellement pour le démontrer.

Spécification thread_local

Une variable thread_local est locale au fil d'exécution où elle est définie; en pratique, thread_local n'a pas de sens pour une variable locale de durée automatique (celles-ci, en pratique sur la pile d'exécution, sont locales au fil d'exécution par définition), ce qui signifie qu'une variable thread_local se comporte comme une variable static au sens de son initialisation et de sa durée de vie.

Exemple :

Spécification automatique Spécification static Spécification thread_local

Le code à droite...

#include <thread>
#include <iostream>
using namespace std;
void f(int &n) {
   int m{ 0 };
   ++m;
   n = m;
}
int main() {
   int a, b;
   thread th0{ f, ref(a) };
   thread th1{ f, ref(b) };
   th0.join();
   th1.join();
   cout << a << ' ' << b << endl;
}
#include <thread>
#include <iostream>
#include <atomic>
using namespace std;
void f(int &n) {
   static atomic<int> m{ 0 };
   ++m;
   n = m;
}
int main() {
   int a, b;
   thread th0{ f, ref(a) };
   thread th1{ f, ref(b) };
   th0.join();
   th1.join();
   cout << a << ' ' << b << endl;
}
#include <thread>
#include <iostream>
using namespace std;
void f(int &n) {
   thread_local int m{ 0 };
   ++m;
   n = m;
}
int main() {
   int a, b;
   thread th0{ f, ref(a) };
   thread th1{ f, ref(b) };
   th0.join();
   th1.join();
   cout << a << ' ' << b << endl;
}

... affichera :

1 1
1 2
1 1

Notez que la version avec variable static doit avoir recours à une atomique pour éviter un accès concurrent en écriture sur la variable m, car cette variable est essentiellement une variable globale.

Une variable thread_local a une durée de vie délimitée par celle du fil d'exécution où elle est définie. Son initialisation précède sa première utilisation dans ce fil d'exécution, et si elle est utilisée, sa destruction précèdera la fin de l'exécution de ce dernier.

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !