Java BinaryOperator et UnaryOperator
Interfaces fonctionnelles spécialisées en Java pour les opérations sur des opérandes du même type — BinaryOperator et UnaryOperator.
Les deux derniers zooms sur les interfaces fonctionnelles de la Partie 12 complètent la taxonomie à quatre coins avec les spécialisations même type :
UnaryOperator<T>étendFunction<T, T>— une entrée, une sortie, même type. La forme derrièreList.replaceAll,Map.replaceAll, et tout appel de « transformation sur place ».BinaryOperator<T>étendBiFunction<T, T, T>— deux entrées et une sortie, toutes du même type. La forme derrièreStream.reduce,Map.merge, et l'étape parallèle « combiner deux résultats partiels en un seul ».
Aucune de ces interfaces n'ajoute de nouvelles SAM — elles héritent de apply de leur parent. Ce qu'elles ajoutent, c'est deux méthodes statiques courtes sur BinaryOperator, minBy et maxBy, qu'il est utile de connaître par leur nom.
UnaryOperator<T> — transformation du même type
@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {
static <T> UnaryOperator<T> identity(); // returns t -> t
}Voilà toute la déclaration. Tout le reste (apply, andThen, compose) est hérité de Function<T, T>.
Un UnaryOperator<T> est aussi un Function<T, T>, donc partout où un Function<String, String> est accepté, un UnaryOperator<String> convient. L'inverse n'est pas vrai : un Function<String, Object> n'est pas un UnaryOperator<String>. La différence compte lorsque l'API exige spécifiquement la garantie du même type :
List<String> names = new ArrayList<>(List.of("alice", "bob"));
names.replaceAll(String::toUpperCase); // UnaryOperator<String>
// names.replaceAll(String::length); // would not compile — String -> IntegerList.replaceAll(UnaryOperator<E>) réécrit chaque élément sur place. Comme le paramètre est UnaryOperator<E>, le compilateur refuse toute transformation qui changerait le type de l'élément — ce qui est exactement ce que l'on souhaite pour une mutation sur place.
Des spécialisations primitives existent là où elles s'avèrent utiles dans le code de flux :
IntUnaryOperator doubleIt = i -> i * 2;
LongUnaryOperator biggify = n -> n + 1_000_000L;
DoubleUnaryOperator halve = d -> d / 2.0;IntStream.map(IntUnaryOperator) est la version sans boxing de Stream<Integer>.map(Function<Integer, Integer>).
BinaryOperator<T> — combinaison de deux valeurs du même type
@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T, T, T> {
static <T> BinaryOperator<T> minBy(Comparator<? super T> c);
static <T> BinaryOperator<T> maxBy(Comparator<? super T> c);
}Un BinaryOperator<T> signifie « combiner ces deux T en un seul T. » La forme existe parce que combiner est l'opération dont la réduction parallèle a besoin :
BinaryOperator<Integer> sum = Integer::sum;
BinaryOperator<String> concat = String::concat;
BinaryOperator<List<String>> merge = (a, b) -> { var c = new ArrayList<>(a); c.addAll(b); return c; };Chacun prend deux éléments du même type et en retourne un du même type. C'est la seule exigence.
Où apparaît BinaryOperator<T>
int total = nums.stream().reduce(0, Integer::sum); // Stream.reduce(identity, BinaryOperator)
Optional<Integer> max = nums.stream().reduce(Integer::max); // Stream.reduce(BinaryOperator)
Optional<Integer> max2 = nums.stream()
.reduce(BinaryOperator.maxBy(Integer::compare)); // same thing, named
scores.merge("alice", 1, Integer::sum); // Map.merge(K, V, BinaryOperator<V>)Stream.reduce est le site d'utilisation principal. Le BinaryOperator<T> que vous passez est appelé répétitivement pour réduire un flux de T en un seul T. Dans un flux parallèle, les résultats partiels de différents threads sont combinés avec le même opérateur — c'est pourquoi l'opérateur doit être associatif : (a ⊕ b) ⊕ c et a ⊕ (b ⊕ c) doivent donner le même résultat, quelle que soit la façon dont la JVM divise le travail.
Map.merge(key, value, remapping) est l'autre endroit où un BinaryOperator<V> apparaît dans le code quotidien — et c'est la façon la plus propre d'implémenter « incrémenter un compteur dans une map » :
Map<String, Integer> counts = new HashMap<>();
for (String word : words) counts.merge(word, 1, Integer::sum);Si la clé est absente, la valeur est stockée telle quelle ; si la clé est présente, le BinaryOperator<V> de remappage combine les anciennes et nouvelles valeurs.
minBy et maxBy — nommer la réduction évidente
Deux fabriques statiques courtes qui encapsulent un Comparator :
BinaryOperator<Person> oldest = BinaryOperator.maxBy(Comparator.comparingInt(Person::age));
BinaryOperator<Person> shortest = BinaryOperator.minBy(Comparator.comparing(Person::name));
Optional<Person> winner = people.stream().reduce(oldest);On pourrait écrire les lambdas à la main — (a, b) -> a.age() > b.age() ? a : b — mais BinaryOperator.maxBy(cmp) exprime l'intention et réutilise un Comparator existant. Collectors.maxBy(cmp) est la forme de collecteur ; les deux aboutissent au même résultat via des API différentes.
L'associativité est le contrat
Le compilateur ne peut pas vérifier que votre BinaryOperator<T> est associatif. Le JDK le suppose. Dans une reduce séquentielle, un bug d'associativité ne change le résultat que si l'opérateur n'est pas commutatif ; dans une reduce parallèle, les opérateurs non associatifs donnent des réponses non déterministes — même entrée, totaux différents selon les exécutions :
BinaryOperator<Integer> bad = (a, b) -> a - b; // not associative
// ((1 - 2) - 3) = -4
// (1 - (2 - 3)) = 2
// In a parallel reduce, you get whichever the split happened to produce.+, *, min, max, la concaténation de listes, l'union d'ensembles et la concaténation de chaînes sont toutes associatives. La soustraction et la division ne le sont pas. Utiliser celles-ci dans un BinaryOperator revient à introduire un bug de parallélisme qui ne demande qu'à se manifester.
Exemple concret : replaceAll, reduce, merge, et les statiques minBy/maxBy
Le programme ci-dessous utilise UnaryOperator<String> pour mettre une liste en majuscules sur place, réduit un IntStream avec un BinaryOperator via la référence de méthode Integer::sum, parcourt Map.merge pour construire un histogramme de fréquence de mots, et utilise BinaryOperator.maxBy avec Stream.reduce pour trouver la personne la plus âgée dans une liste.
Ce qu'il faut retenir de l'exécution :
names.replaceAll(String::toUpperCase)a réécrit la liste sur place. La formeUnaryOperator<String>était ce qui la rendait sûre du point de vue des types —String::lengthn'aurait pas compilé car elle ne retourne pas uneString.Stream.reduce(0, Integer::sum)a réduit cinq entiers en un seul à l'aide d'unBinaryOperator<Integer>associatif. L'élément identité0rend le cas du flux vide significatif : un flux vide se réduit à l'identité.Stream.reduce(BinaryOperator)sans identité a retournéOptional<T>— il n'y a pas de réponse sensée pour un flux vide lorsqu'aucune identité n'est fournie.counts.merge(w, 1, Integer::sum)est l'idiome de comptage de mots en une ligne. Il met1lorsque la clé est absente et ajoute1à la valeur existante lorsqu'elle est présente. LeBinaryOperator<Integer>est l'étape de combinaison.BinaryOperator.maxBy(Comparator.comparingInt(Person::age))a nommé la réduction comme « comparer par âge et garder le plus grand ». L'équivalent lambda fonctionne, mais la méthode statique nommée exprime mieux l'intention.- La réduction non associative
(a, b) -> a - ba retourné des nombres différents en mode séquentiel et parallèle — le résultat parallèle est ce que le découpage du travail a calculé. L'associativité est un contrat invisible dans le type mais dont le moteur d'exécution dépend entièrement.
Et ensuite
Cela clôt la Partie 12. Vous avez maintenant vu le vocabulaire fonctionnel complet fourni par le JDK : les interfaces fonctionnelles et @FunctionalInterface, les lambdas, les références de méthodes, le package java.util.function de bout en bout, le pipeline de flux (sources, intermédiaires, terminaux, collecteurs, parallèle), Optional, et enfin Predicate, Function, Consumer/Supplier, et la famille des opérateurs un par un. La prochaine partie, Fichiers et E/S, commence par l'introduction Java I/O — la distinction octets/caractères, la couche de flux bufférisés, et la relation entre java.io et la nouvelle API java.nio.file. Plusieurs des patterns de cette partie — try-with-resources, les formes Consumer/Supplier pour la lecture et l'écriture, et le pipeline de flux pour les fichiers orientés lignes — apparaissent immédiatement.