Valeurs
Faisons maintenant un point sur les variables et autres valeurs nommées en Rust.
Variables locales
Comme vous l’avez vu à la fin du chapitre précédent, on déclare une variable
locale avec let
, comme dans les langages de la famille ML (OCaml, Haskell,
…) :
#![allow(unused)] fn main() { let vrai = true; }
Les types sont inférés à l’intérieur d’une fonction, en exploitant non seulement
la façon dont une valeur est créée (comme fait auto
en C++), mais aussi la
fonction de la façon dont la valeur est utilisée ultérieurement dans le code.
Il est donc rarement nécessaire de typer manuellement ses variables, et ce
n’est pas une pratique idiomatique en Rust.
En revanche, les types d’entrées et de sortie des fonctions doivent être spécifiés explicitement, comme nous le verrons plus tard dans ce cours, ce qui garantit…
- Un bon niveau de clarté du code (pour peu qu’il soit bien découpé en fonctions).
- Une stabilité des APIs (en modifiant l’implémentation d’une fonction, on ne risque pas de changer accidentellement ses types d’entrée et de sortie et de casser du code client).
Pour guider l’inférence, on sera parfois amené à spécifier le type d’une variable explicitement. On peut le faire avec la syntaxe suivante :
#![allow(unused)] fn main() { let faux: bool = false; }
Pour conclure, mentionnons qu’en Rust, ce n’est pas une erreur de définir une nouvelle variable qui porte le même nom qu’une ancienne, ce qui rend cette dernière inaccessible (shadowing). C’est même idiomatique lorsque la nouvelle variable remplace l’ancienne qui ne devrait plus être utilisée.
#![allow(unused)] fn main() { // Imaginez que cette chaîne nous vienne d'une entrée utilisateur... let nombre = "123"; // ...et qu'on la traduise en nombre pour la suite let nombre = nombre.parse::<u8>() .expect("La chaîne source ne contient pas un nombre"); }
Initialisation différée
La lecture de mémoire non initialisée est un comportement indéfini en C++ et en Rust. N’ayant pas de valeur particulière à retourner, le compilateur est autorisé aussi bien à retourner n’importe quoi qu’à supprimer purement et simplement le code qui utilise la valeur lue, ce qu’il fera ou pas selon les décisions prises pendant le processus d’optimisation de code.
Sachant ça et connaissant les objectifs de conception de Rust (zéro comportement indéfini hors unsafe), vous ne serez pas surpris d’apprendre que ce code est illégal :
#![allow(unused)] fn main() { let a: bool; let b = a; // Interdit ! }
La raison pour laquelle la syntaxe de déclaration de variable sans initialisation existe néanmoins est que cela permet l’initialisation différée d’une variable. En voici un exemple un peu artificiel :
#![allow(unused)] fn main() { let condition = true; let resultat; if condition { resultat = 42; } else { resultat = 24; } let lecture = resultat; // OK }
Mutabilité
Par défaut, la plupart des variables sont immutables. Ainsi, ce code ne compilera pas :
#![allow(unused)] fn main() { let a = 42; a = 43; // Erreur de compilation }
Pour qu’une variable soit modifiable, il faut généralement l’avoir demandé avec
le mot-clé mut
:
#![allow(unused)] fn main() { let mut b = 123; b = 456; // OK }
Par rapport à C++, Rust encourage donc un style de programmation plus fonctionnel où la modification de variables est rare, et on manie principalement des valeurs immuables. Ce style de code est souvent à la fois plus facile à comprendre et à paralléliser.
Mais malheureusement, pour diverses raisons pratiques sordides sur lesquelles je
reviendrai plus tard, les choses ne sont pas tout à fait si simples, et les
variables de certains types peuvent être modifiées par des opérations spéciales
même si elles n’ont pas été déclarées avec le mot-clé mut
.
Nous aborderons ces types à “mutabilité interne” plus tard, pour l’instant
retenez juste que si mut
signifie toujours “modifiable”, l’interprétation
d’une absence de mut
dépend du type de la variable.
Variables globales
Les variables locales ne sont pas le seul type de variable nommée autorisée en Rust. On rencontre également plus rarement les variables statiques, qui se comportent comme si un exemplaire unique était stocké au sein du binaire et réutilisé chaque fois que du code y fait référence…
#![allow(unused)] fn main() { // Notez l'absence d'inférence de type sur les variables statiques static F: f32 = 4.2; }
…ainsi que les constantes de compilation, qui se comportent comme si, après évaluation à la compilation, le résultat était copié-collé en chaque point où la valeur est utilisée :
#![allow(unused)] fn main() { // Pas d'inférence de type ici non plus const X: u64 = 123 + 456; }
Ces deux types de valeurs doivent, par définition, être disponibles dès le
lancement du programme. Cela pourrait être assuré par une injection de code
avant la fonction main()
, mais l’expérience quotidienne de C++ et Python nous
enseigne que l’existence d’un tel mécanisme peut rendre le processus
d’initialisation des programmes très difficile à comprendre et à déboguer.
A la place, Rust impose donc que les valeurs const
et static
soient
initialisées avec des valeurs connues à la compilation. On peut utiliser pour
cela const fn
, l’équivalent en Rust des fonctions constexpr
de C++.
Mutabilité globale
Parfois, l’initialisation d’une variable statique à l’exécution est inévitable.
Dans ce cas on utilisera des mécanismes d’initialisation paresseuse comme
OnceLock
.
OnceLock
est un premier exemple de mutabilité interne : il faut bien que
la variable soit modifiable durant le processus d’initialisation, mais le type
OnceLock
garantit qu’elle ne sera plus modifiée par la suite.
Voici un exemple d’utilisation de OnceLock
pour charger paresseusement le
contenu d’un fichier de configuration. Ce code utilise plusieurs concepts que
nous n’avons pas encore abordés, donc concentrez-vous juste sur la structure
générale sans chercher à comprendre le détail.
#![allow(unused)] fn main() { use std::sync::OnceLock; /// Lit le contenu du fichier de configuration fn read_config() -> String { std::fs::read_to_string("/etc/ma_config.conf") .expect("Echec de la lecture du fichier") } /// Retourne une copie cachée du contenu du fichier de configuration fn cached_config() -> &'static String { // Contenu du fichier, lu paresseusement static CONFIG: OnceLock<String> = OnceLock::new(); // Lit le fichier de config depuis le disque au premier accès, retourne // la value lue initialement lors des appels suivants à `cached_config()` CONFIG.get_or_init(read_config) } }
Les valeurs const
ne sont jamais modifiables, et l’utilisation de variables
static
modifiables à volonté est fortement découragée pour les raisons
habituelles : perte d’intégrité référentielle, non-composabilité entre les
bibliothèques, mauvaise interaction avec le parallélisme…
En particulier, l’utilisation des variables static mut
, qui permettent la
modification libre, n’est possible en Rust que via des blocs unsafe
. En
effet, il n’est pas possible pour le compilateur de prouver qu’un code qui
utilise static mut
est correct en présence de plusieurs threads
d’exécution.