Le Langage C++

et un peu de

Programmation par Objets


 


Première Partie: Objets et Classes en C++..
 


Najib TOUNSI ( ntounsi@emi.ac.ma)
Creation : Mai 1998
Derniere MAJ: Oct 98


N.B. Les lecteur.es sont supposé.es avancé.es en matière de programmation et de structure de données, et connaissant le langage C.

0. Il Etait Une Fois C

Le langage C++, est né du besoin d'intégrer les mécanismes d'abstraction dans le langage C. En effet, des notions déjà très connues --modularité et protection de l'information, types abstraits de données et classes à héritage etc ...-- et bien étudiées par les chercheurs, en particulier dans le domaine du génie logiciel, sont devenues essentielles pour une bonne pratique de la programmation. C++ est né au début des années 80s grâce à Bjarn STROUSTRUP[1] , avec pour but de succéder au langage C, non pas comme un nouveau langage, mais comme un sur-ensemble de C, c'est à dire C augmenté. La principale influence vient du concept de classe du langage SIMULA. Le but est fournir à C des facilités d'abstraction qui aident à concevoir et à structurer un système logiciel. L'idée fondamentale ici, est de séparer les détails particuliers d'une implantation de "sous-programme" des propriétés essentielles à son utilisation correcte. Une telle séparation peut être réalisée en canalisant toute utilisation du "sous-programme" à travers une interface spécifique. Typiquement, une interface est un ensemble de fonctions qui accèdent à une structure de données qui représente l'abstraction. C est donc augmenté par de nouveaux mécanismes, classes d'objets avec héritage, surcharge et polymorphismes, tout en gardant son caractère concis, et ses performances.

Nous allons d'abord examiner quelques améliorations propres apportées au langage C, un premier +, avant de considérer les classes.

1. Un Premier +

Les améliorations apportées à C, concernent surtout le domaine de vérification des types et tous contrôles de sémantique statique réalisables à la compilation.

1) Typage et déclaration de fonction: Toute fonction doit être déclarée (sinon définie) avant d'être utilisée. Cette déclaration (appelée prototype en C ANSI) serait, pour une fonction factorielle:

int factorielle(int);

Ainsi le compilateur peut vérifier si la fonction est bien utilisée lors de chaque appel. Par ailleurs, le type void est introduit pour spécifier une fonction routine sans résultat.

void permut (int, int);

2) Moins de préprocesseur: La facilité de fonction macrodéfinies en C, est avantageusement améliorée par un mécanisme de fonction en ligne (inline).

inline int max (int x, int y)
{ return (x > y ? x : y );}

Chaque appel à cette fonction sera donc remplacé par le code correspondant (au lieu de générer un branchement, avec empilement des paramètres etc..., sources d'overhead). Contrairement aux macros C, les fonctions en lignes préservent la sémantique des appels. Cependant, le compilateur peut ne pas tenir compte de inline si l'expression résultante du remplacement est très complexe.

3) Déclaration d'un objet dès son usage: On n'est plus obligé de déclarer les variables locales en début de bloc. on peut écrire par exemple

for(int i=0; i<n; i++) ....

On peut aussi déclarer des constantes typées.

const float pi = 3.14;

garantit que pi ne peut changer de valeur (du moins directement).
const s'étend aux pointeurs aussi

const int* p;

déclare que p contiendra une adresse qui doit rester constante.

4) Arguments de fonction en nombre variable: Une fonction peut avoir un nombre variable de paramètres. Cette caractéristique existe aussi en C ANSI (voir ellipse en langage C).

5) Arguments par défaut: Une fonction peut recevoir à l'appel un nombre de paramètres effectifs inférieur à celui déclaré. Les paramètres manquants reçoivent alors une valeur par défaut, signalée par =.

void f(int, float = 1., char = '\0');

Les appels suivants sont possibles: f(5); f(5, 3.14); f(5, 3.14, 'c'); Si l'un des paramètres manque, il est remplacé par celui fourni à la déclaration. Les deux premiers appels sont donc équivalents à: f(5, 1., '\0'); f(5, 3.14, '\0'); . On peut noter que c'est une façon beaucoup plus simple de réaliser des fonctions à nombre de paramètres variable, mais borné.

6) Surcharge de fonctions: Plusieurs fonctions différentes peuvent avoir le même nom. La fonction à appeler est déterminée au moment de l'appel, selon le prototype qui colle le mieux. On peut par exemple avoir les 3 fonctions:

void f(int);
void f(char);
void f(int,int);

et les appeler par: f(1), f('c'), f(2, 3); A chacun des appels correspond à la bonne fonction.

Cette caractéristique est intéressante quand on veut avoir un même nom mnémonique pour plusieurs fonctions semblables. Cette caractéristique s'avère très utile pour les constructeurs d'objets.

7) Passage des paramètres par référence: C++ a introduit un nouveau constructeur de type qui est référence à un objet. On déclare TYPE& variable; La séquence

int &r1, &r2;
int i, j;
r1 = &i;
r2 = i;

affecte l'adresse de i aussi bien à r1 qu' à r2. Quand elle est utilisée, une référence est implicitement déréférencée. Après

j = r2;

j contient l'objet référencé (pointé) par r2. L'écriture

r1 = r2;

signifie copier l'objet référencé par r2 dans celui référencé par r1; Une référence est traitée comme un pointeur, mais la déréférence est automatique. L'usage des références est essentiellement prévu pour le passage des paramètres sans copie. Exemple:

void permut(int& x, int& y){x=x+y; y=x-y; x=x-y;}

sera appelée par permut (u,v); A comparer avec l'appel permut (*u,*v); en C normale avec:

void permut(int* x,int* y)
{*x= *x + *y; *y= *x - *y; *x= *x - *y;}

Le passage des paramètres par référence est utile quand la taille d'un objet est trop grande pour être passé par valeur (et copié).

8) Commentaires: On peut les entourer par /* ...*/ comme en C, ou les introduire par // qui prend le reste de la même ligne comme commentaire. Cela a l'avantage de la concision (en plus du fait que le compilateur ne perd pas le texte du programme si le commentaire n'est pas fermé). On peut aussi imbriquer les commentaires

// nouveau // ancien commentaire.

9) Allocation/désallocation mémoire: new et delete sont deux nouvelles instructions de gestion dynamique de la mémoire, i.e. allocation/désallocation en cours d'exécution. On écrit new TYPE; et delete VARIABLE;

int *i = new int; char *s = new char[10];
i = new int; delete i; delete[10]s;

10) cin et cout : Ce sont de nouvelles fonctionnalites pour les entrée/sortie qui peuvent tenir lieu du couple printf/scanf. On se passe de format par exemple, et l'instruction est ainsi plus simple (!) à écrire. cout est pour l'écriture et cin pour la lecture:

cout << "x =" << x;
cout <<i <<j <<s <<i ;
cin >>a >>b >>c;

Les expressions, à imprimer (resp. variables à lire) sont séparée par << (resp. >>). Ces fonctions sont définies dans <stream.h>[2].

2. Type Abstrait de Données

C++, a introduit les moyens de programmer avec des abstractions. Un Objet informatique se caractérise par la nature des manipulations qu'on désire y effectuer. Dans un langage de programmation, la notion de type sert à cela; un entier par exemple ne ne se manipule pas--n'a pas le même comportement-- comme un caractère ou un booléen. Un type abstrait de donnée (TAD) est un ensemble d'objets caractérisés par les mêmes opérations. Ces dernières opèrent sur la structure de l'objet (appelée représentation) qui est composée des données reflétant l'état de l'objet. Il en est ainsi pour un objet pile qu'on peut manipuler par des opérations comme empiler, dépiler, sommet etc... Une représentation pourra être par exemple un tableau -qui contient les éléments empilés-avec un entier qui indique l'élément sommet de pile. Un programme qui nécessite l'usage d'une pile n'a besoin de connaître que les opérations mentionnées et qu'on appelle interface . Il n'a en effet pas besoin savoir qu'une pile est un tableau ( et encore moins d'y accéder directement), ni même de connaître le code des opérations, lequel peut varier selon la représentation. C'est cela l'abstraction. Ce principe place le programmeur à un haut niveau de conception. Un TAD, encapsule les opérations et cache la structure sous-jacente d'un objet. Ainsi, le programmeur consacrera ses efforts à la solution d'un problème sans se préoccuper des détails inutiles. Il s'intéressera à ce qu'un objet peut fournir comme service et ignorera comment ses services sont réalisés de façon concrète. Si p est une pile, on écrira p.empiler(n), au lieu de quelque chose comme s = s+1; t[s]=n;

Figure 1. Encapsulation des Données et
des Traitements dans un Objet

3. Notion de Classe

Une classe est un ensemble d'objets de même nature, i.e. caractérisés par la même structure et manipulables par les mêmes opérations. C'est en fait la notion de TAD avec l'héritage (possibilité de définir des sous-classe, voir [[section]] 10) et une certaine souplesse[3] d'utilisation. On peut aussi considérer qu'un TAD est une notion conceptuelle et la classe est un mécanisme qui l'implante et qui permet d'en instancier des objets (voir constructeurs [[section]] 5).

La classe est donc le moule qui permet de construire des objets de nouveaux types. Un objet est constitué d'une représentation (sa structure) et d'un ensemble d'opérations par lesquelles il est utilisable ( opérations sous formes de fonctions qui travaillent sur sa représentation). Cet ensemble d'opérations constitue ce qu'on appelle parfois aussi le comportement de l' objet, dans le sens algorithmes qui lui s'appliquent.

exemple 1:

Voici une classe d'objets de type article, correspondant à un article de commerce (inspiré de "Les Langages à Objets", Masini et al. Dunod).

class article {

int numero;  // numéro article
char* nom; // son nom
float prixHT; // son prix
int qte; // Qte en stock

public:

float prixTTC ()
    { return prixHT*1.19;}
    // opération qui calcule le prixTTC d'un article


void ajouter (int q)
    {qte = qte + q;}

    // opération qui augmente la quantité en stock

void retirer (int q)
    {qte = qte - q;}
    // opération qui diminue la quantité en stock

};

Un article est décrit par un entier qui représente son numéro, une chaîne qui représente son nom, un réel qui représente son prix hors taxe et un entier qui indique la quantité actuelle en stock. Sur un objet de type article on peut appliquer l'opération représentée par la fonction prixTTC() , qui permet de connaître le prix T.T.C. d'un article, augmenter ou diminuer sa quantité en stock, opérations représentées par les fonctions ajouter() et retirer() respectivement.

En terminologie objets, les données (variables) constituant la représentation sont appelées champs et les opérations (fonctions et procédures) appelées méthodes. Parfois, mais à un niveau plus conceptuel, on parle indifféremment de caractéristiques d'un objet, que ce soit champ ou méthode[4]. En C++, on dit membre (member) pour un champ ou une méthodes.

Le mot clé public signifie que les caractéristiques (membres donc) qui le suivent sont les seules utilisables par les programmes qui font usage de cette classe. Il partage ainsi la classe en deux parties: l'une publique, l'autre privée. Par défaut les membres sont privés. Dans cet exemple, les champs ne sont pas utilisables pour un objet article (ne sont pas visibles à l'extérieur), et seules les méthodes doivent être utilisées pour manipuler un tel objet. Cela va dans le sens de l'abstraction et de la protection de l'information (information hiding), où pour utiliser un objet, on n'a besoin de connaître que son interface, c'est à dire les méthodes applicables, sans se préoccuper de sa structure qui est son affaire privée (que seules les méthodes connaissent. Et pour cause). Noter qu'il existe aussi le mot private qui indique, pour la lisibilité, les membres privés.

Dans le paragraphe suivant on verra comment on utilise un objet d'une classe dans un programmes. Voici maintenant un deuxième exemple, très classique (et déjà mentionné plus haut) d'une pile. Il s'agit ici d'une pile de caractères. Les fonctions membres sont supposées auto significatives (et sont légèrement commentées). MAX est une constante supposée macro-définie par ailleurs.

exemple 2:

class pile{

    char t[MAX];     // Tableau pour les éléments de la pile
    int top;         // Indice du sommet de la pile.

public:

    void init();         // Initialise la pile à vide
    void empiler(char);    // Met un char en sommet de  pile
    void depiler();     // Décapite la pile (enlève le  sommet)
    char sommet();     // Consultation du sommet de  pile
    int estVide();     // Teste si pile vide (1 oui,  0 non)
};


Dans cette description de classe pile, on n'a mis que les entêtes ou déclarations des fonctions membres. Leur implantation, ou corps, peut faire l'objet d'une définition ultérieure dans le programme, comme pour une fonction C normale. Mais chaque définition doit , pour se distinguer, être préfixée par le nom de la classe pour laquelle elle est membre. On utilise pour cela, le symbole :: dit opérateur de résolution de portée (classe::membre). Il sert à indiquer que cette fonction n'est pas une fonction C quelconque, mais appartient à la classe spécifiée. Voici une implantation pour les membres de la classe pile:

void pile::init(){
    top = -1;
}

void pile::empiler(char c){
    if (top < MAX)
        t[++top] = c;
    else cout << "Pile pleine\n";
}

int pile::sommet(){
    return t[top];
}
...

Quand les corps sont développées dans la déclaration de la classe (comme dans la classe article de l'exemple précédent) , ils sont considérés en ligne (mot inline implicite) et bénéficient donc de cet avantage.

Dans cet exemple pile, on a utilisé une opération init() sensée initialiser un objet pile. En effet, un objet doit pouvoir être construit, avec des valeurs initiales, avant qu'on puisse le manipuler par ses méthodes. Dit autrement, une classe doit avoir un (ou plusieurs) de ses membres qui jouera un rôle de constructeur qui crée (initialise plus exactement) une première fois un objet de la classe. Cette notion de constructeur, est plus formelle que cela, et nous en reparlerons plus loin pour C++. Pour l'instant, nous en considérerons juste l'idée intuitive représentée par la fonction membre init() de la classe pile[5].

4. Usage d'une Classe et Instanciations d'Objets

Figure 2. Une Classe, deux Instances. Les mêmes méthodes
s'appliquent à chacune des instances.


Instanciation

Une fois une classe définie, on dispose d'un nouveau type d'objets, qui vient enrichir ceux déjà existants. On peut déclarer des variables du type de cette classe et les manipuler avec les méthodes associées. On appelle instance, un exemplaire (ou occurrence) d'objet d'une classe (Fig-2). C'est un objet particulier de la classe. Nous dirons invariablement instance ou objet, cela dépendra du contexte de la phrase. Chaque variable déclarée d'une classe, désigne une instance particulière de la classe. La syntaxe est classique.

pile p; // p désignera un objet pile
article a,b; // a et b un objet article
article *pa; // pa pointeur sur un objet article

Une fois déclarée des instances, on peut faire des calculs en leur appliquant les méthodes fournies --on dit qu'on envoie un message à un objet. C'est un appel à la fonction correspondante. La notation utilisée est du type instance.méthode();

p.empiler('c');

empile le caractère 'c' sur la pile p.

a.ajouter(50);
x = b.prixTTC();

ajoute 50 unités à la quantité en stock pour l'article a et affecte à x (supposé float) le prix TTC de b. Pour pa on écrira pa->prixTTC() .

On peut aussi utiliser un champ avec cette fois-ci la notation instance.champ; et de façon générale, la notation instance.sélecteur, est le choix d'une caractéristique , champ ou méthode, d'un objet. Quand c'est un champ, c'est un accès à une donnée - comme x = a.prixHT*1.19 - et quand c'est une méthode, c'est un appel de fonction membre. Le mot public justement, indique lesquelles des sélecteurs font partie de l'interface et sont donc utilisables à l'extérieur de la classe.

Le fait de ne pas mettre les champs dans la zone publique d'une classe, interdit donc de les utiliser directement -ils sont privés comme déjà dit. Pour accéder aux informations qu'ils contiennent, on doit pouvoir passer par une méthode de l'interface. Pour connaître la quantité en stock par exemple pour un objet article, on doit prévoir la méthode supplémentaire

int aQte() { return qte;}

On appelle fonction d'accès ce genre de caractéristique pour un objet.

Rendre les champs privés présente un double avantage: (1) cela les protège contre toute modification provenant de l'extérieur de l'objet[6]; (2) si la structure de l'objet change -et donc aussi les algorithmes des méthodes- cela ne remet pas en cause les programmes qui utilisent la classe. C'est là une des grandes avancées[7] en génie logiciel.

Voici à présent un exemple de programme complet qui utilise une pile. C'est un programme qui vérifie si une expression (chaîne) contenant des symboles parmi ([{}]) est bien parenthésée, c'est à dire un symbole fermant doit correspondre au dernier symbole ouvrant rencontré. La chaîne est lue caractère par caractère (terminée par #), et les symboles ouvrants sont empilés. La rencontre d'un symbole fermant doit correspondre au dernier symbole empilé, auquel cas ce dernier est dépilé. A la fin la pile doit se trouver vide.

main(){
        char c;
        pile p;
        cin >>c;
        while (c != '#'){
                if (c=='(' || c=='{' || c=='[')
                        p.empiler(c);
                else if (c==')' || c=='}' || c==']'){
                        if ( !OK(p.sommet(),c))
                                cout << "Erreur!..\n";
                        p.depiler();
                }
                else;
                // autre caractere ...
                    cin >>c;
        }
        if (p.estVide()) cout << "Bien parenthese\n";
        else cout << "Mal parenthese\n";
}
void OK(char a,char b){
        // test si symbole ouvrant a compatible
        // avec symbole fermant b
        // ...

};


Retour sur l'Abstraction

Ce programme illustre un autre aspect de l'abstraction de données. Ce qui doit préoccuper le programmeur c'est la conception de la solution (ici en termes de pile) et non pas la structure de données à utiliser et de fait les contraintes sur sa manipulation (imaginer que ce programme utilise directement un tableaux et doit faire t[++top]=c;... La structure de données est ainsi figée et le programme avec) . Et on voit bien que, dans ce programme, il n'est fait aucune référence ni hypothèse sur comment la pile est représentée - le tableau en question. Cette représentation peut changer, une liste chaînée au lieu de tableau, sans remettre en cause ce programme (exercice: le vérifier).

Variation sur un thème: le pointeur this:

Appliquer une opération à un objet est une demande d'exécution (c'est cela le message envoyé à un objet) d'une méthode, fonction membre de la classe à laquelle appartient l'objet. La sémantique est la suivante:

envoyer_message (Instance, Méthode, [Paramètres]);

par exemple, envoyer_message (p, empiler, c); envoyer_message (p, depiler); x=envoyer_message (b, prixTTC); etc... On peut alors noter, en simplifiant le mot envoyer_message, comme suit

empiler(p, c); depiler(p); prixTTC(b);

etc... c'est à dire Méthode (Instance, [Paramètres]); ce qui revient à la syntaxe des appels de fonction dans les langages de programmation. Mais dans les corps des méthodes on devrait écrire, pour pile::empiler(c) par exemple

p.t[++p.top] = c;

pour indiquer que le t ou le top en question sont ceux de l'instance p en paramètre. D'où la notation préférable:

Instance.Méthode([Paramètres]);

c'est à dire

p.empiler(c);

qui, comme de toute façon il y aura toujours l'instance en (premier) paramètre, caractérise bien un appel de méthode, et simplifie l'écriture de son corps. C'est la raison pour laquelle on a

t[++top] = c;

dans le corps de empiler pour une pile. Le t ou le top en question se rapportent à l'instance pour laquelle l'appel est fait, c'est à dire p ici. A ce propos, C++ offre une variable implicite de nom this qui existe toujours dans une fonction membre, et qui est un pointeur vers l'instance appelante (p toujours). L'écriture précédente est équivalente à

this->t[++this->top] = c;

Membres Statiques (static)

Quand un champ membre d'une classe est déclaré static , ce champ existe en un seul exemplaire pour toutes les instances de la classe. Sa valeur est donc partagée par ces instances. Un champ membre static est créé avec la classe et existe avant toute instanciation. Il peut être référencé comme un champ membre avec un objet (objet.champ) ou sans objet avec l'opérateur de résolution de portée :: (classe::champ) .

Class C{
public:
    static int x;
    int y;
//...
};

C a,b;

a.x et b.x représentent le même objet et ont la même valeur toujours. Ce n'est pas le cas de a.y et b.y. Pour imprimer la valeur de x, on peut écrire indifféremment :

cout << a.x; cout << b.x; ou
cout << C::x;

D'ailleurs, x est appelée instance de classe, puisqu'elle est attachée à toutes les instances de la classe.

Exercices:

. Compléter le programme de la pile.

. Refaire la classe pile, avec cette fois-ci la représentation suivante:

char* t; // t est pointeur sur la base
         // (début) de pile
char* top; // top est pointeur sur sommet
            // de pile

On initialisera t par t= new char[MAX];

. Programmer cette fois une pile bornée, i.e. dont le nombre maximal d'éléments est donné pour chaque instance (on voudrait avoir un pile d'au plus 20 caractères ou d'au plus 7 caractères etc...) . Défaut 10.

5. Constructeurs et Destructeurs

Nous avons mentionné à propos de la pile, une opération caractéristique (méthode nommée init()) dont l'utilité est de fournir une première initialisation d'un objet pile. Par conséquent elle doit être la première appelée et de façon explicite. On court le risque de l'oublier. Une meilleur approche est de définir une fonction dont le rôle systématique est d'initialiser un objet. Une telle opération est appelée constructeur. En C++ un constructeur est une fonction membre qui a le même nom que sa classe. Par exemple:

class article{
...
article(int, char*, float, int);
    // Construit un article de numéro, nom, prixHT et
    // quantité données
...
};

Le fait pour une fonction membre d'avoir le même nom que sa classe lui donne un statut spécial: toutes les instances de cette classe sont initialisées et par cette fonction membre.

article a = article(102, "chemise", 100.00, 234);
article a(102, "chemise", 100.00, 234); // en abrégé

A la première ligne, le constructeur est appelé dès la déclaration pour fournir les valeurs initiales pour l'instance déclarée (a est initialisé par affectation d'une instance crée par le constructeur). La deuxième ligne est une forme abrégée qui est intéressante dans la mesure où elle fait automatiquement appel au constructeur.

article b = a;
article c; // incorrecte, paramètres manquants.

L'instance b est initialisée à partir d'une autre instance a (supposée déjà crée). La dernière écriture n'est pas correcte car il manque les paramètres du constructeur. En fait un objet ne peut être déclaré sans être initialisé: affectation d'une instance crée par un constructeur, ou d'une instance déjà existante (voir plus bas, une meilleur façon de réaliser ce deuxième point).

Dans le cas où on déclare un pointeur sur un objet, on doit utiliser new:

article* pa = new article(102, "chemise", 100.00, 234);

On peut aussi écrire

article* pa;

et ensuite faire

pa = new article(102, "chemise", 100.00, 234);

car l'objet est créé à l'initiative du programmeur (par new), et non plus à l'entrée d'un bloc (ou dès la déclaration) comme précédemment.

Le programmeur doit écrire le code adéquat pour les constructeurs. Par exemple:

article::article(int n, char* m, float p, int q){
numero = n;
nom = new char[strlen(m)]; strcpy (nom, m);
prixHT = p;
qte = q;
};

Remarquer la déclaration du constructeur sans type résultat. En effet, un constructeur crée (et retourne) une instance de sa classe.

Pour la classe pile, on aurait le constructeur suivant (défini en ligne cette fois):

class pile{
...
public:
    pile() { top = -1;}
...
};

et on peut déclarer

pile p = pile();
pile p; // en abrégé

Un constructeur sans paramètres est dit par défaut. En n'exigeant pas de paramètres, il simplifie les déclarations.

Il est souvent intéressant de définir plus d'un constructeur pour initialiser des objets d'une classe. Ces constructeurs auront tous le même nom (surchargé) qui est celui de la classe. Le constructeur à appeler est déterminé grâce à la forme de l'appel. Toujours à propos de la classe pile, on pourrait définir un deuxième constructeur paramétré par la taille d'une instance donnée (pile bornée).

class pile{
...
    pile(); // constructeur par défaut
    pile(int);  // Deuxième constructeur
                // pile de int éléments
...
};

On déclarerait alors pile q(10); pour une pile de 10 éléments, ou pile p; pour une taille par défaut. A chaque fois c'est le bon constructeur qui est appelé. Une classe date illustre encore bien cet aspect. On aurait les constructeurs

date();                 // date du jour par défaut
date(int, int, int);    // jour, mois, année
date(char*);             // date sous forme "12/04/95"
date(int, char*, int)     // forme 12 Avr 1995

etc...

En C++, les constructeurs jouent un rôle important. Pour des objets définis par l'utilisateur, le compilateur ne peut généralement pas prendre de décision quand à la place à allouer ou aux valeurs initiales à affecter. Dans la classe article, comme nom est un pointeur vers une chaîne, il a fallu allouer de la place par new et copier dedans la chaîne m en paramètre.

Remarque: L'usage des constructeurs n'est pas limité aux initialisations. Ils peuvent être utilisés là où il est possible d'avoir un objet d'une classe:

article f(...){
    ...
    return article (n, m, p, q);
}
...
g(article(n, m, 100., 1));

etc ...

Destructeurs

Un destructeur, est une fonction membre qui est implicitement appelée quand un objet devient hors de portée (sortie de son bloc de déclaration). Pour une classe X il a pour nom ~X() (~ est l'opérateur complément en C).

article::~article(){ delete nom; }

Un destructeur sert en générale à récupérer la mémoire occupée par un objet. Opération parfois nécessaire qui complète utilement un constructeur. Comme nombre de classes utilisent de la mémoire dynamiques (pointeurs), allouée par un constructeur, il est nécessaire de récupérer cette place. C'est le rôle d'un destructeur.

Quand il n'est pas fournis de destructeur, le désallocateur standard est appliqué. C++ manque de "ramasse-miettes", cet algorithme qui parcourt la mémoire en récupérant la place supplémentaire occupée par les pointeurs, et il est souvent essentiel pour une classe de fournir le sien dans le destructeur. Cette solution s'avère beaucoup plus simple à implanter.

Résumé:

Une classe d'objets définis par l'utilisateur, et quelque peu complexes, nécessiterait parfois (et au moins) le jeu suivants d'opérations:

class X {
...
X() // Initialisation par défaut si
// souhaitable
X(objets_de_base);// Initialisation à partir
// d'objets de base.
X(X&); // Initialisation à partir
// d'une instance déjà créée
~X();
};

Le premier constructeur est l'initialiseur par défaut. Le deuxième constructeur est celui qui crée un objet d'un nouveau type, à partir d'objets de types plus élémentaires, ou déjà connus. Par exemple, à partir d'un numéro, d'un nom, d'un prix et d'une quantité, on crée (par composition) un nouvel objet qui est article.

Le troisième constructeur est utile pour initialiser un objet à partir d'une instance déjà crée. Exemple:

article a(102, "chemise", 100.00, 234);
// constructeur 2e type appelé
article b (a);
// constructeur type X(X&) appelé

On aurait pu en effet écrire

article b = a;

mais dans ce cas b.nom et a.nom serait deux pointeurs égaux. C'est parfois indésirable, et c'est pourquoi on doit rajouter à la classe article le constructeur suivant:

article::article(article& e){
numero = e.n;
nom = new char[strlen(e.m)]; strcpy (nom, e.m);
prixHT = e.p;
qte = e.q;
};

qui alloue une nouvelle place pour le nom du nouvel article avant de copier dedans le nom de l'article d'origine. Noter enfin que ce constructeur est celui qui est appelé pour initialiser un paramètre formel, ou un paramètre retour de fonction.

Exercice

5.1. Ecrire la classe article complète. Imaginer un constructeur par défaut (sans paramètres). Tester par programme les différentes facettes de cette classe.

6. Fonctions Amies

En C++, des fonctions amies, appelées friend, sont des fonctions normales, mais qui jouissent du privilège de pouvoir accéder directement aux champ d'une classe (plus exactement à tous ses membres privés). Elles sont déclarées friend au seins de la classe.

class X{
    int a, b;
    friend f(X);
    ...
};

f est une fonction C ordinaire qui peut utiliser les données a et b. Comme ce sont les champs d'un objet, f doit avoir, entre autre, un paramètre de classe X.

f(X o){
    // o.a et o.b accessibles
}

Noter que f n'est pas membre de la classe et le critère publique ou privé ne lui s'applique pas. Noter donc aussi l'absence de l'opérateur :: dans la définition de f.

L'intérêt des fonctions amies apparaît quand on a besoin d'écrire des fonctions type routines - par exemple imprimer(article)- qui utilisent des objets sans pour autant en faire des fonctions membres. Passer par ces mêmes fonctions membres pour accéder aux données de l'objet peut très vite se révéler fastidieux. Là les fonctions friend rentrent en jeu. En générale elles n'ont pas besoin de modifier l'état d'un objet.

Une fonction friend peut l'être pour plusieurs classes. Un exemple caractéristique est une fonction qui fait le produit d'une matrice par un vecteur.

class matrice {
    float m[3][2];
    friend vecteur produit (matrice, vecteur);
...
}
class vecteur {
    float v[2];
    friend vecteur produit (matrice, vecteur);
...
}
vecteur produit(matrice a, vecteur b){
    // multiplier a.m[i][j] par b.v[j] ...
}

Remarque: Une fonction amie ne peut être constructeur, ni virtuelle ([[section]]13)

7. Surcharge des Opérateurs

En C++, on peut appliquer les opérateurs standards de C ( + - == * = etc...) à tout objet pour lequel cela a un sens, comme l'addition ou la multiplication pour un nombre complexe, un vecteur etc ... Il suffit de munir sa classe de cette opération. Il est alors possible d'utiliser la notation habituelle et écrire w=u+v , même pour des objets non standards. Voici un exemple de classe pour les nombres complexes:

classe complexe{
double re, im;
friend complexe operator+(complexe, complexe);
friend complexe operator*(complexe, complexe);
public:
complexe(double r=0, double i=0){re=r; im=i;}
...
};
complexe u(1,2), v(3,4), w;
w=u+v;

Un opérateur est reconnu avec le mot clé operator . L'appel à cet opérateur par u+v est interprété comme operator+(u,v). Voici un définition de cette fonction:

friend complexe operator+(complexe a, complexe b){
    return complexe (a.re+b.re, a.im+b.im);
}

Ainsi, un opérateur standard [[theta]] de C peut être surchargé par une fonction de nom operator[[theta]] et avoir un sens pour des objets utilisateurs. Pour le compilateur, il jouira des mêmes règles d'arité, de priorité et d'associativité que ses homologues standards (dans u+v*w, * est évalué en premier). Et pour cela on ne peut introduire un symbole non standard. Par ailleurs, un opérateur doit avoir au moins un argument de sa classe, et il doit conserver son caractère unaire ou binaire... Ici, nous avons fait des opérateurs des fonctions amies. En effet cela leur sied mieux en générale[8].

Des opérations particulièrement importantes à définir aussi pour des objets utilisateurs, sont l'égalité -et de façon générale la comparaison- et surtout l'affectation. En effet, l'affectation standard d'un compilateur est la copie bit à bit du contenu binaire d'une variable. Que ce soit une valeur de donnée, une valeur d'adresse ou une agrégation (structure) de tels valeurs. Est-ce cela que désire l'utilisateur? Reprenons l'exemple de la classe article. On peut définir l'opération = d'affectation entre deux objets

class article{

int numero;
char* nom;
float prixHT;
int qte;

public:

void operator=(article&);
// Affecte à un objet article, l'objet en paramètre
...

};

et l'implanter comme suit.

void article::operator=(article& e){

if(this == &e) return; // c'était a=a;
delete nom;
numero = e.numero;
nom = new char[strlen(e.nom)];
strcpy (nom, e.nom);
prixHT = e.prixHT;
qte = e.qte;

}

Si on écrit a=b; ce sera interprété comme a.operator=(b); Noter que operator= est fonction membre ici.

On remarque qu'on a le même code que le constructeur article(article&); sauf qu'ici, on restitue (delete) la mémoire secondaire éventuelle occupée par l'objet receveur. Car en effet, l'affectation suppose que ce dernier est déjà initialisé (en particulier ici, le champ nom est alloué).

L'intérêt de programmer une affectation entre objets, est de réaliser une copie en profondeur (deep copy), i.e. tous les composants réels (y compris ceux en mémoire dynamique) de l'objet sont recursivement copiés (fig-3). Ici, la chaîne composant le nom de l'article est aussi copiée. Avec une affectation classique, c'est le pointeur nom, qui est copié (shallow copy, ou copie superficielle). On notera, que les autres composants de article sont implicitement copiés en profondeur, puisqu'ils n'utilisent pas de mémoire secondaire (copie en profondeur et superficielle confondues ).

On procédant de la même façon, on peut définir une opération d'égalité entre deux articles

int operator==(article&);

et décider que deux articles sont égaux si et seulement si ils ont le même numéro par exemple. En poussant l'idée plus loin on peut définir plusieurs types d'égalité: égalité en profondeur ou égalité superficielle, tout comme pour l'affectation.
 

copie en profondeur 
deep copy

copie superficielle 
shallow copy 


Figure 3. Copie en Profondeur vs. Copie Superficielle
(YX)

8. Un Exemple Complet

Un bon exemple, simple, mais qui montre les subtilités de C++ est celui d'un objet chaîne String.

class String{

char* str;

public:

String (){
    str = new char[1];
    *str = 0; // chaine vide
}
String (char* s){
    str= new char[strlen(s)+1];
    strcpy(str,s);
}
String (String& s){
    str= new char[strlen(s.str)+1];
    strcpy(str,s.str);
}
~String(){ delete str;}
String& operator=(String& s){
    if(this == &s)
        return *this; // c'était a=a;
    delete str;
    str = new char[strlen(s.str)+1];
    strcpy (str, s.str);
    return *this;
}
friend int operator==(String& s1, String&s2){
                   // compare s1 et s2 comme strcmp() de C
        return strcmp(s1.str,  s2.str);
}
}; 

On a les différents types de constructeurs qui permettent d'écrire:

String a; // chaîne vide, String() appelé

String b ("maChaine"); // String(char*) appelé

String c = b; // String(String& b) appelé

funct(c); // Initialisation paramètre
// avec String(String&)

Quand à l'affectation, elle permet des copies en profondeur.

c = b;

duplique la chaîne b dans c. Remarquer qu'elle retourne l'adresse de la nouvelle chaîne, pour respecter la convention de l'affectation (i.e. expressions de type a=b=c;).

Pour la comparaison, on a choisi la spécification -1 pour inférieure, 0 pour égale et 1 pour supérieure.
 


Retour au sommaire