6/9

6. Objets

Introduction

Un "objet" est un élément de programmation liant données et fonctions. Un objet est composé de membres.

  • Les membres peuvent être des données: elles sont appelées attributs

  • Les membres peuvent être des fonctions: elles sont appelées méthodes (ou fonctions membres)

En C++ les membres peuvent être qualifiés de 'public' ou 'private'.

  • Les membres public peuvent être accédés hors de la classe en utilisant la syntaxe nom_objet.nom_membre. On dit qu’ils font partie de l’API publique de l’objet.

  • Les membres private ne peuvent pas être accédés directement hors de la définition de l’objet. Ils peuvent servir à modéliser un état interne à l’objet qui ne doit pas être vu ou modifié en dehors des fonctions membres.

Rem. Les membres peuvent également être qualifiés de restricted: accès autorisé pour les objets dérivés uniquement.

En C++ deux mots-clés permettent de définir un objet: struct et class

  • Une struct possède, par défaut, des membres publiques

  • Une class possède, par défaut, des membres privés

En dehors de cela, les struct et class possèdent les mêmes caractéristiques [1].
Les pratiques standards tendent généralement à utiliser les struct pour des objets de faible taille en mémoire possédant des attributs publics (ex. vecteur 3D dont les attributs sont x, y, z). A l’opposé, les class sont davantages utilisées pour des objets volumineux possédant des états privés cachés (ex. Objet modélisant une fenêtre graphique).

Example de déclaration d’un objet

Considérons que l’on souhaite modéliser un objet de type vec3 contenant 3 floats (x,y,z).

//definition of the object
struct vec3
{
    float x;
    float y;
    float z;
};


int main()
{
    // creation of vec3 with coordinates (1, 2.5, 3.5)
    vec3 p {1.0f, 2.5f, 3.5f};


    // attributes can be accessed by their name
    p.y = -4.5f;
    p.z = p.x - p.y;


    // objects can be const-qualified (ensure that their attributes cannot be changed)
    const vec3 p2 {5.0f, 1.5f, 3.5f};


    return 0;
}

Ou, de manière similaire, par le mot clé class

class vec3
{
// public keyword is necessary to be able to access attributes outside the class definition.
public:
    float x;
    float y;
    float z;
};
  • L’objet vec3 correspond à un type de variable

  • p et p2 sont des instances de cet objet (des variables spécifiques allouées en mémoire)

  • Les objets sont, par défaut, créés sur la pile en C++ (création et accès très rapide, gestion automatique de l’allocation et de la desalocation). Les instances des objets sont allouées en mémoire jusqu’à la fin du bloc courant.

  • Les attributs des objets sont placés de manière contigüe en mémoire. Dans le cas présent, les trois float du vec3 sont placés sur 3 \(\times\) sizeof(float) = 12 octets consécutifs.

  • Les attributs des objets peuvent être d’autres objets.

Exemple

struct vec3
{
    float x;
    float y;
    float z;
};


struct segment {
    vec3 p0;
    vec3 p1;
};

Organisation mémoire

L’une des forces de C++ est de permettre la création d’une série d’objets spécifiques (tels que des vecteurs, matrices, etc) et de pouvoir garantir la contigüité des éléments en mémoire.
Cela permet d’accéder, à haut niveau, aux objets tels que défini par le programmeur, et à bas niveau, en tant que suite d’octets (typiquement une suite de floats ou d’entiers) manipulés par pointeurs de manière très efficace.

Exemple

class vec3
{
// public keyword is necessary to be able to access attributes outside the class definition.
public:
    float x;
    float y;
    float z;
};


int main()
{
    std::vector<vec3> v = {{1,4,5}, {2,-1,3}, {7,-4,0} };
    // v can be filled with a large quantity of data if necessary


    // Access v as a vector of vec3 (with x, y, z components)
    v[1].x = 12;


    // Access v as a contiguous set of floats in memory
    float* p = &v[0].x; // create a pointer containing the address of the first element of v
    p[4] = 8;           // access as a set of contiguous floats in memory (buffer of data) (no more notion of vec3)
    // (p[4] refers to the same element than v[1].x)


    return 0;
}

