Aller au contenu

Programmation C++ : CM séance 03

6. Plus d'objets

6.1. Objet membre

Dans une classe, une donnée membre qui est un objet est appelé un objet membre.

Il existe une syntaxe spéciale dans le constructeur pour initialiser les données membre et en particulier les objets membre, qui s'appelle member initializer list :

class NomClasse {
public:
    NomClasse (type_paramètre paramètre, ....)
        : donnée_membre{valeur1}, objet_membre{valeur2}, ....  // {} ou ()
    {
        ...
    }
};

Par exemple, on se donne la classe Point suivante :

class Point {
public:
    int m_x, m_y;                       # données membre

    Point (int x=0, int y=0)
        : m_x{x}, m_y{y}
    { std::cout << "Construction Point " << m_x << " , " << m_y << std::endl; }

    ~Point()
    { std::cout << "Destruction Point " << m_x << " , " << m_y << std::endl; }
};
puis on définit une classe Rectangle qui possède 2 objets membre :

class Rectangle {
public:
    int m_numero;                       # donnée membre
    Point m_point1, m_point2;           # objets membre

    Rectangle (int n, int x, int y, int w, int h)
        : m_numero{n}, m_point1{x, y}, m_point2{x+w, y+h}
    { std::cout << "Construction Rectangle " << m_numero << std::endl; }

    ~Rectangle()
    { std::cout << "Destruction Rectangle " << m_numero << std::endl; }
};

En instanciant cette classe dans un bloc par

{ Rectangle rect {1, 2, 3, 10, 20}; std::cout << "Fin bloc" << std::endl; }

on obtient la trace

Construction Point 2 , 3
Construction Point 12 , 23
Construction Rectangle 1
Fin bloc
Destruction Rectangle 1
Destruction Point 12 , 23
Destruction Point 2 , 3

Les constructeurs des objets membre Point sont bien appelés par le constructeur du Rectangle, avec les bons paramètres, avant l'exécution de son corps.

Si on n'avait pas utilisé ce mécanisme dans le constructeur, en écrivant

1
2
3
4
5
6
7
8
    Rectangle (int n, int x, int y, int w, int h)
        // : m_numero{n}, m_point1{x, y}, m_point2{x+w, y+h}
    {
        m_numero = n;
        m_point1 = Point {x, y};    // mauvais
        m_point2 = {x+w, y+h};      // variante, mauvais
        std::cout << "Construction Rectangle " << m_numero << std::endl;
    }

alors

{ Rectangle rect {1, 2, 3, 10, 20}; std::cout << "Fin bloc" << std::endl; }
aurait affiché

Construction Point 0 , 0
Construction Point 0 , 0
Construction Point 2 , 3
Destruction Point 2 , 3
Construction Point 12 , 23
Destruction Point 12 , 23
Construction Rectangle 1
Fin bloc
Destruction Rectangle 1
Destruction Point 12 , 23
Destruction Point 2 , 3

Explication : les objets membre Point sont initialisés aux valeurs par défaut avant l'exécution du corps du constructeur ; puis le constructeur de Rectangle crée des objets temporaires, les recopie et les détruit immédiatement. C'est inefficace et donc à éviter.

Remarque : priorité de l'initialisation

class Toto {
public:
    int m_foo = 5;          // initialisation par défaut d'un membre m_foo
    Toto() : m_foo{7} {}    // ceci affectera m_foo à 7, et non à 5.
};

6.2. Méthode statique

Le mot clé static a plusieurs significations, selon qu'il est utilisé dans un bloc ou une déclaration de classe.

Dans un bloc, comme en C, une variable déclarée static est initialisée à son premier usage, puis sa valeur est conservée lors des usages suivants. Autrement dit, une telle variable a une durée de vie permanente.

Exemple :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <iostream>

void plop()
{
    static int i {5};
    std::cout << i << "\n";
    i++;
}

int main()
{
    plop();     // 5
    plop();     // 6
    plop();     // 7
}

À l'intérieur d'une classe, le mot clé static sert à déclarer des membres qui ne dépendent pas de l'existence d'une instance.

