UP | HOME

Agrégation et héritage - Polymorphisme

Table of Contents

1 La grande question : Comment construire des interfaces ou des classes ?

  • Par agrégation
    • La relation "a un"
  • Par héritage
    • La relation "est un"
  • Avantages de l'héritage : factorisation du code et polymorphisme

2 En Java

2.1 Le cas des interfaces

2.1.1 Agrégation : la brique de base

// Un entier naturel
interface Nat { 
  // - a pour valeur un int.
  int val();
  // - peut être nul ou non
  boolean estNul();
  // - a un prédécesseur (s'il est non nul)
  Nat predecesseur();
}  

// Une fabrique générique d'entiers naturels 
interface FabriqueNaturels<T> {
  // a une méthode fabriquant zéro.
  T creer();
}

2.1.2 Héritage : la puissance de la factorisation

  • Possibilité d'un héritage multiple
  • Préservation des méthodes des interfaces parentes
  • Possibilité d'une extension par agrégation
// Un entier naturel est 
// - un élément d'un semi-anneau unitaire et
// - une fabrique d'entiers naturels. 
public interface Nat extends SemiAnneauUnitaire<Nat>, FabriqueNaturels<Nat> {
  ... // Extension par agrégation
}

// Un semi-anneau unitaire est un semi-anneau, un monoide multiplicatif 
//   et est biunifère.
interface SemiAnneauUnitaire<T> 
  extends SemiAnneau<T>, MonoideMultiplicatif<T>, 
          BiUnifere<T> // Héritage multiple
{}

2.1.3 Deux particularités de l'héritage

  • Possibilité de spécialiser une méthode

    // Cas typique d'une interface possédant des fabriques (méthodes de fabrication)
    interface Nat1 {
      Nat1 creer(); // Une fabrique
      ...
    }
    interface Nat2 extends Nat1 {
      Nat2 creer(); // Une fabrique spécialisant
                    //   (et remplaçant) Nat1 creer()
      ...
    }
    
    • Covariance possible pour le type de retour
    • Invariance pour le type des arguments (la contravariance aurait été possible mais les concepteurs du langage ont préféré permettre la surcharge)
  • Possibilité d'une surcharge : méthodes ayant le même nom mais des types différents de paramètres
// Fabrique générique avec des méthodes de même nom
interface FabriqueNat<T> {
  T creer();
  T creer(T pred);
  T creer(int val);
}

2.2 Le cas des classes

2.2.1 Agrégation : la brique de base (bis)

class NatParInt implements Nat {
  // Attributs  
  private int val;
  // Constructeurs
  public NatParInt(int val){
    if(val < 0)
      throw new IllegalArgumentException("Pas de Nat à patir d'un int négatif.");
    this.val = val;
  }
  // Méthodes (accesseurs, fabriques et services)
  @Override
  public Nat zero() {
    return this.creerNatAvecValeur(0);
  }
  ...
}

2.2.2 Héritage : la puissance de la factorisation (bis)

  • Héritage simple uniquement
  • Préservation des attributs et des méthodes des classes parentes
  • Possibilité d'une extension par agrégation
// Implémentation des entiers naturels non nuls (des successeurs)
class Succ implements Nat {
  private Nat predecesseur;
  public Succ(Nat predecesseur) {
    this.predecesseur = predecesseur;
  }
  @Override
  public int val() {
    return 1 + this.predecesseur().val();
  }
}
// Variante mémorisant la valeur de l'entier naturel
class SuccMemoire extends Succ implements Nat {
  // Extension de l'état
  private int val;
  // Constructeur
  public SuccMemoire(Nat predecesseur) {
    super(predecesseur); // Appel du constructeur parent
    this.val = this.init();
  } 
  // Extension des services
  private int init() { ... }
}

2.2.3 La possibilité, certes limitée, d'un héritage multiple

