ENPC - Programmation - Séance 5

L'égalité entre anneaux: première approche

De la même manière que nous avons redéfini la méthode surface() dans la classe Anneau afin qu'elle donne le résultat attendu, nous voulons maintenant redéfinir la méthode same(), qui existait dans la classe Disque, afin qu'elle permette de tester l'égalité de deux anneaux (même centre, même rayon et même rayon interne).

Exercice 1: Redéfinissez la méthode same() dans Anneau.

Testez alors cette méthode sur le code suivant:

  Point p = new Point();
  Disque d1 = new Disque(p, 1);
  Disque d2 = new Disque(p, 1);
  Anneau a1 = new Anneau(p, 1, 2);
  Anneau a2 = new Anneau(p, 1, 2);
  System.out.println(d1.same(d2));
  System.out.println(a1.same(a2));
  System.out.println(d1.same(a1));
  System.out.println(a1.same(d1));
Expliquez les différents comportements constatés par le mécanisme de résolution de méthode appliqué successivement par le compilateur puis par la machine virtuelle Java (JVM).

Héritage implicite de Object

L'héritage est transitif: si une classe B hérite de A et que C hérite de B, alors la classe C hérite via B de toutes les méthodes et tous les champs de la classe A. Ainsi, comme toute classe hérite implicitement de la classe Object, toute classe dispose par héritage de toutes les méthodes définies dans la classe Object. Recherchez ces méthodes dans la documentation des API. Toutes ses méthodes sont donc héritées dans les classes Disque et Anneau, entre autres.

Exercice 2: Vérifiez que les classes Disque et Anneau possèdent bien, par héritage, la méthode toString() définie dans la classe Object. Pour cela, mettez en commentaire les méthodes toString() que vous avez pu définir dans les classes Disque et Anneau, et faites afficher le résultat de l'appel à la méthode toString() sur des références à des anneaux et à des disques.

L'égalité entre objets: une meilleure approche

Sachant que toute référence à une instance d'une classe peut être stockée dans une variable déclarée d'un super-type, on peut donc mettre n'importe quelle référence à n'importe quel instance de n'importe quelle classe dans une variable déclarée du type Object.
En utilisant les méthodes same() précédement définies, tester le code suivant:

  Point p = new Point();
  Disque d1 = new Disque(p, 1);
  Disque d2 = new Disque(p, 1);
  Object o = d;   // les variables o et d contienent la même référence 
  System.out.println(d1.same(d2));
  System.out.println(o.same(d2));

Expliquez le comportement constaté.

Si la situation suivante est possible :

Disque d = new Anneau(...);
celle-ci ne l'est pas :
Anneau a = new Disque(...);
car dans notre conception des anneaux et des disques les anneaux sont des disques (au niveau typage). Le code suivant n'est donc pas correct (il ne compile pas):
Disque d = new Anneau(...);
Anneau a = d;
Pourtant, il a un sens : le programmeur sait que la référence d, de type Disque, désigne un anneau, et il souhaite que la référence a désigne cet anneau. Le programmeur peut forcer le compilateur a accepter cette assignation par :
Anneau a = (Anneau)d;
Il indique alors au compilateur qu'il sait que d désigne un anneau. Cette opération (Anneau)d se nomme cast ou transtypage. Si au moment de l'exécution du programme, la machine virtuelle se rend compte que d ne désigne pas un anneau alors elle génère une exception de type ClassCastException.

Avant de faire un transtypage, le programmeur peut s'assurer qu'une référence désigne bien un objet du type qu'il souhaite en utilisant instanceof:

if (d instanceof Anneau)
  ...
else
  ...
Bien entendu, il est inutile de faire ce test si le programmeur est sûr que d désigne bien un anneau.

Exercice 3: Comme vous avez dû le constater, la classe Object définit une méthode equals(Object). Redéfinissez cette méthode dans les classes Disque et Anneau et testez les.