Une donnée membre statique est indépendante des instances ; elle doit être associée au mot clé inline ou const :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <iostream>

class Foo
{
public:
    inline static int m_bar = 1;
    void show() { std::cout << m_bar << "\n"; }
};

int main()
{
    std::cout << Foo::m_bar << "\n";    // 1
    Foo ga; ga.show();                  // 1
    Foo::m_bar = 2; ga.show();          // 2
    Foo bu; bu.m_bar = 3; bu.show();    // 3
    ga.show();                          // 3
}

Une fonction membre statique est accessible hors d'une instance. Pour accéder aux données membre d'une instance, elle doit être passée en paramètre :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

class Foo
{
public:
    int m_bar = 5;
    static void hello() { std::cout << "hello\n"; };
    static void show (Foo& other) { std::cout << other.m_bar << "\n"; }
};

int main()
{
    Foo::hello();           // hello
    Foo bar;
    Foo::show (bar);        // 5
    Foo ga; 
    ga.m_bar = 6;
    ga.show(bar);           // 5
    bar.show(ga);           // 6
}

6.3. Déclaration séparée

Lorsqu'on déclare une classe, tous les membres doivent être définis (données et fonctions), on ne peut pas en rajouter ensuite.

Cependant, on peut faire une déclaration séparée pour les fonctions membre en utilisant l'opérateur de portée :: (scope operator) :

  • dans la déclaration de la classe NomClasse, on ne définit que les entêtes des fonctions membre (déclaration forward) ;

  • le corps des fonctions membre est implémenté après (ou dans un autre fichier) avec le préfixe de résolution de portée NomClasse::).

Avantages : déclaration plus compacte ; découpage possible en fichiers.

Exemple :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <cmath>        // pour sqrt()

class Point {
public:
    int m_x, m_y;

    Point (int x=0, int y=0);
    ~Point();
    double distance_to (Point& other);
};

Point::Point (int x, int y)
    : m_x{x}, m_y{y}
{
    std::cout << "Construction Point " << m_x << " , " << m_y << std::endl; 
}

Point::~Point()
{
    std::cout << "Destruction Point " << m_x << " , " << m_y << std::endl;
}

double Point::distance_to(Point& other)
{
    double dx = m_x - other.m_x, dy = m_y - other.m_y;
    return sqrt (dx*dx + dy*dy);
}

int main()
{
    Point p1 {2, 3}, p2 {32, 43};
    std::cout << "Distance = " << p1.distance_to(p2) << std::endl;
}

Affiche :

Construction Point 2 , 3
Construction Point 32 , 43
Distance = 50
Destruction Point 32 , 43
Destruction Point 2 , 3

6.4. Modularité

Un programme est composé de fichiers d'entêtes .hpp (header), contenant des définitions, et de fichiers d'implémentation .cpp, contenant les corps des fonctions.

Le but de ce découpage est que chaque fichier .cpp puisse être compilé séparément des autres fichiers .cpp (voire compilé uniquement si nécessaire, ou compilés en parallèle).

Pour inclure un fichier d'entête .hpp dans un .cpp avec une directive #include :

nom_fichier.cpp
#include <iostream>                 // entête de bibliothèque
#include <....>
#include "nom_fichier.hpp"          // inclut un fichier d'entête

Le fichier .hpp doit présenter des gardes pour le protéger des inclusions multiples, classiquement :

nom_fichier.hpp
#ifndef NOM_FICHIER_HPP
#define NOM_FICHIER_HPP

// Définitions ici

#endif // NOM_FICHIER_HPP

Une solution plus moderne consiste à déclarer #pragma once en tête du fichier (non normalisé, mais accepté par la plupart des compilateurs) :

nom_fichier.hpp
#pragma once

// Définitions ici

Ceci garantit que le contenu du fichier ne sera utilisé qu'une seule fois.

Un fichier ne correspond pas forcément à une classe (comme en Java) ; on peut regrouper par exemple des classes qui vont ensemble :

