Génériques
Last updated
Was this helpful?
Last updated
Was this helpful?
Fonctions et types peuvent être génériques, avec + de flexibilité qu'en C♯.
Par défaut, généricité implicite
Inférée
Voire généralisée, grâce à « généralisation automatique »
Sinon, généricité peut être explicite ou résolue statiquement.
Notations différentes (avant F# 7) :
'T
: paramètre de type générique
^T
: paramètre de type résolu statiquement (SRTP)
Depuis F# 7, 'T
peut désigner l'un ou l'autre
Le compilateur est capable d'inférer qu'une fonction est générique. → Simplifie le code
module ListHelper =
let singleton x = [x]
// val singleton : x:'a -> 'a list
let couple x y = [x; y]
// val couple : x:'a -> y:'a -> 'a list
👉 Explications :
singleton
: x
est juste mis dans une liste générique → son type est donc quelconque
couple
: ses 2 arguments x
et y
doivent être du même type pour pouvoir être dans une liste
☝️ Les noms des types génériques inférés rendent parfois une signature de fonction difficile à comprendre. Ne pas hésiter à ajouter alors des annotations de types plus explicites.
Exemple: Result<'a, 'b>
→ Result<'ok, 'err>
let print2 x y = printfn "%A, %A" x y
// val print2 : x:'a -> y:'b -> unit
→ Inférence de la généricité de x
et y
👍
❓ Comment indiquer que x
et y
doivent avoir le même type ?
→ Besoin de l'indiquer explicitement :
let print2<'T> (x: 'T) (y: 'T) = printfn "%A, %A" x y
// val print2 : x:'T -> y:'T -> unit
💡 Astuce : la convention en 'x
permet ici d'être + concis :
// AVANT
let print2<'T> (x: 'T) (y: 'T) = printfn "%A, %A" x y
// APRES
let print2 (x: 'T) (y: 'T) = printfn "%A, %A" x y
La définition des types génériques est explicite :
type Pair = { Item1: 'T ; Item2: 'T }
// 💥 ~~ ~~
// Error FS0039: Le paramètre de type `'T` n'est pas défini.
// ✅ Records et unions avec 1 ou 2 paramètres de type
type Pair<'T> = { Item1: 'T; Item2: 'T }
type Tuple<'T, 'U> = { Item1: 'T; Item2: 'U }
type Option<'T> = None | Some of 'T
type Result<'TOk, 'TErr> =
| Ok of 'TOk
| Error of 'TErr
Le wildcard _
permet de remplacer un paramètre de type ignoré :
let printSequence (sequence: seq<'T>) = sequence |> Seq.iteri (printfn "%i: %A")
// Versus
let printSequence (sequence: seq<_>) = ...
Encore + utile avec type flexible📍Types flexibles :
let tap action (sequence: 'TSeq when 'TSeq :> seq<_>) =
sequence |> Seq.iteri action
sequence
// action:(int -> 'a -> unit) -> sequence:'TSeq -> 'TSeq when 'TSeq :> seq<'a>
// Versus
let tap action (sequence: #seq<_>) = ...
F♯ propose deux catégories de types de paramètre :
'X
: type de paramètre générique comme en C# : le type concret est défini au runtime.
^X
: type de paramètre résolu statiquement : le type concret est défini lors de la compilation.
☝ SRTP : abréviation fréquente de Statically Resolved Type Parameter
Sans SRTP :
let add x y = x + y
// val add : x:int -> y:int -> int
→ Inférence du type int
pour x
et y
, sans généralisation (aux float
par ex.) !
Avec SRTP, de pair avec fonction inline
:
let inline add x y = x + y
// val inline add : x: ^a -> y: ^b -> ^c
// when ( ^a or ^b ) : (static member (+) : ^a * ^b -> ^c)
// ☝ Contrainte de membre 📍
let x = add 1 2 // ✅ val x: int = 3
let y = add 1.0 2.0 // ✅ val y: float = 3.0
On peut utiliser les SRTP pour appeler un membre en même temps que l'on contraint son existence dans le type associé.
// -- Déclaration --
let inline Length x = (^a : (member Length : _) x)
// FSI: val inline Length: x: ^a -> 'a0 when ^a: (member get_Length: ^a -> 'a0)
// -- Usages --
let textLength = "text" |> Length
// FSI: val textLength: int = 4
let listLength = [ 1..10 ] |> Length
// FSI: val listLength: int = 10
💡 Dans vscode avec Ionide, l'IntelliSense est plus friendly :
val inline Length:
x: 'a (requires member Length )
-> 'a
Déclaration :
let inline ToString format x = (^x : (member ToString : ^format -> string) (x, format))
(*
val inline ToString:
format: ^format -> x: ^x -> string
when ^x: (member ToString: ^x * ^format -> string)
*)
💡 IntelliSense dans VsCode + Ionide :
val inline ToString:
format: 'format ->
x : 'x (requires member ToString )
-> string
Usages (exemples) :
1 |> ToString "c" // "1,00 €", or "£1.00"... (selon paramètres locaux)
open System
DateTime.Now |> ToString "s" // "2023-02-24T14:04:37"
DateTime.Now |> ToString Globalization.CultureInfo.CurrentCulture // "24/02/2023 14:04:37"
💡 Plus d'informations et de conseils sur le duck typing dans l'article ci-dessous duquel sont extraits certains exemples :
Un autre exemple de SRTP est détaillé dans cette réponse sous StackOverflow à propos du type Functor
dans la librairie FSharpPlus.
Plusieurs améliorations ont été introduites en F# 7.0 pour améliorer la syntaxe des SRTP.
Jusqu'en F♯ 6.0, il fallait mettre un espace entre le chevron ouvrant et le SRTP.
Depuis F♯ 7.0 (novembre 2022), cela n'est pas nécessaire. Cela permet d'être uniforme avec les types génériques :
// Avant F# 7.0
type C< ^T> = class end
let f< ^T> (x:^T) = ()
// Depuis F# 7.0
type C<^T> = class end
let f<^T> (x:^T) = ()
Plusieurs améliorations ont été introduites en F# 7.0 pour améliorer la syntaxe des SRTP. Elles rendent le code plus lisible.
Cependant, la syntaxe reste encore un peu difficile à lire, à dessein : pour encourager les solutions alternatives.
De plus, on ne peut pas savoir à l'avance tous les types compatibles avec une fonction avec SRTP.
Enfin, cela peut ralentir beaucoup la compilation. C'est un des problèmes remontés par des utilisateurs de la librairie FSharpPlus.
👉 Les SRTP sont à utiliser avec parcimonie car leur syntaxe est difficile à lire.
Les contraintes sur paramètres de type en F♯ reposent sur le même principe qu'en C♯, avec quelques différences :
Mots clés
when xxx and yyy
where xxx, yyy
Emplacement
Juste après type :
Fin de ligne :
fn (arg: 'T when 'T ...)
Method<T>(arg: T) where T ...
Dans chevrons :
fn<'T when 'T ...> (arg: 'T)
Type de base
'T :> my-base
T : my-base
Type valeur
'T : struct
T : struct
Type référence
'T : not struct
T : class
Type référence nullable
'T : null
T : class?
Constructeur sans param
'T : (new: unit -> 'T)
T : new()
Énumération
'T : enum<my-enum>
T : System.Enum
Comparaison
'T : comparison
≃ T : System.IComparable
Égalité
'T : equality
(pas nécessaire)
Membre explicite
^T : member-signature
(pas d'équivalent)
Pour forcer le type de base : classe mère ou interface
let check<'error' when 'error' :> System.Exception> condition (error: 'error') =
if not condition then raise error
→ Équivalent en C♯ :
static void check<TError>(bool condition, TError error) where TError : System.Exception
{
if (!condition) throw error;
}
💡 Syntaxe alternative : let check condition (error: #System.Exception)
→ Cf. Types flexibles 📍
Exemple de fonction avec une telle contrainte : la fonction Option.ofObj
(Type Option📍) prend en entrée une valeur nullable venant "de l'extérieur" et la convertit en type Option
plus sûr à utiliser.
module Option =
let ofObj value = // 'a -> 'a option when 'a: null
match value with
| null -> None
| _ -> Some value
👉 Le paramètre générique 'a
comporte une contrainte de nullabilité : when 'a: null
.
☝️ Note : cette contrainte ne s'applique pas aux types F♯ (Tuple, Record, Union) qui sont bien des types référence mais qui ne peuvent pas être instanciés null
dans les cas d'usage standard. Cependant, lors d'une interop avec une librairie .NET telle que le micro-ORM Dapper, on peut obtenir une valeur null
à la barbe du compilateur F♯. Pour retomber sur nos pieds, on peut :
Soit décorer le type avec un attribut AllowNullLiteral
, mais on perd alors la sécurité des types non nullables.
Soit utiliser la fonction Unchecked.defaultof
pour tester cette nullité sans perdre en sécurité dans le reste de la codebase :
module Option =
let private isHiddenNull (arg: 'a when 'a: not struct) =
arg = Unchecked.defaultof<'a> // ☝️ Ajoute contrainte `'when a: equality`
let ofHiddenNullable value =
if value |> isHiddenNull
then None
else Some value
open System
let getValues<'T when 'T : enum<int>>() =
Enum.GetValues(typeof<'T>) :?> 'T array
type ColorEnum = Red = 1 | Blue = 2
type ColorUnion = Red | Blue
let x = getValues<ColorEnum>() // [| Red; Blue |]
let y = getValues<ColorUnion>() // 💥 Exception ou erreur de compilation (1)
(1) La contrainte when 'T : enum<int>
permet :
D'éviter la ArgumentException
au runtime (Type provided must be an Enum)
Au profit d'une erreur dès la compilation (The type 'ColorUnion' is not an enum)
Syntaxe : 'T : comparison
Indique que le type 'T
doit :
soit implémenter IComparable
(1)
soit être un collection d'éléments comparables (2)
☝ Notes :
'T : comparison
> 'T : IComparable
❗
'T : comparison
≠ 'T : IComparable<'T>
❗
Pratique pour méthodes génériques compare
ou sort
💡
Exemple :
let compare (x: 'T) (y: 'T when 'T : comparison) =
if x < y then -1
elif x > y then +1
else 0
// Comparaison de nombres et de chaînes
let x = compare 1.0 2.0 // -1
let y = compare "a" "A" // +1
// Comparaison de listes d'entier
let z = compare [ 1; 2; 3 ] [ 2; 3; 1 ] // -1
// Comparaison de listes de fonctions
let a = compare [ id; ignore ] [ id; ignore ]
// 💥 ~~
// error FS0001: Le type '('a -> 'a)' ne prend pas en charge la contrainte 'comparison'.
// Par exemple, il ne prend pas en charge l'interface 'System.IComparable'
Pb : Comment indiquer qu'un objet doit disposer d'un certain membre ?
• Manière classique en .NET : typage nominal → Contrainte spécifiant type de base (interface ou classe parent)
• Alternative en F♯ : typage structurel (a.k.a duck-typing des langages dynamiques) → Contrainte de membre explicite → Utilisée avec les SRTP (statically resolved type parameter)
let inline add (value1 : ^T when ^T : (static member (+) : ^T * ^T -> ^T), value2: ^T) =
value1 + value2
let x = add (1, 2)
// val x : int = 3
let y = add (1.0, 2.0)
// val y : float = 3.0
⚖️ Pour et contre :
👍 Permet de rendre code générique pour types hétérogènes
👎 Difficile à lire, à maintenir. Ralentit la compilation
👉 À utiliser dans une librairie, pas pour modéliser un domaine
Attention : ne pas confondre avec le type System.Nullable<T>
qui est un type valeur alors que la contrainte de nullabilité ne s'applique à qu'un type référence.