Blog Arolla

Il était une fois la fonction reduce

Imaginons une collection d'entiers:

    Collection<Integer> integers = Arrays.asList(1, 2, 3, 4)

Dans une approche impérative, lorsque nous voulons calculer une valeur à partir d'une liste de valeurs, nous devons utiliser une boucle sur la liste et accumuler le résultat à l'extérieur de la boucle. Par exemple, pour calculer la somme des entiers, nous procédons comme suit :

    int sum = 0; 
    for (int i : integers) { 
      sum += i; 
    } 

Dans une approche fonctionnelle, nous déléguons ce traitement à une fonction nommée reduce. Pour sommer les entiers, nous procédons comme ceci :

    reduce((sum, i) -> sum + i, 0, integers)

Le traitement à effectuer est passé en paramètre en définissant une fonction de réduction ((sum, i) -> sum + i).

Savez-vous qu'avec cette fonction de réduction on peut définir les fonctions mapet filter? On peut même combiner les mapet filter pour former une fonction de réduction qui s'applique à une collection sans avoir une d’intermédiaire. Cette technique s'appelle les Transducers. Les Transducers sont un concept qui vient de l'univers Clojure. Mais, même en Clojure, tout le monde ne les connait pas !
En ce qui me concerne, je les ai découverts grâce aux excellentes présentations d'Arnaud Lemaire (@lilobase). Il utilise des langages dynamiques non typés (python, PHP, etc...) pour sa démonstration.
J'ai donc décidé de relever le défi : coder les Transducers en Java 8 avec ses types génériques qui ne sont pas toujours simples à dompter. Comme si cela ne suffisait pas, je vais en profiter pour montrer comment cela fonctionne ! Ainsi, nous verrons que ce concept devient inévitable pour tout craftman qui se respecte refactorant régulièrement son code. N’est-ce pas ?

A l’aide d’un exemple concret (calculer le montant total des factures dues pour un mois donné) nous partirons d’une implémentation impérative et nous nous dirigerons vers une implémentation fonctionnelle. Au passage, nous verrons comment écrire les fonctions reduce, map et filter.
Ainsi, en refactorant successivement notre fonction de calcul nous aboutirons au développement et à l'utilisation des Transducers.

Une collection de factures

Pour faire cette démonstration, nous utiliserons les factures suivantes :

Liste des factures
Due pour le mois Montant H.T. TVA Quantité Payée
Octobre 36.0 20 % 1 Non
Septembre 28.0 10 % 1 Oui
Octobre 17.0 5 % 2 Non
Octobre 27.0 5 % 2 Oui

En Java, pour représenter le tableau ci-dessus, nous construisons la collection de factures (Invoice) comme suit:

    
    static Collection<Invoice> invoices() {
        return Arrays.asList( //
                anInvoice(invoiceBuilder -> {
                    invoiceBuilder.dueTo(Month.OCTOBER);
                    invoiceBuilder.amountExcludeVAT(36.0);
                    invoiceBuilder.vatRate(20.0);
                    invoiceBuilder.quantity(1);
                    invoiceBuilder.mustBePaid();
                }), //
                anInvoice(invoiceBuilder -> {
                    invoiceBuilder.dueTo(Month.SEPTEMBER);
                    invoiceBuilder.amountExcludeVAT(28.0);
                    invoiceBuilder.vatRate(10.0);
                    invoiceBuilder.quantity(1);
                    invoiceBuilder.isPaid();
                }), //
                anInvoice(invoiceBuilder -> {
                    invoiceBuilder.dueTo(Month.OCTOBER);
                    invoiceBuilder.amountExcludeVAT(17.0);
                    invoiceBuilder.vatRate(5.0);
                    invoiceBuilder.quantity(2);
                    invoiceBuilder.mustBePaid();
                }), //
                anInvoice(invoiceBuilder -> {
                    invoiceBuilder.dueTo(Month.OCTOBER);
                    invoiceBuilder.amountExcludeVAT(27.0);
                    invoiceBuilder.vatRate(5.0);
                    invoiceBuilder.quantity(2);
                    invoiceBuilder.isPaid();
                }));
    }

