8/9

8. Multi fichiers

Il est généralement pratique de scinder différentes parties de son code dans différents fichiers. Le code C++ est scindé dans deux catégories de fichiers: les fichiers dits de sources (ex. .cpp, .cxx) contenant l’implémentation des fonctions, et les fichiers d’en-têtes (ex. .h, .hpp, .hxx)

Compilation séparée

Lorsqu’un code est composé de plusieurs fichiers, la compilation se réalise alors suivant l’approche dite de compilation séparée. C’est-à-dire que tous les fichiers sources (.cpp) sont compilés séparément les uns des autres. Lors de la compilation d’un fichier, celui-ci n’a pas accès au contenu des autres, cela n’aboutit donc pas a un programme exécutable final, mais à des fichiers temporaires appelés fichiers objets.

Les fichiers objets contiennent le code assembleur correspondant à chaque fichier source, mais ne possède pas les informations pour lier les appels de fonctions et méthodes définies dans les autres fichiers. Une étape supplémentaire appelée edition de liens est alors réalisée afin de lier ensemble tous les fichiers objets (ainsi que les bibliothèques externes) en un unique fichier exécutable.

Cas d’application

Considérons le cas où l’on souhaite séparer la fonction main et les appels généraux dans un fichier, et l’implémentation d’un vec3 dans un autre fichier séparé.

Approche naive

On pourrait considérer la structure suivante

  • Fichier main.cpp

#include <iostream>


int main()
{
    vec3 v {1,2,-1};
    std::cout<< norm(v) << std::endl;

    return 0;
}
  • Fichier vec3.cpp

#include <cmath>


struct vec3
{
    float x,y,z;
    float operator[](int k) const;
};


float vec3::operator[](int k) const
{
    switch(k)
    {
        case 0: return x;
        case 1: return y;
        case 2: return z;
    }
    // error cases
    abort();
}


float norm(const vec3& v)
{
    return std::sqrt(v.x*v.x + v.y*v.y + v.z*v.z);
}

Et tentons de compiler séparément ces deux fichiers (la création d’un fichier objet est obtenue par l’option -c de gcc)

  • Compilation séparée de vec3.cpp

$ g++ -c vec3.cpp  -Wall -Wextra -g -std=c++11

La compilation fonctionne et le compilateur génère le fichier vec3.o

$ g++ -c main.cpp  -Wall -Wextra -g -std=c++11


main.cpp: In function ‘int main()’:
main.cpp:5:5: error: ‘vec3’ was not declared in this scope
     vec3 v {1,2,-1};
main.cpp:6:22: error: ‘v’ was not declared in this scope
     std::cout<< norm(v) << std::endl;
main.cpp:6:17: error: ‘norm’ was not declared in this scope
     std::cout<< norm(v) << std::endl;

La compilation échoue: en effet la fonction main utilise l’objet vec ainsi que la fonction norm, mais ceux-ci ne sont pas accessibles depuis le fichier main.cpp unique.

Fichier d’en-tête

Afin de permettre la compilation de main.cpp tout en séparant l’implémentation de vec3 du fichier main.cpp, il est possible de créer des fichiers d’en-têtes qui vont contenir uniquement les informations nécessaires à la compilation, et pourront être partagés entre plusieurs fichiers sources.

Les informations nécessaires à la compilation de main.cpp sont: La définition de la structure vec3 (ce qui permet au compilateur de connaitre la taille en pile de l’objet), et la signature de la fonction norm (connaissance des arguments d’entrées et de retour).

Pour cela, on considère désormais trois fichiers: main.cpp, vec3.cpp, et vec3.hpp

  • vec3.cpp contient uniquement l’implémentation des méthodes liées à vec

#include "vec3.hpp"

#include <cmath>

float vec3::operator[](int k) const
{
    switch(k)
    {
        case 0: return x;
        case 1: return y;
        case 2: return z;
    }
    // error cases
    abort();
}

float norm(const vec3& v)
{
    return std::sqrt(v.x*v.x + v.y*v.y + v.z*v.z);
}
  • vec3.hpp contient les informations nécessaires à l’utilisation de vec3 (sans les implémentations)

#pragma once


struct vec3
{
    float x,y,z;
    float operator[](int k) const;
};

float norm(const vec3& v);
  • main.cpp contient les appels à vec3, et va inclure le fichier vec3.hpp

#include "vec3.hpp"

#include <iostream>

int main()
{
    vec3 v {1,2,-1};
    std::cout<< norm(v) << std::endl;

    return 0;
}

Rem.

  • La syntaxe #include "fichier" corresponds à une commande dite du preprocesseur, c’est-à-dire une étape précédant la compilation en tant que telle qui permet d’inclure des fichiers dans un autre (ou encore de définir des valeurs). La commande #include réalise un copier/coller brute du fichier désigné.

  • On notera la commande #pragma once en début de fichier d’en-tête permettant d’éviter qu’un autre fichier inclue celui-ci plusieurs fois de suite. (On pourra également trouver une syntaxe utilisant #ifndef LABEL #define LABEL).

Remarque générale

L’approche précédente se généralise à un nombre quelconque de fichiers.

Les fichiers d’en-tête (.h, .hpp, .hxx) contiendront les informations partagées hors du fichier d’implémentation

  • Les signatures de fonctions (utilisable en dehors d’un seul fichier)

  • Les signatures des objets (struct/class): leur nom, attributs, signature de méthodes

Les fichiers d’en-tête sont inclus (direction #include) par tous les autres fichiers qui ont besoin de leurs informations.

Les fichiers d’implémentations (.cpp, .cxx) contiendront

  • Le corps des fonctions

  • L’implémentation des méthodes des objets

Ces implémentations seront compilées séparément (rem. Elles n’auront plus besoin d’être compilées par la suite si seulement d’autres fichiers sources sont modifiés).

Outils de compilation

La compilation en ligne de commande devient pénible à réaliser lorsqu’un programme contient plusieurs fichiers. Pour cela, des outils d’automatisation de chaine de compilation existent (Makefile, CMake, Meson, etc.), et vous seront proposés par la suite dans les TD.