Utiliser au mieux la plomberie fournie par le langage de programmation ou exprimer au mieux le domaine métier ? C'est le dilemme habituel dans un langage de programmation tel que Java.
Voyons cela de plus près au travers de quelques exemples, et comment nous parvenons à résoudre ce problème avec plus ou moins de bonheur selon le langage de programmation choisi.
Type standard et type métier
Prenons l'exemple du prix et de la quantité en e-commerce. Nous souhaitons créer un type Price
autour d'un double et un type Quantity
autour d'un int afin d'exprimer au mieux le métier avec des types. Cela amène aussi la protection du typage pour éviter de passer un int
à la place d'un autre par erreur.
En Java, il faut donc créer une classe pour chaque :
public final class Price {
private final double value;
public Price(double value) { this.value = value; }
...
}
public final class Quantity {
private final int value;
public Quantity(int value) { this.value = value; }
...
}
Maintenant nous voulons multiplier le prix par la quantité pour calculer le prix total, qui doit être aussi de type Price
:
public final class Price {
...
public Price multiply(double multiplicand) {
return new Price(this.value * multiplicand);
}
}
public final class Quantity {
...
public double asDouble() { return value; }
}
Nous aimerions pouvoir passer directement une instance de Quantity
en paramètre, mais cela nous oblige soit à sortir la valeur primitive :
myPrice.multiply(myQuantity.asDouble());
soit à modifier la méthode multiply()
pour accepter le type Quantity
, ce qui la rend désormais spécifique à ce type, et donc couplée par la même occasion :
Price multiply(Quantity quantity) {
return new Price(this.value * quantity.asDouble());
}
Cela dit, d'un point vue métier, nous pouvons admettre qu'il est possible de multiplier un prix par une quantité.
En Haskell, la notion de synonyme ou alias permet de donner un nom plus métier au type :
type Price = Double
type Quantity = Int
Nous avons un type sur-mesure, mais que nous pouvons aussi utiliser comme un entier quand nous le souhaitons, car il reste aussi un entier :
multiply :: Price -> Quantity -> Price
multiply p q = p * (asDouble q)
where asDouble = fromIntegral
Notez qu'on peut appeler la fonction multiply
avec n'importe quel entier.
multiply 4.5 5 `shouldBe` 22.5
multiply 4.5 (6 :: Int) `shouldBe` 27.0
Si nous souhaitons faire des types wrapper comme en Java, nous utilisons des newtype
:
newtype Price = Price Double
deriving (Eq, Show)
newtype Quantity = Quantity Int
deriving (Show)
La fonction multiply
s'appuie alors sur des "déconstructeurs" pour extraire les valeurs du prix et de la quantité :
multiply :: Price -> Quantity -> Price
multiply (Price p) (Quantity q) = Price (p * (asDouble q))
where asDouble = fromIntegral
Une fois le calcul réalisé, nous construisons le prix avec le résultat. Pour appeler la fonction, il faut créer un prix et une quantité :
(Price 4.5) `multiply` (Quantity 5) `shouldBe` Price 22.5
Là, nous sommes donc vraiment "type-safe". De plus, comme la fonction multiply
a deux arguments, nous pouvons l'infixer. Il plus naturel de dire "le prix multiplié par la quantité".
Une autre façon de contourner le problème consistant à être à la fois un type standard et un type sur-mesure métier est la conversion implicite (implicit cast en C++, C# ou Scala). Java supporte une construction similaire, le "unboxing", mais seulement pour les types primitifs.
Monoid standard et monoid métier
Maintenant, nous souhaitons additionner des prix. Nous définissons donc une méthode add() à la class Price :
Price add(Price other) {
return other != null ? new Price(value + other.value) : this;
}
Et par confort, j'ajoute le prix zéro, bien utile dans de nombreux cas :
public static final Price ZERO = new Price(0.);
Nous avons donc de fait un type qui obéit à la structure et aux propriétés d'un Monoid. Pas d'inquiétude! Si vous ne savez pas ce que c'est, ce n'est pas indispensable pour cet article.
Imaginons que nous avons une interface Monoid disponible que nous utilisons déjà un peu partout :
public interface Monoid<M extends Monoid<M>> {
M append(M other);
M empty();
default M concat(M... ms) {
return Stream.of(ms).reduce(empty(), Monoid::append);
}
}
Nous souhaitons alors que Price implémente l'interface Monoid :
class Price implements Monoid<Price> {
...
public Price add(Price otherPrice) {
return append(otherPrice);
}
public Price total(Price... prices) {
return concat(prices);
}
@Override
public Price append(Price other) {
return other != null ? new Price(value + other.value) : this;
}
@Override
public Price empty() {
return ZERO;
}
}
Cela nous oblige à définir les méthodes obligatoires génériques append()
et empty()
, qui renvoient vers les membres expressifs du métier, la méthode add()
et le champ ZERO. Cela va encombrer la classe.
En F# ou Haskell, il est possible là aussi de définir des alias de fonctions. Cela permet d'avoir une fonction nommée selon le domaine métier, tout en étant en même temps l'implémentation d'une fonction définie dans une "interface" sous un autre nom :
import Data.Monoid
instance Monoid Price where
mempty = Price 0
mappend (Price p1) (Price p2) = Price (p1 + p2)
add :: Price -> Price -> Price
add = mappend
total :: [Price] -> Price
total = mconcat
(Price 10.0) `mappend` (Price 20.0) `shouldBe` Price 30.0
(Price 10.0) `add` (Price 20.0) `shouldBe` Price 30.0
mconcat [Price 12.0, Price 13.0, Price 14.0] `shouldBe` Price 39.0
total [Price 12.0, Price 13.0, Price 14.0] `shouldBe` Price 39.0
Predicate standard et predicate métier
Un dernier exemple pour la route, cette fois-ci avec les Predicate
Java 8 et une astuce !
Nous avons de nombreuses règles de dégressivité sur les quantités, que nous avons modélisées sous forme de critères :
public interface QuantityCriteria {
boolean isSatisfied(Quantity q);
}
Le nommage correspond au langage du métier dans notre domaine. Cependant, il est évident que notre critère n'est autre qu'un prédicat, et pour pouvoir utiliser toute la plomberie fournie par le langage de programmation autour des prédicats, il faudrait implémenter l'interface Predicate
standard :
public interface QuantityCriteria extends Predicate<Quantity> {
boolean isSatisfied(Quantity q);
}
Ce qui oblige alors à définir en plus la méthode obligatoire test()
, un petit encombrement :
public interface QuantityCriteria extends Predicate<Quantity> {
boolean isSatisfied(Quantity q);
boolean test(Quantity q);
}
En outre, il faut que la méthode test()
renvoie à la méthode isSatisfied()
. Pour éviter de faire celà dans chaque implémentation concrète, nous pouvons utiliser une méthode default dans notre interface:
public interface QuantityCriteria extends Predicate<Quantity> {
boolean isSatisfied(Quantity q);
default boolean test(Quantity q) { return isSatisfied(q); }
}
Et voilà ! Nous pouvons donc désormais passer n'importe quelle instance de QuantityCriteria
à toute fonction qui attend un Predicate
en paramètre. C'est cool!
Le QuantityCriteria
peut également être implémenté par une lambda, notamment dans la classe Quantity
pour accéder à sa valeur sans casser l'encapsulation :
public final class Quantity {
...
public static QuantityCriteria criteria(IntPredicate predicate) {
return quantity -> predicate.test(quantity.value);
}
}
Imaginons maintenant que nous voulions composer plusieurs QuantityCriteria
avec un AND logique :
QuantityCriteria over10 = Quantity.criteria(v -> v > 10);
QuantityCriteria below100 = Quantity.criteria(v -> v < 100);
Predicate between10And100 = over10.and(below100);
Notez que la méthode and
intégrée retourne un simple Predicate
, et non une QuantityCriteria
. Dommage ! Pourtant, nous souhaiterions utiliser les critères composés, de type Predicate<Quantity>
, dans la méthode isEligible
qui attend un QuantityCriteria
, idéalement :
boolean eligible = myPurchaseHistory.isEligible(between10And100);
Et bien, c'est impossible ! En contournement, nous pouvons enrichir QuantityCriteria
d'une méthode and
d'adaptation :
public interface QuantityCriteria extends Predicate<Quantity> {
boolean isSatisfied(Quantity q);
default boolean test(Quantity q) { return isSatisfied(q); }
default QuantityCriteria and(QuantityCriteria other) {
return quantity -> Predicate.super.and(other).test(quantity);
}
}
Le problème avec cette approche est qu'il faudra ensuite redéfinir toutes les autres méthodes autour des Predicate
, par exemple : or
, all
, any
, not
.
Mais tout n'est pas perdu ! Par exemple, nous pouvons utiliser une méthode référence (équivalent à une lambda) :
myHistoryPurchase.isEligible(between10And100::test);
Ou encore enrichir QuantityCriteria
avec un adapter :
public interface QuantityCriteria extends Predicate<Quantity> {
boolean isSatisfied(Quantity q);
default boolean test(Quantity q) {
return isSatisfied(q);
}
static QuantityCriteria from(Predicate<Quantity> predicate) {
return predicate::test;
}
}
Ce qui permet ensuite de convertir à la volée un Predicate générique dans son équivalent typé sur-mesure métier :
myHistoryPurchase.isEligible(QuantityCriteria.from(between10And100));
Notez en bonus que QuantityCriteria
n'a qu'une seule méthode abstraite, et est donc une FunctionalInterface
. Il est donc possible de passer une instance de QuantityCriteria
partout où une lambda de même signature est attendue :
myStream.filter(myQuantityCritera);
Dans les cercles d'amateurs de programmation fonctionnelle, il existe bien souvent un biais très fort pour le générique, au détriment parfois de l'expressivité du langage et des concepts métiers. Ce n'est pas une fatalité.
Et si ça devenait un jeu, d'exprimer le métier le plus littéralement possible, dans ses termes propres, tout en gardant tous les avantages propres aux langages et à leurs écosystèmes disponibles ?
Cet article a été écrit en collaboration avec Cyrille Martraire.
Si vous souhaitez retrouver les sources de cet article, elles sont disponibles sur mon Github.
La programmation est toujours compliqué. Merci pour ton aide.
Merci Patrick pour cet article clair et détaillé sur un sujet peu documenté. On y voit les limites de chaque paradigme : fonctionnel plus abstrait vs orienté-objet plus expressif mais avec beaucoup de plomberie / boilerplate. Un crafter qui souhaite améliorer son code objet en s’inspirant de la FP sera confronté au dilemme évoqué, d’autant plus dans un langage comme TypeScript plus souple pour établir des passerelles entre les 2 paradigmes.
Concernant la partie sur les monoïdes, je trouve que faire des alias (empty = ZERO…) pollue autant le code Java que Haskell. C’est juste que c’est plus condensé en Haskell. L’interface Monoid en Java sert à 2 choses : fournir une méthode par défaut concat et en quelque sorte documenter le code. Ce 2e point peut être réalisé en combinant annotations personnalisées telle que @Monoid(empty : “ZERO”, append : “add”) et au besoin une interface vide / marker Monoid (exploitable en C# pour fournir la méthode concat sous forme de méthode d’extension). Qu’en penses-tu ?