EPOOL - Environnement de Programmation Orientée Objet en Lingo par Irv Kalb

Chapitre précédent

Table des matières

Chapitre suivant

Implémentations des objets - sous le capot

Lorsque nous voyons une nouvelle technologie, nous sommes intéressés par comment l'utiliser (la partie extérieure), mais aussi parfois par son fonctionnement (la partir interne). Ce chapitre est consacré à l'implémentation des objets. Si ceci ne vous intéresse pas, libre à vous de le passer. Cependant je crois que savoir ce qui se passe "sous le capot", ou au moins en avoir une représentation assez précise, pourra vous aider à mieux comprendre l'utilisation des objets, et plus généralement la programmation orientée objet.

Si vous imaginez Director comme un objet, alors le Lingo est l'API de Director. Vous pouvez écrire du code en Lingo mais le code sous-jacent et les propriétés utilisés par Director vous restent cachés. Macromédia peut réécrire le code interne de Director sans que cela affecte votre code, comme nous l'avons vu avec les principes de la POO. Il semble que chaque version de Director apporte une accélération de la vitesse d'exécution du Lingo sans qu'aucun programmeur Lingo n'ait à changer une seule ligne de son code. Pour aller plus loin dans l'analogie, nous (utilisateurs de Director) n'avons pas accès au code source de Director pour observer son fonctionnement interne. Cependant nous pouvons faire des suppositions. Je vais présenter une modélisation hypothétique de l'implémentation interne de la POO dans Director. (Certains éléments que je présente ici m'ont été confirmés lors d'une conversation avec John Thompson, le "père" de Lingo.)

Un compilateur est un programme spécial qui analyse du code que vous avez écrit dans un langage de haut niveau et le transcrit dans du code que l'ordinateur peut comprendre. Les programmeurs écrivent du code en C ou en Pascal ou en Fortran, puis exécutent le compilateur approprié pour le convertir en code machine pour une certaine architecture. A l'intérieur de Director; Lingo dispose aussi de ce que Macromédia appelle un compilateur. Celui ci ne traduit pas le Lingo en 0 et en 1 comme le ferait un vrai compilateur ; il ce contente de traduire votre code Lingo en code IML (Idealized Machine Layer). L'IML représente un pseudo-ordinateur idéal, mais qui n'existe pas. Ceci est merveilleux, puisque c'est ce qui permet au même code Lingo d'être exécuté sur différentes plateformes. Chaque opération IML telle que addition, soustraction, etc. est codée sur un octet - un simple nombre . Le résultat de la pseudo-compilation est appelé byte code. Lors de l'exécution, le byte code est traduit en code machine pour la plateforme sur laquelle le programme est exécuté.

Quand vous programmez dans de nombreux autres langages de haut niveau, vous devez écrire beaucoup de code pour gérer la mémoire. Heureusement pour nous autres, développeurs Lingo, Director s'occupe de beaucoup des détails des appels de bas niveau vers le système d'exploitation et pour l'allocation de mémoire. Ceci est très bien. Les programmeurs Lingo ont rarement à s'inquiéter de combien de mémoire sera requise pour quelque chose et n'ont en aucun cas à s'inquiéter de quelles commandes du système d'exploitation (MacOS et Windows) sont exécutées. Mais si vous voulez creuser un peu et essayer de comprendre comment fonctionne la mémoire, la génération de code et l'adressage, je pense que vous trouverez cela très enrichissant. Cela peu sembler être un peu hors sujet, mais comprendre comment sont gérées les variables vous aidera à conforter et mieux comprendre pourquoi la POO est une bonne chose.

 

Un exemple d'IML