Fonctions prenant en argument des objets

Les fonctions classiques peuvent prendre en argument des objets que l’on passera généralement sous forme de référence const (si on ne fait qu’y accéder en lecture) ou référence non-const (si on modifie la valeur de l’objet).

Exemple

vec3 addition(const vec3& a, const vec3& b)
{
    return vec3{a.x+b.x, a.y+b.y, a.z+b.z};
}
void display(const vec3& v)
{
    std::cout << v.x << "," << v.y << "," << v.z << std::endl;
}


int main()
{
    const vec3 p1 {1.0f, 2.5f, 3.5f};
    const vec3 p2 {2.0f, -1.0f, 1.5f};


    const vec3 p3 = addition(p1,p2);
    display(p3); // print 3.0, 1.5, 5.0


    return 0;
}

Méthodes

Les méthodes consistent à définir une fonction associée à l’objet.

Exemple

#include <iostream>


//definition of the object
struct vec3
{
    float x;
    float y;
    float z;


    vec3 addition(const vec3& b) const;
    void display() const;
};



vec3 vec3::addition(const vec3& b) const
{
    return vec3{x+b.x, y+b.y, z+b.z};
}
void vec3::display() const
{
    std::cout << x << "," << y << "," << z << std::endl;
}


int main()
{
    const vec3 p1 {1.0f, 2.5f, 3.5f};
    const vec3 p2 {2.0f, -1.0f, 1.5f};


    const vec3 p3 = p1.addition(p2);
    p3.display(); // print 3.0, 1.5, 5.0


    return 0;
}

Les méthodes sont appelées à l’aide de la syntaxe object.method(arguments)

La signature des méthodes dans leur implémentation diffère légèrement des fonctions libres vec vec::addition(const vec3& b) const

  • vec3:: indique que l’on définit une méthode sur l’objet vec3 (rappel: :: est l’opérateur de résolution de portée - défini ici le fait que le nom de la méthode est lié à la classe)

  • le dernier const indique que la méthode ne modifie pas les attributs de l’objet. Similairement aux variables const, les méthodes peuvent également être qualifiées de const de cette manière. De même qu’avec les variables, préférez déclarer par défaut toutes vos méthodes const lorsque celles-ci ne modifient pas les attributs de la classe.

  • Dans l’implémentation du corps de la classe, vous pouvez accéder aux attributs de l’objets courant (ici x, y, z).

    • Notez qu’il est possible de spécifier qu’il s’agit d’attributs de l’objet en précisant this->x, this->y, etc mais cela n’est pas obligatoire en C++ (this correspond au pointeur sur l’objet courant).

Note: les deux précédents exemples montrent qu’il est possible d’implémenter des opérations sur les objets, soit en tant que fonction simple, soit en tant que méthode de l’objet. Ce choix est souvent possible et lié au choix de design du développeur [2].

Héritage

L’héritage consiste à définir un objet en tant que sous-type d’un autre objet. Les objets hérités ont accès aux membres public de l’objet parent, et peuvent en plus définir de nouveaux membres.

L’héritage permet typiquement de modéliser la notion de comportement spécialisé à partir d’une catégorie plus générale d’objet.

L’héritage en C++ est indiqué par la syntaxe suivante

  • struct inheritedType : baseType pour les structs (héritage des membres public par défaut)

  • class inheritedType : public baseType pour les class (l’ajout de public est nécessaire dans ce cas)

Exemple d’héritage en C++ (éléments sur une carte)

// Base object, everything has a position on a map
struct element_on_map
{
    float latitude;
    float longitude;
};


// Inherited object
// A house is an element on map and contains other attributes (postal address, population, etc)
struct house : element_on_map
{
    int population;
    std::string address;
};


// Other inherited object
// A tree is an element on map and contains other attributes (type of tree, etc)
struct tree : element_on_map
{
    std::string type;
};

