Publié par

Il y a 11 ans -

Temps de lecture 14 minutes

Enumérations – Utilisation avancée

Avant l’arrivée des énumérations, deux principaux choix s’offraient à vous pour effectuer des énumérations : les constantes et les classes de type N-gleton. C’était au développeur de choisir, selon le contexte, quelle solution semblait la plus adaptée à son problème.

Vous constaterez rapidement qu’aucune de ces solutions n’était satisfaisante.

Comment développiez-vous vos énumérations ?

Utilisation de constantes

Java ne permettant pas de créer des namespaces, les développeurs se contentaient, la plupart du temps, de simuler ceux-ci par l’intermédiaire d’une simple convention de nommage (ex : en utilisant le préfixe commun ‘GENDER_’) (exemple 1).

Même si ce problème était facilement soluble en regroupant les valeurs dans une interface (exemple 2), restait un problème majeur : les dangers liés au typage.

Les différentes valeurs étant typées ‘int’, aucune erreur n’était remontée lorsque vous passiez une ‘PRIMARY_COLOR’ en paramètre d’une méthode demandant un ‘GENDER’. Les erreurs engendrées par ce comportement pouvaient être difficiles à détecter. C’est un peu comme si on se contentait de développer une classe en utilisant un seul type pour ses champs et de déléguer aux clients la bonne utilisation de celle-ci.

// Exemple 1
public static final int PRIMARY_COLOR_BLUE = 0;
public static final int PRIMARY_COLOR_RED = 1;
public static final int PRIMARY_COLOR_RED = 2;

// Exemple 2
interface Gender {
  int MALE = 0;
  int FEMALE = 1;
}

Utilisation d’un ‘N-gleton’

Du coup, la meilleure solution pour les développeurs pour simuler les énumérations restait encore d’utiliser des inner classes statiques et de s’assurer manuellement de l’unicité de ces instances. Si nous voulons utiliser un typage fort et des fonctionnalités de haut niveau, on peut définir une classe dont les instances sont les valeurs du type énuméré, c’est plus fiable, mais un peu lourd !

L’idée est d’adapter le pattern singleton pour en faire un ‘N-gleton’. Le nombre d’instances correspondant au nombre de valeurs différentes du type énuméré. On dispose alors de l’égalité, l’implémentation de la méthode equals() héritée de Object convient parfaitement. Malheureusement, il reste impossible d’utiliser les instances pour contrôler un switch/case.

// Exemple 3
public class Member {

  // ... stuff of the member class

  public static final class Gender {

    // simulation des éléments de l'énumération
    public static final Gender MALE = new Gender(0, "MALE");
    public static final Gender FEMALE = new Gender(1, "FEMALE");

    // constructeur privé
    private final int ordinal;
    private final String name;
    private Gender(int ordinal, String name) {
      this.ordinal = ordinal;
      this.name = name;
    }

    // simulation de la méthode ordinal() disponible sur les énumérations depuis le jdk 5
    public final int ordinal() {
      return ordinal;
    }

    // simulation de la méthode name() disponible sur les énumérations depuis le jdk 5
    public final String name() {
      return name;
    }

    // simulation de la méthode toString() disponible sur les énumérations depuis le jdk 5
    @Override
    public String toString() {
      return name();
    }

    // simulation de la méthode values() disponible sur les énumérations depuis le jdk 5
    private static final Gender[] values = new Gender[] { MALE, FEMALE };
    public static Gender[] values() {
      return values;
    }

    // simulation de la méthode valueOf() disponible sur les énumérations depuis le jdk 5
    public static Gender valueOf(String name) {
    	for (int i = 0; i < values.length; i++) {
    		if (values[i].name().equals(name)) {
    			return values[i];
    		}
    	}
      return null;
    }

  }

  // set the gender to the bean Member
  private Gender gender;
  public void setGender(Gender gender) {
    this.gender = gender;
  }
}