Exercice 4: Écrire un programme qui déclare une variable o de type Object et qui lui affecte soit une instance de la classe Anneau, soit une instance de la classe Disque. Pour cela, on effectue un tirage aléatoire avec la méthode statique Math.random() (qui renvoie un double entre 0 et 1) et on choisira suivant si ce tirage est inférieur ou supérieur à 0,5. Une fois la variable o affectée, on affichera un message qui indique si o est un anneau ou non.

Interfaces

Une interface est un ensemble de déclarations de méthodes qui ne sont pas définies: au lieu d'avoir un corps entre accolades qui définit leur code comme dans les classes, leur déclaration est simplement suivie d'un point virgule. Pour définir une interface, il suffit de remplacer le mot clé class par interface:

  interface I {
    void m1(Object o);
    double m2();
  }

Au même titre qu'une classe, une interface définit un type qui permet de déclarer des variables, comme par exemple I i;. En revanche, une interface ne permet de créer aucun objet, aucune instance; elle ne peut avoir aucun constructeur. Elle ne peut d'ailleurs définir aucun champ (à part des constantes final static). Ainsi, une interface est seulement une "déclaration d'intention" qui dit que toute instance qui sera stockable dans une variable du type de l'interface (telle que i), sera capable de supporter toutes les méthodes déclarée dans I (les méthodes m1 et m2).

On dit qu'une classe C implante ou réalise une interface I à deux conditions:

  1. elle revendique cette implantation en déclarant:
    class C implements I { ... }
  2. chaque méthode déclarée par I est définie par C, c'est à dire dans notre exemple que C possède une définition de m1 et de m2 avec leur code.
On peut alors utiliser une variable déclarée du type de l'interface pour stocker une instance d'une classe qui l'implante et appeler sur cette variable n'importe quelle méthode qui est déclarée par l'interface.
  I i = new C();
  i.m1(new Object());
  double d = i.m2(3.5);

Exercice 5: Définissez une interface Mesurable qui déclare une unique méthode de signature double surface(). Faites en sorte que les classes Disque et Anneau implantent cette interface.

Exercice 6: Définissez, dans une classe Test, une méthode statique double somme(Mesurable[]) qui calcule la somme des surfaces des instances d'objets mesurables que contient le tableau qui lui est passé en argument. Testez cette méthode.

Exercice 7: Écrire une classe Carre, qui représente un carré par un Point qui est son coin (toujours le même, disons supérieur gauche) et par un double qui est la taille de ses côtés. Écrire un constructeur et une méthode toString() pour cette classe, et lui faire implanter l'interface Mesurable. Testez à nouveau la méthode somme() de l'exercice 6 en lui passant en argument un tableau contenant à la fois des Disque, des Anneau et des Carre.

Classes abstraites

Nous avons vu qu'une classe C permettait de créer des instances, sur lesquelles chacune des méthodes (définies dans la classe ou héritées de ses super-classes) peut être appliquée puisqu'elle dispose d'un corps spécifiant une suite d'instructions à exécuter.

En plus de décrire un type, une classe implante toutes les fonctionnalités offertes par ce type. Ce type est concrètement utilisable, car on peut directement créer des objets ayant ce type.

Une interface, elle, se contente de décrire un type sans offrir d'implantation pour les fonctionnalités du type. On ne peut pas directement créer d'objets ayant le type de l'interface : on est obligé de créer des objets de classes qui implantent l'interface.

Il existe en Java des définitions de types intermédiaires entre les classes et les interfaces : les classes abtraites. Les classes abstraites sont des classes qui peuvent éventuellement contenir des déclarations de méthodes sans définition associée (comme pour les interfaces).

abstract class A {
  private int a;                     // Comme dans une classe usuelle
  public A(int a) { this.a=a; }      // Comme dans une classe usuelle
  public int getA() { return a; }    // Comme dans une classe usuelle
  public abstract void f();          // Ici, une méthode abstraite : déclarée mais pas définie
}
Ces méthodes sont alors déclarées abstraites. Une classe (abstraite) contenant des méthodes n'est pas instanciable. Bien que dans l'exemple précédent on trouve un constructeur, on ne peut écrire :
A a = new
A(1);
Les définitions des méthodes abstraites sont données dans des sous-classes de A :
class B extends A {
  public int B(int x) { super(x); ... }
  public void f() { System.out.println("Bonjour"); }
}
Les sous classes vont donc compléter les définitions manquantes de A. On peut créer des instances des sous-classes implantant toutes les méhodes déclarées abstraites dans B :
B b = new B(3); // Correct
A a = new B(4); // Egalement correct

