Cathy Berthouzoz LATL, Université de Genève, Candolle 2 CH-1204 Genève e-mail: berthouzoz@latl.unige.ch Cathy's homepage Petra Fabian et Günter Dotzel ModulaWare, La Chanenche, F-04340 Meolans Revel e-mail: 100023.2527@compuserve.com ModulaWare homepage
Un langage de programmation extensible comme Oberon-2 possède toutes les propriétés essentielles qui caractérisent un langage de programmation orienté objet, à savoir les concepts de classes, d'héritage, de méthodes et de messages, même si le vocabulaire est différent (type enregistrement extensible pour classe, variable de ce type pour objet, procédure liée au type pour méthode) [Wirt92]. La différence primordiale d'Oberon-2 par rapport à d'autres langages de programmation objet est qu'il est réduit à l'essentiel et qu'il n'introduit que peu de termes étrangers à la programmation procédurale, plus conventionnelle et bien connue des programmeurs. Ses concepts sont clairs, précis et intuitifs, ce qui facilite son apprentissage et son utilisation (voir [Temp94] pour une comparaison entre Oberon-2 et C++).
Cet article commence par présenter les concepts d'Oberon-2. Puis il montre comment utiliser l'extensibilité pour implémenter des structures de données génériques et hétorogènes. Ensuite une réalisation possible de la persistance est présentée. Finalement, toutes ces notions sont mises en pratique dans un exemple réaliste, un gestionnaire d'arbres AVL, pour montrer les avantages d'Oberon-2 - structuration, rapidité, sçreté, lisibilité - par rapport à un langage traditionnel comme Modula-2.
Oberon-2 est une extension d'Oberon compatible vers le haut, développée par Niklaus Wirth, Jürg Gutknecht et Hanspeter Mössenbock en 1991 [Möss93]. La différence essentielle est l'introduction des procédures liées au type (méthodes), ce qui permet d'implémenter des objets polymorphes : la programmation orientée objet devient plus aisée [Möss93]. Les nouvelles caractéristiques comprennent entre autres les tableaux dynamiques et l'exportation de variables en lecture seulement. Oberon-2, comme Oberon, se distingue par sa petitesse et sa fiabilité.
L'avantage principal de l'héritage réside dans le fait que le type étendu reste compatible avec le type original : tous les algorithmes prévus pour le type original peuvent être utilisés avec le type étendu. Il y a donc la possibilité de fabriquer des produits 'semi- finis', qui seront complétés plus tard, et des structures de données génériques, qui pourront contenir des objets différents.
Oberon-2 ne permet pas l'héritage multiple. Mais cette propriété n'est pas essentielle car on peut construire à l'aide de l'héritage simple des structures de données qui présentent les caractéristiques de l'héritage multiple tout en étant beaucoup plus sçres [Temp93].
TYPE ObjectA* = RECORD (* Note 1 *) key : INTEGER; END; ObjectB* = POINTER TO ObjectDesc; ObjectDesc* = RECORD END; PROCEDURE (VAR obj: ObjectDesc) Operation *; END Operation; PROCEDURE (obj: ObjectB) Compare * (item: ObjectB); END Compare;Les méthodes Compare et Operation sont liées à un objet du type ObjectDesc. Le paramètre obj agit comme récepteur (Receiver). Lorsque le récepteur est un type enregistrement, il doit être passé comme paramètre variable, s'il est d'un type pointeur, il doit être passé comme paramètre valeur.
Lorsque le récepteur est un type pointeur, on ne peut passer le message qu'à une variable de type pointeur; par contre lorsque le récepteur est un type enregistrement, le message peut être passé soit à une variable de type pointeur ou enregistrement :
VAR ptr1, ptr2 : ObjectB; obj : ObjectDesc; ... ptr1.Operation; (* ok *) ptr1.Compare(ptr2); (* ok *) obj.Operation; (* ok *) obj.Compare(ptr2); (* faux *)
TYPE Object1 = POINTER TO ObjectDesc1; ObjectDesc1 = RECORD (ObjectDesc) att: CHAR; END; PROCEDURE (obj: Object1) Write; BEGIN IO.Write(obj.att); END Write;ObjectDesc1 est une extension du type ObjectDesc, dont elle hérite les méthodes Operation et Compare, et qu'elle étend avec l'attribut att et la méthode Write. Elle peut redéfinir les méthodes héritées. Dans ce cas, le récepteur doit être du type étendu, par contre la liste de paramètres doit être identique à celle de la méthode de la classe de base :
PROCEDURE (obj: Object1) Compare (item : ObjectB); BEGIN (* ... *) END Compare; VAR oPtr1,oPtr2 : Object1; oPtrB : ObjectB; obj1 : ObjectDesc1; objB : ObjectDesc; NEW(obj1);NEW(objB); NEW(oPtr1); NEW(oPtrB); oPtrB := oPtr1; (* a) ok *) oPtr1 := oPtrB; (* b) faux *) objB := obj1; (* c) projection *) obj1 := objB; (* d) faux *)L'héritage garantit la compatibilité entre un type de base et ses extensions. C'est pourquoi les assignations a) et c) ci-dessus sont possibles. Dans le cas a), oPtrB est du type dynamique Object1, mais du type statique ObjectB. Comme ce sont des types pointeurs, ils référencent le même objet, mais les champs qui ne font pas partie de l'extension ne peuvent pas être référencés par oPtrB. Dans le cas c), objB a le même type dynamique que statique car les champs du type étendu sont perdus lors de l'assignation : on parle alors de projection. C'est pourquoi il est préférable d'utiliser des pointeurs lors d'assignations. Les cas b) et c) sont interdits car ObjectDesc n'étant pas une extension de ObjectDesc1, le champs att n'obtient pas de valeur.
On remarque dans la méthode Write l'appel à la procédure IO.Write pour écrire le champ att de l'objet obj. IO est ici un alias pour l'un des modules standard d'entrées/sorties (pour les types prédéfinis du langage) définis dans la librairie ISO Modula2 qui est fournie avec le compilateur Oberon-2 [ISO94]. La possibilité de donner un alias à un module dans la liste d'importation est une catactéristique intéressante du langage car Oberon-2 exige de préfixer toutes les entités importées avec le nom du module qui les exporte.
IF oPtrB IS Object1 THEN Proc(oPtrB(Object1).att) END;A l'aide d'un test de type et d'un gardien de type, les assignations b) et d) deviennent possibles :
IF oPtrB IS Object1 THEN oPtr1 := oPtrB(Object1) END;On peut aussi utiliser un gardien de type régional, le test de type étant effectué à l'entrée dans l'instruction :
WITH objB : ObjectDesc1 DO obj1 := objB; ELSE END;Le message oPtrB.Operation entraîne l'exécution de la méthode Operation du type Object avant l'assignation a) ci-dessus, par contre il entraîne l'exécution de la méthode Operation du type Object1 après. Si après l'assignation on veut exécuter la méthode du type de base, il faut utiliser oPtrB.Operation^.
Pour tous les types, elle doit retourner 3 valeurs, selon que l'objet est égal, plus petit ou plus grand. C'est pourquoi elle est définie comme méthode abstraite (son implémentation est vide) attachée au type de noeud. Elle doit être redéfinie pour tout nouveau type, en tenant compte de ses champs propres.
CONST LESS = -1; EQUAL = 0; GREATER = 1; TYPE Node* = POINTER TO NodeDesc; NodeDesc* = RECORD left,right: Node; END; Tree* = POINTER TO TreeDesc; TreeDesc* = RECORD root: Node; END; PROCEDURE (no: Node) Compare* (item: Node): INTEGER; BEGIN (* méthode abstraite *) END Compare;Pour insérer un nouveau noeud dans l'arbre, la méthode Add procède la manière (non récursive) suivante : l'arbre est parcouru depuis la racine pour déterminer la place du nouvel objet. Comme il ne s'agit pas d'un arbre balancé, les nouveaux objets sont toujours insérés à la fin de la branche. La recherche de la place adéquate se fait à l'aide de la méthode d'ordonnancement de l'objet à insérer, item.Compare.
PROCEDURE (t: Tree) Add* (item: Node); VAR this,father: Node; result : INTEGER; BEGIN IF t.root # NIL THEN t.root := item; ELSE this := t.root; WHILE this # NIL DO father := this;.result := item.Compare(this); IF result = EQUAL THEN.RETURN; (* noeud déjà inséré *) ELSIF result = LESS THEN this := this.left; ELSE this := this.right; END; END; IF item.Compare(father) = LESS THEN.father.left := item; ELSE father.right := thisItem; END; END; END Add;Pour gérer un arbre contenant des chaînes de caractères, il suffit d'étendre le type Node ci-dessus (en admettant qu'il soit déclaré dans le module Trees) avec le champ spécifique key :
TYPE Item* = POINTER TO ItemDesc; ItemDesc* = RECORD(Trees.NodeDesc) key * : STRING; END;La méthode Compare est redéfinie pour ce nouvel objet, c'est pourquoi le récepteur obj est du type étendu Item. Par contre, le paramètre item doit correspondre au paramètre de la méthode de base, donc du type de base Node. Mais pour qu'il puisse être comparé, son type dynamique doit être du type de l'objet étendu Item, c'est pourquoi on utilise un gardien de type. :
PROCEDURE (obj: Item) Compare* (item:Trees.Node) : INTEGER; BEGIN WITH item: Item DO (* gardien de type *) IF obj.key < item.key THEN RETURN LESS; ELSIF obj.key > item.key THEN RETURN GREATER; ELSE RETURN EQUAL; END; ELSE error('wrong Type') ; END; END Compare;Voici un exemple d'insertion dans l'arbre de ces nouveaux objets :
VAR myTree : Trees.tree; obj : Item; i : INTEGER; BEGIN NEW(myTree); myTree.NewTree; (* création de l'arbre *) FOR i:= 1 TO 5 DO NEW(obj); IO.ReadString(obj.key); myTree.Add(obj); END; END;
TYPE Item1 = POINTER TO ItemDesc1; ItemDesc1 = RECORD(OrderedTrees.ItemDesc) elem : INTEGER; END; Item2 = POINTER TO ItemDesc2; ItemDesc2 = RECORD(OrderedTrees.ItemDesc) elem : CHAR; END;Une procédure d'écriture des objets, prend comme paramètre un objet de la classe de base, mais doit tenir compte des différents types dynamiques :
PROCEDURE Write(VAR i : Trees.Node); BEGIN WITH i : Item1 DO SWholeIO.WriteInt(i.elem,5); | i : Item2 DO STextIO.WriteChar(i.elem); ELSE END; END Write;Oberon-2 a hérité de Modula-2 le type PROCEDURE, qui permet de passer des procédures en paramètres à d'autres procédures :
TYPE procType* = PROCEDURE (n : Node); PROCEDURE (t: Tree) WalkTreeInOrder *(proc : ProcType); END WalkTreeInOrder;La méthode WalkTreeInOrder parcout l'arbre binaire et applique la procédure proc à chaque noeud, en commençant par le noeud le plus à gauche. Pour écrire le contenu de l'arbre, il suffit de passer la procédure Write en paramètre à la méthode de parcours de l'arbre : suivant le type dynamique du noeud, l'une ou l'autre des variantes du gardien de type régional est vraie et la bonne procédure d'écriture est appelée :
VAR mytree : Trees.Tree; (* ... *) mytree.WalkTreeInOrder(Write)
Pour résoudre ce problème, il faut aussi écrire dans le fichier le type de l'objet. Comme certaines informations sur les objets sont nécessaires à l'exécution, pour les tests et gardiens de types, l'appel des méthodes et la récupération de mémoire, chaque type enregistrement possède en mémoire un descripteur de type, qui est le même pour tous les objets d'une classe. Ce descripteur contient aussi le nom de la classe. Comme il doit être non ambigu, il est formé du nom du module dans lequel le type est déclaré suivi de l'identificateur de type .
Comme les noms de types peuvent prendre une place considérable, ils sont stockés dans le fichier dans une forme compressée [Möss93]. A chaque fichier est associée une table de longueur arbitraire (maxNames) dans laquelle la première occurence des noms de type est inscrite. L'index dans la table et le nom complet sont écrits dans le fichier. Pour les prochaines occurences, seul l'index est écrit. Le champ end indique le prochain index libre dans la table. La classe Stream est fournie avec le compilateur :
TYPE Stream* = RECORD file : IOChan.ChanId; tab : ARRAY maxNames OF TypeName; (* Note 3 *) end : INTEGER; END; PROCEDURE (VAR r: Stream) WriteString * (s: TypeName); VAR i: INTEGER; BEGIN i:=0; LOOP (* search s in r.tab *) IF i=r.end THEN (* première occurence de s *) RawIO.Write(r.file,i); RawIO.Write(r.file,s); r.tab[r.end] := s; INC(r.end); EXIT ELSIF s=r.tab[i] THEN RawIO.Write(r.file,i); EXIT; ELSE INC(i); END; END; END WriteString; PROCEDURE (VAR r: Stream) ReadString * (VAR s: TypeName); VAR i: INTEGER; BEGIN RawIO.Read(r.file,i); IF i = r.end THEN (* le texte complet suit *) RawIO.Read(r.file,s); COPY(s,r.tab[end]); INC(r.tab[end]); ELSE COPY(r.tab[i],s); END; END ReadString;La procédure de sauvegarde d'un objet écrit le nom du type (généré par la procédure TypeName du module Types := Objects_Types (Note 4)) puis l'objet lui-même dans un fichier. Le dernier objet est toujours un objet vide (NoName) pour faciliter la lecture, car ainsi le fichier n'a pas besoin de contenir une marque spéciale de fin de fichier :
PROCEDURE WriteObj (VAR r: Stream; x: Object; VAR ok : BOOLEAN); VAR module,name: TypeName; BEGIN IF x=NIL THEN r.WriteString(NoName); (*NoName = ""*) ELSE Types.TypeName(Types.TypeOf(x),module,name); r.WriteString(module); r.WriteString(name); WriteNBytes(r.file, x, Types.SizeOf(x), ok); END; END WriteObj;Pour charger un objet qui a été sauvé dans un fichier, le nom du type est d'abord reconstruit, puis l'objet est créé avec le type dynamique correspondant (généré par la procédure This du module Types, qui est le module de base implémentant la persistance et fourni avec le compilateur). Le dernier objet lu est un objet vide (NoName) :
PROCEDURE ReadObj(VAR r:Stream;VAR x:Object;VAR ok:BOOLEAN); VAR module,name: TypeName; y: Types.Object; BEGIN r.ReadString(module); IF module="" THEN x:=NIL; ok:=TRUE; ELSE r.ReadString(name); Types.NewObj(y,Types.This(module,name)); x:=y(Object); ReadNBytes(r.file,x,Types.SizeOf(x), ok); END; END ReadObj;Pour une description plus complète de la gestion des objets persistants, voir [Goeb93].
TYPE OrderType = PROCEDURE(ADDRESS,ADDRESS ): BOOLEAN; TreeHeader = RECORD adr : ADDRESS; equal : OrderType; order : OrderType; size : CARDINAL; root : NodePointer; END; TreeNode = RECORD info : ADDRESS; left : NodePointer; right : NodePointer; bal : [-1..1]; END;La procédure Define permet de définir les différents paramètres de l'arbre :
PROCEDURE Define (VAR t : Tree; equal, order : OrderType; item : ARRAY OF BYTE); BEGIN NEW(t); t^.adr := ADR(t); t^.root := NIL; t^.equal := equal; t^.order := order; t^.size := HIGH(item) + 1; END Define;Pour créer un nouveau noeud, la procédure CreateNode doit connaître la taille de l'élément contenu dans le noeud (paramètre size) ainsi que son adresse (item) pour réserver la place mémoire correspondante (avec la procédure ALLOCATE du module Storage). Les données sont ensuite copiées octet par octet :
PROCEDURE CreateNode (size : CARDINAL; item : ADDRESS): NodePointer; VAR newnode : NodePointer; bytecount : CARDINAL; from,to : POINTER TO LOC; BEGIN NEW( newnode ); newnode^.left := NIL; newnode^.right := NIL; newnode^.bal := 0; ALLOCATE(newnode^.info, size); from:=item; to:=newnode^.info; FOR bytecount := 0 TO size - 1 DO to^:=from^; to:= ADDADR( CAST(ADDRESS, to), 1 ); from := ADDADR( CAST(ADDRESS, from), 1 ); END; RETURN newnode; END CreateNode;L'utilisation du type ADDRESS permet certes la généricité, mais par contre il empêche toute vérification de type entre les modules d'interface et d'implémentation du gestionnaire : l'utilisateur de ce module doit être très vigilant.
Puisque la bibliothèque contient un module définissant le type Object et des procédures pour retrouver certaines informations sur le type dynamique (voir le chapitre 5 sur la persistance), un noeud de l'arbre contient un pointeur sur une extension de ce type de base. Il est vide pour que l'arbre soit générique :
TYPE Object* = POINTER TO ObjectDesc; ObjectDesc* = RECORD (Types.ObjectDesc) END; Type* = Types.Type; NodePtr = POINTER TO TreeNode; TreeNode = RECORD left: NodePtr; right: NodePtr; bal : INTEGER ; (* Note 2 *) info : Object; END; Tree* = POINTER TO TreeDesc; TreeDesc = RECORD root: NodePtr; objType: Type; END;Les opérations de comparaison, dont l'arbre a besoin comme critère d'ordonnancement, sont déclarées comme méthodes abstraites de l'objet :
PROCEDURE (info : Object) Order* (item : Object): BOOLEAN; BEGIN HALT(20); END Order; PROCEDURE (info : Object) Equal* (item : Object): BOOLEAN; BEGIN HALT(20); END Equal;Le champ objType permet de contraindre l'arbre à ne traiter que des objets d'un même type, qui est passé en paramètre à la procédure Define :
PROCEDURE Define* (VAR t : Tree; type: Type); BEGIN NEW(t); t.Init(type); (* initialise les champs de t *) END Define;La fonction CreateNode devient plus simple et plus claire :
PROCEDURE CreateNode (item : Object) : NodePtr; VAR newnode : NodePtr; BEGIN NEW(newnode); newnode^.left := NIL; newnode^.right := NIL; newnode^.bal := 0; newnode^.info := item; RETURN newnode; END CreateNode;Les méthodes de sauvegarde et de restoration de l'arbre sont complètement génériques, elles n'ont pas besoin d'être redéfinies pour les extensions de la classe Tree. L'arbre doit exister avant de pouvoir sauver ses éléments ou les récupérer (dans le cas contraire, la variable globale ok prend la valeur FALSE).
La méthode Save sauvegarde les éléments de l'arbre dans le fichier donné en paramètre depuis l'élément le plus à gauche jusqu'à l'élément tout à droite au moyen de la procédure récursive VisitSave. L'écriture proprement dite est effectuée à l'aide de la procédure WriteObj vue au chapitre 5 :
PROCEDURE (t : Tree) Save* (fn: ARRAY OF CHAR); VAR s : Stream; ores : StreamFile.OpenResults; PROCEDURE VisitSave( p: NodePtr ); VAR ok : BOOLEAN; BEGIN IF p # NIL THEN VisitSave( p^.left ); WriteObj(s,p.info,ok); IF ~ ok THEN error(writeerror ); END; VisitSave( p^.right ); END; END VisitSave; BEGIN InitStream(s); ok := FALSE; IF ( t # NIL ) THEN IF ~ t.Empty() THEN StreamFile.OpenWrite(s.file, fn, StreamFile.raw, ores ); IF ores = StreamFile.opened THEN VisitSave( t^.root ); WriteObj(s,NIL,ok); (* eof *) StreamFile.Close( s.file ); END; END; END; END Save;La méthode Load récupère les objets du fichier passé en paramètre. La lecture est effectuée à l'aide de la procédure ReadObj vue au chapitre 5 :
PROCEDURE (t : Tree) Load* (fn: ARRAY OF CHAR); VAR new : Object; s : Stream; ores : StreamFile.OpenResults; BEGIN InitStream(s); ok := FALSE; IF ( t # NIL ) THEN t.MakeEmpty;(* vide l'arbre *) StreamFile.OpenRead( s.file, fn, StreamFile.raw, ores ); IF ores = StreamFile.opened THEN LOOP ReadObj(s,new,ok); IF new = NIL THEN EXIT; ELSIF ok THEN t.Insert( new ); ELSE error( readerror ); END; END; StreamFile.Close( s.file ); ELSE error( readerror ); END; END; END Load;Pour terminer, voici un exemple d'arbre AVL, qui étend la classe de base Tree avec des éléments de type REAL. Les méthodes Order et Equal ont été redéfinies pour tenir compte du nouvel attribut. La procédure PrintOutRec permet d'écrire les nombres stockés dans l'arbre. Elle est passée en paramètre à la méthode Visit de parcours de l'arbre (voir le chapitre 4) :
MODULE Exemple; IMPORT AVL, SRealIO; CONST max=1000; datafile="x.dat"; TYPE Object = POINTER TO ObjectDesc; ObjectDesc = RECORD (AVL.ObjectDesc) pu: REAL; END; VAR myobj, out: Object; mytype: AVL.Type; myTree: AVL.Tree; i: INTEGER; PROCEDURE PrintOutRec(VAR obj: AVL.Object); BEGIN WITH obj : Object DO SRealIO.WriteReal(obj^.pu, 13); ELSE HALT(20); END; END PrintOutRec; PROCEDURE (obj1 : Object) Equal* (obj2 : AVL.Object) : BOOLEAN; BEGIN WITH obj2 : Object DO RETURN obj1^.pu = obj2^.pu; END; END Equal; PROCEDURE (obj1 : Object) Order (obj2 : AVL.Object) : BOOLEAN; BEGIN WITH obj2 : Object DO RETURN obj1^.pu < obj2^.pu; END; END Order; BEGIN NEW(myobj); mytype := AVL.TypeOf(myobj); AVL.Define(mytree, mytype); FOR i:=max TO 0 BY -1 DO NEW(out); out^.pu:=i; tree.Insert(out); END; mytree.Visit(PrintOutRec); mytree.Save(datafile); mytree.MakeEmpty; mytree.Load(datafile); IF mytree.type() = mytype THEN mytree.Visit(PrintOutRec); ELSE HALT(20); END; END Exemple;
[ISO94] IEEE/IEC : ISO 10154 Modula-2, 1994
[Möss93] Mössenböck, Hanspeter: Object-Oriented Programming in Oberon-2 Springer-Verlag Berlin Heidelberg, 1993
[Temp93] Templ, Josef : A systematic approach to multiple inheritance implementation, in ACM SIGPLAN, Apr-1993
[Temp94] Templ, Josef : Oberon vs C++, The ModulaTor, Vol. 4, Nr. 9, ModulaWare, Oct-1994.
[Wirt92] Reiser, Martin; Wirth, Niklaus: Programming in Oberon. Addison Wesley, 1992.
Note 2: Le champ de balance (bal) ne peut être déclaré que comme INTEGER car Oberon n'a pas de type énumération.
Note 3: En Oberon-2, on pourrait utiliser un tableau ouvert dynamique : POINTER TO ARRAY OF TypeName
Note 4: Des routines pour dériver un type à partir d'un nom de type, et inversément, sont fournies avec le compilateur:
DEFINITION Objects_Types; IMPORT SYSTEM; CONST QuadwordSize = 8; adrSize = 8; maxIdentLen = 32; tagSize = 8; TYPE ADDRESS_64 = SYSTEM.SIGNED_64; Name = ARRAY 32 OF CHAR; NamePtr = POINTER TO RECORD name: Name; END ; Object = POINTER TO ObjectDesc; ObjectDesc = RECORD END ; Size = SYSTEM.SIGNED_64; Tag = SYSTEM.SIGNED_64; Type = POINTER TO TypeDesc; TypeDesc = RECORD module: NamePtr; name: Name; END ; VAR Modules-: ARRAY 256 OF ModEntryDesc; PROCEDURE DisposeArray (VAR o: SYSTEM.PTR); PROCEDURE DisposeDynArray (VAR o: SYSTEM.PTR; obolete1, obsolete2: LONGINT); PROCEDURE DisposeObj (VAR o: Object); PROCEDURE NewObj (VAR o: Object; t: Type); PROCEDURE SizeOf (o: Object): LONGINT; PROCEDURE StoreModObjects (typeDescBase: TypesArray); PROCEDURE This (module, name: ARRAY OF CHAR): Type; PROCEDURE TypeName (typ: Type; VAR module, name: ARRAY OF CHAR); PROCEDURE TypeOf (o: Object): Type; END Objects_Types.