Chapitre 4

Les Classes

Introduction

Dans le chapitre précédent nous avons expliqué ce que sont les objets. Ceux-ci étant maintenant bien compris, il est facile d’expliquer ce que sont les classes.

Dans une première partie, nous présentons d’abord la définition formelle des classes en Objective Caml.

Dans une seconde partie, nous insistons d’abord sur trois points essentiels : avant tout, il faut distinguer la notion de sous-classe de celle de sous-type ; nous montrons qu’une classe se comporte comme une collection de pré-méthodes, i.e. de fonctions dans lesquelles self n’est pas lié, et l’état interne n’est pas caché ; enfin, l’héritage simple ou multiple, peut être vu comme la concaténation de ces pré-méthodes.

4.1 Les classes en Objective Caml

Dans cette partie nous définissons formellement les classes du langage Objective Caml, leur syntaxe, leur typage, puis nous donnons leur sémantique. Comme son titre l’indique, il ne suppose pas de connaissance des langages à objets.

4.1.1 Syntaxe des classes

Le corps des classes est une séquence b de petites définitions e. La syntaxe des classes est donnée ci-dessous

a ::= … | class (x) b | new a | s#m  Expressions
z ::= x | c
b ::= ∅ | e b  Class body

     e ::= inherit a a as x | let x = a | val x = a | method m = a

Chaque définition à pour effet d’ajouter une ou plusieurs liaisons de variables d’instance et de méthodes. Les définitions les plus à droite peuvent cacher des définitions antérieures.

Les liaisons let permettent de partager des valeurs ou des calculs, en particulier pour l’initialisation des variables d’instance en présence d’effets de bord. Techniquement, elles facilitent la définition de la sémantique des classes.

4.1.2 Typage

Les schémas de types sont étendus avec des types de classe. Le type d’une classe, est composé du type τ du paramètre d’initialisation (pour simplifier on suppose que les classes ne sont pas currifiées), du type généralement noté τ_{y} de self, des types τ^{X} de ses variables d’instance et des types τ^{M} de ses méthodes.

                      γ ::= class (τ) : τ val τ^{X} method τ^{M}

Pour simplifier, nous omettons la construction de dupplication ainsi que l’envoi de message à une classe parente. Nous considérons également self comme un mot clé. Pendant le typage d’une classe, les jugements de typage sont de la forme A, B ⊢ b : φ où φ est une séquence de liaisons de la forme val x : τ ou method m : τ. L’environnement A est utilisé pour typer les variables d’instance et l’héritage, alors que l’environnement B est utilisé pour typer les méthodes. L’environnement A représente l’environnement pendant l’initialisation de l’objet, et B l’environnement pendant l’invocation d’une méthode.

Les règles de typage sont données dans la figure 4.1. Rappelons que ⊕ étend une séquence avec priorité à dorite, alors que + impose que les deux séquences soient compatibles (même valeur sur leur domaines communs). Les classes sont typées par ajout successif de petites définitions (classes hérités, variables d’instance ou méthodes) avec priorité à droite.

Quand une méthode est redéfinie, son type doit être le même que celui de l’ancienne méthode, ce qui est exprimé par l’opérateur + entre les types des méthodes. Bien sûr, une sous-classe peut utiliser des versions spécialisées de ses classes parentes en instantiant certaines variables.

La règle Inherits montre que le type de self dans une classe est le même que celui de la classe héritée. Ce qui garantit que les méthodes qui retournent self sont typées correctement, mais ne restreint pas la puissance, car le type de la classe héritée est une instance du type de la classe parente.

    let copy = Oo.copy;;
    class copy () as s = method copy = copy s end;;

Dans cette classse, 'a est lié au type de self, c’est-à-dire que les objets de cette classe ont le type ⟨copy : 'a⟩ as 'a (avec la même variable 'a). La classe copy peut maintenant être héritée. La méthode copy a bien le type attendu.

(Basic)

       A, B ⊢ ∅ : val ∅ method ∅

(Inherit)

\frac{B ⊢ self : τ_{y}  A ⊢ c : class (τ) : τ_{y} φ_{1} A ⊢ a : τ  A, B ⊕ φ|` val⊢ b : φ}{A, B ⊢ inherit c a as s b : φ + φ_{1}}

(Method)

\frac{B ⊢ self : τ_{y}  B ⊢ a : τ  A, B ⊢ b : φ}{A, B ⊢ method m = a b : φ+ method m : τ}

(Val)

\frac{A ⊢ a : τ A, B ⊕ (x : τ) ⊢ b : φ}{A, B ⊢ val x = a b : φ+ val x : τ}

\frac{A ⊢ a : τ A ⊕ (x : τ), B ⊢ b : φ}{A, B ⊢ let x = a b : φ}^{(Let)}

(Class)

\frac{A ⊕ (x : τ), A ⊕ (self : τ_{y}) ⊢ b : φ τ_{y} = ⟨φ|` method; τ′′⟩ A ⊕ (c : Gen(A, class (τ) : τ_{y} φ)) ⊢ d : τ′}{A ⊢ let c = class (x) b in d : τ′}