geometry.hpp
#pragma once

class Point {
public:
    int m_x, m_y;

    Point (int x=0, int y=0);
    ~Point();
    double distance_to (Point& other);
};

class Rectangle {
public:
    int m_numero;
    Point m_point1, m_point2;

    Rectangle (int n, int x, int y, int w, int h);
    ~Rectangle();
    double diameter();
};
geometry.cpp
#include <iostream>
#include <cmath>
#include "geometry.hpp"

Point::Point (int x, int y)
    : m_x{x}, m_y{y}
{
    std::cout << "Construction Point " << m_x << " , " << m_y << std::endl; 
}

Point::~Point()
{
    std::cout << "Destruction Point " << m_x << " , " << m_y << std::endl;
}

double Point::distance_to(Point& other)
{
    double dx = m_x - other.m_x, dy = m_y - other.m_y;
    return sqrt (dx*dx + dy*dy);
}

Rectangle::Rectangle (int n, int x, int y, int w, int h)
    : m_numero{n}, m_point1{x, y}, m_point2{x+w, y+h}
{
    std::cout << "Construction Rectangle " << m_numero << std::endl;
}

Rectangle::~Rectangle()
{
    std::cout << "Destruction Rectangle " << m_numero << std::endl;
}

double Rectangle::diameter()
{
    return m_point1.distance_to(m_point2);
}

Pour le tester on déclare une fonction main dans un troisième fichier :

myprog.cpp
#include <iostream>
#include "geometry.hpp"

int main()
{
    Rectangle rect {1, 2, 3, 30, 40};
    std::cout << "Diamètre = " << rect.diameter() << std::endl;
}

Compilation et exécution :

$ g++ --std=c++17 -Wall -c geometry.cpp
$ g++ --std=c++17 -Wall -c myprog.cpp
$ g++ -o myprog geometry.o myprog.o
$ ./myprog

Raccourci : compilation des fichiers, production directe d'un exécutable et appel :

$ g++ --std=c++17 -Wall geometry.cpp myprog.cpp -o myprog && ./myprog

Remarque :

Une nouvelle solution est apparue dans C++20/C++23 avec le mot clé module, mais non encore bien supportée par tous les compilateurs. D'après Stroustrup elle accélérerait considérablement le temps de compilation. À suivre.

7. Conteneurs

Un conteneur est un type qui peut contenir une collection d'éléments.

Les éléments sont stockés par recopie ; ils ne peuvent pas être des références.

7.1. Tableaux

Il s'agit du type le plus fondamental : un tableau est une séquence contiguë de cases d'un même type en mémoire, de taille fixe.

Déclaration :

1
2
3
type_case nom_tableau[nombre_cases];
type_case nom_tableau[nombre_cases] = {valeur_case, ....};
type_case nom_tableau[] = {valeur_case, ....};
  • ligne 1 : tableau non initialisé ;
  • ligne 2 : tableau initialisé ;
  • ligne 3 : le nombre de cases est déduit de la liste des valeurs.

L'accès commence à l'indice 0 :

nom_tableau[indice]

Arithmétique des pointeurs : nom_tableau est en réalité un pointeur sur la première case du tableau, donc on a :

nom_tableau[0] == *(nom_tableau+0) == *nom_tableau
nom_tableau[i] == *(nom_tableau+i)   // == *(i+nom_tableau) == i[nom_tableau]
&nom_tableau[i] == nom_tableau+i

Pour obtenir le nombre de cases d'un tableau (si on ne l'a pas stockée) on peut utiliser l'opérateur sizeof, qui renvoie la taille en octets dans un std::size_t :

std::size_t nombre_cases = sizeof(nom_tableau) / sizeof(*nom_tableau);

Exemple :

#include <iostream>

class Toto {
public:
    Toto() { std::cout << "C "; }       // pour tracer les constructions
    ~Toto() { std::cout << "D "; }      // et les destructions
};

int main()
{
    Toto v[5];
    std::size_t t = sizeof(v) / sizeof(*v);
    std::cout << t << " ";
}

