Loupe

De C# à C++ : Types valeurs, types références, c’est quoi l’équivalent en c++ ?

En C#, il existe 2 catégories de types: les types “valeur” (structures, énumérations, types primitifs…) et les types “référence” (classes, délégués, etc.). En C++, une telle distinction n’existe pas, mais on retrouve des concepts relativement similaires. Ce post a pour but de rappeler brièvement ce que sont ces concepts, et de montrer leur équivalence dans le monde C++.

Types valeurs, types références

En C#, il existe une différence fondamentale entre structures et classes: les structures font partie de la catégorie de type “valeur”, alors que les classes font partie de la catégorie de type “référence”. Un type référence est TOUJOURS alloué dynamiquement (sur le tas), alors qu’un type valeur est TOUJOURS alloué statiquement (sur la pile, ou à l’intérieur de la zone de stockage de l’objet contenant dans le cas d’une variable membre).

Cela veut dire que lorsque j’écris:

var monInstanceDeClasse = new MaClasse(/* parametres */);
var monInstanceDeStructure = new MaStructure(/* parametres */);

La variable monInstanceDeClasse ne contient que l’adresse mémoire de l’objet créé (ses données membres et metadata étant stockées à l’endroit pointé par cette addresse), alors que la variable monInstanceDeStructure contient directement les données de l’objet ainsi créé. On en déduit que l’opérateur “new” a un comportement différent lorsqu’il est appliqué à une classe (allocation mémoire + appel de la méthode .ctor()), ou sur une structure (simple appel de la méthode .ctor() en utilisant l’espace mémoire déjà alloué sur la pile).

Cela a aussi une incidence sur le passage de paramètre: si je passe une structure en paramètre d’une méthode, l’intégralité de la structure est recopiée dans ce paramètre (les modifications faites à son état à l’intérieur de la méthode sont donc non visibles à l’extérieur), alors que si je passe une classe en paramètre, seule l’adresse est recopiée (les modifications faites à son état à l’intérieur de la méthodes sont donc visibles à l’extérieur).

Cette distinction entre type valeur et type référence permet du coup au compilateur d’interdire certains concepts de la POO qui ne sont pas exploitables lors du passage de paramètres par copie (l’héritage et le polymorphisme principalement) aux structures tout en les autorisant pour les classes.

Allocation statique vs dynamique en C++

En C++ une telle distinction n’existe pas (la seule différence entre structure et classe étant que la visibilité par défaut des membres d’une structure est “public” là ou celle des membres d’une classe est “private”). Mais la possibilité d’instancier des objets statiquement ou dynamiquement permet à tout objet C++ de se comporter au choix comme un type référence ou un type valeur. Pour illustrer cela, nous utiliserons la classe suivante:

class Point2D{
private: // fields
    double _x;
    double _y;
public: // constructors
    Point2D() : _x(0), _y(0){
        cout << "Default constructor called\n";
    }
    Point2D(double x, double y) : _x(x), _y(y){
        cout << "Constructor(x,y) called\n";
    }
    Point2D(const Point2D& copied) : _x(copied._x), _y(copied._y){
        cout << "Copy constructor called\n";
    }
    ~Point2D(){
        cout << "Destructor called\n";
    }
public: // public methods
    double X() const{return _x;}
    double Y() const{return _y;}
    void X(double value){_x = value;}
    void Y(double value){_y = value;}
    virtual void Dump() const {
        cout << "x: " << _x << " y: "<< _y << "\n";
    }
};

Petite particularité de C++ à remarquer : à la ligne 12, nous avons un constructeur par copie. Cette notion n’existe pas en C#, car les classes C# ne sont JAMAIS recopiées. En C++, ce constructeur nous permet de personnaliser la manière dont une variable est recopiée lors d’un passage de paramètre par copie (ou par appel explicite). A noter que si il n’est pas explicitement déclaré, le compilateur en génère un qui ne fait que de la recopie membre par membre.

Voyons maintenant un exemple d’allocation statique:

void doSomethingWithAPoint(Point2D point){
    point.X(42);
    point.Y(42);
    point.Dump();
} // destruction de "point"

int _tmain(int argc, _TCHAR* argv[])
{
    Point2D p;                    // appel constructeur par défaut
    doSomethingWithAPoint(p);    // appel du constructeur par copie
    p.Dump();                    // l'objet p n'a pas été modifié
    return 0;
}

La variable p contient un Point2D statiquement alloué créé en utilisant son constructeur par défaut (pour appeler son 2e constructeur, on aurait pu écrire “Point2D p(1, 2)”). Lors de l’appel à doSomethingWithAPoint, p est recopié dans le paramètre point. A la sortie de la fonction, point est détruit, mais p est toujours vivant (et non modifié). On obtient donc la sortie console suivante:

Default constructor called
Copy constructor called
x: 42 y: 42
Destructor called
x: 0 y: 0
Destructor called

On peut noter qu’un des gros avantage de l’allocation statique, c’est que la structure du langage elle-même contrôle la durée de vie des objets. Ainsi quand une variable contenant un objet statiquement alloué sort du scope, son destructeur est automatiquement appelé ! De la même manière si une classe contient un champ statiquement alloué, la destruction de ce champs se fait automatiquement:

class Segment{
private:
    Point2D _start;
    Point2D _end;
public:
    Segment(Point2D start, Point2D end) : _start(start), _end(end){}
};

