Guava par l'exemple (2/3) : les collections
Dans ce second article, je vous propose de découvrir les fonctionnalités de Guava relatives aux collections.
Nous nous intéresserons dans un premier temps aux Prédicats et aux Fonctions, puis nous découvrirons les nouvelles Collections proposées par Google.
Cet article fait partie d’une série :
Fonctions et Prédicats
Guava propose les notions de Fonction (Function<T,R>
) et de Prédicat (Predicate<T>
).
Ces deux concepts, empruntés au domaine de la programmation fonctionnelle, permettent d’encapsuler des algorithmes de transformation ou de vérification dans des objets réutilisables. Comme on le verra plus loin, ils expriment toute leur puissance lorsqu’ils sont utilisés pour filtrer ou transformer des collections d’objets ; mais ils peuvent tout aussi bien être utilisés indépendamment.
Voyons maintenant leurs caractéristiques :
Une Fonction réalise une opération de mapping entre une donnée en entrée (de type T) et un résultat en sortie (de type R) : Function<T,R>
. Son unique méthode R apply(T input)
doit être redéfinie pour effectuer la transformation.
@Test public void lengthMapping() { Function<String, Integer> lengthMapper = new Function<String, Integer>() { public Integer apply(String s) { return s==null ? 0 : s.length(); } }; assertEquals(0, (int) lengthMapper.apply(null)); assertEquals(0, (int) lengthMapper.apply("")); assertEquals(11, (int) lengthMapper.apply("Hello World")); } @Test public void nameMapping() { class Person { private final String name; Person(String name) { this.name = name; } public String getName() {return name;} } Function<Person, String> nameMapper = new Function<Person,String>() { public String apply(Person c) { return c==null ? null : c.getName(); } }; assertEquals(null, nameMapper.apply(null)); assertEquals("Joe Dalton", nameMapper.apply(new Person("Joe Dalton"))); }
Un Prédicat permet d’effectuer une vérification sur un objet passé en entrée (de type T) : Predicate<T>
. Sa méthode boolean apply(T input)
nous permet d’effectuer la vérification souhaitée.
@Test public void emptyStringDetector() { Predicate<String> nonEmptyStringPredicate = new Predicate<String>() { public boolean apply(String s) { return s!=null && s.trim().length()>0; } }; assertFalse(nonEmptyStringPredicate.apply(null)); assertFalse(nonEmptyStringPredicate.apply("")); assertTrue(nonEmptyStringPredicate.apply("Hello World")); } @Test public void daltonDetector() { class Person { private final String firstName; private final String lastName; Person(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } public String getFirstName() {return firstName;} public String getLastName() {return lastName;} } Predicate<Person> daltonPredicate = new Predicate<Person>() { public boolean apply(Person p) { return p!=null && "Dalton".equals(p.getLastName()); } }; assertFalse(daltonPredicate.apply(null)); assertTrue(daltonPredicate.apply(new Person("Joe", "Dalton"))); assertFalse(daltonPredicate.apply(new Person("Luke", "Lucky"))); }
Bonnes pratiques : Les Fonctions et Prédicats ne doivent avoir aucun effet secondaire dans l’application. En particulier, ils ne doivent pas modifier les objets qui leur sont passés en paramètre.
Les classes Predicates
et Functions
permettent de composer (respectivement) des Prédicats et Fonctions selon la logique booléenne grâce aux méthodes or
, and
, not
, et compose
.
Collections2
La classe Collections2
fournit justement les méthodes filter
et transform
qui utilisent respectivement les Prédicats et les Fonctions.
filter
accepte une collection et un prédicat, et renvoie une nouvelle collection ne comprenant que les éléments vérifiant le prédicat.
transform
applique une Fonction sur chaque élément de la collection passée en paramètre, et génère une nouvelle collection contenant les résultats de cette transformation.Cette méthode est pratique, par exemple, pour extraire un attribut d’un objet complexe.
private static List<Person> people; @BeforeClass public static void initTests() { people = Arrays.asList( new Person("Joe", "Dalton"), new Person("Jack", "Dalton"), new Person("William", "Dalton"), new Person("Averell", "Dalton"), new Person("Luke", "Lucky"), new Person("Rantanplan", "Dog")); } @Test public void filter() { Predicate<Person> isDaltonPredicate = new Predicate<Person>() { public boolean apply(Person p) { return p != null && "Dalton".equals(p.getLastName()); } }; Collection<Person> daltons = Collections2.filter(people, isDaltonPredicate); assertEquals(4, daltons.size()); assertEquals("Joe",daltons.iterator().next().getFirstName()); } @Test public void transform() { Function<Person, String> personFullNameMapper = new Function<Person, String>() { public String apply(Person person) { return person == null ? null : person.getFirstName()+" "+person.getLastName(); } }; Collection<String> peopleNames = Collections2.transform(people, personFullNameMapper); assertEquals(6, peopleNames.size()); assertEquals("Joe Dalton", peopleNames.iterator().next()); } private static class Person { private final String firstName; private final String lastName; Person(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } public String getFirstName() {return firstName;} public String getLastName() {return lastName;} }
Lists, Sets, Maps
Lists
La classe utilitaire Lists
facilite le travail avec les listes, notamment :
Leur instanciation, grâce à des méthodes factory tirant parti des capacités d’inférence de type du compilateur : newArrayList
et newLinkedList
. Notez qu’il y en aura moins besoin avec la notation “en diamant” de Java 7).
Leur découpage en partitions, c’est-à-dire en sous-listes d’une certaine taille : partition
.
Leur transformation à l’aide de Fonctions (voir la section “Collections” ci-dessus).
@Test public void instanciation() { Collection<String> someIterable = new HashSet<String>(); List<String> arrayList1 = Lists.newArrayList(); List<String> arrayList2 = Lists.newArrayList("Hello", "World"); List<String> arrayList3 = Lists.newArrayList(someIterable); assertEquals(ArrayList.class, arrayList1.getClass()); List<String> linkedList1 = Lists.newLinkedList(); List<String> linkedList2 = Lists.newLinkedList(someIterable); assertEquals(LinkedList.class, linkedList1.getClass()); } @Test public void partition() { List<Integer> numbers = Arrays.asList(0,1,2,3,4,5,6,7,8,9); List<List<Integer>> subLists = Lists.partition(numbers, 4); assertEquals(3, subLists.size()); assertEquals(Arrays.asList(0,1,2,3), subLists.get(0)); assertEquals(Arrays.asList(4,5,6,7), subLists.get(1)); assertEquals(Arrays.asList(8,9), subLists.get(2)); }
Sets
De manière équivalente à la classe Lists
, la classe utilitaire Sets
facilite le travail avec les sets, notamment :
Leur instanciation, newHashSet
, newLinkedHashSet
, newTreeSet
.
L’application de fonctions ensemblistes : cartesianProduct
, difference, intersection
et union
Leur filtrage à l’aide de Prédicats (voir la section “Collections” ci-dessus).
@Test public void instanciation() { Collection<String> someIterable = new HashSet<String>(); Set<String> hashSet1 = Sets.newHashSet(); Set<String> hashSet2 = Sets.newHashSet("Hello", "World"); Set<String> hashSet3 = Sets.newHashSet(someIterable); assertEquals(HashSet.class, hashSet1.getClass()); Set<String> linkedHashSet1 = Sets.newLinkedHashSet(); Set<String> linkedHashSet2 = Sets.newLinkedHashSet(someIterable); assertEquals(LinkedHashSet.class, linkedHashSet1.getClass()); Set<String> treeSet1 = Sets.newTreeSet(); Set<String> treeSet2 = Sets.newTreeSet(someIterable); Set<String> treeSet3 = Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER); assertEquals(TreeSet.class, treeSet1.getClass()); } @Test public void ensembles() { Set<String> chiffres = Sets.newHashSet("1", "2", "3"); Set<String> lettres = Sets.newHashSet("A", "B", "C"); Set<String> voyelles= Sets.newHashSet("A", "E", "I", "O", "U", "Y"); // Produit cartésien : 3 x 3 possibilités Set<List<String>> combinations = Sets.cartesianProduct(chiffres, lettres); assertEquals(9, combinations.size()); // Différence : lettres - voyelles Sets.SetView<String> difference = Sets.difference(lettres, voyelles); assertEquals(2, difference.size()); assertTrue(difference.containsAll(Sets.newHashSet("B", "C"))); // Intersection : éléments présents dans 'lettres' ET dans 'voyelles' Sets.SetView<String> intersection = Sets.intersection(lettres, voyelles); assertEquals(1, intersection.size()); assertEquals("A", intersection.iterator().next()); // Union : lettres + voyelles Sets.SetView<String> union = Sets.union(lettres, voyelles); assertEquals(8, union.size()); assertTrue(union.containsAll(Sets.newHashSet("A", "B", "C", "E", "I", "O", "U", "Y"))); }
Maps
Pour terminer, et sans surprise, la classe Maps
s’occupe de simplifier la manipulation des Map
, notamment :
Leur instanciation : newHashMap
, newLinkedHashMap
, newTreeMap
et newConcurrentMap
.
La transformation de Properties
en Map
avec la méthode fromProperties
. Très pratique !
La comparaison de deux Map
grâce à la méthode difference
, qui renvoie une structure complexe décrivant les entrées communes, les entrées différentes, celles qui n’apparaissent que dans l’une ou l’autre des Map
.
Le filtrage et la transformation des clés et/ou des valeurs à l’aide de Prédicats et de Fonctions.
@Test public void instanciation() { Map<String, String> someIterable = new HashMap<String, String>(); Comparator<String> someComparator = String.CASE_INSENSITIVE_ORDER; Map<String, String> hashMap1 = Maps.newHashMap(); Map<String, String> hashMap2 = Maps.newHashMap(someIterable); assertEquals(HashMap.class, hashMap1.getClass()); Map<String, String> linkedHashMap1 = Maps.newLinkedHashMap(); Map<String, String> linkedHashMap2 = Maps.newLinkedHashMap(someIterable); assertEquals(LinkedHashMap.class, linkedHashMap1.getClass()); Map<String, String> treeMap1 = Maps.newTreeMap(); Map<String, String> treeMap2 = Maps.newTreeMap(someComparator); assertEquals(TreeMap.class, treeMap1.getClass()); Map<String, String> concurrentMap = Maps.newConcurrentMap(); assertEquals(ConcurrentHashMap.class, concurrentMap.getClass()); } @Test public void fromProperties() { Properties props = new Properties(); props.put("message","Hello World"); props.put("foo", "bar"); props.put("universalAnswer", "42"); Map<String,String> map = Maps.fromProperties(props); assertEquals(3, map.size()); assertEquals("42", map.get("universalAnswer")); } @Test public void difference() { Map<String, String> map1 = Maps.newHashMap(); map1.put("A","A"); map1.put("B","B"); map1.put("C","C"); Map<String, String> map2 = Maps.newHashMap(); map2.put("B","-"); map2.put("C","C"); map2.put("D","D"); MapDifference<String,String> diff = Maps.difference(map1, map2); // Clé et valeur communes Map<String, String> commonEntries = diff.entriesInCommon(); assertEquals(1, commonEntries.size()); assertTrue(commonEntries.containsKey("C")); // Clé commune, valeur différente Map<String, MapDifference.ValueDifference<String>> differentEntries = diff.entriesDiffering(); assertEquals(1, differentEntries.size()); assertEquals("B", differentEntries.get("B").leftValue()); assertEquals("-", differentEntries.get("B").rightValue()); // Clés seulement à gauche Map<String, String> leftEntries = diff.entriesOnlyOnLeft(); assertEquals(1, leftEntries.size()); assertTrue(leftEntries.containsKey("A")); // Clés seulement à droite Map<String, String> rightEntries = diff.entriesOnlyOnRight(); assertEquals(1, rightEntries.size()); assertTrue(rightEntries.containsKey("D")); }
Iterables et Iterators
Il arrive que nous devions travailler avec des Iterator
ou des objets implémentant Iterable
, plutôt qu’ave
c des collections. Ces deux interfaces sont très pauvres, et sont pénibles à utiliser. Les classes utilitaires Iterables
et Iterators
permettent de retrouver la plupart des fonctionnalités présentes dans les Collections.
Iterables
et Iterators
agissent respectivement sur des objets implémentant Iterable
(comme les collections), et sur les Iterator
qu’ils renvoient, mais fournissent exactement les mêmes fonctionnalités.
Les méthodes all
et any
déterminent respectivement si tous, ou au moins un élément vérifie(nt) un certain Prédicat ;
La recherche avec contains
, find
et indexOf
;
L’itération limitée (limit
) ou illimitée (cycle
) sur les éléments ;
La détermination de la taille de l’ensemble parcouru avec isEmpty
et size
private final List<Integer> nombres = Arrays.asList(0, 1, 2, 3, 4, 5, 6, 7, 8, 9); private final List<Integer> nombresPairs = Arrays.asList(0, 2, 4, 6, 8); private final Predicate<Integer> isNombrePair = new Predicate<Integer>() { public boolean apply(Integer number) { return number % 2 == 0; } }; @Test public void usingPredicates() { // Tous les nombres sont pairs ? assertFalse(Iterables.all(nombres, isNombrePair)); assertTrue(Iterables.all(nombresPairs, isNombrePair)); // Au moins un des nombres est pair ? assertTrue(Iterables.any(nombres, isNombrePair)); assertTrue(Iterables.any(nombresPairs, isNombrePair)); // Filtrage et égalité Iterable<Integer> filteredNumbers = Iterables.filter(nombres, isNombrePair); assertTrue(Iterables.elementsEqual(nombresPairs, filteredNumbers)); } @Test public void search() { // Contains assertFalse(Iterables.contains(nombres, -1)); assertTrue(Iterables.contains(nombres, 1)); // Find Integer firstMatchingElement = Iterables.find(nombres, isNombrePair); assertNotNull(firstMatchingElement); assertEquals(0, firstMatchingElement.intValue()); // IndexOf int firstMatchingIndex = Iterables.indexOf(nombres, isNombrePair); assertEquals(0, firstMatchingIndex); } @Test public void iteration() { List<Integer> nombres = Arrays.asList(0, 1, 2, 3, 4, 5, 6, 7, 8, 9); int limit = 3; // Limited iteration Iterable<Integer> limited = Iterables.limit(nombres, limit); Iterator<Integer> limitedIterator = limited.iterator(); for (int i = 0; i < limit; i++) { assertTrue(limitedIterator.hasNext()); limitedIterator.next(); } assertFalse(limitedIterator.hasNext()); // Unlimited iteration Iterable<Integer> cycling = Iterables.cycle(nombres); Iterator<Integer> cyclingIterator = cycling.iterator(); for (int i = 0; i < 10 * nombres.size(); i++) { assertTrue(cyclingIterator.hasNext()); cyclingIterator.next(); } } @Test public void size() { assertFalse(Iterables.isEmpty(nombres)); assertEquals(nombres.size(), Iterables.size(nombres)); }
Multi-collections
Guava fournit quelques nouvelles collections pratiques, comme Multimap
et Multiset
, ainsi que BiMap
.
Multimap
Une Multimap
associe plusieurs valeurs à chaque clé. Ces valeurs sont contenues dans une List
ou un Set
, créés automatiquement à la première insertion.
En tant que développeur, vous n’avez donc plus à vous soucier de l’existence ou de la création des collections contenant vos valeurs : il suffit d’appeler put(K,V)
ou remove(K,V)
comme d’habitude.
Il existe deux familles de Multimap
:
Les ListMultimap
, qui utilisent au choix une LinkedList
(LinkedListMultimap
) ou une ArrayList
(ArrayListMultimap
)pour stocker les valeurs.
Les SetMultimap
, qui associent à chaque clé un Set
de valeurs : HashMultimap
, LinkedHashMultimap
et TreeMultimap
.
Chacune de ces classes est instanciée via une méthode factory appelée create()
.
@Test public void listMultimap() { ListMultimap<Integer, Integer> tablesMultiplication = LinkedListMultimap.create(); // Existe aussi en version ArrayList : // ListMultimap<Integer, Integer> tablesMultiplication = ArrayListMultimap.create(); for (int table = 0; table < 10; table++) { for (int i = 0; i < 10; i++) { tablesMultiplication.put(table, i * table); } } List<Integer> tableDe2 = tablesMultiplication.get(2); assertTrue(tableDe2 instanceof List); assertEquals(10, tableDe2.size()); assertTrue(tableDe2.containsAll(Arrays.asList(0, 2, 4, 6, 8, 10, 12, 14, 16, 18))); // Suppression de l'élément "10" dans la table de 2 tablesMultiplication.remove(2, 10); assertEquals(9, tableDe2.size()); assertTrue(tableDe2.containsAll(Arrays.asList(0, 2, 4, 6, 8, /*10,*/ 12, 14, 16, 18))); // Transformation en simple Map Map<Integer, Collection<Integer>> map = tablesMultiplication.asMap(); } @Test public void setMultimap() { SetMultimap<String, Integer> caveAVins = HashMultimap.create(); // Existe aussi en version LinkedHashMultimap et TreeMultimap : // SetMultimap<String, Integer> caveAVins = LinkedHashMultimap.create(); // SetMultimap<String, Integer> caveAVins = TreeMultimap.create(); caveAVins.put("Bordeaux", 1985); caveAVins.put("Bordeaux", 1986); caveAVins.put("Bordeaux", 1985); caveAVins.put("Bordeaux", 1985); caveAVins.put("Bordeaux", 1990); Set<Integer> bordeaux = caveAVins.get("Bordeaux"); assertTrue(bordeaux instanceof Set); assertEquals(3, bordeaux.size()); assertTrue(bordeaux.containsAll(Arrays.asList(1985, 1986, 1990))); // Suppression de 1996 bordeaux.remove(1986); assertEquals(2, bordeaux.size()); assertTrue(bordeaux.containsAll(Arrays.asList(1985, /*1986,*/ 1990))); // Transformation en simple Map Map<String, Collection<Integer>> map = caveAVins.asMap(); }
Multiset
“Multiset
” est un nom un peu trompeur pour cette classe. En réalité, il s’agit plutôt d’un bag, c’est-à-dire une collection non ordonnée autorisant les doublons.
Cette collection a pour particularité de compter le nombre d’occurrences de chaque élément qui y est inséré. Elle est pratique pour collecter des statistiques sur la fréquence d’apparition des éléments au sein d’un ensemble.
Plusieurs versions existent : HashMultiset
, LinkedHashMultiset
et TreeMultiset
. Elles sont toutes instanciées via leur méthode factory create()
.
Enfin, il est possible de retransformer un Multiset
en Set
grâce à la méthode elementSet()
(les doublons sont éliminés au passage).
@Test public void multiset() { HashMultiset<Integer> ages = HashMultiset.create(); // Existe également en version LinkedHashSet, TreeSet, et ConcurrentHashSet : // HashMultiset<Integer> ages = LinkedHashMultiset.create(); // HashMultiset<Integer> ages = TreeMultiset.create(); // HashMultiset<Integer> ages = ConcurrentHashMultiset.create(); ages.add(28); ages.add(28); ages.add(30, 3); // Ajouter 3 fois cet élément ages.add(31); ages.add(32); // Attention, size() renvoie le nombre total d'éléments dans la collection assertEquals(7, ages.size()); // Pour obtenir la taille du Set, utiliser entrySet().size() assertEquals(4, ages.entrySet().size()); // comptage des occurrences assertEquals(0, ages.count(0)); assertEquals(2, ages.count(28)); assertEquals(3, ages.count(30)); assertEquals(1, ages.count(31)); assertEquals(1, ages.count(32)); }
BiMap
Une Bimap
est une Map
bidirectionnelle : on peut interroger ses clés pour récupérer les valeurs associées, et également interroger ses valeurs pour retrouver les clés associées.
Pour que ce soit possible, les valeurs ne doivent évidemment pas comporter de doublons.
La méthode inverse()
d’une Bimap
permet d’en obtenir une vue symétrique, où clés et valeurs sont inversées. On peut ainsi interroger la collection sur ses valeurs, pour récupérer les clés associées.
@Test public void bimap() { HashBiMap<String, Integer> daltonsSizeByName = HashBiMap.create(); daltonsSizeByName.put("Joe", 1); daltonsSizeByName.put("Jack", 2); daltonsSizeByName.put("William", 3); daltonsSizeByName.put("Averell", 4); // Inverse map BiMap<Integer,String> daltonsNameBySize = daltonsSizeByName.inverse(); assertEquals(daltonsSizeByName.size(), daltonsNameBySize.size()); assertEquals(daltonsNameBySize.keySet(), daltonsSizeByName.values()); assertEquals(daltonsNameBySize.values(), daltonsSizeByName.keySet()); assertEquals(1, (int) daltonsSizeByName.get("Joe")); assertEquals("Joe", daltonsNameBySize.get(1)); }
Conclusion
Grâce aux Fonctions et aux Prédicats, Guava introduit des éléments de programmation fonctionnelle dans Java, et permet de capturer des algorithmes dans des composants réutilisables.
Si l’intérêt des méthodes factory dans les classes Lists
, Sets
etc. peut sembler limité depuis l’avènement de Java 7 et de la notation “en diamant”, les autres méthodes fournies par ces classes rendent toujours bien service.
Dans un troisière article, je vous présenterai certaines fonctionnalités intéressantes de Guava dans le domaine de la gestion des I/O et du multithreading. Stay tuned !