1) Introduction
L’objectif de cet article est de parler de l’un des pattern du GOF (gang of four), assez connu, qui est le decorator.
Ce patron de conception correspond à un design pattern de type comportement (behavioural pattern).
Pour résumer, l’idée de ce pattern est d’ajouter du comportement au code au runtime (c’est-à-dire à l’exécution du code).
Ce pattern présente de nombreux avantages, car dès lors que la conception est mise en place, il devient très simple d’ajouter du comportement au code existant, ce qui le rend très évolutif.
Avant Java 8, malgré l’aspect intéressant évoqué précédement, ce pattern n'était pas beaucoup utilisé car le code nécessaire à sa mise en place était très verbeux.
En effet le design de code était basé sur l’héritage, ce qui obligeait le développeur à écrire une classe par décorator, ainsi qu’une interface accompagnée d’une classe abstraite. La verbosité induite par ce type de dévéloppement pouvait décourager pas mal de développeurs.
En Java 8, avec l’arrivée des lambdas, nous allons montrer que le decorator est beaucoup plus simple à implémenter, moins verbeux, fonctionnel et plus lisible pour le client de l’API.
L’article se décompose en 2 parties consistant à présenter le code et la conception du decorator avant et après Java 8. En Java 8 nous montrerons différentes techniques.
L’exemple choisi pour illustrer ce pattern, est le calcul du bénéfice d’une entreprise. Chaque nouveau decorator permettra d’ajouter un calcul au runtime à la formule générale.
2) Decorator en Java 7
Premièrement une interface définissant le contrat doit être créée, nous allons l’appeler ProfitCalculator (calculateur de bénéfice). Voici le code de cette interface :
public interface ProfitCalculator {
double calculate(double turnover);
}
Un calculateur de profit dispose d’une méthode abstraite "calculate", qui va appliquer un nouveau calcul au CA passé en paramètre.
Ensuite une classe abstraite doit être créée (classe mère de chaque decorator), nous allons l’appeler AbstractProfitDecorator :
public abstract class AbstractProfitDecorator implements ProfitCalculator {
private final ProfitCalculator profitCalculator;
public AbstractProfitDecorator(ProfitCalculator profitCalculator) {
this.profitCalculator = profitCalculator;
}
protected abstract double applyExpense(double turnover);
@Override
public double calculate(double turnover) {
double profit = profitCalculator.calculate(turnover);
return applyExpense(profit);
}
}
Cette classe implémente l’interface précédente et doit donc proposer l’implémentation de la méthode "calculate(double turnover)".
Chaque decorator va appliquer une dépense en proposant l’implémentation de la méthode "applyExpense(double turnover)".
Une subtilité est à constater ici : la classe prend également en paramètre l’interface "ProfitCalculator".
En effet, chaque decorator va appliquer le calcul du decorator qui le précède et va ensuite y ajouter son calcul. C’est ce comportement qui permet d’ajouter des traitements au runtime.
Nous allons commencer par une classe proposant un calcul par défaut appelée "DefaultProfitCalculator". Il est utile de commencer par une classe de type "ProfitCalculator" qui ne dépend de rien lors de son instantiation. Ceci correspond au calcul initial des decorators :
public class DefaultProfitCalculator implements ProfitCalculator {
@Override
public double calculate(double turnover) {
return Expenses.getTransportExpenses(turnover);
}
}
Nous allons ensuite montrer le code des différents decorators :
Charges d’exploitation ⇒ OperatingExpensesDecorator :
public class OperatingExpensesDecorator extends AbstractProfitDecorator {
public OperatingExpensesDecorator(ProfitCalculator profitCalculator) {
super(profitCalculator);
}
@Override
protected double applyExpense(double turnover) {
return Expenses.getOperatingExpenses(turnover);
}
}
Rémunération ⇒ RemunerationDecorator :
public class RemunerationDecorator extends AbstractProfitDecorator {
public RemunerationDecorator(ProfitCalculator profitCalculator) {
super(profitCalculator);
}
@Override
protected double applyExpense(double turnover) {
return Expenses.getRemuneration(turnover);
}
}
Dépenses exceptionnelles ⇒ ExceptionalExpensesDecorator :
public class ExceptionalExpensesDecorator extends AbstractProfitDecorator {
public ExceptionalExpensesDecorator(ProfitCalculator profitCalculator) {
super(profitCalculator);
}
@Override
protected double applyExpense(double turnover) {
return Expenses.getExceptionalExpenses(turnover);
}
}
Taxes déductibles ⇒ DeductibleTaxesDecorator :
public class DeductibleTaxesDecorator extends AbstractProfitDecorator {
public DeductibleTaxesDecorator(ProfitCalculator profitCalculator) {
super(profitCalculator);
}
@Override
protected double applyExpense(double turnover) {
return Expenses.getDeductibleTaxes(turnover);
}
}
Le principe de chaque decorator est le même, chacun doit proposer une implémentation de la méthode "applyExpense". Il est à noter que les cas choisis dans cet article pour représenter le bénéfice d’une entreprise ne reflètent pas forcément la réalité, mais servent juste d’exemple.
Une "garbage class" appelée "Expenses" contient des méthodes "static" permettant de calculer chaque cas. Nous présenterons cette classe un peu plus tard.
Nous allons désormais passer aux tests d’intégration et à l’appel des decorators.
Le premier test permet de composer tous les decorators :
@Test
public void givenTurnover_whenComposingAllDecorators_thenCorrectResult() {
// Given.
final double turnover = 100000;
// When.
final double profit = new ExceptionalExpensesDecorator
(new RemunerationDecorator
(new DeductibleTaxesDecorator
(new OperatingExpensesDecorator
(new DefaultProfitCalculator()))))
.calculate(turnover);
// Then.
assertThat(profit).isNotNull().isEqualTo(32600);
}
Le calcul commence de droite à gauche, le point de départ est le DefaultProfitCalculator; ensuite cette classe est passée en paramètre du décorator OperatingExpensesDecorator, et ainsi de suite.
OperatingExpensesDecorator applique le calcul de DefaultProfitCalculator puis le sien.
Voici un second test qui n’applique pas tous les decorators, et qui démontre qu’il est très simple d’ajouter ou de supprimer un decorator au runtime. Le code peut ainsi être évolutif :
@Test
public void givenTurnover_whenNotComposingAllDecorators_thenCorrectResult() {
// Given.
final double turnover = 100000;
// When.
final double profit = new RemunerationDecorator
(new DeductibleTaxesDecorator
(new OperatingExpensesDecorator
(new DefaultProfitCalculator())))
.calculate(turnover);
// Then.
assertThat(profit).isNotNull().isEqualTo(34600);
}
Au niveau du client de l’API nous avons la confirmation que ce pattern est intéressant à utiliser de par sa souplesse. Il suffit de créer un nouveau decorator pour ajouter un nouveau calcul. Ainsi en cas d'évolution, la conception mise en place n’aura pas besoin d'être modifiée.
Cependant nous constatons que la mise en place de ce patron de conception est très verbeuse. Beaucoup de classes et de lignes de codes doivent être écrites pour arriver à l’objectif attendu.
De plus, le fait qu’il soit orienté héritage peut rendre sa compréhension compliquée. Tous ces éléments peuvent dissuader le développeur de se lancer sur ce type de conception.
Un des exemples de l’utilisation de ce pattern dans le JDK :
new DataInputStream(new BufferedInputStream(new FileInputStream(new File("PATH"))));
Nous allons voir dans la deuxième partie comment revisiter le pattern decorator en Java 8 avec des lambdas et des fonctions. Nous verrons également que l'écriture est plus simple et beaucoup moins verbeuse.
2) Decorator en Java 8
Nous allons commencer par montrer les méthodes "static" proposées par la garbage class "Expenses" :
public class Expenses {
public static double getTransportExpenses(final double turnover) {
return turnover - 2400;
}
public static double getOperatingExpenses(final double turnover) {
return turnover - 15000;
}
public static double getDeductibleTaxes(final double turnover) {
return turnover - 3000;
}
public static double getRemuneration(final double turnover) {
return turnover - 45000;
}
public static double getExceptionalExpenses(final double turnover) {
return turnover - 2000;
}
}
Chaque méthode "static" effectue le calcul souhaité en se basant sur un double en entrée et en sortie.
Nous allons ensuite montrer différentes façons d’implémenter ce pattern en Java 8.
a) Decorator en Java 8 avec de la composition de fonctions
Comme pour la partie Java 7 nous allons écrire une classe contenant le calcul par défaut :
public class DefaultProfitCalculator implements DoubleUnaryOperator {
@Override
public double applyAsDouble(final double operand) {
return Expenses.getTransportExpenses(operand);
}
}
Cette classe implémente une interface fonctionnelle (= à une fonction) proposée par défaut dans le JDK 8 "DoubleUnaryOperator". Cette fonction prend un double en entrée et retourne un double en sortie, ce qui correspond à la signature des calculs présents dans la classe Expenses. L’implémentation de la méthode applyAsDouble est effectuée avec un calcul par défaut.
Et c’est tout… nous allons pouvoir désormais écrire notre decorator en Java 8 via un test :
@Test
public void givenTurnover_whenComposingAllDecoratorsWithAndThen_thenCorrectResult() {
// Given.
final double turnover = 100000;
// When.
final double profit = new DefaultProfitCalculator()
.andThen(Expenses::getOperatingExpenses)
.andThen(Expenses::getDeductibleTaxes)
.andThen(Expenses::getRemuneration)
.andThen(Expenses::getExceptionalExpenses)
.applyAsDouble(turnover);
// Then.
assertThat(profit).isNotNull().isEqualTo(32600);
}
N’est-ce pas grandiose ? Nous avons pu réecrire le pattern décorator avec très peu de lignes de code.
Le JDK 8 donne la possibilité de composer plusieurs fonctions entre elles via la "default" méthode "andThen". Cette méthode est proposée dans les fonctions par défaut du JDK, dont le DoubleUnaryOperator fait partie.
On démarre à partir de la classe DefaultProfitCalculator, et via "andThen" on compose ce traitement avec une autre fonction. Dans cet exemple, des appels par référence de méthode ont été privilégiés afin d’avoir un code plus concis et plus expressif (Expenses::getOperatingExpenses), mais des lambdas expression auraient également pu faire l’affaire (e → Expenses.getOperatingExpenses(e)).
Ainsi, il devient très simple d’ajouter ou de supprimer des decorators. Dans l’exemple ci-dessous nous supprimons le decorator qui représente les dépenses exceptionnelles :
@Test
public void givenTurnover_whenNotComposingAllDecoratorsWithAndThen_thenCorrectResult() {
// Given.
final double turnover = 100000;
// When.
final double profit = new DefaultProfitCalculator()
.andThen(Expenses::getOperatingExpenses)
.andThen(Expenses::getDeductibleTaxes)
.andThen(Expenses::getRemuneration)
.applyAsDouble(turnover);
// Then.
assertThat(profit).isNotNull().isEqualTo(34600);
}
Dans les parties suivantes, nous allons voir d’autres façons d’implémenter le pattern decorator en Java 8.
b) Decorator en Java 8 avec l’API Stream
Nous allons voir maintenant qu’il est possible d’implémenter le pattern decorator avec l’API Stream.
Nous allons créer une classe appelée "StreamDecorator" correspondant à une enum singleton (instance unique). Cette classe contient une méthode appelée "calculateProfit" qui sera exposée au client de l’API :
public enum StreamDecorator {
// Single instance.
INSTANCE;
public double calculateProfit(final double turnover, final DoubleUnaryOperator... operators) {
return Stream.of(operators).reduce(DoubleUnaryOperator.identity(), DoubleUnaryOperator::andThen)
.applyAsDouble(turnover);
}
}
Le principe ici est de passer une suite de fonctions representée par des DoubleUnaryOperator (équivalent à un tableau de fonctions). La méthode prend également en paramètre le CA.
L’API stream propose une "factory method" "of" permettant d’initialiser une Stream à partir d’un tableau. Nous utilisons ensuite la méthode "reduce" qui permet de réduire les éléments du flux à une seule valeur.
En programmation fonctionnelle le reduce correspond à du "fold". Le principe est de passer 2 fonctions, une initiale (et valeur par défaut) et l’autre permettant d’accumuler des éléments. Il devient très simple avec ce type d’opérateur de calculer la somme des élements d’une liste.
Par exemple :
reduce(0, (a, b) -> a + b)
On considère dans cet exemple que a et b sont des entiers.
La fonction initiale est la valeur 0. Le calcul va commencer avec la valeur par défaut et l’accumulateur "(a, b) → a + b" va permettre de sommer chaque élement de la liste au fur et à mesure (somme le résultat de l’itération précédente avec le résultat de l’itération en cours). Si la structure est vide la valeur initiale est retournée, c’est-à-dire 0.
Notre exemple suit le même principe, la fonction initiale est "DoubleUnaryOperator.identity()" et l’accumulateur est "DoubleUnaryOperator::andThen" ou "(ope1, ope2) → ope1.andThen(ope2)". Comme vu précédemment, à chaque itération "andThen" va permettre de composer la fonction précédente avec la fonction en cours. Si la structure est vide "DoubleUnaryOperator.identity()" sera retourné (dans ce cas une fonction vide).
Voici le code du test utilisant un exemple avec tous les decorators :
@Test
public void givenTurnover_whenComposingAllDecoratorsWithStream_thenCorrectResult() {
// Given.
final double turnover = 100000;
// When.
final double profit = StreamDecorator.INSTANCE.calculateProfit(turnover
, new DefaultProfitCalculator()
, Expenses::getOperatingExpenses
, Expenses::getDeductibleTaxes
, Expenses::getRemuneration
, Expenses::getExceptionalExpenses);
// Then.
assertThat(profit).isNotNull().isEqualTo(32600);
}
La méthode "calculateProfit" est appelée avec le CA et la liste des fonctions decorator séparées par une virgule (rendu possible grâce au paramètre de la méthode suivante ⇒ "DoubleUnaryOperator… operators").
De nouveau le client de l’API dispose d’un traitement très souple, facilement modifiable et évolutif.
Voici un exemple sans le decorator "ExceptionalExpenses" :
@Test
public void givenTurnover_whenNotComposingAllDecoratorsWithStream_thenCorrectResult() {
// Given.
final double turnover = 100000;
// When.
final double profit = StreamDecorator.INSTANCE.calculateProfit(turnover
, new DefaultProfitCalculator()
, Expenses::getOperatingExpenses
, Expenses::getDeductibleTaxes
, Expenses::getRemuneration);
// Then.
assertThat(profit).isNotNull().isEqualTo(34600);
}
b) Decorator en Java 8 avec une API fluent
Dans cette dernière partie nous allons voir qu’il est possible d’implémenter le pattern decorator via une API "fluent", permettant d’indiquer clairement au client de l’API les traitements effectués.
L’objectif va être de créer une classe wrapper de type builder afin de composer nos fonctions de façon fluide.
Voici le code complet de la classe appelée FluentDecorator :
public final class FluentDecorator<T> {
private final T value;
private final Function<T, T> function;
private FluentDecorator(final T value, Function<T, T> function) {
this.value = value;
this.function = function;
}
public static <T> FluentDecorator<T> from(final T value) {
Objects.requireNonNull(value);
return new FluentDecorator<>(value, Function.identity());
}
public FluentDecorator<T> with(final Function<T, T> otherFunction) {
return new FluentDecorator<>(this.value, function.andThen(otherFunction));
}
public T calculate() {
return this.function.apply(value);
}
}
Le fluent decorator se base sur une valeur de type T (n’importe quel type via les Generics en Java) et enveloppe une Function<T,T>, c’est-à-dire une fonction prenant un élément de type T en entrée en retournant un élement du même type (comme le DoubleUnaryOperator).
private final T value;
private final Function<T, T> function;
Un constructeur privé prend en paramètre les 2 éléments expliqués précédemment (l’aspect privé permet d’empêcher l’instantiation en dehors de la classe) :
private FluentDecorator(final T value, Function<T, T> function) {
this.value = value;
this.function = function;
}
Une "static factory method" est exposée au client de l’API pour initialiser la classe avec un nom parlant. Cette méthode s’appelle "from" et prend en paramètre la valeur qui servira de base de calcul, dans notre cas le CA.
Un contrôle est effectué sur la valeur afin de renvoyer une "runtime exception" si elle est nulle. Le constructeur de la classe est appelé avec cette valeur et une fonction initiale vide (ainsi le paramètre global "function" de la classe ne sera pas nul et ceci permettra d'éviter des nullPointerException).
public static <T> FluentDecorator<T> from(final T value) {
Objects.requireNonNull(value);
return new FluentDecorator<>(value, Function.identity());
}
La composition des decorators se fait via la méthode "with" qui prend en paramètre la fonction à ajouter à la composition générale. Le but est de rappeler de nouveau le constructeur de la classe, mais cette fois-ci de la façon suivante :
new FluentDecorator<T>(this.value, function.andThen(otherFunction))
La fonction globale est composée avec "otherFunction" (via andThen). La "value" globale (this.value) et le résultat de cette composition sont repassés en paramètres de la classe FluentDecorator. Voici le code la méthode :
public FluentDecorator<T> with(final Function<T, T> otherFunction) {
return new FluentDecorator<T>(this.value, function.andThen(otherFunction));
}
Lors de l’appel à la méthode "with", le traitement est lazy, non évalué et non executé (lazy evaluation). Ceci constitue l’un des appports très interessants proposés par la programmation fonctionnelle. En effet l’implémentation de la fonction diffère du moment où elle est évaluée, ce qui rend le code très intéressant car on peut décider de l’exécuter plus tard dans le programme.
Dans notre cas une méthode finale permet d’exécuter la fonction globale à la classe avec la valeur globale "function.apply(value)". Ainsi le résultat de la fonction, dans notre cas le calcul du bénéfice, est récupéré :
public T calculate() {
return this.function.apply(value);
}
Voici le code du test avec tous les decorators :
@Test
public void givenTurnover_whenComposingAllDecoratorsWithFluentStyle_thenCorrectResult() {
// Given.
final double turnover = 100000;
// When.
final double profit = FluentDecorator
.from(turnover)
.with(Expenses::getTransportExpenses)
.with(Expenses::getOperatingExpenses)
.with(Expenses::getDeductibleTaxes)
.with(Expenses::getRemuneration)
.with(Expenses::getExceptionalExpenses)
.calculate();
// Then.
assertThat(profit).isNotNull().isEqualTo(32600);
}
Le test sans le decorator "ExceptionalExpenses" :
@Test
public void givenTurnover_whenNotComposingAllDecoratorsWithFluentStyle_thenCorrectResult() {
// Given.
final double turnover = 100000;
// When.
final double profit = FluentDecorator
.from(turnover)
.with(Expenses::getTransportExpenses)
.with(Expenses::getOperatingExpenses)
.with(Expenses::getDeductibleTaxes)
.with(Expenses::getRemuneration)
.calculate();
// Then.
assertThat(profit).isNotNull().isEqualTo(34600);
}
3) Conclusion
Cet article montre à quel point les lambdas et les fonctions, apportent au programme et aux développeurs. Le code en devient plus concis, expressif et paramètrable.
De plus la programmation fonctionnelle est orientée composition, ce qui rend le code élégant, maintenable, évolutif et très compréhensible.
Un des principes des pattern du GOF est de "favoriser la composition à l’héritage", cependant on constate que pas mal d’entre eux sont orientés héritage, ce qui provoque pas mal de problèmes :
- Le moindre changement des objets parents, provoque un code, qui ne fonctionne plus pour les éléments qui en héritent.
- Le code n’est pas évolutif.
- Le code est verbeux.
Les lambdas et fonctions permettent de donner beaucoup plus de possibiltés au développeur lorsqu’il fait sa conception de code. Certains pattern du GOF deviennent obsolètes et peuvent être revisités très élégamment en Java 8.
Le code présenté dans cet article est accessible via les liens github suivants :
https://github.com/tosun-si/java8-example