Dans de précédents numéros de Linux Magazine, vous avez pu découvrir une série d'articles sur la programmation fonctionnelle avec le langage Scheme. Nous allons aujourd'hui nous intéresser à une autre grande famille de langages : les langages à objets. Nous illustrerons notre article d'exemples réalisés en Eiffel. Parallèlement à l'étude des concepts de ce langage, nous ferons quelques comparaisons avec les autres grands langages de cette famille : Java, C++ et Smalltalk.
Eiffel est un langage à objets créé en 1985 qui réunit l'ensemble des grands concepts de la programmation à objets - instanciation, liaison dynamique, héritage. A ceux-ci s'ajoutent un typage statique fort (contrairement à Smalltalk), la généricité (contrainte ou non), un mécanisme d'exceptions, la gestion automatique de la mémoire et une des seules implantations de la programmation par contrat dans les langages modernes utilisés sur une large échelle. La présence de tous ces concepts de haut niveau a longtemps retardé l'apparition de compilateurs vraiment performants. Cette situation s'est aujourd'hui grandement améliorée, en particulier grâce à la naissance de SmallEiffel, le compilateur GNU Eiffel, issu du labora toire de recherche en informatique du Loria à Nancy (http://SmallEiffel.loria.fr).
A travers cette présentation du langage Eiffel, nous allons aborder les grandes notions de la programmation à objets (le lecteur attentif aura remarqué l'emploi du terme, plus approprié, de 'langage à objets' plutôt que la traduction littérale du terme anglais 'langage orienté objet'). Pour de plus amples informations, nous conseillons de consulter la spécification officielle du langage (Eiffel : Le Langage, Bertrand Meyer, Inter-Editions) et d'examiner la bibliothèque de classes fournie dans la distribution de SmallEiffel (et dont les sources sont, bien entendu, libres).
"Bonjour le monde"
Dans un fichier bonjour_le_monde.e, nous allons écrire notre premier programme Eiffel (Emacs dispose d'un mode Eiffel activé par l'extension '.e' des fichiers édités). Précisons tout d'abord que les commentaires Eiffel commencent par '--', que les noms de classe sont en majuscules (par défaut, SmallEiffel est sensible à la casse) et que le point-virgule à la fin des instructions est facultatif.
class BONJOUR_LE_MONDE
-- mon premier programme Eiffel
feature
make is
do
io.put_string("Bonjour le monde! %N");
end
end
Pour compiler ce programme avec SmallEiffel :
> compile bonjour_le_monde -verbose
Comme la plupart des autres compilateurs Eiffel, SmallEiffel génère du code C ANSI, qui est automatiquement compilé en un exécutable par gcc sous Linux. Notons que le code Eiffel est portable, que l'on peut donc facilement transférer un programme écrit en Eiffel d'une plate-forme à une autre et également utiliser d'autres compilateurs C, gratuits ou non. SmallEiffel est aussi capable de générer du bytecode Java, directement interprétable par n'importe quelle machine virtuelle Java (il suffit pour cela de remplacer la commande compile par compile_to_jvm). L'option '-verbose' permet de visualiser les différentes phases de compilation exécutées par SmallEiffel, y compris la compilation du C généré.
> a.out
Bonjour le monde!
SmallEiffel dispose de diverses options de compilation, dont certaines modifient le volume de code de contrôle supplémentaire généré. Avec l'option -boost, le minimum de code est généré et ce dernier est optimisé au maximum. En ajoutant l'option -O2, transmise à gcc, on obtient (dans tous les cas, et pas seulement sur cet exemple jouet) un binaire aux performances comparables au même programme écrit en C, la facilité de programmation et la réutilisabilité en plus. Après ce court exemple, examinons maintenant les concepts de plus haut niveau de la programmation à objets.
Les Classes
La classe est l'unité de base de la programmation à objets. Une classe correspond à la définition d'un type. Elle peut contenir des variables d'instances (des attributs) qui correspondent aux données associées au type, c'est-à-dire à sa structure. Elle peut également comprendre un certain nombre de méthodes (fonctions et procédures) qui s'appliquent sur ce type. On peut décomposer la structure d'une classe Eiffel en plusieurs sections :
- la section "class MA_CLASSE" qui définit le nom du type
- la section creation qui contient une liste de méthodes pouvant être utilisées pour créer un nouvel objet. En Java et C++, ces constructeurs portent le nom de la classe, avec des paramètres éventuellement différents. Ce mécanisme, connu sous le terme de surcharge ("overloading"), n'existe pas en Eiffel.
- plusieurs sections "feature" qui contiennent les déclarations de primitives (méthodes et attributs). C++ et Java permettent de contrôler la visibilité de ces déclarations de manière rudimentaire : elles peuvent être "public" (visibilité et utilisation par toute autre classe), "private" (utilisables uniquement à l'intérieur de la classe où elles sont définies, ainsi que dans leurs descendants), "protected" (qui étend la visibilité à toutes les classes d'un même package Java). Java dispose aussi d'un mode de visibilité par défaut, intermédiaire entre "public" et "protected". Le contrôle de visibilité disponible en Eiffel est beaucoup plus précis, puisqu'il est possible de spécifier des listes de classes ayant la visibilité sur une méthode ou un attribut. Ainsi, si MA_CLASSE a des primitives "a" et "b" contenues dans une clause "feature {AUTRE_CLASSE}", ces primitives sont accessibles par tous les objets de type AUTRE_CLASSE, ainsi que ses descendants. L'objet courant, bien entendu, a toujours accès à toutes ses primitives, donc a "a" et "b" avec lui-même comme receveur. En revanche, notons qu'un objet de type MA_CLASS n'a pas accès à "a" et "b" sur les autres objets de type MA_CLASS, puisque MA_CLASS n'est pas listée dans la clause d'exportation de ces primitives. Ce mécanisme n'est donc pas limité à quelques niveaux prédéfinis, mais permet de spécifier tous les niveaux d'exportations possibles, de l'absence d'exportation à la visibilité totale. Ces deux niveaux extrêmes sont atteints en utilisant deux classes un peu particulières : NONE et ANY. ANY est la classe la plus haute de l'arbre d'héritage, toute classe héritant implicitement d'elle. La clause d'exportation "feature {ANY}", ou plus brièvement "feature", signifie que les primitives qu'elle contient sont accessibles de n'importe où (c'est l'équivalent de "public"). Symétriquement, la classe NONE est conceptuellement la classe la plus basse dans l'arbre d'héritage et "feature{NONE}" indique qu'aucune classe extérieure n'a visibilité sur ses primitives (équivalent de "private").
Ecrivons maintenant une classe légèrement plus complexe.
class POINT_COLORE
-- un POINT_COLORE a 2 coordonnées et une couleur
creation
make
feature {ANY} -- attributs publics
x: REAL
y: REAL
feature {NONE} -- attribut privé
c: COULEUR
feature -- méthode publique
make(x0, y0: REAL ; couleur: COULEUR) is
do
x := x0; y := y0;
c := couleur;
end
translate(dx, dy: REAL) is
do
x := x + dx
y := y + dy
end
feature {TEST} -- méthode réservée à la classe TEST
get_couleur: COULEUR is
do
Result := c;
end
end
Et utilisons-la :
class TEST
feature
test is
local
p: POINT_COLORE
x: REAL
c: COULEUR
do
!!p -- instanciation d'un POINT_COLORE
-- de coordonnées (0,0)
p.translate(1.1 , 2.0) -- translate le point
x := p.x -- accès à la variable d'instance x
c := p.get_couleur -- car c := p.c est interdit
end
end
Cette classe définit le type POINT_COLORE. Ce type possède un constructeur 'make', qui permet d'instancier un nouveau point. Elle possède 2 coordonnées réelles, x et y, qui sont visibles (feature {ANY}) et une couleur privée (feature {NONE}). La méthode get_couleur n'est visible que par la classe TEST.
Cet exemple nous a aussi permis d'introduire l'opérateur d'instanciation '!!', équivalent du 'new' en C++ et Java, qui permet de créer un objet. Par défaut, en Eiffel, toutes les variables, d'instances ou locales, sont initialisées à 0 ou à Void (l'équivalent du NULL de C). Il faut aussi remarquer que, contrairement à C/C++/Java qui utilisent le mot clé "return" pour renvoyer le résultat d'une fonction (Smalltalk utilise l'opérateur '^'), la valeur de retour d'une fonction Eiffel doit être stockée dans une variable Result définie implicitement. La section 'local' d'une méthode permet de définir des variables locales. On peut aussi remarquer qu'Eiffel utilise la notation pointée (comme en Java) pour accéder à une variable d'instance (ici p.x) ou appeler une méthode (p.translate). Cependant, alors que Java et C++ persistent à imposer la présence de parenthèses aux fonctions sans argument, Eiffel supprime cette contrainte. Examinons l'impact, plus important qu'il n'y paraît, que ce détail peut avoir :
test is
local
i: INTEGER
b: B
do
i := b.c
end
Dans la méthode ci-dessus, l'appel 'b.c' peut correspondre à deux définitions différentes dans la classe B :
c: INTEGER is
do
Result := un_calcul_complique
end
ou bien simplement :
c: INTEGER
Dans le premier cas, 'c' est une fonction sans argument retournant un entier et dans le second, 'c' est un attribut entier de la classe B (en C++ ou Java, cela aurait été impossible en raison de la présence de parenthèses dans le cas où 'c' aurait été une fonction : on aurait alors dû écrire i := b.c() dans le premier cas). Ce type de mécanisme contribue à renforcer « l'implementation hiding » (masquage de l'implantation) qui est un concept fondamental de la programmation à objets. C'est en effet un bon moyen d'assurer la réutilisabilité du code : le changement d'une méthode en attribut peut alors se faire sans avoir besoin de modifier les appels dans les classes clientes.
L'héritage
L'héritage est un puissant mécanisme des langages à objets, qui permet une meilleure modélisation du "monde" et qui favorise la réutilisabilité. Java et Smalltalk se contentent de proposer de l'héritage simple : une classe ne peut hériter que d'une seule autre classe. En Eiffel, comme en C++, l'héritage multiple est disponible. Utilisons maintenant l'héritage sur une classe abstraite POINT (mot clé 'deferred') afin d'en définir deux implantations, POINT_CARTESIEN et POINT_POLAIRE.
deferred class POINT
feature
x: REAL is deferred end
y: REAL is deferred end
alpha: REAL is deferred end
d: REAL is deferred end
is_origin: BOOLEAN is
do
Result := (x = 0.0) and (y = 0.0)
end;
end;
class POINT_CARTESIEN
inherit POINT
creation
make
feature
make (abscisse, ordonnee: REAL) is
do
x := abscisse;
y := ordonne;
end
x: REAL
y: REAL
alpha: REAL is
do
Result := (y/x).atan.to_real
end
d: REAL is
do
Result := (x^2 + y^2).sqrt
end
end
class POINT_POLAIRE
inherit POINT
creation make
feature
make (angle, distance: REAL) is
do
alpha := angle;
d := distance;
end
x: REAL is
do
Result := d.cos(alpha)
end
y: REAL is
do
Result := d.sin(alpha)
end
alpha: REAL
d: REAL
end
Quelques explications s'imposent. Tout d'abord, la classe POINT définit (les signatures de) 4 primitives, x, y, d et alpha, sans décider de leur implantation. La classe POINT est donc 'deferred' (abstraite) et ne peut être instanciée. Les classes POINT_CARTESIEN et POINT_POLAIRE implantent toutes les primitives retardées de POINT et sont donc concrètes et instanciables. POINT_CARTESIEN fait de x et y deux attributs, d et alpha devenant des fonctions. POINT_POLAIRE fait l'inverse. Ces deux classes héritent de la méthode is_origin, définie dans POINT, sans changer son implantation. Celle-ci a pu être définie dans POINT, puisque les types des variables x et y étaient connus, bien que leur implantation ne l'était pas encore. Ces deux classes disposent donc d'une routine is_origin, dont le code a été défini et implanté dans POINT, sans surcoût aucun, au lieu d'avoir eu à les implanter elles-mêmes. Bien entendu, sur ce petit exemple, le gain en termes de lignes de code économisées est faible, mais sur des applications de plusieurs centaines de classes, ce type d'héritage permet de réutiliser un volume important de code, ce qui facilite fortement le développement et le débogage. C'est la principale justification de l'héritage et de bonnes bibliothèques en font un usage judicieux.
On peut noter que l'implantation de is_origin n'est pas très efficace lorsque l'on considère la classe POINT_POLAIRE. En effet, is_origin utilise x et y qui, dans POINT_POLAIRE sont calculés. Ceci n'est pas gênant sur cet exemple, mais pourrait être important en termes de performances sur un véritable programme. Il est donc possible en Eiffel de redéfinir une routine dont l'implantation héritée n'est pas efficace ou n'est plus correcte (car incomplète, par exemple) dans le contexte de la classe héritière. Ceci se ferait de la façon suivante :
class POINT_POLAIRE
inherit POINT
redefine
is_origin
end
creation
make
feature
is_origin: BOOLEAN is
do
Result := (d = 0.0)
end
-- reste du code inchangé
end
Dans un prochain numéro de Linux Mag', nous étudierons (entre autres), la généricité, l'héritage multiple et la programmation par contrat.
Philippe Coucaud, Olivier Zendra, Dominique Colnet
Web BibliographieSmallEiffel Home Page : http://SmallEiffel.loria.fr
Newsgroup : comp.lang.eiffel