A partir de Java 8, il est possible d'implémenter les méthodes déclarées dans une interface. Elles doivent alors être précédées du qualificatif default. On parle de trait en certains langages, notamment Scala, mais nous utiliserons plutôt dans ce cours le terme d'interface concrète, par analogie avec celui de classe abstraite.

interface Nat {
  default Nat somme(Nat x){
    return ...;
  }
}

Il devient possible d'obtenir l'héritage multiple par une construction simple, en suivant la méthode de définition d'une classe d'implémentation. Toute classe X se décompose en trois types :

  • une classe E réifiant l'état (c'est-à-dire représentant l'état par un objet), qui agrège les attributs, les constructeurs et les accesseurs,
  • une interface I déclarant les accesseurs et les fabriques, non implémentés, et les services, implémentés,
  • une classe de composition C, équivalente à celle initiale X,
    • agrégeant un objet de la classe E définissant l'état,
    • implémentant les constructeurs en initialisant l'état,
    • implémentant les accesseurs par délégation à l'état,
    • implémentant les fabriques en appelant les constructeurs,
    • implémentant l'interface I et ainsi héritant du code des services.

Cette construction se généralise facilement à l'héritage multiple. Supposons des classes X_j décomposées en (E_j, I_j, C_j). La classe M correspondant à l'héritage multiple des X_j se construit comme la classe de composition précédente :

  • agrégation d'objets des classes E_j pour définir l'état,
  • implémentation des constructeurs en initialisant l'état,
  • implémentation des accesseurs par délégation à l'état,
  • implémentation des fabriques en appelant les constructeurs,
  • implémentation des interfaces I_j et ainsi héritage du code des services.

Lorsque deux méthodes identiques (même nom, même type pour les arguments) sont héritées de deux interfaces, le conflit doit être levé explicitement, en redéfinissant la méthode. Pour accéder à une méthode f définie dans une interface parente P, on doit utiliser la notation P.super.f.

Enfin, les méthodes de la classe Object ne peuvent être définies dans une interface. Les concepteurs du langages Java ont fait le choix de considérer ces méthodes comme des méthodes de bas niveau, devant accéder aux attributs : elles ne peuvent être définies que dans les classes. Si on considère qu'une de ces méthodes est plutôt de haut niveau, comme equals, et doit être implémentée dans une interface, il est nécessaire de la doubler par une seconde méthode, estEgal(Object o) par exemple.

2.2.4 Une particularité très importante : la spécialisation

  • Redéfinition d'une méthode
class Succ implements Nat {
  ...
  @Override
  public int val() {
    return 1 + this.predecesseur().val();
  }
}
class SuccMemoire extends Succ implements Nat {
  ... 
  @Override
  public int val() {
    return this.val;
  }
}
  • Effet : remplacement de la méthode spécialisée par la méthode spécialisante

    Pas d'effet lors d'une conversion

SuccMemoire b = new SuccMemoire(...);
Succ a = b;
a.val() // -> accès à val dans SuccMemoire
  • Possibilité d'appeler la méthode spécialisée de la classe parente avec super
class SuccMemoire extends Succ implements Nat {
  ...
  // Extension des services
  private int init() {
    return super.val(); // Appel de la méthode spécialisée dans Succ
  }
  //

2.2.5 Deux particularités secondaires

  • Masquage des attributs

    class A {
      public int x;
    }
    class B extends A {
      public int x;
    }
    

    Les deux attributs existent dans un objet de la classe B mais celui déclaré dans B masque celui déclaré dans A.

    Comment accéder au x déclaré dans A ?

    Par conversion.

    B b = new B();
    A a = b;
    a.x // -> accès au *x* déclaré dans *A*
    
  • Possibilité d'une surcharge

    Cf. les interfaces.

3 TODO En Typescript

Exercice : étudier toutes les constructions précédentes en Typescript. Merci d'envoyer vos réponses via des "pull requests" pour les intégrer dans le dépôt.

Comparaison : similitudes et différences

