Conventions de codage F#

Les conventions suivantes sont formulées à partir de l’expérience d’utilisation de grandes codebases F#. Les Cinq principes d’un code F# efficace constituent la base de chaque recommandation. Elles sont liées aux instructions de conception du composant F#, mais s’appliquent à tout code F#, et pas seulement aux composants tels que les bibliothèques.

Organisation du code

F# propose deux méthodes principales pour organiser le code : les modules et les espaces de noms. Celles-ci sont similaires, mais présentent les différences suivantes :

  • Les espaces de noms sont compilés en tant qu’espaces de noms .NET. Les modules sont compilés en tant que classes statiques.
  • Les espaces de noms sont toujours de niveau supérieur. Les modules peuvent être de niveau supérieur et imbriqués dans d’autres modules.
  • Les espaces de noms peuvent s’étendre sur plusieurs fichiers. Les modules ne peuvent pas.
  • Les modules peuvent être agrémentés avec [<RequireQualifiedAccess>] et [<AutoOpen>].

Les instructions suivantes vous aideront à les utiliser pour organiser votre code.

Préférer les espaces de noms au niveau supérieur

Pour tout code publiquement consommable, les espaces de noms sont préférables face aux modules au niveau supérieur. Étant donné qu’ils sont compilés en tant qu’espaces de noms .NET, ils sont consommables à partir de C# sans avoir recours à using static.

// Recommended.
namespace MyCode

type MyClass() =
    ...

L’utilisation d’un module de niveau supérieur peut ne pas sembler différente lorsqu’elle est appelée uniquement à partir de F#, mais pour les consommateurs C#, les appelants peuvent être surpris de devoir se qualifier MyClass avec le module MyCode lorsqu’ils ne connaissent pas la construction spécifique C# using static.

// Will be seen as a static class outside F#
module MyCode

type MyClass() =
    ...

Appliquer soigneusement [<AutoOpen>]

La construction [<AutoOpen>] peut polluer l’étendue de ce qui est disponible pour les appelants, et la réponse à d’où vient quelque chose est « magique ». Ce n’est pas une bonne chose. Une exception à cette règle est la bibliothèque principale F# elle-même (bien que ce fait soit également un peu controversé).

Toutefois, il est pratique si vous disposez de fonctionnalités d’assistance pour une API publique que vous souhaitez organiser séparément de cette API publique.

module MyAPI =
    [<AutoOpen>]
    module private Helpers =
        let helper1 x y z =
            ...

    let myFunction1 x =
        let y = ...
        let z = ...

        helper1 x y z

Cela vous permet de séparer proprement les détails de l’implémentation de l’API publique d’une fonction sans avoir à qualifier entièrement une assistance chaque fois que vous l’appelez.

En outre, l’exposition des méthodes d’extension et des générateurs d’expressions au niveau de l’espace de noms peut être clairement exprimée avec [<AutoOpen>].

Utilisez [<RequireQualifiedAccess>] chaque fois que les noms peuvent entrer en conflit ou que vous pensez que cela aide à la lisibilité

L’ajout de l’attribut [<RequireQualifiedAccess>] à un module indique que le module ne peut pas être ouvert et que les références aux éléments du module nécessitent un accès qualifié explicite. Par exemple, le module Microsoft.FSharp.Collections.List a cet attribut.

Il est utile quand les fonctions et les valeurs du module ont des noms susceptibles d’entrer en conflit avec les noms d’autres modules. L’exigence d’un accès qualifié peut considérablement augmenter la facilité de maintenance à long terme et la capacité d’une bibliothèque à évoluer.

[<RequireQualifiedAccess>]
module StringTokenization =
    let parse s = ...

...

let s = getAString()
let parsed = StringTokenization.parse s // Must qualify to use 'parse'

Trier les instructions open de manière topologique

Dans F#, l’ordre des déclarations est important, y compris avec les instructions open (et open type, appelées open plus loin). Cela est différent de C#, où l’effet de using et using static est indépendant de l’ordre de ces instructions dans un fichier.

Dans F#, les éléments ouverts dans une étendue peuvent masquer d’autres éléments déjà présents. Cela signifie que la réorganisation des instructions open peut modifier la signification du code. Par conséquent, tout tri arbitraire de toutes les instructions open (par exemple, alphanumérique) n’est pas recommandé, au risque de générer un comportement différent de celui que vous attendez.

Au lieu de cela, nous vous recommandons de les trier de manière topologique, c’est-à-dire, classer vos open instructions dans l’ordre dans lequel les couches de votre système sont définies. Le tri alphanumérique au sein de différentes couches topologiques peut également être envisagé.

Par exemple, voici le tri topologique pour le fichier d’API publique du service de compilateur F# :

namespace Microsoft.FSharp.Compiler.SourceCodeServices

open System
open System.Collections.Generic
open System.Collections.Concurrent
open System.Diagnostics
open System.IO
open System.Reflection
open System.Text

open FSharp.Compiler
open FSharp.Compiler.AbstractIL
open FSharp.Compiler.AbstractIL.Diagnostics
open FSharp.Compiler.AbstractIL.IL
open FSharp.Compiler.AbstractIL.ILBinaryReader
open FSharp.Compiler.AbstractIL.Internal
open FSharp.Compiler.AbstractIL.Internal.Library

open FSharp.Compiler.AccessibilityLogic
open FSharp.Compiler.Ast
open FSharp.Compiler.CompileOps
open FSharp.Compiler.CompileOptions
open FSharp.Compiler.Driver

open Internal.Utilities
open Internal.Utilities.Collections

