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.