(Ne prenez pas la peine de me dire qu'il y avait plus simple ou plus performant, il ne s'agit que d'un exemple)

Utiliser les énumérations

Au premier abord, il est tentant de comparer les énumérations java avec ceux des autres langages. Elles sont bien plus puissantes que dans la plupart des autres langages. Là où C / C++ ou C# se contentent de traiter les énumérations comme une suite de nombres entiers, les enum Java se comportent comme de vraies classes. On peut tout à fait les comparer au N-gleton que nous venons de développer dans l'exemple précédent possédant un certain nombre de propriétés implicites.

  • Chaque élément d'un enum est représenté comme une instance unique d'une classe marquée final dont le constructeur n'est jamais rendu visible depuis l'extérieur.
  • L'utilisation d'un enum en paramètre d'une méthode la rend type safe. Aucun élément autre que ceux d'une énumération particulière ne sera accepté en paramètre d'une méthode : on ne mélange pas les choux avec les carottes.
  • Chaque enum offre des fonctionnalités de base permettant de la convertir et de l'identifier. Par exemple, la méthode ordinal() renvoie l'index de l'élément dans la liste ; la méthode name() renvoie le nom de l'élément ; la méthode values() renvoie un tableau contenant l'ensemble ordonné des éléments de l'énumération ; la méthode valueOf() permet de récupérer un élément de l'énumération à partir d'une chaîne de caractères
  • Chaque enum fournie une implémentation implicite et correcte des méthodes définies dans Object. Il est possible d'utiliser == pour comparer deux instances d'un enum. Par défaut, la méthode toString() renvoie le nom de l'élément tel qui a été écrit lors de sa déclaration.
  • Chaque enum implémente implicitement les interfaces Comparable et Serializable. Comme elles se comportent comme de simples classes, il est également possible de leur faire implémenter nos propres interfaces.

Il est possible de déclarer les enum soit en top-level class, soit en inner class. En général, si une énumération est assez générique pour être utilisée à plusieurs endroits, il est préférable de la déclarer en top-level dans un fichier séparé.

Côté performance, dans la plupart des cas, les énumérations n'ont rien à envier aux listes de valeurs constantes. Ce n'est bien entendu pas magique-, si on pinaille,- : on note effectivement des pertes mineures, en taille et en temps de chargement. Ces pertes sont négligeables dans la plupart des contextes.

// Exemple 4
public class Member {

  // ... stuff of the member class

  // déclaration de l'énumération quasi équivalent à l'exemple 3 utilisant une inner classe
  public static enum Gender { MALE, FEMALE; }

  // set the gender to the bean Member
  private Gender gender;
  public void setGender(Gender gender) {
    this.gender = gender;
  }
}

Lier des données spécifiques pour chaque instance d'une énumération

Il est possible de lier des données spécifiques pour chaque instance d'une énumération. Pour cela, il suffit de déclarer des champs comme vous le feriez dans une classe traditionnelle et de les affecter via un constructeur de la façon suivante (exemple 5). Chaque champ peut (devrait) être marqué final, puisqu'il ne devrait être affecté qu'une seule fois lors de l'initialisation de l'enum. Du coup, ces champs finaux pourraient être déclarés public pour donner un accès direct aux utilisateurs, mais il est préférable de les laisser private et d'ajouter des accesseurs traditionnels.

//Exemple 5
public enum Operation {

  // élements de l'énumération avec des données attachées
  PLUS("+"), MINUS("-"), TIMES("*"), DIVIDE("/");

  // constructeur privé affectant le symbole au champ local
  private final String symbol;
  private Operation(String symbol) {
	this.symbol = symbol;
  }

  // getter traditionnel
  public String getSymbol() {
	  return symbol;
  }
}

Ajouter / surcharger des méthodes à une énumération

Les énumérations étant comparables à des classes, il est possible de leur ajouter des méthodes static ou non après la déclaration des éléments de l'enum. Dans la pratique, c'est d'ailleurs souvent le cas, on commence par déclarer une simple liste d'éléments. Puis on l'étoffe petit à petit jusqu'à obtenir un 'gros objet' contenant des données et des comportements particuliers à certaines situations.

Reprenons notre exemple précédent et rajoutons une méthode d'instance permettant d'effectuer l'opération.

//Exemple 6
public enum Operation {

  PLUS, MINUS, TIMES, DIVIDE;

  // méthode questionnable en fonction de l'instance courante
  public double calculate(double x, double y) {
	  switch(this) {
	  case PLUS: return x + y;
	  case MINUS: return x - y;
	  case TIMES: return x * y;
	  case DIVIDE: return x / y;
	  }
	  throw new AssertionError("Opération inconnue : " + this);
  }
}

Vous aurez noté que ce code reste fragile. Si vous ajoutez un élément à l'énumération, celle-ci compilera toujours. Par conséquent, vous vous rendrez compte de votre oubli au runtime lorsque l'application échouera.

Essayons de l'améliorer un peu, pour cela, nous allons utiliser l'héritage en ligne. En effet, s'il n'est pas possible de faire hériter une énumération dans son ensemble ("public enum MyEnum2 extends MyEnum1" n'est pas valide), il est possible, pour chaque élément de celui-ci, de surcharger une ou plusieurs méthodes en utilisant un héritage anonyme.

//Exemple 7
public enum Operation {

  // Liste d'élements de l'énumération implémentant la méthode abstraite
  PLUS {

    @Override
    public double calculate(double x, double y) {
      return x + y;
    }

  },
  MINUS   { @Override public double calculate(double x, double y) { return x - y; } },
  TIMES   { @Override public double calculate(double x, double y) { return x * y; } },
  DIVIDE  { @Override public double calculate(double x, double y) { return x / y; } };

  // méthode abstraite, à implémenter pour chaque élement de l'énum
  public abstract double calculate(double x, double y);

}

Avec cette implémentation, si vous rajoutez un élément dans l'énumération, le compilateur vous avertira si vous oubliez d'implémenter la méthode abstraite. Pourtant, elle comporte encore des défauts : il est très difficile de partager une implémentation de méthode entre différents éléments de l'énumération. Contrairement au switch qui nous permettait de réutiliser du code en séquençant les case et / ou en utilisant default, le partage du code n'est plus possible via l'héritage anonyme. Il est bien entendu possible de dupliquer le code ou d'externaliser celui-ci dans un helper, mais ces deux solutions complexifient le code et le rendent plus difficile à lire. À terme, elles facilitent l'arrivée de bugs et complexifient la maintenance.

Ce que nous désirons réellement, c'est un moyen simple de pouvoir choisir (et de forcer à choisir) entre plusieurs implémentations lors de l'ajout d'un nouvel élément à une énumération. Il est temps de dévoiler notre ultime botte secrète : plus lourd, mais aussi plus sûr et plus flexible que les solutions précédentes, voici le strategy enum pattern !

// Exemple 8 : strategy enum pattern
public enum SuperHero {

	// la sélection d'un genre est rendue obligatoire lors de l'ajout
	// d'un élément, il est ainsi possible de partager / réutiliser
	// plusieurs implémentations d'une méthode.
	BATMAN(Gender.MALE), WONDER_WOMAN(Gender.FEMALE), SPIDERMAN(Gender.MALE);

	// constructeur affectant un élément de l'inner enum
	private final Gender gender;
	private SuperHero(Gender gender) {
		this.gender = gender;
	}

	@Override
	public String toString() {
		return "My name is " + name() + " ! ";
	}

	// affiche le nom du super héro et un messge en fonction de son genre.
	public String introduceMe() {
		return toString() + gender.whoiam();
	}

	// INNER ENUM
	// utilisé pour partager des implémentations
	// entre différents super héros
	private enum Gender {
		MALE {
			@Override
			public String whoiam() {
				return "I am a boy !";
			}
		},
		FEMALE {
			@Override
			public String whoiam() {
				return "I am a girl !";
			}
		};

		// méthode abstraite : doit être implémentée
		public abstract String whoiam();

	}
}

Je vous avais prévenu, on est bien loin d'une énumération traditionnelle. Vue la quantité de code à fournir, inutile de vous dire que vous n'avez peut-être pas besoin de ce pattern pour chacune de vos énumérations...

Best / Worst practices

Proscrire l'utilisation de la méthode ordinal()

Comme nous l'avons vu plus haut, chaque énumération fournit une méthode ordinal() implicite permettant d'identifier la position d'un élément dans l'énumération. Au premier abord, nous sommes tentés d'utiliser cette méthode pour identifier un élément de manière unique. Hibernate nous propose d'ailleurs d'utiliser celle-ci pour persister nos champs énumérés. Il s'avère qu'il s'agit en général d'une bien mauvaise idée. La javadoc est d'ailleurs très claire à ce sujet : "Cette méthode n'a aucune utilité pour la plupart des développeurs".

Pour illustrer ce problème, prenons l'exemple d'une énumération représentant un ensemble fini de formes gérées par un système. Ajoutons à cette énumération une méthode permettant de calculer le nombre de segments de chaque forme.

// Exemple 9
public enum Forme {

  CERCLE, ARC, TRIANGLE, CARRE, PENTAGONE;

  // DANGER : calvaire en maintenance
  public int getNbSegments() {
    return ordinal() + 1;
  }
}

L'utilisation de la méthode ordinal() est ici une bien mauvaise idée. Que se passe-t-il si l'on désire ajouter le losange au système ? La méthode getNbSegements() ne fonctionne plus du tout. Pour une si petite modification, le coût de maintenance est démesuré, une restructuration complète de l'énumération est nécessaire. De plus, ici encore, le problème n'est remonté qu'au runtime : il y a un risque de partir en production sans même s'apercevoir que la méthode getNbSegements() ne fonctionne plus.

Pour éviter ce genre de problème, il est préférable d'utiliser un champ personnalisé.

// Exemple 10
public enum Forme {

	// Eléments contenant une position définit par l'utilisateur
	// Ici, on n'a préféré ne pas utiliser ordinal()
	CERCLE(1), ARC(2), TRIANGLE(3), LOSANGE(4), CARRE(4), PENTAGONE(5);

	// constructeur affectant la position
	private final int nbSegments;
	private Forme(int nbSegments) {
		this.nbSegments = nbSegments;
	}

	// getter
	public int getNbSegments() {
		return nbSegments;
	}
}

Quand utiliser les switch avec les énumérations ?

Nous avons vu précédemment dans cet article qu'utiliser un switch au sein d'une méthode d'une énumération sur ses différentes valeurs n'est pas idéal. S'il est dangereux d'utiliser les switch sur les énumérations, il est légitime de se demander s'il est bon de les utiliser ailleurs dans le code. La réponse est oui : ouf, on était à deux doigts de faire une pétition à Sun pour retirer cette fonctionnalité !

Si nous voulons modifier ou rajouter du comportement à une énumération sur laquelle nous n'avons pas la main, nous n'avons d'autres choix que de tester au cas sur quel élément de l'énumération sommes-nous.

Si nous reprenons l'exemple 7 déclarant une liste d'opérations et que nous voulons y ajouter une méthode permettant de récupérer l'inverse d'une opération, le code, externalisé dans un helper, à de fortes chances de ressembler à ça :

// Exemple 11
public static Operation inverse(Operation operation) {
  switch(operation) {
    case PLUS:   return Operation.MINUS;
	case MINUS:  return Operation.PLUS;
	case TIMES:  return Operation.DIVIDE;
	case DIVIDE: return Operation.TIMES;
	default: throw new IllegalArgumentException("Opération inconnue : " + operation.name());
  }
}

Comment obtenir efficacement un élément d'une énumération ?

Vous pouvez charger un élément d'une énumération à partir de son nom (name()) en utilisant la méthode implicite valueOf(String). Par exemple, l'appel Operation.valueOf("TIMES") ; retournera l'instance correspondante de l'énumération.

Maintenant, il est souvent nécessaire de pouvoir effectuer cette même opération sur un autre champ (données personnalisées). Pour cela vous n'avez d'autre solution que d'effectuer le traitement manuellement.

// Exemple 12
public static enum Operation {

	// éléments avec un symbole
	PLUS("+"), MINUS("-"), TIMES("*"), DIVIDE("/");

	// constructeur
	private final String symbol;
	private Operation(String symbol) {
		this.symbol = symbol;
	}

	// getter
	public String getSymbol() {
		return symbol;
	}

	// map permettant de récupérer une Operation avec un symbole
	private static final Map mapSymbols = new HashMap();
	static {
		for (Operation operation : values()) {
			mapSymbols.put(operation.getSymbol(), operation);
		}
	}

	// récupération de l'instance
	public static Operation fromSymbol(String symbole) {
		final Operation value = mapSymbols.get(symbole);
		if (value != null) {
			return value;
		}
		throw new IllegalArgumentException("Symbole incconu : " + symbole);
	}

}

L'idée est de déléguer le travail à une map contenant l'ensemble des valeurs de l'énumération dont la clé représente les données permettant d'identifier les différentes instances. Ainsi, lorsque la méthode est appelée, il ne reste qu'à retourner directement le contenu de la map pour la donnée passée en paramètre.

Utiliser des EnumSet à la place de bit fields

Il n'est pas rare de tomber sur du code contenant des listes de constantes entières représentées par des puissances de 2. Champs champ de bits, très utilisés dans le monde du langage C, permettent de stocker plusieurs constantes au sein d'un même entier.

// Exemple 13 : bit fields
public class Text {
	public static final int STYLE_BOLD = 1 << 0; // 1
	public static final int STYLE_ITALIC = 1 << 1; // 2
	public static final int STYLE_UNDERLINE = 1 << 2; // 4

	// appelez la méthode avec un ou plusieurs styles séparés par des OU
	// exemple : applyStyles(STYLE_BOLD | STYLE_ITALIC)
	public void applyStyles(int styles) { ... }
}

Les énumérations les rendent obsolètes. Il est préférable de remplacer les champs de bits non typés et peu lisibles par des enum. L'ellipse (méthode à nombre variable d'arguments) facilite l'appel de la l'utilisation de la méthode, mais reste moins performant qu'un champ de bits. C'est pourquoi certains recommandent l'utilisation d'un EnumSet. Ils sont spécialement optimisés pour ce type de traitement : pour les énumérations contenant moins de 64 (!) éléments, l'EnumSet est représenté en mémoire sous forme d'un seul long offrant ainsi des performances à peu près équivalentes à un champ de bits traditionnel tout en gardant les avantages offerts pas les enum.

// Exemple 14
public class Text {

  public static enum Style { BOLD, ITALIC, UNDERLINE; }

  // version Elipse : accepte les doublons
  // appelez la méthode avec zéro, un ou plusieurs styles
  public void applyStyles(Style... styles) { }

  // version EnumSet : filtre les doublons
  // exemple d'appel : applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC))
  public void applyStyles(EnumSet