Rust, un nouvel espoir (2/3)

Après avoir vu l’outillage accompagnant le langage, entrons dans le vif du sujet ! :D

Le langage en lui-même

L’outillage n’est pas le principal atout de Rust, ce qui est rassurant pour un langage. :p Je ne vais pas m’étendre sur les qualités habituellement mises en avant car c’est traité un peu partout sur la toile dans d’autres articles. Je vais plutôt me concentrer sur les points qui m’ont marqué.

Déjà, c’est un langage qui est très difficile d’approche, d’autant plus quand on a de l’expérience avec d’autres langages, car des constructions qui nous paraissent triviales sont interdites, de base, en Rust.

Les chaînes de caractères

Une qui me revient en mémoire :

fn ma_fonction(chaine: String) {
  // bla bla
}

fn main() {
  let s = "toto";
  ma_fonction(s);
}

Ceci ne compile pas (notez la clarté des messages d’erreurs ; oui, je vous vise, compilateurs C++) :

error[E0308]: mismatched types
 --> src\main.rs:7:15
  |
7 |   ma_fonction(s);
  |               ^
  |               |
  |               expected struct `std::string::String`, found &str
  |               help: try using a conversion method: `s.to_string()`
  |
  = note: expected type `std::string::String`
             found type `&str`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0308`.

On a donc 2 types de chaînes de caractères différents. Ça a l’air lourd comme ça, mais c’est nécessaire pour assurer cette sécurité niveau mémoire. Brièvement, &str représente les chaînes littérales comme "toto", dont on connaît la valeur, alors que String est une struct qui contient une chaîne variable. C’est d’ailleurs le cas en C/C++ aussi, sauf qu’ils les convertissent automatiquement selon les besoins. En Rust, les conversions sont à expliciter afin d’aider le compilateur à détecter les problèmes mémoire.

Et cerise sur le gâteau, remarquez la proposition de correction pour notre erreur :

help:try using a conversion method: `s.to_string()`

Le borrow checker

L’autre grosse difficulté, c’est le borrow checker. Pour s’assurer qu’on ne fait pas n’importe quoi avec la mémoire, une variable (binding dans la terminologie Rust) n’a qu’un seul propriétaire. Quand le binding est passé en paramètre d’une fonction, il est possible que celui-ci soit moved, c’est-à-dire qu’elle change de propriétaire, le précédent ne pouvant alors plus du tout l’utiliser. Il est aussi possible d’emprunter un temps la variable pour la rendre à son propriétaire, mais ce vocabulaire traduit bien une chose : un seule « personne » à la fois peut utiliser la variable. C’est très abstrait à expliquer comme ça, mais un petit exemple honteusement pompé de l’article que je vous ai référencé plus haut illustre très bien la chose :

fn do_something_with_object(o: SomeObject) {
 // ...
}

fn main() {
 let obj = SomeObject::new();

 do_something_with_object(obj);
 do_something_with_object(obj);
}

Ce code, qui semble totalement innocent, fait pourtant hurler le compilateur :

error[E0382]: use of moved value: `obj`
  --> src/main.rs:17:30
   |
 8 | do_something_with_object(obj);
   | --- value moved here
 9 | do_something_with_object(obj);
   | ^^^ value used here after move
   |

Ici, il nous engueule parce que la fonction a été écrite de sorte que tout paramètre donné soit moved. Du coup, après le premier appel, le binding obj n’est plus valide. La fonction récupère la propriété de la zone mémoire concernée par obj et obj n’a plus le droit d’y accéder. Il faut alors soit que la fonction retourne le paramètre pour rendre la propriété, soit qu’au lieu de prendre possession du binding elle déclare qu’elle l’emprunte :

fn do_something_with_object(o: &SomeObject) {
 // ...
}

Ceci passe bien mieux ! Ici, la fonction emprunte le binding et le rend automatiquement à la fin. C’est indéniablement LE nœud concentrant toute la frustration lors de l’apprentissage de Rust. ^^ Il faut pourtant s’accrocher, ça en vaut la chandelle !

La gestion d’erreurs

Vous l’aurez compris, Rust essaie de détecter un maximum d’erreurs à la compilation et nous force à écrire d’une certaine façon. Il nous propose aussi certains mécanismes afin d’exprimer le cas où une fonction pourrait retourner une erreur.

Dans beaucoup de langages, cela se traduit par :

  • des exceptions et leurs lots de problèmes de performance ;
  • retourner la valeur ou null, menant à des crashs si on omet de vérifier la validité du pointeur qui nous est donné ;
  • retourner un code d’erreur : 0 si tout est ok, et une autre valeur si une erreur est survenue (ceci étant juste une convention). Le résultat est alors retourné par effet de bord dans l’un des paramètres, et le code d’erreur peut là encore être ignoré.

Pour résoudre ces problèmes, Rust fournit dans sa bibliothèque standard deux outils : Option et Result.

Option

Cette enum permet d’exprimer le fait qu’une variable peut ne pas avoir de valeur. Un équivalent du null, donc, pour d’autres langages. Mais en exprimant ce null avec un type bien particulier, Rust nous force à considérer la possibilité que ce soit null quand on cherche à utiliser la variable :

fn retourne_si_pair(i: i32) -> Option<i32> {
  if i % 2 == 0 {
    Some(i)
  } else {
    None
  }
}

fn main() {
  let i = retourne_si_pair(2);
  let j = retourne_si_pair(3);

  // on ne peut pas faire `if i == 2` car i n’est pas un `i32`
  match i {
    Some(value) => printf!("has value: {}", value),
    None => printf!("has no value")
  }
  match j {
    Some(value) => printf!("has value: {}", value),
    None => printf!("has no value")
  }
}

On le voit ici, une fonction retournant une Option nous force à vérifier qu’il existe une valeur avant de l’exploiter. Notez au passage le pattern matching faisant partie intégrante du langage. :D

Result

Ce type est aussi défini comme une énumération:

#[must_use]
pub enum Result<T, E> {
   Ok(T),
   Err(E),
}

Il nous permet d’exprimer le fait qu’une fonction peut émettre une erreur de type E ou un résultat de type T. Là encore, l’objectif est de nous forcer à traiter les deux cas de figure et ainsi ne pas laisser de côté la gestion d’erreurs :

fn peut_retourner_erreur() -> Result<i32, String> {
  // bla bla 
  if ok {
    Ok(42)
  } else {
    Err("pti problème".to_string())
  }
}

fn main() {
  let res = peut_retourner_erreur();
  match res {
    Ok(res) => {},
    Err(cause) => { println!("erreur: {}", cause); }
  }
}

Comble du raffinement, on peut remarquer le #[must_use], qui est un attribut qui annonce au compilateur que ce type a l’obligation d’être traité :

fn main() {
  peut_retourner_erreur();
}

Si on ignore la valeur de retour, le compilateur va nous avertir avec un warning !

Notons aussi qu’il existe un crate qui améliore la gestion d’erreurs en s’appuyant sur Result, très utilisé dans la communauté et qui a des chances d’être bientôt intégré à la bibliothèque standard : Failure.

Suite au prochain épisode

J’espère que vous commencez à sentir à quel point Rust est différent dans son approche face à des problématiques qui minent le monde du développement depuis toujours. Mais l’article est déjà très fourni, faisons donc une pause avant de parler programmation objet !