Pourquoi passer au Rust

Pourquoi Rust change la donne par rapport aux langages habituels

Rust n’est pas juste un langage avec une syntaxe différente, il incorpore les meilleures idées de différents autres langages, et le code s’écrit vraiment différemment. Ce n’est pas le premier langage à tenter cela, mais jusqu’à maintenant, tout tentative de faire du “haut niveau” incluait de sacrifier la performance. Rust est une révolution en ce que la performance n’est non seulement pas sacrifiée, mais mis en avant.

Au niveau du langage, si on devait mettre en avant très rapidement Rust, on retiendrait:

  • Les variables sont par défaut immutables.
  • Chaque variable mutable ne peut être modifiée que dans un seul scope à la fois, et ne peut être possédé que par une seule entité. c’est le concept d’ownership.
  • La durée de vie est vérifiée pour chaque variable : le lifetime. Si le compilateur a un doute, par exemple lorsqu’on compare 2 variables entre elles, il faudra spécifier de manière explicite la durée de vie de chacune d’entre elles.

Le compilateur est strict. Vraiment strict. Vous ne vous êtes jamais vraiment battu avec un compilateur tant que vous n’avez pas essayé celui de Rust !

Rust n’est pas parfait, loin de là, mais c’est actuellement bien mieux que tout ce qui se fait actuellement.

Sécurité et vitesse

Rust ajoute pas mal de sécurités, puisque “by design”, les erreurs les plus communes ne sont pas possibles. Comme par exemple:

  • array bounds checks
  • data races
  • use-after free
  • uninitialized memory

Il est à noter que Rust n’a pas de pointeurs nuls ! Ça exclut donc immédiatement l’erreur la plus courante: null pointer exception.