Rem. En C++ il est possible d’hériter de plusieurs objets à la fois (héritage multiple) tant qu’il n’y a pas de conflits de noms.
ex. struct object : base_1, base_2 {…​};

Constructeurs et destructeurs

Le constructeur est une méthode appelée lorsqu’un objet est instancié.

Les objets possédant des attributs publics peuvent être initialisés de manière directe par défaut sans définition explicite d’un constructeur par le programmeur. On parle de constructeur par défaut (default constructor).

#include <iostream>


struct vec3
{
    float x;
    float y;
    float z;
};


void display(const vec3& p)
{
    std::cout<< p.x<<","<<p.y<<","<<p.z<<std::endl;
}


int main()
{
    // built-in empty constructor
    // no initialization => undefined values in p1
    vec3 p1;
    display(p1);


    // Use of the "trivial default constructor" with aggregate initialization
    vec3 p2 {5,-4,7};
    display(p2);


    // same than previous
    auto p3 = vec3{5,-4,7};
    display(p3);


    return 0;
}

Notez que les attributs de p1 ne sont pas initialisés, aboutissant à des valeurs indéterminées lors de l’affichage (et donc à des comportements indéterminés si ces valeurs sont utilisées par la suite). Pour éviter un tel comportement, il est possible de définir des valeurs par défaut dans la définition de l’objet.

struct vec3
{
    float x = 0; // defaults values for x, y, and z
    float y = 0;
    float z = 0;
};
Constructeurs dédiés

L’utilisateur peut également définir ses propres constructeurs.

Considérons le cas où l’on souhaite pouvoir initialiser un object vec3 à partir d’une seule valeur tel que x, y, et z soit égaux.

struct vec3
{
    vec3(float value); // user-defined constructor

    float x;
    float y;
    float z;
};


// Constructor implementation
vec3::vec3(float value)
    :x(value),y(value),z(value)
{
}

Et peut alors être utilisé de la manière suivante

vec3 p1 {5.0f};
// or
vec3 p2 (5.0f);
  • Un constructeur est une méthode dont le nom est celui de la classe. Le constructeur possède la particularité de ne pas avoir de type de retour (même pas void). La déclaration de son implémentation est donc de la forme objectName::objectName(arguments …​)

  • Les constructeurs peuvent utiliser une syntaxe spéciale pour initialiser leurs attributs :attribute1(value1), attribute2(value2), etc, qui est globalement similaire à initialiser dans le corps du constructeur avec la syntaxe classique attribute1 = value1; attribute2 = value2; etc

Il aurait ainsi été possible d’écrire le constructeur de vec3 de cette manière

vec3::vec3(float value)
{
    x = value;
    y = value;
    z = value;
}

Cette syntaxe spécifique (attribute1(value1),attribute2(value2), ...) est appelée liste d’initialisation des membres (member initalizer list), qui permet d’affecter les valeurs des attributs avant toute autre initialisation. En particulier, elle permet d’initialiser des références constantes sur des variables passées en paramètres (qui ne pourrait pas être fait par la syntaxe classique de copie).

  • Les paramètres des constructeurs définis par l’utilisateur peuvent être placés entre accolades {…​} ou parenthèses (…​).

Lorsqu’au moins un constructeur est défini par l’utilisateur, le compilateur ne génère plus de constructeur par défaut. Ainsi, si l’on souhaite toujours pouvoir initialiser un vec3 sans paramètre, ou avec 3 paramètres (x,y,z), il est nécessaire de définir l’intégralité des constructeurs [3].

Exemple complet

#include <iostream>


struct vec3
{
    vec3();
    vec3(float value);
    vec3(float x, float y, float z);


    float x;
    float y;
    float z;
};



vec3::vec3()
    :x(0.0f), y(0.0f), z(0.0f)
{}


vec3::vec3(float value)
    :x(value),y(value),z(value)
{}


vec3::vec3(float x_arg, float y_arg, float z_arg)
    :x(x_arg), y(y_arg), z(z_arg)
{}