Les factures sont définies comme suit:

    public class Invoice {
        private final Month dueTo;
        private final Amount amountExcludeVAT;
        private final Quantity quantity;
        private final Rate vatRate;
        private final boolean paid;

        public static Invoice anInvoice(Consumer<Builder> consumer) {
            Builder builder = new Builder();
            consumer.accept(builder);
            return builder.build();
        }

        private Invoice(Month dueTo, Amount amountExcludeVAT, Quantity quantity, 
                        Rate vatRate, boolean paid) {
            this.dueTo = dueTo;
            this.amountExcludeVAT = amountExcludeVAT;
            this.quantity = quantity;
            this.vatRate = vatRate;
            this.paid = paid;
        }

        public boolean isDueFor(Month month) { return !paid && dueTo == month; }

        public Amount totalIncludingVAT() { 
            return amountExcludeVAT.add(VATAmount()).multiply(quantity); 
        }
 
        private Amount VATAmount() { 
             return amountExcludeVAT.divideByHundred().multiply(vatRate); 
        }

        public static class Builder {
            private Month dueMonth;
            private Amount amountExcludeVAT;
            private Quantity quantity;
            private Rate vatRate;
            private boolean paid;

            private Builder() {}

            public void dueTo(Month month) { dueMonth = month; }

            public void amountExcludeVAT(double amountExcludeVAT) { 
                this.amountExcludeVAT = Amount.of(amountExcludeVAT); 
            }

            public void quantity(long quantity) { this.quantity = Quantity.of(quantity); }

            public void vatRate(double vatRate) { this.vatRate = Rate.of(vatRate); }

            public void mustBePaid() { paid = false; }

            public void isPaid() { paid = true; }

            private Invoice build() {
                return new Invoice(dueMonth, amountExcludeVAT, quantity, vatRate, paid);
            }
        }
    }

    public class Amount {
        public static final Amount HUNDRED = Amount.of(100);
        public static final Amount ZERO = Amount.of(BigDecimal.ZERO);
    
        private final BigDecimal value;

        public static Amount of(double amountAsDouble) {
            return Amount.of(BigDecimal.valueOf(amountAsDouble));
        }
    
        public static Amount of(BigDecimal amountAsBigDecimal) { 
            return new Amount(Objects.requireNonNull(amountAsBigDecimal)); 
        }

        private Amount(BigDecimal value) {
            this.value = value.setScale(2, BigDecimal.ROUND_DOWN);
        }

        public Amount add(Amount otherAmount) {
            return new Amount(value.add(otherAmount.value));
        }

        public Amount multiply(Quantity quantity) {
            return Amount.of(value.multiply(quantity.asBigDecimal()));
        }

        public Amount divideByHundred() {
            return Amount.of(value.divide(Amount.HUNDRED.value, 2, BigDecimal.ROUND_DOWN));
        }

        public Amount multiply(Rate vatRate) {
            return Amount.of(value.multiply(vatRate.asBigDecimal()));
        }
    }

    class Quantity  {
        private final long value;

        static Quantity of(long value) { return new Quantity(value); }

        private Quantity(long value) { this.value = value; }

        BigDecimal asBigDecimal() { return BigDecimal.valueOf(value); }

    }

    class Rate {
        private final double value;

        static Rate of(double value) { return new Rate(value); }

        private Rate(double value) { this.value = value; }

        BigDecimal asBigDecimal() { return BigDecimal.valueOf(value); }

    }

Nous allons calculer le montant total des factures dues pour le mois d'octobre. On commence donc par un test :

    assertThat(getAmountOf(Month.OCTOBER)).isEqualTo(Amount.of(78.90));

