Indications de conception de composants F#

Ce document regroupe des instructions de conception pour la programmation de composants en F#. Il repose sur les instructions de conception de composants F# (v14) de Microsoft Research et sur une version initialement organisée et gérée par la F# Software Foundation.

Ce document part du principe que vous avez déjà programmé en F#. Nous tenons à remercier la communauté F# pour ses contributions et ses commentaires utiles sur les différentes versions de ce guide.

Vue d’ensemble

Ce document examine quelques-uns des problèmes liés à la conception et à la programmation de composants F#. Un composant peut se présenter sous plusieurs formes :

  • Une couche dans votre projet F# avec des consommateurs externes dans ce projet.
  • Une bibliothèque destinée à la consommation par du code F# au-delà des limites de l’assembly.
  • Une bibliothèque destinée à la consommation par n’importe quel langage .NET au-delà des limites de l’assembly.
  • Une bibliothèque destinée à la distribution par le biais d’un dépôt de packages comme NuGet.

Les techniques décrites dans cet article adoptent les cinq principes d’un bon code F#, et utilisent donc la programmation fonctionnelle et la programmation d’objets selon les besoins.

Quelle que soit la méthodologie adoptée, le concepteur de composants et de bibliothèques pose un certain nombre de problèmes pratiques et prosaïques quand vous tentez de créer une API facilement utilisable par les développeurs. L’application consciencieuse des instructions de conception de bibliothèques .NET vous aidera à créer un ensemble cohérent d’API agréables à consommer.

Recommandations générales

Voici quelques instructions universelles qui s’appliquent aux bibliothèques F#, quel que soit le public visé par la bibliothèque.

Se familiariser avec les instructions de conception de bibliothèques .NET

Quel que soit le type de programmation F# que vous effectuez, il est utile d’avoir une connaissance pratique des instructions de conception de bibliothèques .NET. La plupart des autres programmeurs F# et .NET connaissent ces instructions et s’attendent à ce que le code .NET s’y conforme.

Les instructions de conception de bibliothèques .NET donnent des conseils généraux concernant, entre autres, le nommage, la conception de classes et d’interfaces, la conception de membres (propriétés, méthodes, événements, etc.). Elles constituent un premier point de référence utile pour une variété de conseils de conception.

Ajouter des commentaires de documentation XML à votre code

La documentation XML sur les API publiques garantit que les utilisateurs peuvent obtenir d’excellentes informations Intellisense et Quickinfo lors de l’utilisation de ces types et membres. Elle permet également de créer des fichiers de documentation pour la bibliothèque. Consultez la documentation XML sur les différentes balises xml pouvant être utilisées pour ajouter un balisage supplémentaire aux commentaires xmldoc.

/// A class for representing (x,y) coordinates
type Point =

    /// Computes the distance between this point and another
    member DistanceTo: otherPoint:Point -> float

