Introduction à Rust (4) : le langage est-il orienté objet ?
Rust est-il orienté objet ? La question est pertinente car ce paradigme est adopté par bon nombre de langages de programmation populaires. C’est également le paradigme que je connais le plus.
Pour tenter d’y répondre, je vous propose d’étudier les principales caractéristiques de la programmation orientée objet une par une.
La notion d’objet et d’encapsulation#
Par objet, j’entends des structures qui regroupent des données et des méthodes. Ces données et méthodes peuvent être plus ou moins accessibles depuis l’extérieur des structures avec la notion de visibilité (donnée public ou privée par exemple). Cela permet de supporter le concept d’encapsulation. Dans la plupart des langages orientés objet, ces structures sont définies par des classes que l’on peut instancier pour obtenir des objets.
En Rust, nous n’avons pas de classe mais des structs.Le concept reste cependant assez similaire: ces structures peuvent contenir de la donnée et des méthodes.
struct Library {
users: Vec<String>,
available_books : Vec<String>,
borrowed_books: Vec<String>,
budget:f32
}
impl Library {
/// On définit un constructeur
fn new(available_books: Vec<String>, budget: f32) -> Library {
Library {
available_books,
budget,
borrowed_books: Vec::new(),
users: Vec::new()
}
}
/// self signifie que c'est une méthode d'instance
/// &self signifie que la méthode ne modifie pas l'instance
pub fn available_books(&self) -> &Vec<String> {
// Une méthode sans ; final équivant à faire un retour de valeur
&self.available_books
}
/// &mut self indique que la méthode modifie l'objet
pub fn borrow_book(&mut self, user: String, book: String)
-> Result<String, String> {
if !self.users.contains(&user){
return Err("User not registered".to_string());
}
if let Some(book_index) = self.available_books
.iter()
.position(|b| *b == book) {
self.available_books.remove(book_index);
self.borrowed_books.push(book.clone());
return Ok(book);
} else {
return Err("Book not available".to_string());
}
}
}
fn main()
{
let mut library = Library::new(vec![
"Sherlock Holmes".to_string(),
"Martine à la mer".to_string()
],
10000.0);
match library.borrow_book("non-existing-user".to_string(),
"Sherlock Holmes".to_string()){
Ok(_) => println!("Borrow succeeded !"),
Err(e) => println!("Impossible to borrow : {e}")
};
println!("Available books :");
for b in library.available_books() {
println!("- {b}");
}
}
Petites explications :
- avec le mot clé
structon définit une structure en décrivant les données qu’elle contient, - avec
implon définit les méthodes liées à cette structure. Si les méthodes prennent comme premier argumentself, &self, ou &mut self, c’est que ce sont des méthodes d’instances. Sinon, ce sont des méthodes dites associées qui s’appellent directement depuis la nom de la structure. Par exemple, en Rust, les structures n’ont pas de constructeur par défaut mais par convention, on définit souvent une méthode associée nomméenewpour construire l’objet, - par défaut, les propriétés et méthodes ne sont accessibles que dans le module
où est définie la structure (généralement, le fichier de définition).
Avec le modificateur
pub, les méthodesavailable_booksetborrow_bookdeviennent publiques et sont donc accessibles depuis l’extérieur du module, - dans notre exemple, la méthode
borrow_bookretournera une erreur car aucune méthode pour enregistrer un utilisateur n’a été définie.
Héritage et polymorphisme#
Rust ne possède pas de mécanisme d’héritage. Certains en concluent donc que le langage ne propose pas de paradigme orienté objet au sens strict. Rust fait le choix de suivre les critiques faites à l’héritage 1 pour lui préférer la composition 2 (c’est d’ailleurs quelque chose que nous pouvons appliquer dans des langages très objet comme C# ou Java).
Le langage propose cependant la programmation par contrat via des interfaces qui sont appelés trait.
// On partage un comportement via un trait
trait UserRepository {
fn fetch_users(&self) -> Vec<String>;
}
struct SqliteDatabase;
impl UserRepository for SqliteDatabase{
fn fetch_users(&self) -> Vec<String> {
Vec::new()
}
}
struct PgDatabase;
impl UserRepository for PgDatabase{
fn fetch_users(&self) -> Vec<String> {
Vec::new()
}
}
fn main(){
let sqlite_repos = SqliteDatabase;
let pg_repos = PgDatabase;
get_users(sqlite_repos);
get_users(pg_repos);
}
fn get_users(repos: impl UserRepository) {
let _ = repos.fetch_users();
}
Cela permet du polymorphisme:
la fonction get_users fonctionne avec n’importe quel objet qui implémente
UserRepository. On dit que la fonction est trait bound.
Pour les petits curieux, sachez que dans ce cas, le compilateur va dupliquer la définition de cette méthode pour chaque type d’implémentation qui lui sera passée. On parle de static dispatch. Lorsqu’il est impossible de déterminer à la compilation quel type concret sera passé en argument, le langage utilise le dynamic dispatch.
Conclusion#
Nous avons vu - rapidement et sans entrer dans les détails - que Rust n’est
pas un langage orienté objet au sens strict. Cependant, il propose des concepts
similaires avec les structs ou les traits auquel il ajoute ses spécificités:
unique propriétaire, sécurité mémoire, etc.
Si vous souhaitez plus de détails sur ce sujet, sachez que le tutorial officiel
dédie un chapitre
complet sur la question !
Dans le prochain chapitre, nous verrons comment les spécificités de Rust nous permettent d’écrire du code parallèle plus sécurisé !