Retour
Développement

Les traits en Rust…

Les traits en Rust, ou comment s'affranchir les limites de la POO avec Rust.
Félix Lescaudey de Maneville
21/12/2023 - 10 minutes

…ou comment s’affranchir les limites de la POO avec Rust.

Introduction

Le paradigme le plus courant dans le développement informatique est la Programmation Orientée Objet, ou POO. La référence étant le Java.Les divers langages qui utilisent ce paradigme permettent énormément d’outils, dont les plus communs :

Déclaration de classes, avec propriétés et méthodes

  • Héritages entre classes parent-enfant
  • Interfaces
  • Classes abstraites
  • Niveaux de protections
  • etc.

Rust de son côté n’est pas un langage orienté objet, c’est un langage itératif. Il ne permet pas de déclarer des classes ni d’avoir le moindre héritage.

Un peu comme le C finalement ?

Comme pour le langage C, Rust permet tout de même de déclarer des objets via des structures ou struct, et ainsi d’avoir le tout stocké sur la stack et non la heap.

De plus les structures de données struct et enum en Rust peuvent disposer de méthodes et de niveaux de protection, ce que le C ne permet pas.

Note : Rust et C ont beaucoup de différences, le choix de cette comparaison vient du fait que Rust est considéré comme un C modernisé. Presque aussi rapide, pas de soucis d’allocation mémoire, et de l’inspiration des langages objet et fonctionnels le rendant très appréciable. De plus, il faut considérer que le C est un langage qui évolue toujours, la norme C17 ayant été publiée en 2017, soit après la première version stable du compilateur de Rust (1.0, 2015), et une norme C2X étant en travaux.

Le Rust

Cet article n’est pas un cours sur le Rust mais un petit résumé de ses fonctionnalités et de sa philosophie est important.

Le Rust omet deux fonctionnalités fondamentales que la plupart des langages, tout paradigme confondus, proposent : null et le partage d’adresse mémoire.

Pour la valeur null ce petit extrait cité dans la documentation Rust l’explique très bien :

I call it my billion-dollar mistake… At that time, I was designing the first comprehensive type system for references in an object-oriented language. My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.– Tony Hoare, inventor of ALGOL W.

Proposition de traduction :

J’appelle cela mon erreur à un milliard de dollars… À l’époque, je concevais le premier système de typage complet pour des références dans un langage orienté objet. Mon objectif était de m’assurer que toute utilisation de références devait être absolument sécurisée, avec une vérification effectuée automatiquement par le compilateur. Mais je n’ai pas pu résister à la tentation de mettre une référence nulle, parce que c’était facile à mettre en œuvre. Cela a conduit à d’innombrables erreurs, vulnérabilités et pannes du système, qui ont probablement causé un milliard de dollars de souffrances et de dommages au cours des quarante années qui suivirent.– Tony Hoare, inventeur du ALGOL W.

Après avoir programmé un petit peu en Rust on se rend vite compte à quel point les valeurs null sont indésirables, Rust garanti l’intégrité de la totalité des opérations et des retours, vous pouvez si vous le désirez signifier une absence de donnée avec un enum (voir le très utilisé Option<T>).Fini les horribles, et si communs, NullPointerException !

Pour le partage d’adresse c’est très simple, Rust ne veut pas que le développeur utilise comme en C malloc et free, cause principale de la perte de cheveux des développeurs dans ce langage.Cependant, Rust ne veut pas non plus faire comme le reste des langages de haut-niveau et utiliser un ramasse-miettes (Garbage Collector) qui va vérifier si l’adresse mémoire allouée est utilisée ou non en arrière plan.La raison est qu’un ramasse-miettes est lourd, lent et souvent peu efficace (Oui c’est toi que je regarde Java).

Donc comment fait Rust ? Il introduit la notion de propriété et durée de vie (lifetime), empêchant qu’une donnée soit référencée par plusieurs variables.

Ce système est un peu abrupt mais pas trop difficile à comprendre, de plus le compilateur et la plupart des IDE vous préviennent lors d’une mauvaise manipulation

Avec ces deux changements radicaux on peux entrevoir cette philosophie de Rust: Cut the bullshit and go fastRust est un nouveau C, aussi rapide et performant mais infiniment plus moderne et plus sécurisant.

On veut du beau code, et que ça aille vite

Et pour cela, on innove et on est prêts à jeter à la poubelle des standards très largement implémentés.

Exemple

Voici deux déclarations de modèles avec les mêmes fonctionnalités en Rust et C#

pub struct Character {
    name: String,
    pub strength: u8,
    pub dexterity: u8,
    pub charisma: u8,
    pub intelligence: u8,
}

impl Character {
    pub fn new(name: &str) -> Self {
        Self {
            name: String::from(name),
            strength: 1,
            dexterity: 1,
            charisma: 1,
            intelligence: 1, 
        }   
    }

    pub fn name(&self) -> &String {
       &self.name
    }
}
public class Character {
    private string name;
    public uint strength;
    public uint dexterity;
    public uint charisma;
    public uint intelligence;

    public string Name => name;

    public Character(string name) {
        this.name = name;
        strength = 1;
        dexterity = 1;
        charisma = 1;
        intelligence = 1;
    }
}


Contrairement au C, le Rust peux bien déclarer des modèles avec propriétés et méthodes comme n’importe quel langage moderne.

On peux donc créer des classes en Rust ?

Non, car même si ces deux modèles sont équivalents la version Rust ne peux pas avoir de parents ou d’enfants.Cependant les deux ont des propriétés et des méthodes. Les types en Rust n’ont pas de notion de constructeur ou de getter/setter mais on obtient le même fonctionnent avec des méthodes.