Un saut de ligne sépare les couches topologiques, chaque couche étant ensuite triée de manière alphanumérique. Cela organise proprement le code sans masquer accidentellement les valeurs.

Utiliser des classes pour contenir des valeurs qui ont des effets secondaires

Il arrive souvent que l’initialisation d’une valeur ait des effets secondaires, tels que l’instanciation d’un contexte dans une base de données ou une autre ressource distante. Il est tentant d’initialiser de telles choses dans un module et de les utiliser dans les fonctions suivantes :

// Not recommended, side-effect at static initialization
module MyApi =
    let dep1 = File.ReadAllText "/Users/<name>/connectionstring.txt"
    let dep2 = Environment.GetEnvironmentVariable "DEP_2"

    let private r = Random()
    let dep3() = r.Next() // Problematic if multiple threads use this

    let function1 arg = doStuffWith dep1 dep2 dep3 arg
    let function2 arg = doStuffWith dep1 dep2 dep3 arg

C’est souvent problématique pour plusieurs raisons :

Tout d’abord, la configuration de l’application est poussée dans la codebase avec dep1 et dep2. C’est difficile à gérer dans des codebases plus volumineuses.

Deuxièmement, les données initialisées statiquement ne doivent pas inclure de valeurs qui ne sont pas thread-safe si votre composant utilise lui-même plusieurs threads. Cela est clairement violé par dep3.

Enfin, l’initialisation du module est compilée en un constructeur statique pour l’ensemble de l’unité de compilation. Si une erreur se produit lors de l’initialisation de la valeur liée par let dans ce module, elle se manifeste sous la forme d’un TypeInitializationException qui est ensuite mis en cache pendant toute la durée de vie de l’application. Ceci peut être difficile à diagnostiquer. Il existe généralement une exception interne que vous pouvez tenter de raisonner, mais si ce n’est pas le cas, il n’est pas possible de déterminer la cause racine.

Au lieu de cela, utilisez simplement une classe simple pour contenir des dépendances :

type MyParametricApi(dep1, dep2, dep3) =
    member _.Function1 arg1 = doStuffWith dep1 dep2 dep3 arg1
    member _.Function2 arg2 = doStuffWith dep1 dep2 dep3 arg2

Cette option permet :

  1. Envoi push de n’importe quel état dépendant en dehors de l’API elle-même.
  2. La configuration peut maintenant être effectuée en dehors de l’API.
  3. Les erreurs d’initialisation des valeurs dépendantes ne sont pas susceptibles de se manifester en tant que TypeInitializationException.
  4. L’API est désormais plus facile à tester.

Gestion des erreurs

La gestion des erreurs dans les grands systèmes est une entreprise complexe et nuancée, et il n’y a pas de solution miracle pour s’assurer que vos systèmes sont tolérants aux pannes et se comportent bien. Les instructions suivantes fournissent des conseils pour faciliter cette gestion complexe.

Représenter des cas d’erreur et un état illégal dans les types intrinsèques à votre domaine

Avec les unions discriminées, F# vous permet de représenter l’état de programme défectueux dans votre système de type. Par exemple :

type MoneyWithdrawalResult =
    | Success of amount:decimal
    | InsufficientFunds of balance:decimal
    | CardExpired of DateTime
    | UndisclosedFailure

Dans ce cas, il existe trois façons connues de retirer de l’argent d’un compte bancaire. Chaque cas d’erreur est représenté dans le type et peut donc être traité en toute sécurité tout au long du programme.

let handleWithdrawal amount =
    let w = withdrawMoney amount
    match w with
    | Success am -> printfn $"Successfully withdrew %f{am}"
    | InsufficientFunds balance -> printfn $"Failed: balance is %f{balance}"
    | CardExpired expiredDate -> printfn $"Failed: card expired on {expiredDate}"
    | UndisclosedFailure -> printfn "Failed: unknown"

En règle générale, si vous pouvez modéliser les différentes façons dont quelque chose peut échouer dans votre domaine, le code de gestion des erreurs n’est plus traité comme quelque chose que vous devez gérer en plus du flux de programme normal. Il s’agit simplement d’une partie du flux normal du programme et n’est pas considéré comme exceptionnel. Il existe deux grands avantages :

  1. Il est plus facile à gérer à mesure que votre domaine change au fil du temps.
  2. Les cas d’erreur sont plus faciles à tester unitairement.

Utiliser des exceptions lorsque les erreurs ne peuvent pas être représentées avec des types

Toutes les erreurs ne peuvent pas être représentées dans un domaine problématique. Ces types de fautes sont de nature exceptionnelle, d’où la possibilité de lever et d’intercepter des exceptions dans F#.

Tout d’abord, il est recommandé de lire les Instructions relatives à la conception des exceptions. Elles s’appliquent également à F#.

Les principales constructions disponibles dans F# à des fins de levée d’exceptions doivent être considérées dans l’ordre de préférence suivant :

Fonction Syntaxe Objectif
nullArg nullArg "argumentName" Déclenche un System.ArgumentNullException avec le nom de l’argument spécifié.
invalidArg invalidArg "argumentName" "message" Déclenche un System.ArgumentException avec un nom d’argument et un message spécifiés.
invalidOp invalidOp "message" Déclenche un System.InvalidOperationException avec le message spécifié.
raise raise (ExceptionType("message")) Mécanisme d’usage général pour lever des exceptions.
failwith failwith "message" Déclenche un System.Exception avec le message spécifié.
failwithf failwithf "format string" argForFormatString Déclenche un System.Exception avec un message déterminé par la chaîne de format et ses entrées.