  • Interfaces
    • Agrégation : idem
    • Héritage : idem
    • Deux particularités
      • Spécialisation : possible pour le type de retour (covariance), autorisée pour le type des arguments (contravariance et covariance, ce qui n'est pas sûr pour la covariance mais permet d'exprimer des usages courants en Javascript)
      • Surcharge : autorisée, l'identité d'une méthode étant donnée par son nom et le nombre de ses arguments (et non le type de ses arguments comme en Java). Cette particularité s'explique par la traduction en Javascript, qui oublie les types.
  • Classes
    • Agrégation : idem
      • Notation pratique : déclaration d'un attribut via le paramètre du constructeur

        constructor(private attribut : TypeAttribut) { ... }
        
    • Héritage : idem
    • Possibilité d'un héritage multiple (une classe et plusieurs interfaces concrètes) : possible, moyennant une astuce utilisant les capacités réflexives de Javascript (voir la documentation et le répertoire heritageMultiple).
    • Spécialisation : idem
    • Deux particularités secondaires
      • Masquage des attributs : masquage interdit, d'où une erreur à la compilation
      • Possibilité d'une surcharge : voir le cas des interfaces.

4 Le polymorphisme

4.1 Typage nominal en Java, typage structurel en Typescript

En Java, un type (objet) correspond soit à une classe, soit à une interface. On peut donc lui associer un unique nom, celui de la classe ou de l'interface. Réciproquement, à tout nom d'une classe ou d'une interface correspond un unique type. Ce nom est un nom qualifié, préfixé par le nom du paquet contenant la définition de la classe ou de l'interface. Cette forme de typage est dite nominale, du fait de cette correspondance.

En Typescript, un type est interprété par une structure d'enregistrement. Une telle structure déclare des noms avec leur type, leur visibilité et le droit d'accès :

{
  ctrl1 visi1 nom1 : type1,
  ctrl2 visi2 nom2 : (arg : Arg) => type2, // Attribut de type fonctionnel
  ...
}

Remarque 1 : nom2 aurait pu être déclaré ainsi, de manière équivalente, en l'absence de droit d'accès.

visi2 nom2(arg : Arg): type2, // Méthode

Il existe cependant une différence entre ces deux déclarations. Lorsqu'une méthode est déclarée, son code est partagé par toutes les instances de la classe. Lorsqu'un attribut de type fonctionnel est déclaré, ce n'est pas le cas.

Remarque 2 : comme en Java, la visibilité se déduit du qualificatif de visibilité utilisé dans le code. Cependant, l'interprétation de protected et de l'absence de qualificatif diffère d'un langage à l'autre. On considère un membre hérité dans une classe dérivée de la classe où apparaît la déclaration héritée du membre.

  • private : visible dans la classe de déclaration,
  • protected :
    • Java : visible dans la classe dérivée et dans toutes les sur-classes jusqu'à la classe de déclaration, et dans le paquet de la classe de déclaration,
    • Typescript : visible dans la classe dérivée et dans toutes les sur-classes jusqu'à la classe de déclaration,
  • aucun :
    • Java : visible dans le paquet,
    • Typescript : équivalent de public,
  • public : visible partout.

Remarque 3 : comme en Java, Typescript utilise des espaces de nommage pour ranger les noms. C'est l'instruction namespace qui introduit un espace de nommage. Les espaces de nommage ne sont pas liés à la visibilité, contrairement à Java. Ils ressemblent à ceux utilisés en C++.

Remarque 4 : il est possible d'interdire l'accès en écriture, en qualifiant un attribut de classe avec final en Java et readonly en Typescript. En Java, le qualificatif final devant une méthode est aussi possible. Il signifie l'impossibilité de redéfinir la méthode dans une sous-classe. Une redéfinition correspond à une modification en écriture.

Typescript utilise donc une interprétation structurelle des types. Un type en Typescript est :

