2 Cours ENPC, printemps 2001
Java, objets, interfaces et Internet

Valeurs et types

Les valeurs se répartissent en deux catégories :

  • les valeurs primitives (d'un type primitif),
  • les références, qui sont
    • des références à des tableaux (d'un type tableau), ou
    • des références à des instances de classe (dont le type est une classe).

Les valeurs primitives sont celles familières dans les langages de programmation : entiers, nombres flottants, caractères et booléens. Leur type est l'un des types primitifs de Java :

  • boolean : valeurs true ou false
  • char : caractère 16 bits Unicode
  • byte : entiers 8 bits signés, en complément à 2
  • short : entiers 16 bits signés, en complément à 2
  • int : entiers 32 bits signés, en complément à 2
  • long : entiers 64 bits signés, en complément à 2
  • float : flottants 32 bits IEEE 754-1985
  • double : flottants 64 bits IEEE 754-1985

Tous les types numériques primitifs sont signés.

Les tableaux occupent une position intermédiaire entre données primitives et instances. À tout type correspond un type de tableaux obtenu en suffixant [] à son nom : par exemple les types int[] des tableaux d'entiers, Object[] des tableaux d'objets quelconques, int[][] des tableaux de tableaux d'entiers.

La déclaration

  int[] t;

introduit t comme le nom d'un tableau d'entiers ; aucun objet n'est créé. Pour qu'un tableau soit créé, il faut soit utiliser l'opérateur new, qui crée le tableau et retourne une référence vers celui-ci, soit l'initialiser à la déclaration, ce qui a en outre l'effet d'initialiser les éléments du tableau :

  int[] t1 = new int[3];
  int[] t1 = { 0, 1, 2 };

Les tableaux sont indicés comme en C, à partir de 0. La longueur d'un tableau est fixée lors de sa construction et ne peut être modifiée ultérieurement ; elle est obtenue comme la valeur du champ non-modifiable length de l'objet, mais elle ne fait pas partie du type. À la différence de C, une méthode peut retourner un tableau.

Il existe une quatrième catégorie de types, les interfaces (mais aucune valeur ne peut être de ce type). Les types de tableaux, de classes et d'interfaces forment les types référencés ; tableaux et instances de classes constituent les objets.

Un programme Java est une collection de classes et d'interfaces.

Valeurs initiales

Les champs des classes qui ne sont pas explicitement initialisés le sont implicitement, par la valeur nulle du type approprié (0 pour les types numériques, false pour le type boolean, \u0000 pour le type char, et null pour les types référencés).

La classe Object et la généricité

Toute classe dérive implicitement de la classe Object. Ainsi, la définition

class A { ... }

est équivalente à

class A extends Object { ... }

Tout type tableau dérive également de Object. Par suite, toute méthode déclarée avec un paramètre de type Object accepte en argument un tableau quelconque ou un objet de n'importe quelle classe, mais pas une valeur d'un type primitif.

Pour passer un argument d'un type primitif à une méthode demandant un objet, on a recours aux classes enveloppes Integer, Float, etc. Ces classes enveloppes ont notamment pour membres des constantes MAX_VALUE et MIN_VALUE qui représentent les valeurs maximale et minimale des types primitifs correspondants int et float, etc.

On peut utiliser la classe Object pour transformer une structure de données en une structure générique, par exemple les arbres à noeuds de type int en arbres à noeuds de types référencés quelconques. Il suffit de remplacer int par Object. La définition de la classe devient :

abstract class Arbre {

  abstract boolean estVide();
}

class AVide extends Arbre {

  AVide(){}

  boolean estVide() {
    return true;
  }
}

class ACons extends Arbre {
  Object label;
  Arbre gauche, droite;

  ACons(Object label, Arbre gauche, Arbre droite) {
   this.label = label;
   this.gauche = gauche;
   this.droite = droite;
  }

  boolean estVide() {
    return false;
  }
}

On obtient ainsi des arbres génériques hétérogènes (les types des objets aux noeuds peuvent être différents entre eux) :

Arbre t = new ACons("toto", 
                  new ACons(new Integer(3), new AVide(), new AVide()),
                  new ACons(new Double(0.4), new AVide(), new AVide()));
    
Les chaînes littérales, comme "toto" sont des objets de classe String.

Encapsulation et champs privés

L'encapsulation est une technique de génie logiciel qui permet de cacher les détails d'implantation d'un objet pour ses utilisateurs. Java donne plusieurs moyens pour assurer cette encapsulation : les champs private et les interfaces.

Afin d'empêcher l'utilisateur d'accéder directement aux champs de l'objet, on peut déclarer ces champs private et offrir éventuellement des méthodes de lecture ou d'écriture de ces champs, formés selon l'usage par get ou set suivi du nom du champ capitalisé. Le fait de ne définir que get interdit l'accès en écriture. Par exemple,

class A {
 private int val;

 int getVal() {
  return val;
 }

 void setVal(int val) { 
  this.val = val;
 }
}

Un champ privé n'est pas héritable ; une instance d'une sous-classe contient ce champ, mais il n'est accessible qu'à travers des méthodes getVal et setVal.

Membres statiques

Les membres déclarés static, appelés aussi membres de classe, sont communs à toutes les instances d'une classe. Un champ statique est donc une variable unique, partagée par toutes les instances de la classe (cette variable est toujours créée, même si aucune instance de la classe n'est construite).

Une méthode statique ne peut accéder qu'aux membres statiques de la classe ; elle ne doit pas utiliser this ni super. C'est le cas de la méthode main qui ne peut invoquer directement d'autres méthodes ou accéder directement à d'autres champs de la classe principale que si ces méthodes et champs sont également statiques. Par contre, une méthode statique peut construire un objet, et accéder à tous les membres, même non statiques, de cet objet. En particulier, la classe principale se trouve souvent être instanciée dans la méthode main, dans l'unique but de pouvoir accéder aux membres non-statiques de la classe :

class Principale {
  void methode() { ... }
  public static void main(String args[]) {
    Principale p = new Principale();

    p.methode();
  }
}

L'autre solution est de ne définir dans la classe principale que des membres statiques :

class Principale {
  static void methode() { ... }
  public static void main(String args[]) {

    methode();
  }
}

On accède à un membre statique d'une classe en préfixant le nom du membre par le nom de la classe, par exemple Math.PI.

Par exemple, la classe Math, qui n'est pas destinée à être instanciée, ne comporte que des membres statiques, dont les constantes E et PI, et toutes les fonctions mathématiques usuelles ; on invoquera par exemple Math.cos(Math.PI).

Constantes

Une constante de classe est déclarée comme un champ static final ; elle est obligatoirement initialisée lors de sa déclaration et ne peut pas être modifiée ultérieurement :

static final double PI = 3.14;

Par exemple, l'expression System.out.println("Hello") est l'invocation de la méthode println de la constante (membre statique final) out (de type PrintStream) de la classe System.

Les constantes sont souvent désignées par un nom en lettres majuscules (comme PI).

Méthodes et classes finales

Une méthode spécifiée final est finale, c'est-à-dire qu'elle ne peut pas être redéfinie (ou, si elle est statique, elle ne peut pas être masquée) dans une classe dérivée :

  final void f() { ... }

Une classe spécifiée final est finale, c'est-à-dire qu'aucune classe ne peut en être dérivée :

  final class A { ... }

Méthodes et classes finales sont utilisées dans un but de sécurité (un utilisateur de la classe ne peut pas modifier son comportement) et dans un but d'optimisation.

Un exemple de pattern de création : les classes singletons

Une classe singleton est une classe qui ne peut avoir qu'une seule instance. Leur réalisation met en oeuvre :

  • un champ instance privé statique désignant l'instance,
  • une méthode publique statique de création, qui teste si l'instance n'a pas encore été crée, et si c'est le cas, qui appelle un constructeur,
  • un constructeur, qui doit être privé pour ne pas être appelé librement de l'extérieur de la classe
  • les autres champs (val, etc) sont privés par sécurité
class Singleton {
  private double val;
  private static Singleton instance;
  private Singleton(double val) {
    this.val = val;
  }
  double getVal() { return val; }
  static Singleton creerUniqueInstance(double val) {
   if (instance == null) {
      instance = new Singleton(val);
    }
    return instance;
  }
}

Cette classe sera utilisée ainsi :

    Singleton s1 = Singleton.creerUniqueInstance(2.3);

Un exemple de pattern de délégation : les visiteurs (3)

On doit représenter les expressions arithmétiques et effectuer un certains nombres de traitements sur celles-ci, par exemple, les évaluer, les imprimer de façon infixe, ou suffixe, etc. Une expression est soit une constante, soit l'addition de deux expressions, soit la multiplication de deux expressions, etc. On transcrit cette définition en une hiérarchie de classes : la classe parente est une classe abstraite Expr, ses classes dérivées sont des classes concrètes Const, Plus, Mult, etc. Par exemple, on pourra définir :

    Expr expr =                         // expr = 2 + (3+6)
      new Plus(new Const(2), 
	       new Plus(new Const(3),
			new Const(6)));

en utilisant les constructeurs des classes concrètes dérivées de Expr :

class Const extends Expr {
  private int c;
  Const(int c) {
    this.c = c;
  }
   ...
}

class Plus extends Expr {
  private Expr expr1, expr2;
  Plus(Expr expr1, Expr expr2) {
    this.expr1 = expr1;
    this.expr2 = expr2;
  }
   ...
}

Une façon de procéder serait de définir des méthodes d'évaluation et d'affichage dans la classe Expr et ses classes dérivées. Une autre façon est de déléguer ces fonctions à un autre objet. On introduit donc une nouvelle classe abstraite de « visiteurs » d'expression, avec autant de méthodes que de classes concrètes d'expression :

abstract class ExprVisiteur {
  abstract Object visiterConst(int c);
  abstract Object visiterPlus(Expr expr1, Expr expr2);
}

Chacune de ces méthodes doit traiter une instance d'une classe d'expressions et produire un objet. Il y aura autant d'implémentations de cette classe abstraite que de traitements demandés :

class EvalVisiteur extends ExprVisiteur { ... }
class InfixeVisiteur extends ExprVisiteur { ... }

La classe Expr n'a plus besoin que d'une unique méthode pour associer un de ses objets à un traitement. On appelle cette méthode accepter (l'expression « accepte » un visiteur et lui laisse faire le travail) et on l'utilise ainsi :

    Expr expr = ...                              // expr = 2 + (3+6)
    ExprVisiteur eval = new EvalVisiteur();
    ExprVisiteur infixe = new InfixeVisiteur();
    Object valeur = expr.accepter(eval);         // valeur = 11
    Object chaine = expr.accepter(infixe);       // chaine = "2+(3+6)"

Ainsi, les données (les expressions) et les traitements (évaluations, etc) sur ces données sont découplés en deux objets distincts. On doit donc définir une méthode abstraite dans la classe Expr :

abstract class Expr {
  abstract Object accepter(ExprVisiteur v);
}

Il faut l'implémenter dans chaque classe dérivée, en appelant la méthode du visiteur spéciale à cette classe  :

class Const extends Expr {
  private int c;
  Const(int c) {
    this.c = c;
  }
  //-------------------------------
  Object accepter(ExprVisiteur v) {
    return v.visiterConst(c);
  }
}

class Plus extends Expr {
  private Expr expr1, expr2;
  Plus(Expr expr1, Expr expr2) {
    this.expr1 = expr1;
    this.expr2 = expr2;
  }
  //-------------------------------
  Object accepter(ExprVisiteur v) {
    return v.visiterPlus(expr1, expr2);
  }
}

Il reste à implémenter les méthodes des visiteurs, pour chaque classe d'expression et pour chaque traitement demandé. Comme tous les traitements doivent retourner un Object, l'évaluation retourne un Integer (pas un int!) et l'impression retourne un String, qui sont des Objects :

class EvalVisiteur extends ExprVisiteur {
  EvalVisiteur(){}
  // -----------------------------------------
  Object visiterConst(int c) {
    return new Integer(c);
  }
  Object visiterPlus(Expr expr1, Expr expr2) {
    return 
      new Integer(((Integer)expr1.accepter(this)).intValue() +
		  ((Integer)expr2.accepter(this)).intValue());
  }
}

class InfixeVisiteur extends ExprVisiteur {
  InfixeVisiteur(){}
  // -----------------------------------------
  Object visiterConst(int c) {
    return Integer.toString(c);
  }
  Object visiterPlus(Expr expr1, Expr expr2) {
    return 
      "(" +  
      expr1.accepter(this) + 
      "+" + 
      expr2.accepter(this) +
      ")";
  }
}

Les données et les traitements étant découplés, si un nouveau traitement doit être programmé, il suffit d'écrire une nouvelle classe dérivée de ExprVisiteur, sans toucher aux autres ni toucher aux différentes classes d'expressions. Si par contre, on ajoute une nouvelle classe d'expressions (les produits, divisions, etc), il faut ausi ajouter une méthode pour cette classe dans chacune des classes concrètes de visiteurs.

Interfaces

L'autre moyen pour encapsuler un objet consiste à rendre son implantation indépendante de son utilisation à l'aide d'interfaces, qui sont des types abstraits. Aucun objet d'un type abstrait ne peut être construit. Par contre, on peut déclarer une référence ou des paramètres de méthode d'un type abstrait. Considérons l'interface suivante des piles d'objets :

interface Pile {
  boolean estVide();
  void empiler(Object n);
  Object sommet();
  Object depiler();
}

Une interface est une classe abstraite, sans constructeur, dont les méthodes n'auraient pas de corps, mais simplement un « ; ». L'usage est de donner à certaines interfaces des noms se terminant en able. On ne peut pas construire d'objets de type Pile mais on peut déclarer des noms de variable ou de paramètres de méthode de ce type. Voici une méthode à ajouter à la classe Arbre, qui permet d'imprimer les étiquettes d'un arbre parcouru en profondeur d'abord et qui utilise cette interface :

void depthFirstPrint() {
  Pile s = new PileParTableau();
  Arbre t;

  s.empiler(this);
  while (!s.estVide()) {
    t = (Arbre)s.depiler();
    if (!t.estVide()) {
      System.out.println(t.label);
      s.empiler(t.gauche);
      s.empiler(t.droite);
    }
  }
}

Ici, PileParTableau est une implantation de l'interface Pile. Il est inutile d'en connaître le contenu pour définir la méthode depthFirstPrint. Si l'on préférait utiliser une autre implantation de Pile, soit Pile2, il suffirait de remplacer l'appel au constructeur PileParTableau() par un appel au constructeur Pile2(). Objection ?

Du fait de la généricité de Pile, la valeur retournée par depiler() est de type Object ; avant de l'affecter à t, on lui applique une coercition vers le type Arbre, de façon à pouvoir accéder aux membres de la classe Arbre à travers t.

Implémentation d'une interface

Une classe implémente une interface si elle contient une implémentation publique pour chacune des méthodes de l'interface. Une classe dont la définition spécifie implements, suivi de noms d'interface, doit implémenter ces interfaces. Voici une implémentation de l'interface Pile par la classe PileParTableau ; un objet de classe PileParTableau a trois champs privés (un tableau d'objets, un entier qui représente la hauteur de la pile.

class PileParTableau implements Pile {

  private Object[] contenu;
  private int hauteur;
  private static final int N=100;

  PileParTableau(int max) {
    contenu = new Object[max]; 
  }

  public boolean estVide() {
    return hauteur == 0;
  }

  public void empiler(Object o) {
      if (hauteur < N) {
        contenu[hauteur] = o;
        hauteur++;
      }
  }
  
  public Object sommet() {
      return contenu[hauteur-1];
  }

  public Object depiler() {
      hauteur--;
      return contenu[hauteur];
  } 
}

Cette implémentation n'est pas vraiment satisfaisante, car elle ne traite pas correctement les situations exceptionnelles (pile vide, pile pleine). Au lieu d'un tableau d'objets, on pourrait utiliser un objet de la classe List qui implémente une version extensible des tableaux.

Indépendance type abstrait/implémentation

Même si on peut déclarer des variables d'un type abstrait, les constructeurs font nécessairement partie d'un type concret. Une application qui contient un grand nombre d'appels à des constructeurs est donc très dépendante des classes concrètes choisies. Il y a plusieurs façons (patterns de création) pour assurer l'indépendance entre type abstrait et implémentation.

Une façon consiste à déléguer la construction à un autre objet, une « fabrique », en remplaçant Pile s = new PileParTableau(); par

Fabrique f = new FabriqueParTableaux();
Pile s = f.fabriquerPile();

Fabrique est une classe abstraite, et c'est en l'implémentant qu'on décide quelles implémentations (des piles ou d'autres types abstraits) vont être utilisées :

abstract class Fabrique {
  abstract Pile fabriquerPile();
  // idem pour d'autres types
}

class FabriqueParTableaux extends Fabrique {
  static final int N = 100;
  Pile fabriquerPile() {
    return new PileParTableau(N);
  }
  // idem pour d'autres types
}

Tous les choix d'implémentation sont faits au moyen d'une casse concrète dérivée de Fabrique. Si d'autres choix étaient à faire, il suffit de modifier la classe concrète FabriqueParTableaux, voire d'en définir une autre et de modifier la seule ligne Fabrique f = new FabriqueParTableaux();. Dans une approche plus systématique, l'application figurerait dans une classe Application, dont un constructeur aurait un paramètre de type Fabrique. L'instanciation de l'application se ferait par :

 ... new Application(new FabriqueMaison()) ...


[Cours Java] [Notes 1] [Notes 3]

Mise à jour :