Paradigmes et interprétation - semaine 2

Intro Rust 2: Notion de trait

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

In [2]:
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.

In [8]:
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.

In [11]:
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.

In [84]:
// 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 pour Display)
  • Une implémentation de Display génère une méthode associée to_string() (trait ToString) par défaut.
In [66]:
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 }
Out[66]:
"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

In [65]:
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)
Out[65]:
('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).

In [32]:
#[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 et Eq doit vérifier "k1 == k2 implique hash(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.

In [81]:
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 de Clone (valeur gérée en sémantique MOVE avec des clones implicites)

  • Eq hérite de PartialEq

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 que x == x
  • f32 n'implémente pas Eq ( NaN != NaN)
  • PartialEq peut être heterogène, mais Eq impose Rhs = 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'un Vec< >
  • str: le type d'un slice issu d'une String
  • 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
        }
    }
}

Itérer sur Vec<T>

(rappel cours 1)

Trois façons de faire

for x in v { ... } // x:T, consomme `v`
for x in &mut v { ... } // x:&mut T, permet de modifier v via x
for x in &v { ... } // x:&T, emprunt partagé de v, laisse v inchangé

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)
  • 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

En TP

  • Maitriser les traits
  • Maitriser les itérateurs

La semaine prochaine

  • Les pointeurs "intelligents" (smart pointers):

    • Box
    • Cell
    • RefCell
    • Rc
  • Gestion mémoire avancée. Partage.

    • structures de donnés récursives
    • comptage de références
    • références faibles

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.