| auteur : Marshall Cline |
Les techniques OO sont la meilleure façon connue de développer de grosses applications ou des systèmes complexes.
L'industrie du logiciel n'arrive pas à satisfaire les demandes pour des systèmes logiciels aussi imposants que complexes, mais cet échec
est dû à nos succès : nos réussites ont habitué les utilisateurs à toujours en demander plus. Malheureusement, nous avons ainsi créé
une demande du marché que les techniques 'classiques' de programmation ne pouvaient satisfaire. Cela nous a obligé à créer un meilleur
paradigme.
le C++ permet de programmer OO, mais il peut aussi être utilisé comme un langage classique ("un C amélioré"). Si vous comptez
l'utiliser de cette façon, n'espérez pas profiter des bénéfices apportés par la programmation OO.
|
| auteur : Marshall Cline |
Une zone de stockage avec une sémantique associée.
Après la déclaration suivante,
on peut dire que i est un objet de type int. En programmation objet / C++, "Objet" signifie habituellement "une instance d'une classe".
Une classe définit donc le comportement d'un ou plusieurs objets (c'est-ce qu'on peut appeler "instance").
|
| auteur : LFE |
L'héritage consiste à construire une classe (appelée classe fille) par spécialisation d'une autre classe (classe mère).
On peut illustrer ce principe en prenant l'exemple des mammifères (classe mère) et l'homme d'un côté (classe fille 1) et les chiens
(classe fille2). En effet, les chiens et les hommes sont tous deux des mammifères mais ont des spécificités.
|
| auteur : LFE |
La surcharge est un mécanisme qui permet d'utiliser le même nom pour une fonction mais en lui passant des paramètres de types différents
et/ou en nombre différent.
Le nom de la fonction et les types des paramètres constituent ce qu'on appelle la signature de la fonction.
int moyenne (int i1, int i2);
float moyenne (float f1, float f2);
float moyenne (int i1, int i2);
|
|
| auteur : Marshall Cline |
Il s'agit d'éviter des accès non autorisés à certaines informations et/ou fonctionnalités.
L'idée clé est de séparer la partie volatile de la partie stable. L'encapsulation permet de dresser un mur autour d'une partie du code,
ce qui permet d'empêcher une autre partie d'accéder à
cette partie dite volatile ; les autres parties du code ne peuvent accéder qu'à la
partie stable. Cela évite que le reste du code ne fonctionne plus correctement lorsque le code volatile est changé. Dans le cadre de la
programmation objet, ces parties de code sont normalement une classe ou un petit groupe de classe.
Les "parties volatiles" sont les détails d'implémentation. Si le morceau de code est une seule classe, la partie volatile est
habituellement encapsulée en utilisant les mots-clés private et protected. S'il s'agit d'un petit groupe de classe,
l'encapsulation peut être utilisée pour interdire à des classes entières de ce groupe. L'héritage peut aussi être utilisé comme une forme
d'encapsulation.
Les parties stables sont les interfaces. Une bonne interface procure une vue simplifiée exprimée dans le vocabulaire de l'utilisateur,
et est créée dans l'optique du client. (un utilisateur, dans le cas présent, signifie un autre développeur, non pas le client qui achètera
l'application). Si le morceau de code est une classe unique, l'interface est simplement l'ensemble de ses membres publics et des fonctions
amies. S'il s'agit d'un groupe de classes, l'interface peut inclure un certain nombre de classes.
Concevoir une interface propre et séparer cette interface de son implémentation permet aux utilisateurs de l'utiliser convenablement.
Mais encapsuler (mettre dans une capsule) l'implémentation force l'utilisateur à utiliser l'interface.
|
| auteur : Marshall Cline |
Non.
L'encapsulation ne constitue pas un mécanisme de sécurité. Il s'agit d'une protection contre les erreurs, pas contre l'espionnage.
|
| auteur : Marshall Cline |
En C, l'encapsulation se faisait en
définissant les static dans un fichier compilé ou dans un module. Cela permettait d'éviter
qu'un autre module n'accède à la partie déclarée statique. (Au passage, les données statiques dans la limite d'un fichier est dépréciée
en C++, ne le faites donc plus).
Malheureusement, cette approche ne permet pas de supporter plusieurs instances des données, étant donné qu'il n'y a pas de support direct
pour créer des instances multiples des données statiques d'un module. Si plusieurs instances étaient nécessaires en C, les programmeurs
utilisaient généralement une structure. Mais, pas de chance, les structures C ne supportent pas l'encapsulation. Cela dégradait le
compromis entre fiabilité (le fait ce dissimuler l'information) et facilité d'utilisation (les instances multiples).
En C++, vous pouvez avoir des instances multiples et l'encapsulation en utilisant les classes. La partie publique de la classe contient
son interface, qui consiste habituellement en ses fonctions membres publiques et ses fonctions amies. Les parties protégées et/ou privées
contiennent l'implémentation de la classe, ce qui est habituellement l'endroit où sont stockées les données.
Le résultat final est comme une "structure encapsulée". Cela améliore le compromis entre fiabilité (dissimulation de l'information) et
facilité d'utilisation (les instances multiples).
|
| auteur : LFE |
J'applique une méthode simple : la question à se poser est la suivante : est-ce que X est un genre de Y, ou est-ce que X utilise un Y ?
Si la réponse est X est un genre de Y, il s'agit d'un cas où je dérive une classe.
Si la réponse est X utilise Y, il s'agit d'un cas où je vais encapsuler une classe.
|
| auteur : Marshall Cline |
Quand elle présente une vue simplifiée d'un bout de logiciel, et est exprimée dans les termes de l'utilisateur (le bout de logiciel correspond habituellement à une classe ou un petit groupe de classes et l'utilisateur est un autre développeur, non le client final).
"Vue simplifiée" signifie que les détails sont intentionnellement cachés. Cela réduit donc le risque d'erreur lors de l'utilisation de la
classe.
"Vocabulaire de l'utilisateur" veut dire que l'utilisateur n'a pas besoin d'apprendre de nouveaux mots ou concepts. Cela réduit donc la courbe d'apprentissage de l'utilisateur.
|
| auteurs : Luc Hermitte, Aurélien Regat-Barrel |
Un accesseur (accessor en anglais) est une fonction membre renvoyant la valeur d'une propriété d'un objet.
Un mutateur (mutator en anglais) ou encore modifieur (modifier en anglais) est une fonction membre qui modifie la valeur d'une propriété d'un objet.
L'utilisation d'accesseurs / mutateurs permet de masquer l'implémentation des données de la classe (encapsulation) et de faire évoluer celle-ci sans contraintes pour l'utilisateur final. Si ce dernier est obligé de passer par des accesseurs / mutateurs au lieu d'accéder directement aux données internes, ces dernières peuvent être changées à tout moment et il suffit alors d'adapter le code des accesseurs / mutateurs. Le code qui utilisait l'ancienne classe peut utiliser la nouvelle sans s'apercevoir des changements effectués, alors qu'un accès direct aux données internes aurait nécessité de tout reprendre.
Les accesseurs / mutateurs permettent donc de séparer l'utilisation des données de leur implémentation, en plus de pouvoir effectuer des traitements ou des contrôles annexes lors de l'assignation des membres.
Dans l'exemple suivant :
class Person
{
public :
const std:: string & GetName () const
{
return this - > name;
}
void SetName ( const std:: string & NewName )
{
this - > name = NewName;
}
private :
std:: string name;
} ;
|
GetName est un accesseur car elle renvoie la valeur de la propriété Name.
SetName est un mutateur car elle modifie la valeur de la propriété Name.
Comme le montre cet exemple, il est courant de préfixer le nom des accesseurs / mutateurs respectivement par Get / Set.
Pour cette raison, on appelle aussi les accesseurs / mutateurs des getter / setter.
Les accesseurs ne modifiant pas l'objet mais se contentant de fournir un accès (d'où leur nom) en lecture seule sur une de ses propriétés, c'est une bonne pratique que de rendre une telle fonction membre constante comme cela est le cas ici pour GetName (lire à ce sujet Pourquoi certaines fonctions membres possèdent le mot clé const après leur nom ?.
Un point important est que les accesseurs / mutateurs ne s'appliquent pas forcément sur des données membres existantes d'une classe, mais peuvent être utilisés pour simuler l'existence d'une propriété qui n'est pas directement stockées en interne dans la classe. Lire à ce sujet Quand et comment faut-il utiliser des accesseurs / mutateurs ?.
|
| auteurs : Luc Hermitte, JolyLoic, Aurélien Regat-Barrel |
Parmi les fonctions publiques d'une classe, certaines miment la présence d'une donnée membre. On nomme aussi de telles fonctions des accesseurs. Il n'y a pas forcément de relation un-pour-un entre un accesseur et une donnée membre (comme cela est le cas pour l'accesseur GetName et la variable name dans l'exemple de la question Que sont les accesseurs / mutateurs ?.
Une donnée encapsulée ne doit pas forcement être exposée via à un accesseur. L'état interne d'un objet est... interne, et doit le rester.
Il faut distinguer deux choses lorsque l'on écrit une classe : son interface et son implémentation. Le but des accesseurs / mutateurs est d'effectuer le lien entre les deux, lien qui n'a pas à être direct. L'interface, qui sera visible du reste du monde et qui est donc la première chose à déterminer quand on écrit une classe, expose un certain nombre de propriétés, qui peuvent ou non être directement stockées dans la classe. Ce dernier point est un détail d'implémentation qui n'a pas à être connu, et c'est le rôle des accesseurs / mutateurs de le masquer.
Prenons l'exemple d'une classe qui permet de connaître l'âge d'un individu :
# include "date.h" // classe permettant de stocker une date (pour l'exemple)
class Person
{
public :
int GetAge () const ;
private :
Date date_of_birth;
} ;
|
L'âge d'une personne évolue constamment au fil du temps, c'est pourquoi il a été décidé dans cet exemple de ne pas le stocker mais de conserver à la place sa date de naissance. L'accesseur GetAge se charge de calculer son âge courant à partir de sa date de naissance et de la date du jour. Ainsi nous avons bien un accesseur sur la propriété Age de la classe, mais il n'y a pas de transposition directe sous forme de donnée membre int age; pour autant. On utilise à la place une autre donnée membre : la date de naissance. S'agissant d'un détail d'implémentation, aucun accesseur n'existe pour renvoyer cette dernière.
Cet exemple illustre bien le fait qu'un accesseur exporte une propriété qui n'a nullement l'obligation d'exister de manière explicite dans la classe. De même, une variable membre ne doit pas forcément être exportée via un accesseur, comme dans cet exemple avec la date de naissance.
Un autre exemple typique est celui de la classe Temperature qui permet de manipuler des températures en degrés Celsius ou Fahrenheit :
class Temperature
{
public :
double GetCelsius () const
{
return this - > temp_celsius;
}
void SetCelsius ( double NewTemp )
{
this - > temp_celsius = NewTemp;
}
double GetFahrenheit () const
{
return ( ( this - > temp_celsius * 9 .0 ) / 5 .0 ) + 32 .0 ;
}
void SetFahrenheit ( double NewTemp )
{
this - > temp_celsius = ( NewTemp - 32 .0 ) * 5 .0 / 9 .0 ;
}
private :
double temp_celsius;
} ;
|
D'un point de vue logique il y a deux propriétés : Celsius et Fahrenheit. Mais en interne il n'y a qu'une seule donnée membre. Imaginons maintenant que l'utilisation de cette classe montre que la plupart du temps on manipule les températures en degrés Fahrenheit, ce qui a chaque fois nécessite de faire un calcul de conversion. On décide alors de changer l'implémentation de la classe pour stocker directement en Fahrenheit, ce qui donne :
class Temperature
{
public :
double GetCelsius () const
{
return ( this - > temp_fahrenheit - 32 .0 ) * 5 .0 / 9 .0 ;
}
void SetCelsius ( double NewTemp )
{
this - > temp_fahrenheit = ( ( NewTemp * 9 .0 ) / 5 .0 ) + 32 .0 ;
}
double GetFahrenheit () const
{
return this - > temp_fahrenheit;
}
void SetFahrenheit ( double NewTemp )
{
this - > temp_fahrenheit = NewTemp;
}
private :
double temp_fahrenheit;
} ;
|
Comme on peut le constater, cette nouvelle implémentation est sans conséquence d'un point de vue logique sur la classe. Son interface est intacte, ce qui la rend inchangée vis à vis de l'extérieur. Pourtant en interne il a été fait des modifications qui la rendent plus performante. C'est un des intérêts des accesseurs : s'adapter de façon transparente aux évolutions de l'implémentation, chose que l'on ne peut pas garantir avec des données membre publiques.
Vous l'aurez compris : le choix de définir des accesseurs / mutateurs doit être en accord avec la conception et l'analyse du problème. Il ne faut pas systématiser leur définition pour toutes les données membres d'une classe.
|
| auteur : Marshall Cline |
Par l'extérieur !
Une bonne interface fournit une vue simplifiée exprimée dans le vocabulaire de l'utilisateur. Dans le cas de la programmation par objets,
une interface est généralement représentée par une classe unique ou par un groupe de classes très proches.
Réfléchissez d'abord à ce qu'un objet de la classe est du point de vue logique, plutôt que de réfléchir à la façon dont vous allez le
représenter physiquement. Imaginez par exemple que vous ayez une classe Stack (une pile) et que vous vouliez que son implémentation
utilise une LinkedList (une liste chaînée)
class Stack {
public :
private :
LinkedList list_;
} ;
|
La classe Stack doit-elle avoir une fonction membre get() qui retourne la LinkedList ? Ou une fonction set() qui prenne une LinkedList ? Ou encore
une constructeur qui prenne une LinkedList ? La réponse est évidemment non, puisque la conception d'une classe doit s'effectuer de
l'extérieur vers l'intérieur. Les utilisateurs des objets Stack n'ont rien à faire des LinkedLists ; ce qui les intéresse, c'est de
pouvoir faire des push (empiler) et des pop (dépiler).
Voyons maintenant un cas un peu plus subtil. Supposez que l'implémentation de la classe LinkedList soit basée sur une liste chaînée
d'objets Node (noeuds), et que chaque Node ait un pointeur sur le Node suivant :
class Node
{
} ;
class LinkedList {
public :
private :
Node* first_;
} ;
|
La classe LinkedList doit-elle avoir une fonction get() qui donne accès au premier Node ? L'objet Node doit-il avoir une fonction get()
qui permette aux utilisateurs de passer au Node suivant dans la chaîne ? La question est en fait : à quoi une LinkedList doit-elle
ressembler vu de l'extérieur ? Une LinkedList est-elle vraiment une chaîne d'objets Node ? Ou cela n'est-il finalement qu'un détail
d'implémentation ? Et si c'est juste un détail d'implémentation, comment la LinkedList va-t-elle donner à ses utilisateurs la possibilité
d'accéder à chacun de ses éléments ?
Une réponse parmi d'autres : une LinkedList n'est pas une chaîne d'objets Nodes. C'est peut-être bien comme ça qu'elle est implémentée,
mais ce n'est pas ce qu'elle est. Ce qu'elle est, c'est une suite d'éléments. L'abstraction LinkedList doit donc être fournie avec une
classe "LinkedListIterator", et c'est cette classe "LinkedListIterator" qui doit disposer d'un operator++ permettant de passer à
l'élément suivant, ainsi que de fonctions get()/set() donnant accès à la valeur stockée dans un Node (la valeur stockée dans un Node est
sous l'unique responsabilité de l'utilisateur de la LinkedList, c'est pourquoi il faut des fonctions get()/set() permettant à cet
utilisateur de la manipuler comme il l'entend).
Toujours du point de vue de l'utilisateur, il pourrait être souhaitable que la classe LinkedList offre un moyen d'accéder à ses éléments
qui mimique la façon dont on accède aux éléments d'un tableau en utilisant l'arithmétique des pointeurs :
void userCode (LinkedList& a)
{
for (LinkedListIterator p = a.begin (); p ! = a.end (); + + p)
cout < < * p < < ' \n ' ;
}
|
Pour implémenter cette interface, la LinkedList va avoir besoin d'une fonction begin() et d'une fonction end(). Ces fonctions devront
renvoyer un objet de type "LinkedListIterator". Et cet objet "LinkedListIterator" aura lui besoin : d'une fonction pour se déplacer vers
l'avant (de façon à pouvoir écrire ++p); d'une fonction pour pouvoir accéder à la valeur de l'élément courant (de façon à pouvoir écrire
*p); et d'un opérateur de comparaison (de façon à pouvoir écrire p != a.end()).
Le code se trouve ci-dessous. L'idée centrale est que la classe LinkedList n'a pas de fonction donnant accès aux Nodes. Les Nodes sont
une technique d'implémentation, technique qui est complètement masquée. Les internes de la classe LinkedList pourraient tout à fait
être remplacés par une liste doublement chaînée, ou même par un tableau, avec pour seule différence une modification au niveau de la
performance des fonctions prepend(elem) et append(elem).
# include <cassert> // Succédané de gestion d'exceptions
class LinkedListIterator;
class LinkedList;
class Node {
friend LinkedListIterator;
friend LinkedList;
Node* next_;
int elem_;
} ;
class LinkedListIterator {
public :
bool operator = = (LinkedListIterator i) const ;
bool operator ! = (LinkedListIterator i) const ;
void operator + + ();
int & operator * ();
private :
LinkedListIterator (Node* p);
Node* p_;
} ;
class LinkedList {
public :
void append (int elem);
void prepend (int elem);
LinkedListIterator begin ();
LinkedListIterator end ();
private :
Node* first_;
} ;
|
Les fonctions membres suivantes sont de bonnes candidates pour être inline (à mettre sans doute dans le même .h):
inline bool LinkedListIterator:: operator = = (LinkedListIterator i) const
{
return p_ = = i.p_;
}
inline bool LinkedListIterator:: operator ! = (LinkedListIterator i) const
{
return p_ ! = i.p_;
}
inline void LinkedListIterator:: operator + + ()
{
assert (p_ ! = NULL );
p_ = p_- > next_;
}
inline int & LinkedListIterator:: operator * ()
{
assert (p_ ! = NULL );
return p_- > elem_;
}
inline LinkedListIterator:: LinkedListIterator (Node* p)
: p_ (p)
{
}
inline LinkedListIterator LinkedList:: begin ()
{
return first_;
}
inline LinkedListIterator LinkedList:: end ()
{
return NULL ;
}
|
Pour conclure : la liste chaînée gère deux sortes de données différentes. On trouve d'un côté les valeurs des éléments qui sont stockés
dans la liste chaînée. Ces valeurs sont sous la responsabilité de l'utilisateur de la liste et seulement de l'utilisateur. La liste
elle-même ne fera rien par exemple pour empêcher à un utilisateur de donner la valeur 5 au troisième élément, même si ça n'a pas de sens
dans le contexte de cet utilisateur. On trouve de l'autre côté les données d'implémentation de la liste (pointeurs next, etc.), dont les
valeurs sont sous la responsabilité de la liste et seulement de la liste, laquelle ne donne aux utilisateurs aucun accès (que ce soit en
lecture ou en écriture) aux divers pointeurs qui composent son implémentation.
Ainsi, les seules fonctions get()/set() présentes sont là pour permettre la modification des éléments de la liste chaînée, mais ne
permettent absolument pas la modification des données d'implémentation de la liste. Et la liste chaînée ayant complètement masqué son
implémentation, elle peut donner des garanties très fortes concernant cette implémentation (dans le cas d'une liste doublement chaînée
par exemple, la garantie pourrait être qu'il y a pour chaque pointeur avant, un pointeur arrière dans le Node suivant).
Nous avons donc vu un exemple dans lequel les valeurs de certaines des données d'une classe étaient sous la responsabilité des
utilisateurs de la classe (et la classe a besoin d'exposer des fonctions get()/set() pour ces données) mais dans lequel les données
contrôlées uniquement par la classe ne sont pas nécessairement accessibles par des fonctions get()/set().
Note : le but de cet exemple n'était pas de vous montrer comment écrire une classe de liste chaînée. Et d'abord, vous ne devriez pas
"pondre" votre propre classe liste, vous devriez plutôt utiliser l'une des classes de type "conteneur standard" fournie avec votre
compilateur. La meilleure solution est d'utiliser l'une des classes conteneurs du standard C++ , par exemple la classe template list<T>.
|
| auteur : 3DArchi |
Comme Mr Jourdain écrivait de la prose sans le savoir, vous avez
certainement déjà utilisé la coercition sans le savoir. Derrière cette
expression se cache tout simplement les mécanismes de conversion
implicite :
int op1 (1 );
double op2 (2 .1 );
double result = op1 + op2;
|
Ici, op1 est implicitement converti en double pour faire
l'opération d'addition.
Une classe s'appuie sur la définition d'opérateur de conversion
pour pouvoir être utilisée dans ce type de polymorphisme :
# include <iostream>
class CMyClass
{
public :
operator bool ()const
{
return true ;
}
} ;
int main ()
{
CMyClass a;
std:: cout< < std:: boolalpha< < a< < std:: endl;
return 0 ;
}
|
L'objet 'a' est implicitement converti en bool. Le code présenté ici
illustre la définition. Il est en général déconseillé d'utiliser ce
mécanisme (cf bool idiom), les cas pertinents demeurant rares
(enveloppes sur des handles CWnd<->HWND par exemple).
|
lien : Qu'est-ce que le polymorphisme ?
lien :
The Safe Bool Idiom, par Bjorn Karlsson
|
| auteur : 3DArchi |
Il s'agit probablement d'une des premières caractéristiques du C++
présentées : la capacité à surcharger une opération avec
différentes signatures :
void function (int );
void function (double );
void function (CMyClass);
|
L'adjonction d'un nouveau type passe par la définition d'une
nouvelle surcharge pour ce type.
Les fonctions mathématiques (std::sqrt) par exemple sont définies
sur plusieurs types (pour std::sqrt, en général, float,
double et long double).
C'est aussi par cette approche que les flux sont utilisés de façon
polymorphe.
|
lien : Qu'est-ce que le polymorphisme ?
lien : Comment utiliser les flux pour afficher ou saisir mes objets ?
|
| auteur : 3DArchi |
Le polymorphisme paramétrique passe par l'utilisation des techniques
génériques pour offrir un même service pour tout un ensemble de types :
template < class T>
void dump (T var)
{
std:: cout< < Timestamp ()< < " : " < < var< < std:: endl;
}
|
Cette opération peut être appelée pour n'importe quel type
d'objet du moment qu'il supporte l'opérateur << sur le
flux de sortie.
La plupart des bibliothèques modernes en C++ s'appuient sur
ce mécanisme. C'est le cas de la STL ou de Boost par exemple.
On parle parfois de polymorphisme contraint ou borné lorsqu'il
s'agit d'introduire des contraintes sur les types avec lesquels
une fonction ou une classe générique peut effectivement être
instanciée. Cela n'est pas possible nativement avec le C++ mais
peut être mis en œuvre en combinant les classes traits et des
bibliothèques comme Boost (boost::enable_if).
|
lien : Qu'est-ce que le polymorphisme ?
|
| auteur : 3DArchi |
Souvent résumé tout simplement (et trop hativement) à 'polymorphisme',
le polymorphisme d'inclusion s'appuie sur l'héritage public :
void function (IInterface const & var_)
{
var_.Action ();
}
class IInterface
{
} ;
class CConcrete : public IInterface
{
} ;
int main ()
{
CConcrete c;
Fonction (c);
return 0 ;
}
|
Les fonctions virtuelles utilisent bien sûr ce polymophisme.
Le polymorphisme d'inclusion doit faire sens avec l'héritage public.
Il ne doit pas être utilisé uniquement pour bénéficier d'une surcharge.
|
lien : Qu'est-ce que le polymorphisme ?
|
| auteurs : Alp Mestan, 3DArchi |
En C++, on utilise souvent l'héritage pour ce faire. En effet,
imaginez que nous soyons en présence d'une hiérarchie de composants
graphiques, dont la classe de base serait Widget. On aurait ainsi
Button et Textfield qui hériteraient de Widget par exemple. Enfin,
chacun possèderait une méthode show() qui permet d'afficher le
composant en question. Bien entendu, un Button et un Textfield
étant de natures différentes, leur affichage le serait aussi.
C'est grâce au polymorphisme d'héritage, mis en oeuvre en C++
grâce au mot clé virtual, que l'on peut réaliser cela
dynamiquement : à l'exécution du programme, il sera choisi d'utiliser
la méthode Button::show() ou la méthode Textfield::show() selon le type
réel de l'objet sur lequel on appelle show(). Voici un exemple minimal
illustrant cela.
class Widget
{
public :
virtual ~ Widget () { }
void show ()
{
do_show ();
}
private :
virtual void do_show ()= 0 ;
} ;
class Button : public Widget
{
private :
virtual void do_show () { std:: cout < < " Button " < < std:: endl; }
} ;
class Textfield : public Widget
{
private :
virtual void do_show () { std:: cout < < " Textfield " < < std:: endl; }
} ;
void show_widget (Widget& w)
{
w.show ();
}
Button b;
Textfield t;
show_widget (b);
show_widget (t);
|
Dans ce cas, rien à redire, vous avez fait un choix correct.
|
| auteurs : Alp Mestan, 3DArchi |
Imaginez que vous ayez conçu une classe qui encapsule un
calcul très lourd, au point que vous ayez mis sur pieds deux
implémentations, l'une monothreadée, l'autre multithreadée. Il
serait dommage de les faire hériter d'une classe abstraite et d'en
hériter pour chacune des versions, induisant un coût à cause de la
virtualité, qui est ici superflue. Vous avez une possibilité
qui vous permettra de tout gérer à la compilation, en utilisant les
templates. Nous allons illustrer avec une fonction qui mesure le temps
pris par le calcul.
template < class Computation>
void do_something ()
{
std:: time_t start = time (NULL );
Computation:: compute ();
std:: time_t end = time (NULL );
std:: cout < < end - start < < " seconds. " < < std:: endl;
}
struct SingleThreadedComputation
{
static void compute ()
{
}
} ;
struct MultiThreadedComputation
{
static void compute ()
{
}
} ;
# ifdef STCOMPUTATION
do_something< SingleThreadedComputation> ();
# elif defined MTCOMPUTATION
do_something< MultiThreadedComputation> ();
# endif
|
Ainsi, on a factorisé la variabilité (multithreading ou pas) de notre
calcul dans une fonction paramétrée, Compute, sans ajouter la surcharge
induite par l'utilisation de la virtualité. Le "défaut" est que la
variabilité est statique, c'est-à-dire fixée à la compilation, tandis
que le polymorphisme lié à l'héritage nous permet d'avoir une
variabilité à l'exécution.
Cette façon de faire s'approche de ce que l'on appelle
le Policy Based Design,
qui permet de paramétrer de manière très flexible le comportement,
dès la compilation, avec une utilisation intelligente des templates.
C'est une sorte d'équivalent du Design Pattern Strategy, à la
sauce C++ et templates.
|
| auteurs : Alp Mestan, 3DArchi |
Ensuite vient le polymorphisme issu de la manipulation du préprocesseur
de votre compilateur. En effet, en jouant avec les #ifdef, nous
pouvons par exemple sélectionner un certain code ou un autre selon
des directives de compilation, qui permettent de modifier le
comportement de l'application générée, et ce au moment de compiler.
Cela se base sur le schéma basique suivant (qui a été utilisé pour
l'exemple de calcul mono|multithread) :
# ifdef OPTION1
# elif defined OPTION2
# elif defined OPTION3
# else
# endif
|
Ainsi, vous pouvez sélectionner le code à utiliser de 2 façons
principalement. Soit vous écrivez dans votre programme principal
quelque chose comme #define OPTION3, soit vous passez
l'option -DOPTION3 à votre compilateur. Si aucune option
n'est passée, ici ce sera le code 4 qui sera sélectionné, par exemple.
|
| auteurs : Alp Mestan, 3DArchi |
Vous pouvez également obtenir du polymorphisme en jouant sur la
liaison avec des bibliothèques. A partir d'une même interface,
vous pouvez avoir différentes implémentations produisant des
bibliothèques statiques différentes (.lib, .a, ...). La commande
d'édition des liens (ou dans votre makefile ou dans les options
d'un projet avec un I.D.E.) précise la bibliothèque avec laquelle
les liens doivent être résolus. L'exécutable généré fait alors appel
à l'interface implémentée dans la bibliothèque avec laquelle il a
été liée.
|
| auteurs : Alp Mestan, 3DArchi |
Une application peut choisir de varier son comportement en chargeant
dynamiquement des bibliothèques (.dll, .so, ...) et en allant chercher
dans celles-ci l'implémentation de l'interface variable. Le comportement
va alors changer selon la DLL proposée à l'exécution du moment qu'elle
respecte l'interface qu'attend le programme.
|
| auteur : Florian Goujeon |
S'il est nécessaire de pouvoir modifier le comportement d'un objet au
cour de l'exécution, la solution la plus adaptée est sans doute
l'application du design pattern Strategy.
Le principe de ce patron de conception est de définir autant de
classes que de comportements différents. Toutes ces classes
implémentent une même interface. La classe à paramétrer possède
une agrégation vers un objet du type de l'interface.
class walkman_behaviour
{
public :
virtual
~ walkman_behaviour (){ }
void
click_back_button ()
{
do_click_back_button ();
}
void
click_forward_button ()
{
do_click_forward_button ();
}
private :
virtual
void
do_click_back_button () = 0 ;
virtual
void
do_click_forward_button () = 0 ;
} ;
namespace walkman_behaviours
{
class mp3_reader: public walkman_behaviour
{
public :
void
do_click_back_button ()
{
}
void
do_click_forward_button ()
{
}
} ;
class fm_tuner: public walkman_behaviour
{
public :
void
do_click_back_button ()
{
}
void
do_click_forward_button ()
{
}
} ;
}
class walkman
{
public :
walkman (walkman_behaviour& c):
behaviour_ (& c)
{
}
void
behaviour (walkman_behaviour& c)
{
behaviour_ = & c;
}
void
click_back_button ()
{
comportement_- > click_back_button ();
}
void
click_forward_button ()
{
comportement_- > click_forward_button ();
}
private :
walkman_behaviour* behaviour_;
} ;
int
main ()
{
walkman_behaviours:: mp3_reader behave_mp3;
walkman_behaviours:: fm_tuner behave_fm;
walkman b (behave_mp3);
b.click_forward_button ();
b.behaviour (behave_fm);
b.click_back_button ();
return 0 ;
}
|
Pour changer le comportement de l'objet, il suffit de l'agréger à
un autre objet de comportement.
|
| auteurs : Alp Mestan, 3DArchi |
Il faut désormais choisir celui qui convient au type de paramétrage de
comportement que vous voulez introduire.
Une application complexe met souvent en œuvre les différentes solutions
pour sa variabilité et son extension. Selon les cas, la
variabilité est intégrée par les templates (générique),
par l'héritage (expl : Widget), par les directives de compilation
ou l'édition statique de liens (expl : dépendance de plateforme),
ou par le chargement dynamique de bibliothèque (expl : plugin).
|
Consultez les autres F.A.Q.
|
|