Le principal avantage d'une classe abstraite CA est de pouvoir fournir en même temps:

Reprenons l'exemple de notre interface Mesurable qui déclarait une méthode double surface(). Une première remarque est de constater qu'en fait, dans une interface, toute méthode est forcément publique et abstraite. Autrement dit:

public interface Mesurable {
  double surface();
}
// est absoluement équivalent à
public interface Mesurable {
  public abstract double surface();
}

Nous aimerions maintenant disposer d'une méthode min() sur les Mesurable qui retourne la référence du plus petit Mesurable entre le receveur et l'argument. Puisque cette méthode n'utilise que la méthode surface(), il est possible de définir son code une seule fois dans une classe abstraite afin qu'il soit hérité dans toutes les classes concrètes telles que Disque Anneau Carre, etc...

Exercice 8: Remplacer l'interface Mesurable par une classe abstraite AbstractMesurable qui déclare surface() et définit min(). Faire hériter les classes concrètes de cette classe abstraite et tester la méthode min().

Exercice 9: Comment était il possible de conserver en même temps Mesurable et AbstractMesurable? Essayer cette solution.

Piles et implantations

Les piles sont une des structures de données classiques, largement utilisées. Elles sont quelquefois appelées structure de données LIFO (Last In First Out): le dernier élément ajouté à une pile est le premier à en être retiré. Cette appellation fait également référence à une autre structure de données, les files ou queues, qui elles sont FIFO (First In First Out), pour lesquelles le premier élément ajouté à une file sera le premier à être retiré.

Intuitivement, tandis qu'une queue FIFO correspond à une file d'attente, une pile LIFO correspond à une pile d'assiettes sur laquelle on ne peut ajouter qu'une assiette à la fois et sur le dessus, et de laquelle on ne peut prendre qu'une assiette à la fois et sur le dessus.

Exercice 10: Écrire une interface Stack représentant les fonctionnalités qu'on attend d'une pile d'Object. Classiquement, ces fonctionnalités sont l'ajout (void push(Object o)), le retrait (Object pop()) et le test permettant de savoir si la pile est vide (boolean isEmpty()).

Exercice 11: Écrire une première implantation de l'interface Stack sous la forme d'une classe ArrayStack qui stocke, en interne, les éléments de la pile dans un tableau d'objets.

Exercice 12: Écrire une seconde implantation de l'interface Stack sous la forme d'une classe ArrayListStack qui stocke, en interne, les éléments de la pile dans une liste (de la classe java.util.ArrayList).

Empiler les éléments d'une pile

Nous voulons maintenant disposer d'une fonctionnalité un peu particulière, permettant d'empiler tous les éléments d'une pile p2 sur une pile p1. Néanmoins, avec l'interface Stack dont on dispose, l'instruction p1.push(p2) a pour effet d'ajouter sur le dessus de la pile p1 un seul objet qui est la pile p2 elle-même. On voudrait avoir maintenant une méthode pushAll() qui, lorsqu'elle est appelée sur une pile p1 et qu'elle accepte un objet en argument qui est une pile p2, ajoute séparément chacun des éléments de la pile p2 à la pile p1, dans le même ordre où ils étaient empilés dans p2.

Exercice 13: Complétez la hiérarchie de types formée par Stack, ArrayStack et ArrayListStack par des classes, interfaces ou classes abstraites de sorte à disposer de cette méthode pushAll.


Etienne Duris - Nicolas Bedon - © École Nationale des Ponts et Chaussées - Décembre 2002 - http://www-igm.univ-mlv.fr/~duris/ENPC/