Ce qui est vraiment intéressant ici, c’est qu’on a toujours eu des langages rapides (C, C++, …) et des langages “safe” (Java, C#, Python, …). Les langages “safe” étant généralement plus lent. Il a toujours fallu choisir entre rapidité et sécurité, et on ne pouvait pas avoir les deux. Rust contredit ce fait, et propose une vitesse qui rivalise avec le C tout étant autant plus sécurisé que les langages dit “safe”.

Ce qui est impressionnant, c’est que Rust accomplie cela sans utiliser de garbage collector. Le garbages collector est pratique mais à tendance à consommer pas mal de mémoire et à créer des spikes de CPU à des moments inopportuns. Il est pratiquement impossible de mitiger un souci de collecte sur lequel on n’aurait pas la main. Enfin, le fait de ne pas avoir de garbage collector, permet de plus facilement utiliser ce langage dans de l’embarqué (notamment pour faire du Wasm par exemple).

Mécanisme d’emprunt

Le borrow checker est une innovation tirée du langage de recherche Cyclone et est certainement l’innovation la plus importante dans un langage de programmation système depuis le C.

Le principe est le suivant: chaque information a exactement un seul propriétaire. On peut soit partager cette information, soit la modifier, mais jamais les deux en même temps. Ce qui veut dire qu’on peut soit avoir une seule référence mutable ou soit avoir plusieurs références immutables sur cette information.

Au final, ce principe oblige à écrire et penser son code très différemment des autres langages. On est forcé à réfléchir comment structurer son programme, ce qui évite de nombreux bugs communs (et pas seulement des bugs de mémoires). Tout ceci est fait pendant la compilation, ce qui garantit qu’on ne retrouvera pas ces soucis à l’exécution.

Au départ, le borrow checker est très déroutant, puisqu’il empêche de faire des choses dont on a pris l’habitude dans d’autres langages. Il faut un petit temps d’adaptation pour apprendre à écrire différemment son code, et à écouter le compilateur. Ça peut être frustrant, mais une fois habitué on se rend compte que le code est mieux structuré. Chaque information a un seul propriétaire explicite, et les mutations sont exclusives. C’est bien plus simple à relire, et moins “error prone”. Enfin, du fait qu’il n’y a pas plusieurs propriétaires sur une information, l’écriture de programme multi-threading est aisé à écrire. Le code mono et multi thread est similaire en Rust.

Exhaustivité

Du fait que Rust interdit les comportements indéfinis, toutes les variables doivent être initialisées. Ça inclut aussi les structures. L’un des avantages agréable de Rust, c’est que si l’on ajoute des champs dans une structure existante, il faudra alors ajouter une initialisation partout où on utilise cette structure. Ça évite vraiment les surprises dans des bases de code conséquentes où “oublier” d’initialiser un champ d’une structure après évolution du code est très fréquent.

Exemple:

struct IncompleteStruct {
  a: u32,
  b: u32,
  c: u32,
}

fn main() {
  let _ = IncompleteStruct {
    a: 42,
    b: 21,
    // missing c
  };
}

Ceci ne compilera pas et donnera:

error[E0063]: missing field `c` in initializer of `IncompleteStruct`
 --> src/main.rs:8:11
  |
8 |   let _ = IncompleteStruct {
  |           ^^^^^^^^^^^^^^^^ missing `c`

Enums

Les enums en Rust sont bien plus puissants que dans les autres langages. Ce ne sont pas juste des valeurs, mais aussi des types complexes que l’on peut déstructurer. On peut exprimer un état et ses valeurs d’un seul coup.

Tout comme les structures doivent être exhaustivement remplis, le matching d’enum doit lui aussi être exhaustif. C’est-à-dire qu’il faut impérativement gérer tous les cas ! C’est l’une des propriétés les plus agréables de Rust, et ça évite les cas non gérés quand on fait évoluer le code.

Un simple exemple pour illustrer cela:

enum WebEvent {
    // An `enum` may either be `unit-like`,
    PageLoad,
    PageUnload,
    // like tuple structs,
    KeyPress(char),
    Paste(String),
    // or c-like structures.
    Click { x: i64, y: i64 },
}

// A function which takes a `WebEvent` enum as an argument and
// returns nothing.
fn handle_event(event: WebEvent) {
    match event {
        WebEvent::PageLoad => println!("page loaded"),
        WebEvent::PageUnload => println!("page unloaded"),
        // Destructure `c` from inside the `enum`.
        WebEvent::KeyPress(c) => println!("pressed '{}'.", c),
        WebEvent::Paste(s) => println!("pasted \"{}\".", s),
        // Destructure `Click` into `x` and `y`.
        WebEvent::Click { x, y } => {
            println!("clicked at x={}, y={}.", x, y);
        },
    }
}

Si on ajoute un nouveau champ dans l’enum, alors handle_event ne compilera pas tant que l’on n’aura pas géré ce nouveau cas. Si on devait faire cela dans un autre langage, ça serait bien plus “error prone”.

Gestion d’erreur

La gestion d’erreur est un aspect extrêmement important dans le travail d’un ingénieur, et oublier de gérer un cas d’erreur mène généralement sur un bug sévère.

Rust gère ses erreurs comme on le ferait en Golang, ou en C. C’est-à-dire que chaque fonction retourne explicitement les erreurs. Il n’y a pas de système d’exception.

Le souci en Go, par exemple, c’est que c’est rapidement très verbeux:

obj, err := MyFunc()
if err != nil {
   return 0, err
}
result, err := obj.MyMethod()
if err != nil {
   return 0, err
}

Au point que ça en est devenu une blague récurrente au sein de la communauté Go: Golang error button

En plus d’être verbeux, il n’est pas obligatoire de gérer une erreur, ce qui peut mener à des soucis.

Dans les langages avec exceptions, comme C++, Java, Python, le problème est différent puisqu’à la place, il y a un problème “d’erreur invisible”. Si on reprend l’exemple précédent en C++, en utilisant des exceptions, on obtient ceci:

auto result = MyFunc().MyMethod();

Ici, il n’y a aucun moyen de savoir si une erreur potentielle peut arriver. Même si je regarde la déclaration de la fonction pour MyFunc et MyMethod, rien ne dit explicitement qu’une exception pourrait être levée.

En Rust, les erreurs doivent forcément être gérées explicitement, et sont propagées. En revanche, pour éviter le même boiler plate que l’on retrouve en Go, toutes les erreurs peuvent être gérées grâce à l’opérateur ?.

Si on reprend l’exemple précédent, mais en Rust:

let result = my_func()?.my_method()?;

L’opérateur ? signifie que l’expression peut retourner une erreur et retourne alors cette erreur le cas échéant. Si on oublie l’opérateur ?, le code ne compilera pas. Il est donc obligatoire de gérer correctement les erreurs et leur propagation. C’est à la fois explicite et concis.

RAII (resource acquisition is initialization)

Pour quelqu’un qui vient du C++, ce n’est pas une pratique révolutionnaire, mais c’est une notion importante à comprendre dans un langage sans garbage collector. Le principe est très simple, tout ce qui est acquis à la construction aura la garantie d’être détruit par son possesseur quand celui-ci “meurt”.

Par exemple, si on crée un objet “fichier” qui pointe sur un fichier ouvert, alors, lorsque cette variable “fichier” disparaît (en sortant du scope par exemple), lors de sa destruction, il y aura la garantie que le destructeur sera appelé (et dans celui-ci, le fichier sera fermé).

Un exemple pour illustrer cela:

{
    let mut file = std::fs::File::open(&path)?;
    let mut contents = Vec::new();
    file.read_to_end(&mut contents)?;

    // when we reach the end of the scope,
    // the `file` is automatically closed
    // and the `contents` automatically freed.
}

Traits

En Rust, les traits sont utilisés à la fois comme interface polymorphique au runtime et à la compilation. Ça permet de faire de la programmation orientée objet, sans utiliser de classe, et donc de ne pas en hériter des défauts. Au final, ça donne une grande souplesse pour la structure du code, sans pour autant en payer le prix.

Par exemple:

trait Animal {
    fn make_noise(&self);
}

// Run-time polymorphism (dynamic dispatch).
// Here `Animal` acts like a Java interface or an abstract base class.
fn runtime(obj: &dyn Animal) {
    obj.make_noise();
}

// Compile-time polymorphism (generics).
// Here `Animal` acts as a constraint on what types can be passed to the function
// (what C++ calls a "concept").
fn compile_time<T: Animal>(obj: &T) {
    obj.make_noise();
}

// Implementation example.
struct Cow {}

impl Animal for Cow {
  fn make_noise(&self) {
    println!("Mooh");
  }
}

fn main() {
  let cow = Cow {};
  runtime(&cow);
  compile_time(&cow);
}

Outils et écosystème

Rust a un écosystème exceptionnel ! Apprendre et utiliser Rust est bien plus plaisant que la plupart des langages.

Tout d’abord l’un des points les plus impressionnants: Les messages d’erreurs sont d’un autre niveau. Les erreurs de compilation pointent exactement l’erreur faite, pourquoi l’erreur est générée et en plus, propose souvent une correction ! Rust possède sans doute le système d’erreur le plus avancé qui existe (ce qui permet de contrebalancer avec sa difficulté d’apprentissage).

Exemple de messages d’erreur:

error[E0502]: cannot borrow `x` as immutable because it is also borrowed as mutable
 --> src/lib.rs:5:23
  |
3 |     let y = &mut x;
  |             ------ mutable borrow occurs here
4 |
5 |     println!("{} {}", x, y);
  |                       ^  - mutable borrow later used here
  |                       |
  |                       immutable borrow occurs here
error[E0596]: cannot borrow `window` as mutable, as it is not declared as mutable
  --> playground/src/main.rs:22:34
   |
7  |     let window = lightbox::new_window("Playground", 1280, 720, 0);
   |         ------ help: consider changing this to be mutable: `mut window`
...
22 |     lightbox::handle_gfw_events(&mut window, &mut scroll_events);
   |                                 ^^^^^^^^^^^ cannot borrow as mutable

Ensuite, il y a Cargo. C’est un système de gestion et de construction de paquet. C’est super agréable de ne pas avoir à dépendre d’un système externe (comme CMake ou Bazel) et de l’avoir built-in dans le langage.

Les bibliothèques Rust sont appelées “crates” et peuvent être publiées et parcourues aisément sur le site http://crates.io. Bien que l’écosystème soit encore jeune, il y a déjà une pléthore de crate de grande qualité. Tester une crate est aussi simple que d’ajouter une seule ligne dans le fichier de configuration d’un projet.

Enfin, l’un des outils les plus pratiques: rust-analyzer. Il fournit de la complétion, la possibilité de parcourir le code et de le refactoriser rapidement. C’est utilisable dans quasiment tous les IDE.

La documentation Rust est très bonne, d’une part parce qu’une attention particulière y a été apporté par ses contributeurs, mais aussi parce que les outils de documentation en Rust sont puissants. Par exemple cargo doc permet de facilement générer de la documentation, et les tests et exemples de code à l’intérieur de la documentation (ou des commentaires) peuvent être vérifiés !

Exemple:

/// Adds two numbers together.
///
/// ## Example:
/// ```
/// assert_eq!(add(1, 2), 3);
/// assert_eq!(add(10, -10), 0);
/// ```
fn add(a: i32, b: i32) -> i32 {
    a + b
}

Ici, le compilateur vérifiera que le code en commentaire est correct !

Wasm

WebAssembly (raccourci en Wasm) est un langage intermédiaire, qui est géré par la plupart des navigateurs web. Ça permet d’exécuter du code sur une page web avec des performances supérieures à celle du Javascript.

Rust est un langage qui a géré le Wasm très tôt au début de sa création. Ce qui permet d’écrire des app web:

  • directement en Rust
  • presque aussi rapide que du natif
  • sécurisées et “sandboxées”

Il est très facile de porter du code Rust en Wasm. Il suffit juste d’ajouter ceci à la commande de compilation: --target wasm32-unknown-unknown.

Défauts

Bien évidemment, rien n’est jamais parfait, et Rust possède quelques défauts.

Temps d’apprentissage

Rust est difficile, car très différent de ce qui existe. Il nécessite du temps pour être appris. Même si on maîtrise déjà la programmation fonctionnelle et impérative, il faut tout de même apprendre les notions de “borrow checker” et de “lifetime”. L’investissement à long terme vaut le coup, mais ça a un coût a court terme.

Temps de compilation

Ce n’est pas un énorme défaut, mais du fait que Rust vérifie beaucoup de chose à la compilation, on le ressent sur le temps de compilation. Sur de gros projets, ça peut tout de même se sentir.

Syntaxe de template verbeuse

Ce n’est pas un défaut inhérent à Rust, C++ ayant le même souci. Dès le moment où on utilise des templates, ça complexifie forcément le code. Rentrer dans un code bourré de <'_> et de ::<T> n’est pas toujours agréable. On s’y habitue vite, mais pour un néophyte, ça peut effrayer.

Nombre à virgule peu pratique

En Rust, les nombres à virgules flottantes (f32 et f64) n’implémente pas le trait Ord. Cela implique qu’on ne peut pas aisément trier des floats, sans recourir à des hacks. De la même manière, ils n’implémentent pas non plus le trait Hash, ce qui est tout aussi pénible.

Il y a une raison de performance derrière. Ca a été correctement pensé, mais pour des usages basiques, c’est vite ennuyeux. Fort heureusement, il existe une crate, ordered-float, qui permet de pallier à cela, mais c’est dommage de devoir utiliser des types wrappés pour faire quelque chose d’aussi primaire.

Langage très contextuel

Le langage est plus contextuel que ne l’était le C++. Ce qui est entendu par là, c’est que parfois la lecture du code n’est pas linéaire. Il nécessaire de devoir déduire du code non explicite.

Par exemple, en C++, si on a ceci:

auto res = a + b;

On ne peut pas savoir ce que donnera a + b sans d’abord regarder si l’opérateur + aurait été redéfini. Ce qui veut dire qu’un même code, trivial, aura un comportement différent en fonction de ce qui est inclus. C’est-à-dire qu’il faut comprendre ce qui est “injecté” pour deviner le comportement.

En Rust, c’est la même chose. Du code injecté, implicite, peut changer le comportement. C’est souvent le cas avec les itérateurs par exemple. Si on prend un exemple avec la bibliothèque rayon, qui permet de faire du parallélisme aisément:

    let collection = vec![0, 1, 2, 3, ...];
    let res = collection
        .into_par_iter()
        .map(|item| {
            compute(item)
        })
        .collect::<Vec<_>>();

Ici, le code ne compilera pas avec l’erreur suivante:

error[E0599]: no method named `into_par_iter` found for struct `Vec<u32>` in the current scope
   --> src/main.rs:2:10
    |
2   |         .into_par_iter()
    |          ^^^^^^^^^^^^^ method not found in `Vec<u32>`

Eh bien, rien n’indique ce qui ne va pas ! Il faut en fait injecter les itérateurs de “rayon”, pour qu’une partie du code utilisée en profondeur dans les bibliothèques internes fonctionne.
Ceci solutionne le problème de compilation:

use rayon::prelude::{IntoParallelIterator, ParallelIterator};

Deviner qu’il faut ajouter cet import, sans que rien ne soit précisé n’est pas facile. On aura le même type de souci avec les From/TryForm. Il y a souvent du code qui interagit avec d’autres morceaux de code à des endroits très éloignés et implicitement liés.

Conclusion

Les quelques défauts du langage sont largement compensés par ce qu’il apporte. Rust est clairement le langage du futur, et permet déjà de l’appliquer dans pratiquement tous les domaines (web, serveur, embarqué, etc…).

Il y a encore une certaine frilosité au niveau de son utilisation, à cause de sa jeunesse et du peu de programmeurs formés à ce langage, mais ça va clairement rapidement évoluer.