(Rec)

\frac{A ⊕ (new c : τ_{0}) ⊢ let c = class (x) b in ((new c : τ_{0}); d) : τ′}{A ⊢ let c = class (x) b in d : τ′}

(New)

\frac{A ⊢ c : class (τ_{0}) : ⟨φ|` method⟩φ}{A ⊢ new c : τ_{0} → ⟨τ^{M}⟩}

Figure 4.1 : Règles de typage des classes

    class point x as self =
      val x = x
      method getx = x
      method print = print int self#getx
    end;;
    class copy point x = inherit copy () inherit point x end;;

Pendant l’héritage, les parents sont ordonnés, ce qui évite toute ambiguïté : la définition finale de la méthode est celle qui est héritée par le parent qui apparaît le plus tard dans la liste.

La règle Rec permet à l’expression new c d’apparaître dans la définition de la classe c. Autrement la règle New est utilisée. La généralisation des types de classe est similaire à celle des autres types.

La prise en compte de la dupplication et de la liaison des méthodes définies dans des classes parentes ne posent pas de problème réel, mais elle complique l’écriture des règles de typage. Dans le typage des corps des classes, il faut notament séparé le contexte B en deux parties B et C. Les variables d’instances sont ajoutée dans C. Les méthodes définies dans les classes parentes sont ajoutées dans B. Les méthodes sont alors typées dans le contexte B ⊕ C ⊕ ({⟨C⟩}^{∗} : τ_{y}).

(Inherit)

\frac{B ⊢ self : τ_{y}  A ⊢ c : class (τ) : τ_{y} φ_{1} A ⊢ a : τ  A, B ⊕ s#τ_{1}^{M}, C ⊕ τ_{1}^{X} ⊢ b : φ}{A, B, C ⊢ inherit c a as s b : φ + φ_{1}}

(Val)

\frac{A ⊢ a : τ A, B, C ⊕ (x : τ) ⊢ b : φ}{A, B, C ⊢ val x = a b : φ+ val x : τ}

(Method)

\frac{B ⊢ self : τ_{y}  B ⊕ C ⊕ (\{⟨C⟩\}^{∗} : τ_{y}) ⊢ a : τ  A, B, C ⊢ b : φ}{A, B, C ⊢ method m = a b : φ+ method m : τ}

Les autres règles sont modifiées de façon cohérente.

4.1.3 Sémantique

L’évaluation des classes consiste simplement à résoudre l’héritage. Pour cela, il faut évaluer la concaténation des différentes composantes (avec priorité à droite) dans les définitions de classes. Il y a trois difficultés à surmonter : impérative :

• Il faut réordonner les champs (et en éliminer certains) car les objets exigent que les variables d’instance apparaissent en premier. Il faut éviter d’inverser l’ordre d’évaluation.

• Il faut éviter de dupliquer le calcul d’initialisation des variables d’instance.

• Il faut lier “super” méthodes définie dans les classes parentes.

Ces difficultés sont facilement résolues grâce à la liaison dans les définitions de classe.

La création d’un objet à partir d’une classe, consiste simplement à évaluer les liaisons, ce qui initialise les variables d’instance, puis à fabriquer un objet avec ces variables d’instance et les méthodes de la classe.

La définition formelle de l’évaluation est donnée dans la figure 4.2.

4.1.4 Oublis

Les variables d’instance mutables ne posent pas de difficulté particulière. Elle sont typées comme les variables non mutables. La modification en place des objets est typée par ajout de primitives, de façon similaire à la modification en style applicatif. La sémantique peut être décrite dans le formalisme décrivant les références.

La sécurité des constructions impératives est garantie par la restriction que seule les valeurs peuvent être polymorphes. En particulier, même si les classes sont paramétriques, les Contextes d’évaluation

E ::= … | let z = E in d
| class (x) E | w; E

Réduction des définitions de classe

val x = a w ⟶ let x = a val x = x w (a ∉ X)
val x′ = x′′ let x = v w ⟶ let x = v val x′ = x′′ w (x′ ≠ x)

      method m = a let x = v w ⟶ let x = v method m = a w
     inherit (class (x) let a_{1}^{X} val a_{2}^{X} method a^{M}) a as s w

⟶ let x = a let a_{1}^{X} val a_{2}^{X}
method a^{M} w[a^{M}(m)/s#m]^{m∈dom (a^{M})}

     new (class (x) let a_{1}^{X} val x_{2}^{X} method a^{M}
                             ⟶ fun (x) let a^{X} in ⟨ val x_{2}^{X} method a^{M}⟩

Figure 4.2 : Sémantique des classes

objets ne sont jamais polymorphes. En effet, la création d’une classe n’est pas une valeur ; elle est donc monomorphe. D’autre part, les objets produits par duplication ont le même type que leur original.

4.2 L’héritage n’est pas du sous-typage

Avant d’étudier plus en détail le mécanisme d’héritage, il est important de se convaincre que les deux concepts n’ont rien à voir.

L’héritage est la possibilité de construire des classes à partir d’autres classes en réutilisant globalement toutes leurs méthodes. C’est donc avant tout une propriété syntaxique. Le soustypage est la possibilité pour un objet d’un certain type d’être considéré et donc utilisé comme un objet d’un autre type. C’est une propriété sémantique.

Nous avons montré dans la première partie du chapitre précédent que les points colorés construits à partir de points par héritage ne peuvent pas être considérés comme des points, si les points comportent une méthode testant l’égalité de deux points. Autrement dit, le type des points colorés n’est pas un sous-type des points. Cet exemple montre que l’héritage n’entraîne pas le sous-typage des types des objets des classes considérés.

Inversement, si les points colorés (sans égalité) sont aussi des points, il s’agit d’une propriété des objets qui est indépendante de la façon dont les points colorés ont été créés. On peut, bien entendu redéfinir sans hérités des points :

    class point coloré x c =
      val mutable x = (x:int)

      method getx = x
      val c = (c : string)
      method getc = c
    end;;
    let point coloré = new point coloré 1 "blanc−cassé";;
    (point coloré :⟩ point);;

On peut aussi coercer le type d’un objet vers un type qui ne correspond pas à une définition de classe.

    type pauvre point = ⟨ ⟩;;
    (point coloré :⟩ pauvre point);;

On pourrait aussi considérer deux implémentations indépendante des points dans le plan, fonctionnant en coordoonées cartésiennes pour l’une et en coordonnées polaires pour l’autre. Les deux classes devront fournir la même interface, c’est à dire que les types des objets de ces classes sont identiques donc a fortiori dans une relation de sous-typage. Pourtant les classes ne sont pas dans une relation de sous-classage. Identifier sous-classage et soustypage interdirait cette abstraction de la représentation d’une classe. Ou forcerait à définir artificiellement une classe abstraite ayant la même interface, mais aucune implémentation dont hériteraient à la fois les deux implémentations des points.

Retenons que :

Le sous-typage n’a rien à voir avec l’héritage

La notion d’héritage est plus proche d’une notion de filtrage sur les types associés. La déclaration de la classe point coloré ci-dessus produit la déclaration automatique de type équivalente à :

       type 'r #point coloré = ⟨getx : int; getc : string; 'r⟩

En fait la variable de rangée est un paramètre implicite et elle est remplacée par une ellipse dans le type : on écrit simplement #point coloré pour signifier ⟨getx : int; getc : string; ..⟩. La #-abréviation d’une classe filtre tous le type des objets de tous les sous-classes. Cette propriété est immédiate : elle résulte du fait qu’une sous-classe ne peut pas caché une méthode définie dans une classe parente, ni changer son type, mais seulement le préciser.

Il n’y a pas de règles sans exceptions. Nous devons donc parler des nombreux langages qui pour simplifier identifient la notions d’héritage et de sous-typage. Ils sont de deux sortes.

• Certains comme Eiffel, O2 font cette identification au dépend de la sécurité. Ils permettent de considérer des points colorés avec égalité comme des points avec égalité. Ce qui bien entendu conduit à des erreurs à l’exécution. En général, les versions plus récentes de ces langages insèrent maintenant des tests dynamiques lorsque ces coercions dangereuses sont effectuées afin de rattraper les erreurs plus proprement.

• D’autres langages comme Java contourne le problème en affaiblissant les règles de typage. Il suffit d’interdire le type de self d’apparaître dans le type d’une méthode. Par exemple la méthode d’égalité aura le type point → point dans la classe point et conservera ce type lorsqu’elle sera héritée dans la classe des points colorés.

Cet affaiblissement du système de typage force l’utilisateur à effectuer des coercions explicites qui peuvent échouées. On n’est donc pas très loin de l’approche ci-dessus, sinon que l’échec provient d’une coercion mise par l’utilisateur. Elle est donc prévisible.

Le traitement de C++ est très similaire. La différence est qu’en C++ l’utilisateur est encouragé à effectuer une coercion sans test, c’est-à-dire forcer le type à la main avec un cast, ce qui peut avoir des conséquences plus graves.

4.3 Pré-méthodes

Le but de cette section est de montrer que les classes ne sont au fond que de l’assemblage des méthodes abstraites par rapport à l’objet self dans lequel elle seront placées.

Que ce soit dans la simulation des classes par des enregistrements extensibles dans le langage Caml, ou bien le typage des classes dans Objective Caml, les méthodes sont définies dans les classes comme des fonctions, abstraites par rapport aux variables d’instance et par rapport à self. Nous les appelons pré-méthodes.

Les pré-méthodes doivent pouvoir être utilisées comme méthodes des objets de la classe où elles sont définies (si cette classe n’est pas virtuelle), mais aussi comme méthodes des objets de ses sous-classes.

Rappelons la règle de typage de la méthode m dans une classe :

(Method)

\frac{B ⊢ self : τ_{y}  B ⊕ C ⊢ a : τ  A, B, C ⊢ b : val τ^{X} method τ^{M}}{A, B, C ⊢ method m = a b : val τ^{X} method τ^{M} + m : τ}

On pourrait dire que l’on type la pré-méthode fun (self : τ_{y}) fun (C) a

(Method)

\frac{B ⊢ self : τ_{y}  B ⊢ fun (self : τ_{y}) fun (C) a : self → C → τ  A, B, C ⊢ b : val τ^{X} method τ^{M}}{A, B, C ⊢ method m = a b : val τ^{X} method τ^{M} + m : τ}

4.4 Liaison tardive et typage

La liaison tardive implique que dans une classe la valeur liée à self peut-être tout objet d’une sous-classe. En particulier, les méthodes qui dépendent de self doivent fonctionner aussi bien dans les classes que dans les sous-classes.

Cela est obtenu en donnant à self un type ouvert dans la définition d’une classe. Les préméthodes sont donc paramétrique par rapport à la valeur réelle de self. Le type dans une pré-méthode contient toutes les contraintes qui lui sont imposées dans cette pré-méthode.

Le type de self dans est unifié dans toutes les pré-méthodes d’une classe. Il contient alors toutes les contraintes imposées à self dans cette classe.

Une sous-classe ne relâchera jamais des contraintes imposés à self dans une classe parente, car cela invaliderait le typage. En revanche, elle peuvent le contraindre d’avantage.

Une pré-méthode définie dans une classe parente et hérité est spécialisée par instantiation de type pour satisfaire les nouvelles contraintes imposées à self.

C’est donc le mécanisme général du polymorphisme de ML, et le polymorphisme des variables de rangées qui assure la sécurité du typage de la liaison tardive.

Le raisonnement ci-dessus reste valide pour les pré-méthodes binaires.

La liaison tardive est permise et sécurisée par le polymorphsime de ML sur les variables de rangée des types objets

Dans une classe, le type de self est normalement ouvert

Il peut arrivé que le type de self soit fermé.

• Volontairement : Cela interdit d’ajouter ultérieurement des méthodes par héritage. Mais n’a guère d’intérêt.

• Involontairement : parce que self est mélangé avec un objet déjà créé.

4.5 Création des objets

Une classe est une collection de fonctions pour d’initialisation des variables d’instance, et une collection de pré-méthodes.

La création d’un objet à partir d’une classe revient à grouper

• créer l’état interne,

• créer l’enregistrement des méthodes à partir des pré-méthodes, en donnant à self sa vraie valeur.

À la création des objets à partir des classes, le type objet est fermé. Cela revient à dire que les seules méthodes de l’objet sont celles couramment définies. S’il reste des méthodes virtuelles (des méthodes requises mais non définies, la classe est virtuelle).

4.6 Héritage

4.6.1 Héritage simple

L’héritage est simplement un assemblage des composantes de classes. variables d’instance et pré-méthodes, tout en gardant un contrôle précis sur la portée des variables. L’héritage simple est un ajout de composantes une par une. Il repose sur la concaténation des enregistrements. L’héritage multiple repose sur la concaténation des enregistrements. On peut ajouter les composantes en blocs.

4.6.2 Héritage multiple

Rappelons que l’héritage multiple consiste à construire une classe à partir de plusieurs classes parentes. La construction d’héritage en Objective Caml est naturellement multiple. L’héritage multiple permet surtout d’ajouter à une classe existante les fonctionnalité d’une autre classe a posteriori. Sans l’héritage multiple, il faudrait ou bien dupliquer certaines opérations ou bien les insérer à la racine commune aux branches qui doivent être étendues. Par exemple, on peut construire la hiérarchie suivante (figure de gauche) :

Héritage multiple

I A  B  J C  D  BJ DJ

Héritage simple

IJ AJ  BJ CJ  DJ

L’intérêt est que seule les classes B et D peuvent hériter de la classe J. Pour conserver le partage (écrire une seule fois les composantes de J) avec seulement de l’héritage simple, il est nécessaire d’introduire J très haut dans la hiérarchie (figure de droite). Il en résulte que la classe J est hérité dans toute la hiérarchie, ce qui peut être dangereux, ou créer des conflits.

De façon plus générale, l’héritage multiple permet d’organiser les classes de façon plus proche de leur dépendances réelles. Ceci accroît la sécurité bien entendu, mais renforce également tous les aspects de la modularité. Dans l’exemple ci-dessus, l’héritage multiple permet d’exprimer que BJ et DJ sont les seules classes à dépendre de J, alors que la version avec héritage simple force une dépendance artificielle de les classes en dessous de la racine commune à B et D.

4.7 Exemple

La difficulté dans la programmation avec des objets est souvent l’organisation des classes. Quelle sorte de modularité recherche-t-on ?

Pour montrer que ce problème n’est pas toujours simple, considérons l’exemple suivant. Imaginons que l’on manipule des figures géométriques. En commençant par des points, des segments, etc. et que l’on veuille introduire les cercles.

Qu’est-ce qu’un cercle ? Il semble naturelle de représenter un cercle par un point — son centre, et un rayon :

    class circle p r =
      val c = (p : point)
      val r = (r : int)
      method center = c
      method radius = r
      method move dv = p#move
    end;;

On s’aperçoit qu’un objet cercle comporte un certain nombre de méthodes telles que move sont identiques à celles des points ; de plus l’objet fait simplement suivre ces méthodes vers son centre. La définitions alternative suivante semble donc aussi raisonable :

    class circle x r =
      inherits point x
      method center = (x :⟩ point)
      val r = r
      method radius = r
    end;;

Quelles sont les différences entre les deux définitions ? L’une est-elle préférable ? Il n’est pas évident de répondre à ces questions. D’abord, l’interface d’un objet cercle sera dans les deux cas la même ; il va de soit que l’on attend aussi un comportement identique.

La seconde définition évite de redéfinir les méthodes des points dans les cercles. D’autre l’ajout d’une méthode aux points est immédiatement répercutée vers les cercles. Enfin elle permet de considérer un cercle comme un point, et donc par exemple de définir un segment à partir de deux cercles comme le segment reliant leur centre. En bref, cette deuxième solution semble préférable.

Pourtant, elle ne permet pas de créer un point à partir d’un point déjà créé. Imaginons qu’un point soit partager par plusieurs objets géométriques et que l’on veuille maintenant créer un cercle autour de ce point. Avec la seconde définition, il faudra créer un nouveau point-cercle, c’est-à-dire que le cercle ne pourra pas partager le point existant. Ceci est embêtant si ce point est bougé physiquement, le cercle restera là où il était.

Il n’y a donc pas de meilleure solution. Il s’agit simplement de bine comprendre ce que l’on veut.

4.8 Conclusion

Il faut retenir que les classes sont essentiellement un ensemble de déclaration toplevel permettant de construire des objets ou bien d’être ultérieurement étendues avec d’autres déclarations.

Dans cette approche les classes sont essentiellement une collection de pré-méthodes, c’està-dire des méthodes abstraite par rapport à l’objet self dans lequel elle apparaîtront ainsi que par rapport aux variables d’instance de cet objet.

Les règles de typage des classes dans Objective Caml, font également apparaître une méticuleuse gestion de la visibilité des différentes liaisons. C’est aussi un des aspects important de la notion de classe.

Il faut retenir que la vision de self dans une classe est une vision ouverte, puisque nous ne connaissons alors qu’un sous-ensemble des méthodes de self. Dans Objective Caml cette ouverture est exprimé par un type objet ouvert, donc comportant une variable de rangée polymorphe.

Enfin, il est essentiel de distinguer la notion de sous-type et celle de sous-classe.