Rust

Quelques points importants sur le langage Rust, qui est un langage compilé (donc rapide) et orienté objet (ce qui est bôf).

Langage : rappels

Base sur les variables

let, mut et const

mut rend une variable mutable, donc sa valeur peut varier. Sans ce mot-clé, la variable est fixe par défaut.

Cependant, on peut ré-assigner une variable a priori fixe, par le mécanisme de shadowing : il suffit de lui ré-assigner sa valeur avec un let. Sinon, on a une erreur de compilation.

Les constantes sont… constantes. Point. Avec un type parmi les types de base (entiers, nombres à virgule, caractères ou chaînes de caractères, booléen).

t-uples et tableaux

On accède à un objet d’un t-uple avec un point : variable.position. C’est un peu étrange, mais admettons.

Pour un tableau, ce sont les classiques crochets : tableau[indice].

struct, enum, Option<T>

struct et enum se comprennent assez bien. Par contre Option<T>, qui permet de gérer les valeurs nulles (ou pas), est un concept qui semble un peu plus capillotracté ; pourtant il permet de gérer convenablement les valeurs nulles (ou absence de valeur) sans planter le programme ou mélanger les pointeurs.

let some_string = Some("a string");
let absent_number: Option<i32> = None;
println!("Is some_string some ? {:?}", some_string.is_some());
println!("Is absent_number none ? {:?}", absent_number.is_none());  

Vecteurs

Sacré morceau, en raison du typage fort imposé aux variables. En principe, un vecteur ne peut contenir qu’un seul type de variable.

Références, déréférences…

Le truc dont j’ai horreur. Pourtant c’est indispensable de bien comprendre et bien gérer pour accéder au bons objets !

let x = 5;
let y = &x;

assert_eq!(5, x);
assert_eq!(5, *y);

est parfaitement équivalent à :

let x = 5;
let y = Box::new(x);

assert_eq!(5, x);
assert_eq!(5, *y);

Pourquoi faut-il dans l’affectation de la variable y passer par la référence (&) ? Parce que sinon cela revient à affecter x dans y, et qu’on désalloue x ! Ici ça marcherait quand même car on utilise un type simple (entier), donc le compilateur sait recopier un entier dans une autre variable.

De même, dans la comparaison assert_eq!, il faut repasser à la valeur (en déréférençant, c’est-à-dire en allant chercher la valeur contenue dans la référence), car sinon on comparerait chou et carotte (entier et référence).

Box est une façon élégante de gérer les références d’un objet quelconque.

Note : assert_eq! est une macro qui ne produit rien si ses deux arguments sont égaux. Sinon, panique à bord et traces de debug !

Déclarations et fonctions

Attention : une déclaration ne retourne pas de valeur.

Exemple :

let x = (let y = 7) ne marche pas !

Par contre :

let x = { let y = 7; y + 1 } fonctionnera et retournera 8, la dernière expression évaluée au sein de la fonction (à savoir y + 1, soit 8). Et attention au point-virgule qui changerait tout !

On aurait aussi pu écrire let x = { let y = 7; return y + 1 }.

Boucles

loop, while, if

Leur usage est classique. On peut utiliser les expressions associées (loop ou if en combinaison avec let, passage de valeur de retour dans break, etc.).

Example :

fn main() {
    let mut counter = 0;

    let result = loop {
        counter += 1;

        if counter == 10 {
            break counter * 2;
        }
    };

    println!("The result is {}", result);
}

for

On peut avoir des for assez évolués comme en Python.

...
let a = [10, 20, 30, 40, 50];

for element in a.iter() {
    println!("the value is: {}", element);
}
...
for number in (1..4).rev() {
    println!("{}!", number);
}
println!("LIFTOFF!!!");
....

Un peu de contrôle

Il existe quelques simplifications d’expression de contrôle.

let some_u8_value = Some(0u8);
match some_u8_value {
    Some(3) => println!("three"),
    _ => (),
}

Son équivalent est :

let some_u8_value = Some(0u8);
if let Some(3) = some_u8_value {
    println!("three");
}

Simple, mais un peu plus dur à lire… Autre exemple intéressant :

let some_u8_value = Some(3u8);
    if let Some(i) = some_u8_value {
        println!("assigned {} to i", i);
    }

Objets

