Approccio migliore per la progettazione di librerie F # da utilizzare sia da F # che da C #


113

Sto cercando di progettare una libreria in F #. La libreria dovrebbe essere facile da usare sia da F # che da C # .

Ed è qui che sono bloccato un po '. Posso renderlo compatibile con F # o renderlo compatibile con C #, ma il problema è come renderlo amichevole per entrambi.

Ecco un esempio. Immagina di avere la seguente funzione in F #:

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

Questo è perfettamente utilizzabile da F #:

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

In C #, la composefunzione ha la seguente firma:

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

Ma ovviamente, non voglio FSharpFuncnella firma, quello che voglio è Funce Actioninvece, in questo modo:

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

Per ottenere ciò, posso creare compose2funzioni come questa:

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

Ora, questo è perfettamente utilizzabile in C #:

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

Ma ora abbiamo un problema usando compose2da F #! Ora devo racchiudere tutto lo standard F # funsin Funce Action, in questo modo:

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 domanda: come possiamo ottenere un'esperienza di prima classe per la libreria scritta in F # sia per gli utenti F # che per quelli C #?

Finora, non sono riuscito a trovare niente di meglio di questi due approcci:

  1. Due assembly separati: uno destinato agli utenti di F # e il secondo agli utenti di C #.
  2. Un assembly ma spazi dei nomi diversi: uno per gli utenti di F # e il secondo per gli utenti di C #.

Per il primo approccio, farei qualcosa del genere:

  1. Creare un progetto F #, chiamarlo FooBarFs e compilarlo in FooBarFs.dll.

    • Indirizza la libreria esclusivamente agli utenti F #.
    • Nascondi tutto ciò che non è necessario dai file .fsi.
  2. Creare un altro progetto F #, chiamare if FooBarCs e compilarlo in FooFar.dll

    • Riutilizzare il primo progetto F # a livello di origine.
    • Crea file .fsi che nasconde tutto da quel progetto.
    • Crea un file .fsi che esponga la libreria in modo C #, utilizzando idiomi C # per nome, spazi dei nomi, ecc.
    • Crea wrapper che delegano alla libreria principale, eseguendo la conversione dove necessario.

Penso che il secondo approccio con gli spazi dei nomi possa creare confusione per gli utenti, ma poi hai un assembly.

La domanda: nessuno di questi è l'ideale, forse mi manca qualche tipo di flag / switch / attributo del compilatore o qualche tipo di trucco e c'è un modo migliore per farlo?

La domanda: qualcun altro ha provato a realizzare qualcosa di simile e se sì come hai fatto?

EDIT: per chiarire, la domanda non riguarda solo funzioni e delegati, ma l'esperienza complessiva di un utente C # con una libreria F #. Ciò include spazi dei nomi, convenzioni di denominazione, espressioni idiomatiche e simili che sono nativi di C #. Fondamentalmente, un utente C # non dovrebbe essere in grado di rilevare che la libreria è stata creata in F #. E viceversa, un utente F # dovrebbe avere la sensazione di avere a che fare con una libreria C #.


MODIFICA 2:

Posso vedere dalle risposte e dai commenti finora che la mia domanda manca della profondità necessaria, forse principalmente a causa dell'uso di un solo esempio in cui sorgono problemi di interoperabilità tra F # e C #, la questione dei valori delle funzioni. Penso che questo sia l'esempio più ovvio e quindi questo mi ha portato a usarlo per porre la domanda, ma per lo stesso motivo ha dato l'impressione che questo sia l'unico problema che mi interessa.

Consentitemi di fornire esempi più concreti. Ho letto il documento F # Component Design Guidelines più eccellente (molte grazie @gradbot per questo!). Le linee guida nel documento, se utilizzate, affrontano alcuni dei problemi ma non tutti.

