Rust : l'héritage n'existe pas !

Jean-Noël - Oct 21 '22 - - Dev Community

Rust : l'héritage n'existe pas

Après avoir vu le dernier opus de la saga The Matrix, j'ai été pris d'une envie de revenir aux sources. Alors, ici, il ne sera pas question de critique de film, mais de revenir sur un sujet technique essentiel qu'est l'héritage.

Tenez, Rust par exemple. Un langage natif, certes, mais moderne (article découverte ici). Eh bien ! il faut faire éclater une vérité, Néo : en Rust, l'héritage n'existe pas !

la cuillère n'existe pas

Alors dans ce cas, comment transmettre des fonctions ou des propriétés qui sont communes à de multiples structures de données, ou encore imposer un pattern de fonctionnalités ? Bref, ce qu'on a toujours fait avec les classes abstraites en C++, par exemple ?

Si vous aussi vous vous êtes déjà posé ces questions, alors suivez le lapin blanc...

Note : tous les extraits de code ci-après sont testables sur le Playground Rust.

Pas d'héritage : pourquoi ?

Soyons clair, l'héritage est un concept essentiel de la programmation orientée objet (OOP en anglais), ce que Rust n'est pas.

Rust est un langage C-like où les "classes" sont avant tout des structures de données, et pas nécessairement des fonctionnalités. C'est aussi un langage qui se veut explicite. En C++, à travers plusieurs héritages successifs, on a tendance à perdre cet aspect.

En Rust, on est incité à coder des petits modules indépendants et génériques.

Bien entendu, Rust n'est pas en reste (haha) pour autant. Il a été pourvu d'un minimum vital pour gérer un certain niveau d'abstraction. Il y a principalement 2 solutions qui s'offrent à vous : les traits et les compositions.

Dilemne des pilules

Pilule rouge : les traits

Un trait est l'équivalence d'une interface en Java : on impose l'implémentation d'une liste de fonctions.

Prenons l'exemple des 3 grands agents du film : Smith, Jones et Brown (si vous ne connaissiez pas les 2 derniers noms, c'est cadeau !). Ils font initialement tous partie d'une même faction : les agents. Un agent, quel qu'il soit, doit être en mesure de repérer et d'éliminer les anomalies de la Matrice.

Si on devait les modéliser avec des struct on pourrait donc imposer la compétence eliminer_anomalies de cette manière :



// Trait générique
trait Agent {
    fn eliminer_anomalies(&self);
}

// Différentes "classes"
struct Smith {}
struct Jones {}
struct Brown {}

// Implémentations des traits
impl Agent for Smith {
    fn eliminer_anomalies(&self){
        println!("Smith doit éliminer anomalies")
    }
}

impl Agent for Jones {
    fn eliminer_anomalies(&self){
        println!("Jones doit éliminer anomalies")
    }
}

impl Agent for Brown {
    fn eliminer_anomalies(&self){
        println!("Brown doit éliminer anomalies")
    }
}

fn main() {
    let agent_smith = Smith {};
    let agent_jones = Jones {};
    let agent_brown = Brown {};

    agent_smith.eliminer_anomalies(); // "Smith doit éliminer anomalies"
    agent_jones.eliminer_anomalies(); // "Jones doit éliminer anomalies"
    agent_brown.eliminer_anomalies(); // "Brown doit éliminer anomalies"
}


Enter fullscreen mode Exit fullscreen mode

Voilà nos 3 agents opérationnels !

Les 3 agents

Pour information, on peut voir les traits en Rust un peu comme des traits de caractère d'un personnage : une structure peut également implémenter autant de traits que nécessaire.

Pilule bleue : les compositions

Autre solution à préférer si vous avez besoin de transmettre des propriétés (et des fonctions par la même occasion) : composer les structures entre elles. C'est ce qui se rapproche le plus d'un "héritage" de propriétés.

Plaçons nous cette fois-ci du côté des êtres humains. Ils ont chacun un nom et peuvent évoluer au sein de la Matrice. Puis il y a les humains libérés qui en plus ont la capacité de sortir de la Matrice comme bon leur semble. Et pour finir, il y a l'élu : il dispose de toutes les capacités précédentes, et en plus il sait voler (le veinard !).

