Vous aimez ce que vous lisez sur ce blog ?
Envie d'aller plus loin avec véritable formation d'expertise en Java ?
Venez suivre ma formation Masterclasse Expertise Java !

"Même un développeur experimenté a besoin de continuer à apprendre. Et dans cette formation... j'ai appris beaucoup !" - A.G., Java Champion

Sessions intra-entreprises sur demande : contact[at]mokatech.net.
Inscrivez-vous vite !

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'avec 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 !


Commentaires

1. Le lundi 3 octobre 2011, 14:18 par pdecat

Très bonne introduction à Guava, merci.
Petite remarque : dans la phrase "La classe Collections2 fournit justement les méthodes filter et transform qui utilisent respectivement les Fonctions et les Prédicats.", il faudrait échanger les mots "Fonctions" et "Prédicats"...

2. Le lundi 3 octobre 2011, 22:07 par Olivier Croisier

Oups, merci pour la correction !

3. Le lundi 3 octobre 2011, 22:51 par NicolasGeraud

Article très intéressant.

Une correction : "la classe Maps s'occuper de simplifie la manipulation" => s'occupe de simplifier.

4. Le lundi 3 octobre 2011, 23:03 par Olivier Croisier

Décidément... Merci pour la correction !

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.