Il documento è suddiviso in due parti principali: 1) linee guida per il targeting degli utenti F #; e 2) linee guida per il targeting degli utenti C #. Da nessuna parte tenta nemmeno di fingere che sia possibile avere un approccio uniforme, che riecheggia esattamente la mia domanda: possiamo targetizzare F #, possiamo targetizzare C #, ma qual è la soluzione pratica per targetizzare entrambi?

Per ricordare, l'obiettivo è avere una libreria creata in F # e che può essere utilizzata in modo idiomatico da entrambi i linguaggi F # e C #.

La parola chiave qui è idiomatica . Il problema non è l'interoperabilità generale in cui è solo possibile utilizzare librerie in lingue diverse.

Passiamo ora agli esempi, che prendo direttamente dalle linee guida per la progettazione di componenti F # .

  1. Moduli + funzioni (F #) vs spazi dei nomi + tipi + funzioni

    • F #: usa spazi dei nomi o moduli per contenere i tuoi tipi e moduli. L'uso idiomatico è inserire funzioni nei moduli, ad esempio:

      // library
      module Foo
      let bar() = ...
      let zoo() = ...
      
      
      // Use from F#
      open Foo
      bar()
      zoo()
    • C #: usa spazi dei nomi, tipi e membri come struttura organizzativa principale per i tuoi componenti (al contrario dei moduli), per le API .NET vanilla.

      Ciò non è compatibile con le linee guida di F # e l'esempio dovrebbe essere riscritto per adattarsi agli utenti C #:

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

      In questo modo, tuttavia, interrompiamo l'uso idiomatico di F # perché non possiamo più usarlo bare zoosenza prefissarlo con Foo.

  2. Uso di tuple

    • F #: usa le tuple quando appropriato per i valori restituiti.

    • C #: evitare di usare tuple come valori restituiti nelle API .NET vanilla.

  3. Async

    • F #: usa Async per la programmazione asincrona ai limiti dell'API F #.

    • C #: esporre operazioni asincrone utilizzando il modello di programmazione asincrona .NET (BeginFoo, EndFoo) o come metodi che restituiscono attività .NET (attività), anziché come oggetti asincroni F #.

  4. Uso di Option

    • F #: prendere in considerazione l'utilizzo di valori di opzione per i tipi restituiti invece di generare eccezioni (per il codice di affiancamento F #).

    • Prendi in considerazione l'utilizzo del pattern TryGetValue invece di restituire i valori dell'opzione F # (opzione) nelle API .NET vanilla e preferisci il sovraccarico del metodo rispetto all'assunzione dei valori dell'opzione F # come argomenti.

  5. Unioni discriminate

    • F #: utilizzare unioni discriminate come alternativa alle gerarchie di classi per la creazione di dati strutturati ad albero

    • C #: nessuna linea guida specifica per questo, ma il concetto di unioni discriminate è estraneo a C #

  6. Funzioni al curry

    • F #: le funzioni al curry sono idiomatiche per F #

    • C #: non utilizzare il currying dei parametri nelle API .NET vanilla.

  7. Verifica di valori nulli

    • F #: questo non è idiomatico per F #

    • C #: valutare la possibilità di verificare la presenza di valori null sui limiti dell'API .NET vanilla.

  8. L'utilizzo di F # tipi list, map, set, ecc

    • F #: è idiomatico usarli in F #

    • C #: prendere in considerazione l'utilizzo dei tipi di interfaccia della raccolta .NET IEnumerable e IDictionary per i parametri e i valori restituiti nelle API .NET vanilla. ( Cioè non usare F # list, map,set )

  9. Tipi di funzione (quella ovvia)

    • F #: l' uso delle funzioni F # come valori è idiomatico per F #, ovviamente

    • C #: utilizzare i tipi delegati .NET rispetto ai tipi di funzione F # nelle API .NET vanilla.

Penso che dovrebbero essere sufficienti per dimostrare la natura della mia domanda.

Per inciso, le linee guida hanno anche una risposta parziale:

... una strategia di implementazione comune quando si sviluppano metodi di ordine superiore per le librerie .NET vanilla è quella di creare tutta l'implementazione utilizzando i tipi di funzione F #, quindi creare l'API pubblica utilizzando i delegati come una sottile facciata sopra l'implementazione F # effettiva.

Riassumere.

C'è una risposta definitiva: non ci sono trucchi del compilatore che mi sono perso .

Secondo il documento delle linee guida, sembra che creare prima per F # e poi creare un involucro di facciata per .NET sia una strategia ragionevole.

La domanda rimane quindi per quanto riguarda l'attuazione pratica di questo:

  • Assemblee separate? o

  • Spazi dei nomi diversi?

Se la mia interpretazione è corretta, Tomas suggerisce che l'uso di spazi dei nomi separati dovrebbe essere sufficiente e dovrebbe essere una soluzione accettabile.

Penso di essere d'accordo con questo dato che la scelta degli spazi dei nomi è tale da non sorprendere o confondere gli utenti .NET / C #, il che significa che lo spazio dei nomi per loro dovrebbe probabilmente apparire come lo spazio dei nomi principale per loro. Gli utenti di F # dovranno assumersi l'onere di scegliere lo spazio dei nomi specifico di F #. Per esempio:

  • FSharp.Foo.Bar -> spazio dei nomi per F # rivolto verso la libreria

  • Foo.Bar -> spazio dei nomi per .NET wrapper, idiomatico per C #


8
Un po 'di
rilievo

Oltre a trasmettere funzioni di prima classe, quali sono le tue preoccupazioni? F # fa già molto per fornire un'esperienza senza interruzioni da altre lingue.
Daniel

Per quanto riguarda la tua modifica, non sono ancora chiaro il motivo per cui pensi che una libreria creata in F # apparirebbe non standard da C #. Per la maggior parte, F # fornisce un superset di funzionalità C #. Rendere naturale una biblioteca è principalmente una questione di convenzione, il che è vero indipendentemente dalla lingua che stai usando. Tutto ciò per dire, F # fornisce tutti gli strumenti necessari per farlo.
Daniel

Onestamente, anche se includi un esempio di codice, stai facendo una domanda abbastanza aperta. Chiedete "l'esperienza complessiva di un utente C # con una libreria F #" ma l'unico esempio che fornite è qualcosa che in realtà non è supportato bene in nessun linguaggio imperativo. Trattare le funzioni come cittadini di prima classe è un principio centrale della programmazione funzionale, che si associa male alla maggior parte dei linguaggi imperativi.
Onorio Catenacci

2
@KomradeP .: per quanto riguarda la tua EDIT 2, FSharpx fornisce alcuni mezzi per rendere più apparente l' interoperabilità. Potresti trovare alcuni suggerimenti in questo eccellente post sul blog .
pad

Risposte:


40

Daniel ha già spiegato come definire una versione compatibile con C # della funzione F # che hai scritto, quindi aggiungerò alcuni commenti di livello superiore. Prima di tutto, dovresti leggere le linee guida per la progettazione dei componenti F # (a cui fa già riferimento gradbot). Questo è un documento che spiega come progettare librerie F # e .NET usando F # e dovrebbe rispondere a molte delle tue domande.

Quando si utilizza F #, esistono fondamentalmente due tipi di librerie che è possibile scrivere:

  • La libreria F # è progettata per essere utilizzata solo da F #, quindi la sua interfaccia pubblica è scritta in uno stile funzionale (utilizzando tipi di funzione F #, tuple, unioni discriminate ecc.)

  • La libreria .NET è progettata per essere utilizzata da qualsiasi linguaggio .NET (inclusi C # e F #) e in genere segue lo stile .NET orientato agli oggetti. Ciò significa che esporrai la maggior parte delle funzionalità come classi con metodo (e talvolta metodi di estensione o metodi statici, ma principalmente il codice dovrebbe essere scritto nella progettazione OO).

Nella tua domanda, stai chiedendo come esporre la composizione di funzioni come una libreria .NET, ma penso che funzioni come la tua composesiano concetti di livello troppo basso dal punto di vista della libreria .NET. Puoi esporli come metodi che lavorano con Funce Action, ma probabilmente non è così che progetteresti una normale libreria .NET in primo luogo (forse useresti invece il modello Builder o qualcosa del genere).

In alcuni casi (ad esempio, quando si progettano librerie numeriche che non si adattano veramente bene allo stile della libreria .NET), ha senso progettare una libreria che mescoli gli stili F # e .NET in una singola libreria. Il modo migliore per eseguire questa operazione è disporre di una normale API F # (o .NET normale) e quindi fornire wrapper per un utilizzo naturale nell'altro stile. I wrapper possono trovarsi in uno spazio dei nomi separato (come MyLibrary.FSharpe MyLibrary).

Nel tuo esempio, potresti lasciare l'implementazione di F # MyLibrary.FSharpe quindi aggiungere wrapper .NET (C # -friendly) (simili al codice che Daniel ha pubblicato) nello MyLibraryspazio dei nomi come metodo statico di qualche classe. Ma ancora una volta, la libreria .NET avrebbe probabilmente un'API più specifica rispetto alla composizione delle funzioni.


1
Le linee guida per la progettazione dei componenti F # sono ora ospitate qui
Jan Schiefer

19

Devi solo racchiudere i valori delle funzioni ( funzioni parzialmente applicate, ecc.) Con Funco Action, il resto viene convertito automaticamente. Per esempio:

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

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

Quindi ha senso usare Func/ Actiondove applicabile. Questo elimina le tue preoccupazioni? Penso che le tue soluzioni proposte siano eccessivamente complicate. Puoi scrivere l'intera libreria in F # e usarla senza problemi da F # e C # (lo faccio sempre).

Inoltre, F # è più flessibile di C # in termini di interoperabilità, quindi è generalmente meglio seguire lo stile .NET tradizionale quando questo è un problema.

MODIFICARE

Il lavoro richiesto per creare due interfacce pubbliche in spazi dei nomi separati, credo, è garantito solo quando sono complementari o la funzionalità F # non è utilizzabile da C # (come le funzioni inline, che dipendono da metadati specifici di F #).

Prendendo i tuoi punti a turno:

  1. Modulo + letassociazioni e tipo senza costruttore + membri statici appaiono esattamente gli stessi in C #, quindi vai con i moduli se puoi. È possibile utilizzare CompiledNameAttributeper assegnare ai membri nomi compatibili con C #.

  2. Potrei sbagliarmi, ma la mia ipotesi è che le linee guida sui componenti siano state scritte prima di System.Tupleessere aggiunte al framework. (Nelle versioni precedenti F # definiva il proprio tipo di tupla.) Da allora è diventato più accettabile l'uso Tuplein un'interfaccia pubblica per tipi banali.

  3. È qui che penso che tu debba fare le cose in modo C # perché F # suona bene Taskma C # non suona bene Async. È possibile utilizzare internamente asincrono quindi chiamare Async.StartAsTaskprima di tornare da un metodo pubblico.

  4. L'abbraccio di nullpotrebbe essere il singolo più grande svantaggio durante lo sviluppo di un'API da utilizzare da C #. In passato, ho provato tutti i tipi di trucchi per evitare di considerare null nel codice F # interno ma, alla fine, era meglio contrassegnare i tipi con costruttori pubblici con [<AllowNullLiteral>]e controllare gli arg per null. Non è peggio di C # in questo senso.

  5. Le unioni discriminate sono generalmente compilate in gerarchie di classi ma hanno sempre una rappresentazione relativamente amichevole in C #. Direi, contrassegnali con [<AllowNullLiteral>]e usali.

  6. Le funzioni curry producono valori di funzione , che non dovrebbero essere usati.

  7. Ho scoperto che era meglio abbracciare null che dipendere dal fatto che fosse catturato nell'interfaccia pubblica e ignorarlo internamente. YMMV.

  8. Ha molto senso usare list/ map/ setinternamente. Possono essere tutti esposti tramite l'interfaccia pubblica come IEnumerable<_>. Inoltre, seq, dict, e Seq.readonlysono spesso utili.

  9. Vedi # 6.

La strategia da adottare dipende dal tipo e dalle dimensioni della libreria ma, secondo la mia esperienza, trovare il punto debole tra F # e C # ha richiesto meno lavoro, a lungo termine, rispetto alla creazione di API separate.


sì, vedo che ci sono alcuni modi per far funzionare le cose, non sono del tutto convinto. Re moduli. Se hai module Foo.Bar.Zooquindi in C # questo sarà class Foonello spazio dei nomi Foo.Bar. Tuttavia, se hai moduli nidificati come module Foo=... module Bar=... module Zoo==, questo genererà una classe Foocon classi interne Bare Zoo. Re IEnumerable, questo potrebbe non essere accettabile quando abbiamo davvero bisogno di lavorare con mappe e set. Rispetta i nullvalori e AllowNullLiteral- uno dei motivi per cui mi piace F # è perché sono stanco di nullC #, davvero non vorrei inquinare di nuovo tutto con esso!
Philip P.

In genere, preferisci gli spazi dei nomi ai moduli annidati per l'interoperabilità. Re: null, sono d'accordo con te, è certamente una regressione doverla considerare, ma qualcosa che devi affrontare se la tua API verrà utilizzata da C #.
Daniel

2

Anche se probabilmente sarebbe eccessivo, potresti prendere in considerazione la possibilità di scrivere un'applicazione utilizzando Mono.Cecil (ha un fantastico supporto sulla mailing list) che automatizzerebbe la conversione a livello IL. Ad esempio, se si implementa l'assembly in F #, utilizzando l'API pubblica in stile F #, lo strumento genera un wrapper compatibile con C # su di esso.

Ad esempio, in F # dovresti ovviamente usare option<'T>( None, in particolare) invece di usare nullcome in C #. Scrivere un generatore di wrapper per questo scenario dovrebbe essere abbastanza semplice: il metodo wrapper richiamerebbe il metodo originale: se il valore di ritorno era Some x, allora torna x, altrimenti torna null.

Dovresti gestire il caso quando Tè un tipo di valore, cioè non annullabile; dovresti racchiudere il valore di ritorno del metodo wrapper Nullable<T>, il che lo rende un po 'doloroso.

Ancora una volta, sono abbastanza certo che sarebbe utile scrivere uno strumento del genere nel tuo scenario, forse tranne se lavorerai su questa libreria di questo tipo (utilizzabile senza problemi da F # e C # entrambi) regolarmente. In ogni caso, penso che sarebbe un esperimento interessante, che potrei anche esplorare prima o poi.


sembra interessante. Usare Mono.Cecilsignificherebbe fondamentalmente scrivere un programma per caricare l'assembly F #, trovare gli elementi che richiedono la mappatura e emettere un altro assembly con i wrapper C #? Ho capito bene?
Philip P.

@Komrade P .: Puoi farlo anche tu, ma è molto più complicato, perché allora dovresti aggiustare tutti i riferimenti in modo che puntino ai membri della nuova assemblea. È molto più semplice se si aggiungono semplicemente i nuovi membri (i wrapper) all'assembly F # esistente.
ShdNx

2

Bozza delle linee guida per la progettazione dei componenti F # (agosto 2010)

Panoramica Questo documento esamina alcuni dei problemi relativi alla progettazione e alla codifica dei componenti F #. In particolare, copre:

  • Linee guida per la progettazione di librerie .NET "vanilla" da utilizzare da qualsiasi linguaggio .NET.
  • Linee guida per le librerie da F # a F # e il codice di implementazione di F #.
  • Suggerimenti sulle convenzioni di codifica per il codice di implementazione di F #
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.