Utilisez nullArg, invalidArg et invalidOp comme mécanisme pour lever ArgumentNullException, ArgumentException et InvalidOperationException, le cas échéant.

Les fonctions failwith et failwithf doivent généralement être évitées, car elles soulèvent le type Exception de base, et non une exception spécifique. Conformément aux Instructions relatives à la conception des exceptions, vous devez lever des exceptions plus spécifiques lorsque vous le pouvez.

Utiliser la syntaxe de gestion des exceptions

F# prend en charge les modèles d’exception via la syntaxe try...with :

try
    tryGetFileContents()
with
| :? System.IO.FileNotFoundException as e -> // Do something with it here
| :? System.Security.SecurityException as e -> // Do something with it here

Concilier les fonctionnalités à effectuer en cas d’exception avec les critères spéciaux peut être un peu difficile si vous souhaitez conserver le code propre. Une façon de gérer cela consiste à utiliser des modèles actifs comme un moyen de regrouper les fonctionnalités entourant un cas d’erreur avec une exception elle-même. Par exemple, vous pouvez consommer une API qui, lorsqu’elle lève une exception, enferme des informations précieuses dans les métadonnées d’exception. Désenvelopper une valeur utile dans le corps de l’exception capturée à l’intérieur du modèle actif et retourner cette valeur peut être utile dans certaines situations.

Ne pas utiliser la gestion des erreurs monadiques pour remplacer les exceptions

