« Inlining » et mot clé inline

Note : cet article porte sur deux dossiers distincts bien que reliés, soit le mot clé inline et l'optimisation qu'on nomme « inlining ». Bien que les deux aillent souvent de pair, l'un peut exister sans l'autre, et il est important de ne pas les confondre. Comprendre la One Definition Rule (ODR) peut être utile pour saisir ce qui suit.

Cet article porte sur plusieurs aspects d'une même idée, soit la visibilité pour le compilateur de certaines fonctions et de certains objets, dans une optique d'optimisation ou de simplification de l'écriture de programmes.

Le « Inlining »

L'inlining, ou le remplacement (par le compilateur) du code d'un appel de fonction par le code de la fonction appelée. Cette optimisation permet au compilateur de faire disparaître (littéralement) des appels de fonction dans le code généré lorsque cela s'avère propice.

L'enjeu pour un compilateur est d'évaluer si le jeu en vaut la chandelle, car l'inlining peut accroître la taille du code généré, ce qui peut en ralentir l'exécution et accroître sa consommation de ressources. Il faut comprendre que, pour certaines fonctions, par exemple celles qui réalisent un bref calcul et en retournent le résultat (les accesseurs, souvent nommés getters; plusieurs prédicats (est_pair() par exemple); une fonction retournant le successeur d'une valeur; etc.), le coût de l'appel peut être plus élevée que le coût du calcul fait par la fonction. Pour cette raison, l'inlining est plus qu'une simple optimisation : c'est un encouragement à écrire du code à la fois efficace, correct et bien découpé.

Par exemple, un programme comme celui-ci :

int somme(int a, int b) {
   return a + b;
}
int main() {
   return somme(2,3); // 5
}

... sera résolu par la majorité des compilateurs comme un programme se limitant à retourner 5, sans le moindre appel de fonction (voir https://godbolt.org/z/UMw8cE à titre d'exemple). Pour un exemple semblable mais moins trivial, un programme comme celui-ci (qui utilise un template variadique et une Fold Expression) génèrera du code parfait dû au inlining :

template <class ... Ts>
   auto somme(Ts ... args) {
      return (args + ...);
   }
int main() {
   return somme(2,3,5,7,11); // 28
}

(voir https://godbolt.org/z/_5uYSp pour le constater).

« Inlining » implicite

Comme les exemples (triviaux) présentés ci-dessus, les compilateurs réalisent le inlining automatiquement et implicitement dans la plupart des cas où cela est possible, et avantageux.

Ce qui permet à un compilateur de réaliser du inlining est typiquement l'accès, au point d'appel d'une fonction, à la définition du code de cette fonction. Dans mon modèle de compilation séparée comme celui de C ou de C++, où les fichiers sources sont compilés séparément et où l'édition des liens résout par la suite la connexion entre le code appelant et les fonctions appelées, cette visibilité à la compilation du code appelé dépend souvent de certaines décisions d'organisation du code par les programmeuses et par les programmeurs.

Dans le cas des templates, où le code doit être visible au compilateur à partir du point d'appel, car le compilateur ne génère le code requis que lorsqu'il constate les types impliqués. Dans le cas des classes, les méthodes définies à leur point de déclaration sont aussi implicitement inline :

// dans un .h
class X {
   // ...
public:
   // ceci est à la fois la déclaration et la définition de X::f()
   int f() const {
      return 3; // implicitement inline
   }
   // ceci est à la fois la déclaration de X::g()
   int g() const; // le inlining de X::g() devient moins probable si sa
                  // définition n'est pas visible au point d'appel
};

Notez que cette occasion d'inlining ne signifie pas que toutes les méthodes d'une classe non-générique devraient être systématiquement définies au point de leur déclaration En effet, si la définition d'une méthode change souvent, placer cette définition à un endroit où elle est visible pour le code client signifie aussi recompiler le code client plus souvent, ce qui peut rendre le processus de développement plus laborieux... surtout si la définition de la méthode est visible de plusieurs fichiers sources, car il fait le savoir : un programme C++ de grande taille peut compiler pendant des heures!

Fonctions inline

Une fonction inline est telle que sa définition est visible au compilateur lorsque ce dernier rencontre le point d'appel, et permet au compilateur de remplacer le code à l'appel par le code appelé. On a typiquement recours à cette manoeuvre dans le cas de fonctions globales (fonctions qu'on pourrait avoir en langage C), car cela évite de provoquer une violation d'ODR.

Ceci mène à du comportement indéfini Ceci est légal
// a.h
#ifndef A_H
#define A_H
int carre(int n); // déclaration seulement
int cube(int n) { // déclaration et définition
   return n * carre(n);
}
#endif
// a.h
#ifndef A_H
#define A_H
int carre(int n); // déclaration seulement
inline int cube(int n) { // déclaration et définition
   return n * carre(n);
}
#endif
// a.cpp
#include "a.h"
int carre(int n) { // définition
   return n * n;
}
// a.cpp
#include "a.h"
int carre(int n) { // définition
   return n * n;
}
// principal.cpp
#include "a.h"
#include <iostream>
int main() {
   return cube(3); // comportement indéfini
}
// principal.cpp
#include "a.h"
#include <iostream>
int main() {
   return cube(3); // Ok
}

L'enjeu ici est que placer une définition de fonction globale de manière à ce que plus d'un fichier source la contienne (à gauche, a.cpp et principal.cpp incluent tous deux a.h, et incluent donc la définition de cube(int)) provoque une violation d'ODR, donc du comportement indéfini.

Apposer inline sur cube(int) à droite indique au compilateur d'injecter la définition de cette fonction là où elle est appelée, ce qui éviter de générer une définition pour cette fonction et règle le problème.

Il arrive que l'on voit inline apposé à une méthode d'instance définie à son point de déclaration. C'est légal mais redondant, du moins avec les compilateurs contemporains.

Variables inline

Depuis C++ 17, une variable peut aussi être inline; ceci n'a de sens que pour les variables globales et les variables de classe (static), qui sont... des globales.

Déclarer une variable globale inline a deux conséquences :

Les variables constexpr sont implicitement inline. Ainsi, l'édition des liens du programme ci-dessous se fait sans erreur :

// X.h
#ifndef X_H
#define X_H
constexpr int n = 3; // implicitement inline
void f();
#endif
// X.cpp
#include "X.h"
#include <iostream>
using namespace std;
void f() {
   cout << n;
}
// principal.cpp
#include "X.h"
int main() { f(); } // 3

Avec les variables inline, ce qui suit compile désormais sans erreur et sans provoquer de violation d'ODR :

// X.h
#ifndef X_H
#define X_H
inline int n = 3;
void f();
#endif
// X.cpp
#include "X.h"
#include <iostream>
using namespace std;
void f() {
   cout << n;
}
// principal.cpp
#include "X.h"
int main() { ++n; f(); } // 4

Notez que ODR, règle selon laquelle une seule définition doit exister par unité de traduction demeure. Ainsi, ceci demeure illégal (avec ou sans inline) :

int m = 3;
int m = 3; // non
inline int n = 3;
inline int n = 3; // non
int main() {
}

Une application intéressante des variables inline est la simplification de la définition des variables et des constantes de classe (membres static). Par exemple, traditionnellement, pour définir la constante de classe A::s ci-dessous, il fallait séparer la déclaration de cette variable de sa définition (pour éviter une violation d'ODR) :

// A.h
#ifndef A_H
#define A_H
#include <string>
class A {
  // déclaration
  static const std::string s;
public:
  static auto f() {
    return s;
  }
};
#endif
// A.cpp
#include "a.h"
#include <string>
using std::string;
// définition
const string A::s = "J'aime mon prof";
// principal.cpp
#include "a.h"
#include <iostream>
using namespace std;
int main() {
   cout << A::f() << endl;
}

Désormais, il est possible d'y aller plus directement

// A.h
#ifndef A_H
#define A_H
#include <string>
class A {
  // déclaration
  static inline const std::string s = "J'aime mon prof";
public:
  static auto f() {
    return s;
  }
};
#endif
// A.cpp
#include "a.h"
// pas vraiment besoin de ce fichier ici, en passant
// principal.cpp
#include "a.h"
#include <iostream>
using namespace std;
int main() {
   cout << A::f() << endl;
}

Faciliter l'utilisation de globales partagées entre plusieurs unités de traduction (plusieurs fichiers sources) ne signifie pas encourager le recours aux globales. Je vous en prie, utilisez ce mécanisme avec intelligence et parcimonie.

Lectures complémentaires

Quelques liens pour enrichir le propos.

À propos des fonctions inline :

À propos des variables inline :

À propos du Inlining :


Valid XHTML 1.0 Transitional

CSS Valide !