Programmation Objet : Objective C - 2e partie

Bonjour à tous, notre premier article nous a permis de nous familiariser avec quelques-uns des concepts de base d'Objective-C. Néanmoins, des notions aussi prépondérantes que l'héritage, en programmation objet, mais aussi le fonctionnement des messages - dont nous n'avons abordé que la syntaxe - nécessitent une présentation plus approfondie, que je vous propose au menu de ce numéro. Nous en profiterons pour enrichir notre vocabulaire de quelques nouveaux mots clés...

L'héritage en Objective-C

Dans notre exemple précédent : définition et implémentation d'une classe Rectangle, nous avons utilisé l'un des mécanismes les plus importants de la programmation objet : l'héritage.

Pour ne pas avoir à nous préoccuper du comportement basique de tout objet (allocation/libération de la mémoire, etc.), nous avions choisi de profiter des services de la classe NSObject.

#import <Foundation/NSObject.> // besoin d'une superclass

@interface Rectangle : NSObject // déclaration de class

En Objective, à l'exception de la classe racine, chaque classe est basée sur une autre classe, dont elle hérite et des variables d'instance et des méthodes. Ce principe d'héritage offre à toute nouvelle classe la possibilité de modifier ou d'enrichir cet «avoir», supprimant ainsi la nécessité de dupliquer le code hérité. Il établit, entre toutes les classes d'une librairie, un lien hiérarchique représenté par un arbre d'héritage ayant pour racine une classe unique.

Intégré dans un petit programme de dessin, notre classe Rectangle trouvera sa place dans un arbre d'héritage qui pourra ressembler à celui-ci :

La classe Racine

La classe NSObject est la classe racine de GNUstep. Elle se trouve par conséquent dans la chaîne d'héritage de toutes les autres classes définies dans cette librairie. Elle constitue en quelque sorte la charpente de tout Objet Objective-C et assure à chacune des classes et des instances qui en héritent la capacité de se comporter en tant qu'objets et à coopérer avec le run-time système.

Héritage des Variables d'Instance

Quand une nouvelle instance est créée, le nouvel objet ne contient pas seulement les variables d'instances déclarées dans sa définition, mais aussi toutes celles qui l'ont été dans sa super-classe et ainsi de suite jusqu'à la classe racine. Cela signifie donc que toutes les variables déclarées dans la classe NSObject, dont la variable d'instance isa (voir article 1 -isa assure la connexion entre un objet et sa classe), sont parties intégrantes de chaque objet de la librairie.

Ainsi, une classe pourra ne pas déclarer de variables, mais seulement les méthodes qui manipuleront les variables dont elle hérite. La classe Carre pourra donc ne déclarer aucune variables d'instances et ne spécialiser dans le dessin du carré que la méthode Display héritée de la classe Rectangle.

Héritage des Méthodes

Il en va de même pour les méthodes : un objet n'a pas seulement accès aux méthodes déclarées dans sa définition, mais également à celles déclarées dans la hiérarchie de son héritage, jusqu'à celles de la classe racine. Notre objet Carre a donc accès non seulement à ses méthodes propres mais aussi à celles des objets Rectangle, Forme, NSObject.

Les programmeurs C++ reconnaîtront là des notions qui leur sont familières mais remarqueront aussi l'absence de la notion d'héritage multiple. Nous verrons plus tard les mécanismes d'Objective-C qui s'y substituent.

