J'ai découvert ça cette nuit, du coup je vous le partage.
Il existe des cas où les types de base d'un langage ne sont pas suffisants, mais où l'utilisation d'une structure serait de trop.
C'est le contexte parfait pour dégainer les Refinement Types.
Solution Naïve
Un exemple pour comprendre.
Vous avez à mesurer des surfaces rectangulaires.
Pour une raison que je tairai ici, les capteurs sont un peu fatigués et renvoient parfois des valeurs négatives.
On veut qu'à ce moment-là, on ne prenne pas en compte la mesure et que l'on n'effectue pas non plus le calcul d'aire correspondant, sinon nous allons nous retrouver avec des aires négatives. Ce qui n'est pas top...
La solution naïve est de réaliser ceci :
fncalc_area(w:i8, h:i8)->Option<i8>{if w <0|| h <0{None}else{Some(w * h)}}fnmain(){let witdths =[5,-22,-15,3];let heights =[5,2,-12,-7];for i in0..4{let area =calc_aire(witdths[i], heights[i]);println!("{:?}", area);}}
On obtient bien notre résultat voulu, si l'un des capteurs est dans les choux, on invalide le résultat.
Some(25)
None
None
None
Encapsulation
Maintenant essayons autre chose.
Pourquoi ne pas enfermer dans une boîte notre donnée provenant du capteur.
Cette boîte appelons la WorkingSensor.
Elle ne contient qu'un champ Option<i8> qui symbolise si la valeur du capteur a été prise en compte ou non.
structWorkingSensor{inner:Option<i8>}
On lui rajoute un constructeur.
implWorkingSensor{fnnew(value:i8)->Self{if value <0{Self{inner :None}}else{Self{inner:Some(value)}}}}
Puis, l'on modifie notre fonction calc_area.
On y fait plusieurs choses.
D'abord, on change les paramètres d'entrées i8 en des références de WorkingSensor puis on utilise le champ inner de la structure pour venir vérifier que les données du couple de mesures sont corrects.
Finalement, on unwrap et on réalise la multiplication.
On crée des Vec<WorkingSensor> et on boucle dessus.
fnmain(){let witdths =[5,-22,-15,3].into_iter().map(WorkingSensor::new).collect::<Vec<WorkingSensor>>();let heights =[5,2,-12,-7].into_iter().map(WorkingSensor::new).collect::<Vec<WorkingSensor>>();for i in0..4{let area =calc_area(&witdths[i],&heights[i]);println!("{:?}", area);}}
Optimisons tout ça.
Premièrement, on va se débarrasser des .inner qui polluent la lisibilité.
Ce qui permet de réécrire notre signature de calc_area qui renvoie désormais un PositiveNumber.
fncalc_area(w:&PositiveNumber, h:&PositiveNumber)-> PositiveNumber{ w * h
}
On active tout ça via un main.
Et ça nous donne :
fnmain(){let widths =[5,-22,-15,3].into_iter().map(PositiveNumber::new).collect::<Vec<PositiveNumber>>();let heights =[5,2,-12,-7].into_iter().map(PositiveNumber::new).collect::<Vec<PositiveNumber>>();for i in0..4{let area =calc_area(&widths[i],&heights[i]);println!("{:?}", area);}}
Comme maintenant, nous avons une multiplication conforme, nous pouvons définir une opération de mise à l'échelle qui vient réaliser une multiplication scalaire par 2, par exemple de notre aire.
fncalc_area(w:&PositiveNumber, h:&PositiveNumber)-> PositiveNumber{ w * h *2}implMul<i8>forPositiveNumber{typeOutput= PositiveNumber;fnmul(self, rhs:i8)->Self::Output{matchself.inner {Some(self_value)=>PositiveNumber::new(self_value * rhs),_=> PositiveNumber { inner:None},}}}
On peut ainsi utiliser les génériques pour gérer n'importe quel type.
On généralise les implémentations.
impl<T> Deref forRefinement<T>{typeTarget=Option<T>;fnderef(&self)->&Self::Target{&self.inner
}}// On doit spécifier le type T pour qu'il soit multipliable
impl<T> Mul for&Refinement<T>where T: Clone + Copy + Mul<Output = T>,
{typeOutput=Refinement<T>;fnmul(self, rhs:Self)->Self::Output{match(self.inner, rhs.inner){(Some(self_value),Some(rhs_value))=>Refinement::new(self_value * rhs_value),_=> Refinement { inner:None},}}}// On doit spécifier le type T pour qu'il soit multipliable
impl<T>Mul<T>forRefinement<T>where T: Clone + Copy + Mul<Output = T>,
{typeOutput=Refinement<T>;fnmul(self, rhs: T)->Self::Output{matchself.inner {Some(self_value)=>Refinement::new(self_value * rhs),_=> Refinement { inner:None},}}}
Notre méthode calc_area devient
fncalc_area<T:Clone+Copy+Mul<Output = T>>(w:&Refinement<T>,
h:&Refinement<T>,
)->Refinement<T>{ w * h
}
Et on peut aussi réaliser la mise à l'échelle également.
fncalc_area<T:Clone+Copy+Mul<Output = T>>(w:&Refinement<T>,
h:&Refinement<T>,
scale: T
)->Refinement<T>{ w * h * scale
}
Par contre, là, le constructeur pose problème... 🙄
Lorsque l'on connaissait le type de la valeur d'entrée, on pouvait créer un check statique.
fnnew(value:i16)->Self{if value <0{Self{ inner:None}}else{Self{ inner:Some(value asi8),}}}
Sauf que si value est de type T. T pouvant être tout et n'importe quoi, on ne peut plus définir de if qui pourrait correspondre à ce type T.
Mais ça pose des problèmes dans beaucoup d'autres parties du code. À commencer par le main.
Qui doit prendre le prédicat pour chaque new.
Ce n'est vraiment pas l'idéal.
fnmain(){let widths =[10,-22,-15,3].into_iter().map(|x|Refinement::new(x,???)).collect::<Vec<Refinement<i8>>>();let heights =[10,2,-12,-7].into_iter().map(|x|Refinement::new(x,???)).collect::<Vec<Refinement<i8>>>();for i in0..4{let area =calc_area(&widths[i],&heights[i]);println!("{:?}", area);}}
Toute la mécanique est réalisée par le P::check. En effet P étant un Predicate<T>, nous avons l'assurance qu'il existe une méthode statique P::check qui prend une référence de T et par conséquent même en ne connaissant pas la nature du prédicat, nous pouvons tout de même l'appeler sans crainte. 😀
Et de fait, on peut alors définir la nature de ce P par l'inférence de type offerte par Rust, en définissant ce que l'on désire collect.
Ici un i8 vérifié par le prédicat PositiveNumber :
fnmain(){let widths =[10,-22,-15,3].into_iter().map(Refinement::new).collect::<Vec<Refinement<i8, PositiveNumber>>>();let heights =[10,2,-12,-7].into_iter().map(Refinement::new).collect::<Vec<Refinement<i8, PositiveNumber>>>();for i in0..4{let area =calc_area(&widths[i],&heights[i]);println!("{:?}", area);}}
Et si on lance !
Refinement { inner: None, predicate: PhantomData<refinement::PositiveNumber> }
Refinement { inner: None, predicate: PhantomData<refinement::PositiveNumber> }
thread 'main' panicked at 'attempt to multiply with overflow', /rustc/a6b7274a462829f8ef08a1ddcdcec7ac80dbf3e1\library\core\src\ops\arith.rs:349:1
Panic !!!
Ah oui! Overflow de multiplication.
Mais grâce à notre système, on peut modifier le type facilement.
On se crée un nouveau prédicat PositiveBigNumber qui va être en mesure de prendre un i64.
Puis en utilisant celui-ci dans notre méthode calc_area.
fncalc_area(w:&BigPositiveNumber, h:&BigPositiveNumber)-> BigPositiveNumber{ w * h
}
Et en modifiant le main en conséquences.
fnmain(){let widths =[10,-22,-15,3].into_iter().map(Refinement::new).collect::<Vec<BigPositiveNumber>>();let heights =[10,2,-12,-7].into_iter().map(Refinement::new).collect::<Vec<BigPositiveNumber>>();for i in0..4{let area =calc_area(&widths[i],&heights[i]);println!("{:?}", area);}}
#[derive(Debug)]structPositiveNumber;implPredicate<i8>forPositiveNumber{fncheck(value:&i8)->bool{*value >0}fnerror()-> String{"Must be a positive value".to_string()}}#[derive(Debug)]structPositiveBigNumber;implPredicate<i64>forPositiveBigNumber{fncheck(value:&i64)->bool{*value >0}fnerror()-> String{"Must be a positive value".to_string()}}
On peut ensuite définir le trait Display pour notre structure Refinement.
Les Refinement Types sont des objets qui permettent de s'assurer de la cohérence des données en mathématique on appellerait ceci un sous-ensemble.
Dans l'article notre prédicat était très simple. Mais l'on peut imaginer des prédicats très complexes qui permettent de valider des mots de passes par exemple.