Formation de base pour débuter la programmation avec le langage C++
Résumé
Ce livre s’adresse à tout développeur désireux d’apprendre le langage C++, dans le cadre de sesétudes ou pour consolider son expérience professionnelle.
Le premier chapitre présente les bases de la syntaxe du langage ainsi que l’organisation des programmes. Le chapitre suivant est une transition vers C++, il explicite les notions clés pour créer ses premières applications : structures, pointeurs, bibliothèques standard… Le troisième chapitre détaille la programmation orientée objets et les mécanismes spécifiques au langage (héritage, modèles de classes…). Vient ensuite l’étude de la STL (Standard Template Library), présentée à travers ses mécanismes les plus importants : les chaînes, les structures de données et les algorithmes. Le chapitre 5 ouvre C++ sur ses univers, le framework MFC et l’environnement .NET C++ CLI.
Comme illustration des capacités de C++ à créer tout type d’applications, l’ouvrage propose un exemple complet de tableur graphique ou encore un grapheur 3D. L’ouvrage se termine par un chapitre consacré à l’optimisation et aux méthodes de conception orientée objet (UML). Le code source des exemples du livre est disponible en téléchargement sur .
Les chapitres du livre :
Avant-propos - Introduction - De C à C++ - Programmation orientée objet - La bibliothèque Standard Template Library - Les univers de C++ - Des programmes C++ efficaces L'auteur
Ingénieur ESIEA, Brice-Arnaud GUÉRIN est responsable des développements logiciels chez LexisNexis, acteur majeur dans l'information juridique, économique et financière, et les solutions de gestion pour les professionnels. Ses compétences en développement, son désir de partager ses connaissances l'ont naturellement conduit à l'écriture d'ouvrages consacrés à la réalisation d'applications (.NET, PHP, C++) et à la conduite de projets.
Ce livre numérique a été conçu et est diffusé dans le respect des droits d’auteur. Toutes les marques citées ont été déposées par leur éditeur respectif. La loi du 11 Mars 1957 n’autorisant aux termes des alinéas 2 et 3 de l’article 41, d’une part, que les “copies ou reproductions strictement réservées à l’usage privé du copiste et non destinées à une utilisation collective”, et, d’autre part, que les analyses et les courtes citations dans un but d’exemple et d’illustration, “toute représentation ou reproduction intégrale, ou partielle, faite sans le consentement de l’auteur ou de ses ayants droit ou ayant cause, est illicite” (alinéa 1er de l’article 40). Cette représentation ou reproduction, par quelque procédé que ce soit, constituerait donc une contrefaçon sanctionnée par les articles 425 et suivants du Code Pénal. Copyright Editions ENI Ce livre numérique intègre plusieurs mesures de protection dont un marquage lié à votre identifiant visible sur les principales images.
Classes et instances
L’objectif poursuivi par Bjarne Stroustrup était, rappelonsle, d’implémenter sur un compilateur C les classes décrites par le langage Simula. Ces deux derniers langages étant radicalement opposés dans leur approche, il fallait identifier une double continuité, notamment du côté du C.
Il fut aisé de remarquer que la programmation serait grandement simplifiée si certaines fonctions pouvaient migrer à l’intérieur des structures du C. De ce fait, il n’y aurait plus de structure à passer à ces fonctions puisqu’elles s’appliqueraient évidemment aux champs de la structure.
Toutefois, il fallait conserver un moyen de distinguer deux instances et c’est pour cette raison que l’on a modifié la syntaxe de l’opérateur point :
Programmation fonctionnelle |
Programmation orientée objet |
struct Point { int x,y ; } ; void afficher(Point p) { printf("%d,%d\n",p.x,p.y); } |
struct Point { int x,y; void afficher() { printf("%d,%d\n",x,y); } } ; |
Point p1; afficher(p1); |
Point p1; p1.afficher(); |
Cette différence d’approche a plusieurs conséquences positives pour la programmation. Pour commencer, le programmeur n’a plus à effectuer un choix parmi les différents modes de passage de la structure à la fonction afficher(). Ensuite, nous allons pouvoir opérer une distinction entre les éléments (champs, fonctions) de premier plan et de second plan. Ceux de premier plan seront visibles, accessibles à l’extérieur de la structure. Les autres seront cachés, inaccessibles.
Ce procédé garantit une grande indépendance dans l’implémentation d’un concept, ce qui induit également une bonne stabilité des développements.
1. Définition de classe
Une classe est donc une structure possédant à la fois des champs et des fonctions. Lorsque les fonctions sont considérées à l’intérieur d’une classe, elles reçoivent le nom de méthodes.
L’ensemble des champs et des méthodes est désigné sous le terme de membres. Nous ne recommandons pas la désignation des champs à l’aide du terme attribut, car il peut prendre un sens très particulier en langage C++ managé ou en langage C#.
Pour le lecteur qui passe de Java à C++, il faut faire attention à terminer la déclaration d’une classe par le caractère pointvirgule, une classe étant la continuité du concept de structure :
class Point { int x,y ; // deux champs // une méthode void afficher() { printf("%d,%d\t",x,y); |
}
} ; // point-virgule
La classe suit globalement les mêmes règles que la structure pour ce qui est de son utilisation : instanciation / allocation, copie, passage à une fonction
a. Les modificateurs d’accès
Nous allons à présent opérer une distinction entre les méthodes (fonctions) accessibles depuis l’extérieur de la classe et celles qui n’ont qu’une utilité algorithmique. De même, certains champs sont exposés à l’extérieur de la classe, leur accès en lecture et en modification est autorisé, alors que d’autres doivent être protégés contre des accès intempestifs.
Cette distinction laisse une grande latitude dans l’organisation d’une classe, la partie cachée pouvant évoluer sans risque de remettre en question le reste du programme, la partie accessible étant au contraire considérée comme stable.
Le langage C++ offre plusieurs niveaux d’accessibilité et la palette de possibilités est assez large, si bien que certains langages qui lui succèdent dans l’ordre des publications ne les retiennent pas tous.
... ... ...
Les champs x et y sont bien présents pour chaque instance, mais ils ne sont lisibles/modifiables que par des méthodes appartenant à la classe, comme afficher() et positionner(). Les autres fonctions, même si elles appartiennent à des classes, n’ont pas d’accès direct à x et y. Elles doivent passer par des méthodes publiques de la classe Point.
Le champ couleur est lui public, ce qui signifie qu’il est complètement exposé.
Essayons maintenant d’appliquer ces règles de visibilité à la classe susdécrite.
Point p;
p.x=3; // erreur, x est privé
p.y=2; // erreur, y est privé
p.positionner(89,2); // ok, positionner() est publique
p.afficher(); // ok, afficher() est publique
p.couleur=0x00FF00FF; // ok, couleur est public
Lorsque les commentaires indiquent erreur, cela signifie que le compilateur relèvera la ligne comme n’étant pas valide. Le message circonstanciel peut avoir la forme "champ inaccessible" ou "champ privé", voire "champ invisible". Parfois le compilateur est plus explicite en indiquant que la portée privée (private) d’un membre ne convient pas à l’usage que l’on en fait.
Pour découvrir des exemples de champs protected ou de fonctions amies, nous devrons attendre encore quelques pages.
Pour le programmeur qui débute, le choix d’une portée n’est pas facile à opérer. Et il faut éviter de suivre le conseil suivant qui se révèle trop stéréotypé, un peu grossier : tous les champs sont privés (private) et toutes les méthodes sont publiques. Cette règle peut convenir dans certains cas, mais sans doute pas dans tous les cas de figure. D’abord, elle favorise trop la notion d’interface au détriment de l’algorithmie (la programmation fonctionnelle). Or une classe ne se résume pas à une interface, sans quoi l’encapsulation la réunion d’une structure et de fonctions n’aurait aucun intérêt. Les détails d’implémentation ont besoin d’être cachés pour évoluer librement sans remettre en question le reste du programme, fort bien. Ce qui nous conduit à déclarer certaines méthodes avec un niveau de visibilité inférieur à public. De même, certains champs ont un typage particulièrement stable, et si aucun contrôle n’est nécessaire quant à leur affectation, il n’y a aucune raison de les déclarer de manière privée.
Bien que le choix d’une visibilité puisse être décidé par le concepteur qui s’aidera d’un système de modélisation comme UML, le programmeur à qui incomberait cette responsabilité peut s’aider du tableau suivant pour décider quelle visibilité choisir. En l’absence de dérivation (héritage), le nombre de cas de figure est assez limité, et il faut bien reconnaître que les programmes contenant un nombre élevé de dérivations ne sont pas légion, surtout sans l’emploi d’UML.
champ algorithmique |
private ou protected |
champ de structure |
public |
champ en lecture seule |
private ou protected avec une méthode getXXX() publique |
champ en écriture seule |
private ou protected avec une méthode setXXX() publique |
champ en lecture et écriture avec contrôle des accès |
private ou protected, avec deux méthodes publiques getXXX() et setXXX() |
champ "constante" de classe |
champ public, statique et sans doute déclaré const |
méthode caractérisant les opérations accessibles à un objet |
Publique. La méthode fait alors partie de l’interface |
Méthode destinée à porter l’algorithmie |
private ou protected |
Nous constatons dans ce tableau que les fonctions amies n’y figurent pas. Il s’agit d’un concept propre à C++, donc très peu portable, qui a surtout de l’utilité pour la surcharge des opérateurs lorsque le premier argument n’est pas du type de la classe (reportezvous à la partie Autres aspects de la POO Surchages d’opérateurs sur la surcharge pour d’autres détails).
Également, il est fait plusieurs fois mention du terme "interface". Une interface est une classe qui ne peut être instanciée. C’est un concept. Il y a deux façons d’envisager l’interface, selon qu’on la déduit d’une classe ordinaire ou bien que l’on construit une classe ordinaire à partir d’une interface. Dans le premier cas, on déduit l’interface d’une classe en créant une liste constituée des méthodes publiques de la classe. Le concept est bâti à partir de la réalisation concrète. Dans le second cas, on crée une classe dérivant (héritant) de l’interface. Le langage C++ n’a pas de terme spécifique pour désigner les interfaces, bien qu’il connaisse les classes abstraites. On se reportera à la section Héritage Méthodes virtuelles et méthodes virtuelles pures sur les méthodes virtuelles pures pour terminer l’étude des interfaces.
Quoi qu’il en soit, c’est une habitude en programmation orientée objet (POO) de désigner l’ensemble des méthodes publiques d’une classe sous le terme d’interface.
b. Organisation de la programmation des classes
Il existe avec C++ une organisation particulière de la programmation des classes. Le type est défini dans un fichier d’entête .h, comme pour les structures, alors que l’implémentation est généralement déportée dans un fichier source .cpp. Nous nous rendrons compte par la suite, en étudiant les modules, que les notations correspondantes restent très cohérentes.
En reprenant la classe Point, nous obtiendrons deux fichiers, point.h et . À la différence du langage Java, le nom de fichier n’a d’importance que dans les inclusions et les makefiles. Comme il est toujours explicité par le programmeur, aucune règle syntaxique n’impose de noms de fichier pour une classe. Toutefois, par souci de rigueur et de cohérence, il est d’usage de nommer fichier d’entête et fichier source à partir du nom de la classe, en s’efforçant de ne définir qu’une classe par fichier. Donc si la classe PointColore vient compléter notre programme, elle sera déclarée dans PointColore.h et définie dans .
Voici pour commencer la déclaration de la classe Point dans le fichier point.h :
class Point { private: int x,y; public: void afficher(); void positionner(int X,int Y); int couleur; } ; |
Nous remarquons que les méthodes sont uniquement déclarées à l’aide de prototypes (signature close par un pointvirgule). Cela suffit aux autres fonctions pour les invoquer, peu importe où elles sont réellement implémentées.
Ensuite, nous implémentons (définissons) la classe Point dans le fichier :
void Point::afficher() { printf("%d,%d\t",x,y); } void Point::positionner(int X,int Y) { x=X; y=Y; |
}
Dans cette écriture, il faut comprendre que l’identifiant de la fonction afficher( ) se rapporte à la classe
Point (à son espace de noms, en fait, mais cela revient au même). C’est la même technique pour la méthode positionner().
Nous avions déjà rencontré l’opérateur de résolution de portée :: lorsque nous voulions atteindre une variable globale masquée par une variable locale à l’intérieur d’une fonction. En voilà une autre utilisation et ce n’est pas la dernière.
De nos jours, les compilateurs C++ sont très rapides et il n’y a plus de différence de performances à utiliser la définition complète de la classe lors de la déclaration ou bien la définition déportée dans un fichier .cpp. Les développeurs Java préféreront vraisemblablement la première version qui coïncide avec leur manière d’écrire une classe, mais l’approche déportée du C++, en plus d’être standard, offre comme avantage une lisibilité accrue si votre classe est assez longue. En effet, seule la connaissance des champs et de la signature des méthodes est importante pour utiliser une classe, alors que l’implémentation est à la charge de celui qui a créé la classe.
2. Instanciation
S’il est une règle impérative en science des algorithmes, c’est bien celle des valeurs consistantes pour les variables. Ainsi l’écriture suivante est une violation de cette règle :
int x,y; y = 2*x;
La variable x n’étant pas initialisée, il n’est pas possible de prédire la valeur de y. Bien que certains compilateurs initialisent les variables à 0, ce n’est ni une règle syntaxique, ni très rigoureux. Donc, chaque variable doit recevoir une valeur avant d’être utilisée, et si possible dès sa création.
D’ailleurs, certains langages comme Java ou C# interdisent l’emploi du fragment de code cidessus, soulevant une erreur bloquante et interrompant le processus de compilation.
Que se passetil à présent lorsqu’une instance de classe (structure) est créée ? Un jeu de variables tout neuf est disponible, n’attendant plus qu’une méthode vienne les affecter, ou bien les lire. Et c’est cette dernière éventualité qui pose problème. Ainsi, considérons la classe Point et l’instanciation d’un nouveau point, puis son affichage. Nous employons l’instanciation avec l’opérateur new car elle est plus explicite que l’instanciation automatique sur la pile.
Point*p; // un point qui n’existe pas encore p=new Point; // instanciation
p->afficher(); // affichage erroné, trop précoce
En principe, la méthode afficher() compte sur des valeurs consistantes pour les champs de coordonnées x et y. Mais ces champs n’ont jusqu’à présent pas été initialisés. La méthode afficher() affichera n’importe quelles valeurs, violant à nouveau la règle énoncée cidessus.
Comment alors initialiser au plus tôt les champs d’une nouvelle instance pour éviter au programmeur une utilisation inopportune de ses classes ? Tout simplement en faisant coïncider l’instanciation et l’initialisation. Pour arriver à ce résultat, on utilise un constructeur, méthode spéciale destinée à initialiser tous les champs de la nouvelle instance.
Nous compléterons l’étude des constructeurs au chapitre suivant et en terminons avec l’instanciation, qui se révèle plus évoluée que pour les structures.
Pour ce qui est de la syntaxe, la classe constituant un prolongement des structures, ces deux entités partagent les mêmes mécanismes :
? réservation par malloc() ?
? instanciation automatique, sur la pile ?
? instanciation à la demande avec l’opérateur new.
Pour les raisons qui ont été évoquées précédemment, la réservation de mémoire avec la fonction malloc() est à proscrire, car elle n’invoque pas le constructeur. Les deux autres modes, automatique et par l’opérateur new sont eux, parfaitement applicables aux classes.
Point p, m; // deux objets instanciés sur la pile
Point* t = new Point; // un objet instancié à la demande
3. Constructeur et destructeur
Maintenant que nous connaissons mieux le mécanisme de l’instanciation des classes, nous devons définir un ou plusieurs constructeurs. Il est en effet assez fréquent de trouver plusieurs constructeurs, distingués par leurs paramètres (signature), entraînant l’initialisation des nouvelles instances en fonction de situations variées. Si l’on considère la classe chaine, il est possible de prévoir un constructeur dit par défaut, c’estàdire ne prenant aucun argument, et un autre constructeur recevant un entier spécifiant la taille du tampon à allouer pour cette chaîne.
a. Constructeur
Vous l’aurez compris, le constructeur est une méthode puisqu’il s’agit d’un groupe d’instructions, nommé, utilisant une liste de paramètres. Quelles sont les caractéristiques qui le distinguent d’une méthode ordinaire ? Tout d’abord le type de retour, car le constructeur ne retourne rien, pas même void. Ensuite, le constructeur porte le nom de la classe. Comme C++ est sensible à la casse il différencie les majuscules et les minuscules il faut faire attention à nommer le constructeur Point() pour la classe Point et non point() ou Paint().
Comme toute méthode, un constructeur peut être déclaré dans le corps de la classe et défini de manière déportée, en utilisant l’opérateur de résolution de portée.
... ... ..
5. Constructeur de copie
Nous avons découvert lors de l’étude des structures que la copie d’une instance vers une autre, par affectation, avait pour conséquence la recopie de toutes les valeurs de la première instance vers la seconde. Cette règle reste vraie pour les classes.
Chaine s(20);
Chaine r;
Chaine x = s; // initialisation de x avec s
r = s; // recopie s dans r
Nous devons également nous rappeler que cette copie pose des problèmes lorsque la structure (classe) possède des champs de type pointeur. En effet, l’instance initialisée en copie voit ses propres pointeurs désigner les mêmes zones mémoire que l’instance source. D’autre part, lorsque le destructeur est invoqué, les libérations à répétition des mêmes zones mémoire auront probablement un effet désastreux pour l’exécution du programme. Lorsqu’un bloc a été libéré, il est considéré comme perdu et libre pour une réallocation, donc il convient de ne pas insister.
Les classes de C++ autorisent une meilleure prise en charge de cette situation. Il faut tout d’abord écrire un constructeur dit de copie, pour régler le problème d’initialisation d’un objet à partir d’un autre. Ensuite, la surcharge de l’opérateur (cf. Autres aspects de la POO Surcharge d’opérateurs) se charge d’éliminer le problème de la recopie par affectation.
class Chaine { // la déclaration ci-dessus public: Chaine(const Chaine&); // constructeur de copie Chaine& operator = (const Chaine&); // affectation copie } ; |
Commençons par le constructeur de copie :
Chaine::Chaine(const Chaine & ch)
{ t_buf = ch.t_buf; longueur = ch.longueur; buffer = new char[ch.t_buf]; // ch référence for(int i=0; i<ch.longueur; i++) buffer[i]=ch.buffer[i];
}
Le constructeur de copie prend comme unique paramètre une référence vers un objet du type considéré.
Poursuivons avec la surcharge de l’opérateur d’affectation :
Chaine& Chaine::operator=(const Chaine& ch) { if(this != &ch) { delete buffer; buffer = new char[ch.t_buf]; // ch référence for(int i=0; i<ch.longueur; i++) buffer[i] = ch.buffer[i]; longueur = ch.longueur; } return *this; } |
Il faut faire attention à ne pas libérer par erreur la mémoire lorsque le programmeur procède à une affectation d’un objet vers luimême :
s = s;
Pour une classe munie de ces deux opérations, les recopies par initialisation et par affectation ne devraient plus poser de problèmes. Naturellement, le programmeur a la charge de décrire l’algorithme approprié à la recopie.
Héritage
1. Dérivation de classe (héritage)
Maintenant que nous connaissons bien la structure et le fonctionnement d’une classe, nous allons rendre nos programmes plus génériques. Il est fréquent de décrire un problème général avec des algorithmes appropriés, puis de procéder à de petites modifications lorsqu’un cas similaire vient à être traité.
La philosophie orientée objet consiste à limiter au maximum les macros, les inclusions, les modules. Cette façon d’aborder les choses présente de nombreux risques lorsque la complexité des problèmes vient à croître. La programmation orientée objet s’exprime plutôt sur un axe générique/spécifique bien plus adapté aux petites variations des données d’un problème. Des méthodes de modélisation s’appuyant sur UML peuvent vous guider pour construire des réseaux de classes adaptés aux circonstances d’un projet. Mais il faut également s’appuyer sur des langages supportant cette approche, et C++ en fait partie.
a. Dérivation de classe (héritage)
Imaginons une classe Compte, composée des éléments suivants :
class Compte { protected: int numero; // numéro du compte double solde; // solde du compte static int num; // variable utilisée pour calculer le prochain numéro static int prochain_numero(); public: char*titulaire; // titulaire du compte Compte(char*titulaire); ~Compte(void); void crediter(double montant); bool debiter(double montant); void relever(); }; |
Nous pouvons maintenant imaginer une classe CompteRemunere, spécialisant le fonctionnement de la classe Compte. Il est aisé de concevoir qu’un compte rémunéré admet globalement les mêmes opérations qu’un compte classique, son comportement étant légèrement modifié pour ce qui est de l’opération de crédit, puisqu’un intérêt est versé par l’organisme bancaire. En conséquence, il est fastidieux de vouloir réécrire totalement le programme qui fonctionne pour la classe Compte. Nous allons plutôt dériver cette dernière classe pour obtenir la classe CompteRemunere.
La classe CompteRemunere, quant à elle, reprend l’ensemble des caractéristiques de la classe Compte : elle hérite de ses champs et de ses méthodes. On dit pour simplifier que CompteRemunere hérite de Compte.
class CompteRemunere : public Compte { protected: double taux; public: CompteRemunere(char*titulaire,double taux); ~CompteRemunere(void); void crediter(double montant); }; |
Vous aurez remarqué que seules les différences (modifications et ajouts) sont inscrites dans la déclaration de la classe qui hérite. Tous les membres sont transmis par l’héritage : public Compte.
Voici maintenant l’implémentation de la classe Compte :
#include "compte.h" #include #include Compte::Compte(char*titulaire) { this->titulaire=new char[strlen(titulaire)+1]; strcpy(this->titulaire,titulaire); solde=0; numero=prochain_numero(); } Compte::~Compte(void) { delete titulaire; } void Compte::crediter(double montant) { solde+=montant; } bool Compte::debiter(double montant) { if(montant<=solde) { solde-=montant; return true; } return false; } void Compte::relever() { printf("%s (%d) : %.2g euros\n",titulaire,numero,solde); } int Compte::num=1000; int Compte::prochain_numero() { return num++; } |
Mis à part l’utilisation d’un champ et d’une méthode statiques, il s’agit d’un exercice connu. Passons à la classe CompteRemunere :
#include ".\compteremunere.h" CompteRemunere::CompteRemunere(char*titulaire, double taux) : Compte(titulaire) { this->taux=taux; } CompteRemunere::~CompteRemunere(void) { } void CompteRemunere::crediter(double montant) |
{
Compte::crediter(montant*(1+taux/100));
}
L’appel du constructeur de la classe Compte depuis le constructeur de la classe CompteRemunere sera traité dans un prochain paragraphe. Concentronsnous plutôt sur l’écriture de la méthode crediter.
Cette méthode existe déjà dans la classe de base, Compte. Mais il faut remarquer que le crédit sur un compte rémunéré ressemble beaucoup au crédit sur un compte classique, sauf que l’organisme bancaire verse un intérêt. Nous devrions donc appeler la méthode de base, crediter, située dans la classe Compte. Hélas, les règles de visibilité et de portée font qu’elle n’est pas accessible autrement qu’en spécifiant le nom de la classe à laquelle on fait référence :