A chaque fois que vous éditez du code Lingo dans une fenêtre de l'éditeur de script puis fermez cette fenêtre, Director compile ce script en code IML. Le code IML qui est "généré" par le compilateur Lingo pour chaque script est stocké en mémoire. De cette manière, tous les scripts compilés sont en mémoire, prêts à être exécutés n'importe quand. Voici l'exemple d'un simple gestionnaire Lingo et de sa conversion en IML (si vous avez des notions d'assembleur, ce code vous paraîtra sans doute familier)

on AdditionneDeuxNombres v1, v2
  valeurARetourner = v1 + v2
  return
valeurARetourner
end

Le code IML généré à partir de ce gestionnaire pourrait ressembler à :

Label: Instruction: Addresse:

AdditionneDeuxNombres LOAD v1
  ADD v2
  STORE valeurARetourner

Le CPU de la plupart des ordinateurs possèdent un endroit spécial appelé "accumulateur" où sont effectuées les opérations arithmétiques. Dans la séquence de code IML ci-dessus, l'instruction LOAD définit la valeur de l'accumulateur à la valeur de v1, puis la valeur de la variable v2 est ajoutée dans l'accumulateur, et enfin le résultat est stockée dans la variable valeurARetourner.

Je vais vous demander maintenant de me faire confiance. Pour simplifier les explications, nous allons supposer que le stockage de n'importe quelle variable occupe une adresse mémoire. En réalité c'est un peu plus compliqué que ça. Mais cette supposition nous écarte un tout petit peu de la réalité en nous épargnant des détails à propos des bits et des octets et rendra l'exposé plus facile à suivre et à comprendre. A partir de cette hypothèse qu'une variable occupe une adresse mémoire, alors nous en déduisons qu'une liste de 'n' éléments occupera 'n' adresses mémoire. Supposons que nous exécutons la ligne Lingo suivante:

  glNombres = [30, 40, 50]

Ensuite nous voulons accéder individuellement aux éléments de cette liste, pour les lire ou leur attribuer une valeur. Comment l'ordinateur stocke-t-il réellement ces éléments ? Pour répondre à cette question, il nous faut un moyen de décrire ma mémoire. On apprend souvent aux étudiants en informatique à penser la mémoire comme une liste linéaire d'emplacements de mémoire. Chaque emplacement de mémoire a une adresse (sa positon, définie par un entier non négatif), et un contenu (sa valeur). Voici comment la liste ci-dessus pourrait être stockée en mémoire.

 

Puisque c'est peut-être la première fois que vous voyez un diagramme de mémoire comme celui-ci, laissez moi l'expliciter un peu. Nous avons une variable Lingo appelée glNombres qui est stockée quelque part en mémoire. Nous ne savons pas vraiment où, et nous n'avons pas besoin de le savoir, et cela importe peu - Lingo gère cela pour nous. Mais puisque nous essayons d'expliquer à quoi ressemble la mémoire j'ai choisi une adresse. Disons que cette variable est stockée à l'adresse 1000, et que son contenu est la valeur 1234. S'il s'agissait d'une variable simple, Lingo interpréterait ceci comme la valeur entière 1234, et glNombres aurait cette valeur. Mais Lingo sait qu'il s'agit d'une liste et interprète donc 1234 comme un pointeur - une adresse mémoire - vers le contenu réel de la liste glNombres. Nous constatons que le contenu de la mémoire à l'adresse 1234 est la valeur 30, c'es à dire glNombres[1], à l'adresse 1235 on trouve la valeur de glNombres[2], etc. Si vous changez le contenu de la liste, par exemple en exécutant glNombres = [1,2,3,4], l'adresse mémoire de glNombres resterait la même (1000), mais son contenu, la zone mémoire où sont stockées les valeurs de la liste, changera certainement.

La plupart des ordinateurs ont une gestion de la mémoire qui vous permet de décrire l'adresse d'une case mémoire à partir d'une adresse de base et d'un offset. Pour calculer une adresse, il vous suffit d'ajouter la valeur de la base et celle de l'offset. Par exemple Pour récupérer un élément dans une liste, vous utilisez l'adresse de base de la liste (l'emplacement dans la mémoire où la liste commence) et vous y ajoutez l'offset (l'indice de l'élément de la liste que vous voulez atteindre). Cependant, puisque les listes Lingo sont indexées à partir de 1, nous devons soustraire 1 pour obtenir la bonne adresse. Lorsque nous utilisons une adresse de base et un offset pour accéder à un élément d'une liste, le compilateur nous générera une instruction IML du style :

  LOAD ListBase, Offset

Cela charge la valeur contenu en mémoire à l'adresse définie par la somme des valeurs de ListBase et Offset. L'exemple suivant devrait vous aider à y voir plus clair. Imaginons que nous travaillons sur un gestionnaire comme celui-ci:

global glNombres -- liste de nombres
on SoustraitDeuxNombres
  glNombres[1] = glNombres[2] - glNombres[3]
end