La notion du «tout est objet», fidèle à la philosophie SmallTalk, conduit à considérer les classes elles-mêmes comme des objets. La caractéristique principale de l'objet classe (ou objet de classe - en anglais, class objet-) est sa capacité à créer une nouvelle instance de la classe. Bien sûr, de même que tout autre objet, l'objet classe hérite des classes qui le précèdent dans la hiérarchie. Toutefois, au contraire d'une instance d'objet qui seule possède des variables (d'instance), cet objet classe, créé par le compilateur, n'hérite que des méthodes de sa super-classe. Un objet classe ne pourra pas non plus être statiquement typé (voir plus loin) et, dans un message, il sera désigné par le nom de la classe. Pour le reste, il se comporte comme tout objet Objective-C.

Surcharge des méthodes

La propriété «cumulative» de l'héritage se combine avec une autre des notions les plus importantes de la programmation objet : le polymorphisme.

Pour s'afficher, notre objet Rectangle a une méthode Display qu'il hérite de sa super-classe (Forme) et qu'il spécialise dans le dessin des rectangles. La classe Carre, qui hérite de Rectangle, et qui n'a que faire de savoir dessiner des Rectangle, déclare donc à son tour une méthode Display dont seule la spécialisation change, pas le nom ! De la sorte, cette méthode, en plus de ses «spécialités», possède toutes celles dont elle a hérité.

Dans certains cas cependant, il sera préférable d'utiliser les services de la méthode «originale» ; le système de message d'Objective-C permet alors au programmeur d'opérer ce choix en lui permettant d'envoyer un message à SELF ou à SUPER. Nous reviendrons sur le détail du fonctionnement des messages et sur la définition de ces deux nouveaux mots clés d'ici la fin de cet article...

Attention ! Le polymorphisme ne s'applique qu'aux méthodes, jamais aux variables d'instance. En effet, un objet se voit attribué la mémoire nécessaire à toutes les variables dont il hérite. Il est donc impossible de surcharger une variable héritée par la déclaration d'une autre de même nom dans l'interface d'une sous-classe ; le compilateur veille et ne manquera pas de se plaindre!

Le Typage statique

Nous avons déjà parlé du type id :

id myRect;

En écrivant cette ligne de code, nous définissions simplement myRect comme un pointeur sur les variables d'instance de notre objet Rectangle. C'est le runtime qui, grâce à la variable isa, se chargera d'en savoir plus sur l'objet lui-même : c'est le typage dynamique.

Il est également possible d'utiliser le nom d'une classe pour désigner un objet :

Rectangle *myRect;

Procédant de la sorte, nous informons le compilateur de la nature de l'objet : c'est le typage statique. Alors que id est un pointeur sur un objet, un objet statiquement typé est un pointeur sur une classe. En fait, les objets sont toujours typés par un pointeur ; alors que id «masque» le pointeur, le typage statique le rend «visible».

Le typage statique permet au compilateur de procéder à des opérations de contrôle de type, de telle manière qu'il signalera par exemple le fait que tel objet ne lui semble pas capable de répondre à un message qui lui est envoyé.

Un objet peut être statiquement typé par sa propre classe ou par n'importe laquelle des classes dont il hérite. Une instance de notre objet Rectangle, depuis qu'il fait partie de l'arbre d'héritage de la mini librairie de notre programme graphique, pourrait donc être statiquement typée de la manière suivante :

Forme *myRect;

En fait, un Objet Rectangle est donc tout à la fois un NSObject mais aussi une Forme puisqu'il en hérite variables et fonctions. Lorsqu'il procède aux opérations de contrôle de type, le compilateur considère Rectangle comme étant une Forme, mais à l'exécution, il n'en continuera pas moins à être traité comme le Rectangle qu'il est.

La propriété introspective des objets

Comme nous l'avons déjà signalé, une instance est en mesure de révéler son type au runtime. Puisque nous travaillons avec GNUstep, des méthodes telles que isMemberOfClass ou isKindOfClass, définies dans NSObject, permettent d'accéder à ce type d'informations relatives aux objets, tant de classe que d'instance :

if ( [myCarre isMemberOfClass:Carre] ) ...

if ( [myRect isKindOfClass:Rectangle] ) ...

Cette propriété des objets à divulguer les informations les concernant ne se limite évidement pas aux seuls types, la classe NSObject recèle nombre de méthodes qui vous deviendriont bien vite familières...

Les Messages

Le fonctionnement des messages

Nous n'avons jusqu'à présent qu'évoquer les messages et leur syntaxe. Nous allons examiner de manière un peu plus détaillée leur fonctionnement.

Jusqu'à l'exécution, Objective-C n'établit aucun lien entre les messages et les méthodes. Lorsqu'il rencontre un message,

[receiver message]

le compilateur convertit cette expression en un appel à la fonction objc_msgSend() qui prend, comme principaux arguments, le receveur et le nom de la méthode -son sélecteur.

objc_msgSend (receiver, selector)

Evidemment, tous les arguments passés en paramètres dans le corps du message sont également pris en charge :

objc_msgSend(receiver, selector,

arg1, arg2, . . .)

C'est cette fonction objc_msgSend() qui est responsable de tout ce qui concerne la liaison dynamique :

- La localisation de la procédure (l'implémentation de la méthode), référencée par le sélecteur. (La même méthode pouvant être implémentée différemment par différentes classes (polymorphisme), la recherche de la bonne procédure est basée sur le nom du receveur).

- Ensuite objc_msgSend() invoque la procédure de l'objet receveur avec chacun des arguments définis pour la méthode.

- Enfin, objc_msgSend traite la valeur retour de la procédure comme la sienne propre.

C'est le compilateur et lui seul qui procède aux appels à la routine de messagerie objc_msgSend(), en aucun cas le programmeur !

Toute cette mécanique des messages repose en fait sur les structures élaborées par le compilateur pour chaque classe et chaque objet. Toutes les structures de classe possèdent deux éléments essentiels :

- un pointeur vers la super-classe.

- une dispatch table dont les entrées associent les sélecteurs des méthodes avec les adresses spécifiques de chaque classe : le sélecteur pour la méthode Display de la classe Rectangle est associé avec l'adresse de la procédure qui implémente Display::, etc.

A la création d'un nouvel objet, la mémoire est allouée et ses variables d'instance initialisées. La plus importante des variables de ce nouvel objet est le pointeur sur la structure de sa classe, le fameux isa. Il est important de noter que ce pointeur isa ne fait pas strictement partie du langage Objective-C. Néanmoins, il est indispensable pour qu'un objet fonctionne avec le runtime du compilateur gcc et/ou celui d'Apple/Next et donc la librairie GNUstep.

Lorsqu'un message est envoyé à un objet, la routine de messagerie suit à la trace le pointeur isa de notre objet jusqu'à la structure de classe où il tente de trouver le sélecteur de la méthode dans la table. Tant que cette recherche échoue, notre routine continue à pister le pointeur isa en remontant la hiérarchie jusqu'à atteindre la classe racine, NSObject dans notre cas. Dès lors que le sélecteur est localisé, la méthode correspondante de la table est passée à la structure de données de l'objet.

Les Sélecteurs

Pour d'évidentes raisons de performance, à la représentation littérale (ASCII) du nom des méthodes contenues dans la table, le compilateur associe un identificateur unique qui représentera chacune des méthodes à l'exécution.

Deux identificateurs ne peuvent être identiques, deux sélecteurs non plus ; les méthodes de même nom (polymorphisme) ont le même sélecteur. Un fois compilés, ces sélecteurs se voient assigné un type qui leur est propre : SEL, et qui les distingue de toutes les autres données.

Méthodes et Sélecteurs

Compilés, les sélecteurs identifient les noms des méthodes et non leurs implémentations. La méthode Display de Rectangle aura le même sélecteur que celui des autres méthodes Display des autres classes (Formes, Lignes, etc.) de notre librairie graphique. L'une des caractéristiques principales du polymorphisme et de la liaison dynamique, c'est cette capacité à offrir la possibilité d'envoyer le même message à des receveurs appartenant à différentes classes. Si, au contraire, il devait y avoir un sélecteur pour chaque implémentation de méthode, un message ne serait rien d'autre qu'un simple appel de fonction.

De la même manière, une méthode de classe et une méthode d'instance ayant le même nom se voient assigné le même sélecteur. Leurs différents domaines de définition (la classe ou l'instance) suffisent à prévenir toute confusion. Une classe peut ainsi définir une méthode de classe Display, en plus d'une méthode d'instance de même nom.

@interface Rectangle:Forme

{...

}

// méthode de classe

+ Display ...

// méthode d'instance

- Display ...

@end

Valeurs retour et types d'argument des méthodes

La routine de messagerie n'a accès à l'implémentation des méthodes que par l'intermédiaire des sélecteurs. C'est ce sélecteur qui, en les retrouvant, assure à la routine de messagerie de connaître et le type de la valeur retour d'une méthode et le type de ses arguments.

Excepté pour les messages adressés à des receveurs statiquement typés, le principe de la liaison dynamique requiert que toutes les implémentations de méthode de même nom aient le même type de valeur retour et le même type d'arguments. Les receveurs statiquement typés étant évidement une exception à cette règle.

On notera également que les méthodes de classe et les méthodes d'instances de même nom, bien qu'ayant le même sélecteur, pourront avoir des valeurs retour et des arguments de types différents.

Messages à destination de Self et de Super

Deux nouveaux mots vont enrichir notre vocabulaire : self et super.

Revenons à notre Rectangle : pour contrôler sa position, nous définissons une méthode changePosition. Pour effectuer son travail, cette méthode devra utiliser des services de la méthode setOrigin afin de procéder au déplacement demandé. La seule chose que changePosition aura à faire est d'envoyer un message à la méthode setOrigin de l'objet auquel le message changePosition aura été lui-même envoyé :

- changePosition

{

...

[self setOrigin:X :Y];

...

}

Notre Rectangle est aussi une Forme, nous pouvons donc également écrire:

- changePosition

{

...

[super setOrigin:X :Y];

...

}

- self cherche l'implémentation de la méthode d'une manière «normale», en commençant par regarder dans la «dispatch table» de la classe de l'objet receveur. Dans cet exemple, self commencera sa recherche dans la table de la classe de l'objet Rectangle.

- super, au contraire, commence sa recherche dans la table de la super-classe de la classe qui définit la méthode où super est appelé. Dans l'exemple ci-dessus, dans la table de la classe Forme.

Trois classes d'un même arbre suffisent en fait à clarifier la différence entre SELF et SUPER.

Chacune de ces trois classes définit une méthode doSomething. La classe Second déclare en plus une méthode doGenericThing qui, à son tour, réclame les services de doSomething.

Supposons que nous décidions d'envoyer un message à notre objet Third pour qu'il invoque la méthode doGenericThing : celle-ci doit à son tour envoyer un message doSomething au même objet Third. Si, dans notre source, nous appelons l'objet self :

- doGenericThing

{

[self doSomething];

...

}

la routine de messagerie trouvera effectivement la méthode doSomething définie dans la classe Third.

Si, au contraire, nous appelons l'objet super

- doGenericThing

{

[super doSomething];

...

}

la routine de messagerie trouvera la méthode doSomething définie dans la classe First. Elle ignore l'objet receveur Third et achemine le message jusqu'à la super-classe de Second.

L'utilisation de Super

Les messages adressés à super autorisent une distribution «inter-classe» de l'implémentation des méthodes. Il est donc possible de surcharger une méthode, tout en continuant de profiter des services de la méthode originale à travers la méthode surchargée elle-même.

- doSomething

{

...

return [super doSomething];

}

Pour la réalisation de certaines tâches, chaque classe d'un arbre d'héritage peut implémenter une méthode n'assurant qu'une partie du travail et qui, pour le reste, adresse un message à destination de sa super-classe. La méthode init, qui est en charge de l'initialisation de toute nouvelle instance, a été pensée en ce sens. Toute méthode init a la responsabilité d'initialiser les variables d'instance de sa propre classe mais, avant d'accomplir cette tâche spécifique, elle commence par envoyer un message à super pour que la classe dont elle hérite procède elle-même à l'initialisation de ses propres variables d'instance. Chaque méthode init fonctionne de cette manière, de sorte que toutes les variables d'instance sont assurées d'être initialisées dans leur ordre d'héritage.

- (id)init

{

[super init];

...

}

J'espère que cet exposé, en plus d'être clair, vous permet d'imaginer la liberté qu'offre au programmeur les subtiles constructions d'Objective-C.

Au sommaire du prochain épisode

Pas beaucoup d'exemples aujourd'hui, mais quelque soit le langage, un minimum de vocabulaire et de grammaire est indispensable ! Le mois prochain, nous passerons enfin à la réalisation de ce petit programme graphique dont nous avons beaucoup parlé jusqu'à présent...

La présence d' Objective-C sur le Net

Dans notre premier article, les adresses étaient génériques, en voici une autre plus spécifique que les utilisateurs d'une distribution Debian de Linux connaissent peut-être déjà :

les pages de Marcel sur le sujet et, en particulier, sur POC (The Portable Object Compiler)

http://www.qahwah.demon.nl/marcel/poc.html

Objective C : http://www.slip.net/~dekorte/Objective-C/Documentation/index.html

Smalltalk : http://www.smalltalk.org

Yann Le Guen

<yann.le-guen@wanadoo.fr>


© 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".