Les exceptions sont souvent considérées comme un tabou dans le paradigme fonctionnel pur. En effet, les exceptions violent la pureté, il est donc sûr de les considérer comme pas tout à fait pures sur le plan fonctionnel. Toutefois, cela ignore la réalité selon laquelle le code doit s’exécuter et que des erreurs d’exécution peuvent se produire. En général, écrivez du code en supposant que la plupart des choses ne sont pas pures ou totales, afin de minimiser les surprises désagréables (comme le catch vide dans C# ou la mauvaise gestion de l’arborescence des appels de procédure, qui permet de rejeter des informations).

Il est important de prendre en compte les principaux points forts/aspects suivants des exceptions en ce qui concerne leur pertinence et leur adéquation dans l’ensemble de l’écosystème du runtime .NET et de l’interlangage :

  • Ils contiennent des informations de diagnostic détaillées, qui sont utiles lors du débogage d’un problème.
  • Ils sont bien compris par le runtime et d’autres langages .NET.
  • Ils peuvent réduire considérablement les différences par rapport au code qui se dévie de son chemin pour éviter les exceptions en implémentant certains sous-ensembles de leur sémantique sur une base ad hoc.

Ce troisième point est essentiel. Pour les opérations complexes non-triviales, le fait de ne pas utiliser d’exceptions peut entraîner le traitement de structures telles que celles-ci :

Result<Result<MyType, string>, string list>

Ce qui peut facilement conduire à un code fragile comme les critères spéciaux sur les erreurs « typées par chaîne » :

let result = doStuff()
match result with
| Ok r -> ...
| Error e ->
    if e.Contains "Error string 1" then ...
    elif e.Contains "Error string 2" then ...
    else ... // Who knows?

En outre, il peut être tentant d’accepter toute exception dans le désir d’une fonction « simple » qui retourne un type plus « agréable » :

// Can be problematic due to discarding the cause of error.
let tryReadAllText (path : string) =
    try System.IO.File.ReadAllText path |> Some
    with _ -> None

Malheureusement, tryReadAllText peut lever de nombreuses exceptions en fonction de la myriade de choses qui peuvent se produire sur un système de fichiers, et ce code ignore toutes les informations sur ce qui peut réellement se passer dans votre environnement. Si vous remplacez ce code par un type de résultat, vous revenez à l’analyse des messages d’erreur « typée par chaîne » :

// Problematic, callers only have a string to figure the cause of error.
let tryReadAllText (path : string) =
    try System.IO.File.ReadAllText path |> Ok
    with e -> Error e.Message

let r = tryReadAllText "path-to-file"
match r with
| Ok text -> ...
| Error e ->
    if e.Contains "uh oh, here we go again..." then ...
    else ...

De plus, le fait de placer l’objet d’exception lui-même dans le constructeur Error vous oblige simplement à traiter correctement le type d’exception sur le site d’appel plutôt que dans la fonction. Cela crée efficacement des exceptions vérifiées, qui sont notoirement peu agréables à traiter en tant qu’appelant d’une API.

Une bonne alternative aux exemples ci-dessus consiste à intercepter des exceptions spécifiques et à retourner une valeur significative dans le contexte de cette exception. Si vous modifiez la fonction tryReadAllText comme suit, None a plus de signification :

let tryReadAllTextIfPresent (path : string) =
    try System.IO.File.ReadAllText path |> Some
    with :? FileNotFoundException -> None

Au lieu de fonctionner comme un fourre-tout, cette fonction va maintenant gérer correctement le cas où un fichier n’a pas été trouvé et affecter cette signification à un retour. Cette valeur de retour peut correspondre à ce cas d’erreur, sans ignorer les informations contextuelles ou forcer les appelants à traiter un cas qui peut ne pas être pertinent à ce stade du code.

Les types tels que Result<'Success, 'Error> sont appropriés pour les opérations de base où ils ne sont pas imbriqués, et les types facultatifs F# sont parfaits pour représenter quand un élément peut retourner quelque chose ou rien. Toutefois, ils ne remplacent pas les exceptions et ne doivent pas être utilisés dans une tentative de remplacement d’exceptions. Au lieu de cela, ils doivent être appliqués judicieusement pour traiter des aspects spécifiques de la stratégie de gestion des exceptions et des erreurs de manière ciblée.

Application partielle et programmation générique

F# prend en charge l’application partielle et, par conséquent, différentes façons de programmer dans un style générique. Cela peut être utile pour la réutilisation du code dans un module ou l’implémentation d’un élément, mais il ne s’agit pas d’un élément à exposer publiquement. En général, la programmation générique n’est pas une vertu en soi, et peut ajouter une barrière cognitive significative pour les personnes qui ne sont pas familiarisées avec ce style.

Ne pas utiliser l’application partielle et le corroyage dans les API publiques

À quelques exceptions près, l’utilisation d’une application partielle dans les API publiques peut prêter à confusion pour les consommateurs. En règle générale, les valeurs liées par let dans le code F# sont des valeurs, et non des valeurs de fonction. Le mélange de valeurs et de valeurs de fonction peut permettre d’économiser quelques lignes de code en échange d’un certain temps de surcharge cognitive, en particulier s’il est combiné avec des opérateurs tels que >> pour composer des fonctions.

Tenir compte des implications en matière d’outils pour la programmation générique

Les fonctions de corroyage n’étiquettent pas leurs arguments. Cela a des implications sur les outils. Considérons les deux fonctions suivantes :

let func name age =
    printfn $"My name is {name} and I am %d{age} years old!"

let funcWithApplication =
    printfn "My name is %s and I am %d years old!"

Les deux sont des fonctions valides, mais funcWithApplication est une fonction de corroyage. Lorsque vous pointez sur leurs types dans un éditeur, vous voyez ceci :

val func : name:string -> age:int -> unit

val funcWithApplication : (string -> int -> unit)

Sur le site d’appel, les info-bulles dans des outils tels que Visual Studio vous donnent la signature de type, mais comme aucun nom n’est défini, ils n’affichent pas de noms. Les noms sont essentiels à une bonne conception de l’API, car ils aident les appelants à mieux comprendre la signification de l’API. L’utilisation d’un code générique dans l’API publique peut rendre la compréhension plus difficile pour les appelants.

Si vous rencontrez du code générique comme funcWithApplication qui est publiquement consommable, il est recommandé d’effectuer une expansion η complète afin que les outils puissent récupérer des noms significatifs pour les arguments.

En outre, le débogage du code générique peut être difficile, voire impossible. Les outils de débogage s’appuient sur des valeurs liées à des noms (par exemple, liaisons let) afin que vous puissiez inspecter les valeurs intermédiaires à mi-chemin de l’exécution. Lorsque votre code n’a aucune valeur à inspecter, il n’y a rien à déboguer. À l’avenir, les outils de débogage peuvent évoluer pour synthétiser ces valeurs en fonction de chemins d’accès précédemment exécutés, mais il n’est pas judicieux de parier sur les fonctionnalités de débogage potentielles.

Considérer l’application partielle comme une technique pour réduire la réutilisation interne

Contrairement au point précédent, l’application partielle est un outil merveilleux pour réduire les éléments réutilisables à l’intérieur d’une application ou les éléments internes plus profonds d’une API. Il peut être utile pour tester unitairement l’implémentation d’API plus complexes, où la réutilisation est souvent difficile à gérer. Par exemple, le code suivant montre comment vous pouvez accomplir ce que vous offrent la plupart des infrastructures fictives sans avoir à prendre une dépendance externe sur une telle infrastructure et à avoir à apprendre une API personnalisée associée.

Observez par exemple la topographie de la solution suivante :

MySolution.sln
|_/ImplementationLogic.fsproj
|_/ImplementationLogic.Tests.fsproj
|_/API.fsproj

ImplementationLogic.fsproj peut exposer du code tel que :

module Transactions =
    let doTransaction txnContext txnType balance =
        ...

type Transactor(ctx, currentBalance) =
    member _.ExecuteTransaction(txnType) =
        Transactions.doTransaction ctx txnType currentBalance
        ...

Les tests unitaires Transactions.doTransaction dans ImplementationLogic.Tests.fsproj sont faciles :

namespace TransactionsTestingUtil

open Transactions

module TransactionsTestable =
    let getTestableTransactionRoutine mockContext = Transactions.doTransaction mockContext

L’application partielle de doTransaction avec un objet de contexte fictif vous permet d’appeler la fonction dans tous vos tests unitaires sans avoir à construire un contexte fictif à chaque fois :

module TransactionTests

open Xunit
open TransactionTypes
open TransactionsTestingUtil
open TransactionsTestingUtil.TransactionsTestable

let testableContext =
    { new ITransactionContext with
        member _.TheFirstMember() = ...
        member _.TheSecondMember() = ... }

let transactionRoutine = getTestableTransactionRoutine testableContext

[<Fact>]
let ``Test withdrawal transaction with 0.0 for balance``() =
    let expected = ...
    let actual = transactionRoutine TransactionType.Withdraw 0.0
    Assert.Equal(expected, actual)

N’appliquez pas cette technique de manière universelle à l’ensemble de votre codebase, mais il s’agit d’un bon moyen de réduire la réutilisation pour les éléments internes complexes et les tests unitaires de ces éléments internes.

Contrôle d’accès

F# dispose de plusieurs options pour le contrôle d’accès, héritées de ce qui est disponible dans le runtime .NET. Elles ne sont pas seulement utilisables pour les types, vous pouvez également les utiliser pour les fonctions.

Bonnes pratiques dans le contexte des bibliothèques largement consommées :

  • Préférez les types et les membres non-public jusqu’à ce que vous en ayez besoin pour qu’ils soient publiquement consommables. Cela réduit également ce que les consommateurs couplent.
  • Efforcez-vous de conserver toutes les fonctionnalités d’assistance private.
  • Envisagez l’utilisation de [<AutoOpen>] sur un module privé de fonctions d’assistance si elles deviennent nombreuses.

Inférence de type et génériques

L’inférence de type peut vous éviter de taper beaucoup de données réutilisables. De plus, la généralisation automatique dans le compilateur F# peut vous aider à écrire du code plus générique sans presque aucun effort supplémentaire de votre part. Toutefois, ces fonctionnalités ne sont pas universellement bonnes.

  • Envisagez d’étiqueter des noms d’arguments avec des types explicites dans des API publiques et ne comptez pas sur l’inférence de type pour cela.

    La raison en est que vous devez contrôler la forme de votre API, et non le compilateur. Bien que le compilateur puisse faire un bon travail pour déduire des types pour vous, il est possible que la forme de votre API change si les types internes sur lesquels elle s’appuie ont changé de type. C’est peut-être ce que vous voulez, mais cela entraînera presque certainement un changement d’API cassant que les consommateurs en aval devront ensuite gérer. Au lieu de cela, si vous contrôlez explicitement la forme de votre API publique, vous pouvez contrôler ces changements cassants. En termes de DDD, cela peut être considéré comme une couche anti-corruption.

  • Envisagez de donner un nom explicite à vos arguments génériques.

    Sauf si vous écrivez du code vraiment générique qui n’est pas spécifique à un domaine particulier, un nom explicite peut aider d’autres programmeurs à comprendre le domaine dans lequel ils travaillent. Par exemple, un paramètre de type nommé 'Document dans le contexte de l’interaction avec une base de données de documents rend plus clair que les types de documents génériques peuvent être acceptés par la fonction ou le membre avec lequel vous travaillez.

  • Envisagez de nommer des paramètres de type génériques avec PascalCase.

    Il s’agit de la façon générale de procéder dans .NET. Il est donc recommandé d’utiliser PascalCase plutôt que snake_case ou camelCase.

Enfin, la généralisation automatique n’est pas toujours une aubaine pour les personnes qui débutent avec F# ou disposent d’une codebase volumineuse. L’utilisation de composants génériques entraîne une surcharge cognitive. En outre, si les fonctions généralisées automatiquement ne sont pas utilisées avec différents types d’entrée (et encore moins si elles sont destinées à être utilisées en tant que telles), il n’y a aucun avantage réel à ce qu’elles soient génériques. Déterminez toujours si le code que vous écrivez bénéficiera réellement d’être générique.

Performances

Envisager des structs lorsque le type est petit et a des taux d’allocation élevés

L’utilisation de structs (également appelés types de valeurs) peut souvent entraîner des performances plus élevées pour certains codes, car cela évite généralement l’allocation d’objets. Toutefois, les structs ne constituent pas toujours un raccourci : si la taille des données d’un struct dépasse 16 octets, la copie des données peut souvent entraîner plus de temps de processeur que l’utilisation d’un type de référence.

Pour déterminer si vous devez utiliser un struct, tenez compte des conditions suivantes :

  • La taille de vos données est égale ou inférieure à 16 octets.
  • Vous êtes susceptible d’avoir de nombreuses instances de ces types résidant dans la mémoire dans un programme en cours d’exécution.

Si la première condition s’applique, vous devez généralement utiliser un struct. Si les deux s’appliquent, vous devez presque toujours utiliser un struct. Il peut y avoir des cas où les conditions précédentes s’appliquent, mais l’utilisation d’un struct n’est pas meilleure ou pire que l’utilisation d’un type de référence, mais elles sont susceptibles d’être rares. Cependant, il est important de toujours mesurer l’apport de changements comme celui-ci, et de ne pas fonctionner sur des hypothèses ou de l’intuition.

Envisager des tuples de struct lors du regroupement de types de valeur petits avec des taux d’allocation élevés

Considérons les deux fonctions suivantes :

let rec runWithTuple t offset times =
    let offsetValues x y z offset =
        (x + offset, y + offset, z + offset)

    if times <= 0 then
        t
    else
        let (x, y, z) = t
        let r = offsetValues x y z offset
        runWithTuple r offset (times - 1)

let rec runWithStructTuple t offset times =
    let offsetValues x y z offset =
        struct(x + offset, y + offset, z + offset)

    if times <= 0 then
        t
    else
        let struct(x, y, z) = t
        let r = offsetValues x y z offset
        runWithStructTuple r offset (times - 1)

Lorsque vous évaluez ces fonctions avec un outil d’évaluation statistique comme BenchmarkDotNet, vous constaterez que la fonction runWithStructTuple qui utilise des tuples de struct s’exécute 40 % plus rapidement et n’alloue aucune mémoire.

Toutefois, ces résultats ne seront pas toujours le cas dans votre propre code. Si vous marquez une fonction comme inline, le code qui utilise des tuples de référence peut obtenir des optimisations supplémentaires, ou le code à allouer peut simplement être optimisé. Vous devez toujours mesurer les résultats chaque fois que les performances sont concernées, et ne jamais agir en fonction de l’hypothèse ou de l’intuition.

Envisager les enregistrements de struct lorsque le type est petit et a des taux d’allocation élevés

La règle de base décrite précédemment est également valable pour les types d’enregistrements F#. Considérez les types de données et les fonctions suivants qui les traitent :

type Point = { X: float; Y: float; Z: float }

[<Struct>]
type SPoint = { X: float; Y: float; Z: float }

let rec processPoint (p: Point) offset times =
    let inline offsetValues (p: Point) offset =
        { p with X = p.X + offset; Y = p.Y + offset; Z = p.Z + offset }

    if times <= 0 then
        p
    else
        let r = offsetValues p offset
        processPoint r offset (times - 1)

let rec processStructPoint (p: SPoint) offset times =
    let inline offsetValues (p: SPoint) offset =
        { p with X = p.X + offset; Y = p.Y + offset; Z = p.Z + offset }

    if times <= 0 then
        p
    else
        let r = offsetValues p offset
        processStructPoint r offset (times - 1)

Cela est similaire au code tuple précédent, mais cette fois, l’exemple utilise des enregistrements et une fonction interne incorporée.

Lorsque vous évaluez ces fonctions avec un outil d’évaluation statistique comme BenchmarkDotNet, vous constaterez que processStructPoint s’exécute presque 60 % plus rapidement et n’alloue rien sur le tas géré.

Envisager des unions discriminées de struct lorsque le type de données est petit avec des taux d’allocation élevés

Les observations précédentes sur les performances avec des tuples de struct et des enregistrements sont également valables pour les unions discriminées F#. Prenez le code suivant :

    type Name = Name of string

    [<Struct>]
    type SName = SName of string

    let reverseName (Name s) =
        s.ToCharArray()
        |> Array.rev
        |> System.String
        |> Name

    let structReverseName (SName s) =
        s.ToCharArray()
        |> Array.rev
        |> System.String
        |> SName

Il est courant de définir des unions discriminées à cas unique comme ceci pour la modélisation de domaine. Lorsque vous évaluez ces fonctions avec un outil d’évaluation statistique comme BenchmarkDotNet, vous constaterez que structReverseName s’exécute environ 25 % plus rapidement que reverseName pour les petites chaînes. Pour les chaînes volumineuses, les deux fonctionnent à peu près de la même façon. Donc, dans ce cas, il est toujours préférable d’utiliser un struct. Comme mentionné précédemment, mesurez toujours et n’agissez pas sur des hypothèses ou de l’intuition.

Bien que l’exemple précédent ait montré qu’une union discriminée de struct produisait de meilleures performances, il est courant d’avoir des unions discriminées plus grandes lors de la modélisation d’un domaine. Les types de données plus volumineux comme ceux-ci peuvent ne pas fonctionner aussi bien s’ils sont des structs en fonction des opérations effectuées sur eux, car il peut y avoir davantage de copie.

Immuabilité et mutation

Les valeurs F# sont immuables par défaut, ce qui vous permet d’éviter certaines classes de bogues (en particulier ceux impliquant la concurrence et le parallélisme). Toutefois, dans certains cas, pour obtenir une efficacité optimale (voire raisonnable) du temps d’exécution ou de l’allocation de mémoire, il est préférable d’implémenter une étendue de travail en utilisant une mutation d’état sur place. Cela est possible dans une base d’adhésion avec F# avec le mot clé mutable.

L’utilisation de mutable dans F# peut sembler en contradiction avec la pureté fonctionnelle. C’est compréhensible, mais la pureté fonctionnelle à tous les niveaux peut être en contradiction avec les objectifs de performance. Un compromis consiste à encapsuler la mutation de sorte que les appelants n’ont pas besoin de se soucier de ce qui se passe lorsqu’ils appellent une fonction. Cela vous permet d’écrire une interface fonctionnelle sur une implémentation basée sur une mutation pour du code critique pour les performances.

En outre, les constructions de liaison F# let vous permettent d’imbriquer des liaisons dans une autre, ce qui peut être utilisé pour conserver l’étendue de la variable mutable proche ou à son plus petit point.

let data =
    [
        let mutable completed = false
        while not completed do
            logic ()
            // ...
            if someCondition then
                completed <- true   
    ]

Aucun code ne peut accéder au mutable completed utilisé uniquement pour initialiser la valeur data liée.

Encapsuler le code mutable dans des interfaces immuables

Avec comme objectif la transparence référentielle, il est essentiel d’écrire du code qui n’expose pas le point vulnérable mutable des fonctions critiques pour les performances. Par exemple, le code suivant implémente la fonction Array.contains dans la bibliothèque principale F# :

[<CompiledName("Contains")>]
let inline contains value (array:'T[]) =
    checkNonNull "array" array
    let mutable state = false
    let mutable i = 0
    while not state && i < array.Length do
        state <- value = array[i]
        i <- i + 1
    state

L’appel de cette fonction plusieurs fois ne modifie pas le tableau sous-jacent et ne vous oblige pas à maintenir un état mutable lors de sa consommation. Elle est transparente d’un point de vue référentiel, même si presque toutes les lignes de code qu’elle contient utilisent une mutation.

Envisager d’encapsuler des données mutables dans des classes

L’exemple précédent utilisait une seule fonction pour encapsuler des opérations à l’aide de données mutables. Cela n’est pas toujours suffisant pour des jeux de données plus complexes. Considérez les ensembles de fonctions suivants :

open System.Collections.Generic

let addToClosureTable (key, value) (t: Dictionary<_,_>) =
    if t.ContainsKey(key) then
        t[key] <- value
    else
        t.Add(key, value)

let closureTableCount (t: Dictionary<_,_>) = t.Count

let closureTableContains (key, value) (t: Dictionary<_, HashSet<_>>) =
    match t.TryGetValue(key) with
    | (true, v) -> v.Equals(value)
    | (false, _) -> false

Ce code est performant, mais il expose la structure de données basée sur la mutation que les appelants sont chargés de gérer. Cela peut être encapsulé à l’intérieur d’une classe sans qu’aucun membre sous-jacent ne puisse changer :

open System.Collections.Generic

/// The results of computing the LALR(1) closure of an LR(0) kernel
type Closure1Table() =
    let t = Dictionary<Item0, HashSet<TerminalIndex>>()

    member _.Add(key, value) =
        if t.ContainsKey(key) then
            t[key] <- value
        else
            t.Add(key, value)

    member _.Count = t.Count

    member _.Contains(key, value) =
        match t.TryGetValue(key) with
        | (true, v) -> v.Equals(value)
        | (false, _) -> false

Closure1Table encapsule la structure de données basée sur la mutation sous-jacente, ce qui ne force pas les appelants à maintenir la structure de données sous-jacente. Les classes constituent un moyen puissant d’encapsuler des données et des routines basées sur des mutations sans exposer les détails aux appelants.

Préférer let mutable à ref

Les cellules de référence sont un moyen de représenter la référence à une valeur plutôt que la valeur elle-même. Bien qu’elles puissent être utilisées pour du code critique pour les performances, elles ne sont pas recommandées. Prenons l’exemple suivant :

let kernels =
    let acc = ref Set.empty

    processWorkList startKernels (fun kernel ->
        if not ((!acc).Contains(kernel)) then
            acc := (!acc).Add(kernel)
        ...)

    !acc |> Seq.toList

L’utilisation d’une cellule de référence « pollue » désormais tout le code suivant et doit déréférencer et référencer à nouveau les données sous-jacentes. Envisagez plutôt let mutable :

let kernels =
    let mutable acc = Set.empty

    processWorkList startKernels (fun kernel ->
        if not (acc.Contains(kernel)) then
            acc <- acc.Add(kernel)
        ...)

    acc |> Seq.toList

Mis à part le point de mutation unique au milieu de l’expression lambda, tout autre code qui touche acc peut le faire d’une manière qui n’est pas différente de l’utilisation d’une valeur immuable normale liée à let. Cela facilitera les modifications au fil du temps.

Valeurs nulles et valeurs par défaut

Les valeurs nulles doivent généralement être évitées dans F#. Par défaut, les types déclarés par F# ne prennent pas en charge l’utilisation du null littéral, et toutes les valeurs et objets sont initialisés. Toutefois, certaines API .NET courantes retournent ou acceptent des valeurs nulles, et d’autres types déclarés .NET courants, tels que les tableaux et les chaînes, autorisent les valeurs nulles. Toutefois, l’occurrence de valeurs null est très rare dans la programmation F# et l’un des avantages de l’utilisation de F# est d’éviter les erreurs de référence nulle dans la plupart des cas.

Éviter l’utilisation de l’attribut AllowNullLiteral

Par défaut, les types déclarés par F# ne prennent pas en charge l’utilisation du null littéral. Vous pouvez annoter manuellement les types F# avec AllowNullLiteral pour autoriser cette opération. Cependant, il est presque toujours préférable d’éviter cela.

Éviter l’utilisation de l’attribut Unchecked.defaultof<_>

Il est possible de générer une valeur null ou une valeur initialisée par zéro pour un type F# à l’aide de Unchecked.defaultof<_>. Cela peut être utile lors de l’initialisation du stockage pour certaines structures de données, dans un modèle de codage de haute performance ou dans l’interopérabilité. Toutefois, l’utilisation de cette construction doit être évitée.

Éviter l’utilisation de l’attribut DefaultValue

Par défaut, les enregistrements et objets F# doivent être correctement initialisés lors de la construction. L’attribut DefaultValue peut être utilisé pour remplir certains champs d’objets avec une valeur null ou une valeur initialisée par zéro. Cette construction est rarement nécessaire et son utilisation doit être évitée.

Si vous recherchez des entrées nulles, déclenchez des exceptions à la première occasion

Lors de l’écriture d’un nouveau code F#, dans la pratique, il n’est pas nécessaire de rechercher des entrées nulles, sauf si vous vous attendez à ce que le code soit utilisé à partir de C# ou d’autres langages .NET.

Si vous décidez d’ajouter des vérifications pour les entrées nulles, effectuez les vérifications à la première occasion et déclenchez une exception. Par exemple :

let inline checkNonNull argName arg =
    if isNull arg then
        nullArg argName

module Array =
    let contains value (array:'T[]) =
        checkNonNull "array" array
        let mutable result = false
        let mutable i = 0
        while not state && i < array.Length do
            result <- value = array[i]
            i <- i + 1
        result

Pour des raisons héritées, certaines fonctions de chaîne dans FSharp.Core traitent toujours les valeurs nulles comme des chaînes vides et n’échouent pas sur les arguments nuls. Toutefois, n’envisagez pas cela comme des conseils et n’adoptez pas de modèles de codage qui attribuent une signification sémantique à « nul ».

Programmation d’objets

F# prend entièrement en charge les objets et les concepts orientés objet. Bien que de nombreux concepts orientés objet soient puissants et utiles, ils ne sont pas tous idéaux à utiliser. Les listes suivantes offrent des conseils sur les catégories de fonctionnalités orientées objet à un niveau supérieur.

Envisagez d’utiliser ces fonctionnalités dans de nombreuses situations :

  • Notation par points (x.Length)
  • Membres d’instance
  • Constructeurs implicites
  • Membres static
  • Notation de l’indexeur (arr[x]), en définissant une propriété Item
  • Notation de découpage (arr[x..y], arr[x..], arr[..y]), en définissant des membres GetSlice
  • Arguments nommés et facultatifs
  • Interfaces et implémentations d’interface

Ne commencez pas par utiliser ces fonctionnalités, mais appliquez-les judicieusement lorsqu’elles sont pratiques pour résoudre un problème :

  • Surcharge de méthode
  • Données mutables encapsulées
  • Opérateurs sur les types
  • Propriétés automatiques
  • Implémentation de IDisposable et IEnumerable
  • Extensions de type
  • Événements
  • Structures
  • Délégués
  • Énumérations

De manière générale, évitez ces fonctionnalités, sauf si vous devez les utiliser :

  • Hiérarchies de types basées sur l’héritage et héritage d’implémentation
  • Nuls et Unchecked.defaultof<_>

Préférer la composition à l’héritage

La composition sur l’héritage est un langage de longue date auquel un bon code F# peut adhérer. Le principe fondamental est que vous ne devez pas exposer une classe de base et forcer les appelants à hériter de cette classe de base pour obtenir des fonctionnalités.

Utiliser des expressions d’objet pour implémenter des interfaces si vous n’avez pas besoin d’une classe

Les expressions d’objet vous permettent d’implémenter des interfaces à la volée, en liant l’interface implémentée à une valeur sans avoir à le faire à l’intérieur d’une classe. Cela est pratique, en particulier si vous avez uniquement besoin d’implémenter l’interface et que vous n’avez pas besoin d’une classe complète.

Par exemple, voici le code exécuté dans Ionide pour fournir une action de correction de code si vous avez ajouté un symbole pour lequel vous n’avez pas d’instruction open :

    let private createProvider () =
        { new CodeActionProvider with
            member this.provideCodeActions(doc, range, context, ct) =
                let diagnostics = context.diagnostics
                let diagnostic = diagnostics |> Seq.tryFind (fun d -> d.message.Contains "Unused open statement")
                let res =
                    match diagnostic with
                    | None -> [||]
                    | Some d ->
                        let line = doc.lineAt d.range.start.line
                        let cmd = createEmpty<Command>
                        cmd.title <- "Remove unused open"
                        cmd.command <- "fsharp.unusedOpenFix"
                        cmd.arguments <- Some ([| doc |> unbox; line.range |> unbox; |] |> ResizeArray)
                        [|cmd |]
                res
                |> ResizeArray
                |> U2.Case1
        }

Étant donné qu’il n’est pas nécessaire d’utiliser une classe lors de l’interaction avec l’API Visual Studio Code, les expressions d’objet sont un outil idéal pour cela. Elles sont également utiles pour les tests unitaires, lorsque vous souhaitez extraire une interface avec des routines de test de manière improvisée.

Envisager des abréviations de type pour raccourcir les signatures

Les abréviations de type sont un moyen pratique d’affecter une étiquette à un autre type, tel qu’une signature de fonction ou un type plus complexe. Par exemple, l’alias suivant attribue une étiquette à ce qui est nécessaire pour définir un calcul avec CNTK, une bibliothèque deep learning :

open CNTK

// DeviceDescriptor, Variable, and Function all come from CNTK
type Computation = DeviceDescriptor -> Variable -> Function

Le nom Computation est un moyen pratique de désigner toute fonction qui correspond à la signature à laquelle est attribué un alias. L’utilisation d’abréviations de type comme celle-ci est pratique et permet d’obtenir du code plus succinct.

Éviter d’utiliser des abréviations de type pour représenter votre domaine

Bien que les abréviations de type soient pratiques pour donner un nom aux signatures de fonction, elles peuvent prêter à confusion lors de l’abréviation d’autres types. Considérez cette abréviation :

// Does not actually abstract integers.
type BufferSize = int

Cela peut prêter à confusion pour plusieurs raisons :

  • BufferSize n’est pas une abstraction. Il s’agit simplement d’un autre nom pour un entier.
  • Si BufferSize est exposé dans une API publique, il peut facilement être mal interprété pour signifier plus que juste int. En règle générale, les types de domaine ont plusieurs attributs et ne sont pas des types primitifs comme int. Cette abréviation contrevient à cette hypothèse.
  • La casse de BufferSize (PascalCase) implique que ce type contient plus de données.
  • Cet alias n’offre pas davantage de clarté par rapport à la fourniture d’un argument nommé à une fonction.
  • L’abréviation ne se manifeste pas dans le langage intermédiaire compilé. Il s’agit simplement d’un entier et cet alias est une construction au moment de la compilation.
module Networking =
    ...
    let send data (bufferSize: int) = ...

En résumé, le piège des abréviations de type est qu’il ne s’agit pas d’abstractions sur les types qu’elles raccourcissent. Dans l’exemple précédent, BufferSize est juste un int sous-couvert, sans données supplémentaires, ni avantages du système de type en plus de ce que int a déjà.

Une autre approche pour utiliser des abréviations de type pour représenter un domaine consiste à utiliser des unions discriminées à cas unique. L’exemple précédent peut être modélisé comme suit :

type BufferSize = BufferSize of int

Si vous écrivez du code qui fonctionne en termes de BufferSize et sa valeur sous-jacente, vous devez en construire un au lieu de passer n’importe quel entier arbitraire :

module Networking =
    ...
    let send data (BufferSize size) =
    ...

Cela réduit la probabilité de passer par erreur un entier arbitraire dans la fonction send, car l’appelant doit construire un type BufferSize pour encapsuler une valeur avant d’appeler la fonction.