Encore un exemple supplémentaire de Rust qui refuse de prendre les développeurs par la main.

Note : Comme il n’y a pas d’héritage Rust n’a pas d’équivalent à protected

Donc comment peut-on se passer d’héritage en Rust ?

“Compound over inheritance”

Si j’ai pris l’exemple du C# ce n’est pas un hasard, il s’agit de la technologie utilisée par le moteur de jeu Unity3D qui illustre le débat du compound over inheritance.Pour résumer ce débat simplement, c’est une réflexion sur l’utilité de l’héritage dans la programmation orientée objet. En effet, n’est-il pas plus utile d’écrire des petits morceaux de code indépendants plutôt que d’avoir des grands niveaux d’héritages complexes ?

Dans Unity par exemple, chaque objet présent sur la scène de jeu est un GameObject avec divers composants. Pour bien concevoir un jeu sur unity il faut faire un maximum de petits composants indépendants qui ont tous une fonction simple.

Le composant le plus connu est sans doute RigidBody, un composant natif qui permet à l’objet en question d’interagir avec la physique du jeu. Ainsi pour qu’un caillou puisse tomber ou réagir aux interactions il suffit simplement d’ajouter le composant à l’objet caillou.C’est ça le Coumpound over inheritance Si pour qu’un objet subisse la physique il fallait que son script hérite d’une classe en particulier l’architecture serait extrêmement confuse.

Donc on découpe en petits modules pour simplifier le code?

Pas seulement, cela a d’autres avantages:

  • Une arborescence d’héritage complexe ralentit la compilation et l’accès en mémoire
  • On évite de créer des couches d’abstractions inutiles avec des classes parentes fourre-tout
  • On peux toujours faire de l’héritage mais de manière beaucoup plus simple et impactante.

Alors quid du Rust?

En Rust la question ne se pose pas, on a pas d’héritage donc on code des petits modules indépendants et génériques. Cependant on a besoin d’un minimum de niveau d’abstraction, et pour cela il faut introduire les traits.

Les Traits en Rust

Les Traits ressemblent aux interfaces dans d’autres langages. C’est une couche d’abstraction qui définit un comportement précis pour un type, que ce soit un type primitif (i32, String, etc) ou une structure ou enum que vous avez déclarés.

Mais contrairement aux interfaces, pas de limitations sur les méthodes, vous pouvez définir des méthodes virtuelle pures (déclarées mais non implémentées) comme des méthodes classiques qui peuvent être surchargées.De plus, les traits en Rust sont très puissants et n’ont pas de surcoût au runtime. Les traits peuvent être génériques, avoir des implémentations multiples pour un même type, des implémentations par défaut (derive) et même laisser une grande marge de manœuvre sur des implémentations complexes mais nous ne rentrerons pas dans le détail.

En revanche, comme pour les interfaces les traits ne peuvent pas disposer de propriétés, ce ne sont pas des types mais des traits et on ne peux pas reproduire de l’héritage avec.

Pour être sûrs de bien déclarer des traits il y a une méthode simple : le nom du trait doit être un verbe (Display, Serialize, Clone, Record, etc) ou désigner une action relative (From<T>, Into<T>, AsRef<T>, etc).Et bien sûr n’importe quel type doit pouvoir l’implémenter et pas seulement un modèle spécifique.

Si vous avez un struct User et un trait User qui va avec c’est qu’il y a un soucis. Mais on se rend vite compte que Rust nous empêche de faire ce genre de bêtise.

Conclusion

Avec le compound vs inheritance et le fonctionnement des traits on commence à voir comment on peux se passer de l’héritage et profiter de l’incroyable potentiel de Rust.

  • On déclare des modèles via des struct ou des enum.
  • Au lieu d’héritage on découpe l’applicatif en blocs fonctionnels indépendants.
  • On rajoute de l’abstraction avec des traits désignant des comportements.

Également, en utilisant le plein potentiel des génériques, on arrive facilement à un code simple, lisible et rapide et sans pertes mémoire.

Exemple

En POO si on doit créer un RPG (jeu de rôle) avec des personnages ayant diverses classes et compétences, on placerait la santé, les statistiques, le déplacement et tout ce que les personnages ont en commun dans le parent le plus élevé.Le soucis ? On a mis le code pour se déplacer dans une classe parente Character.

Donc si on veux ajouter des PNJ (personnages non joueur) qui peuvent se déplacer? On doit faire une classe au dessus ou dupliquer du code.Si on veut que d’autres éléments du jeu peuvent prendre des dégâts et gagner de l’expérience? On doit dupliquer du code ou avoir encore plus de classes au dessus.

Pour coder ce modèle efficacement on ne peux pas se baser sur l’héritage, croyez moi quand j’ai commencé à programmer sur Unity3D j’ai souvent fait cette erreur.On doit donc se mettre à penser en termes de composants:

  • Un composant Damageable qui gère les points de vie et la mort,
  • Un composant Moveable qui gère des déplacements et collisions,
  • Un composant UserInput qui gère des inputs utilisateurs,
  • Un composant ExperienceManager qui permet d’avoir une gestion de niveau et experience,
  • etc.

Note : Les noms de ces composants sont pas terribles, à vous de choisir une convention de nommage qui vous plaise

Et si tous ces composants implémentent un trait Component par exemple, nos objets peuvent disposer d’une liste de ces composants sans la moindre utilité d’héritage.

Ce n’est qu’un seul exemple mais je le trouve parlant pour comprendre comment se mettre à réfléchir en Rust.

J’espère que cela vous aura plus, et à bientôt pour un prochain article sur Rust !

Félix Lescaudey de ManevilleDéveloppeur chez Qongzi