Cet article est le quatrième de la série consacrée à l’ouvrage Scala for the Impatient. Ce dernier propose, en 22 chapitres, de découvrir ce que Scala peut faire et comment l’utiliser, d’un usage basique jusqu’à un usage plus complet (écriture de librairies). A la fin de chaque chapitre, plusieurs exercices sont proposés. Ces derniers permettent d’une part, de vérifier l’acquisition des notions abordées et d’autre part, de creuser ces dernières.
Cette série a pour buts de:
- Partager et expliquer les solutions des exercices;
- Revenir sur les notions liées le cas échéant;
- Confronter ces solutions à l’expérience et à la vision d’autres experts techniques.
Le seul pré-requis est de connaitre C, C++ ou Java et de prendre le temps de feuilleter, à minima, la première partie de l’ouvrage (disponible librement ici). Cet article concerne le chapitre n°4, intitulé « Maps et Tuples » et fait usage de la version 2.10.2 du langage Scala. Enfin, les solutions présentées sont disponibles sur GitHub.
Contenu du chapitre
Un dicton de développeur bien connu dit:
« Si tu ne dois garder qu’une seule structure de données, fait en sorte que ce soit une table de hachage. »
Les tables de hachage, ou plus généralement Maps, sont parmi les structures de données les plus versatiles. Comme vous le verrez dans ce chapitre, Scala facilite particulièrement leur usage.
Les Maps sont des collections de paires clé/valeur. Scala possède une notion plus générique de tuples; une agrégation de n objets, pas nécessairement du même type. Une paire est simplement un tuple où n = 2. Les tuples sont utiles dès lors que vous avez besoin d’agréger ensemble deux valeurs ou plus. Nous discuterons brièvement de leur syntaxe à la fin de ce chapitre.
Les points importants de ce dernier sont:
- Scala possède une syntaxe plaisante pour créer, requêter et parcourir les maps;
- vous devez choisir entre des maps mutables ou immuables;
- par défaut, vous obtenez une map basée sur le hachage mais vous pouvez aussi obtenir une map basée sur un arbre;
- il est facile de convertir des maps Java en Scala et inversement;
- les tuples sont utiles pour agréger des valeurs.
Solutions des exercices
Exercice 1
Enoncé: Construire une map de prix pour un nombre de trucs à votre convenance. Puis, produire une seconde map avec les mêmes clés mais des prix réduits de 10%.
Solution: La construction de la map de prix se fait selon la syntaxe suivante:
val gizmos = Map("Gizmo1" -> 100.0, "Gizmo2" -> 4.0, "Gizmo3" -> 20.0)
Notez qui si vous omettez le mot clé Map, cela vous empêchera d’itérer dessus. Or, c’est ce que nous allons faire pour construire la map des réductions. Plusieurs syntaxes sont possibles. D’abord en faisant usage d’une boucle for comprehension:
val cheapGizmos = for ((name, price) <- gizmos) yield (name, price / 10)
Ici, on construit une nouvelle paire avec la clé existante et le prix divisé par dix. Une autre syntaxe consiste à faire usage d’une flèche, comme lors de la construction de la première map:
val otherCheapGizmos = for ((name, price) <- gizmos) yield name -> price / 10
Enfin, la solution la plus concise consiste à faire usage de la méthode mapValues, qui applique une transformation à toutes les valeurs de la map:
val cheapGizmosWithMapValues = gizmos.mapValues(_ / 10)
Ici, il s’agit d’appliquer le rabais de 10%. Notez une nouvelle fois l’usage du sucre syntaxique « _ » pour désigner l’élément parcouru.
Dans tout cet exercice, nous avons fait usage de maps immuables, ceci étant déterminé par un import que nous n’avons pas décrit.
Exercice 2
Enoncé: Ecrire un programme qui lit des mots depuis un fichier. Utiliser une map mutable pour compter combien de fois un mot apparait. Pour lire les mots, utilisez simplement un java.util.Scanner:
val in = new Scanner(new File(fileName), "UTF-8") while (in.hasNext()) /* process */ in.next()
Ou jetez un coup d’œil au chapitre 9 pour le faire « à la manière Scala ». A la fin, afficher tous les mots et les compteurs associés.
Solution: Dans un premier temps, solutionnons cet exercice avec un bon vieux scanner Java. Il nous faut déclarer la ligne en cours de lecture, le scanner ainsi qu’une map mutable vide:
var currentLine: String = null; val in = new Scanner(new File(fileName), "UTF-8") val counts = new HashMap[String, Int]
Comme nous déclarons une map sans éléments initiaux, nous devons préciser l’implémentation; ici, nous choisissons une table de hachage. Nous parcourons ensuite les éléments pour compter les mots:
while (in.hasNext()) { currentLine = in.next().toLowerCase() counts(currentLine) = counts.getOrElse(currentLine, 0) + 1 } println(counts)
Quelques précisions. La syntaxe counts(currentLine) nous permet de mettre à jour l’élément s’il existe ou autrement de le créer. Pour incrémenter le compteur d’un mot, il nous faut par contre vérifier sa présence via la méthode getOrElse. Si le mot n’est pas trouvé, la valeur par défaut 0 sera retournée.
Note: Pour optimiser nos comptes, on passe tous les mots en minuscules.
Si ce code effectue le traitement souhaité, il est loin d’être sexy et est relativement verbeux. Voyons ce que nous pouvons faire si l’on s’y prend « à la Scala »:
val counts = new HashMap[String, Int] val tokens: Array[String] = Source.fromFile(fileName, "UTF-8").mkString.split("\\s+") for (word <- tokens.map(_.toLowerCase())) counts(word) = counts.getOrElse(word, 0) + 1 println(counts)
On retrouve d’abord l’initialisation de la map. En revanche, la lecture est simplifiée par l’usage de l’objet Source qui construit une chaîne de caractères depuis le contenu du fichier passé. Ceci est rendu possible lorsque le contenu est limité en taille. De ce dernier, on récupère les différents mots en le découpant selon les espaces trouvés. Ensuite, on fait usage d’une boucle pour mettre à jour les compteurs comme vu précédemment.
D’une manière semblable, on peut produire un code plus concis:
val counts = new HashMap[String, Int] withDefaultValue 0 val tokens: Array[String] = Source.fromFile(fileName, "UTF-8").mkString.split("\\s+") for (word <- tokens.map(_.toLowerCase())) counts(word) += 1 println(counts)
La subtilité se trouve à la déclaration de la map où l’on indique une valeur par défaut grâce à la méthode withDefaultValue. Ainsi, la mise à jour du champ s’en trouve simplifiée (ligne 4).
Exercice 3
Enoncé: Répéter l’exercice précédent avec une map immuable.
Solution: Comme son nom l’indique, une map immuable ne peut être modifiée. On ne peut donc la mettre à jour comme vu dans l’exercice précédent. La solution consiste alors à recréer une nouvelle map à partir de celle existante et à mettre à jour les compteurs dans cette dernière:
val tokens: Array[String] = Source.fromFile("src/main/resources/sentence.txt", "UTF-8").mkString.split("\\s+") var counts = new HashMap[String, Int] withDefaultValue 0 for (word <- tokens.map(_.toLowerCase())) counts += (word -> (counts(word) + 1)) println(counts)
Ligne 4, on créé donc une nouvelle map via la méthode += et l’on y met à jour la paire dont la clé est word. Comme le précise l’auteur et contrairement aux idées reçues, cette action est peu couteuse car les maps sont immuables et partagent donc une large partie de leurs structures.
Exercice 4
Enoncé: Répéter le précédent exercice avec une map triée, afin que les mots soient triés avant l’affichage.
Solution: Peu de changements par rapport à l’exercice précédent si ce n’est l’implémentation choisie à la déclaration de la map:
val rawWords: Array[String] = Source.fromFile("src/main/resources/sentence.txt", "UTF-8").mkString.split("\\s+") var counts = new TreeMap[String, Int] withDefaultValue 0 for (word <- rawWords.map(_.toLowerCase())) counts += (word -> (counts(word) + 1)) println(counts)
Exercice 5
Enoncé: Répéter l’exercice précédent avec une java.util.TreeMap que vous adapterez à l’API Scala.
Solution: Par rapport à l’exercice précédent, le seul changement se situe dans les imports où l’on indique d’une part l’implémentation de JAVA et d’autre part, que nous souhaitons convertir les maps JAVA en maps Scala:
import java.util.TreeMap import scala.collection.JavaConversions.mapAsScalaMap
La puissance de Scala fait le reste; nous n’avons pas eu à changer notre code:
val rawWords: Array[String] = Source.fromFile("src/main/resources/sentence.txt", "UTF-8").mkString.split("\\s+") var counts = new TreeMap[String, Int] withDefaultValue 0 for (word <- rawWords.map(_.toLowerCase())) counts += (word -> (counts(word) + 1)) println(counts)
Exercice 6
Enoncé: Définir une LinkedHashMap qui fait correspondre « Monday » à java.util.Calendar.MONDAY, et ainsi de suite pour les autres jours de la semaine. Démontrer que les éléments sont visités dans l’ordre d’insertion.
Solution: Tout d’abord, nous faisons un import global pour pouvoir faire usage de toutes les constantes de la classe Calendar:
import java.util.Calendar._
Ensuite, nous déclarons notre LinkedHashMap comme une map classique:
val weekdays = LinkedHashMap("Monday" -> MONDAY, "Tuesday" -> TUESDAY, "Wednesday" -> WEDNESDAY, "Thursday" -> THURSDAY, "Friday" -> FRIDAY, "Saturday" -> SATURDAY, "Sunday" -> SUNDAY) println(weekdays)
L’affichage nous prouve que les éléments sont lus dans l’ordre d’insertion, la valeur 1 du dimanche étant à la fin:
Map(Monday -> 2, Tuesday -> 3, Wednesday -> 4, Thursday -> 5, Friday -> 6, Saturday -> 7, Sunday -> 1)
Exercice 7
Enoncé: Imprimer une table de toutes les propriétés Java, comme suit:
user.language | fr java.specification.vendor | Oracle Corporation awt.toolkit | sun.lwawt.macosx.LWCToolkit java.vm.info | mixed mode java.version | 1.7.0_25
Avant d’imprimer la table, trouver la longueur de la plus longue clé.
Solution: Cet exercice se traite en deux parties. Il convient d’abord de récupérer les propriétés systèmes de JAVA. Rien de bien compliqué en cela, la solution était donnée par l’auteur en page 45:
import scala.collection.JavaConversions.propertiesAsScalaMap import scala.collection.Map val javaProperties: Map[String, String] = System.getProperties()
Attention cependant à ne pas oublier les imports pour la conversion en Map Scala. Ensuite, il nous faut trouver la longueur de la plus longue clé. Pour cela, on cherche le maximum d’une valeur donnée sur l’ensemble des clés:
val longestKey = javaProperties.keySet.maxBy(_.length()) val keyColumnLength = longestKey.length() + 1
Cela se fait via la méthode maxBy à laquelle on passe une fonction anonyme portant sur la longueur de la clé. La largeur attendue est ensuite incrémentée de 1 (pour l’espace). Enfin, reste l’affichage. Soit d’une manière relativement complexe en calculant la différence entre la largeur attendue et la largeur de la clé (notez l’usage de la méthode * de la classe StringOps, vu dans les chapitres précédents):
for ((key, value) <- javaProperties) println(key + (" " * (keyColumnLength - key.length())) + "| " + value)
Soit en tirant profit de la méthode printf, bien connue des développeurs C:
for ((key, value) <- javaProperties) printf("%-" + keyColumnLength + "s | %s\n", key, value)
Exercice 8
Enoncé: Ecrire une fonction minmax(values: Array[Int]) qui retourne une paire contenant la plus petite et la plus grande valeur dans le tableau donné.
Solution: Déclarer une paire en Scala est très simple via l’usage des parenthèses. D’autre part, nous avons aperçu dans le chapitre précédent sur les tableaux que des méthodes étaient présentes pour calculer les valeurs minimales et maximales. La solution est donc relativement triviale:
def minmax(values: Array[Int]) = (values.min, values.max)
Exercice 9
Enoncé: Ecrire une fonction lteqgt(values: Array[Int], v: Int) qui retourne un triplet contenant le nombre de valeurs plus petites que v, égales à v et plus grandes que v.
Solution: Ici, la question est relativement similaire à la précédente. Au lieu de construire un tuple à deux éléments, on en construit un à trois; la syntaxe change donc peu:
def lteqgt(values: Array[Int], v: Int) = (values.count(_ < v), values.count(_ == v), values.count(_ > v))
Ici, on a fait usage de la méthode count avec trois fonctions anonymes différentes. Une pour compter les valeurs négatives, une autre pour les valeurs positives et une dernière par les valeurs nulles. On s’aperçoit ici de la puissance de la programmation fonctionnelle et l’on retrouve les sucres syntaxiques abordés à de multiples reprises.
Exercice 10
Enoncé: Que se passe-t-il lorsque « zippe » ensemble deux chaines de caractères, par exemple « Hello ».zip(« World »)? Décrire un cas d’utilisation plausible.
Solution: La méthode regroupe les deux chaines de caractères par paire de lettres de même index:
scala> "Hello".zip("World") res0: scala.collection.immutable.IndexedSeq[(Char, Char)] = Vector((H,W), (e,o), (l,r), (l,l), (o,d))
Si l’une ou l’autre des chaines possèdent un excédent de caractères, ce dernier est ignoré:
scala> "Hello Jack".zip("World") res1: scala.collection.immutable.IndexedSeq[(Char, Char)] = Vector((H,W), (e,o), (l,r), (l,l), (o,d)) scala> "Hello".zip("WorldWorld") res2: scala.collection.immutable.IndexedSeq[(Char, Char)] = Vector((H,W), (e,o), (l,r), (l,l), (o,d))
Un usage possible consisterait à construire une correspondance entre les majuscules et les minuscules:
val zippedLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".zip("abcdefghijklmnopqrstuvwxyz")
Ceci nous donnant une collection de paires, on peut en obtenir une map via la méthode toMap:
val caseMap = zippedLetters.toMap
Et dès lors obtenir la minuscule associée à une majuscule:
println(caseMap('A'))