void display(const vec3& p)
{
    std::cout<< p.x<<","<<p.y<<","<<p.z<<std::endl;
}


int main()
{
    vec3 p1; // no argument - use vec3::vec3()
    display(p1);


    vec3 p2 {5.0f}; // one argument - use vec3::vec3(float value)
    display(p2);


    vec3 p3 {4.0f,7.0f,-4}; // three arguments - use vec3::vec3(float x, float y, float z)
    display(p3);


    return 0;
}
Constructeurs par copie

Lorsqu’un objet est copié sur un autre lors d’une déclaration (ex. object a = b;), les valeurs des attributs de l’objet b sont copiés sur les valeurs des attributs de l’objet a. Le compilateur implémente cette copie par défaut.

Dans certains cas, le programmeur peut souhaiter modifier ce comportement par défaut en implémentant un constructeur par copie.

Exemple

#include <iostream>


struct vec3
{
    vec3(float x, float y, float z);


    // Copy constructor
    vec3(const vec3& source);


    float x;
    float y;
    float z;
};


vec3::vec3(const vec3& source)
    :x(source.x), y(source.y), z(source.z)
{
    std::cout<<"Calling copy constructor"<<std::endl;
}


vec3::vec3(float x_arg, float y_arg, float z_arg)
    :x(x_arg),y(y_arg),z(z_arg)
{


}


void display(const vec3& p)
{
    std::cout<< p.x<<","<<p.y<<","<<p.z<<std::endl;
}


int main()
{
    vec3 p1 {1.0f, 2.0f, 3.0f};
    display(p1);


    vec3 p2 = p1; // copy constructor will be called


    return 0;
}

Rem.

  • Le constructeur par copie est également utilisé lorsque l’on passe un objet par copie en argument d’une fonction.

  • Cet exemple n’a pas d’intérêt spécifique autre que de visualiser en ligne de commande l’appel au constructeur par copie. D’une manière plus générale, le constructeur par copie est utilisé lorsque l’objet contient lui-même des attributs qui ne doivent pas être copiés (ex. des pointeurs pour lesquels il faut allouer de l’espace mémoire).

Dans le cas des TD, le constructeur par copie par défaut conviendra à nos objets.

Destructeurs

Le destructeur est une méthode appelée automatiquement lorsqu’un objet est détruit. A l’opposé des autres méthodes ainsi que du constructeur, le destructeur ne doit pas être appelé explicitement dans le code car le compilateur se charge de le faire automatiquement. Le destructeur est typiquement appelé à la fin du bloc dans lequel l’objet a été déclaré cas d’un objet alloué sur la pile.

#include <iostream>


struct vec3
{
    //destructor
    ~vec3();

    float x;
    float y;
    float z;
};


// destructor implementation
vec3::~vec3()
{
    std::cout<<" [ Destructor is called ] "<<std::endl;
}


void display(const vec3& p)
{
    std::cout<< p.x<<","<<p.y<<","<<p.z<<std::endl;
}


int main()
{
    {
        vec3 p1 {1.0f, 2.0f, 3.0f};


        std::cout<<"Destructor on p1 should be called after this line"<<std::endl;


    } // call destructor on p1


    std::cout<<"p1 should have been destroyed"<<std::endl;


    return 0;
}

Dans le cas d’un objet où les attributs sont tous alloués sur la pile, le destructeur n’a aucune action spécifique à réaliser. Ce sera le cas de la grande majorité des objets que nous allons utiliser.

Bonnes pratiques

D’une manière générale, le comportement par défaut du compilateur est celui attendu pour des objets classiques dont l’allocation mémoire est gérée automatiquement. Il est donc préférable de ne pas définir soit même les constructeurs par copies et destructeurs si cela n’est pas indispensable.


1. Il est courant de trouver l’information erronnée que les structs sont reservées au C et n’ont pas les propriétés des classes du C++ .
2. Examples of discussions 1 2
3. Ou d’utiliser la syntaxe object() = default;.