Première implémentation en style impératif

    Amount getAmountOf(Month month) {
        Amount totalAmount = Amount.ZERO;

        for (Invoice invoice: invoices()) {
            if (invoice.isDueFor(month)) {
                totalAmount = totalAmount.add(invoice.totalIncludingVAT());
            }
        }
        return totalAmount;
    }

Cette implémentation se fait avec les étapes suivantes :

  • Nous définissons une variable `totalAmount` et nous l'initialisons avec la valeur initiale zéro.
  • Puis nous bouclons sur la liste des factures.
  • Pour chaque facture nous vérifions si elle est due pour le mois donné.
  • Si la facture est due:
    • Nous calculons son montant toutes taxes comprises;
    • nous ajoutons son montant toutes taxes comprises au total des factures dues.

Deuxième implémentation avec un filter, un map et un reduce

    Amount getAmountOf(Month month) {
        List<Invoice> invoicesOfTheMonth = 
            filter(invoice -> invoice.isDueFor(month), invoices());
        List<Amount> includeVATAmounts = 
            map(Invoice::totalIncludingVAT, invoicesOfTheMonth);
        return reduce(Amount::add, Amount.ZERO, includeVATAmounts);
    }

Avec cette implémentation, nous avons les étapes suivantes :

  • Sélectionner toutes les factures dues dans le mois spécifié (filter).
  • Transformer la liste des factures dues en une liste de montants toutes taxes comprises (map).
  • Calculer la somme des montants dus pour la liste de montants toutes taxes comprises (reduce).