Vous pouvez utiliser les commentaires XML courts (/// comment) ou les commentaires XML standard (///<summary>comment</summary>).

Envisager d’utiliser des fichiers de signature explicites (.fsi) pour les API de bibliothèques et de composants stables

L’utilisation de fichiers de signature explicites dans une bibliothèque F# fournit un résumé succinct de l’API publique, ce qui vous assure de connaître l’ensemble de la surface publique de votre bibliothèque, et offre une séparation claire entre la documentation publique et les détails d’implémentation internes. Les fichiers de signature ajoutent de la friction à la modification de l’API publique dans la mesure où ils exigent que les modifications soient apportées dans les fichiers d’implémentation et de signature. Par conséquent, les fichiers de signature ne sont généralement introduits que lorsque l’API est stabilisée et qu’elle n’est plus censée changer de manière significative.

Suivre les meilleures pratiques concernant l’utilisation de chaînes dans .NET

Suivez l’aide sur les meilleures pratiques concernant l’utilisation de chaînes dans .NET lorsque l’étendue du projet le justifie. En particulier, indiquez explicitement l’intention culturelle lors de la conversion et de la comparaison de chaînes (le cas échéant).

Instructions pour les bibliothèques orientées F#

Cette section présente des recommandations pour le développement de bibliothèques publiques orientées F#, c’est-à-dire des bibliothèques exposant des API publiques destinées à être consommées par des développeurs F#. Il existe de nombreuses recommandations en matière de conception de bibliothèques qui s’appliquent spécifiquement à F#. En l’absence des recommandations spécifiques qui suivent, utilisez les instructions de conception de bibliothèques .NET comme instructions de secours.

Conventions d’affectation de noms

Utiliser les conventions de nommage et de mise en majuscules de .NET

Le tableau suivant respecte les conventions de nommage et de mise en majuscules de .NET. Quelques ajouts ont été effectués pour inclure également les constructions F#. Ces recommandations sont particulièrement destinées aux API qui dépassent les limites de F# à F#, s’adaptant aux idiomes de .NET BCL et à la majorité des bibliothèques.

Construction Cas Élément Exemples Notes
Types concrets Casse Pascal Nom/ adjectif List, Double, Complex Les types concrets sont des structs, des classes, des énumérations, des délégués, des enregistrements et des unions. Bien que les noms de types soient traditionnellement en minuscules dans OCaml, F# a adopté le schéma de nommage .NET pour les types.
DLL Casse Pascal Fabrikam.Core.dll
Balises d’union Casse Pascal Nom Some, Add, Success N’utilisez pas de préfixe dans les API publiques. Si vous le souhaitez, utilisez un préfixe lorsqu’il est interne, par exemple « type Teams = TAlpha | TBeta | TDelta ».
Event Casse Pascal Verbe ValueChanged / ValueChanging
Exceptions Casse Pascal WebException Le nom doit se terminer par « Exception ».
Champ Casse Pascal Nom CurrentName
Types interface Casse Pascal Nom/ adjectif IDisposable Le nom doit commencer par « I ».
Méthode Casse Pascal Verbe ToString
Espace de noms Casse Pascal Microsoft.FSharp.Core Utilisez généralement <Organization>.<Technology>[.<Subnamespace>], mais supprimez l’organisation si la technologie est indépendante de l’organisation.
Paramètres Casse mixte Nom typeName, transform, range
Valeurs let (internes) Casse mixte ou casse Pascal Nom/ verbe getValue, myTable
Valeurs let (externes) Casse mixte ou casse Pascal Nom/verbe List.map, Dates.Today Les valeurs liées à let sont souvent publiques quand vous suivez des modèles de conception fonctionnelle traditionnels. Toutefois, vous utilisez généralement la casse Pascal quand l’identificateur peut être utilisé à partir d’autres langages .NET.
Propriété Casse Pascal Nom/ adjectif IsEndOfFile, BackColor Les propriétés booléennes utilisent généralement Is et Can et doivent être affirmatives, comme dans IsEndOfFile, et non IsNotEndOfFile.

Éviter les abréviations

Les instructions .NET découragent l’utilisation d’abréviations (par exemple, « utilisez OnButtonClick plutôt que OnBtnClick »). Les abréviations courantes, telles que Async pour « Asynchrone », sont tolérées. Cette instruction est parfois ignorée pour la programmation fonctionnelle ; par exemple, List.iter utilise une abréviation pour « itérer ». Pour cette raison, les abréviations ont tendance à être tolérées dans une plus grande mesure dans la programmation F# à F#, mais vous devriez toujours éviter de les utiliser lors de la conception de composants publics.

Éviter les collisions de noms en raison de la casse

Les instructions .NET indiquent que la casse ne doit pas être le seul critère de différenciation entre noms, car certains langages clients comme Visual Basic ne respectent pas la casse.

Utiliser des acronymes quand cela est approprié

Les acronymes tels que XML ne sont pas des abréviations et sont largement utilisés dans les bibliothèques .NET sans majuscules (Xml). Seuls des acronymes bien connus et largement répandus doivent être utilisés.

Utiliser la casse Pascal pour les noms de paramètres génériques

Utilisez la casse Pascal pour les noms de paramètres génériques dans les API publiques, notamment pour les bibliothèques orientées F#. En particulier, utilisez des noms tels que T, U, T1 et T2 pour des paramètres génériques arbitraires. Quand des noms spécifiques ont un sens, utilisez des noms tels que Key, Value et Arg (mais pas par exemple TKey) pour les bibliothèques orientées F#.

Utiliser la casse Pascal ou la casse mixte pour les fonctions publiques et les valeurs dans les modules F#

La casse mixte est utilisée pour les fonctions publiques conçues pour être utilisées sans qualification (par exemple, invalidArg) et pour les « fonctions de collection standard » (par exemple, List.map). Dans ces deux cas, les noms de fonction agissent comme des mots clés dans le langage.

Conception d’objets, de types et de modules

Utiliser des espaces de noms ou des modules pour contenir vos types et modules

Chaque fichier F# d’un composant doit commencer par une déclaration d’espace de noms ou de module.

namespace Fabrikam.BasicOperationsAndTypes

type ObjectType1() =
    ...

type ObjectType2() =
     ...

module CommonOperations =
    ...

ou

module Fabrikam.BasicOperationsAndTypes

type ObjectType1() =
    ...

type ObjectType2() =
    ...

module CommonOperations =
    ...

Les différences entre les modules et les espaces de noms en termes d’organisation du code au niveau supérieur sont les suivantes :

  • Les espaces de noms peuvent s’étendre sur plusieurs fichiers
  • Les espaces de noms ne peuvent pas contenir de fonctions F#, sauf s’ils se trouvent dans un module interne
  • Le code d’un module donné doit être contenu dans un seul fichier
  • Les modules de niveau supérieur peuvent contenir des fonctions F# sans nécessiter de module interne

Le choix entre un espace de noms ou un module de niveau supérieur affecte la forme compilée du code et donc la vue à partir d’autres langages .NET si votre API est finalement consommée en dehors du code F#.

Utiliser des méthodes et des propriétés pour les opérations intrinsèques aux types d’objets

Quand vous utilisez des objets, il est préférable de vérifier que les fonctionnalités consommables sont implémentées en tant que méthodes et propriétés sur ce type.

type HardwareDevice() =

    member this.ID = ...

    member this.SupportedProtocols = ...

type HashTable<'Key,'Value>(comparer: IEqualityComparer<'Key>) =

    member this.Add(key, value) = ...

    member this.ContainsKey(key) = ...

    member this.ContainsValue(value) = ...

La majeure partie des fonctionnalités pour un membre donné ne doit pas nécessairement être implémentée dans ce membre, mais la partie consommable de ces fonctionnalités doit l’être.

Utiliser des classes pour encapsuler l’état mutable

En F#, cela est uniquement nécessaire quand cet état n’est pas déjà encapsulé par une autre construction de langage comme une fermeture, une expression de séquence ou un calcul asynchrone.

type Counter() =
    // let-bound values are private in classes.
    let mutable count = 0

    member this.Next() =
        count <- count + 1
        count

Utilisez des types interface pour représenter un ensemble d’opérations. Cette option l’emporte sur d’autres, notamment les tuples de fonctions ou les enregistrements de fonctions.

type Serializer =
    abstract Serialize<'T> : preserveRefEq: bool -> value: 'T -> string
    abstract Deserialize<'T> : preserveRefEq: bool -> pickle: string -> 'T

À la place de :

type Serializer<'T> = {
    Serialize: bool -> 'T -> string
    Deserialize: bool -> string -> 'T
}

Dans .NET, les interfaces sont des concepts de première classe que vous pouvez utiliser pour obtenir la même chose qu’avec des foncteurs. Par ailleurs, vous pouvez les utiliser pour encoder des types existentiels dans votre programme, ce que les enregistrements de fonctions ne peuvent pas faire.

Utiliser un module pour regrouper des fonctions qui agissent sur des collections

Quand vous définissez un type de collection, envisagez de fournir un ensemble standard d’opérations telles que CollectionType.map et CollectionType.iter pour les nouveaux types collection.

module CollectionType =
    let map f c =
        ...
    let iter f c =
        ...

Si vous incluez un tel module, suivez les conventions de nommage standard pour les fonctions trouvées dans FSharp.Core.

Utiliser un module pour regrouper des fonctions canoniques courantes, en particulier dans les bibliothèques mathématiques et DSL

Par exemple, Microsoft.FSharp.Core.Operators est une collection ouverte automatiquement de fonctions de niveau supérieur (comme abs et sin) fournies par FSharp.Core.dll.

De même, une bibliothèque de statistiques peut inclure un module avec des fonctions erf et erfc, ce module étant conçu pour être ouvert explicitement ou automatiquement.

Envisager d’utiliser RequireQualifiedAccess et appliquer soigneusement les attributs AutoOpen

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. Le fait d’exiger un accès qualifié peut considérablement augmenter la facilité de maintenance à long terme et la capacité d’une bibliothèque à évoluer.

Il est fortement suggéré d’avoir l’attribut [<RequireQualifiedAccess>] pour des modules personnalisés qui étendent ceux fournis par FSharp.Core (tels que Seq, List, Array), car ces modules sont couramment utilisés dans du code F# et ont [<RequireQualifiedAccess>] défini sur eux ; plus généralement, il est déconseillé de définir des modules personnalisés qui n’ont pas l’attribut, quand ce module met en mémoire fantôme ou étend d’autres modules qui ont l’attribut.

L’ajout de l’attribut [<AutoOpen>] à un module signifie que le module sera ouvert au moment de l’ouverture de l’espace de noms contenant. L’attribut [<AutoOpen>] peut également être appliqué à un assembly pour indiquer un module qui s’ouvre automatiquement quand l’assembly est référencé.

Par exemple, une bibliothèque de statistiques MathsHeaven.Statistics peut contenir un module MathsHeaven.Statistics.Operators contenant les fonctions erf et erfc. Il est raisonnable de marquer ce module comme [<AutoOpen>]. Cela signifie que open MathsHeaven.Statistics ouvrira également ce module et placera les noms erf et erfc dans la portée. Une autre bonne pratique consiste à utiliser [<AutoOpen>] pour des modules contenant des méthodes d’extension.

La surutilisation de [<AutoOpen>] conduisant à des espaces de noms pollués, l’attribut doit être utilisé avec précaution. Pour des bibliothèques spécifiques dans des domaines spécifiques, une utilisation judicieuse de [<AutoOpen>] peut aboutir à une plus grande facilité d’utilisation.

Envisager de définir des membres d’opérateur sur des classes où l’utilisation d’opérateurs connus est appropriée

Les classes sont parfois utilisées pour modéliser des constructions mathématiques telles que des vecteurs. Quand le domaine en cours de modélisation a des opérateurs connus, il est utile de les définir en tant que membres intrinsèques de la classe.

type Vector(x: float) =

    member v.X = x

    static member (*) (vector: Vector, scalar: float) = Vector(vector.X * scalar)

    static member (+) (vector1: Vector, vector2: Vector) = Vector(vector1.X + vector2.X)

let v = Vector(5.0)

let u = v * 10.0

Ces conseils correspondent aux conseils généraux de .NET pour ces types. Toutefois, cela peut être encore plus important en programmation F# dans la mesure où ces types peuvent être utilisés conjointement avec des fonctions et des méthodes F# avec des contraintes de membre comme List.sumBy.

Envisager d’utiliser CompiledName pour fournir un nom convivial .NET pour les consommateurs d’autres langages .NET

Il peut parfois être utile de nommer quelque chose dans un style pour les consommateurs F# (par exemple, un membre statique en minuscules afin qu’il apparaisse comme une fonction liée à un module), mais d’appliquer un style différent au nom quand il est compilé dans un assembly. Vous pouvez utiliser l’attribut [<CompiledName>] pour appliquer un style différent au code non-F# qui consomme l’assembly.

type Vector(x:float, y:float) =

    member v.X = x
    member v.Y = y

    [<CompiledName("Create")>]
    static member create x y = Vector (x, y)

let v = Vector.create 5.0 3.0

En utilisant [<CompiledName>], vous pouvez utiliser les conventions de nommage de .NET pour les consommateurs non-F# de l’assembly.

Utiliser la surcharge de méthode pour les fonctions membres si cela simplifie l’API

La surcharge de méthode est un outil puissant pour simplifier une API qui peut avoir besoin de remplir une fonction analogue, mais avec des options ou des arguments différents.

type Logger() =

    member this.Log(message) =
        ...
    member this.Log(message, retryPolicy) =
        ...

En F#, il est plus courant de surcharger le nombre d’arguments plutôt que les types d’arguments.

Masquer les représentations des types enregistrement et union si la conception de ces types est susceptible d’évoluer

Évitez de révéler des représentations concrètes d’objets. Par exemple, la représentation concrète des valeurs DateTime n’est pas révélée par l’API publique externe de la conception de la bibliothèque .NET. Au moment de l’exécution, le Common Language Runtime sait quelle implémentation validée sera utilisée tout au long de l’exécution. Toutefois, le code compilé ne récupère pas lui-même les dépendances sur la représentation concrète.

Éviter l’utilisation de l’héritage d’implémentation à des fins d’extensibilité

En F#, l’héritage d’implémentation est rarement utilisé. Par ailleurs, les hiérarchies d’héritage sont souvent complexes et difficiles à modifier quand de nouvelles exigences arrivent. L’implémentation de l’héritage existe toujours dans F# pour des raisons de compatibilité et dans les rares cas où il s’agit de la meilleure solution à un problème, mais nous vous recommandons de rechercher d’autres techniques dans vos programmes F# conçus pour le polymorphisme, comme l’implémentation d’interface.

Signatures de fonctions et de membres

Utiliser des tuples pour les valeurs de retour lors du retour d’un petit nombre de valeurs non liées

Voici un bon exemple d’utilisation d’un tuple dans un type de retour :

val divrem: BigInteger -> BigInteger -> BigInteger * BigInteger

Pour les types de retour contenant de nombreux composants, ou si les composants sont liés à une seule entité identifiable, envisagez d’utiliser un type nommé au lieu d’un tuple.

Utiliser Async<T> pour la programmation asynchrone aux limites de l’API F#

S’il existe une opération synchrone correspondante nommée Operation qui retourne un T, l’opération asynchrone doit être nommée AsyncOperation si elle retourne Async<T> ou OperationAsync si elle retourne Task<T>. Pour les types .NET couramment utilisés qui exposent des méthodes Begin/End, envisagez d’utiliser Async.FromBeginEnd pour écrire des méthodes d’extension en tant que façade afin de fournir le modèle de programmation asynchrone F# à ces API .NET.

type SomeType =
    member this.Compute(x:int): int =
        ...
    member this.AsyncCompute(x:int): Async<int> =
        ...

type System.ServiceModel.Channels.IInputChannel with
    member this.AsyncReceive() =
        ...

Exceptions

Pour en savoir plus sur l’utilisation appropriée des exceptions, des résultats et des options, consultez Gestion des erreurs.

Membres d’extension

Appliquer avec précaution les membres d’extension F# dans les composants F# à F#

En général, les membres d’extension F# ne doivent être utilisés que pour les opérations qui se trouvent dans la fermeture d’opérations intrinsèques associées à un type dans la majorité de ses modes d’utilisation. Une utilisation courante consiste à fournir à F# des API plus idiomatiques pour différents types .NET :

type System.ServiceModel.Channels.IInputChannel with
    member this.AsyncReceive() =
        Async.FromBeginEnd(this.BeginReceive, this.EndReceive)

type System.Collections.Generic.IDictionary<'Key,'Value> with
    member this.TryGet key =
        let ok, v = this.TryGetValue key
        if ok then Some v else None

Types union

Utiliser des unions discriminées plutôt que des hiérarchies de classe pour les données structurées en arborescence

Les structures de type arborescence sont définies de manière récursive. C’est maladroit avec l’héritage, mais élégant avec les unions discriminées.

type BST<'T> =
    | Empty
    | Node of 'T * BST<'T> * BST<'T>

La représentation de données de type arborescence avec des unions discriminées vous permet également de tirer parti de l’exhaustivité des critères spéciaux.

Utiliser [<RequireQualifiedAccess>] sur les types union dont les noms de cas ne sont pas suffisamment uniques

Vous pouvez vous retrouver dans un domaine où le même nom est le meilleur nom pour différentes choses, comme des cas d’unions discriminées. Vous pouvez utiliser [<RequireQualifiedAccess>] pour lever l’ambiguïté des noms de cas afin d’éviter de déclencher des erreurs déroutantes en raison d’une mise en mémoire fantôme dépendante de l’ordre des instructions open.

Masquer les représentations d’unions discriminées pour les API compatibles binaires si la conception de ces types est susceptible d’évoluer

Les types union s’appuient sur des formulaires de critères spéciaux F# pour un modèle de programmation succinct. Comme mentionné précédemment, vous devez éviter de révéler les représentations de données concrètes si la conception de ces types est susceptible d’évoluer.

Par exemple, la représentation d’une union discriminée peut être masquée à l’aide d’une déclaration privée ou interne ou à l’aide d’un fichier de signature.

type Union =
    private
    | CaseA of int
    | CaseB of string

Si vous révélez des unions discriminées sans discrimination, vous aurez peut-être du mal à versionner votre bibliothèque sans casser le code utilisateur. Au lieu de cela, envisagez de révéler un ou plusieurs modèles actifs pour autoriser les critères spéciaux sur les valeurs de votre type.

Les modèles actifs sont un autre moyen de fournir aux consommateurs F# des critères spéciaux en évitant d’exposer directement les types union F#.

Fonctions inline et contraintes de membre

Définir des algorithmes numériques génériques à l’aide de fonctions inline avec des contraintes de membres implicites et des types génériques résolus statiquement

Les contraintes de membre arithmétiques et les contraintes de comparaison F# constituent une norme pour la programmation F#. Considérons par exemple le code suivant :

let inline highestCommonFactor a b =
    let rec loop a b =
        if a = LanguagePrimitives.GenericZero<_> then b
        elif a < b then loop a (b - a)
        else loop (a - b) b
    loop a b

Le type de cette fonction est le suivant :

val inline highestCommonFactor : ^T -> ^T -> ^T
                when ^T : (static member Zero : ^T)
                and ^T : (static member ( - ) : ^T * ^T -> ^T)
                and ^T : equality
                and ^T : comparison

Il s’agit d’une fonction appropriée pour une API publique dans une bibliothèque mathématique.

Éviter d’utiliser des contraintes de membre pour simuler des classes de type et le « duck typing »

Il est possible de simuler le « duck typing » à l’aide de contraintes de membre F#. Toutefois, les membres qui l’utilisent ne doivent généralement pas être utilisés dans des conceptions de bibliothèques F# à F#. Cela est dû au fait que les conceptions de bibliothèque basées sur des contraintes implicites inconnues ou non standard ont tendance à rendre le code utilisateur inflexible et lié à un modèle de framework particulier.

Par ailleurs, il y a de fortes chances qu’une utilisation intensive des contraintes de membre de cette manière entraîne des temps de compilation très longs.

Définitions d’opérateur

Éviter de définir des opérateurs symboliques personnalisés

Les opérateurs personnalisés sont essentiels dans certaines situations et constituent des dispositifs de notation très utiles dans un grand corps de code d’implémentation. Pour les nouveaux utilisateurs d’une bibliothèque, les fonctions nommées sont souvent plus faciles à utiliser. De plus, les opérateurs symboliques personnalisés peuvent être difficiles à documenter, et les utilisateurs éprouvent plus de difficultés à trouver de l’aide sur les opérateurs en raison des limitations existantes dans l’IDE et les moteurs de recherche.

Il est donc préférable de publier vos fonctionnalités en tant que fonctions et membres nommés, et d’exposer les opérateurs pour cette fonctionnalité uniquement si les avantages de la notation l’emportent sur la documentation et le coût cognitif de leur utilisation.

Unités de mesure

Utiliser avec précaution les unités de mesure pour améliorer la cohérence des types dans le code F#

Les informations de typage supplémentaires pour les unités de mesure sont effacées quand d’autres langages .NET les consultent. Sachez que les composants, les outils et la réflexion .NET voient les types sans unités. Par exemple, les consommateurs C# voient float au lieu de float<kg>.

Abréviations de types

Utiliser avec précaution les abréviations de type pour simplifier le code F#

Les composants, les outils et la réflexion .NET ne voient pas les noms abrégés des types. Une utilisation importante des abréviations de type peut également rendre un domaine plus complexe qu’il ne l’est réellement et perturber les consommateurs.

Éviter les abréviations de type pour les types publics dont les membres et les propriétés doivent être intrinsèquement différents de ceux disponibles dans le type abrégé

Dans ce cas, le type abrégé révèle trop d’informations sur la représentation du type réel défini. Songez plutôt à wrapper l’abréviation dans un type de classe ou une union discriminée à cas unique (si les performances sont primordiales, songez à utiliser un type struct pour wrapper l’abréviation).

Par exemple, il est tentant de définir un multimappage comme cas spécial d’un mappage F#, par exemple :

type MultiMap<'Key,'Value> = Map<'Key,'Value list>

Toutefois, les opérations de notation par points logiques sur ce type ne sont pas les mêmes que les opérations sur un mappage. Par exemple, l’opérateur de recherche map[key] peut raisonnablement retourner une liste vide si la clé n’est pas dans le dictionnaire plutôt que de lever une exception.

Instructions relatives aux bibliothèques utilisées à partir d’autres langages .NET

Quand vous concevez des bibliothèques qui seront utilisées à partir d’autres langages .NET, il est important de respecter les instructions de conception de bibliothèques .NET. Dans ce document, ces bibliothèques sont étiquetées en tant que bibliothèques Vanilla .NET, par opposition aux bibliothèques orientées F# qui utilisent des constructions F# sans aucune restriction. La conception de bibliothèques Vanilla .NET implique de fournir des API familières et idiomatiques cohérentes avec le reste du .NET Framework, en minimisant l’utilisation de constructions spécifiques à F# dans l’API publique. Les règles sont expliquées dans les sections suivantes.

Conception d’espaces de noms et de types (pour les bibliothèques utilisées à partir d’autres langages .NET)

Appliquer les conventions de nommage .NET à l’API publique de vos composants

Prêtez une attention particulière à l’utilisation des noms abrégés et aux instructions de mise en majuscules de .NET.

type pCoord = ...
    member this.theta = ...

type PolarCoordinate = ...
    member this.Theta = ...

Utiliser des espaces de noms, des types et des membres comme structure organisationnelle principale pour vos composants

Tous les fichiers contenant des fonctionnalités publiques doivent commencer par une déclaration namespace, et les seules entités publiques dans les espaces de noms doivent être des types. N’utilisez pas de modules F#.

Utilisez des modules non publics pour stocker le code d’implémentation, les types d’utilitaires et les fonctions utilitaires.

Préférez les types statiques aux modules, car ils permettent à l’API portée à évoluer d’utiliser la surcharge et d’autres concepts de l’API .NET qui ne peuvent pas être utilisés dans les modules F#.

Par exemple, au lieu de l’API publique suivante :

module Fabrikam

module Utilities =
    let Name = "Bob"
    let Add2 x y = x + y
    let Add3 x y z = x + y + z

Considérez plutôt :

namespace Fabrikam

[<AbstractClass; Sealed>]
type Utilities =
    static member Name = "Bob"
    static member Add(x,y) = x + y
    static member Add(x,y,z) = x + y + z

Utiliser des types enregistrement F# dans les API Vanilla .NET si la conception des types n’évoluera pas

Les types enregistrement F# sont compilés dans une classe .NET simple. Ils conviennent à certains types simples et stables dans les API. Envisagez d’utiliser les attributs [<NoEquality>] et [<NoComparison>] pour supprimer la génération automatique d’interfaces. Évitez également d’utiliser des champs d’enregistrement mutables dans les API Vanilla .NET, car ils exposent un champ public. Pensez toujours à déterminer si une classe constituerait une option plus flexible compte tenu de l’évolution de l’API.

Par exemple, le code F# suivant expose l’API publique à un consommateur C# :

F# :

[<NoEquality; NoComparison>]
type MyRecord =
    { FirstThing: int
        SecondThing: string }

C# :

public sealed class MyRecord
{
    public MyRecord(int firstThing, string secondThing);
    public int FirstThing { get; }
    public string SecondThing { get; }
}

Masquer la représentation des types union F# dans les API Vanilla .NET

Les types union F# ne sont pas couramment utilisés au-delà des limites des composants, même pour la programmation F# à F#. Ils constituent un excellent dispositif d’implémentation quand ils sont utilisés en interne dans des composants et des bibliothèques.

Lors de la conception d’une API Vanilla .NET, envisagez de masquer la représentation d’un type union à l’aide d’une déclaration privée ou d’un fichier de signature.

type PropLogic =
    private
    | And of PropLogic * PropLogic
    | Not of PropLogic
    | True

Vous pouvez également augmenter les types qui utilisent une représentation union en interne avec des membres pour fournir l’API orientée .NET souhaitée.

type PropLogic =
    private
    | And of PropLogic * PropLogic
    | Not of PropLogic
    | True

    /// A public member for use from C#
    member x.Evaluate =
        match x with
        | And(a,b) -> a.Evaluate && b.Evaluate
        | Not a -> not a.Evaluate
        | True -> true

    /// A public member for use from C#
    static member CreateAnd(a,b) = And(a,b)

Concevoir l’interface graphique utilisateur et d’autres composants avec les modèles de conception du framework

De nombreux frameworks sont disponibles dans .NET, notamment WinForms, WPF et ASP.NET. Vous devez utiliser des conventions de nommage et de conception pour chacun d’eux si vous concevez des composants à utiliser dans ces frameworks. Par exemple, pour la programmation WPF, adoptez des modèles de conception WPF pour les classes que vous concevez. Pour les modèles dans la programmation de l’interface utilisateur, utilisez des modèles de conception tels que des événements et des collections basées sur des notifications telles que celles disponibles dans System.Collections.ObjectModel.

Conception d’objets et de membres (pour les bibliothèques utilisées à partir d’autres langages .NET)

Utiliser l’attribut CLIEvent pour exposer des événements .NET

Construisez un DelegateEvent avec un type délégué .NET spécifique qui prend un objet et EventArgs (plutôt qu’un Event, qui utilise simplement le type FSharpHandler par défaut) afin que les événements soient publiés de manière familière dans d’autres langages .NET.

type MyBadType() =
    let myEv = new Event<int>()

    [<CLIEvent>]
    member this.MyEvent = myEv.Publish

type MyEventArgs(x: int) =
    inherit System.EventArgs()
    member this.X = x

    /// A type in a component designed for use from other .NET languages
type MyGoodType() =
    let myEv = new DelegateEvent<EventHandler<MyEventArgs>>()

    [<CLIEvent>]
    member this.MyEvent = myEv.Publish

Exposer des opérations asynchrones en tant que méthodes qui retournent des tâches .NET

Les tâches sont utilisées dans .NET pour représenter des calculs asynchrones actifs. Les tâches sont généralement moins compositionnelles que les objets F# Async<T>, car elles représentent des tâches « déjà en cours d’exécution » et ne peuvent pas être composées ensemble de manière à effectuer une composition parallèle ou à masquer la propagation des signaux d’annulation et d’autres paramètres contextuels.

Toutefois, malgré cela, les méthodes qui retournent des tâches sont la représentation standard de la programmation asynchrone sur .NET.

/// A type in a component designed for use from other .NET languages
type MyType() =

    let compute (x: int): Async<int> = async { ... }

    member this.ComputeAsync(x) = compute x |> Async.StartAsTask

Vous devrez souvent accepter un jeton d’annulation explicite :

/// A type in a component designed for use from other .NET languages
type MyType() =
    let compute(x: int): Async<int> = async { ... }
    member this.ComputeAsTask(x, cancellationToken) = Async.StartAsTask(compute x, cancellationToken)

Utiliser des types délégué .NET au lieu de types fonction F#

Ici, les « types fonction F# » signifient des types « flèche » comme int -> int.

Au lieu de cela :

member this.Transform(f: int->int) =
    ...

Procédez comme suit :

member this.Transform(f: Func<int,int>) =
    ...

Le type fonction F# apparaît sous la forme class FSharpFunc<T,U> pour d’autres langages .NET et est moins adapté aux fonctionnalités de langage et aux outils comprenant les types délégué. Lors de la création d’une méthode d’ordre supérieur ciblant le .NET Framework 3.5 ou ultérieur, les délégués System.Func et System.Action sont les bonnes API à publier pour permettre aux développeurs .NET d’utiliser ces API avec peu de friction. (Quand vous ciblez le .NET Framework 2.0, les types délégué définis par le système sont plus limités ; envisagez d’utiliser des types délégué prédéfinis tels que System.Converter<T,U> ou de définir un type délégué spécifique.)

D’un autre côté, les délégués .NET ne sont pas naturels pour les bibliothèques orientées F# (voir la section suivante sur les bibliothèques orientées F#). Par conséquent, une stratégie d’implémentation courante lors du développement de méthodes d’ordre supérieur pour des bibliothèques Vanilla .NET consiste à créer toute l’implémentation à l’aide de types fonction F#, puis à créer l’API publique à l’aide de délégués pour former une façade mince au-dessus de l’implémentation F# réelle.

Utiliser le modèle TryGetValue au lieu de retourner des valeurs d’options F#, et préférer la surcharge de méthode à la prise de valeurs d’options F# comme arguments

Les modèles d’utilisation courants pour le type option F# dans les API sont mieux implémentés dans les API Vanilla .NET à l’aide de techniques de conception .NET standard. Au lieu de retourner une valeur d’option F#, songez à utiliser le type de retour bool plus un paramètre out comme dans le modèle « TryGetValue ». De plus, au lieu de prendre les valeurs d’option F# comme paramètres, envisagez d’utiliser la surcharge de méthode ou des arguments facultatifs.

member this.ReturnOption() = Some 3

member this.ReturnBoolAndOut(outVal: byref<int>) =
    outVal <- 3
    true

member this.ParamOption(x: int, y: int option) =
    match y with
    | Some y2 -> x + y2
    | None -> x

member this.ParamOverload(x: int) = x

member this.ParamOverload(x: int, y: int) = x + y

Utiliser les types interface de collection .NET IEnumerable<T> et IDictionary<Key,Value> pour les paramètres et les valeurs de retour

Évitez d’utiliser des types collection concrets tels que les tableaux .NET T[], les types F# list<T>, Map<Key,Value> et Set<T> ainsi que les types collection concrets .NET tels que Dictionary<Key,Value>. Les instructions de conception de bibliothèques .NET donnent de bons conseils pour savoir quand utiliser des types collection comme IEnumerable<T>. L’utilisation de tableaux (T[]) est acceptable dans certains cas, pour des raisons de performances. Notez que seq<T> est uniquement l’alias F# de IEnumerable<T> et que seq est souvent un type approprié pour une API Vanilla .NET.

Au lieu d’utiliser des listes F# :

member this.PrintNames(names: string list) =
    ...

Utilisez des séquences F# :

member this.PrintNames(names: seq<string>) =
    ...

Utiliser le type d’unité comme seul type d’entrée d’une méthode pour définir une méthode sans argument ou comme seul type de retour pour définir une méthode retournant void

Évitez les autres utilisations du type unité. Exemples de bonne utilisation :

✔ member this.NoArguments() = 3

✔ member this.ReturnVoid(x: int) = ()

Exemple de mauvaise utilisation :

member this.WrongUnit( x: unit, z: int) = ((), ())

Rechercher des valeurs null sur les limites de l’API Vanilla .NET

Le code d’implémentation F# a tendance à avoir moins de valeurs null, en raison de modèles de conception immuables et de restrictions sur l’utilisation des littéraux null pour les types F#. D’autres langages .NET utilisent beaucoup plus fréquemment null comme valeur. Pour cette raison, le code F# qui expose une API Vanilla .NET doit vérifier les paramètres pour null au niveau de la limite de l’API et empêcher ces valeurs de circuler plus profondément dans le code d’implémentation F#. Vous pouvez utiliser la fonction isNull ou les critères spéciaux sur le modèle null.

let checkNonNull argName (arg: obj) =
    match arg with
    | null -> nullArg argName
    | _ -> ()

let checkNonNull` argName (arg: obj) =
    if isNull arg then nullArg argName
    else ()

Éviter d’utiliser des tuples comme valeurs de retour

Au lieu de cela, retournez un type nommé contenant les données agrégées ou utilisez des paramètres out pour retourner plusieurs valeurs. Bien que les tuples et les tuples struct existent dans .NET (le langage C# prend notamment en charge les tuples struct), ils ne fournissent généralement pas l’API idéale à laquelle s’attendent les développeurs .NET.

Éviter d’utiliser la curryfication des paramètres

Utilisez plutôt les conventions d’appel .NET Method(arg1,arg2,…,argN).

member this.TupledArguments(str, num) = String.replicate num str

Conseil : Si vous concevez des bibliothèques qui seront utilisées à partir d’autres langages .NET, rien ne remplace la programmation expérimentale en C# et Visual Basic pour vérifier que vos bibliothèques « se comportent bien » dans ces langages. Vous pouvez également utiliser des outils tels que .NET Reflector et l’Explorateur d’objets de Visual Studio pour vérifier que les bibliothèques et leur documentation sont présentées comme prévu aux développeurs.

Annexe

Exemple de bout en bout de conception d’un code F# en vue d’une utilisation par d’autres langages .NET

Considérez la classe suivante :

open System

type Point1(angle,radius) =
    new() = Point1(angle=0.0, radius=0.0)
    member x.Angle = angle
    member x.Radius = radius
    member x.Stretch(l) = Point1(angle=x.Angle, radius=x.Radius * l)
    member x.Warp(f) = Point1(angle=f(x.Angle), radius=x.Radius)
    static member Circle(n) =
        [ for i in 1..n -> Point1(angle=2.0*Math.PI/float(n), radius=1.0) ]

Le type F# déduit de cette classe est le suivant :

type Point1 =
    new : unit -> Point1
    new : angle:double * radius:double -> Point1
    static member Circle : n:int -> Point1 list
    member Stretch : l:double -> Point1
    member Warp : f:(double -> double) -> Point1
    member Angle : double
    member Radius : double

Examinons comment ce type F# est présenté à un programmeur utilisant un autre langage .NET. Par exemple, la « signature » C# approximative est la suivante :

// C# signature for the unadjusted Point1 class
public class Point1
{
    public Point1();

    public Point1(double angle, double radius);

    public static Microsoft.FSharp.Collections.List<Point1> Circle(int count);

    public Point1 Stretch(double factor);

    public Point1 Warp(Microsoft.FSharp.Core.FastFunc<double,double> transform);

    public double Angle { get; }

    public double Radius { get; }
}

Notez ici quelques points importants en ce qui concerne la façon dont F# représente les constructions. Par exemple :

  • Les métadonnées comme les noms d’arguments ont été conservées.

  • Les méthodes F# qui prennent deux arguments deviennent des méthodes C# qui prennent deux arguments.

  • Les fonctions et les listes deviennent des références aux types correspondants dans la bibliothèque F#.

Le code suivant montre comment ajuster ce code pour tenir compte de ces points.

namespace SuperDuperFSharpLibrary.Types

type RadialPoint(angle:double, radius:double) =

    /// Return a point at the origin
    new() = RadialPoint(angle=0.0, radius=0.0)

    /// The angle to the point, from the x-axis
    member x.Angle = angle

    /// The distance to the point, from the origin
    member x.Radius = radius

    /// Return a new point, with radius multiplied by the given factor
    member x.Stretch(factor) =
        RadialPoint(angle=angle, radius=radius * factor)

    /// Return a new point, with angle transformed by the function
    member x.Warp(transform:Func<_,_>) =
        RadialPoint(angle=transform.Invoke angle, radius=radius)

    /// Return a sequence of points describing an approximate circle using
    /// the given count of points
    static member Circle(count) =
        seq { for i in 1..count ->
                RadialPoint(angle=2.0*Math.PI/float(count), radius=1.0) }

Le type F# déduit du code est le suivant :

type RadialPoint =
    new : unit -> RadialPoint
    new : angle:double * radius:double -> RadialPoint
    static member Circle : count:int -> seq<RadialPoint>
    member Stretch : factor:double -> RadialPoint
    member Warp : transform:System.Func<double,double> -> RadialPoint
    member Angle : double
    member Radius : double

La signature C# est désormais la suivante :

public class RadialPoint
{
    public RadialPoint();

    public RadialPoint(double angle, double radius);

    public static System.Collections.Generic.IEnumerable<RadialPoint> Circle(int count);

    public RadialPoint Stretch(double factor);

    public RadialPoint Warp(System.Func<double,double> transform);

    public double Angle { get; }

    public double Radius { get; }
}

Les correctifs apportés pour préparer ce type en vue d’une utilisation dans le cadre d’une bibliothèque Vanilla .NET sont les suivants :

  • Ajustement de plusieurs noms : Point1, n, l et f ont été remplacés respectivement par RadialPoint, count, factor et transform.

  • Utilisation du type de retour seq<RadialPoint> au lieu de RadialPoint list en remplaçant une construction de liste avec [ ... ] par une construction de séquence avec IEnumerable<RadialPoint>.

  • Utilisation du type délégué .NET System.Func au lieu d’un type fonction F#.

La consommation dans le code C# est ainsi beaucoup plus agréable.