En utilisant l'accès mémoire à partir de base plus offset, les instructions IML générées à partir de ce gestionnaire SoustraitDeuxNombres ressembleront à:

SoustraitDeuxNombres LOAD glNumbers, 2
  SUB glNumbers, 1
  STORE glNumbers, 0

La première ligne charge dans l'accumulateur l'élément qui est 2 adresses mémoire plus loin que la base de la liste glNombres (c'est à dire le troisième élément de la liste). Nous y retranchons ensuite la valeur qui est 1 adresse mémoire plus loin que la base de la liste (c'est à dire le deuxième élément de la liste). Enfin nous stockons le résultat en mémoire à l'adresse qui est à 0 emplacement mémoire plus loin que le début de la liste, c'est à dire le tout premier élément de la liste.

 

L'instanciation d'un objet

Comprendre comment est organisée et adressée la mémoire vous sera très utile pour comprendre l'instanciation des objets. Jusqu'ici nous avons vu qu'un objet est créé dynamiquement par le programmeur. Pendant la conception, à chaque fois que vous éditez un script, Director le compile et place une copie du code IML généré en mémoire - même si vous n'avez pas créé d'objets à partir de ce script. Pendant l'exécution, lorsqu'un programme instancie un script à partir d'un script parent, Director alloue dynamiquement de la mémoire pour les propriétés déclarées dans ce script. Le montant de mémoire allouée à l'instanciation d'un objet est très faible - juste ce qu'il faut de mémoire pour conserver les propriétés.

Voici ma conception de la succession d'événements lors de l'instanciation d'un objet. Un programme exécute la ligne de code suivante:

oQuelqueChose = new(script "UnNomDeScriptParent")

