La programmation à objets. Application au langage Eiffel

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

 


© Copyright 2000 Diamond Editions/Linux magazine France. - Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.1or any later version published by the Free Software Foundation; A copy of the license is included in the section entitled "GNU Free Documentation License".