Généralement un type est un ensemble de valeurs. Ce qui ne
nous avance pas beaucoup ! Disons plutôt qu’un type regroupe des
valeurs qui vont naturellement ensemble, parce qu’elles ont des
représentations en machine identiques (byte
occupe 8 bits en
machine, int
en occupe 32), ou surtout parce qu’elles vont
naturellement ensemble (un objet Point
est un point du plan, un
objet Object
est un objet).
Java est un langage typé statiquement, c’est-à-dire que si l’on écrit un programme incorrect du point de vue des types, alors le compilateur refuse de le compiler. Il s’agit non pas d’une contrainte irraisonnée imposée par des informaticiens fous, mais d’une aide à la mise au point des programmes : la majorité des erreurs stupides ne passe pas la compilation. Par exemple, le programme suivant contient deux erreurs de type :
La compilation de la classe MalType
échoue :
% javac MalType.java MalType.java:9: Incompatible type for method. Can't convert boolean to int. System.out.println(incr(true)) ; // Mauvais type ^ MalType.java:10: No method matching incr() found in class MalType. System.out.println(incr()) ; // Oubli d'argument ^ 2 errors
Le système de types de Java assez puissant
et les classes permettent
certaines audaces. La plus courante se voit très bien dans
l’utilisation de System.out.println
(afficher une ligne sur la
console), on peut passer n’importe quoi ou presque en argument, séparé
par des « + » :
Cela peut se comprendre si on sait que + est l’opérateur de
concaténation sur les chaînes, que System.out.println
prend une
chaîne en argument et que le compilateur insère des
conversions là où il sent que c’est utile.
Il y a huit types scalaires, à savoir d’abord quatre types « entier »,
byte
, short
, int
et long
. Ces entiers sont en
fait des entiers modulo 2p (avec p respectivement égal à 8,
16, 32 et 64) les représentants des classes d’équivalence modulo
2p sont centrés autour de zéro, c’est-à-dire compris entre
−2p−1 (inclus) et 2p−1 (exclu). On dit aussi que les quatre
types entiers correspondent aux entiers représentables sur 8, 16,
32 et 64 chiffres binaires, selon la technique dite du complément
à la base (l’opposé d’un entier n est 2p−n).
Les autres types scalaires sont les booléens boolean
(deux valeurs true
et false
), les
caractères char
et deux sortes de nombres flottants,
simple précision float
et double précision double
.
L’économie de mémoire réalisée en utilisant les float
(sur 32 bits) à la place des double
(64 bits) n’a d’intérêt que
dans des cas spécialisés.
Les tableaux sont un cas à part (voir B.3.6). Les classes sont des types pour les objets. Mais attention, les types sont en fait bien plus une propriété du source des programmes, que des valeurs lors de l’exécution. Nous essayons d’éviter de parler du « type » d’un objet. En revanche, il n’y a aucune difficulté à parler du type d’une variable qui peut contenir un objet, ou du type d’un argument objet.
La syntaxe de la conversion de type est simple
(type)expression
La sémantique est un peu moins simple. Une conversion de type s’adresse d’abord au compilateur. Elle lui dit de changer son opinion sur expression. Par exemple, comme nous l’avons déjà vu en section B.2.3
dit explicitement au compilateur que l’expression
p (normalement
de type Pair
) est vue comme un Object
. Comme Pair
est une sous-classe de Object
(ce que sait le compilateur), ce
changement d’opinion est toujours possible et
(Object)p
ne correspond à aucun calcul au cours de l’exécution.
En fait, dans ce cas d’une conversion vers un sur-type, on peut
même omettre la conversion explicite, le compilateur saura
changer son opinion tout seul si besoin est.
Il n’en va pas de même dans l’autre sens
Le compilateur accepte ce source, la conversion est nécessaire pour le
faire changer d’opinion sur la valeur d’abord rangée dans o, puis
dans p : cet objet est finalement une paire, et possède donc une
variable d’instance x. Mais ici, rien ne le garantit, et
l’expression (Pair)o
correspond à une vérification lors de
l’exécution. Si cette vérification échoue, alors le programme échoue
aussi (en lançant l’exception ClassCastException).
Il est malheureusement parfois nécessaire d’utiliser de telles
conversions (vers un sous-type) voir 3.3.2, même
quand on programme proprement en ne mélangeant pas
des objets de classes distinctes.
On peut aussi convertir le type des scalaires, cette fois les
conversions entraînent des transformations des valeurs converties, car
les représentations internes des scalaires ne sont pas toutes
identiques. Par exemple, si on change un int
(32 bits) en
long
(64 bits), la machine réalise un certain travail. Ce
calcul n’est pas ici une vérification, mais un changement de
représentation. La plupart des conversions de types entre scalaires
restent implicites et sont effectuées à l’occasion des opérations. Si
par exemple on écrit 1.5 + 2
, alors le compilateur arrive à
comprendre 1.5 + (double)2
, afin d’effectuer une addition
entre double
. Il y a un cas où on insère soit-même ce type de
conversions.
(Notez l’abondance de parenthèses pour bien spécifier les
arguments de la conversion et des opérations.)
Si on ne change pas explicitement un des arguments de la division
en double
, alors « / » est la division euclidienne,
alors que l’on veut ici la division des flottants.
On aurait d’ailleurs pu écrire :
En effet, le compilateur change alors a en double
, pour
avoir le même type que l’autre argument de la multiplication.
Ensuite, la division est entre un flottant et un int
, et
ce dernier est converti afin d’effectuer la division
des flottants.
Le compilateur n’effectue jamais tout seul une conversion
qui risque de faire perdre de l’information. À la place,
quand une telle conversion est nécessaire pour typer le programme,
il échoue.
Par exemple, prenons la partie entière d’un double
.
Le compilateur, assez bavard, nous dit :
T.java:4: possible loss of precision found : double required: int int e = d ; ^
Dans ce cas, on doit prendre ses responsabilités et écrire
Il faut noter que nous avons effectivement pris le risque de faire n’importe quoi. Si d est trop gros pour que sa partie entière tienne dans 32 bits (supérieure à 231−1), alors on a un résultat étrange. Le programme
conduit à afficher 1.0E100, 2147483647.
Les caractères de Java sont définis par deux normes internationales
synchronisées ISO/CEI 10646 et
Unicode.
Le nom générique le plus approprié semblant être UTF-16.
En simplifiant, une valeur du type char
occupe 16 chiffres
binaires et chaque valeur correspond à un caractère.
C’est simplifié parce qu’en fait Unicode définit plus de 216
caractères et qu’il faut parfois plusieurs char
pour faire un
caractère Unicode. Dans la suite nous ne tenons pas compte de cette
complexité supplémentaire introduite notamment pour représenter tous
les idéogrammes chinois.
Un char
a une double personnalité, est d’une part
un caractère (comme 'a'
, 'é'
etc.) et d’autre part un
entier sur 16 bits (disons le « code » du caractère),
qui contrairement à short
est toujours positif.
La première personnalité d’un caractère se révèle quand on l’affiche,
la seconde quand on le compare à un autre caractère.
Les 128 premiers caractères (c’est-à-dire ceux dont les codes sont
compris entre 0 et 127) correspondent exactement à un autre
standard international bien plus ancien, l’ASCII.
L’ASCII regroupe
notamment les chiffres de '0'
à '9'
et les lettres
(non-accentuées) minuscules et majuscules, mais aussi un certain
nombre de caractères de contrôle, dont les plus fréquents expriment le
« retour à la ligne ». Une petite digression va nous montrer
que la standardisation du jeu de caractères n’est malheureusement pas
suffisante pour tout normaliser. En Unix un retour à la ligne
s’exprime par le caractère line feed noté
'\n'
, en Windows c’est la séquence d’un carriage return
noté '\r'
et d’un
line feed, et en Mac OS, c’est un carriage return tout
seul !
Le plus affligeant est
que ces différences s’expliquent par
la conception de la machine à écrire mécanique, dont est dérivé le télétype,
le premier terminal pratique des ordinateurs.
En effet (visualisez une machine à écrire), pour passer à la ligne, il
faut à la fois renvoyer le chariot de la machine à fond vers la droite
(carriage return) et tourner le rouleau (line feed).
On peut certes imaginer des effets spéciaux,
écrire en escalier (Unix) ou écraser la ligne en cours (Mac OS), mais bon.
Les concepteurs de la machine à écrire étaient d’ailleurs conscient de ce
que les deux opérations sont souvent associées, puisque ces machines
offrent un levier qui permet d’effectuer ensemble les deux opérations
de pousser le chariot et de tourner le rouleau.
Alors, pourquoi pas nous ?
Le plus souvent ces détails restent cachés, par exemple
System.out.println()
effectue toujours un retour à la ligne
sur l’affichage de la console, c’est le code de bibliothèque
qui se charge de fournir les bons caractères à la console selon
le système sur lequel le programme est en train de s’exécuter.
Toutefois des problèmes peuvent surgir en cas de transfert de fichiers
d’un système à l’autre…