Director possède une méthode "new" générique qui accepte différents types de paramètres (#bitmap, #flash, #cursor, #script, etc). La fonction générique "new" de Director se comporte différemment suivant le type de paramètre passé en argument. L'implémentation de la fonction "new" de Director doit être essentiellement composée d'un grand 'case' qui filtre le type d'argument passé. Si la fonction "new" de Director était écrite en Lingo, elle ressemblerait sans doute à :

on new unParametre, param1, param2, param3, etc
  case
ilk(unParametre) of
    #bitmap:
      -- code pour un bitmap
      
    #flash:
      -- code pour un flash
      
    #parentScript:
      -- alloue de la mémoire pour cette instance de script parent
      uneAdresseMemoire = rawNew(
unParametre)
      
      -- appelle la méthode "new" du script de l'utilisateur
      valeurARetourner = new(
uneAdresseMemoire, param1, param2, param3, etc)
      
      -- retourne la valeur retournée par la méthode "new" du script de l'utilisateur
      return
valeurARetourner
      
    #othertypes:
  end
case
end

Lorsque le paramètre est une référence vers un script parent (comme nous essayons de l'expliquer ici), Director sait que l'utilisateur veut instancier un objet depuis ce script parent. Dans ce cas, Director appelle une routine interne (appelée "rawNew", documentée dans l'aide de Director 8) pour allouer un espace mémoire suffisamment grand pour toutes les propriétés du script. Cette fonction retourne l'adresse de la mémoire allouée.

Director appelle ensuite la méthode "new" du script parent, en passant en premier paramètre cette adresse mémoire. Dans la méthode "new" du script parent, le premier paramètre est habituellement la variable "me". Les autres paramètres sont assignés aux autres variables. ensuite est exécuté le code d'initialisation contenu dans la méthode "new" du script parent. Généralement la dernière commande de la méthode "new" est "return me". Cette commande renvoie la valeur de "me" à la fonction "new" de Director. Enfin, Director renvoie cette valeur à la ligne qui a lancée l'instanciation.

avec Director 8, la fonction d'allocation de mémoire "rawNew" est susceptible d'être appelée directement depuis le code Lingo. Vous pouvez exécuter une ligne du style :

oQuelqueChose = rawNew(script "UnNomDeSCriptParent")

Director va allouer la mémoire nécessaire pour les propriétés du script parent, et vous retourner cette adresse, mais la méthode "new" du script parent ne sera pas exécutée.

Pour illustrer par un exemple, revenons au début de notre script parent de compte en banque du chapitre 3:

-- script CompteEnBanque
property pMotDePasse
property pSolde


on new me, motDePasse, soldeInitial
  pMotDePasse = motDePasse
  pSolde = soldeInitial
  return me
end


Nous instancions un objet compte en banque avec une commande comme :

oCompteEnBanqueA = new(script "CompteEnBanque", "xyzzy", 400.00)

Le script parent déclare deux propriétés. A l'exécution, lorsque nous exécutons du code pour instancier un objet CompteEnBanque, Director appelle sa fonction interne pour allouer assez de mémoire pour stocker ces deux propriétés. L'adresse de cette portion de mémoire allouée est passée à la méthode "new" du script CompteEnbanque et est assignée au paramètre "me". La dernière ligne de code du gestionnaire "new" de ce script parent est la ligne standard: return me. Vous devez maintenant mieux comprendre pourquoi. Lorsque la méthode "new" s'est exécutée, elle retourne l'adresse mémoire de l'instance, qui est stockée dans la variable oCompteEnbanqueA. oCompteEnBanqueA est une variable qui contient une référence vers l'instance, c'est à dire l'adresse de la mémoire allouée pour l'objet CompteEnBanque nouvellement créé. Si nous regardons la valeur de oCompteEnbanqueA dans la fenêtre de message, nous obtenons quelque chose comme:

put oCompteEnBanqueA
-- <offspring "CompteEnBanque" 2 59c6134>

Afficher une référence d'objet nous donne trois informations. D'abord la référence d'objet nous dit qu'il s'agit d'un instance du script parent "CompteEnBanque". Le mot offspring (progéniture) affiché vient du concept dans Director de script parent et d'objets enfants (child objects). Ensuite le nombre 2 est le compteur de références. Le compteur de références nous dit combien de variables pointent vers l'objet. Dans ce cas, oCompteEnbanqueA en est une, et la ligne "put" en a créé une seconde. Enfin nous trouvons l'adresse où la mémoire a été allouée pour l'objet. Dans ce cas, l'adresse de base de l'objet est 59c6134. En elle-même l'adresse mémoire d'un objet n'a aucune importance, car aucun programmeur Lingo ne travaille avec en tant que telle. Cependant le concept important à saisir ici est que la référence vers un objet contient l'adresse de base qui permet de trouver les propriétés de l'instance de l'objet.

En réalité, la référence d'objet ne contient que l'adresse de la mémoire allouée à l'instance. Les propriétés, le compteur de référence et le nom du script parent sont eux stockés à cette adresse. La commande "put" fait le travail d'habillage de ces informations sur la référence d'objet pour afficher le résultat vu.

regardons ce qui se passe si nous instancions un deuxième objet CompteEnBanque.

oCompteEnbanqueB = new(script "CompteEnBanque", "abcde", 800.00)
put oCompteEnbanqueB
-- <offspring "CompteEnBanque" 2 59c6a30>

Remarquez que la valeur de la référence d'objet est presque la même, sauf que la mémoire allouée par Director pour le deuxième objet n'est pas au même endroit que le premier. (dans cet exemple, 59c6134 et 59c6a30). Nous avons créé deux objets compte en Banque (ou plus précisément deux instances d'un objet compte en banque) et nous pouvons voir que la seule différence entre ces instances est qu'elles pointent vers des adresses mémoire différentes.

 

Retour sur l'adressage de la mémoire

Pour continuer à développer notre représentation du fonctionnement interne de Director, vous pouvez imaginer que les propriétés déclarées dans un script parent sont gérées de façon très semblable aux éléments d'une liste. Par exemple, si nous avons les lignes suivantes au début d'un script parent:

-- script parent QuelqueChose

property pPremierePropriete
property pDeuxiemePropriete
property pTroisiemePropriete

Lorsque Lingo compile ce script parent, il sait qu'il doit allouer de la mémoire pour trois propriétés. Lors de l'instanciation d'un objet à partir de ce script parent, l'allocation de mémoire pour ces trois variable sera continue. C'est à dire que Director alloue 3 adresses mémoires consécutives pour contenir ces variables. Si vous créez l'objet comme ceci:

oQuelqueChose = new(script "QuelqueChose")

Alors la mémoire allouée pour l'objet ressemblera à :

Dans ce cas, pPremierePropriete, étant la première variable, sera à zéro adresse mémoire du début de l'allocation mémoire de l'instance. En utilisant l'adressage par base plus offset comme vu précédemment, l'adresse de pPremirePropriete sera oQuelqueChose, 0. de même l'adresse de pDeuxiemePropriete sera oQuelqueChose, 1, et l'adresse de pTroisiemePropriete sera oQuelqueChose, 2. Comme nous le verrons bientôt, le fait que ces adresses soient liées à l'adresse de base est très important pour nous permettre de créer plusieurs instances d'un même objet.

Voici à quoi peut ressembler la mémoire après l'instanciation de deux objets CompteEnBanque:

 

Réunissons les morceaux

Supposons que nous puissions voir le code IML généré par le compilateur de Director. Prenons cette méthode simple de l'objet CompteEnBanque:


property pMotDePasse
property pSolde

-- ignorons la méthode new pour l'instant

on mDepot me, montantADeposer
  pSolde = pSolde + montantADeposer
end

Si vous vous rappelez de notre explication sur la variable "me", nous avons vu que "me" pointait vers l'instance courante. Pour être encore plus précis, nous pouvons dire que "me" contient l'adresse mémoire de l'instance courante ("me" est un pointeur). En effet elle pointe vers la première propriété déclarée dans le script parent. De plus, en utilisant l'adressage base plus offset, la première propriété se situe à l'adresse me, 0. La propriété suivante est à l'adresse me, 1, etc. Le script parent CompteEnBanque a deux propriétés ; pMotDePasse sera localisée à l'adresse me, 0 et pSolde à me, 1. Le code IML pour la méthode mDepot pourrait donc ressembler à:

mDepot LOAD me, 1 -- charge la valeur de pSolde
  ADD montantADeposer -- ajoute montantADeposer
  STORE me, 1 -- sauvegarde pSolde

Nous pouvons enfin pénétrer dans le coeur du problème. Voyons ce qui ce passe lorsque vous appelez la méthode mDepot des deux instances distinctes créées à partir du même script parent:

oCompteEnBanqueA.mDepot(200)

oCompteEnBanqueB.mDepot(500)

Avec ces deux lignes de code nous essayons de déposer $200 sur le compte oCompteEnBanqueA puis $500 sur oCompteEnBnqueB. Souvenez-vous qu'il n'y a en mémoire qu'une seule copie du code du script parent CompteEnBanque compilé en IML, mais que nous avons deux adresses vers les données de deux instances de cet objet CompteEnbanque.

Lorsque la première ligne est exécutée et appelle la méthode mDepot, la valeur de oCompteEnBanqueA est assignée à la variable "me", et 200 est assigné à montantADeposer. Puis le code de mDepot s'exécute. En utilisant l'adressage base plus offset pour calculer les adresses, la valeur de pSolde de oCompteEnBanqueA (1'adresse mémoire plus loin que l'adresse de base oCompteEnBanqueA) est chargée dans l'accumulateur. Puis nous y ajoutons la valeur de montantADeposer. Enfin, en utilisant le même calcul d'adresse, la nouvelle valeur est stockée dans la propriété pSolde de l'instance oCompteEnBanqueA.

Dans le deuxième appel, la valeur de oCompteEnBanqueB est assignée à la variable "me", et 800 à montantADeposer. Lors de cet appel, le même code IML est exécuté. Mais cette fois, puisque la valeur assignée à "me" est l'adresse de l'instance oCompteEnBanqueB, le résultat sera affecté à pSolde de l'instance oCompteEnBanqueB.

Qu'est ce que cela implique ? Vous voyez qu'en utilisant la programmation orientée objet, vous pouvez avoir de nombreuses instances d'un objet qui partage le même code, mais ont des données différentes (leurs propriétés). Cela fonctionne puisque à chaque appel à une méthode d'un objet, vous lui passez une référence d'objet pour spécifier à quelle instance vous envoyez le message. La référence d'objet n'est ni plus ni moins que l'adresse à laquelle on trouve la première propriété de l'objet.

Il y a une aute leçon importante à apprendre ici. Nous avons appris que l'instanciation d'un objet occupe peu de mémoire. Lorsque vous instanciez un objet, Director n'alloue la mémoire que pour y stocker une copie des propriétés. Le code des méthodes n'étant chargé qu'une seule fois, quelque soit le nombre d'instances.

 

Chapitre précédent

Table des matières

Chapitre suivant