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 !
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.
Pilule rouge : les traits
Un <span class="css-span">trait</span> 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 <span class="css-span">struct</span> on pourrait donc imposer la compétence <span class="css-span">eliminer_anomalies</span> de cette manière :
<pre><code>// 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"
}</code></pre>
Voilà nos 3 agents opérationnels !
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 :
<pre><code>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 !"
}</code></pre>
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.
Petit inconvénient : la composition n'impose pas l'implémentation de fonctions. Pour ça, il faut compléter avec les <span class="css-span">traits</span>.
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 :
<pre><code>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
}
}</code></pre>
Nous pouvons alors changer notre main :
<pre><code>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 !"
}</code></pre>
Et hop, un accès direct !
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 <span class="css-span">traits</span> pour des fonctions virtuelles, et passez plutôt par des compositions de <span class="css-span">struct</span> pour transmettre des propriétés.
One More thing
Pour votre curiosité, il existe un <span class="css-span">crate</span> (comprenez module) intitulé <span class="css-span">inheritance</span> 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...
Jean-Noël aime sa guitare, sa PS4 (quoi de mieux que de réaliser un super combo sur sa manette) et coder... mais de préférence en Typescript !
Et oui, le Typescript, c’est son dada ! Il y a une très forte interopérabilité, le typage apporte de la rigueur au JS, et tout fonctionne très bien, très vite ! En revanche, quand il s’agit d’algorithmes, il préfère le Rust ! C’est le seul langage pour lequel, lorsqu’il a réussi à compiler, il peut respirer en criant : « MON CODE EST SUUUUR !!! ».
On ne sait pas si c’est pour pouvoir rédiger des tas d’articles pour le blog YOUNUP mais il nous a confié rêver de pouvoir regénérer ses cellules pour une jeunesse et un savoir sans limite. Un mix entre le film « Bienvenue à Gattaca » et les écrits de « Laurent Alexandre » ?