Meilleure approche pour concevoir des bibliothèques F # à utiliser à la fois à partir de F # et C #

113

J'essaye de concevoir une bibliothèque en F #. La bibliothèque doit être conviviale pour une utilisation à la fois en F # et C # .

Et c'est là que je suis un peu coincé. Je peux le rendre compatible F #, ou je peux le rendre compatible C #, mais le problème est de savoir comment le rendre convivial pour les deux.

Voici un exemple. Imaginez que j'ai la fonction suivante en F #:

let compose (f: 'T -> 'TResult) (a : 'TResult -> unit) = f >> a

Ceci est parfaitement utilisable à partir de F #:

let useComposeInFsharp() =
    let composite = compose (fun item -> item.ToString) (fun item -> printfn "%A" item)
    composite "foo"
    composite "bar"

En C #, la composefonction a la signature suivante:

FSharpFunc<T, Unit> compose<T, TResult>(FSharpFunc<T, TResult> f, FSharpFunc<TResult, Unit> a);

Mais bien sûr, je ne veux pas FSharpFuncdans la signature, ce que je veux c'est Funcet à la Actionplace, comme ceci:

Action<T> compose2<T, TResult>(Func<T, TResult> f, Action<TResult> a);

Pour y parvenir, je peux créer une compose2fonction comme celle-ci:

let compose2 (f: Func<'T, 'TResult>) (a : Action<'TResult> ) = 
    new Action<'T>(f.Invoke >> a.Invoke)

Maintenant, c'est parfaitement utilisable en C #:

void UseCompose2FromCs()
{
    compose2((string s) => s.ToUpper(), Console.WriteLine);
}

Mais maintenant, nous avons un problème avec l'utilisation compose2de F #! Maintenant, je dois envelopper tous les F # standard funsdans Funcet Action, comme ceci:

let useCompose2InFsharp() =
    let f = Func<_,_>(fun item -> item.ToString())
    let a = Action<_>(fun item -> printfn "%A" item)
    let composite2 = compose2 f a

    composite2.Invoke "foo"
    composite2.Invoke "bar"

La question: comment pouvons-nous obtenir une expérience de première classe pour la bibliothèque écrite en F # pour les utilisateurs F # et C #?

Jusqu'à présent, je ne pouvais rien proposer de mieux que ces deux approches:

  1. Deux assemblys distincts: l'un destiné aux utilisateurs F # et le second aux utilisateurs C #.
  2. Un assembly mais des espaces de noms différents: un pour les utilisateurs F # et le second pour les utilisateurs C #.

Pour la première approche, je ferais quelque chose comme ceci:

  1. Créez un projet F #, appelez-le FooBarFs et compilez-le dans FooBarFs.dll.

    • Ciblez la bibliothèque uniquement aux utilisateurs F #.
    • Cachez tout ce qui n'est pas nécessaire dans les fichiers .fsi.
  2. Créez un autre projet F #, appelez if FooBarCs et compilez-le dans FooFar.dll

    • Réutilisez le premier projet F # au niveau source.
    • Créez un fichier .fsi qui cache tout de ce projet.
    • Créez un fichier .fsi qui expose la bibliothèque de manière C #, en utilisant des idiomes C # pour le nom, les espaces de noms, etc.
    • Créez des wrappers qui délèguent à la bibliothèque principale, en effectuant la conversion si nécessaire.

Je pense que la deuxième approche avec les espaces de noms peut être déroutante pour les utilisateurs, mais alors vous avez un assemblage.

La question: aucun de ceux-ci n'est idéal, il me manque peut-être une sorte d'indicateur / commutateur / attribut de compilateur ou une sorte d'astuce et il y a une meilleure façon de faire cela?

La question: quelqu'un d'autre a-t-il essayé de réaliser quelque chose de similaire et si oui, comment l'avez-vous fait?

EDIT: pour clarifier, la question ne concerne pas seulement les fonctions et les délégués, mais l'expérience globale d'un utilisateur C # avec une bibliothèque F #. Cela inclut les espaces de noms, les conventions de dénomination, les expressions idiomatiques et autres qui sont natifs de C #. Fondamentalement, un utilisateur C # ne devrait pas être en mesure de détecter que la bibliothèque a été créée en F #. Et vice versa, un utilisateur F # devrait avoir envie de travailler avec une bibliothèque C #.


MODIFIER 2:

Je peux voir d'après les réponses et les commentaires jusqu'à présent que ma question n'a pas la profondeur nécessaire, peut-être principalement en raison de l'utilisation d'un seul exemple où des problèmes d'interopérabilité entre F # et C # se posent, la question des valeurs de fonction. Je pense que c'est l'exemple le plus évident et cela m'a donc amené à l'utiliser pour poser la question, mais du même coup a donné l'impression que c'est la seule question qui me préoccupe.

Permettez-moi de donner des exemples plus concrets. J'ai lu le plus excellent document F # Component Design Guidelines (merci beaucoup @gradbot pour cela!). Les lignes directrices du document, si elles sont utilisées, abordent certains des problèmes, mais pas tous.

Le document est divisé en deux parties principales: 1) directives pour cibler les utilisateurs F #; et 2) des directives pour cibler les utilisateurs C #. Nulle part il n'essaye même de prétendre qu'il est possible d'avoir une approche uniforme, qui fait exactement écho à ma question: on peut cibler F #, on peut cibler C #, mais quelle est la solution pratique pour cibler les deux?

Pour rappel, l'objectif est d'avoir une bibliothèque créée en F #, et qui peut être utilisée de manière idiomatique à partir des langages F # et C #.

Le mot-clé ici est idiomatique . Le problème n'est pas l'interopérabilité générale où il est simplement possible d'utiliser des bibliothèques dans différentes langues.

Passons maintenant aux exemples, que je tire directement des Directives de conception de composants F # .

  1. Modules + fonctions (F #) vs espaces de noms + types + fonctions

    • F #: utilisez des espaces de noms ou des modules pour contenir vos types et modules. L'utilisation idiomatique est de placer des fonctions dans des modules, par exemple:

      // library
      module Foo
      let bar() = ...
      let zoo() = ...
      
      
      // Use from F#
      open Foo
      bar()
      zoo()
    • C #: utilisez les espaces de noms, les types et les membres comme structure organisationnelle principale pour vos composants (par opposition aux modules), pour les API .NET vanilla.

      Ceci est incompatible avec la directive F #, et l'exemple devrait être réécrit pour s'adapter aux utilisateurs C #:

      [<AbstractClass; Sealed>]
      type Foo =
          static member bar() = ...
          static member zoo() = ...

      En faisant cela, nous cassons l'utilisation idiomatique de F # car nous ne pouvons plus l'utiliser baret zoosans le préfixer avec Foo.

  2. Utilisation de tuples

    • F #: utilisez des tuples lorsque cela est approprié pour les valeurs de retour.

    • C #: évitez d'utiliser des tuples comme valeurs de retour dans les API .NET vanilla.

  3. Async

    • F #: utilisez Async pour la programmation asynchrone aux limites de l'API F #.

    • C #: exposez les opérations asynchrones à l'aide du modèle de programmation asynchrone .NET (BeginFoo, EndFoo), ou en tant que méthodes renvoyant des tâches .NET (Task), plutôt qu'en tant qu'objets F # Async.

  4. Utilisation de Option

    • F #: envisagez d'utiliser des valeurs d'option pour les types de retour au lieu de déclencher des exceptions (pour le code F #).

    • Envisagez d'utiliser le modèle TryGetValue au lieu de renvoyer les valeurs d'option F # (option) dans les API .NET vanilla, et préférez la surcharge de méthode à la prise des valeurs d'option F # comme arguments.

  5. Syndicats discriminés

    • F #: Utilisez des unions discriminées comme alternative aux hiérarchies de classes pour créer des données arborescentes

    • C #: pas de directives spécifiques pour cela, mais le concept d'unions discriminées est étranger à C #

  6. Fonctions au curry

    • F #: les fonctions curry sont idiomatiques pour F #

    • C #: n'utilisez pas le currying des paramètres dans les API .NET vanilla.

  7. Vérification des valeurs nulles

    • F #: ce n'est pas idiomatique pour F #

    • C #: envisagez de vérifier les valeurs nulles sur les limites de l'API .NET vanilla.

  8. L' utilisation de F types de # list, map, set, etc.

    • F #: il est idiomatique de les utiliser en F #

    • C #: envisagez d'utiliser les types d'interface de collection .NET IEnumerable et IDictionary pour les paramètres et les valeurs de retour dans les API .NET vanilla. ( Ie ne pas utiliser F # list, map,set )

  9. Types de fonction (le plus évident)

    • F #: l' utilisation des fonctions F # comme valeurs est idiomatique pour F #, évidemment

    • C #: utilisez les types de délégués .NET de préférence aux types de fonctions F # dans les API .NET vanilla.

Je pense que cela devrait suffire à démontrer la nature de ma question.

Incidemment, les lignes directrices ont également une réponse partielle:

... une stratégie d'implémentation courante lors du développement de méthodes d'ordre supérieur pour les bibliothèques .NET vanilla est de créer toute l'implémentation à l'aide de types de fonction F #, puis de créer l'API publique en utilisant des délégués comme une façade mince au-dessus de l'implémentation F # réelle.

Pour résumer.

Il y a une réponse définitive: il n'y a pas d'astuces de compilation que j'ai manquées .

Selon le document de directives, il semble que la création pour F # d'abord, puis la création d'un wrapper de façade pour .NET soit une stratégie raisonnable.

Reste alors la question de la mise en œuvre pratique de ceci:

  • Assemblages séparés? ou

  • Différents espaces de noms?

Si mon interprétation est correcte, Tomas suggère que l'utilisation d'espaces de noms séparés devrait être suffisante et devrait être une solution acceptable.

Je pense que je serai d'accord avec cela étant donné que le choix des espaces de noms est tel qu'il ne surprend ni ne confond les utilisateurs .NET / C #, ce qui signifie que l'espace de noms pour eux devrait probablement ressembler à l'espace de noms principal pour eux. Les utilisateurs F # devront assumer le fardeau de choisir un espace de noms spécifique à F #. Par exemple:

  • FSharp.Foo.Bar -> espace de noms pour F # face à la bibliothèque

  • Foo.Bar -> namespace pour le wrapper .NET, idiomatique pour C #

Philippe P.
la source
Hormis la transmission de fonctions de premier ordre, quelles sont vos préoccupations? F # permet déjà de fournir une expérience transparente à partir d'autres langues.
Daniel
Re: votre modification, je ne sais toujours pas pourquoi vous pensez qu'une bibliothèque créée en F # apparaîtrait non standard à partir de C #. Pour la plupart, F # fournit un sur-ensemble de fonctionnalités C #. Rendre une bibliothèque naturelle est principalement une question de convention, ce qui est vrai quelle que soit la langue que vous utilisez. Tout cela pour dire, F # fournit tous les outils dont vous avez besoin pour ce faire.
Daniel
Honnêtement, même si vous incluez un exemple de code, vous posez une question assez ouverte. Vous posez des questions sur «l'expérience globale d'un utilisateur C # avec une bibliothèque F #», mais le seul exemple que vous donnez est quelque chose qui n'est vraiment pas bien pris en charge dans aucun langage impératif. Traiter les fonctions comme des citoyens de première classe est un principe assez central de la programmation fonctionnelle - qui correspond mal à la plupart des langages impératifs.
Onorio Catenacci
2
@KomradeP: concernant votre EDIT 2, FSharpx fournit des moyens pour rendre l'interopérabilité plus simple. Vous pouvez trouver quelques indices dans cet excellent article de blog .
pad

Réponses:

40

Daniel a déjà expliqué comment définir une version compatible C # de la fonction F # que vous avez écrite, je vais donc ajouter quelques commentaires de plus haut niveau. Tout d'abord, vous devez lire les directives de conception de composants F # (déjà référencées par gradbot). Il s'agit d'un document qui explique comment concevoir des bibliothèques F # et .NET à l'aide de F # et il devrait répondre à beaucoup de vos questions.

Lorsque vous utilisez F #, il existe essentiellement deux types de bibliothèques que vous pouvez écrire:

  • La bibliothèque F # est conçue pour être utilisée uniquement à partir de F #, donc son interface publique est écrite dans un style fonctionnel (en utilisant des types de fonctions F #, des tuples, des unions discriminées, etc.)

  • La bibliothèque .NET est conçue pour être utilisée à partir de n'importe quel langage .NET (y compris C # et F #) et elle suit généralement le style orienté objet .NET. Cela signifie que vous exposerez la plupart des fonctionnalités sous forme de classes avec méthode (et parfois des méthodes d'extension ou des méthodes statiques, mais la plupart du temps, le code doit être écrit dans la conception OO).

Dans votre question, vous demandez comment exposer la composition de fonctions en tant que bibliothèque .NET, mais je pense que des fonctions comme la vôtre composesont des concepts de trop bas niveau du point de vue de la bibliothèque .NET. Vous pouvez les exposer en tant que méthodes fonctionnant avec Funcet Action, mais ce n'est probablement pas ainsi que vous concevriez une bibliothèque .NET normale en premier lieu (vous utiliseriez peut-être le modèle Builder à la place ou quelque chose du genre).

Dans certains cas (c'est-à-dire lors de la conception de bibliothèques numériques qui ne correspondent pas vraiment au style de bibliothèque .NET), il est judicieux de concevoir une bibliothèque qui mélange les styles F # et .NET dans une seule bibliothèque. La meilleure façon de le faire est d'avoir une API F # normale (ou .NET normale), puis de fournir des wrappers pour une utilisation naturelle dans l'autre style. Les wrappers peuvent être dans un espace de noms séparé (comme MyLibrary.FSharpet MyLibrary).

Dans votre exemple, vous pouvez laisser l'implémentation F # dans MyLibrary.FSharp, puis ajouter des wrappers .NET (C #-friendly) (similaires au code que Daniel a publié) dans l' MyLibraryespace de noms en tant que méthode statique d'une classe. Mais encore une fois, la bibliothèque .NET aurait probablement une API plus spécifique que la composition de fonctions.

Tomas Petricek
la source
1
Les directives de conception des composants F # sont maintenant hébergées ici
Jan Schiefer
19

Vous n'avez qu'à encapsuler les valeurs de fonction ( fonctions partiellement appliquées, etc.) avec Funcou Action, le reste est converti automatiquement. Par exemple:

type A(arg) =
  member x.Invoke(f: Func<_,_>) = f.Invoke(arg)

let a = A(1)
a.Invoke(fun i -> i + 1)

Il est donc logique d'utiliser Func/ le Actioncas échéant. Est-ce que cela élimine vos préoccupations? Je pense que les solutions que vous proposez sont trop compliquées. Vous pouvez écrire toute votre bibliothèque en F # et l'utiliser sans douleur à partir de F # et C # (je le fais tout le temps).

De plus, F # est plus flexible que C # en termes d'interopérabilité, il est donc généralement préférable de suivre le style .NET traditionnel lorsque cela pose un problème.

ÉDITER

Le travail nécessaire pour créer deux interfaces publiques dans des espaces de noms séparés, je pense, n'est justifié que lorsqu'ils sont complémentaires ou que la fonctionnalité F # n'est pas utilisable à partir de C # (comme les fonctions intégrées, qui dépendent des métadonnées spécifiques à F #).

Reprenez vos points à tour de rôle:

  1. Module + letliaisons et type sans constructeur + membres statiques apparaissent exactement les mêmes en C #, alors optez pour des modules si vous le pouvez. Vous pouvez utiliser CompiledNameAttributepour donner aux membres des noms compatibles C #.

  2. Je me trompe peut-être, mais je suppose que les directives sur les composants ont été rédigées avant d' System.Tupleêtre ajoutées au cadre. (Dans les versions antérieures, F # définissait son propre type de tuple.) Il est depuis devenu plus acceptable de l'utiliser Tupledans une interface publique pour les types triviaux.

  3. C'est là que je pense que vous devez faire les choses à la manière C # parce que F # joue bien avec Taskmais C # ne joue pas bien avec Async. Vous pouvez utiliser async en interne puis appeler Async.StartAsTaskavant de revenir d'une méthode publique.

  4. L'adoption de nullpeut être le plus gros inconvénient lors du développement d'une API à utiliser à partir de C #. Dans le passé, j'ai essayé toutes sortes d'astuces pour éviter de considérer null dans le code F # interne mais, à la fin, il était préférable de marquer les types avec des constructeurs publics avec [<AllowNullLiteral>]et de vérifier les arguments pour null. Ce n'est pas pire que C # à cet égard.

  5. Les unions discriminées sont généralement compilées en hiérarchies de classes mais ont toujours une représentation relativement conviviale en C #. Je dirais, marquez-les [<AllowNullLiteral>]et utilisez-les.

  6. Les fonctions curry produisent des valeurs de fonction , qui ne doivent pas être utilisées.

  7. J'ai trouvé qu'il valait mieux embrasser null que de dépendre de sa capture à l'interface publique et de l'ignorer en interne. YMMV.

  8. Il est très judicieux d'utiliser list/ map/ en setinterne. Ils peuvent tous être exposés via l'interface publique au format IEnumerable<_>. En outre, seq, dictet Seq.readonlysont souvent utiles.

  9. Voir # 6.

La stratégie que vous adoptez dépend du type et de la taille de votre bibliothèque mais, d'après mon expérience, trouver le juste milieu entre F # et C # nécessitait moins de travail - à long terme - que de créer des API distinctes.

Daniel
la source
oui je vois qu'il y a des moyens de faire fonctionner les choses, je ne suis pas entièrement convaincu. Re modules. Si vous avez module Foo.Bar.Zooalors en C #, ce sera class Foodans l'espace de noms Foo.Bar. Cependant, si vous avez des modules imbriqués comme module Foo=... module Bar=... module Zoo==, cela générera une classe Fooavec des classes internes Baret Zoo. Re IEnumerable, cela peut ne pas être acceptable lorsque nous avons vraiment besoin de travailler avec des cartes et des ensembles. Re nullvaleurs et AllowNullLiteral- une des raisons pour lesquelles j'aime F # est que j'en ai marre nullen C #, je ne voudrais vraiment pas polluer tout avec à nouveau!
Philip P.
Préférez généralement les espaces de noms aux modules imbriqués pour l'interopérabilité. Re: null, je suis d'accord avec vous, c'est certainement une régression à considérer, mais quelque chose que vous devez gérer si votre API sera utilisée à partir de C #.
Daniel
2

Bien que ce soit probablement exagéré, vous pourriez envisager d'écrire une application utilisant Mono.Cecil (il a un support impressionnant sur la liste de diffusion) qui automatiserait la conversion au niveau IL. Par exemple, si vous implémentez votre assembly en F #, à l'aide de l'API publique de style F #, l'outil générerait un wrapper compatible C # dessus.

Par exemple, en F #, vous utiliseriez évidemment option<'T>( None, spécifiquement) au lieu d'utiliser nullcomme en C #. L'écriture d'un générateur de wrapper pour ce scénario devrait être assez simple: la méthode wrapper invoquerait la méthode d'origine: si sa valeur de retour était Some x, alors return x, sinon return null.

Vous auriez besoin de gérer le cas où Test un type valeur, c'est-à-dire non nullable; vous devrez envelopper la valeur de retour de la méthode wrapper Nullable<T>, ce qui la rend un peu pénible.

Encore une fois, je suis tout à fait certain qu'il serait payant d'écrire un tel outil dans votre scénario, peut-être sauf si vous travaillerez régulièrement sur cette bibliothèque (utilisable de manière transparente à partir de F # et C #) régulièrement. En tout cas, je pense que ce serait une expérience intéressante, que je pourrais même explorer un jour.

ShdNx
la source
ça a l'air intéressant. Est-ce que l'utilisation Mono.Cecilsignifierait essentiellement écrire un programme pour charger l'assembly F #, trouver des éléments nécessitant un mappage et émettre un autre assembly avec les wrappers C #? Ai-je bien compris?
Philip P.
@Komrade P.: Vous pouvez le faire aussi, mais c'est beaucoup plus compliqué, car vous devrez alors ajuster toutes les références pour qu'elles pointent vers les membres de la nouvelle assemblée. C'est beaucoup plus simple si vous ajoutez simplement les nouveaux membres (les wrappers) à l'assembly écrit en F # existant.
ShdNx
2

Projet de lignes directrices pour la conception des composants F # (août 2010)

Aperçu Ce document examine certains des problèmes liés à la conception et au codage des composants F #. En particulier, il couvre:

  • Directives pour la conception de bibliothèques .NET «vanille» à utiliser à partir de n'importe quel langage .NET.
  • Directives pour les bibliothèques F # -to-F # et le code d'implémentation F #.
  • Suggestions sur les conventions de codage pour le code d'implémentation F #
gradbot
la source