Membres
Définition
Éléments complémentaires dans définition d'un type (classe, record, union)
(Événement)
Méthode
Propriété
Propriété indexée
Surcharge d'opérateur
Membres statiques et d'instance
Membre statique : static member member-name ...
Membre d'instance :
Membre concret :
member self-identifier.member-name ...
Membre abstrait :
abstract member member-name : type-signature
Membre virtuel = nécessite 2 déclarations
Membre abstrait
Implémentation par défaut :
default self-identifier.member-name ...
Surcharge d'un membre virtuel :
override self-identifier.member-name ...
☝ member-name
en PascalCase (convention .NET)
☝ Pas de membre protected
ou private
!
Self identifier
En C♯, Java, TypeScript :
this
En VB :
Me
En F♯ : au choix →
this
,self
,me
, n'importe quel identifier valide...
Définissable de 3 manières complémentaires :
Pour le constructeur primaire : avec
as
→type MyClass() as self = ...
Pour un membre :
member me.Introduce() = printfn $"Hi, I'm {me.Name}"
Pour un membre ne l'utilisant pas : avec
_
→member _.Hi() = printfn "Hi!"
☝ Depuis F# 6. Avant, on utilisait__
Appeler un membre
💡 Quasiment les mêmes règles qu'en C♯
Appeler un membre d'instance à l'intérieur du type
→ Préfixer avec self-identifier : self-identifier.instance-member-name
Appeler un membre d'instance depuis l'extérieur
→ Préfixer avec le nom de l'instance : instance-name.instance-member-name
Appeler un membre statique
→ Préfixer par le nom du type : type-name.static-member-name
☝ Même à l'intérieur du type c'est nécessaire en F# (alors que c'est optionnel en C#)
Méthode
Méthode ≃ Fonction attachée directement à un type
2 formes de déclaration des paramètres :
Paramètres curryfiés = Style FP
Paramètres en tuple = Style OOP
Meilleure interop avec C♯
Seul mode autorisé pour les constructeurs
Support des paramètres nommés, optionnels, en tableau
Support des surcharges (overloads)
// (1) Forme en tuple (la + classique)
type Product = { SKU: string; Price: float } with
member this.TupleTotal(qty, discount) =
(this.Price * float qty) - discount // (A)
// (2) Forme currifiée
type Product' =
{ SKU: string; Price: float }
member me.CurriedTotal qty discount =
(me.Price * float qty) - discount // (B)
☝ with
nécessaire en ① mais pas en ② à cause de l'indentation
→ end
peut terminer le bloc commencé avec with
☝ this.Price
Ⓐ et me.Price
Ⓑ
→ Accès à l'instance via le self-identifier défini par le membre
Arguments nommés
Permet d'appeler une méthode tuplifiée en spécifiant le nom des paramètres :
type SpeedingTicket() =
member _.SpeedExcess(speed: int, limit: int) =
speed - limit
member x.CalculateFine() =
if x.SpeedExcess(limit = 55, speed = 70) < 20 then 50.0 else 100.0
Pratique pour :
Clarifier un usage pour le lecteur ou le compilateur (en cas de surcharges)
Choisir l'ordre des arguments
Ne spécifier que certains arguments, les autres étant optionnels
☝ Les arguments après un argument nommé sont forcément nommés eux-aussi
Paramètres optionnels
Cette fonctionnalité permet d'appeler une méthode tuplifiée sans spécifier tous les paramètres.
Paramètre optionnel :
Déclaré avec
?
devant son nom →?arg1: int
Dans le corps de la méthode, le paramètre a en fait le type
Option
→arg1: int option
On peut utiliser
defaultArg
pour indiquer la valeur par défautMais cette valeur par défaut n'apparaît pas dans la signature, contrairement au C# !
Lors de l'appel de la méthode, on peut spécifier la valeur d'un paramètre optionnel via un argument nommé, ceci de 2 façons possibles :
Nom et type d'origine →
M(arg1 = 1)
Nom préfixé par
?
et type wrappé dans uneOption
→M(?arg1 = Some 1)
Exemple :
type DuplexType = Full | Half
type Connection(?rate: int, ?duplex: DuplexType, ?parity: bool) =
let duplex = defaultArg duplex Full
let parity = defaultArg parity false
let defaultRate = match duplex with Full -> 9600 | Half -> 4800
let rate = defaultArg rate defaultRate
do printfn "Baud Rate: %d - Duplex: %A - Parity: %b" rate duplex parity
let conn1 = Connection(duplex = Full)
let conn2 = Connection(?duplex = Some Half)
let conn3 = Connection(300, Half, true)
☝ A noter : le shadowing des paramètres par des variables de même nom, par exemple parity
let parity (* bool *) = defaultArg parity (* bool option *) Full
Interop .NET
Pour l'interop avec .NET, on utilise une syntaxe différente, basée sur des attributs, ce qui permet de pouvoir spécifier une valeur par défaut :
[<Optional; DefaultParameterValue(...)>] arg
Les attributs Optional
et DefaultParameterValue
sont disponibles dans open System.Runtime.InteropServices
.
Exemple : méthode pour tracer l'appel à une fonction en récupérant son nom grâce à l'attribut CallerMemberName
dans System.Runtime.CompilerServices
(*)
open System.Runtime.CompilerServices
open System.Runtime.InteropServices
type Tracer() =
static member trace(message: string,
[<CallerMemberName; Optional; DefaultParameterValue("")>] memberName: string,
[<CallerFilePath; Optional; DefaultParameterValue("")>] path: string,
[<CallerLineNumber; Optional; DefaultParameterValue(0)>] line: int) =
printfn $"Message: {message}"
printfn $"Member name: {memberName}"
printfn $"Source file path: {path}"
printfn $"Source line number: {line}"
open type Tracer
let main() =
trace "foo"
main();;
// Message: foo
// Member name: main
// Source file path: C:\Users\xxx\stdin
// Source line number: 18
Utilisation du pipe |>
|>
On peut utiliser le pipe avec une méthode à : • 1 paramètre, • 2 paramètres dont le dernier est optionnel.
open System.Runtime.InteropServices
type LogLevel =
| Trace = 1
| Error = 2
type Logger() =
member _.Log(message: string, [<Optional; DefaultParameterValue(LogLevel.Trace)>] level: LogLevel) =
printfn $"[{level}] {message}"
let logger = Logger()
"Mon message" |> logger.Log
// > [Trace] Mon message
💡 Si l'on veut avoir un 3e paramètre, il faut passer par une lambda intermédiaire, un peu comme si on écrivait nous-même la curryfication :
// ...
type Logger() =
// ...
member this.LogFrom(origin: string, [<Optional; DefaultParameterValue(LogLevel.Trace)>] level: LogLevel) =
fun message -> this.Log($"Origin: {origin} | {message}", level)
let logger = Logger()
"Mon message" |> logger.LogFrom "Root"
// > [Trace] Origin: Root | Mon message
Tableau de paramètres
Permet de spécifier un nombre variable de paramètres de même type → Via attribut System.ParamArray
sur le dernier argument de la méthode
open System
type MathHelper() =
static member Max([<ParamArray>] items) =
items |> Array.max
let x = MathHelper.Max(1, 2, 4, 5) // 5
💡 Équivalent en C♯ de public static T Max<T>(params T[] items)
Appeler méthode C♯ TryXxx()
❓ Comment appeler en F♯ une méthode C♯ bool TryXxx(args, out T outputArg)
? (Exemple : int.TryParse
, IDictionnary::TryGetValue
)
👎 Utiliser équivalent F♯ de
out outputArg
mais utilise mutation 🤮✅ Ne pas spécifier l'argument
outputArg
Change le type de retour en tuple
bool * T
outputArg
devient le 2e élément de ce tuple
match System.Int32.TryParse text with
| true, i -> printf $"It's the number {value}."
| false, _ -> printf $"{text} is not a number."
Appeler méthode Xxx(tuple)
❓ Comment appeler une méthode dont 1er param est lui-même un tuple ?!
Essayons :
let friendsLocation = Map.ofList [ (0,0),"Peter" ; (1,0),"Jane" ]
// Map<(int * int), string>
let peter = friendsLocation.TryGetValue (0,0)
// 💥 error FS0001: expression censée avoir le type `int * int`, pas `int`
💡 Explications : TryGetValue(0,0)
= appel méthode en mode tuplifié
→ Spécifie 2 paramètres, 0
et 0
.
→ 0
est un int
alors qu'on attend un tuple int * int
!
Solutions
😕 Doubles parenthèses, mais syntaxe confusante
friendsLocation.TryGetValue((0,0))
😕 Backward pipe, mais confusant aussi
friendsLocation.TryGetValue <| (0,0)
✅ Utiliser une fonction plutôt qu'une méthode
friendsLocation |> Map.tryFind (0,0)
Méthode vs Fonction
Application partielle
✅ oui
✅ oui
❌ non
Arguments nommés
❌ non
❌ non
✅ oui
Paramètres optionnels
❌ non
❌ non
✅ oui
Tableau de paramètres
❌ non
❌ non
✅ oui
Surcharge / overload
❌ non
❌ non
✅ oui 1️⃣
Sensibilité à l'ordre de déclaration
✅ oui
❌ non 2️⃣
❌ non 2️⃣
Séparation données / comportement
✅ oui 3️⃣
❌ non
❌ non
Nommage
camelCase
PascalCase
PascalCase
Support du inline
✅ oui
✅ oui
✅ oui
Récursive
✅ si rec
✅ oui
✅ oui
Inférence de x
dans
f x
→ ✅ oui
➖
x.M()
→ ❌ non
Passable en argument
✅ oui : g f
✅ oui : g T.M
❌ non : g x.M
4️⃣
Notes
1️⃣ Si possible, préférer des paramètres optionnels à des surcharges.
2️⃣ Concernant l'ordre de déclaration :
Il est malgré tout pris en compte dans le cas de membres génériques → Cf. https://stackoverflow.com/q/66358718/8634147
Il est recommandé de déclarer les membres de haut en bas, par homogénéité avec le reste du code.
3️⃣ La séparation des données et du comportement est un principe fort en programmation fonctionnelle. → En orienté-objet, on privilégie le polymorphisme ad-hoc : le comportement est en façade et les données sont encapsulées dans les classes. → Si on veut ajouter du comportement dans un périmètre + limité, on passe par une nouvelle classe, par composition si possible ou par héritage ou par copie des données. → En C♯, on peut également le faire au moyen de méthodes d'extension, au détriment du polymorphisme. → En F♯, on privilégie la séparation des données et du comportement. Les données sont mises dans un type immutable pour contrer cette perte (relative) d'encapsulation. Cela permet de partager un type et de lui affecter des comportements spécifiques, localisés uniquement là où on en a besoin. → Par exemple dans l'architecture Elmish, on fait en sorte de séparer State et View : chacun agit sur le Model dans son registre, sans s'interférer l'un l'autre : le State gère l'Init du Model et son Update en fonction du Message reçu, la View s'occupe du rendu du Model. Si l'on souhaite ajouter des méthodes dans le Model, il faut faire attention à ce qu'elles soient suffisamment génériques pour être utilisables à la fois le State et dans la View.
4️⃣ Solutions alternatives :
Wrapper dans lambda :
g (fun x -> x.M())
Passer à F♯8 :
g _.M()
Méthodes statiques ou module compagnon ?
Le choix entre méthode et fonction se pose également dans le cas de méthodes statiques ou de fonctions pour un module compagnon. L'usage dans le code appelant sera le même, à la casse près (camelCase vs PascalCase).
La question est pertinente en particulier dans le cas d'un SmartConstructor ou d'une Factory c'est-à-dire pour créer une instance du type. Ce qui peut faire pencher la balance du côté d'une méthode statique est la possibilité d'avoir des paramètres optionnels que l'on peut mettre en relation avec des champs optionnels du type.
type PersonName =
{ FirstName : string
MiddleName : string option
LastName : string }
static member Create(firstName, lastName, ?middleName) =
{ FirstName = firstName
MiddleName = middleName
LastName = lastName }
// Usage
let johnDoe = PersonName.Create("John", "Doe")
// Equivalent avec le constructeur primaire
let johnDoe' =
{ FirstName = "John"
LastName = "Doe"
MiddleName = None }
// 💡 Egalement élégant pour définir les 3 champs
let jfk = PersonName.Create("John", "Fitzgerald", "Kennedy")
// Equivalent avec le constructeur primaire
let jfk' =
{ FirstName = "John"
MiddleName = Some "Fitzgerald"
LastName = "Kennedy" }
// 💡 Permet enfin de bénéficer des arguments nommés
let pierreLaurent = PersonName.Create(firstName = "Pierre", lastName = "Laurent")
💡Une telle méthode présente un autre avantage : permettre de créer un Record
en spécifiant le nom de son type.
→ C'est parfois nécessaire, pour enlever une ambiguïté avec un autre Record
de même structure.
→ Cela n'est pas toujours pratique/élégant à faire à l'aide d'une annotation de type ({ FirstName = ...} : PersonName
) ou en qualifiant les champs ({ PersonName.FirstName = ... }
)
Propriétés
≃ Sucre syntaxique masquant un getter et/ou un setter
→ Permet d'utiliser la propriété comme s'il s'agissait d'un champ
2 façons de déclarer une propriété :
Getter
Syntaxe 1 :
member this.Property = expression
Syntaxe 2 :
member this.Property with get () = expression
expression
peut accéder aux variables et autres membres de l'objet grâce authis
expression
est ré-évaluée à chaque appel
Exemple 1 :
type Person = { FirstName: string; LastName: string } with
member it.FullName = $"{it.LastName.ToUpper()} {it.FirstName}"
let joe = { FirstName = "Joe"; LastName = "Dalton" }
let s = joe.FullName // "DALTON Joe"
Exemple 2 : → Permet de voir que la valeur d'un Getter est réévaluée à chaque appel → Mais dans un tel cas préférer une méthode à une propriété ❗
type Generator() =
let random = new System.Random()
member _.NextValue = random.Next() // La valeur change à chaque appel
Automatique
Read-only :
member val Property = value
→ Equivalent d'une propriété C#{ get; }
Read/write :
member val Property = value with get, set
→ Equivalent d'une propriété C#{ get; set; }
value
est la valeur d'initialisation. Ce n'est pas le backing field de la propriété car il est, lui, implicite, généré par le compilateur.La propriété conserve la même valeur à chaque appel au
get
. Seul l'éventuelset
permet de modifier sa valeur.
Exemple 1 :
type PersonName(first: string, last: string) =
member val First = first
member val Last = last
member val Full = $"{last.ToUpper()} {first}"
let joe = PersonName(first = "Joe", last = "Dalton")
let s = joe.Full // "DALTON Joe"
Exemple 2 :
type Generator() =
let random = new System.Random()
member val FirstValue = random.Next() // La valeur ne change pas à chaque appel
Autres cas
Dans les autres cas, la syntaxe est verbeuse (détails) 👉 Préférer des méthodes, c'est plus explicite
Pattern matching
→ Peuvent participer à un pattern matching que dans partie when
type Person = { First: string; Last: string } with
member this.FullName = // Getter
$"{this.Last.ToUpper()} {this.First}"
let joe = { First = "Joe"; Last = "Dalton" }
let { First = first } = joe // val first : string = "Joe"
let { FullName = x } = joe
// 💥 ~~~~~~~~ Error FS0039: undefined record label 'FullName'
let salut =
match joe with
| _ when joe.FullName = "DALTON Joe" -> "Salut, Joe !"
| _ -> "Bonjour !"
// val salut : string = "Salut, Joe !"
Propriétés indexées
Permet accès par indice, comme si la classe était un tableau : instance.[index]
→ Intéressant pour une collection ordonnée, pour masquer l'implémentation
Mise en place en déclarant membre Item
member self-identifier.Item
with get(index) =
get-member-body
and set index value =
set-member-body
💡 Propriété read-only (write-only) → ne déclarer que le getter (setter)
☝ Paramètre en tuple pour getter ≠ paramètres curryfiés setter
Exemple :
type Lang = En | Fr
type DigitLabel() =
let labels = // Map<Lang, string[]>
[| (En, [| "zero"; "one"; "two"; "three" |])
(Fr, [| "zéro"; "un"; "deux"; "trois" |]) |] |> Map.ofArray
member val Lang = En with get, set
member me.Item with get(i) = labels.[me.Lang].[i]
member _.En with get(i) = labels.[En].[i]
let digitLabel = DigitLabel()
let v1 = digitLabel.[1] // "one"
digitLabel.Lang <- Fr
let v2 = digitLabel.[2] // "deux"
let v3 = digitLabel.En(2) // "two"
// 💡 Notez la différence de syntaxe de l'appel à la propriété `En`
Slice
Idem propriété indexée mais renvoie plusieurs valeurs
Définition : via méthode (normale ou d'extension) GetSlice(?start, ?end)
Usage : via opérateur ..
type Range = { Min: int; Max: int } with
member this.GetSlice(min, max) =
{ Min = System.Math.Max(defaultArg min this.Min, this.Min)
; Max = System.Math.Min(defaultArg max this.Max, this.Max) }
let range = { Min = 1; Max = 5 }
let slice1 = range.[0..3] // { Min = 1; Max = 3 }
let slice2 = range.[2..] // { Min = 2; Max = 5 }
Surcharge d'opérateur
Opérateur surchargé à 2 niveaux possibles :
Dans un module, sous forme de fonction
let [inline] (operator-symbols) parameter-list = ...
👉 Cf. session sur les fonctions
☝ Limité : 1 seule surcharge possible
Dans un type, sous forme de membre
static member (operator-symbols) (parameter-list) =
Mêmes règles que pour la forme de fonction
👍 Plusieurs surcharges possibles (N types × P overloads)
Exemple :
type Vector(x: float, y: float) =
member _.X = x
member _.Y = y
override me.ToString() =
let format n = (sprintf "%+.1f" n)
$"Vector (X: {format me.X}, Y: {format me.Y})"
static member (*)(a, v: Vector) = Vector(a * v.X, a * v.Y)
static member (*)(v: Vector, a) = a * v
static member (~-)(v: Vector) = -1.0 * v
static member (+) (v: Vector, w: Vector) = Vector(v.X + w.X, v.Y + w.Y)
let v1 = Vector(1.0, 2.0) // Vector (X: +1.0, Y: +2.0)
let v2 = v1 * 2.0 // Vector (X: +2.0, Y: +4.0)
let v3 = 0.75 * v2 // Vector (X: +1.5, Y: +3.0)
let v4 = -v3 // Vector (X: -1.5, Y: -3.0)
let v5 = v1 + v4 // Vector (X: -0.5, Y: -1.0)
Last updated
Was this helpful?