Dans cette implémentation nous voyons que:

  • Chaque étape à réaliser correspond à une déclaration (on définit ce que c'est et non comment on le fait).
  • Les paramètres en entrée ne sont pas modifiés.
  • Les étapes et comportements sont isolés.
  • Les effets de bords sont supprimés.
  • Les comportements peuvent être réutilisés.
  • Le code est plus modulaire.

Cette implémentation pourrait être réalisée avec une version de Java inférieure à 8, en utilisant les classes anonymes :

    Amount getAmountOf(Month month) {
        List<Invoice> invoicesOfTheMonth = filter(new Predicate<Invoice>() {
            @Override
            public boolean test(Invoice invoice) {
                return invoice.isDueFor(month);
            }
        }, invoices());
        List<Amount> includeVATAmounts = map(new Function<Invoice, Amount>() {
            @Override
            public Amount apply(Invoice invoice) {
                return invoice.totalIncludingVAT();
            }
        }, invoicesOfTheMonth);
        return reduce(new BiFunction<Amount, Amount, Amount>() {
            @Override
            public Amount apply(Amount amount, Amount otherAmount) {
                return amount.add(otherAmount);
            }
        }, Amount.ZERO, includeVATAmounts);
    }

Il faut aussi définir les interfaces suivantes:

Un prédicat

    public interface Predicate<T> {
        boolean test(T t);
    }

Une fonction avec un seul paramètre

    public interface Function<T, R> {
        R apply(T t);
    }

Une fonction avec deux paramètres

    public interface BiFunction<T,U,R> {
        R apply(T t, U u);
    }

L'écriture est moins concise qu'avec les lambdas et les méthodes références.

Pour exécuter cela et montrer comme ça marche nous allons implémenter de façon impérative les méthodes reduce, map et filter.

reduce

    static <T, R> R reduce(BiFunction<R, T, R> reducer, 
                           R identity, Collection<T> collection) {
        R accumulator = identity;
        for (T element: collection) {
            accumulator = reducer.apply(accumulator, element);
        }
        return accumulator;
    }

La fonction reduce prend en paramètre:

  • La fonction de réduction (reducer);
  • L'élément d'identité ou l'élément neutre (identity);
  • Les éléments à réduire (collection).

Elle retourne le résultat après avoir appliqué la fonction de réduction autant de fois qu'il y a d'éléments à réduire.

La fonction de réduction

    R reducer(R accumulator, T element)

C'est une fonction qui prend deux paramètres:

  • Le résultat accumulé (accumulator);
  • L'entrée à traiter (element).

Elle retourne le résultat accumulé (result) après traitement.

En java 8 nous pouvons écrire reduce de manière plus fonctionnelle avec l'API Stream:

    static <T, R> R reduce(BiFunction<R, T, R> reducer, 
                           R identity, 
                           BinaryOperator<R> combiner, 
                           Collection<T> collection) {
        return collection.stream()
                         .reduce(identity, reducer, combiner);
    }

Il faut alors un paramètre supplémentaire requis (combiner) qui est utilisé pour reconstituer le résultat final si la fonction reduce s'est exécutée sur des Stream parallèles.

    static <T, R> R parallelReduce(BiFunction<R, T, R> reducer, 
                                   R identity, 
                                   BinaryOperator<R> combiner,
                                   Collection<T> collection) {
        return collection.parallelStream()
                         .reduce(identity, reducer, combiner);
    }

map

    static <T, R> List<R> map(Function<T, R> mapper, 
                              Collection<T> collection) {
        List<R> accumulator = new ArrayList<>();
        for (T element: collection) {
            accumulator.add(mapper.apply(element));
        }
        return accumulator;
    }

La fonction map prend en paramètre :

  • Une fonction pour transformer tous les éléments fournis en entrée (mapper)
  • L'ensemble des éléments à transformer (collection).

Elle retourne la liste des éléments transformés.

La fonction de transformation

    R mapper(T element) 

C'est une fonction qui prend en paramètre l'élément à transformer.
Elle retourne l'élément transformé.

En java 8 nous pouvons écrire map de manière plus fonctionnelle avec l'API Stream:

    static <T, R> List<R> map(Function<T, R> mapper, 
                              Collection<T> collection) {
        return collection.stream()
                         .map(mapper).collect(Collectors.toList());
    }

filter

    static  <T> List<T> filter(Predicate<T> predicate, 
                               Collection<T> collection) {
        List<T> accumulator = new ArrayList<>();
        for(T element: collection) {
            if (predicate.test(element)) {
                accumulator.add(element);
            }
        }
        return accumulator;
    }

La fonction filter prend en paramètre:

  • Un prédicat qui est utilisé pour sélectionner les éléments (predicate).
  • L'ensemble des éléments à sélectionner en fonction du prédicat (collection).

Elle retourne tous les éléments sélectionnés (ceux pour lesquels le prédicat est vrai).

Le prédicat

    boolean predicate(T element)

C'est une fonction qui prend l'élément à tester en paramètre.
Elle retourne true si le prédicat est vrai pour l'élément, false sinon.

En java 8 nous pouvons écrire filter de manière plus fonctionnelle avec l'API Stream:

    static <T> List<T> filter(Predicate<T> predicate, 
                              Collection<T> collection) {
        return collection.stream()
                         .filter(predicate)
                         .collect(Collectors.toList());
    }

reducepeut accumuler des non-scalaires

Supposons que nous voulions compter le nombre d'occurence de chaque lettre dans la phrase "Il était une fois une fonction reduce" . Nous devrions obtenir le résultat suivant:

    
     Map<Character, Long> expectedResult = new HashMap<>();
        expectedResult.put(' ', 6L);
        expectedResult.put('I', 1L);
        expectedResult.put('a', 1L);
        expectedResult.put('c', 2L);
        expectedResult.put('d', 1L);
        expectedResult.put('e', 4L);
        expectedResult.put('f', 2L);
        expectedResult.put('i', 3L);
        expectedResult.put('l', 1L);
        expectedResult.put('n', 4L);
        expectedResult.put('o', 3L);
        expectedResult.put('r', 1L);
        expectedResult.put('s', 1L);
        expectedResult.put('t', 3L);
        expectedResult.put('u', 3L);
        expectedResult.put('é', 1L);

        assertThat(result).isEqualTo(expectedResult);

Pour construire le résultat attendu, ici une Map, nous pouvons utiliser reduce:

    List<Character> listOfCharacters = 
         listOfCharacters("Il était une fois une fonction reduce");
    
    Map<Character, Long> result = reduce(accLetter, 
                                         new HashMap<>(), 
                                         listOfCharacters);

Nous transformons la phrase en liste de caractères comme suit:

    
    private List<Character> listOfCharacters(String s) {
        return s.chars()
                .mapToObj(c -> (char) c).collect(toList());
    }

La fonction de réduction (accLetter) est la suivante:

        
    final BiFunction<Map<Character, Long>, Character,
                     Map<Character, Long>> accLetter =
        (accumulator, letter) -> {
            accumulator.put(letter,
                       accumulator.getOrDefault(letter, 0L) + 1L);
            return accumulator;
        };

Elle prend en paramètres (accumulator) la Map contenant les compteurs de lettres dejà existants et la nouvelle lettre (letter) à traiter. Elle retourne la Map contenant les compteurs de lettres mis à jour.

    Map<Character, Long> accLetter(
          Map<Character, Long> letterCounters, Character letter) 

Troisième implémentation les transducers

Cette deuxième implémentation a les défauts suivants:

  • L'ensemble des éléments est parcouru à chaque étape de transformation;
  • Chaque étape de transformation crée une collection intermédiaire;
  • Chaque étape supplémentaire augmente le temps de calcul et la mémoire consommée.

Dans la troisième implémentation nous allons procéder par étapes pour arriver au final aux Transducers

Première étape: exprimons mapet filter via reduce

    static <T, R> R reduce(BiFunction<R, T, R> reducer, 
                           R identity, 
                           BinaryOperator<R> combiner,             
                           Collection<T> collection) {
        return collection.stream()
                         .reduce(identity, reducer, combiner);
    }

mapavec reduce

Avant:

    static <T, R> List<R> map(Function<T, R> mapper, 
                              Collection<T> collection) {
        List<R> accumulator = new ArrayList<>();
        for (T element: collection) {
            accumulator.add(mapper.apply(element));
        }
        return accumulator;
    }

Après:

    static <T, U, R extends Collection<U>> R map(
                   Function<T, U> mapper, 
                   R result, 
                   Collection<T> collection) {
        BiFunction<R, T, R> mapReducer = 
           (accumulator, item) -> {
               accumulator.add(mapper.apply(item));
               return accumulator;
           };
        return reduce(mapReducer, result, 
                      CollectionUtils::addAll, collection);
    }

Dans cette nouvelle version de map on remplace la boucle par un appel à la fonction reduce.
Pour appliquer cette fonction nous devons avoir:

  • Une fonction de réduction (mapReducer) qui applique la transformation (mapper) à l'élément en entrée et ajoute l'élément transformé à la collection en cours de réduction (result).
  • Une collection qui sera remplie par la réduction et qui contiendra les éléments transformés (result);

Le paramètre combiner de reduce est implémenté par la méthode addAll qui fusionne deux collections en une.

    static <T, R extends Collection<T>> R addAll(R c1, R c2) {
        c1.addAll(c2);
        return c1;
    }

filter avec reduce

Avant:

    static  <T> List<T> filter(Predicate<T> predicate, 
                               Collection<T> collection) {
        List<T> accumulator = new ArrayList<>();
        for(T element: collection) {
            if (predicate.test(element)) {
                accumulator.add(element);
            }
        }
        return accumulator;
    }

Après:

    static <T, R extends Collection<T>> R filter(
                         Predicate<T> predicate, 
                         R result, 
                         Collection<T> collection) {
        BiFunction<R, T, R> filterReducer = 
          (accumulator, item) -> {
            if (predicate.test(item)) {
                accumulator.add(item);
            }
            return accumulator;
          };
        return reduce(filterReducer, result,
                      CollectionUtils::addAll, collection);
    }

Dans cette nouvelle version de filter on remplace la boucle par un appel à la fonction reduce.
Pour appliquer cette fonction nous devons avoir:

  • Une fonction de réduction (filterReducer) qui ajoute l'élément en entrée si il est sélectionné (predicate) à la collection en cours de réduction (result).
  • Une collection qui sera remplie par la réduction et qui contiendra les éléments sélectionnés (result);

Implémentons getAmountOf avec les nouvelles fonctions map et filter

Avant:

    Amount getAmountOf(Month month) {
        List<Invoice> invoicesOfTheMonth = 
            filter(invoice -> invoice.isDueFor(month), invoices());
        List<Amount> includeVATAmounts =
            map(Invoice::totalIncludingVAT, invoicesOfTheMonth);
        return reduce(Amount.ZERO, Amount::add, Amount::add,
                      includeVATAmounts);
    }

Après:

    Amount getAmountOf(Month month) {
        List<Invoice> invoicesOfTheMonth = 
             filter(invoice -> invoice.isDueFor(month), 
                    new ArrayList<>(), invoices());
        List<Amount> includeVATAmounts =            
             map(Invoice::totalIncludingVAT, 
                    new ArrayList<>(), invoicesOfTheMonth);
        return reduce(Amount::add, Amount.ZERO, Amount::add,
                      includeVATAmounts);
    }

Pas de gros changement si ce n'est la création des listes résultats lors des appels de filter et de map.

Au passage nous venons de voir comme construire mapet filter à partir de reduce.

Deuxième étape: sortons maintenant reduce des implémentations de map et filter

Lors de l'étape précédente le fait de construire map et filter avec reduce nous a permis de définir les fonctions de réduction qui permettent d'implémenter un map (mapReducer) et un filter (filterReducer).
Mais cette implémentation nous oblige aussi à avoir des collections intermédiaires.
Nous allons donc sortir reduce des implémentations de map et filter.

map devient uniquement une fabrique de fonction de réduction

Avant:

    static <T, U, R extends Collection<U>> R map(
                    Function<T, U> mapper, 
                    R result, 
                    Collection<T> collection) {
        BiFunction<R, T, R> mapReducer = 
          (accumulator, item) -> {
            accumulator.add(mapper.apply(item));
            return accumulator;
          };
        return reduce(mapReducer, result, 
                      CollectionUtils::addAll, collection);
    }

Après:

    static <T, U, R extends Collection<U>> BiFunction<R, T, R>
                                       map(Function<T, U> mapper) {
        return (accumulator, item) -> {
            accumulator.add(mapper.apply(item));
            return accumulator;
        };
    }

Dans cette version map devient une fabrique de fonction de réduction pour ajouter des éléments transformés dans la collection résultat.
Elle ne dépend plus des éléments à transformer, ni de la création de la collection résultat.
Elle a en paramètre uniquement la fonction de transformation (mapper).

filter devient uniquement une fabrique de fonction de réduction

Avant:

    static <T, R extends Collection<T>> R filter(
                                   Predicate<T> predicate, 
                                   R result, 
                                   Collection<T> collection) {
        BiFunction<R, T, R> filterReducer = 
          (accumulator, item) -> {
            if (predicate.test(item)) {
                accumulator.add(item);
            }
            return accumulator;
          };
        return reduce(filterReducer, result,
                      CollectionUtils::addAll, collection);
    }

Après:

    static <T, R extends Collection<T>> BiFunction<R, T, R>
                                   filter(Predicate<T> predicate) {
        return (R accumulator, T item) -> {
            if (predicate.test(item)) {
                accumulator.add(item);
            }
            return accumulator;
        };
    }

Dans cette version filter devient une fabrique de fonction de réduction pour sélectionner les éléments correspondant au prédicat dans la collection résultat.
Elle ne dépend plus des éléments à sélectionner, ni de la création de la collection résultat.
Elle a en paramètre uniquement le prédicat à appliquer (predicate).

Implémentons getAmountOf avec les nouvelles fonctions map et filter

Avant:

    Amount getAmountOf(Month month) {
        List<Invoice> invoicesOfTheMonth = 
               filter(invoice -> invoice.isDueFor(month), 
                      new ArrayList<>(), invoices());
        List<Amount> includeVATAmounts =
               map(Invoice::totalIncludingVAT, 
                     new ArrayList<>(), invoicesOfTheMonth);
        return reduce(Amount::add, Amount.ZERO, Amount::add, 
                      includeVATAmounts);
    }

Après:

    Amount getAmountOf(Month month) {
        List<Invoice> invoicesOfTheMonth = 
              reduce(filter(invoice -> invoice.isDueFor(month)),        
                            new ArrayList<>(), 
                            CollectionUtils::addAll, invoices());
        List<Amount> includeVATAmounts =
             reduce(map(Invoice::totalIncludingVAT), 
                        new ArrayList<>(),
                        CollectionUtils::addAll,
                        invoicesOfTheMonth);
        return reduce(Amount::add, Amount.ZERO, Amount::add,
                      includeVATAmounts);
    }

Maintenant que nous avons sorti reduce de map et filter nous allons supprimer les reduce intermédiaires et donc les collections intermédiaires.

Troisième étape: supprimons les listes intermédiaires

Nous souhaitons obtenir l'implémentation de getAmountOf suivante:

    Amount getAmountOf(Month month) {
        return reduce(Amount.ZERO,
                (filter((Invoice invoice) -> 
                                invoice.isDueFor(month))  
                     .andThen(map(Invoice::totalIncludingVAT))) 
                     .apply(Amount::add), 
                Amount::add, invoices());
    }

Pour rappel la fonction reduce prend en paramètre une fonction de réduction qui a la signature suivante:

    R reducer(R accumulator, T element)

Il faut donc que la fonction créée par l'application de Amount::add à la composition de fonction soit du même type que reducer.

Dans notre exemple on a Amount::add qui est une fonction avec la signature suivante:

    BiFonction<Amount, Amount, Amount>

Pour réduire une collection de factures (Invoice) en montant (Amount) il faut une fonction avec la signature suivante:

    BiFonction<Amount, Invoice, Amount>

Il faut donc que la méthode apply ait la signature suivante:

    BiFonction<Amount, Invoice, Amount> apply(
                  BiFunction<Amount, Amount, Amount> nextReducer)

Si on généralise on obtient la signature suivante:

    BiFunction<R, U, R> apply(BiFunction<R, T, R> nextReducer)

Avec:

    R <=> Amount
    U <=> Invoice
    T <=> Amount

La fonction apply permet de créer une chaîne de fonctions de réduction , c'est ce qu'on appelle un Transducer.

    abstract class Transducer<T, U> {
 
        private Transducer() {}

        abstract <R> BiFunction<R, U, R> apply(
                                 BiFunction<R, T, R> nextReducer);

        ...
    }

Pour sélectionner les factures à payer il faut une fonction filter qui fabrique un Transducer:

    filter((Invoice invoice) -> invoice.isDueFor(month))

    static <V> Transducer<V, V> filter(Predicate<V> predicate) {
        Objects.requireNonNull(predicate);
        return new Transducer<V, V>() {
            @Override
            public <R> BiFunction<R, V, R> apply(
                                BiFunction<R, V, R> nextReducer) {
                return (accumulator, input) -> {
                    if (predicate.test(input)) {
                        return nextReducer
                                   .apply(accumulator, input);
                    }
                    return accumulator;
                };
            }
        };
    }

Quand la fonction de réduction est appliquée, le prédicat est testé et si l'élément courant est sélectionné, la fonction de réduction suivante est appliquée avec l'accumulateur en cours (accumulator) et l'élément sélectionné (input) sinon l'accumulateur (accumulator) est retourné.

Pour transformer les factures à payer en montant toutes taxes comprises, il faut une fonction map qui crée un Transducer.

    map(Invoice::totalIncludingVAT)

    static <T, U> Transducer<T, U> map(Function<U, T> mapper) {
        Objects.requireNonNull(mapper);
        return new Transducer<T, U>() {
            @Override
            public <R> BiFunction<R, U, R> apply(
                                BiFunction<R, T, R> nextReducer) {
                return (accumulator, input) -> nextReducer
                        .apply(accumulator, mapper.apply(input));
            }
        };
    }

Quand la fonction de réduction est appliquée, la transformation (mapper) est appliquée sur l'élément à transformer et la fonction de réduction suivante est appliquée avec l'accumulateur en cours (accumulator) et l'élément transformé (mapper.apply(input)).

Pour pouvoir composer une fonction à partir des fonctions comme filter et map on ajoute la méthode andThen qui fabrique un nouveau Transducer.

    nextReducer -> filter.apply(mapper.apply(nextReducer)

    <V> Transducer<V, U> andThen(Transducer<V, T> after) {
        Objects.requireNonNull(after);
        return new Transducer<V, U>() {
            @Override
            public <R> BiFunction<R, U, R> apply(
                               BiFunction<R, V, R> nextReducer) {
                return Transducer.this.apply(
                                   after.apply(nextReducer));
            }
        };
    }

Quand la fonction de réduction est appliquée le Transducer mapper est créé en appliquant after.apply(nextReducer), puis le Transducer filter est créé en appliquant le Transducer courant (Transducer.this.apply(...) avec le Transducer mapper en paramètre (créé par after.apply(nextReducer)).

On peut maintenant définir tous les Transducers que l'on souhaite:

    Transducer<Invoice, Invoice> t1 = filter(
                  (Invoice invoice) -> invoice.isDueFor(month));

    Transducer<Amount, Invoice> t2 = map(
                  Invoice::totalIncludingVAT);

    Transducer<Amount, Invoice> t3 = t1.andThen(t2);

Mais ce ne sont que des définitions de fonction . Elles créent une fonction de réduction que lorsque nous les appliquons avec en paramètre une autre fonction de réduction .

Par exemple si on veut la liste des factures dues:

    Transducer<Invoice, Invoice> t1 = filter(
            (Invoice invoice) -> invoice.isDueFor(Month.OCTOBER));

    BiFunction<List<Invoice>, Invoice, List<Invoice>> reducer =
            t1.apply(CollectionUtils::add);

    assertThat(reduce(reducer, new ArrayList<>(),
            CollectionUtils::addAll, invoices()))                        
       .extracting(Invoice::totalIncludingVAT)
       .containsOnly(Amount.of(43.20), Amount.of(35.70));

On voit ici que si on crée un Transducer (t1) pour sélectionner les factures dues, nous pouvons créer une fonction de réduction (reducer). Si nous appelons, reduce avec la fonction reducer, nous obtenons la liste des factures dues.

Nous venons donc de voir que les Transducers sont des définitions de fonction qui peuvent être utilisées seules ou composées de plusieurs Transducers. Nous pouvons appliquer n'importe quelle fonction de réduction au Transducer pour créer un reducer. Cette technique permet de réutiliser les Transducers et de les tester séparément. Un Reducer peut être passé en paramètre à n'importe quelle implémentation de reduce ou équivalent et les éléments à réduire peuvent provenir aussi bien d'une collection que de flux d'une socket, d'un fichier, etc... Cela permet de marier les Transducers avec la programmation réactive par exemple, mais ceci peut faire l'objet d'un article à part entière.

Si vous souhaitez retrouver les sources de cet article, elles sont disponibles sur mon
Github.

Plus de publications

Comments are closed.