Par Michel Schinz and Philipp Haller.
Traduction et arrangements par Agnès Maury.
Introduction
Ce document présente une introduction rapide au langage Scala et à son compilateur. Il est destiné aux personnes ayant une expérience de programmation et qui souhaitent un aperçu de ce qu’ils peuvent faire avec Scala. On part du principe que le lecteur possède des connaissances de base sur la programmation orientée objet, particulièrement sur Java.
Un premier exemple
Commençons par écrire le célèbre programme Hello world. Bien que simple, il permet de découvrir plusieurs fonctionnalités du language avec peu de connaissance préalable de Scala. Voilà à quoi il ressemble :
object HelloWorld {
def main(args: Array[String]): Unit = {
println("Hello, world!")
}
}
La structure de ce programme devrait être familière pour les développeurs Java :
il consiste en une méthode appelée main
qui prend les arguments de la ligne de commande,
une array de String, comme paramètre ; le corps de cette méthode consiste en un simple appel de la méthode
prédéfinie println
avec le salut amical comme argument. Cette méthode main
ne retourne pas de valeur.
Pourtant, son type de retour est déclaré comme Unit
.
Ce qui est moins familier pour les développeurs Java est la déclaration object
qui contient la méthode
main
. Une telle déclaration introduit ce qui est communément connu comme un objet singleton, qui est une classe
avec une seule instance. La déclaration ci-dessus déclare à la fois une classe nommée HelloWorld
et une instance de cette classe, aussi nommée HelloWorld
. Cette instance est créée sur demande, c’est-à-dire,
la première fois qu’elle est utilisée.
Le lecteur avisé a pu remarquer que la méthode main
n’est pas déclarée en tant que static
.
C’est parce que les membres statiques (membres ou champs) n’existent pas en Scala. Plutôt que de définir des
membres statiques, le développeur Scala déclare ces membres dans un objet singleton.
Compiler l’exemple
Pour compiler cet exemple, nous utilisons scalac
, le compilateur Scala.
scalac
fonctionne comme la plupart des compilateurs : il prend comme argument un fichier source,
potentiellement certaines options, et produit un ou plusieurs fichiers objets.
Les fichiers objets produits sont des fichiers classes de Java classiques.
Si nous sauvegardons le programme ci-dessus dans un fichier appelé HelloWorld.scala
,
nous pouvons le compiler en exécutant la commande suivante (le symbole supérieur >
représente
l’invité de commandes et ne doit pas être écrit) :
> scalac HelloWorld.scala
Cette commande va générer un certain nombre de fichiers class dans le répertoire courant.
L’un d’entre eux s’appellera HelloWorld.class
et contiendra une classe qui pourra être directement exécutée
en utilisant la commande scala
, comme décrit dans la section suivante.
Exécuter l’exemple
Une fois compilé, le programme Scala peut être exécuté en utilisant la commande scala
.
Son utilisation est très similaire à la commande java
utilisée pour exécuter les programmes Java,
et qui accepte les mêmes options. L’exemple ci-dessus peut être exécuté en utilisant la commande suivante,
ce qui produit le résultat attendu :
> scala -classpath . HelloWorld
Hello, world!
Interaction avec Java
L’une des forces du Scala est qu’il rend très facile l’interaction avec le code Java.
Toutes les classes du paquet java.lang
sont importées par défaut, alors que les autres
doivent être importées explicitement.
Prenons l’exemple suivant. Nous voulons obtenir et formater la date actuelle par rapport aux conventions utilisées dans un pays spécifique, par exemple la France.
Les librairies de classes Java définissent des classes utilitaires très puissantes, comme Date
et DateFormat
. Comme Scala interagit avec Java, il n’y a pas besoin de ré-implémenter ces classes en Scala
–nous pouvons simplement importer les classes des paquets correspondants de Java :
import java.util.{Date, Locale}
import java.text.DateFormat._
object DateFrancaise {
def main(args: Array[String]): Unit = {
val maintenant = new Date
val df = getDateInstance(LONG, Locale.FRANCE)
println(df format maintenant)
}
}
Les déclarations d’import de Scala sont très similaires à celle de Java, cependant,
elles sont bien plus puissantes. Plusieurs classes peuvent être importées du même paquet en les plaçant
dans des accolades comme démontré dans la première ligne. Une autre différence notable est de pouvoir
importer tous les noms d’un paquet ou d’une classe en utilisant le symbole underscore (_
) au lieu de
l’astérisque (*
). C’est parce que l’astérisque est un identifiant valide en Scala (par exemple pour
un nom de méthode), comme nous le verrons plus tard.
Par conséquent, la déclaration d’importation dans la seconde ligne importe tous les membres de la classe
DateFormat
. Cela rend la méthode statique getDateInstance
et le champ statique LONG
directement visibles.
Dans la méthode main
, nous avons tout d’abord créé une instance de la classe Java Date
qui contient par défaut la date actuelle. Ensuite, nous définissons un format de date en utilisant la
méthode statique getDateInstance
que nous avons importée précédemment. Enfin, nous imprimons
la date actuelle selon l’instance de DateFormat
localisée. Cette dernière ligne montre une
propriété intéressante de la syntaxe Scala. Les méthodes qui ne prennent en entrée qu’un seul argument
peuvent être utilisées avec une syntaxe infixe. C’est-à-dire que l’expression
df format maintenant
est juste une autre façon moins verbeuse d’écrire l’expression
df.format(maintenant)
Cela peut paraître comme un détail syntaxique mineur, mais il entraîne des conséquences importantes, dont l’une va être explorée dans la section suivante.
Pour conclure cette section sur l’intégration avec Java, il faut noter qu’il est possible d’hériter de classes Java et d’implémenter des interfaces Java directement en Scala.
Tout est objet
Scala est un langage purement orienté objet dans le sens où tout est un objet,
y compris les nombres ou les fonctions. Cela diffère du Java dans cet aspect, car Java
distingue les types primitifs (comme boolean
et int
) des types référentiels.
Les nombres sont des objets
Étant donné que les nombres sont des objets, ils ont aussi des méthodes. De fait, une expression arithmétique comme la suivante :
1 + 2 * 3 / x
consiste exclusivement en des appels de méthodes, parce qu’il est équivalent à l’expression suivante, comme nous l’avons vu dans la section précédente :
1.+(2.*(3)./(x)
Cela veut aussi dire que +
, *
, etc. sont des identifiants valides en Scala.
Les fonctions sont des objets
Les fonctions sont aussi des objets en Scala. C’est pourquoi il est possible de passer des fonctions en arguments, de les stocker dans des variables et de les retourner depuis d’autres fonctions. Cette capacité à manipuler les fonctions en tant que valeurs est l’une des pierres angulaires d’un paradigme de programmation très intéressant nommé programmation fonctionnelle.
Pour illustrer à quel point il est peut être utile d’utiliser des fonctions en tant que valeurs, considérons une fonction minuteur qui vise à performer une action toutes les secondes. Comment faire pour passer au minuteur une action à performer ? En toute logique, comme une fonction. Ce concept de passer une fonction devrait être familier à beaucoup de développeurs : il est souvent utilisé dans le code d’interface utilisateur pour enregistrer des fonctions de rappel qui sont invoquées lorsque certains évènements se produisent.
Dans le programme suivant, la fonction minuteur est appelée uneFoisParSeconde
et prend comme argument
une fonction de rappel. Le type de cette fonction est écrit () => Unit
. C’est le type de toutes les
fonctions qui ne prennent aucun argument et ne renvoie rien (le type Unit
est similaire à void
en C/C++).
La principale fonction de ce programme est d’appeler la fonction minuteur avec une fonction de rappel
qui imprime une phrase dans le terminal. Dans d’autres termes, ce programme imprime à l’infini la phrase
“le temps passe comme une flèche”.
object Minuteur {
def uneFoisParSeconde(retour: () => Unit): Unit = {
while (true) {
retour()
Thread sleep 1000
}
}
def leTempsPasse(): Unit = {
println("le temps passe comme une flèche")
}
def main(args: Array[String]): Unit = {
uneFoisParSeconde(leTempsPasse)
}
}
Notez que pour imprimer la String, nous utilisons la méthode prédéfinie println
au lieu
d’utiliser celle du paquet System.out
.
Fonctions anonymes
Bien que ce programme soit facile à comprendre, il peut être affiné un peu plus.
Premièrement, notez que la fonction leTempsPasse
est définie uniquement dans le but d’être
passée plus tard dans la fonction uneFoisParSeconde
. Devoir nommer cette fonction qui ne va
être utilisée qu’une fois peut sembler superflu et il serait plus agréable de pouvoir construire
cette fonction juste au moment où elle est passée à uneFoisParSeconde
. C’est possible en Scala
en utilisant des fonctions anonymes, ce qui correspond exactement à ça : des fonctions sans nom.
La version revisitée de notre programme minuteur en utilisant une fonction anonyme à la place de
leTempsPasse ressemble à ça :
object MinuteurAnonyme {
def uneFoisParSeconde(retour: () => Unit): Unit = {
while (true) {
retour()
Thread sleep 1000
}
}
def main(args: Array[String]): Unit = {
uneFoisParSeconde(
() => println("le temps passe comme une flèche")
)
}
}
La présence d’une fonction anonyme dans cet exemple est reconnaissable par la flèche pointant à droite
=>
qui sépare la liste des arguments de la fonction de son corps. Dans cet exemple, la liste des
arguments est vide, comme en témoigne la paire de parenthèses vide à gauche de la flèche. Le corps
de cette fonction est le même que celui de leTempsPasse
décrit plus haut.
Classes
Comme nous l’avons vu plus tôt, Scala est un langage orienté objet et de ce fait, possède le concept de classe (pour être plus exact, il existe certains langages orientés objet qui ne possèdent pas le concept de classe mais Scala n’en fait pas partie). Les classes en Scala sont déclarées en utilisant une syntaxe proche de celle de Java. Une différence notable est que les classes en Scala peuvent avoir des paramètres. Ceci est illustré dans la définition suivante des nombres complexes.
class Complexe(reel: Double, imaginaire: Double) {
def re() = reel
def im() = imaginaire
}
La classe Complexe
prend en entrée deux arguments : la partie réelle et la partie imaginaire du
nombre complexe. Ces arguments peuvent être passés lors de la création d’une instance de Complexe
comme
ceci :
new Complexe(1.5, 2.3)
La classe contient deux méthodes, appelées re
et im
qui donnent accès à ces deux parties.
Il faut noter que le type de retour de ces méthodes n’est pas explicitement donné. Il sera inféré
automatiquement par le compilateur, qui regarde la partie droite de ces méthodes et en déduit que chacune
de ces fonctions renvoie une valeur de type Double
.
Le compilateur n’est pas toujours capable d’inférer des types comme il le fait ici et il n’y a malheureusement aucune règle simple pour savoir dans quel cas il est capable de le faire. En pratique, ce n’est pas généralement un problème car le compilateur se plaint quand il n’est pas capable d’inférer un type qui n’a pas été donné explicitement. Une règle simple que les développeurs débutant en Scala devraient suivre est d’essayer d’omettre les déclarations de type qui semblent être faciles à déduire et voir si le compilateur ne renvoie pas d’erreur. Après quelque temps, le développeur devrait avoir une bonne idée de quand il peut omettre les types et quand il faut les spécifier explicitement.
Les méthodes sans arguments
Un petit problème des méthodes re
et im
est qu’il faut mettre une paire de parenthèses vides après
leur nom pour les appeler, comme démontré dans l’exemple suivant :
object NombresComplexes {
def main(args: Array[String]): Unit = {
val c = new Complexe(1.2, 3.4)
println("partie imaginaire : " + c.im())
}
}
Il serait plus agréable de pouvoir accéder à la partie réelle et imaginaire comme si elles étaient des
champs, sans ajouter une paire de parenthèses vides. C’est parfaitement faisable en Scala, simplement en
les définissant comme des méthodes sans argument. De telles méthodes diffèrent des méthodes avec
aucun argument : elles n’ont pas de parenthèses après leur nom, que ce soit dans leur déclaration
ou lors de leur utilisation. Notre classe Complexe
peut être réécrite de cette façon :
class Complexe(reel: Double, imaginaire: Double) {
def re = reel
def im = imaginaire
}
Héritage et redéfinition
Toutes les classes en Scala héritent d’une super classe. Quand aucune super classe n’est spécifiée,
comme dans l’exemple Complexe
de la section précédente, la classe scala.AnyRef
est utilisée
implicitement.
Il est possible de redéfinir les méthodes héritées d’une super classe en Scala. Cependant, il est
obligatoire de spécifier explicitement qu’une méthode en redéfinit une autre en utilisant le
modificateur override
dans le but d’éviter les redéfinitions accidentelles. Dans notre exemple,
la classe Complexe
peut être enrichie avec une redéfinition de la méthode toString
héritée
de la classe Object
.
class Complexe(reel: Double, imaginaire: Double) {
def re() = reel
def im() = imaginaire
override def toString() = "" + re + (if (im >= 0) "+" + im + "i" else "")
}
Nous pouvons alors appeler la méthode toString
redéfinie comme ci-dessus.
object NombresComplexes {
def main(args: Array[String]): Unit = {
val c = new Complexe(1.2, 3.4)
println("toString() redéfinie : " + c.toString)
}
}
Les case class et le pattern matching
L’arbre est un type de structure de données qui revient souvent. Par exemple, les interpréteurs et les compilateurs représentent généralement en interne les programmes comme des arbres ; les documents XML sont des arbres ; et beaucoup de conteneurs sont basés sur des arbres, comme les arbres bicolores.
Nous allons maintenant examiner comment de tels arbres sont représentés et manipulés en Scala à travers
d’un petit programme de calculatrice. Le but de ce programme est de manipuler des expressions arithmétiques
simples composées de sommes, de constantes numériques et de variables. Deux exemples de telles expressions
sont 1+2
et (x+x)+(7+y)
.
Nous devons d’abord décider d’une représentation pour de telles expressions. La manière la plus naturelle est un arbre où chaque nœud représente une opération (ici, une addition) et chaque feuille est une valeur (ici des constantes ou variables).
En Java, un tel arbre serait représenté par une super classe abstraite pour les arbres et une sous classe concrète pour chaque nœud et feuille. Dans un langage de programmation fonctionnelle, on utiliserait plutôt un type de donnée algébrique pour faire la même chose. Scala fournit le concept de case class qui est quelque part entre ces deux concepts. Voici comment elles peuvent être utilisées pour définir le type des arbres pour notre exemple :
abstract class Arbre
case class Somme(l: Arbre, r: Arbre) extends Arbre
case class Var(n: String) extends Arbre
case class Const(v: Int) extends Arbre
Le fait que les classes Somme
, Var
et Const
sont définies en tant que case class signifie qu’elles
différent des classes traditionnelles en différents points :
- le mot clé
new
n’est pas obligatoire lors de la création d’instance de ces classes (c’est-à-dire qu’on peut écrireConst(5)
à la place denew Const(5)
) ; - les fonctions accesseurs sont automatiquement définies pour les paramètres du constructeur
(c’est-à-dire qu’il est possible de récupérer la valeur du paramètre du constructeur
v
pour une instancec
de la classeConst
en écrivant tout simplementc.v
) ; - une définition par défaut des méthodes
equals
ethashCode
est fournie, qui se base sur la structure des instances et non pas leur identité ; - une définition par défaut de la méthode
toString
est fournie et imprime la valeur “à la source” (par exemple, l’arbre pour l’expressionx+1
s’imprime commeSomme(Var(x),Const(1))
) ; - les instances de ces classes peuvent être décomposées avec un pattern matching (filtrage par motif) comme nous le verrons plus bas.
Maintenant que nous avons défini le type de données pour représenter nos expressions arithmétiques,
il est temps de définir des opérations pour les manipuler. Nous allons commencer par une fonction
pour évaluer une expression dans un certain environnement. Le but de cet environnement est de
donner des valeurs aux variables. Par exemple, l’expression x+1
évaluée dans un environnement qui
associe la valeur 5
à la variable x
, écrit { x -> 5 }
, donne comme résultat 6
.
Il faut donc trouver un moyen de représenter ces environnements. Nous pouvons certes utiliser
une sorte de structure de données associatives comme une table de hashage, mais nous pouvons aussi
utiliser directement des fonctions ! Un environnement n’est ni plus ni moins qu’une fonction qui associe
une valeur à une variable. L’environnement { x -> 5 }
décrit plus tôt peut être écrit simplement comme
ceci en Scala :
{ case "x" => 5 }
Cette notation définit une fonction qui, quand on lui donne une String "x"
en entrée, retourne l’entier
5
et renvoie une exception dans les autres cas.
Avant d’écrire la fonction d’évaluation, donnons un nom au type de ces environnements.
Nous pouvons toujours utiliser le String => Int
pour ces environnements mais cela simplifie
le programme si nous introduisons un nom pour ce type et rendra les modifications futures plus simples.
En Scala, on le réalise avec la notation suivante :
type Environnement = String => Int
À partir de maintenant, le type Environnement
peut être utilisé comme un alias comme
le type des fonctions de String
à Int
.
Maintenant, nous pouvons donner la définition de l’évaluation de fonction. Théoriquement, c’est très simple : la valeur d’une somme de deux expressions est tout simplement la somme des valeurs de ces expressions ; la valeur d’une variable est obtenue directement à partir de l’environnement ; la valeur d’une constante est la constante elle-même. Pour l’exprimer en Scala, ce n’est pas plus compliqué :
def eval(a: Arbre, env: Environnement): Int = a match {
case Somme(l, r) => eval(l, env) + eval(r, env)
case Var(n) => env(n)
case Const(v) => v
}
Cette fonction d’évaluation fonctionne en effectuant un pattern matching
sur l’arbre a
. De façon intuitive, la signification de la définition ci-dessus
devrait être claire :
- Tout d’abord, il vérifie si l’arbre
a
est uneSomme
. Si c’est le cas, il relie le sous arbre de gauche à une nouvelle variable appeléel
et le sous arbre de gauche à une variable appeléer
. Ensuite, il traite l’expression à droite de la flèche : cette expression peut utiliser (dans notre exemple, c’est le cas) les deux variablesl
etr
extraites dans le motif décrit à gauche de la flèche ; - Si la première vérification échoue, c’est-à-dire que l’arbre n’est pas une
Somme
, on continue et on vérifie sia
est uneVar
. Si c’est le cas, il relie le nom contenu dans le nœudVar
à une variablen
et il traite l’expression à droite de la flèche ; - Si la deuxième vérification échoue, c’est-à-dire que l’arbre n’est ni
une
Somme
ni uneVar
, on vérifie si l’arbre est unConst
. Si c’est le cas, il relie la valeur contenue dans le nœudConst
à une variablev
et il traite l’expression à droite de la flèche ; - Enfin, si toutes les vérifications échouent, une exception est levée pour signaler
l’échec de l’expression. Dans notre cas, cela pourrait arriver si
d’autres sous classes de
Arbre
étaient déclarées.
Nous observons que l’idée basique du pattern matching est de faire correspondre une valeur à une série de motifs et dès qu’un motif correspond, extraire et nommer les différentes parties de la valeur pour enfin évaluer du code qui, généralement, utilise ces parties nommées.
Un développeur orienté objet chevronné pourrait se demander pourquoi nous n’avions pas
défini eval
comme une méthode de la classe Arbre
et de ces
sous classes. En effet, nous aurions pu le faire, étant donné que Scala autorise
la définition de méthodes dans les case class tout comme dans les classes normales.
Décider d’utiliser un pattern matching ou des méthodes est donc une question de
goût mais a aussi des implications importantes sur l’extensibilité :
- quand on utilise des méthodes, il est facile d’ajouter un nouveau type de nœud en même temps
qu’une nouvelle sous classe de
Arbre
est définie. Par contre, ajouter une nouvelle opération pour manipuler un arbre est fastidieux car il demande de modifier toutes les sous classes deArbre
; - quand on utilise un pattern matching, la situation est inversée : ajouter un nouveau type de nœud demande la modification de toutes les fonctions qui effectuent un pattern matching sur un arbre pour prendre en compte le nouveau nœud. Par contre, ajouter une nouvelle opération est facile en la définissant en tant que fonction indépendante.
Pour explorer plus loin dans le pattern matching, définissons une autre opération sur les expressions arithmétiques : la dérivée de fonction. Le lecteur doit garder à l’esprit les règles suivantes par rapport à cette opération :
- la dérivée d’une somme est la somme des dérivées ;
- la dérivée d’une variable
v
est 1 siv
est égale la variable utilisée pour la dérivation et zéro sinon ; - la dérivée d’une constante est zéro.
Ces règles peuvent presque être traduites littéralement en du code Scala pour obtenir la définition suivante :
def derivee(a: Arbres, v: String): Arbres = a match {
case Somme(l, r) => Somme(derivee(l, v), derivee(r, v))
case Var(n) if (v == n) => Const(1)
case _ => Const(0)
}
Cette fonction introduit deux nouveaux concepts reliés au pattern matching.
Premièrement, l’expression case
qui peut être utilisé avec un garde qui suit le mot clé if
.
Ce garde empêche le pattern matching de réussir à moins que l’expression soit vraie. Ici, il est utilisé
pour s’assurer qu’on retourne la constante 1
uniquement si le nom de
la variable se faisant dériver est la même que la variable de dérivation
v
. La seconde nouvelle fonctionnalité du pattern matching utilisée ici est
le motif joker, représenté par _
, qui est un motif correspondant à
n’importe quelle valeur sans lui donner un nom.
Nous n’avons pas encore exploré l’étendue du pouvoir du pattern matching, mais nous
nous arrêterons ici afin de garder ce document court. Nous voulons toujours
voir comment les deux fonctions ci-dessus fonctionnent dans un exemple réel. Pour se
faire, écrivons une fonction main
simple qui effectue plusieurs opérations sur l’expression
(x+x)+(7+y)
: elle évalue tout d’abord sa valeur dans l’environnement
{ x -> 5, y -> 7 }
puis on la dérive par rapport à x
et par rapport à y
.
def main(args: Array[String]): Unit = {
val exp: Arbre = Somme(Somme(Var("x"),Var("x")),Somme(Const(7),Var("y")))
val env: Environnement = { case "x" => 5 case "y" => 7 }
println("Expression : " + exp)
println("Évaluation avec x=5, y=7 : " + eval(exp, env))
println("Dérivée par rapport à x :\n " + derivee(exp, "x"))
println("Dérivée par rapport à y :\n " + derivee(exp, "y"))
}
Vous devrez envelopper le type Environnement
et les méthodeseval
, derivee
et main
dans un objet Calc
avant de compiler. En exécutant ce programme, on obtient le résultat attendu :
Expression : Somme(Somme(Var(x),Var(x)),Somme(Const(7),Var(y)))
Évaluation avec x=5, y=7 : 24
Dérivée par rapport à x :
Somme(Somme(Const(1),Const(1)),Somme(Const(0),Const(0)))
Dérivée par rapport à y :
Somme(Somme(Const(0),Const(0)),Somme(Const(0),Const(1)))
En examinant la sortie, on voit que le résultat de la dérivée devrait être simplifiée avant d’être présentée à l’utilisateur. Définir une simplification basique en utilisant un pattern matching est un problème intéressant (mais curieusement délicat), laissé comme exercice pour le lecteur.
Traits
Hormis le fait d’hériter du code d’une super classe, une classe Scala peut aussi importer du code d’un ou de plusieurs traits.
Peut-être que le moyen le plus simple pour un développeur Java de comprendre les traits est de le voir comme une interface qui peut aussi contenir du code. En Scala, quand une classe hérite d’un trait, elle implémente son interface et hérite de tout le code contenu dans ce trait.
Notez que depuis Java 8, les interfaces Java peut aussi contenir du code, soit
en utilisant le mot clé default
soit avec des méthodes statiques.
Pour s’apercevoir de l’utilité des traits, regardons un exemple classique :
les objets ordonnés. Il est souvent utile de pouvoir comparer des objets
d’une même classe, par exemple pour les trier. En Java,
les objets qui sont comparables implémentent l’interface Comparable
.
En Scala, on peut faire un peu mieux qu’en Java en définissant
notre équivalent de Comparable
en tant que trait, qu’on appellera
Ord
.
Quand on compare des objets, six différents prédicats peuvent être utiles : plus petit, plus petit ou égal, égal, inégal, plus grand, plus grand ou égal. Cependant, tous les définir est fastidieux, surtout que quatre de ces six prédicats peuvent être exprimés en utilisant les deux restantes. En effet, en utilisant les prédicats égal et plus petit (par exemple), on peut exprimer les autres. En Scala, toutes ces observations peuvent être capturées dans la déclaration de trait suivante :
trait Ord {
def < (that: Any): Boolean
def <=(that: Any): Boolean = (this < that) || (this == that)
def > (that: Any): Boolean = !(this <= that)
def >=(that: Any): Boolean = !(this < that)
}
Cette définition crée à la fois un nouveau type appelé Ord
,
qui joue un rôle similaire à l’interface Java Comparable
, et
des implémentations par défaut de trois prédicats par rapport à un
quatrième prédicat abstrait. Les prédicats d’égalité et d’inégalité n’apparaissent pas
ici vu qu’ils sont présents par défaut dans tous les objets.
Le type Any
qui est utilisé plus haut est le type
qui est le super type de tous les autres types en Scala. Il peut être vu comme une
version plus générale du type Java Object
, puisqu’il est aussi un
super type de types basic comme Int
, Float
, etc.
Pour rendre les objets d’une classes comparables, il est alors suffisant de
définir les prédicats qui testent l’égalité et l’infériorité, puis les mixer
dans la classe Ord
ci-dessus. Comme exemple, définissons une
classe Date
qui représente les dates dans le calendrier grégorien. Elles
sont composées d’un jour, un mois et une année, que nous allons
représenter avec des entiers. Nous commençons toutefois la définition de la
classe Date
comme ceci :
class Date(a: Int, m: Int, j: Int) extends Ord {
def annee = a
def mois = m
def jour = j
override def toString(): String = annee + "-" + mois + "-" + jour
La partie importante ici est la déclaration extends Ord
qui
suit le nom de la classe et ses paramètres. Cela veut dire que la
classe Date
hérite du trait Ord
.
Ensuite, nous redéfinissons la méthode equals
, héritée de Object
,
pour comparer correctement les dates en comparant leur
champs individuels. L’implémentation par défaut de equals
n’est pas
utilisable, car en Java, elle compare les objets physiquement. On arrive
à la définition suivante :
override def equals(that: Any): Boolean =
that.isInstanceOf[Date] && {
val d = that.asInstanceOf[Date]
d.jour == jour && d.mois == mois && d.annee == annee
}
Cette méthode utilise les méthodes prédéfinies isInstanceOf
et
asInstanceOf
. La première méthode, isInstanceOf
correspond à l’opérateur
Java instanceof
et retourne true si et seulement si l’objet
sur lequel elle est appliquée est une instance du type donné.
La deuxième, asInstanceOf
, correspond à l’opérateur de conversion de type :
si l’objet est une instance du type donné, il est vu en tant que tel,
sinon une ClassCastException
est levée.
Enfin, la dernière méthode à définir est le prédicat qui teste l’infériorité
comme décrit plus loin. Elle utilise une autre méthode,
error
du paquet scala.sys
, qui lève une exception avec le message d’erreur donné.
def <(that: Any): Boolean = {
if (!that.isInstanceOf[Date])
sys.error("on ne peut pas comparer " + that + " et une Date")
val d = that.asInstanceOf[Date]
(annee < d.annee) ||
(annee == d.annee && (mois < d.mois ||
(mois == d.mois && jour < d.jour)))
}
Cela complète la définition de la classe Date
. Les instances de
cette classe peuvent être vues soit comme des dates, soit comme des objets comparables.
De plus, elles définissent les six prédicats de comparaison mentionnés
ci-dessus : equals
et <
car elles apparaissent directement dans
la définition de la classe Date
, ainsi que les autres qui sont directement héritées du trait Ord
.
Bien sûr, les traits sont utiles dans d’autres situations que celle décrite ici, mais discuter de leurs applications plus amplement est hors de la portée de document.
Généricité
La dernière caractéristique de Scala que nous allons explorer dans ce tutoriel est la généricité. Les développeurs Java devraient être conscients des problèmes posés par le manque de généricité dans leur langage, une lacune qui a été compensée avec Java 1.5.
La généricité est la capacité d’écrire du code paramétrisé par des types. Par
exemple, un développeur qui écrit une librairie pour des listes liées fait face au
problème de décider quel type donner aux éléments de la liste.
Comme cette liste est destinée à être utilisée dans divers contextes, il n’est
pas possible de décider quel type doit avoir les éléments de liste, par exemple,
Int
. Ce serait complètement arbitraire et excessivement restrictif.
Les développeurs Java se retrouvent à utiliser Object
, le super type de
tous les objets. Cependant, cette solution est loin d’être
idéale, puisqu’elle ne marche pas pour les types basiques (int
,
long
, float
, etc.) et cela implique que le développeur
devra faire un certain nombre de conversions de types.
Scala rend possible la définition de classes (et de méthodes) génériques pour résoudre ce problème. Examinons ceci au travers d’un exemple d’une classes conteneur la plus simple possible : une référence, qui peut être vide ou pointer vers un objet typé.
class Reference[T] {
private var contenu: T = _
def set(valeur: T) { contenu = valeur }
def get: T = contenu
}
La classe Reference
est paramétrisé par un type appelé T
qui est le type de son élément. Ce type est utilisé dans le corps de la
classe en tant que de la variable contenu
, l’argument de la méthode
set
et le type de retour de la méthode get
.
L’échantillon de code ci-dessus introduit les variables en Scala, ce qui ne devrait pas
demander plus d’explications. Cependant, il est intéressant de voir que
la valeur initiale donnée à la variable est _
qui représente
une valeur par défaut. Cette valeur par défaut est 0 pour les types numériques,
false
pour le type Boolean
, ()
pour le type Unit
et null
pour tous les types d’objet.
Pour utiliser cette classe Reference
, il faut spécifier quel type utiliser
pour le type paramètre T
, le type de l’élément contenu dans la cellule.
Par exemple, pour créer et utiliser une cellule contenant
un entier, on peut écrire :
object ReferenceEntier {
def main(args: Array[String]): Unit = {
val cellule = new Reference[Int]
cellule.set(13)
println("La référence contient la moitié de " + (cellule.get * 2))
}
}
Comme on peut le voir dans l’exemple, il n’est pas nécessaire de convertir la valeur
retournée par la méthode get
avant de pouvoir l’utiliser en tant qu’entier. Il
n’est pas possible de stocker autre chose d’un entier dans cette
cellule particulière, puisqu’elle a été déclarée comme portant un entier.
Conclusion
Ce document donne un rapide aperçu du langage Scala et présente quelques exemples basiques. Le développeur intéressé peut poursuivre sa lecture, par exemple, en lisant le Tour of Scala (document en anglais) et consulter la spécification du langage Scala si nécessaire.