Instancie des objets, affiche la bonne taille puis les détruit :

C C C C C 5 D D D D D 

On peut itérer sur un tableau par indice :

for (std::size_t i = 0; i < nombre_cases; i++)    // ou for (auto i = 0uz; ...
    .... nom_tableau[i] ....

ou par collection (range-for loop) :

for (type_case case_courante : nom_tableau)
    .... case_courante ....

S'il y a besoin de modifier des cases, on peut itérer avec une référence :

for (type_case& case_courante : nom_tableau)
    .... case_courante = ....

Dans le range-for on peut également remplacer type_case par auto.

7.2. Chaînes de caractères

On distingue les chaînes de caractères classiques des classes std::string.

🔹 Une chaîne de caractères classique est mémorisée par un tableau de char terminée par le caractère nul '\0' (Null-terminated byte string, NTBS) ;

elle est représentée par un littéral délimité par des "" :

"Hello"

en mémoire : ['H', 'e', 'l', 'l', 'o', '\0'].

Deux chaînes littérales adjacentes sont concaténées par le compilateur après la phase du préprocesseur :

"Compi" "lateur"            // donnera "Compilateur"

Déclaration :

char s[] = "foo";           // s contiendra une copie de "foo" (avec '\0')
const char* s = "bar";      // non-const interdit depuis C++11

Les fonctions habituelles du C sont disponibles dans la librairie standard, voir liste :

  • dans <cstdlib> : atoi, strtol, ...
  • dans <cstring> : strcpy, strlen, strncmp, memcpy, strerror, ...

Pour les chaînes en UTF8 : ces fonctions sont utilisables (strcpy, strcat, ...) sauf celles qui nécessitent une connaissance de l'encodage (strlen, ...) :

    char s[] = "aéç€📎";
    std::cout << std::strlen(s) << "\n";    // affiche 12

