Plan¶
- Généricité bornée par un trait
- Exemples de traits: Clone, Hash, Default, ...
- Liaison statique ou dynamique. Duplication de code.
- Traits et héritage. Contrats detraits
Retour sur une des questions du TP1¶
fn dup<T> (x:T) -> (T, T) {
(x, x)
}
[E0382] Error: use of moved value: `x` ╭─[command_2:1:1] │ 1 │ fn dup<T> (x:T) -> (T, T) { │ │ ┬ │ ╰───── help: consider restricting type parameter `T`: `: Copy` │ │ │ ╰── move occurs because `x` has type `T`, which does not implement the `Copy` trait 2 │ (x, x) │ ┬ ┬ │ ╰───── value moved here │ │ │ ╰── value used here after move ───╯
Le problème avec la fonction précédente, c'est qu'elle crée un aliasing (il y a deux façons d'accéder à la valeur contenue dans x
initialement).
Or, comme on l'a vu la semaine dernière, la sémantique MOVE empêche de créer des alias, et ceci est bien utile pour savoir quelle variable possède une valeur donnée, et à quel moment il faudra la désallouer.
Si on clone x
, on peut renvoyer x
et son clone. Encore faut-il que ce soit possible de cloner x
, mais on n'a fait aucune hypothèse sur le type T
de x
.
On va donc ajouter une hypthèse: T
est un type quelconque qui possède une méthode clone
de la forme suivante.
impl T {
fn clone(&self) -> T { ... }
// notez le `&self` et non juste `self`
}
Cette hypothèse se dit de la façon suivante en Rust: le type T
implémente le trait Clone
Remarque: le nom du trait est ici simiaire au nom de la méthode (à la majuscule près), mais il aurait pu tout aussi bien s'appeler Clonable
.
Si l'on ajoute cette hypothèse, il devient possible d'écrire la fonction dup
.
fn dup<T> (x: T) -> (T, T)
where T: Clone // T implémente Clone
{
let y: T = x.clone();
(x, y)
}
let v = vec![1,2,3]; // <- a une méthode clone, implémente Clone
println!("{:?}", dup(v));
([1, 2, 3], [1, 2, 3])
Si le type T
n'a pas de méthode clone
, on ne peut pas utiliser dup
.
struct Foo {bar: i32}
fn dup<T: Clone> (x: T) -> (T, T) {
let y: T = x.clone();
(x, y)
}
let v = Foo {bar: 0}; // <- pas de méthode clone
dup(v);
dup(v);
^ the trait `Clone` is not implemented for `Foo`
dup(v);
^^^ required by a bound introduced by this call
the trait bound `Foo: Clone` is not satisfied
help: consider annotating `Foo` with `#[derive(Clone)]`
Qu'est-ce qu'un trait?¶
Un trait spécifie des signatures de méthodes (ou de fonctions) qu'un type implémentant le trait doit supporter.
Un peu comme une interface en Java.
Et comme pour les interfaces Java, le trait peut proposer une implémentation par défaut de certaines méthodes.
// déclaration du trait `Clone` dans la librairie standard
trait Clone {
// méthode requise pour implémenter le trait
fn clone(&self) -> Self;
// méthode avec une implémentation par défaut
fn clone_from(&mut self, source: &Self) {
*self = source.clone()
}
}
Remarque : le mot-clé Self
désigne le type qui implémente le trait.
Comment implémenter un trait?¶
La syntaxe est simplement impl
trait for
type.
struct Foo {bar: i32}
impl Clone for Foo {
fn clone(&self) -> Self {
return Foo { bar: self.bar }
}
// si on veut changer l'implémentation par défaut
// de `clone_from`, c'est possible
fn clone_from(&mut self, source: &Self) {
self.bar = source.bar
}
}
Pour certains traits (comme Clone
), le code des methodes peut être généré automatiquement en se basant sur la définition
du type.
#[derive(Clone)]
struct Foo {bar: i8}
#[derive(Clone)]
enum Color { HTML(String), RGB(u8, u8, u8) }
let c1 = Color::RGB(0, 0, 0);
let c2 = c1.clone();
Trait et généricité¶
Un type générique peut implémenter un trait.
// un exemple de trait
trait ToString {
fn to_string(&self) -> String;
}
// un exemple de type générique
enum Optionnal<T> {
Ok(T),
Error(String)
}
// on peut implémenter ToString pour Optionnal<T>
// du moment que T lui-même implémente ToString
impl <T:ToString> ToString for Optionnal<T> {
fn to_string(&self) -> String {
match self {
Optionnal::Ok(t) => t.to_string(),
Optionnal::Error(s) => s.clone(),
}
}
}
Un trait peut aussi être générique.
trait Foo<T> { fn bar(x:T) { ... } }
Les traits Display
et Debug
¶
tels que définis dans la librairie standard dans std::fmt
.
pub trait Display {
// Required method
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>;
}
pub trait Debug {
// Required method
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>;
}
- Appelés notamment par
print!
avec"{}"
et"{:?}
- La macro
write!(...)
est utilisée pour implémenter ces traits (permet d'écrire dans un formateur). #derive(Debug)
possible (mais pas pourDisplay
)- Une implémentation de
Display
génère une méthode associéeto_string()
(traitToString
) par défaut.
struct Euros { cents: usize }
use std::fmt;
impl fmt::Display for Euros {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{},{}€", self.cents / 100, self.cents % 100)
}
}
// implémentation générée par #[derive(Debug)]
impl fmt::Debug for Euros {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Euros {{cents: {} }}", self.cents)
}
}
let x = Euros {cents: 1234 };
println!( "[fmt de Display] {}\n[fmt de Debug] {:?}", x, x);
x.to_string()
[fmt de Display] 12,34€ [fmt de Debug] Euros {cents: 1234 }
"12,34€"
Implémenteur¶
On parle d'implémenteur pour une implémentation qui permet de déduire un trait d'un autre trait.
On a vu un exemple avec ToString
qui se déduit automatiquement de Display
.
impl<T: Display> ToString for T {
// --snip--
}
Surcharge et appel de méthode pleinement spécifié¶
Notre type Euros
a deux méthodes qui s'appelent fmt
. Il s'agit d'une surcharge.
La surcharge n'est permise que si elle concerne des implémentations de traits différents; dans une même implémentation, on ne peut avoir deux méthodes qui ont le même nom, même si elles ont des signatures différentes (contrairement à Java).
Pour lever l'ambiguité au moment de l'appel de la méthode, on doit utiliser la syntaxe pleinement spécifiée
trait A { fn m(&self) -> char; }
trait B { fn m(&self) -> char; }
struct S { }
impl A for S { fn m(&self) -> char { 'A' } }
impl B for S { fn m(&self) -> char { 'B' } }
impl S { fn m(&self) -> char { 'S' }}
let s: S = S {};
let a = <dyn A>::m(&s); // s.m() de A
let b = <dyn B>::m(&s); // s.m() de B
let c = <S>::m(&s); // s.m() de S
let d = s.m();
(a, b, c, d)
('A', 'B', 'S', 'S')
Le mot-clé dyn
sera expliqué plus tard...
Le trait Default
¶
Pour certains types, il existe une valeur "par défaut". Par exemple, pour Option<...>
,
la valeur par defaut est Option::None
.
Cette valeur par défaut est utile par exemple pour remplir une structure de données à son initialisation.
// in std::default
trait Default {
fn default() -> Self;
}
fn main() {
let x: i32 = Default::default(); // == 0
let y: Option<i32> = Default::default(); // == None
}
Notez que le type de x
et y
ne peut pas être omis ici: c'est la seule façon de savoir à quelle implémentation de Default
on fait référence.
Le trait Add
¶
L'expression x+y
est un racourci pour x.add(y)
, où x
implémente le trait Add
.
trait Add<Rhs = Self> {
type Output;
// méthode requise
fn add(self, rhs: Rhs) -> Self::Output;
}
Le paramètre de type Rhs
a la valeur par défaut Self
.
Le type associé Output
doit être défini dans l'implémentation.
Le choix de l'instance de Add
dans une expression x+y
dépend des types de x
(Self
) et y
(Rhs
). Il ne dépend pas du type de x+y
(Output
fixé par les types de x
et y
).
#[derive(Clone, Debug)]
struct Entier { val: usize }
use std::ops::Add;
impl Add for Entier {
type Output = Entier;
fn add(self, rhs: Entier) -> Entier {
Entier {val: self.val + rhs.val }
}
}
impl Add<&Entier> for Entier {
type Output = usize;
fn add(self, rhs: &Entier) -> usize {
self.val + (*rhs).val
// ou bien `rhs.val` (Auto deref)
}
}
let (un, deux) = (Entier { val: 1 }, Entier { val: 2 });
let trois /* : Entier */ = un + deux;
let quatre = Entier { val: 4 };
let sept /* : usize */ = trois + &quatre;
println!("quatre = {quatre:?}, sept = {sept:?}");
quatre = Entier { val: 4 }, sept = 7
Autres traits associés à des opérateurs¶
En Rust, tous les operateurrs sont associés à des traits
- arithmetique :
Add
,Sub
,Mul
,Neg
, ... - comparaison :
PartialEq
,Eq
,PartialOrd
,Ord
- operations bit à bit:
BitAnd
,BitOr
,Not
, - increments (
+=
,*=
, ...) :AddAssign
,MulAssign
, ... - indexation (x[y]) :
Index
,IndexMut
. - déréférencement (
*x
) :Deref
,DerefMut
Combiner des contraintes de traits¶
Exemple : les traits Hash (méthode hash
) et Eq
(opérateur ==
) sont utilisés par le type HashMap
de la librairie standard pour contraindre le type générique des clés.
impl<K: Eq + Hash, V> HashMap<K, V> {
...
fn insert(&mut self, k: K, v: V) -> Option<V> { ... }
...
}
La méthode insert
est générique par rapport aux types K
et V
.
La contrainte K: Eq + Hash
exprime le fait qu'il faut pouvoir hacher et comparer les clés avec ==
.
Contrats de traits¶
Pour implémenter un trait, il ne suffit pas d'implémenter toutes les fonctions associées listées par le trait, il faut aussi le faire en respectant le contrat du trait (s'il y en a un).
Ces contrats de traits ne sont pas vérifiés par le compilateur, c'est le travail du programmeur de les satisfaire.
La librairie standard regorge de contrats de traits, quelques exemples:
- pour le trait
Clone
,a.clone_from(&b)
doit être équivalent àa = b.clone()
- un type qui implémente
Hash
etEq
doit vérifier "k1 == k2
impliquehash(k1)==hash(k2)
"
Marker traits¶
Certains traits n'ont même pas de méthodes. Ils servent juste à indiquer que les types qui les implémentent respectent bien un certain contrat.
C'est le cas de Copy
. T: Copy
signifie que les valeurs de type T
peuvent être gérées avec une sémantique
COPY: on peut créer des alias, cela ne posera pas de problème à la gestion mémoire en RAII.
Examples: i32: Copy
, u64: Copy
, &T: Copy
, ...
fn dup<T: Copy> (x:T) -> (T, T) { (x, x) }
Si on essaie d'implémenter Copy
pour un struct (ou un enum), le compilateur vérifie que tous les champs sont Copy
.
struct CopyNonCompliant {
d: Vec<i32>
}
impl<'a> Copy for CopyNonCompliant { } //<- error
d: Vec<i32>
----------- this field does not implement `Copy`
Liaison statique¶
On appele liaison l'étape de compilation ou d'exécution qui consiste à relier un nom (de fonction, de méthode, de variable,...) à sa définition. On parle aussi de "résolution de méthode".
La liaison est dite statique si elle est faite à la compilation, dynamique si elle est faite à l'exécution. En Rust, la liaison est statique par défaut.
C'est possible parce que le types sont connus à la compilation. Un langage avec typage dynamique (comme Python
ou Racket
) aura nécessairement une liaison dynamique.
let x: i32 = 0;
let y: f32 = 0.0;
// méthode `to_string` de `impl ToString for i32`
x.to_string()
// méthode `to_string` de `impl ToString for f32`
y.to_string()
// fonction `default` de `impl Default for usize`
let z : usize = Default::default()
Liaison dynamique (ou liaison tardive)¶
C'est la liaison de méthode en Java.
Object o;
if (/* random */) {
o = Integer.valueOf(1);
} else {
o = Float.valueOf(0);
}
// liaison dynamique
String s = o.toString(); // <- toString() de Integer ou Float,
// on ne sait pas encore
On peut aussi faire de la liaison dynamique en Rust
avec des traits-objets et des boites.
let x: i32 = 0;
let y: f32 = 0.0;
// z est un pointeur vers un objet qui implémente le trait ToString
let z: Box<dyn ToString> =
if /* ... */ {
Box::new(x)
} else {
Box::new(y)
};
z.to_string() // liaison dynamique
On verra les boites en détail au prochain cours. Il s'agit d'un pointeur vers le tas (contrairement à l'emprunt qui est un pointeur vers la pile). Une boite correspond plus ou moins à une référence en Caml (ref
) ou en Java (class Reference).
Duplication de code¶
Le code d'une fonction (ou méthode) générique foo<T>(...)
est dupliqué et optimisé pour chaque T
dont on a l'usage dans le programme (la fonction foo<i8>(...)
, la fonction foo<usize>(...)
, etc). C'est ce que permet une liaison statique sans résolution de méthode.
C'est la même technique de "duplication de code et optimisation spécifique à chaque instance de générique" qui est utilisée avec les templates en C++.
L'inconvénient de cette approche, c'est que le code compilé est un peu plus volumineux (et donc plus lent à charger).
Certains sites mettent en avant ce choix de Rust
comme un atout indubitable de Rust (en parlant par exemple de zero cost abstraction).
La documentation officielle de Rust est, à juste titre, un peu plus nuancée.
Héritage¶
L'héritage existe en Rust, pour les traits uniquement. Il s'agit simplement de ralonger la liste des fonctions associées.
trait Foo { fn foo() -> Self; }
trait Bar : Foo { fn bar(self); } // hérite de Foo
struct S { }
impl Foo for S { fn foo () -> Self { S{} } }
// pas possible sans `impl Foo for S` avant
impl Bar for S { fn bar(self) { } }
On peut aussi hériter sans ajouter de nouvelles méthodes, simplement pour indiquer un contrat de trait plus exigeant.
Exemples dans la librairie standard:
Copy
hérite deClone
(valeur gérée en sémantique MOVE avec des clones implicites)Eq
hérite dePartialEq
trait PartialEq<Rhs> {
fn eq(&self, other: &Rhs) -> bool;
fn ne(&self, other: &Rhs) -> bool { ... /* Impl par défaut */ }
}
trait Eq: PartialEq<Self> { }
Remarques :
- avec
PartialEq
,==
et!=
sont définis - avec
Eq
, on a de plus quex == x
f32
n'implémente pasEq
(NaN != NaN
)PartialEq
peut être heterogène, maisEq
imposeRhs = Self
.
Le trait Sized
¶
Il exprime que le type qui l'implémente a une taille fixe connue de manière statique.
C'est le cas pour les types des variables: si le type T
n'implémente pas Sized
,
alors on ne peut pas déclarer une variable de type T
.
Si un type T
n'est pas Sized
, on peut néanmoins avoir une variable qui contient un pointeur (emprunt ou boite) vers une valeur de type T
(types Box<T>
ou &T
).
Quelques exemples de types qui ne sont pas Sized
:
[T]
: le type d'un slice issu d'unVec< >
str
: le type d'un slice issu d'uneString
- un trait-objet
dyn Tr
let v : Vec<i32> = vec![1,2,3,4];
let s : String = "hello".to_string();
// un emprunt vers un type qui n'est pas `Sized`
let ptr1 : &[i32] = &v[1..2];
// autre exemple
let ptr2 : &str = &s;
// autre exemple, mais avec une boîte
let ptr3 : Box<dyn ToString> = Box::new(s);
Itérateurs¶
Les traits Iterator
et IntoIterator
permettent d'énumérer les éléments d'une structure de données de type "conteneur" (liste, tableau, arbre, etc).
Ils permettent aussi d'itérer sur un "flux" de données (un fichier ouvert en lecture, un générateur aléatoire, etc) potentiellement ininterrompu ("infini").
for x in c { ...
}
est réécrit par le compilateur en
let mut it = c.into_iter();
loop {
match it.next() {
None => break,
Some(x) => { ... }
}
}
Les traits Iterator
et IntoIterator
sont définis (à peu près) comme ceci
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
...
}
trait IntoIterator {
type Item;
type IntoIter: Iterator<Item = Self::Item>; // <- constraint
fn into_iter(self) -> Self::IntoIter;
}
Important : Iterator
n'est pas un type!
Il y a différents types for chaque implémentation de Iterator
.
Chaque structure de données a son propre itérateur qui sauve l'état d'avancement de l'itération dans une structure de données dédiée (un entier, un pointeur,...)
Intervalles¶
Les intervalles comme 0..10
implémentent IntoIterator
et Iterator
.
for i in 0..N {...}
est facilement optimisé par le compilateur.
// Le type qui se cache derrière `0..N`
struct Range {
start: i32,
end: i32,
}
impl Iterator for Range {
type Item = i32;
fn next(&mut self) -> Option<i32> {
if self.start < self.end {
let r = self.start;
self.start += 1;
return Some(r)
} else {
return None
}
}
}
Trois instances de IntoIterator
(version simplifiée)
impl<T> IntoIterator for Vec<T> {
type Item = T;
type IntoIter = IntoIter<T>;
fn into_iter(self) -> IntoIter<T> { ... }
}
impl<'a, T> IntoIterator for &'a Vec<T> {
type Item = &'a T;
type IntoIter = Iter<'a, T>;
fn into_iter(self) -> Iter<'a, T> { ... }
}
impl<'a, T> IntoIterator for &'a mut Vec<T> {
type Item = &'a mut T;
type IntoIter = IterMut<'a, T>;
fn into_iter(self) -> IterMut<'a, T> { ... }
}
for x in &mut v { ... } // x:&mut T, mutable borrow, modifies the elements of v.
impl<'a, T> IntoIterator for &'a mut Vec<T> {
type Item = &'a mut T;
type IntoIter = IterMut<'a, T>;
fn into_iter(self) -> IterMut<'a, T> { ... }
}
Note: en mode emprunt, l'itérateur possède un emprunt sur le vecteur
- l'emprunt dépend de la durée de vie (lifetime)
'a
v
ne peut pas être utilisé tant que l'emprunt est en cours
Ce qu'il faut retenir¶
Rust a un mécanisme de trait qui ressemble aux interfaces de Java (et au typeclasses de Haskell)
- généricité bornée par des traits
- surcharge non ambigüe à l'aide de traits
Les traits sont beaucoup utilisés pour organiser la librairie standard
- traits associés aux opérateurs (
+
,==
,+=
,*x
,x[..]
for..in
, etc) - traits associés à des contrats (
Copy
,Sized
,Send
, etc)
- traits associés aux opérateurs (
La liaison de méthode ou de fonction est statique (sauf pour les trait-objets)
- duplication de code, "zero cost abstraction"
- nécessité de spécifier le type ou le trait en cas d'ambiguité
Rust a une approche de la POO un peu plus épurée qu'en Java
- pas de classes abstraites
- héritage seulement au niveau des traits
Complément: liaison dynamique et héritage: les "crochets"¶
Voici un "patron" de programmation objet qu'on présente parfois et qui exploite la liaison dynamique (ne marcherait pas avec Rust, de toute façon pas d'héritage entre types en Rust).
class Parent {
void run() { hook(); } // this.hook(), liaison dynamique
void hook() { System.out.println("parent"); }
}
class Child extends Parent {
void hook() { System.out.println("child"); }
}
class Main {
void main() {
Parent o = new Child();
o.run(); // -> affiche "child"
}
}
Application: la classe parent est conçue par une personne (ex: une librairie publique) pour permettre à une autre personne (un utilisateur de cette librairie) de redéfinir via une classe enfant des portions du code sans avoir à tout modifier.
Ça peut être par exemple une façon d'associer un traitant à un évènement.