L’inventeur du langage Java aurait dit que l’héritage de classes était une plaie, et qu’il aurait fallu se limiter à l’héritage des interfaces. La bonne nouvelle est que c’est ce que fait Rust. En gros, en Java, il ne faudrait plus jamais utiliser extends (pour étendre une classe, c’est-à-dire créer une classe à partir d’une autre) mais se limiter à implements, c’est-à-dire se limiter à écrire des fonctions communes. On conserve ainsi le polymorphisme (= appel d’un code différent pour la même méthode, en fonction du type de l’objet).

Au fond, ce pattern d’organisation a une logique : on conçoit principalement les objets avec leurs interactions, le code n’est que secondaire (dans la conception des objets).

Propriété (ownership) mémoire

En gros, chaque valeur a un propriétaire, et un seul (à un moment donné). Dès que ce propriétaire n’est plus actif (il sort du scope), la valeur est détruite.

...
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);
...

Ce code ne compilera pas ! En effet, la 2e ligne implique que s1 est désalloué (n’est plus utilisé) puisqu’on a fait un transfert de s1 vers s2.

Ainsi on ne se retrouve pas avec des copies fantômes ou avec des pointeurs dans des zones inconnues ! Ici, chaque valeur n’a qu’un propriétaire et un seul, donc la chaîne de caractères de s1 passe vers s2, et s1 disparaît du scope. Ça n’est pas très intuitif, mais c’est super propre du point de vue de la gestion mémoire.

Seuls les types simples, de taille prévisibles, n’ont pas besoin de cette protection car la compilation est facile et la gestion de mémoire est simple vu que leur longueur est fixe.

Références et emprunts (borrowing)

Pour simplifier la vie des développeurs, on peut appeler des fonctions par référence, ce qui n’implique pas de transfert de propriété ou de copie.

Data race

Cependant, pour éviter les problèmes de race condition, on ne peut avoir qu’une référence sur une valeur mutable à un moment donné.

let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s;

println!("{}, {}", r1, r2);

Ce code ne compilera pas ! Car allez savoir ce qui va se passer ensuite avec deux références sur le même objet… Le résultat peut être imprévisible. Par contre, si on laisse les variables en fixe (sans mut), çà marche, car si on ne fait que lire une donnée, il n’y a pas de problème.

let mut s = String::from("hello");

let r1 = &s;
let r2 = &s;

println!("{}, {}", r1, r2);

Ici on compile.

Résumé des règles sur les références

  • A un moment donné, on ne peut avoir qu’une seule référence à un objet en modification ou autant qu’on veut en lecture (pas de mix) ;
  • Une référence doit toujours être valide (impossible de construire de référence sur un objet qui disparaît en fin de fonction, par exemple).

Slices

Pour accéder à des parties d’objets, on peut utiliser des slices :

let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];

Langage compilé

Rust est compilé. En général, dans tout langage compilé, il y a du pseudo-code généré au milieu, et c’est donc aussi le cas en Rust où il faut rajouter une option pour que ces fichiers puissent être visualisés : --emit mir.

Mid-Level IR (MIR)

Le résultat est un fichier MIR (ou plusieurs), contenant le pseudo-code prêt à être transformé en langage machine.

Voir aussi (pour les MIR)

Debug, pas debug

On peut supprimer après coup les infos de debug dans un programme compilé avec la commande strip (sous Linux).

Organisation du code

C’est pas que c’est compliqué, mais la doc officielle est un peu confuse sur cette partie (la version de juillet 2019). Par exemple elle explique les modules en utilisant… des librairies (des bibliothèques).

Modules

Les modules sont des bouts de code ajoutés au programme principal. Le résultat est un exécutable unique.

Quand on utilise mod utils dans le programme principal (ou dans le fichier qu’on est en train d’écrire, qui peut être lui-même un module), il faut implémenter utils.rs dans le même répertoire, ou un mod.rs dans un répertoire utils.

Si on veut utiliser un module qui se trouve ailleurs dans l’arborescence, il faut faire un use avec le bon chemin (commençant par crate::... si on prend le chemin absolu).

Compilation séparée

On peut aussi compiler séparément les différents modules. Par exemple on compile utils à part, en le transformant en library statique, qu’il faudra ensuite lier (« linker ») avec le programme principal.

Sources d’information

Autres articles