Projet de Programmation en Scheme
6 mai 1999

On se propose de réaliser en Scheme un système de programmation orientée objet (poo).

Un objet est une entité capable de répondre à des messages, en fonction de son état propre matérialisé par la valeur de ses "champs" (en anglais "slot" au singulier). Par exemple un bateau ("ship" en anglais) navigant à la vitesse de 30 noeuds répondra au message "accélérer de 10 noeuds" en portant sa vitesse à 40 noeuds.

Des objets pourvus des mêmes champs et des mêmes méthodes de réaction à des messages sont dits appartenir à une même classe, par exemple, la classe "ship". On dit que ces objets sont des instances de la classe.

Une des richesse de la programmation orientée objet est le concept d'héritage, qui permet à une classe d'hériter de champs ou méthodes d'une autre classe (plus générale) appelée "superclasse". Par exemple, un "destroyer" est une sorte de "ship" possédant en plus des canons et des missiles (sous forme de champs supplémentaires).

Il existe différentes manières de concevoir et d'implanter un système d'objets en LISP. Common LISP incorpore un système d'objets très riche, extrêmement efficace en temps et en espace, mais compliqué à implanter.

Le système que l'on se propose de construire en Scheme est bien plus réduit, quoique présentant les concepts essentiels de la poo. Il n'est pas forcément très efficace en espace, mais il est relativement simple à implanter, grâce à l'utilisation des fermetures pour représenter les objets.

Une fermeture est une fonction munie d'un environnement local susceptible d'évoluer avec le temps. Un objet sera représenté par une fermeture contenant les champs de l'objet (son environnement local) et encapsulant également toutes les méthodes propres à la classe de l'objet.. L'encapsulation lexicale des méthodes dans l'environnement local de l'objet leur donne un accès immédiat, en lecture ou en écriture, aux champs de l'objet; une contrepartie est que le code des méthodes est dupliqué dans toutes les instances d'une même classe, au lieu d'être partagé, d'où la consommation en espace. Dans cette conception, l'envoi d'un message à un objet revient à appliquer l'objet (une fonction) au message (un symbole) suivi de ses arguments spécifiques.
 

Des objets faits main