Si un objet de type Segment est détruit, on verra 2 fois “Destructor called” dans la console (une fois pour _start, et une fois pour _end).

A noter que les smart pointers de la librairie standard C++ 11 (ou ceux de la librairie boost) s’appuient sur ce comportement pour automatiser l’appel à l’opérateur “delete” lorsque l’objet n’est plus référencé. (ces smart pointers feront l’objet d’un prochain post).

Exemple d’allocation dynamique:

void doSomthingWithAPointerToPoint(Point2D* pPoint){
    pPoint->X(42);
    pPoint->Y(42);
    pPoint->Dump();
}

int _tmain(int argc, _TCHAR* argv[])
{
    Point2D* p = new Point2D();          // allocation mémoire dynamique + appel constructeur par défaut
    doSomthingWithAPointerToPoint(p);    // seul le pointer est recopié, l'espace mémoire de l'objet ne l'est pas
    p->Dump();                           // l'objet p a été modifié
    delete p;                            // destruction explicite + désollocation mémoire
    return 0;
}

L’utilisation de l’opérateur “new” en C++ provoque l’allocation dynamique de mémoire pour contenir l’objet à créer et renvoi donc un pointeur vers cet espace mémoire. Dans ce cas là, seule l’adresse de l’objet est copiée lors de l’appel à la fonction “doSomthingWithAPointerToPoint”. On obtient donc dans la console:

Default constructor called
x: 42 y: 42
x: 42 y: 42
Destructor called

Attention cependant, en C++, toute mémoire allouée dynamiquement doit être libérée manuellement. Cela ce fait avec l’opérateur “delete” qui appelle le destructeur de l’objet puis désalloue la mémoire associée. Si on omet l’appel à delete, l’objet n’est pas désalloué et on obtient une fuite mémoire. (Heureusement, la librairie standard C++ propose des smart pointers nous permettant de nous abstraire de ces problèmes et de ne pas avoir à appeler delete manuellement).

A noter que l’on peut très bien avoir une approche hybride : créer un objet de manière statique n’empêche pas de le passer par pointeur ou par référence à une autre fonction. On obtient alors les bénéfices de l’allocation statique (temps de vie de l’objet déduit par le compilateur), et on s’évite des recopies qui peuvent être coûteuses. Attention cependant à s’assurer que l’objet soit toujours vivant quand on manipule cette référence ou ce pointeur. Par exemple, le code suivant nous plonge tout droit vers le monde impitoyable des “undefined behaviors” (et des massacres de poussins qui vont avec):

int _tmain(int argc, _TCHAR* argv[])
{
    Point2D* pointer;
    {
        Point2D point(1,2);
        pointer = &point;
    }
    doSomthingWithAPointerToPoint(pointer);
    return 0;
}

Un cas typique de ce genre est celui de la récupération d’un pointeur sous la forme d’un membre d’un nouvel objet. Si le temps de vie de ce nouvel objet est plus long que celui de l’objet initial, on se retrouve avec un pointeur vers un objet déjà détruit (et ca c’est le mal incarné).

Passage par copie et polymorphisme

A la lumière de ce que l’on a vu précédemment, analysons le code suivant:

void doSomethingWithAPoint(Point2D point){
    point.X(42);
    point.Y(42);
    point.Dump();
}

class Point3D : public Point2D{
private:
    double _z;
public:
    Point3D() : Point2D(), _z(0){}
    Point3D(double x, double y, double z) : Point2D(x,y), _z(z){}

    double Z()const{return _z;}
    void Z(double value){_z = value;}
    virtual void Dump() const override{

        cout << "x: " << X() << " y: "<< Y() << " z: "<< Z() << "\n";
    }
};

int _tmain(int argc, _TCHAR* argv[])
{
    Point3D p(1,2,3);
    doSomethingWithAPoint(p);
    p.Dump();
    return 0;
}

L’appel à doSomethingWithAPoint provoque la recopie de “p” vers le paramètre “point”. Or “point” est de type “Point2D”. Le compilateur a donc généré une fonction qui réserve sur la pile seulement de quoi stocker un Point2D (et non un Point3D). Qu’est-ce qui se passe alors lors de la copie ? Et bien le Point3D est copié dans une instance de Point2D, éliminant donc les données spécifiques et les surcharges de méthodes de Point3D de la version recopiée ! On obtient la sortie assez surprenante suivante:

Constructor(x,y) called
Copy constructor called
x: 42 y: 42
Destructor called
x: 1 y: 2 z: 3
Destructor called

La méthode Dump() invoquée à l’intérieur de la fonction est bien celle de Point2D et non celle de Point3D ! Attention donc, si l’on veut bénéficier des possibilités du polymorphisme, on doit donc obligatoirement passer ses paramètres par pointeur, par référence ou par référence constante. Ainsi, si l’on remplace la définition de doSomethingWithAPoint par celle-ci :

void doSomethingWithAPoint(Point2D& point){
    point.X(42);
    point.Y(42);
    point.Dump();
}

On obtient la sortie beaucoup moins surprenante suivante:

Constructor(x,y) called
x: 42 y: 42 z: 3
x: 42 y: 42 z: 3
Destructor called

Ce comportement assez perturbant est certainement une des raisons ayant poussé les créateurs du langages C# à créer une telle segmentation entre types valeur et types références.

La suite au prochain épisode !

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus