precedent retour  sommaire du cours C++ suivant

Le Langage C++

cours n° 5

Patrick TRAU 24/11/04

Cours n° 5

10) Objets

Ce chapitre a nécessité deux séances de cours, plus divers exemples

10.1) introduction

Nous allons désormais aborder une notion fondamentale : l'objet. L'objet n'existe pas en C standard, il est le principal apport de C++. L'objet est un groupement de données (même de types différents) appelées « attributs » et des fonctions associées (appelées « méthodes »). Nous pourrions prendre comme exemple une date, qui contient trois attributs jour, mois et année. Les méthodes associées pourraient être (entre autres) une fonction permettant de déterminer le jour de la semaine (le 8/11/04 est un lundi), une fonction permettant de comparer des dates, etc...

Nous allons pour ce qui suit utiliser l'exemple des vecteurs.

10.2) la classe

Comme toujours en C++, il faut avant tout déclarer les objets avant de les utiliser. C'est ce que l'on fait en déclarant une « classe ». La définition d'une classe permet d'ajouter un nouveau type à ceux déjà connus par le compilateur (comme les entiers ou les flottants). Définissons donc la classe « vecteur ». Un vecteur comporte trois composantes (à valeur réelle) : ce sont les attributs d'un vecteur. Nous les appellerons x, y et z. Puis nous définirons les méthodes utilisées pour les vecteurs : comment on les additionne, on calcule leur norme, la multiplication par un réel, le produit scalaire et vectoriel... Mais aussi comment on les affiche et les saisit.

class vecteur
 {
   //définition des attributs 
   float x,y,z;
   //déclaration des méthodes
   void saisir(void);
   void afficher(void)
   float norme(void);
   void produit(float);
   void additionner(vecteur);
   //arrêtons nous là pour l'instant
 };

Remarquez le point virgule qui termine la déclaration (comme toute autre déclaration d'ailleurs). C'est un des rares cas où une accolade fermante est suivie d'un point virgule.

10.3) définition des méthodes

Nous n'avons que déclaré les prototypes des méthodes que nous prévoyons d'utiliser avec nos vecteurs. Nous aurions pu les définir complètement dans la classe (entre accolades), mais nous allons plutôt utiliser une définition à l'extérieur de la classe. Pour cela, il faut utiliser une entête de fonction spéciale, spécifiant la classe à laquelle la méthode s'appliquera :

type_retourné nom_classe::nom_méthode(arguments)

Définissons pour commencer l'affichage d'un vecteur : il suffit d'afficher ses trois coordonnées (nous les mettrons entre crochets) :

void vecteur::afficher(void)
 {
  cout<<"["<<x<<","<<y<<","<<z<<"]";
 }

Vous pouvez remarquer que cette fonction n'a besoin d'aucun argument. En effet, toute méthode peut directement accéder aux attributs de l'objet auquel elle s'applique. La connaissance des trois attributs x, y et z suffit à notre méthode, c'est pourquoi elle n'a besoin d'aucun argument supplémentaire (void). De même, une fois son affichage terminé, elle n'a aucune valeur particulière à retourner, d'où son type void. On définirait de la même manière la saisie :

void vecteur::saisir(void)
 {
  cout<<"x?";cin>>x;
  cout<<"y?";cin>>y;
  cout<<"z?";cin>>z;
 }

Passons maintenant à la norme. La norme d'un vecteur est un réel, que l'on calcule uniquement à partir de ses coordonnées, nous n'avons besoin de rien de plus. C'est pourquoi cette méthode ne reçoit aucun argument, mais retourne un float :

float vecteur::norme(void)
 {float r;
  r=sqrt(x*x+y*y+z*z);
  return r;
 }

Pour multiplier un vecteur par un flottant, il faut (en plus des attributs donc des coordonnées du vecteur) connaître ce flottant. Si c'est le vecteur auquel s'applique la méthode qui est directement modifié par cette méthode, il n'y a rien à retourner. Ce qui nous donne (x*=a est équivalent à x=x*a) :

void vecteur::produit (float a)
 {
  x*=a; y*=a; z*=a;
 }

Nous n'allons pas encore définir l'addition pour l'instant, il va falloir attendre un peu.

10.4) utilisation d'un objet

A tout endroit où l'on peut déclarer une variable, on peut désormais déclarer vecteur. Par exemple :

	vecteur V1, V2;

à partir de là, on accède à un attribut d'un objet par « objet.attribut », et à une méthode par « objet.méthode(arguments) », du moins quand on a le droit d'y accéder. En effet, par défaut, seuls les objets d'une même classe peuvent accéder aux attributs et méthodes. Donc là où on y a accès; on peut appliquer la méthode « saisir » à v1, en écrivant :

	v1.saisir();

puis on peut l'afficher par :

	v1.afficher();

ou calculer sa norme :

	n=v1.norme();

Nous allons maintenant pouvoir définir l'addition. Nos désirons additionner un second vecteur à un premier. On appellera cette méthode par exemple par :

	v1.additionner(v2);

ce qui signifie qu'on va appliquer l'addition de v2 au vecteur v1. C'est donc v1 qui sera modifié, alors que v2 correspond aux informations supplémentaires que l'on transmet à la méthode pour pouvoir effectuer sa tâche. Quand on parle des attributs x, y et z, on parle bien de ceux de l'objet à qui s 'applique la méthode, ici v1.

void vecteur::additionner(vecteur arg)
 {
  x+=arg.x y*=arg.y; z*=arg.z;
 }

Comme pour toute fonction, dans la méthode l'argument a un nom « formel » : on l'appelle arg, Mais il correspondra à un argument réel qui dépend de la manière dont on a appelé la méthode (ici c'est v2)

10.5) accessibilité aux membres d'une classe

On peut définir trois degrés d'accessibilité aux membres (arguments et méthodes) d'une classe :

il suffit de définir le type d'accès désiré, il s'appliquera à toutes les déclarations qui suivront.

En général, il vaut mieux définir de manière privée les attributs d'une classe (c'est l'accès par défaut). En effet, l'intérêt de l'approche « objet » est qu'on n'a pas à accéder à l'organisation interne des données, il nous suffit d'accéder aux méthodes. Dans notre exemple du vecteur, qui comporte trois coordonnées, on n'a pas besoin de savoir comment exactement sont stockées ces trois réels (ce pourrait être un tableau par exemple). Par contre il faut évidemment permettre aux utilisateurs de la classe d'accéder à certaines données, on utilise pour cela des méthodes appelées « accesseurs ». Dans une classe « date »,. si les attributs étaient publics, on pourrait y mettre ce qu'on veut. Par contre, si on passe par un accesseur, ce dernier pourra vérifier la validité de la donnée (mois entre 1 et 12 par exemple). On commence en général les noms des accesseurs par « get » ou « set ». Revenons à nos vecteurs. On aimerait permettre à tout le monde de connaître les coordonnées d'un vecteur, mais pas de les modifier individuellement (on laisse cette possibilité aux héritiers). La déclaration de la classe devient donc :

class vecteur
 {
  private :  //définition (privée) des attributs 
   float x,y,z;
  public :   //les accesseurs en lecture sont publics
   float getx(void) {return x;}  
   float gety(void) {return y;}  
   float getz(void) {return z;}  
  protected :  //seuls les vecteurs et les héritiers ont le droit de 
               //modifier une des composantes indépendamment des autres
   void setx(float arg) {x=arg;}
   void sety(float arg) {y=arg;}
   void setz(float arg) {z=arg;}
  public :    //enfin les méthodes, utilisables (et utiles) par tous
   void set(float a,float b, float c) {x=a,y=b,z=c;}
   void saisir(void);
   void afficher(void)
   float norme(void);
   void multiplier_par(float);
   void additionner(vecteur);
 };

Ici les méthodes sont tellement courtes qu'on les définit complètement dans la classe.

10.6) fonctions utilisant des objets, surcharges

Les méthodes vues au dessus s'appliquent à un objet, et un seul. On les appelle par « nomobjet.methode(arguments) ». Mais on peut également créer des fonctions utilisant des objets. Prenons par exemple le produit scalaire : il nécessite deux arguments (des vecteurs) et nous retourne un scalaire (float). Si on ne veut pas utiliser ce produit en l'appliquant spécifiquement à un objet, on créera simplement une fonction :

float produit(vecteur v1, vecteur v2) //il n'y a pas écrit vecteur::
 {
  float p=v1.getx()*v2.getx()+v1.gety()*v2.gety()+v1.getz()*v2.getz();
  return p;
 }

On est ici à l'extérieur de la classe, nous n'avons donc droit qu'aux membres publics des vecteurs, c'est pourquoi il faut utiliser les accesseurs, car les attributs sont privés.

On appelle cette fonction de manière classique :

float x;
vecteur A,B;
x=produit(A,B);

On aurait aussi pu définir ce produit comme une méthode d'un vecteur. On aurait alors dû l'appliquer à un vecteur :

float vecteur::produit(vecteur v2) //il faut aussi le définir dans la classe
 {
  float p=x*v2.x+y*v2.y+z*v2.z;
  return p;
 }

Ici, on l'appelle par x=A.produit(B). La méthode est donc appliquée à A, et reçoit comme argument réel B. Dans la méthode, quand on parle de x c'est x de A, quand on parle de v2.x. c'est l'attribut x de B. Laquelle des deux est la meilleure solution ? Je ne sais pas.

Avez-vous remarqué une bizarrerie ? J'ai déjà défini une méthode nommée produit. Deux méthodes différentes qui ont le même nom, Mr Trau a dû se tromper ! Non, c'est même fait exprès. On peut donner le même nom à deux méthodes si elles ont des « signatures » différentes. On appelle signature le nombre d'arguments, ainsi que leur type. Ici, si j'écris A.produit(x) il appelle la méthode qui attend un float en argument, si j'écris A.produit(B) il sait qu'il doit appeler l'autre. On appelle cela la « surcharge ». Attention, le type retourné ne fait pas partie de la signature, donc on ne peut pas donner le même nom deux méthodes qui n'ont de différent que le type retourné.

On peut surcharger des méthodes, mais aussi des fonctions, et même les opérateurs. Par exemple on peut surcharger l'opérateur *. Pour l'instant, il sait déjà que faire la multiplication entre deux entiers ou flottants (d'ailleurs, puisqu'il ne fait pas la même chose, cela correspond déjà à de la surcharge). Apprenons lui à multiplier deux vecteurs, mais aussi un vecteur et un flottant. Pour surcharger un opérateur, il faut le précéder dans son entête par le mot « operator ». Les arguments, bien qu'à l'appel ils soient placés des deux côtés du signe *, sont gérés ici de manière classique (le premier en premier, le second en second) :

float operator * (vecteur A, vecteur B)
{
 return A.produit(B); //en supposant que j'ai utilisé la deuxième écriture
}
vecteur operator * (vecteur A, float X)
{
 return A.produit(X);
}
vecteur operator * (float X,vecteur A)
{
 return A.produit(X);
}

On a donc trois fonctions de même nom (*) avec trois signatures différentes : pour deux vecteurs (retourne un float) et pour un vecteur et un float (retourne un vecteur). Il a fallu lui donner les deux cas, pour le produit avec un float, car le compilateur accepte aussi les fonctions non commutatives.

Par contre je ne peux pas utiliser le même opérateur pour définir le produit vectoriel (il prend aussi deux vecteurs en argument, et bien qu'il retourne un vecteur, il aurait la même signature que le produit scalaire). Mais rien ne m'empêche d'utiliser un autre opérateur, par exemple ^

10.7) constructeur, destructeur, opérateur de copie

Par défaut à chaque fois que l'on crée un objet (on dit aussi « instancie »), le compilateur réserve la quantité de mémoire nécessaire et l'alloue à cet objet. Mais il ne fait rien d'autre. En particulier il n'initialise pas les attributs. On peut vouloir une création d'objet plus sophistiquée (initialisation par défaut, empêcher que certains attributs (surtout les pointeurs) soient vides...). C'est pour cela que vous pouvez réécrire le constructeur. Son entête est

nomdelaclasse(liste arguments)

On peut remarquer qu'il ne retourne rien, même pas void. Les arguments (s'il y en a) serviront à l'initialisation. Pour le vecteur, il n'y a rien de spécial à faire à l'instanciation, mais je propose néanmoins un constructeur pour initialiser le vecteur :

  vecteur(float a=0,float b=0,float c=0) {x=a;y=b;z=c;}

Le constructeur sera appelé lors de l'instanciation simple d'un objet (vecteur V1(1,1,2); vecteur V2;), là où l'on a besoin d'un vecteur constant (V2=vecteur(1,0,0);) ou lors d'une création dynamique (vecteur *p; p=new vecteur;). Vous pouvez par exemple prévoir un constructeur qui interroge au clavier au cas où des attributs ne sont pas initialisés, qui vérifie que les valeurs initiales proposées sont cohérentes, qui ouvre un fichier pour y chercher les valeurs des attributs,...

Dans certains cas on peut également redéfinir le destructeur (appelé lors de la destruction de l'objet). Son entête est ~nomdelaclasse(void) (il n'accepte aucun argument). Il ne sera nécessaire que pour désalouer des attributs dynamiques ou fermer des fichiers (ce ne sera pas notre cas).

Dernier point : la copie. Lors d'une affectation (signe =), le compilateur copie tous les attributs, et ça devrait suffire dans tous les cas simples. Dans certains cas (pointeurs, tableaux) où un attribut comporte une référence sur un autre objet, il ne recopie que cette référence, pas l'objet. On peut dans ce cas là redéfinir l'opérateur =, qui doit obligatoirement être une méthode (pas une fonction). Par exemple si l'on veut pouvoir écrire V=0; (copie d'un entier sur un vecteur, impossible par défaut) :

vecteur operator = (int arg)
  { if(arg==0) x=y=z=0; else cout<<"affectation suspecte\n"; }

10.8) exemples complets

Vous pouvez trouver ci-dessous la version complète de cet exemple. Vous pouvez également la télécharger : source C++ ou en pdf.

Je propose également à ceux que ça intéresse une version plus élaborée d'une bibliothèque de calculs vectoriels (pour les usages classiques en mécanique), contenant une classe « point » (deux coordonnées), une classe « vecteur » qui hérite du point, et une classe « torseur » qui hérite du point (pour le point d'application) et contient deux attributs de type vecteur. Cet exemple vous montrera par la pratique l'utilisation de l'héritage dont je n'ai pas parlé ici. Source C++ ou en pdf.

#include <iostream.h>

class vecteur
 {
 private :  //la structure interne d'un vrai objet n'a pas à être publique
  float x,y,z;

 protected : //à la rigueur les heritiers peuvent accéder 
             //directement aux attributs
  void setx(float a=0) {x=a;}
  void sety(float a=0) {y=a;}
  void setz(float a=0) {z=a;}

 public :
  vecteur(float a=0,float b=0,float c=0) {x=a;y=b;z=c;} //constructeur

//les accesseurs en lecture sont publics
  float getx(void) {return(x);}
  float gety(void) {return(y);}
  float getz(void) {return(z);}

  void affiche(ostream &flux) //flux est en argument car je veux pouvoir utiliser cout mais aussi tout fichier texte
      {flux<<"["<<x<<","<<y<<","<<z<<"]";}
  void saisie(istream &flux); //si le flux est cin on pose des questions sur cout, sinon on saisit sans question

  void additionner(float a) 
      {x=a+x;y=a+y;z=a+z;}
  void additionner(float a,float b,float c) //ceci est une surcharge.
      {x=a+x;y=b+y;z=c+z;}
  void additionner(vecteur a)               //encore une surcharge.
      {x=x+a.x;y=y+a.y;z=z+a.z;}

  float norme(void) {return(sqrt(x*x+y*y+z*z));}  //calcule la norme
  void normer(void) {float n=norme();x/=n;y/=n;z/=n;} //le modifie (le rend unitaire)

  void multiplier_par(float a) {x=a*x;y=a*y;z=a*z;}
  float prodscal(vecteur v) {return(x*v.x+y*v.y+z*v.z);}

  vecteur operator = (int arg)  //opérateur de copie, prévu ici uniquement pour écrire V=0
    { if(arg==0) x=y=z=0; else cout<<"affectation suspecte\n"; }


 };  //n'oubliez pas ce ; c'est la fin d'une déclaration

void vecteur::saisie(istream &f)
 {
  if(f==cin)cout<<"entrez x : ";
  f>>x;
  if(f==cin)cout<<"entrez y : ";
  f>>y;
  if(f==cin)cout<<"entrez z : ";
  f>>z;
 }

//redéfinition des opérateurs (sous forme de fonctions)

ostream& operator << (ostream &f,vecteur v)
 {v.affiche(f);return(f);}

istream& operator >> (istream &f,vecteur &v)
 {
  v.saisie(f);
  return(f);
 }

vecteur operator ^ (vecteur v,vecteur w)     //produit vectoriel
 {vecteur z(
            v.gety()*w.getz()-w.gety()*v.getz() ,
            v.getz()*w.getx()-w.getz()*v.getx() ,
            v.getx()*w.gety()-w.getx()*v.gety() 
           );
  return(z);
 }

vecteur operator * (float f,vecteur v)     //produit par un réel
 {vecteur z=v;
  z.multiplier_par(f);
  return(z);
 }
vecteur operator * (vecteur v,float f)     //le prod par un float est commutatif !!!
 {return(f*v);}    //je l'ai déjà défini dans l'autre sens, autant s'en servir !

vecteur operator / (vecteur v,float f)  
 {return(v*(1/f));}

float operator * (vecteur v,vecteur w)     //produit scalaire 
 {return v.prodscal(w);}

vecteur operator + (vecteur v,vecteur w)     //somme vectorielle
 {vecteur z=v;
  v.additionner(w);
  return(z);
 }

vecteur operator - (vecteur v,vecteur w)     //différence vectorielle
 {return(v+((-1)*w));}

/* petit programme main si l'on veut tester l'objet ***********
int main(void)
 {
  vecteur v(1,1,0),w,z;
  cout<<"la norme de "<<v<<" vaut "<<v.norme()<<"\n";
  cout<<"entrez vos nouvelles coordonnées :\n";
  cin>>w;
  cout<<"la norme de "<<w<<" vaut "<<w.norme()<<"\n";
  cout<<"le prod scal de "<<v<<" et "<<w<<" est "<<v.prodscal(w)
                               <<" (ou "<<v*w<<")\n";
  z=2*(v^w);
  cout<<"le double de leur prod vect vaut "<<z<<"\n";
 }
/* ouf, c'est fini *****************************************/

suivant retour  sommaire du cours C++ precedent Patrick TRAU, ULP - IPST Novembre 04