Il existe des variantes de null-terminated strings pour gérer différents encodages :

  • les multibyte strings (utilisant l'encodage de la locale) ;
  • les wide-strings avec des wide characters wchar_t (16 ou 32 bits).

🔹 La librairie standard fournit la classe std::string avec de nombreuses méthodes et opérateurs, voir liste. Exemple :

#include <string>
std::string s1 = "foo";             // init. par copie
std::string s2 {"bar"};             // init. avec accolades
std::string s3 = s1+s2;             // concaténation
std::cout << s3 << " "              // affichage
          << s3.size() << " "       // taille
          << s3[3] << "\n";         // accès à un caractère

Affiche :

foobar 6 b

Si besoin on peut revenir à une chaîne classique :

char* s4 = s3.data();               // pointeur sur le premier caractère
const char* s5 = s3.c_str();        // idem en read-only

🔹 La librairie standard fournit également la classe std::string_view (C++17) avec différentes méthodes, voir liste. Différences :

  • string est modifiable, de taille variable ;
  • string_view est read-only, ne possède pas la chaîne (mais une référence), pas de gestion de la mémoire, plus léger.
std::string s1 = "foo";
std::string_view s2 = s1;                   // init avec un string
std::cout << s1 << " " << s2 << std::endl;
s1 += "bar";                                // concaténation
s1[0] = 'F';                                // modification
std::cout << s1 << " " << s2 << std::endl;

Affiche :

foo foo
Foobar Foo

Observations :

  • s2 ne possède bien qu'une référence sur s1.data() puisque s2[0] a changé ;
  • la taille n'a pas été changée ;
  • ⚠  effet de bord, la chaîne pointée est sensée être constante.

🔹 Il existe de nombreuses variantes de std::string pour gérer les encodages ou la mémoire :

  • std::wstring contenant des wchar_t ;
  • std::u8string contenant des char8_t pour UTF-8 (C++20) ;
  • std::u16string contenant des char16_t pour UTF-16 (C++11) ;
  • std::u32string contenant des char32_t pour UTF-32 (C++11) ;
  • std::pmr::....string avec allocateur pmr (Polymorphic Memory Resource) permettant de gérer soi-même la mémoire (C++17).

7.3. Vector

Le conteneur vector est un tableau de taille variable avec stockage contigu des éléments en mémoire.

Déclaration :

#include <vector>
std::vector<type_case> nom_vector;
std::vector<type_case> nom_vector = {valeur_case, ....};
std::vector<type_case> nom_vector(taille_initiale);

Le conteneur vector gère la mémoire de manière très efficace lorsqu'on rajoute des éléments : il pré-alloue une zone au départ, puis il agrandit (lorsque nécessaire) la zone mémoire par blocs de plus en plus grands lorsqu'on rajoute des éléments.

Quelques méthodes (voir liste complète) :

  • empty - vrai si le conteneur est vide
  • size - renvoie le nombre d'éléments
  • reserve - pré-alloue une capacité de stockage
  • clear - vide le conteneur
  • insert - insère un élément à une position donnée
  • erase - supprime un élément d'une position donnée
  • push_back - insère un élément à la fin
  • pop_back - supprime l'élément de la fin

Exemple :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <vector>

int main()
{
    std::vector<int> v = {7, 3, 11};
    std::cout << "Taille = " << v.size() << "\n";
    for (std::size_t i = 0; i < v.size(); i++)
        std::cout << v[i] << " ";
    std::cout << std::endl;

    v.push_back(8);
    v.push_back(6);
    std::cout << "Taille = " << v.size() << "\n";
    for (auto x: v)
        std::cout << x << " ";
    std::cout << std::endl;
}

Affiche :

Taille = 3
7 3 11 
Taille = 5
7 3 11 8 6 

Remarque 1 : l'indice est de type std::size_t qui est un unsigned.

  • le déclarer en int émet un warning (comparaison avec un unsigned) ;

  • problème pour faire une boucle décroissante :

    for (std::size_t i = v.size()-1; i >= 0; i--) ....      // mauvais
    

    → boucle infinie car i ne peut pas être négatif. Une solution :

    for (std::size_t i = v.size(); i-- > 0; ) ....
    

Une autre solution est d'utiliser des itérateurs (vus au cours n°5).

Remarque 2 : opérateur []

  • il n'y a pas de contrôle de bornes, l'accès à un élément inexistant avec [] est indéfini (utiliser la méthode at qui génère une exception) ;
  • on ne peut pas insérer un élément avec l'opérateur [].

Complexité

  • l'accès à un indice quelconque est en 𝓞(1).
  • l'insertion ou la suppression à la fin est en globalement en 𝓞(1).
  • l'insertion ou la suppression à une autre position i est en 𝓞(n), où n est la distance entre i et la fin, car pour chaque opération il y a n décalages en mémoire.

7.4. Autres conteneurs

En plus de vector et string, la librairie standard fournit de nombreux conteneurs :

🔹 Des conteneurs de taille fixe :

  • pair - un tuple avec 2 éléments
  • tuple - un tuple de taille fixe
  • array - un tableau de taille fixe, stockage contigu

🔹 Des conteneurs de taille variable, pas d'accès indexé, stockage non contigu :

  • forward_list - liste simplement chaînée, efficace en insertion/suppression n'importe où
  • list - liste doublement chaînée, insertion/suppression n'importe où en temps constant

🔹 Des conteneurs de taille variable, accès indexé, stockage non contigu :

  • deque - queue avec 2 extrémités, insertion aux extrémités en 𝓞(1), consomme plus de mémoire qu'un vector.

🔹 Des conteneurs associatifs clés/valeurs :

  • map - collection ordonnée sur les clés, clés uniques
  • multimap - collection ordonnée sur les clés
  • unordered_map - collection hashée sur les clés, clés uniques, plus efficace que map
  • unordered_multimap - collection hashée sur les clés, plus efficace que multimap

🔹 Des conteneurs de clés :

  • set - collection de clés uniques, ordonnée
  • multiset - collection de clés, ordonnée
  • unordered_set - collection de clés uniques, non ordonnée (plus efficace que set)
  • unordered_multiset - collection de clés, non ordonnée (plus efficace que multiset)