COURS C++ INITIATION :
Déclarer une variable :
On utilise la syntaxe :
TYPE NOM;
ou bien pour initialiser directement :
TYPE NOM = VALEUR_INITIALE;
Exemples :
int foo = 123;
string bar = "toto";
Si la variable ne va pas (ou ne DOIT pas) être remodifiée après son initialisation, on peut la déclarer comme une constante :
const TYPE NOM = VALEUR_INITIALE;
NB : la valeur initiale devient définitive et obligatoir, car si on essaie de remodifier une constante le compilateur produit une erreur.
En C++11 (ou suivants), on préfère utiliser la syntaxe sans le signe égal, en utilisant systématiquement les accolades pour initialiser tous les types de données (variables, listes, structures...) :
TYPE NOM { VALEUR_INITIALE };
On garde le signe égal pour les réaffectations.
Assigner une variable :
En C++ comme dans 90% des langages modernes, l'opérateur d'affectation/assignation est signe égal :
NOM = NOUVELLE_VALEUR;
On peut également simplifier certaines formules (opérateurs d'auto-assignation) :
NOM = NOM + VALEUR;
NOM += VALEUR;
NOM = NOM - VALEUR;
NOM -= VALEUR;
NOM = NOM * VALEUR;
NOM *= VALEUR;
NOM = NOM / VALEUR;
NOM /= VALEUR;
Il existe même des variantes encore plus simples pour 2 cas particuliers :
NOM = NOM + 1;
NOM += 1;
++NOM;
NOM = NOM - 1;
NOM -= 1;
--NOM;
NB : il existe des variantes en suffixes de la syntaxe en préfixe (
NOM++
,NOM--
) mais elle ne doivent pas être utilisées dans un programme moderne (trop d'ambiguiïté quant aux effets secondaires, et problème de performances).
La composition (tableaux et structures) :
On peut avoir souvent besoin de regrouper des valeurs de même type (tableaux) ou de type différent (structures) pour pouvoir les utiliser sous un même nom.
Tableau fixe (array) :
Pour déclarer un tableau, on ajoute la taille entre crochets après le nom, et la valeur devient une liste entre accolades (séparées par des virgules) :
TYPE NOM [TAILLE] = { VALEUR_1, VALEUR_2, ... };
Donc tous les éléments du tableau ont le même type (celui du tableau).
NB : La taille d'un tableau est fixe et doit être connue au moment de la compilation (ça ne peut pas être une variable, seulement un chiffre dans le code ou bien une
static constexpr
, voir section dédiée). Lorsqu'on veut un tableau à taille variable on doit utiliser des objets fournis par des librairies ou des frameworks (on les appelle list, vector, linked_list, set, etc...).
Exemple :
int notes[10] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
NB : si le tableau est initialisé sur la même ligne que sa déclaration, on peut omettre la taille entre les crochets (en laissant les crochets !) car elle sera déduite du nombre d'éléments entre les accolades.
On accède aux valeurs par un "index", c'est-à-dire un chiffre entre 0 et la taille du tableau -1, avec la syntaxe NOM_DU_TABLEAU[INDEX]
.
Structures (struct) :
Pour déclarer une structure on utilise un mot-clé spécial suivi du nom, et on ouvre des accolades pour ensuite déclarer toutes les variables membres à l'intérieur :
struct NOM_DE_LA_STRUCTURE {
TYPE_VAR_1 NOM_VAR_1;
TYPE_VAR_2 NOM_VAR_2;
TYPE_VAR_3 NOM_VAR_3;
...
};
NB : ne pas oublier le point-virgule final après l'accolade fermante !
Chaque élement membre de la structure peut avoir (ou non) un type distinct, et le nom de la structure devient également un type, à son tour utilisable ailleurs pour une variable/constante, un tableau, ou même le membre d'une autre structure.
On accède aux membres d'une structure, on utilise le nom de la structure suivi d'un point (ou d'une flèche dans le cas d'un pointeur, voir section dédiée), et du nom du membre auquel on veut accéder : NOM_DE_LA_STRUCTURE.NOM_VAR_MEMBRE
.
Et lorsqu'on veut initialiser variable de type structure au moment de sa déclaration, on peut spécifier les valeurs de chacun des membres, dans l'ordre, entre les accolades :
NOM_DE_LA_STRUCTURE NOM_DE_LA_VARIABLE { VALEUR_MEMBRE_1, VALEUR_MEMBRE_2, ... };
Exemple :
struct Point {
float x;
float y;
};
Point p1 { 1.2, 3.4 };
Point p2 { 5.6, 7.8 };
NB : si l'ordre change dans la déclaration des membres de la structure, cela casse la compatibilité avec les endroits dans le code où on aura utilisé cette syntaxe basée sur l'ordre des membres. On évitera donc autant que possible de réordonner les membres, mais surtout préférera déclarer explicitement des constructeurs (voir section dédiée), car il sera plus facile de maitriser l'ordre des initialisations.
Portée et durée de vie (scope & lifetime) :
Le RAII :
Le RAII est un concept de gestion automatique de la durée de vie d'une variable/constante, utilisée en C++ comme moyen principal de gérer la mémoire, et basée sur la notion de scope, c'est-à-dire la portée d'une déclaration.
De manière générale dans un fichier C++ (*.cpp), il existe deux scopes de base, le scope "global", en haut du fichier, et en dehors de tout autre bloc (fonction, classe, structure, etc...), et les différents scopes "locaux" qui sont imbriqués dans les blocs.
Un bloc en C++ est délimité par des accolades, le début du scope est marqué par l'accolade ouvrante, et la fin par l'accolade fermante. Le bloc peut être imbriqué dans un autre bloc à son tour.
La règle de base qui définit la portée d'une variable/constante est la suivante : elle est accessible et valide dans le bloc contenant la ligne de sa déclaration, seulement à partir de cette ligne, jusqu'à la fin du bloc. De plus, les éventuels blocs imbriqués, se trouvant entre la déclaration et la fin du bloc, y ont également accès. En revanche, on ne peut pas accéder à l'intérieur d'un bloc enfant depuis l'extérieur.
Exemple : voici le fichier "test.cpp" :
int a;
BLOC_1 {
...
int b;
...
BLOC_2 {
...
int c;
...
BLOC_3 {
...
int d;
...
}
...
int e;
...
}
...
int f;
...
}
Pour le but de l'exemple, chaque variable a été mise dans un niveau différent, et voici la portée de chacune des variables :
- 'a' : se trouve dans le "scope global" à la racine du fichier.
- créée au lancement de l'appli
- détruite à la fermeture
- accessible partout dans le fichier "test.cpp"
- 'b' : se trouve au debut du "scope local" du "BLOC_1"
- créée au passage sur la ligne de sa déclaration
- détruite à sortie du "BLOC_1"
- accessible sans le "BLOC_1", et dans les sous-blocs "BLOC_2" et "BLOC_3", entre sa déclaration et la fin du bloc
- 'c' : se trouve dans le "scope local" du "BLOC_2"
- créée au passage sur la ligne de sa déclaration
- détruite à sortie du "BLOC_2"
- accessible dans le "BLOC_2" et le sous-bloc "BLOC_3" entre sa déclaration et la fin du bloc
- 'd' : se trouve dans le "scope local" du "BLOC_3"
- créée au passage sur la ligne de sa déclaration
- détruite à sortie du "BLOC_3" entre sa déclaration et la fin du bloc
- accessible dans le "BLOC_3"
- 'e' : se trouve dans le "scope local" du "BLOC_2" mais après le "BLOC_3"
- créée au passage sur la ligne de sa déclaration
- détruite à sortie du "BLOC_2"
- accessible dans le "BLOC_2" entre sa déclaration et la fin du bloc
- 'f' : se trouve dans le "scope local" du "BLOC_1" mais après le "BLOC_2"
- créée au passage sur la ligne de sa déclaration
- détruite à sortie du "BLOC_1"
- accessible dans le "BLOC_1", entre sa déclaration et la fin du bloc
Variables/constantes statiques (static)
Il existe plusieurs significations au mot-clé static
en C++, une qui s'applique au stockage des variables/constantes, et l'autre concerne les fonctions (voir section dédiée).
Une variable ou constante statique n'est pas ré-initialisée à chaque fois que l'on passe sur sa déclaration, car elle n'est pas réellement stockée dans le scope contenant la ligne de déclaration. Elle vit dans un espace appelé "static storage", qui est initialisée la première fois que l'ont passe sur la ligne de code qui la déclare, qui est détruit uniquement à la fermeture de l'application.
La syntaxe de déclaration est la même qu'une variable ou constante normale, avec static
juste devant :
static TYPE NOM { VALEUR };
static const TYPE NOM { VALEUR };
static constexpr TYPE NOM { VALEUR };
NB : la dernière syntaxe, avec
constexpr
au lieu deconst
est disponible depuis C++11, et à préférer lorsqu'il s'agit d'une constante numérique. Dans la pratique cela peut permettre beaucoup de choses pointues, mais dans l'immédiat il n'y a qu'une utilité : déclarée une constante de manière absolue, à la compilation, pour pouvoir l'utiliser par exemple comme taille pour un tableau fixe (array).
En programmation orientée-objet (comme c'est le cas du C++ en général) on évite de déclarer des variables/constantes statiques dans le scope global, on les met soit dans un scope local, ou dans une structure/classe. Cela évite les problèmes de type "collision de nom", ou encore de "pollution de l'espace de nom". Cela permet aussi de mieux gérer la durée de vie de ces variable statiques.
Pointeurs et références
Il peut régulièrement être utile de créer une variable contenant l'emplacement mémoire d'une autre, pour pouvoir transmettre cette adresse à une fonction par exemple.
En C, il n'existait que le "pointeur" et le C++ a ajouté la notion de "référence". Ces deux manières de faire ont la même utilité de base : éviter des duplications de donnée inutiles, qui consommeraient de la RAM et du temps CPU.
De façon générique et bas niveau, un pointeur ou une référence est simplement une variable numérique (en interne) qui contient comme valeur la position de la variable pointée/référencée dans la mémoire RAM (l'index de la "case" mémoire).
La principale différence entre pointeur et référence est la façon de l'utiliser : un pointeur se traite comme tel, il faut savoir qu'on en utilise un et adapter la syntaxe de différentes manières, alors qu'une référence est quasiment "transparente" d'un point de vue syntaxique, elle s'utilise comme une variable normale.
Déclaration
La première différence est au niveau de la déclaration :
- Soit une variable déclarée :
int originale = 123;
- On peut déclarer un pointeur :
int * pointeur = &originale;
- On peut déclarer une référence :
int & référence = originale;
Explication : pour créer un pointeur (déclaré avec TYPE *
) à partir d'une variable on doit utiliser l'opérateur &
qui "prend l'adresse" de la variable et la stocke dans le pointeur, alors que la référence (déclarée avec TYPE &
) prend automatiquement l'adresse de la variable qu'on lui assigne.
NB : vu que la prise d'adresse est explicite et manuelle pour les pointeurs, on peut tout-à-fait déclarer un "pointeur de pointeur" (
TYPE **
) et lui affecter l'adresse d'un pointeur. Alors qu'assigner une référance à une autre ne crée pas une "référence de référence".
Utilisation
La deuxième différence est au niveau de l'utilisation (en reprenant les déclaration ci-dessus) :
- Pour accéder à la valeur pointée par un pointeur :
*pointeur
- Pour accéder à la vauleur référencée par une référence :
référence
NB : si on affecte une valeur directement à une variable de type pointeur sans utiliser l'opérateur
*
("dé-référencement"), au lieu de modifier la variable pointée on va modifier le pointeur, et le faire pointer ailleurs (probablement pas à un endroit autorisée).
Cas des structures
La troisième différence, c'est lorsqu'on manipule des pointeurs/références vers des structures :
POINTEUR_STRUCT->MEMBRE = 123;
REFERENCE_STRUCT.MEMBRE = 123;
La référence ne modifie pas la syntaxe de base (utilisation d'un point) mais le pointeur nécessite de remplacer le point par une flèche.
NB : un pointeur (contrairement à une référence) peut également être NULL ou invalid (dangling) s'il a été mal/pas initialisé ou si sa valeur a été modifiée depuis, ou encore si l'objet pointé à été détruit. Il convient donc de s'assurer de la durée de vie des pointeurs par rapport à celle des objets qu'ils pointent, et de toujours les initialiser à NULL ou à une valeur valide, explicitement, afin de pouvoir tester avant leur utilisation, s'ils sont NULL ou non.
Choisir entre pointeur et référence
La référence est beaucoup plus simple à utiliser, presque transparente au niveau syntaxique, et elle génère moins de risque liés à son utilisation. On préférera l'utiliser chaque fois qu'il est nécessaire d'adresser un objetr. Mais il existe quelques rares cas où l'emploi de pointeurs est obligatoire car la référence impose trop de restrictions (pour garantir sa sécurité) :
- Si on doit pouvoir modifier l'adresse de l'objet pointé, il faut utiliser un pointeur, car la référence prend une adresse uniquement lors de sa déclaration et ne peut plus être redirigée ensuite.
- Si on veut pouvoir avec une adresse NULL dans certains cas, il faut utiliser un pointeur, car la référence est obligatoirement non-nullable.
- Si on veut contenir l'adresse d'une adresse, il faut utiliser un pointeur, car on ne peut pas imbriquer les références.
NB : par conception, le compilateur C++ interdit qu'une référence survive à la destruction de l'objet qu'elle adresse. C'est pour cela qu'on aura une erreur de compilation si on tente de renvoyer une référence à une valeur locale, en tant que valeur de sortie d'une fonction, car la variable sera détruite et donc la référence deviendrait invalide (c'est en revanche totalement possible avec un pointeur, bien que totalement dangereux).
Il convient d'utiliser les pointeurs avec grande précaution, en préférant les références chaque fois qu'il est possible, et le reste du temps, essayer d'utiliser les "pointeurs intelligents" fournis par différentes librairies/frameworks, qui intègrent de la logique additionelle pour tenter de palier les risque des pointers "bruts".
Fonctions
Une fonction est un bloc de code autonome, réutilisable, auxquel on peut passer 0...N valeurs en entrée, et qui peut produire 0...1 valeur en sortie.
Les valeurs d'entrée sont appelés "arguments" (arguments ou juste args) ou encore "paramètres" (parameters ou juste params).
La valeur de sortie est appelée "valeur de retour" (return value).
La syntaxe pour déclarer une fonction est la suivante :
TYPE_RETOUR NOM_FONCTION (TYPE_ARG_1 NOM_ARG_1, TYPE_ARG_2 NOM_ARG_2, ...) {
...
...
...
return VALEUR_DE_SORTIE;
}
Si la fonction ne renvoie rien, on met le type de retour void
("vide"), et on peut omettre la ligne avec le mot clé return
car il n'y aura pas de valeur de sortie.
De même, si la fonction n'a aucune valeur d'entrée, on peut mettre void
entre les parenthèses à la place des arguments, ou même rien du tout, juste les parenthèses.
Pour appeler une fonction précédemment déclarée, il suffit d'utiliser son nom suivi par des parenthèses, avec d'éventuels arguments entre ces parenthèses. Si la fonction renvoie quelquechose, on peut également affecter le résultat à une variable déclarée pour ce besoin.
Exemple : fonction renvoyant la somme de ses 2 paramètres :
int add (const int left, const int right) {
return (left + right);
}
const int result1 = add (12, 34);
const int result2 = add (56, 78);
La valeur renvoyée par une fonction est traitée comme n'importe quelle autre variable de type identique, et donc elle peut remplacer n'importe laquelle de ces variables, dans n'importe quelle expression (calcul, assignation, ou même pour la passer en paramètre à une autre fonction).
func1 (func2 (123, func3 (456, 789)));
NB : il est important de se rapeller qu'une fonction est avant tout un bloc, et à ce titre, elle possède les même règles concernant la durée de vie et la portée des variables déclarées à l'intérieur. Les arguments de la fonction sont considérés comme faisant partie du tout premier scope de la fonction, et sont donc crées lors de l'appel de la fonction, et détruit lorsqu'on ressort de la fonction.
Dans le cadre des fonctions, il est très courant d'utiliser des références ou des pointeurs, pour passer des données à la fonction pour qu'elle les utilise, sans pour autant les copier à chaque appel et produire des copies temporaires qui seraient détruites dès la sortie de la fonction.
Pourtant il n'est pas judicieux de mettre des références (ou pointeurs) systématiquement pour tous les arguments, sans distinction, car une référence ou un pointeur ont un double coût :
- en place RAM : ils "pèsent" la taille d'une adresse mémoire (soit 4 octets sur un CPU 32 bit, ou 8 octets sur un 64 bits) donc plus gros que les (u)int8 (1 octet), (u)int16 (2 octets), voire (u)int32 (4 octets), ou encore bool (1 octet), donc il serait dommage d'occuper plus de place en essayant d'éviter de copier un objet pourtant plus petit.
- en temps CPU : ils ajoutent un "niveau d'indirection", ce qui veut dire que pour accéder à la "vraie" donnée il faut d'abord résoudre son adresse (comme une redirection, un "jeu de piste") donc le CPU perd un peu de temps.
La règle pour choisir ou non d'utiliser une référence (ou un pointeur) au lieu d'une copie temporaire est donc la suivante :
- si la fonction doit pouvoir modifier la valeur d'origine qu'on lui passe (et non une copie temporaire qui serait détruite) on lui passe une référence ou un pointeur modifiables (non-constants).
- si la fonction ne doit pas modifier la valeur d'origine, on lui passer une copie si la valeur est plus petite ou de même taille qu'un pointeur, et sinon pour toute valeur plus grosse qu'un pointeur, on utilise une référence constante, ou éventuellement un pointeur constant.
TYPE_RETOUR fonction (const TYPE_ARG_LOURD & NOM_ARG) {
...
}
NB : On peut omettre de passer certains arguments à une fonction, à la condition qu'ils aient été déclarés comme optionels, en précisant simplement leur valeur par défaut lors de la déclaration avec le signe égal. Le/les arguments optionnels doivent obligatoirement se trouver après les arguments non-optionels, car le C++ passe toujours les arguments dans l'ordre et donc il doit pouvoir savoir que tous les arguments obligatoires ont bien été fournis.
Classes vs. Structures
Les structures (struct NOM { ... };
) sont comme des tableaux, elles servent à regrouper des variables ensemble, mais contrairement aux tableaux, les membres d'une variable peuvent avoir des types différents.
Une classe (class NOM { ... };
) est par convention une structure dans laquelle les variables sont encapsulées (cachées) dans la partie private
de la classe, et on choisit au cas par cas d'exposer (ou non) certaines variables dans la partie public
mais jamais directement. On utilise à la place des accesseurs qui sont des fonctions très simples :
- getter : une fonction qui ne prend pas de paramètres et dont la seule tâche est de renvoyer la valeur actuelle d'une des variables internes. Elle ne modifie jamais l'état de la classe.
- setter : une fonction qui prend un paramètre et le stocke dans une des variable interne, pour permettre d'altérer l'état de la classe. Cela ajoute comme possibilités :
- le fait de ne pas forcément tout rendre modifiable
- le fait de modifier plusieurs choses d'un coup (la variable visée, ainsi que d'autres états purement internes)
- la capacité de conditionner l'acceptation de la modification (validation de format)
- la possibilité de convertir la valeur avant de la stocker
Constructeur et destructeur
Il est important d'initialiser l'état de base par défaut des variables d'une classe ou d'une structure. Pour faire cela il existe une fonction spéciale nommée "constructeur", que l'on peut déclarer en plusieurs variantes :
NOM_DE_CLASSE (ARGUMENTS) : INITIALISEURS {
// ... logique de démarrage
}
Plusieurs remarques :
- le constructeur n'a pas de type de retour (pas même
void
) - les arguments peuvent varier et on peut créer plusieurs constructeurs avec différents arguments pour donner plusieurs façon différentes de créer l'objet
- les initialiseurs se présente sous la forme d'une liste (séparée par des virgules) dont chaque élément doit initialiser une des variable interne, dans l'ordre de la déclaration, avec la syntaxe
NOM_VARIABLE { VALEUR }
- les valeurs utilisées dans les initialiseurs peuvent être des arguments du constructeur ou bien de simple valeur codées directement
Héritage
On peut créer une arborescence de classes dont chaque élément récupère tous les attributs (variables et fonctions) de ses ancêtres, bien qu'il n'aura accès qu'à la partie public
.
Lorsqu'une classe dérive d'une autre, son constructeur doit invoquer le constructeur de sa classe parent avant de commencer à initialiser ses propres variables (en lui passant des arguments si besoin) :
NOM_DE_CLASSE (ARGUMENTS)
: CLASSE_PARENT { ARGUMENT_CONSTRUCTEUR_PARENT, ... }
, INITIALISEUR_1 { VALEUR_1 }
, INITIALISEUR_2 { VALEUR_2 }
, INITIALISEUR_3 { VALEUR_3 }
, ...
{
// ... logique de démarrage (peut être vide)
}
En plus de mutualiser les déclarations de variables, et de pouvoir réutiliser une fonction déclarée dans une classe plus générique, cela apporte aussi la notion de "polymorphisme". Le principe de base est qu'on peut déclarer une fonction dans une classe générique en ayant seulement une partie du code nécessaire, voire même rien du tout. On laisse le soin aux sous-classes de compléter le corps de la fonction avec de la logique spécifique à chacune d'entre elles (et donc différentes pour chaque sous-classe d'une même classe générique). Cela permet de dire que toutes les classes dérivées ont la même capacité, sans pour autant qu'elles aient la même implémentation.
Il suffit de déclarer une fonction comme étant "virtuelle" dans la classe de base :
virtual TYPE_RETOUR NOM_FONCTION (ARGUMENTS) { .... }
Les sous-classe pourront mettre leur propre version à la place en "réimplémentant" la fonction :
virtual TYPE_RETOUR NOM_FONCTION (ARGUMENTS) override { .... }
ou
TYPE_RETOUR NOM_FONCTION (ARGUMENTS) final { .... }
La différence entre les deux variantes c'est qu'une "virtual override" pourra à nouveau être redéfinie si la sous-classe venait à être à son tour sous-classée, alors que la "final" comme son nom l'indique est définitive.
Il existe un cas particulier dans lequel la classe de base n'a pas du tout d'implémentation pour la fonction, elle la déclare donc "virtuelle pure" :
virtual TYPE_RETOUR NOM_FONCTION (ARGUMENTS) = 0
Dans ce cas la classe devient "abstraite", on ne peut plus l'instancier directement, on ne peut que la décliner en d'autres sous-classes qui seront forcées de réimplémenter les fonctions virtuelles pures pour ne pas être elles mêmes abstraites.