Rust, un nouvel espoir (3/3)

Après avoir vu l’outillage Rust et certaines caractéristiques du langage, voyons comment il aborde la programmation objet.

La programmation objet

Les habitués de Java ou C++ seront vite déroutés par l’absence de classes, par l’absence de constructeurs, et même par la syntaxe des méthodes. À la place des classes, on a à disposition les struct et les trait. Les premières sont des structures de données, comme on peut s’en douter, et les secondes sont, en gros, des interfaces, au sens Java du terme. Jusque-là, rien de spécial. Mais ajoutez au mix qu’il n’existe pas d’héritage entre struct et là, vous commencez à vous demander comment faire de la POO dans ces conditions.

Avant de répondre à cette question, il faut savoir de quoi on parle quand on parle de programmation orientée objet. Comme expliqué dans le chapitre 17 du livre The Rust Programming Language, l’approche objet est principalement utilisée pour accomplir deux choses :

  • hériter des données et des méthodes du parent à des fins de réutilisation de code ;
  • pouvoir utiliser des objets de type Fille là où on peut utiliser des objets de type Parent (le polymorphisme).

Autant on ne peut pas hériter d’une struct, autant les trait sont, eux, héritables (heureusement, sinon on serait dans l’impasse… ^^'), et toute la logique d’héritage repose dessus.

Réutilisation de code

Pour le premier cas d’utilisation, on peut définir des méthodes par défaut aux traits. Toute struct qui implémente un trait avec des comportements par défaut en héritera et pourra les surcharger si besoin :

trait Fruit {
  fn colour(&self) -> String;
  fn is_desert(&self) -> bool {
    true
  }
}

struct Apple {
  colour: String
}

struct Tomato {
  colour: String
}

impl Fruit for Apple {
  fn new() -> Apple { Apple { colour: "red".to_owned() } }
  fn colour(&self) -> String { self.colour.clone() }
}

impl Fruit for Tomato {
  fn new() -> Tomato { Tomato { colour: "red".to_owned() } }
  fn colour(&self) -> String { self.colour.clone() }
  fn is_desert(&self) -> bool { false }
}

On notera qu’il n’est pas possible d’avoir des champs dans les traits, ce qui nous a obligés à dupliquer le champ colour dans les deux structures. Il y a une discussion en cours pour intégrer cette fonctionnalité au langage, mais pour l’instant (Rust 1.27), ce n’est pas possible.

Syntaxe des méthodes

Remarquez la syntaxe des méthodes : ce sont celles qui prennent self en paramètre. Comme tout paramètre en Rust, on peut en prendre possession (self), l’emprunter sans vouloir le modifier (&self) — équivalent à void method() const; en C++ —, ou l’emprunter avec intention de modification (&mut self). Le cas self est très rare pour des méthodes, car l’instance qui utilisera une telle méthode sera invalidée. C’est principalement utilisé quand la méthode transforme l’instance en quelque chose d’autre qui invalide totalement l’instance courante.

Autre particularité, les fonctions new sont des fonctions ordinaires, et elles font office de constructeurs uniquement par convention :

let gala = Apple::new();

Cela dit, rien dans le langage ne nous oblige à les nommer comme ça. La communauté s’appuie d’ailleurs beaucoup sur le builder pattern pour construire un objet tout en évitant de multiplier les « constructeurs ».

Polymorphisme

Le deuxième cas de la POO est également couvert par les traits. Il suffit d’attendre un objet qui implémente un trait. En reprenant l’exemple précédent, on peut imaginer cette fonction :

fn do_stuff_with<T: Fruit>(f: &T) {
  if f.is_desert() {
    println!("It’s a dessert!");
  } else {
    println!("not a dessert :(");
  }
}

let gala = Apple::new();
let cherry = Tomato::new();
do_stuff_with(&gala);
do_stuff_with(&cherry);

Cette approche est même bien plus précise, car lorsqu’on attend une certaine classe en paramètre, ce qu’on exprime est qu’on attend un sous-ensemble de méthodes dont on a besoin dans telle fonction. Les traits nous poussent à un découpage fin des services rendus par un trait et ainsi définir clairement ce qu’on attend de la part d’une instance. Ici, on est assuré que l’objet passé possède une méthode is_desert().

Bien sûr, une struct peut implémenter plusieurs traits à la fois, et on peut définir que telle fonction accepte un paramètre qui nécessite tel et tel trait en même temps (on appelle ça les trait bounds):

fn do_stuff<T: Trait1 + Trait2 + Trait3, U: Trait4 + Trait5>(param: T, param2: U) { 
  // bla bla
}

// ou forme plus lisible
fn do_stuff<T, U>(param: T, param2: U) 
  where T: Trait1 + Trait2 + Trait3,
        U: Trait4 + Trait5
{
  // bla bla
}

Du coup, nouvel espoir de quoi ?

Vous l’aurez compris, j’apprécie énormément ce langage, même si je lutte encore pour être fluide avec : je ne comprends pas toujours pourquoi je viole une règle d’ownership et certains messages me sont encore complètement obscurs. Mais la communauté autour de Rust est très active, et il est rare que je bute longtemps sur ce genre de problèmes. Il suffit de chercher rapidement sur Internet pour tomber sur une réponse sur le Reddit, StackOverflow ou leur forum officiel.

Sus au C/C++ !

Alors oui, pour moi Rust est un nouvel espoir, mais « un espoir de quoi ? », me demanderez-vous. Eh bien un espoir de supplanter le plus vite possible C et son héritier C++. Il cible les mêmes choses (programmation système), le compilateur est un véritable outil pour aider le développeur (coucou les messages d’erreur ignobles des compilo C++) et l’ownership est tout simplement génial. Je ne compte plus le nombre de fois où j’ai rencontré des bugs en C++ qui n’auraient tout simplement pas existé en Rust, car détectés dès la compilation… Exemple marquant :

struct MonType 
{
  // bla bla
  int mon_champs = 0;
};

std::unique_ptr<MonType> pt = std::make_unique<int>();

// bla bla bla 

ma_super_fonction(std::move(pt), pt->mon_champs);

Oui, ce n’est pas très propre (pourquoi passer ->mon_champs alors qu’on passe déjà l’objet qui le contient ?), mais dans le feu de l’action, ça arrive. Et ce code crashe. Vous avez vu pourquoi ? Si oui, bravo. Moi, il m’aura fallu quelques heures… On utilise les smart pointers pour éviter des leaks, et ici on a un unique_ptr qui s’assure à la compilation qu’on ait qu’un seul possesseur du pointeur, qui sera donc responsable du nettoyage de la mémoire, chose qui se fait automatiquement quand ledit responsable a fini d’exister. Ici, ça correspond à quand la fonction aura fini de s’exécuter ; pt sera libéré. Et c’est chouette, les unique_ptr, sauf que le compilo fait son taff à moitié, comparé à Rust : il ne râle pas sur pt->mon_champs alors que pt a été invalidé par le std::move. Rust aurait hurlé à la compilation et le crash n’aurait même pas existé.

Programmation web

Un autre aspect que je n’ai découvert que récemment, c’est que Rust compile aussi vers Wasm. Je n’y connais pas grand-chose, mais de ce que j’en comprends, Wasm, pour Web Assembly, est un format binaire pour navigateurs web qui permet d’atteindre des performances proches du code natif pour les applications web. Applications qui, jusqu’à présent, étaient écrites en JavaScript, langage qui n’a jamais été pensé pour des applications de l’envergure d’un Twitter ou d’un YouTube. Certes, il a évolué, mais il reste un langage scripté (performances en deçà) et faiblement, voire très faiblement typé. Je passerai sur les incohérences qui font l’objet de pas mal d’articles sur la Toile et qui me font halluciner à chaque fois.

Avoir un langage comme Rust pour faire du web permettra d’avoir enfin un outil digne de ce nom pour construire des applications de qualité et mieux sécurisées en y mettant moins d’efforts (surtout niveau débug…) .

IoT

Le Internet of Thing, terme désignant le fait que tout devient connecté, de la montre au frigo en passant par la sonnette de votre porte d’entrée, fait souvent l’impasse sur la sécurité. Oh, la sécurité de base est présente, mais comme elle est codée en C, on se retrouve toujours avec des exploits souvent dû à une mauvaise gestion mémoire. Chose qui n’ira qu’en empirant au fur et mesure que ces appareils pourront exécuter plusieurs threads à la fois.

Là encore, Rust, avec sa politique de memory safety, permettrait d’accroître la qualité et la sécurité des firmwares équipant ces appareils.

Quelques défauts

Au niveau des faiblesses, je relèverais principalement la jeunesse du langage. Le support des IDE est convenable mais manque encore un peu de finesse et tombe parfois en panne. L’intégration des debuggers est également très artisanale et nécessite quelques manipulations pour les intégrer dans un IDE. Certaines boîtes utilisent Rust, mais il manque des fournisseurs offrant des solutions dans ce langage. Les API supportant Rust viennent pour la grande majorité de la communauté open source ; la programmation embarquée relève du bricolage, pas encore assez mature pour une utilisation professionnelle. Étant obligé de faire du C++ dans le cadre de mon travail, j’aimerais voir Rust prendre une place suffisante pour convaincre les grosses entreprises (ou au moins celle où je travaille :p).

Tous ces défauts ne sont que temporaires et disparaîtront assez vite, au fur et à mesure des progrès de la communauté. Rien de dramatique, donc.

Pour finir, apprendre un nouveau langage influe sur notre façon de coder de manière générale. Adopter les bonnes pratiques forcées par Rust ne peut que vous faire produire du meilleur code dans d’autres langages, alors, n’hésitez plus, apprenez Rust !