  • une interface,
  • une classe,
  • une énumération,
  • une union ou une intersection de types,
  • un alias,
  • un type JSON.

Tout type est interprété comme une structure d'enregistrement. Cette forme de typage est dite structurelle, du fait de cette interprétation.

4.2 La relation de sous-typage

4.2.1 Sous-typage en Java et Typescript

En Java comme en Typescript, à tout type A, il est possible d'associer une structure d'enregistrement S. Ces structures peuvent être munies d'une relation de sous-typage, purement structurelle :

  • une structure S est sous-type d'une structure T si tout membre de T est aussi un membre de S, avec une visibilité compatible et un droit d'accès compatible.

La compatibilité entre visibilités s'exprime ainsi :

  • soit préservation des visibilités,
  • soit passage de public à protected, ou de protected à la visibilité par défaut (package) en Java, du sous-type vers le sur-type.

Ce n'est la définition ni la plus générale, ni la plus rigoureuse, comme nous allons le voir. La définition correcte théoriquement permet de diminuer la visibilité du sous-type vers le sur-type.

La compatibilité des droits d'accès n'est pas vérifiée en Typescript, ce qui n'est pas correct en théorie. En Java, elle correspond à une préservation, ce qui n'est pas la définition la plus générale. La définition correcte théoriquement permet de restreindre les droits d'accès, du sous-type vers le sur-type.

Il devient possible de définir formellement la relation de sous-typage.

Java

  • Relation de sous-typage = fermeture réflexive et transitive de la réunion des deux relations suivantes :
    • relation d'implémentation (implements)
    • relation d'héritage (extends)

Typescript

  • Relation de sous-typage entre types = image réciproque de la relation de sous-typage structurel entre structures associées

En Java comme en Typescript, si A est un sous-type de B, la structure S associée à A est un sous-type structurel de la structure T associée à B. En Java, la réciproque est fausse; en Typescript, elle est vraie.

Comme la relation de sous-typage en Typescript s'appuie sur les structures, elle est plus riche que celle en Java, qui s'appuie sur les déclarations explicites d'héritage et d'implémentation. En typescript, les déclarations d'héritage et d'implémentation impliquent seulement des vérifications de compatibilité, alors qu'en Java, elles impliquent en plus la définition de la relation de sous-typage.

Nous verrons par la suite les règles de variance qui s'appliquent aux constructeurs de types dans les deux langages. Ces règles expriment si un constructeur de types est croissant ou décroissant en ses arguments, des types, relativement à la relation de sous-typage vue comme une relation d'ordre.

4.2.2 Principe de subsomption, principe de substitution

Principe de subsomption (lire la règle ainsi : si A est un sous-type de B et e a la type A, alors e a le type B.)

 A sous-type de B    e de type A
---------------------------------
         e de type B

Le système de types utilise cette règle pour convertir implicitement des expressions d'un sous-type vers un sur-type.

Exemple montrant la différence entre les deux sortes de typage

  • Java

    interface Representable {
      String representation();
    }
    interface RepresentableBis {
      String representation();
    }
    Representable x = ...;
    RepresentableBis y = x; // Erreur !
    
  • Typescript

    interface Representable {
      representation() : string;
    }
    interface RepresentableBis {
      representation() : string;
    }
    let x : Representable = ...;
    let y : RepresentableBis = x; // Compatible !
    

Le principe de subsomption implique qu'une forme particulière du principe de substitution est valide.

Principe de substitution (lire la règle ainsi : si A est un sous-type de B et si le code C paramétré par x est bien typé sous l'hypothése que x a le type B, alors C est bien typé sous l'hypothése que x a le type A.)