On pourrait donc imaginer une modélisation de ces classes de personnes comme suit :



struct Humain {
    nom: String
}

impl Humain {
    fn evoluer_dans_matrice(&self){
        println!("Dans la matrice...")
    }
}

struct Libere {
    humain: Humain
}

impl Libere {
    fn sortir_de_la_matrice(&self){
        println!("Hors de la matrice...")
    }
}

struct Elu {
    libere: Libere,
}

impl Elu {
    // (constructeur pour gagner en lisibilité)
    fn new() -> Self {
        Elu {
            libere: Libere {
                humain: Humain { nom: String::from("Néo") }
            }
        }
    }

    fn voler(&self){
        println!("Vole dans les airs !")
    }
}

fn main() {
    let elu = Elu::new();

    println!("Nom de l'élu: {}", elu.libere.humain.nom); // "Nom de l'élu: Néo"
    elu.libere.sortir_de_la_matrice(); // "Hors de la matrice..."
    elu.libere.humain.evoluer_dans_matrice(); // "Dans la matrice..."
    elu.voler(); // "Vole dans les airs !"
}


Enter fullscreen mode Exit fullscreen mode

Ainsi, Néo hérite bien de toutes les compétences et propriétés en tant qu'élu et humain libéré. Evidemment d'un point de vue modélisation UML, l'élu devrait être une instance unique, mais ce n'est pas le sujet ici.

Néo qui vole

Petit inconvénient : la composition n'impose pas l'implémentation de fonctions. Pour ça, il faut compléter avec les traits.

Vous l'aurez compris, il y a un autre inconvénient bien plus visible : le code est bien explicite, mais il faut traverser toute la hiérarchie manuellement pour accéder aux propriétés les plus hautes. Pas toujours folichon.

En réalité, il existe une astuce lorsque la profondeur de données devient trop grande...

Ptit verre d'eau pour faire passer : Deref

Le trait std::ops::Deref sert à la base à surcharger le déréférencement des pointeurs, et il est conseillé de plutôt les utiliser sur les smart pointers.

Mais ici nous allons l'utiliser pour simplifier l'accès à nos variables, en ajoutant :



use std::ops::Deref;

// ...

impl Deref for Libere {
    type Target = Humain;
    fn deref(&self) -> &Humain {
        &self.humain
    }
}

impl Deref for Elu {
    type Target = Libere;
    fn deref(&self) -> &Libere {
        &self.libere
    }
}


Enter fullscreen mode Exit fullscreen mode

Nous pouvons alors changer notre main :



fn main() {
    println!("Nom de l'élu: {}", elu.nom); // "Nom de l'élu: Néo"
    elu.sortir_de_la_matrice(); // "Hors de la matrice..."
    elu.evoluer_dans_matrice(); // "Dans la matrice..."
    elu.voler(); // "Vole dans les airs !"
}


Enter fullscreen mode Exit fullscreen mode

Et hop, un accès direct !

Néo met ses lunettes

Si besoin, il faudra aussi implémenter le trait std::ops::DerefMut pour obtenir un accès mutable à ces éléments. Rappelez vous : par défaut, tout est immuable en Rust.

Toutefois, retenez bien que c'est plus un hack utilitaire. À utiliser avec modération donc.

Conclusion

L'héritage en Rust est un sujet qui revient régulièrement dans les discussions et les demandes d'évolution du langage. Mais bien que Rust ne soit pas un langage fondamentalement orienté objet, il dispose tout de même de fonctionnalités suffisantes pour gérer les abstractions. C'est aussi un coup à prendre ! Une autre façon de coder.

En résumé, privilégiez une déclaration explicite avec des traits pour des fonctions virtuelles, et passez plutôt par des compositions de struct pour transmettre des propriétés.

La matrice qui défile

One More thing

Pour votre curiosité, il existe un crate (comprenez module) intitulé inheritance en version alpha. Il propose des macros pour implémenter automatiquement des traits sans avoir besoin de les réécrire dans toute la hiérarchie. Mais qui est seulement resté au stade expérimental...

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player