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 "liste des initialisations de membres" (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; }
};
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 |
|
alors
{ Rectangle rect {1, 2, 3, 10, 20}; std::cout << "Fin bloc" << std::endl; }
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 |
|
À 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 |
|
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 |
|
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 |
|
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
:
#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 :
#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) :
#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 :
#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();
};
#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 :
#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 |
|
- 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 surs1.data()
puisques2[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 deswchar_t
;std::u8string
contenant deschar8_t
pour UTF-8 (C++20) ;std::u16string
contenant deschar16_t
pour UTF-16 (C++11) ;std::u32string
contenant deschar32_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 |
|
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 ununsigned
) ; -
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
)