Cet article est le cinquiè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°5, intitulé « Les classes » 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
Dans ce chapitre, vous allez apprendre comment implémenter des classes en Scala. Si vous connaissez ce concept en Java ou C++, vous n’y trouverez aucune difficulté et apprécierez la notation plus concise de Scala.
Les points clés de chapitre sont:
- les membres de classes viennent automatiquement avec des accesseurs et mutateurs;
- vous pouvez remplacer un membre avec un accesseur/mutateur personnalisé sans pour autant changer le contrat de classe – on parle du « principe d’accès uniforme »;
- utilisez l’annotation @BeanProperty pour générer les méthodes getXXX/setXXX propres aux beans Java;
- chaque classe possède un constructeur primaire qui est « entrelacé » avec la définition de cette dernière. Ses paramètres sont transformés en membres de la classe. Ce constructeur exécute tous les déclarations du corps de la classe;
- les constructeurs auxiliaires sont optionnels. Ils sont nommés this.
Solutions des exercices
Exercice 1
Enoncé: Améliorer la classe Counter de la section 5.1 « Classes simples et méthodes sans paramètres » à la page 49, de sorte à ce que sa valeur ne devienne pas négative lorsqu’elle dépasse Int.MaxValue.
Solution: Commençons tout d’abord par reprendre la classe donnée:
class Counter { private var value = 0 def increment() = { value += 1 } def current = value }
Cette dernière est publique sans que nous n’ayons à le déclarer via un mot clé comme en Java. Elle déclare un membre « value », initialisé à zéro et qui ne pourra être accédé que par les instances de cette classe. Notez qu’ici, le mot clé private indique que l’accesseur et le mutateur générés seront privés; en Scala, tous les membres sont privés. Enfin, cette classe déclare deux méthodes publiques pour récupérer la valeur courante et l’incrémenter.
Pour éviter, que la valeur courante ne devienne négative, il faut placer une condition dans la méthode d’incrémentation (ligne 3):
class Counter { private var value = Int.MaxValue - 1 def increment() = { if (value < Int.MaxValue) value += 1 } def current = value }
L’incrément ne se fait que si la valeur maximale n’est pas encore atteinte. Notez qu’outre cette condition, nous avons initialisé le compteur à Int.MaxValue – 1 uniquement dans le but de faciliter le test de notre méthode. Dans un cas réel, nous aurions bien évidemment laissé la valeur initiale à zéro.
Exercice 2
Enoncé: Ecrire une classe BankAccount avec des méthodes deposit et withdraw, et une propriété balance en lecture seule.
Solution: Le code de classe créé est relativement succinct mais comporte beaucoup de points que nous allons expliciter ci-après.
class BankAccount(private var balance: Int = 0) { def currentBalance = balance def deposit(amount: Int) { balance += amount } def withdraw(amount: Int) { balance -= amount } }
Tout d’abord, il convient de s’attarder sur la première ligne qui contient la déclaration de la classe suivit de celle du constructeur:
class BankAccount(private var balance: Int = 0) {
En Scala, les paramètres déclarés en tant que var ou val sont automatiquement transformés en membres de classe. Ici, nous obtiendrons donc un membre « balance » qui aura une visibilité restreinte aux instances de la classe de par la présence du mot clé private. Autrement dit, son accesseur et son mutateur seront privés. Il est possible de restreindre cette visibilité à une instance en faisant usage du mot clé private[this].
D’autre part, ce paramètre est optionnel lors de la construction; si la valeur est manquante, elle sera initialisée à zéro. Notez qu’il est possible d’utiliser une syntaxe plus proche du Java avec une déclaration explicite des membres; dans ce cas, on perd, à mon sens, les gains de lisibilité obtenus avec Scala.
Comme notre constructeur l’a défini, notre propriété « balance » ne sera pas accessible à l’extérieur des instances de la classe. Or, nous souhaitons qu’elle soit publique en lecture seule. Dès lors, nous devons définir un accesseur pour cela en lui donnant un autre nom que celui généré par défaut par le compilateur:
def currentBalance = balance
Enfin, les méthodes pour déposer ou retirer de l’argent sont triviales puisqu’il s’agit d’additionner ou de soustraire une valeur à la balance:
def deposit(amount: Int) { balance += amount } def withdraw(amount: Int) { balance -= amount }
Ici, nous n’avons pas fait de contrôle sur le solde restant et avons fait usage d’entiers afin de simplifier le code d’exemple.
Exercice 3
Enoncé: Ecrire une classe Time avec des propriétés hours et minutes en lecture seule ainsi qu’une méthode before(other: Time): Boolean qui vérifie si le temps courant est avant celui donné. Un objet Time doit être construit via l’appel new Time(hrs, min), où hrs est exprimé en système horaire militaire (entre 0 et 23).
Solution: Depuis l’exercice précédent, obtenir des membres de classe depuis un constructeur ne nous pose plus de problèmes; c’est ce que nous faisons ici pour les propriétés hours et minutes:
class Time(private val hours: Int = 0, private val minutes: Int = 0) {
Notez que l’usage du mot clé val empêche la génération d’un mutateur; seul l’accesseur est généré et cela nous convient parfaitement puisque nous n’avons pas le besoin de modifier ces valeurs après l’instanciation. Nous faisons également usage de valeurs par défaut.
Comme l’énoncé demande à ce que les heures soient exprimées dans le système horaire militaire, nous avons ajouté des vérifications sur le format des données. Elles sont mises en oeuvre durant l’instanciation et sont portées par le corps de la classe:
class Time(private val hours: Int = 0, private val minutes: Int = 0) { if (hours < 0 || hours > 23) { throw new IllegalArgumentException("Invalid hours format; must be between 0 and 23 included") } if (minutes < 0 || minutes > 59) { throw new IllegalArgumentException("Invalid minutes format; must be between 0 and 59 included") }
En Scala, toutes les instructions contenues dans le corps de la classe sont exécutées à l’instanciation de cette dernière. Ici, une exception est levée lorsqu’un mauvais format est détecté.
Enfin, nous déclarons la méthode qui permet de vérifier si une heure se trouve avant une autre:
def before(other: Time) = hours < other.hours || (hours == other.hours && minutes < other.minutes)
L’algorithme est un peu barbare et c’est pourquoi la question suivante va nous demander de changer de représentation interne.
Exercice 4
Enoncé: Ré-implémenter le classe Time du précédent exercice de sorte que sa représentation interne soit le nombre de minutes depuis minuit (entre 0 et 24 x 60 – 1). Ne pas changer l’interface publique. Ainsi, le code client ne devrait pas être affecté par vos changements.
Solution: L’objectif de cet exercice est de ne pas changer l’interface publique de la classe. C’est à dire que l’usage du constructeur ne doit pas changer et qu’elle doit toujours proposer la même méthode before. Néanmoins, il nous faut en changer la représentation interne pour n’avoir que les minutes depuis minuit.
Le premier changement se fait donc dans la déclaration du constructeur:
class Time(hours: Int = 0, minutes: Int = 0) {
On déclare toujours deux paramètres mais sans mot clé (ni var, ni val); ce sont donc des paramètres pleins qui ne seront pas transformés en membres de classe sauf si une méthode de la classe au minimum, en fait usage. Ici, nous nous en servons simplement pour calculer le nombre de minutes depuis minuit.
Ainsi, nous conservons nos vérifications lors de l’instanciation:
class Time(hours: Int = 0, minutes: Int = 0) { if (hours < 0 || hours > 23) { throw new IllegalArgumentException("Invalid hours format; must be between 0 and 23 included") } if (minutes < 0 || minutes > 59) { throw new IllegalArgumentException("Invalid minutes format; must be between 0 and 59 included") }
Mais ensuite, nous déclarons un champ privé en lecture seule qui sera calculé durant l’instanciation:
private val minutesSinceMidnight = hours * 60 + minutes
Le contenu de la méthode before s’en trouve drastiquement simplifié:
def before(other: Time) = minutesSinceMidnight < other.minutesSinceMidnight
Exercice 5
Enoncé: Créer une classe Student avec des propriétés d’écriture/lecture JavaBeans name (de type String) et id (de type Long). Quelles méthodes sont générées? Peut-on appeler ces méthodes en Scala? Devrions-nous?
Astuce: Utilisez javap pour vérifier les méthodes générées à la compilation.
Solution: Comme vu précédemment, la déclaration de membres n’a plus de secret pour vous:
class Student(@BeanProperty var id: Long, @BeanProperty var name: String) {}
Ici, nous avons simplement rajouté l’annotation @BeanProperty devant chacun des paramètres afin que les accesseurs et mutateurs à la forme JavaBean soient générés. Nous pouvons vérifier cela grâce à l’outil javap lancé sur le fichier .class obtenu après compilation:
javap Student.class
Le résultat obtenu est le suivant:
public class fr.mistertie.exemples.sftiexercisessolutions.chapter5.question5.Student { public long id(); public void id_$eq(long); public void setId(long); public java.lang.String name(); public void name_$eq(java.lang.String); public void setName(java.lang.String); public long getId(); public java.lang.String getName(); public fr.mistertie.exemples.sftiexercisessolutions.chapter5.question5.Student(long, java.lang.String); }
On retrouve, par exemple pour le champ id, les accesseurs et mutateurs Scala (lignes 2 et 3) et Java (lignes 4 et 8). Ces derniers sont utilisables en Scala mais ne devraient pas l’être car étant réservés à l’interopérabilité avec Java.
Notez que l’auteur décrit tout ceci en détail dans la section 5.5 « propriétés de Beans« .
Exercice 6
Enoncé: Dans la classe Person de la section 5.1 « Classes simples et méthodes sans paramètres » à la page 49, fournir un constructeur primaire qui remplace les âges négatifs par zéro.
Note: L’énoncé de cet exercice est erroné puisqu’il n’existe pas de classe Person dans la section indiquée. Nous prenons le parti que l’auteur souhaitait mentionner la section 5.2 « Propriétés avec mutateurs et accesseurs » et la classe suivante à la page 51:
class Person { var age = 0 }
Solution: Deux solutions s’offrent à nous ici. La première consiste à faire usage d’un paramètre plein qui nous permet de calculer le membre de classe:
class Person(inputAge: Int) { var age = if (inputAge >= 0) inputAge else 0 }
Si la valeur est négative, alors l’âge sera de zéro. Nous avons déjà abordé cette syntaxe précédemment mais ici, l’usage de paramètre plein n’est pas nécessaire. Nous pouvons mettre en place la même condition de la manière suivante:
class AnotherPerson(var age: Int) { if (age < 0) age = 0 }
Ainsi, le membre est créé automatiquement et se voit affecter la valeur zéro si nécessaire dans le corps de classe donc lors de l’instanciation. Ceci est rendu possible par l’usage du mot clé var.
Exercice 7
Enoncé: Ecrire une classe Person avec un constructeur primaire qui accepte une chaine de caractères contant un prénom, un espace et un nom donnant un usage de ce type: new Person(« Fred Smith »). Fournir des propriétés firstName et lastName en lecture seule. Les paramètres du constructeur primaire doivent-ils être des var, des val ou des paramètres pleins? Pourquoi?
Solution: L’énoncé de cet exercice ressemble fortement à la description de paramètres pleins pour un constructeur primaire. Nous en faisons donc usage via cette syntaxe maintenant bien connue:
class Person(name: String) { val firstName = name.split(' ')(0) val lastName = name.split(' ')(1) }
La propriété name est utilisée pour calculer les membres firstName et lastName qui sont en lecture seule de par l’usage du mot clé val. Comme elle n’est pas utilisée par une méthode de la classe, elle ne sera pas transformée en membre. Pour en avoir le coeur net, vous n’avez qu’à le vérifier à l’aide de l’outil javap.
Nous avons ici fait usage de la méthode split qui permet de découper une chaine de caractères selon un symbole donné (voir la documentation Scala pour plus de détails). Néanmoins, nous découpons deux fois la même chaine ce qui n’est pas optimal. Une solution consiste à faire usage de la syntaxe suivante:
class ThinPerson(name: String) { val Array(firstName, lastName) = name.split(' ') }
Plus concise, cette dernière permet de ne faire qu’un seul appel à la méthode split. Je ne la détaillerai pas, les prochains chapitres nous permettrons de la comprendre.
Exercice 8
Enoncé: Créer une classe Car avec des propriétés en lecture seule pour le fabriquant, le nom et l’année du modèle et une propriété classique pour la plaque d’immatriculation. Fournir quatre constructeurs. Tous nécessitent le fabriquant et le nom du modèle. De manière optionnelle, l’année du modèle et la plaque d’immatriculation peuvent aussi être spécifiés dans le constructeur. S’ils ne le sont pas, l’année est mise à -1 et la plaque d’immatriculation vaut une chaine vide. Quel constructeur choisir comme constructeur primaire? Pourquoi?
Solution: Pour répondre à cet exercice, commençons par le résoudre comme nous l’aurions fait en Java. Nous déclarons un constructeur primaire qui contient toutes les propriétés à renseigner selon les règles données:
class Car(val manufacturer: String, val modelName: String, val modelYear: Int, var licensePlate: String) {
Ensuite, nous pouvons définir les variantes comme constructeurs auxiliaires en gardant en tête que tout constructeur auxiliaire doit faire appel soit au constructeur primaire, soit à un constructeur auxiliaire précédemment défini:
def this(manufacturer: String, modelName: String) { this(manufacturer, modelName, -1, "") } def this(manufacturer: String, modelName: String, modelYear: Int) { this(manufacturer, modelName, modelYear, "") } def this(manufacturer: String, modelName: String, licensePlate: String) { this(manufacturer, modelName, -1, licensePlate) }
Ces constructeurs auxiliaires se chargent de définir les valeurs par défaut à passer au constructeur primaire. Le critère de choix du constructeur primaire est donc le nombre d’arguments.
Néanmoins, ce code est verbeux et la définition des valeurs par défaut est redondante. Pour l’améliorer, nous n’allons donc créer qu’un seul constructeur primaire avec des valeurs par défaut:
class ThinCar(val manufacturer: String, val modelName: String, val modelYear: Int = -1, var licensePlate: String = "") {}
Ainsi, nous obtenons bien plus de concision et éliminons la redondance. De plus, toutes les combinaisons d’appels au constructeur sont possibles via l’usage des paramètres nommés:
new ThinCar("Renault", "Mégane", 2014) new ThinCar("Renault", "Mégane", licensePlate = "AA-111-BB")
Exercice 9
Enoncé: Ré-implémenter la classe de l’exercice précédent en Java, C#, C++ (selon vos envies). Combien de fois la classe Scala est-elle plus courte?
Solution: Attention! Le code qui va suivre pique les yeux! On y trouve toute la verbosité de Java dans la déclaration des constructeurs, des membres de classes et des accesseurs/mutateurs.
public class Car { private Integer modelYear = -1; private String manufacturer, modelName, lincensePlate; public Car(final String manufacturer, final String modelName) { this(manufacturer, modelName, -1, ""); } public Car(final String manufacturer, final String modelName, final Integer modelYear) { this(manufacturer, modelName, modelYear, ""); } public Car(final String manufacturer, final String modelName, final String licensePlate) { this(manufacturer, modelName, -1, licensePlate); } public Car(final String manufacturer, final String modelName, final Integer modelYear, final String licensePlate) { this.manufacturer = manufacturer; this.modelName = modelName; this.modelYear = modelYear; this.lincensePlate = licensePlate; } public String getLincensePlate() { return lincensePlate; } public void setLincensePlate(String lincensePlate) { this.lincensePlate = lincensePlate; } public Integer getModelYear() { return modelYear; } public String getManufacturer() { return manufacturer; } public String getModelName() { return modelName; } }
Au total, 33 lignes de code là où Scala n’en nécessite qu’une. Scala l’emporte par KO dès le premier round.
Exercice 10
Enoncé: Considérer la classe:
class Employee(val name: String, val salary: Double) { def this() { this("John Q. Public", 0.0) } }
La réécrire en utilisant des membres explicites et une constructeur primaire par défaut. Quelle forme préférez-vous? Pourquoi?
Solution: Après tous les exercices vus ensemble, celui-ci semble trivial. La réécriture nous donne le résultat suivant:
class Employee { val name: String = "Jonh Q. Public" var salary: Double = 0.0 }
Néanmoins, la forme idéale ne me semble être ni celle de l’auteur, ni celle proposée ci-dessus. Je préfère la suivante:
class BestEmployee(val name: String = "Jonh Q. Public", var salary: Double = 0.0) {}
Elle a l’avantage d’être plus concise sans perdre en lisibilité. De plus, elle offre la possibilité de ne fournir que certains paramètres de par l’usage de valeurs par défaut.