Munissons la classe "ship" de deux champs "speed" et "captain", et des deux méthodes "stop" et "accelerate". Voici une instance faite à la main:
(define ship1
   (let ((speed 30)                    ; environnement local des champs
         (captain 'Nemo))              ;  de l'objet "ship1".
      (lambda (message . arguments)    ; l'objet "ship1" lui-même.
         (case message                 ; décodage du message.
            ('stop  (set! speed 0))
            ('accelerate (set! speed (+ (car arguments) speed)))
            (else (error "Undefined method!"))))))
Cn notera que la méthode "stop" n'utilise pas le paramètre "arguments", alors que "accelerate" utilise le premier élément de "arguments", et ignore les autres, mais la référence "(car arguments)" n'est pas très parlante: on préfèrerait appeler "delta" cet argument qui représente un incrément pour la vitesse. Néanmoins, on peut déjà utiliser cet objet, e.g.:
(ship1 'accelerate 10)                 ; accélérer de 10 noeuds!
mais on n'a aucun moyen de contrôler que la vitesse de ship1 est bien passée à 40 noeuds.

Voici un second bateau fait main mais plus perfectionné, pour remédier à ces deux défauts:

(define ship2
  (let ((speed 20)                     ; environnement local des champs
        (captain 'Haddock))            ;  de l'objet "ship2".
    ;;
    ;; Environnement de méthodes:
    (let ((stop (lambda () (set! speed 0)))
          (accelerate (lambda (delta) (set! speed (+ delta speed)))))
      (lambda (message . arguments)    ; l'objet "ship2" lui-même.
        (case message
          ('speed speed)
          ('captain captain)
          ('stop (apply stop arguments))
          ('accelerate (apply accelerate arguments))
          (else (error "Undefined method!")))))))
On peut en effet consulter la vitesse et le capitaine de l'extérieur de l'objet, car ce sont des messages définis par des méthodes:
;;; (ship2 'speed)
;;; (ship2 'captain)
;;; (ship2 'accelerate 10)
;;; (ship2 'speed)
Ces exemples sont traités dans le fichier  hand_made_objects.scm  que l'on pourra tester sous Scheme.

Faire des objets à la main s'avère peu pratique, car il faut répéter le code des méthodes pour chaque instance, et l'on voudrait disposer d'un constructeur qui nous affranchisse de ce travail.

Un constructeur d'objets

La fonction "make_ship" ci-dessous permet de construire des instances de la classe "ship" en spécifiant ou non des valeurs initiales pour les champs "speed" et "captain". Les valeurs par défaut sont respectivement 30 et Nemo.
;;;
;;; Un constructeur de bateaux ordinaires:
;;;
(define (make_ship . init_plist)
  (let ((speed (or (get 'speed init_plist) 30))
        (captain (or (get 'captain init_plist) 'Nemo)))
    (letrec ((stop (lambda () (set! speed 0)))
             (accelerate (lambda (delta) (self 'speed (+ delta speed))))
             (self
               (lambda (message . arguments)
                 (case message
                   ('speed (if (null? arguments)
                               speed
                               (set! speed (car arguments))))
                   ('captain (if (null? arguments)
                                 captain
                                 (set! captain (car arguments))))
                   ('stop  (apply stop arguments))
                   ('accelerate (apply accelerate arguments))
                   (else (error (string-append "Method "
                                               (symbol->string message)
                                               " for class ship is undefined!")))))))
       self)))
Notons que le "letrec" établit un environnement fonctionnel qui définit non seulement les deux méthodes "stop" et "accelerate", mais aussi la fonction "self" représentant l'objet lui-même (le décodeur du message): ceci permet à chaque méthode traitant l'envoi d'un message d'envoyer un (autre) message à l'objet lui-même, ce qui est ici le cas pour la méthode "accelerate". Les méthodes "speed" et "captain" ne sont pas déclarées dans le "letrec", pour ne pas cacher les slots "speed" et "captain": ces deux méthodes permettent de consulter ou modifier les champs correspondants:
;;; (define ship3 (make_ship 'speed 99))   ; fabrication d'un bateau,
;;; (ship3 'speed)                         ; consultation de la vitesse,
;;; (ship3 'captain)

;;; (ship3 'speed 112)                     ; modification de la vitesse.
;;; (ship3 'speed)

;;; (ship3 'accelerate 11)
;;; (ship3 'speed)

Voici maintenant un constructeur de "destroyer": la classe "destroyer" hérite de la classe "ship" en ajoutant les champs "missiles" et "guns" , la méthode "decelerate", et en adaptant la méthode "accelerate":
;;;
;;; Un destroyer est une sorte de bateau:
;;;
(define (make_destroyer . init_plist)
  (let ((super (apply make_ship init_plist))
        (missiles (or (get 'missiles init_plist) *no_default*))
        (guns (or (get 'guns init_plist) 105)))
    (letrec ((accelerate (lambda (delta)
                           (super 'accelerate (+ delta delta))))
             (decelerate (lambda (delta)
                           (self 'accelerate (- delta))))
             (self
               (lambda (message . arguments)
                 (case message
                   ('missiles (if (null? arguments)
                                  missiles
                                  (set! missiles (car arguments))))
                   ('guns (if (null? arguments)
                          guns
                          (set! guns (car arguments))))
                   ('accelerate (apply accelerate arguments))
                   ('decelerate (apply decelerate arguments))
                   (else (apply super message arguments))))))
       self)))
L'héritage se manifeste dans le constructeur par la présence du champ "super" dont la valeur est initialisée à une instance de "ship". Le décodeur de messages délègue d'ailleurs tout message incompris à cette instance (clause "else" du "case"). Ce champ "super" permettre en outre à la classe "destroyer" d'adapter la méthode "accelerate" héritée de la classe "ship": l'incrément "delta" est multiplié par 2.

On pourra tester bateaux et destroyers en chargeant le fichier ship_factory.scm dans l'environnement de Scheme.
 

La macro defclass

L'écriture de constructeurs tels que "make_ship" et "make_destroyer" associés aux classes "ship" et "destroyer" reste pénible et ces constructeurs sont peu lisibles. On désire faciliter la définition de classes en ajoutant au langage Scheme la construction "defclass" définie comme une macro.  C'est là le coeur du projet!

Les deux "defclass" qui suivent auront ainsi pour effet de définir les constructeurs "make_ship" et "make_destroyer" précédents:

(defclass ship                   ; ship class has no superclass.
      (slots (speed 30)
             (captain 'Nemo))
      (methods (defmethod (stop) (set! speed 0))
               (defmethod (accelerate delta)
                 (self 'speed (+ delta speed)))))

(defclass (destroyer ship)       ; destroyer is a kind of ship.
      (slots missiles            ; no default provided.
            (guns 105))
      (methods (defmethod (accelerate delta)
                 (super 'accelerate (+ delta delta)))
               (defmethod (decelerate delta)
                 (self 'accelerate (- delta)))))

Le squelette de la macro "defclass" a la forme suivante:
(define-macro (defclass class_or_class_super slot_spec method_spec)
   ???)
avec les paramètres suivants: Le fichier defclass.scm est à modifier et compléter: il contient en particulier le squelette de la macro "defclass", les définitions de la fonction "get" et de la variable globale "*no_default*".  Il est demandé d'écrire la macro "defclass" de facon à respecter scrupuleusement les expansions suggérées par les exemples "ship" et "destroyer". Le fichier defclass_test.scm vous permettra de tester votre macro.

Réflexivité

Un logiciel est réflexif s'il est capable de s'appliquer à lui-même. En poo, cela signifie que les classes soient elle-mêmes concues comme des objets, susceptibles de recevoir des messages et d'y répondre. On inventera une version réflexive de "defclass", appelée "defclassr", également une macro, avec la même syntaxe que "defclass" et le même effet, reflétant en outre cette propriété. Voici quelques idées et lignes directrices: En supposant que les classes "ship" et "destroyer" soient définies comme auparavant, voici un exemple de session possible:
guile> (ship 'name)
ship

guile> (ship 'superclass)
#f

guile> (ship 'slots)
((speed 30) (captain 'Nemo))

guile> (ship 'methods)
((defmethod (accelerate delta)
              (super 'accelerate (+ delta delta)))
 (defmethod (decelerate delta)
              (self 'accelerate (- delta)))))

guile> (destroyer 'superclass)
ship

guile> (destroyer 'slots)
(missiles (guns 105))

guile> (class 'slots)
(name (superclass ()) slots methods)

guile> (class 'methods)
((defmethod (make . init_plist) ???)
  ...)

Technique: La construction "block" de Scheme peut être utilisée pour définir une macro engendrant une expansion contenant plusieurs formes LISP, devant chacune un effet sur l'environnement global. Par exemple:
guile> (block (define (make_ship . init_plist) ...)
              (define ship ...))
a le même effet sur l'environnement global que
guile> (define (make_ship . init_plist) ...)
guile> (define ship ...)
Raffinement: Etant donné un objet quelconque, comment peut-on retrouver sa classe? Pour permettre à tout objet de répondre par son nom de classe au message "class", on peut créer une classe standard de nom "standard_class" dont hérite toute autre classe, directement ou indirectement. Ainsi la classe "ship" hériterait par défaut de cette classe standard, munie de la méthode "class".

A rendre

On remettra un petit rapport de 4 à 5 pages sur le projet, de préférence au format "html", et des fichiers Scheme dont au moins "defclass.scm" et "defclassr.scm". Chaque document rendu devra indiquer en tête les noms des auteurs.

Critères d'évaluation

On évaluera la qualité du logiciel et en particulier son adéquation avec la spécification, sa fiabilité, sa modularité, la clarté des fonctions et macros définies, et des commentaires les accompagnant, le choix des noms des divers paramètres, le bon usage du style fonctionnel de l'implantation. Le rapport ne devra pas répéter le sujet ou le logiciel, mais mettre l'accent sur la conception, les points délicats, les difficultés éventuelles.

Suivi du projet

On pourra s'adresser à Paul Y Gloess (Paul.Gloess@ENSERB.U-Bordeaux.Fr) pour tout problème relatif au projet: les réponses, incluant vos messages, seront susceptibles d'être diffusées à l'ensemble de la promotion.

Fichiers annexes (récapitulatif)

Dans le répertoire ~gloess/scheme/project on trouve les fichiers: © Copyright 1999 Paul Y Gloess