  B sous-type de A   (x : A) ⊢ C bien typé
-----------------------------------------
         (x : B) ⊢ C  bien typé

Le principe de substitution est généralement respecté par Typecript et Java. Il existe cependant quelques exceptions, ce qui montre que le principe de subsomption n'est pas parfaitement appliqué. Voir le répertoire session1/principeSubstitution.

Première exception : visibilité protected (Java et Typescript)

class A {
  protected f(): void { }
  testA(x: A) {
    x.f(); // Pas d'erreur
  }
  testB(x: B) {
    // x.f();// Erreur
    (<A>x).f(); // Remède : une conversion explicite
  }
}
class B extends A {
  protected f(): void { }
}

La visibilité de f n'est pas restreinte de B à A.

Seconde exception : droit d'accès readonly (Typescript)

class A {
  p: string;
}
class B extends A {
  readonly p: string;
}
function testA(x: A) : void {
  x.p = "problème !"; 
}
function testB(x: B) : void {
  // x.p = "problème !"; // Erreur : 'p' en lecture seulement
  (<A>x).p = "problème !"; // Remède : une conversion explicite
}

Le droit d'accès à p n'est pas restreint de B à A.

4.3 La liaison tardive

  • Un appel de méthode exp.f(args)
  • A la compilation : résolution du nom f
    • Hypothèse 1: l'expression cible exp a pour type C.
    • Hypothèse 2: les arguments args ont pour type A (une liste de types).
    • Conclusion : le nom f est résolu en une déclaration d'une méthode f(B b) (avec B sur-type de A) dans la table des méthodes associée au type C.
  • A l'exécution : liaison du nom f
    • Hypothèse 1 : l'expression cible exp s'évalue en c, une référence à un objet d'une classe D sous-type de C.
    • Hypothèse 2 : les arguments args s'évaluent en a (une liste de valeurs).
    • Conclusion : le nom f est lié dans la table des méthodes de D à la méthode correspondant à la déclaration déterminée par la résolution. La méthode est appelée avec les arguments a.

      \(\Rightarrow\) Possible appel d'une méthode spécialisante

4.4 Exercice - Résolution et liaison en pratique

4.4.1 Résolution 1

class Succ ...  {
  ...
  public int val() { ... } // D1
}
class SuccMemoire extends Succ ... { 
  ...
  public int val() { ... } // D2
}
...
  public static void main(String[] args){
    SuccMemoire x = ...;
    ... x.val() ...; // 
  }
Lors de la compilation de l'appel x.val(), en quelle déclaration le nom val se résout-il ?
  • D1
  • D2

4.4.2 Liaison 1

class Succ ...  {
  ...
  public int val() { ... } // D1
}
class SuccMemoire extends Succ ... { 
  ...
  public int val() { ... } // D2
}
...
  public static void main(String[] args){
    SuccMemoire x = new SuccMemoire();
    ... x.val() ...; // 
  }
Lors de l'exécution de l'appel x.val(), à quelle méthode le nom val est-il lié ? Autrement dit, quelle est la méthode qui s'exécute ?
  • D1
  • D2

4.4.3 Résolution 2

class Succ ...  {
  ...
  public int val() { ... } // D1
}
class SuccMemoire extends Succ ... { 
  ...
  public int val() { ... } // D2
}
...
  public static void main(String[] args){
    Succ x = new SuccMemoire();
    ... x.val() ...; // 
  }
Lors de la compilation de l'appel x.val(), en quelle déclaration le nom val se résout-il ?
  • D1
  • D2

4.4.4 Liaison 2

class Succ ...  {
  ...
  public int val() { ... } // D1
}
class SuccMemoire extends Succ ... { 
  ...
  public int val() { ... } // D2
}
...
  public static void main(String[] args){
    Succ x = new SuccMemoire();
    ... x.val() ...; // 
  }
Lors de l'exécution de l'appel x.val(), à quelle méthode le nom val est-il lié ? Autrement dit, quelle est la méthode qui s'exécute ?
  • D1
  • D2

4.5 Exercice

Prévoir le résultat de l'exécution de la fonction principale de la classe session1.exercices.ResolutionLiaison.

Indication : bien distinguer la résolution de la liaison.

Author: Hervé Grall
Version history: v1: 2016-10-06; v2: 2016-10-14[+multiple inheritance]; v3: 2018-10-17[+Typescript].
Comments or questions: Send a mail.
The webpage content is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.