Convenzioni di codifica F#

Le convenzioni seguenti vengono formulate dall'esperienza nell'uso di codebase F# di grandi dimensioni. I cinque principi del codice F# valido sono la base di ogni raccomandazione. Sono correlati alle linee guida per la progettazione dei componenti F#, ma sono applicabili per qualsiasi codice F#, non solo per i componenti come le librerie.

Organizzazione del codice

F# offre due modi principali per organizzare il codice: moduli e spazi dei nomi. Sono simili, ma presentano le differenze seguenti:

  • Gli spazi dei nomi vengono compilati come spazi dei nomi .NET. I moduli vengono compilati come classi statiche.
  • Gli spazi dei nomi sono sempre di primo livello. I moduli possono essere di primo livello e annidati all'interno di altri moduli.
  • Gli spazi dei nomi possono estendersi su più file. I moduli non possono.
  • I moduli possono essere decorati con [<RequireQualifiedAccess>] e [<AutoOpen>].

Le linee guida seguenti consentono di usarle per organizzare il codice.

Preferisce gli spazi dei nomi al livello superiore

Per qualsiasi codice di consumo pubblico, gli spazi dei nomi sono preferibili ai moduli al livello superiore. Poiché vengono compilati come spazi dei nomi .NET, sono utilizzabili da C# senza ricorrere a using static.

// Recommended.
namespace MyCode

type MyClass() =
    ...

L'uso di un modulo di primo livello potrebbe non essere diverso quando viene chiamato solo da F#, ma per i consumer C#, i chiamanti potrebbero essere sorpresi dalla necessità di qualificarsi MyClass con il MyCode modulo quando non è a conoscenza del costrutto C# specifico using static .

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

type MyClass() =
    ...

Applicare attentamente [<AutoOpen>]

Il [<AutoOpen>] costrutto può inquinare l'ambito di ciò che è disponibile per i chiamanti, e la risposta a dove proviene qualcosa è "magia". Non è una cosa buona. Un'eccezione a questa regola è la libreria principale F# (anche se questo fatto è anche un po'controverso).

Tuttavia, è utile se si dispone di funzionalità helper per un'API pubblica che si vuole organizzare separatamente da tale API pubblica.

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

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

        helper1 x y z

In questo modo è possibile separare in modo pulito i dettagli di implementazione dall'API pubblica di una funzione senza dover qualificare completamente un helper ogni volta che viene chiamato.

Inoltre, l'esposizione di metodi di estensione e generatori di espressioni a livello di spazio dei nomi può essere espressa perfettamente con [<AutoOpen>].

Usare [<RequireQualifiedAccess>] ogni volta che i nomi potrebbero entrare in conflitto o sentirsi utili per la leggibilità

L'aggiunta dell'attributo [<RequireQualifiedAccess>] a un modulo indica che il modulo potrebbe non essere aperto e che i riferimenti agli elementi del modulo richiedono l'accesso completo esplicito. Ad esempio, il Microsoft.FSharp.Collections.List modulo ha questo attributo.

Ciò è utile quando le funzioni e i valori nel modulo hanno nomi che potrebbero essere in conflitto con i nomi in altri moduli. La richiesta di accesso qualificato può aumentare notevolmente la manutenibilità a lungo termine e la capacità di una libreria di evolversi.

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

...

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

Ordinare open le istruzioni topologicamente

In F#, l'ordine delle dichiarazioni è importante, incluso con open le istruzioni (e open type, appena indicato come open più in basso). A differenza di C#, in cui l'effetto di using e using static è indipendente dall'ordinamento di tali istruzioni in un file.

In F# gli elementi aperti in un ambito possono nascondere altri elementi già presenti. Ciò significa che il riordinamento open delle istruzioni potrebbe modificare il significato del codice. Di conseguenza, qualsiasi ordinamento arbitrario di tutte le open istruzioni (ad esempio, alfanumericalmente) non è consigliato, perché si genera un comportamento diverso che potrebbe essere previsto.

È invece consigliabile ordinarli in modo topologico, ovvero ordinare open le istruzioni nell'ordine in cui sono definiti i livelli del sistema. È anche possibile considerare l'ordinamento alfanumerico all'interno di diversi livelli topologici.

Ad esempio, di seguito è riportato l'ordinamento topologico per il file DELL'API pubblica del servizio compilatore 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'interruzione di riga separa i livelli topologici, con ogni livello ordinato alfanumericalmente in seguito. In questo modo il codice viene organizzato in modo pulito senza ombreggiatura accidentalmente dei valori.

Usare le classi per contenere valori con effetti collaterali

L'inizializzazione di un valore può avere molti effetti collaterali, ad esempio la creazione di un'istanza di un contesto in un database o in un'altra risorsa remota. È tentata l'inizializzazione di tali elementi in un modulo e l'uso nelle funzioni successive:

// 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

Questo problema è spesso problematico per alcuni motivi:

Prima di tutto, la configurazione dell'applicazione viene inserita nella codebase con dep1 e dep2. Questa operazione è difficile da gestire in codebase di dimensioni maggiori.

In secondo luogo, i dati inizializzati in modo statico non devono includere valori non thread-safe se il componente userà più thread. Ciò è chiaramente violato da dep3.

Infine, l'inizializzazione del modulo viene compilata in un costruttore statico per l'intera unità di compilazione. Se si verifica un errore nell'inizializzazione del valore con associazione a let in tale modulo, viene visualizzato come oggetto TypeInitializationException che viene quindi memorizzato nella cache per l'intera durata dell'applicazione. Questo può essere difficile da diagnosticare. In genere c'è un'eccezione interna che è possibile tentare di ragionare, ma se non c'è, allora non c'è alcun modo di dire qual è la causa radice.

Usare invece una classe semplice per contenere le dipendenze:

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

In questo modo è possibile:

  1. Push di qualsiasi stato dipendente all'esterno dell'API stessa.
  2. La configurazione può ora essere eseguita all'esterno dell'API.
  3. Gli errori nell'inizializzazione per i valori dipendenti non sono probabilmente manifesti come .TypeInitializationException
  4. L'API è ora più semplice da testare.

Gestione degli errori

La gestione degli errori in sistemi di grandi dimensioni è un impegno complesso e sfumato e non ci sono proiettili d'argento per garantire che i sistemi siano a tolleranza di errore e si comportino bene. Le linee guida seguenti dovrebbero offrire indicazioni per lo spostamento in questo spazio difficile.

Rappresentare i casi di errore e lo stato non valido nei tipi intrinseci al dominio

Con unioni discriminate, F# offre la possibilità di rappresentare lo stato del programma difettoso nel sistema di tipi. Ad esempio:

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

In questo caso, ci sono tre modi noti che il prelievo di denaro da un conto bancario può non riuscire. Ogni caso di errore è rappresentato nel tipo e può quindi essere gestito in modo sicuro in tutto il programma.

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"

In generale, se è possibile modellare i diversi modi in cui un elemento può avere esito negativo nel dominio, il codice di gestione degli errori non viene più considerato come un elemento da gestire oltre al normale flusso del programma. È semplicemente una parte del normale flusso di programma, e non considerato eccezionale. A questo scopo, esistono due vantaggi principali:

  1. È più facile gestire man mano che il dominio cambia nel tempo.
  2. I casi di errore sono più facili da unit test.

Usare le eccezioni quando gli errori non possono essere rappresentati con tipi

Non tutti gli errori possono essere rappresentati in un dominio di problema. Questi tipi di errori sono eccezionali in natura, quindi la possibilità di generare e intercettare le eccezioni in F#.

Prima di tutto, è consigliabile leggere le linee guida per la progettazione delle eccezioni. Sono applicabili anche a F#.

I costrutti principali disponibili in F# ai fini della generazione di eccezioni devono essere considerati nell'ordine di preferenza seguente:

Funzione Sintassi Scopo
nullArg nullArg "argumentName" Genera un System.ArgumentNullException oggetto con il nome dell'argomento specificato.
invalidArg invalidArg "argumentName" "message" Genera un System.ArgumentException oggetto con un nome di argomento e un messaggio specificati.
invalidOp invalidOp "message" Genera un System.InvalidOperationException oggetto con il messaggio specificato.
raise raise (ExceptionType("message")) Meccanismo generico per generare eccezioni.
failwith failwith "message" Genera un System.Exception oggetto con il messaggio specificato.
failwithf failwithf "format string" argForFormatString Genera un System.Exception oggetto con un messaggio determinato dalla stringa di formato e dai relativi input.

Usare nullArg, invalidArge invalidOp come meccanismo per generare ArgumentNullException, ArgumentExceptione InvalidOperationException quando appropriato.

Le failwith funzioni e failwithf devono in genere essere evitate perché generano il tipo di base Exception , non un'eccezione specifica. In base alle linee guida per la progettazione delle eccezioni, è consigliabile generare eccezioni più specifiche quando è possibile.

Usare la sintassi di gestione delle eccezioni

F# supporta i modelli di eccezione tramite la try...with sintassi :

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

La riconciliazione delle funzionalità da eseguire in caso di eccezione con criteri di ricerca può essere un po' complicata se si vuole mantenere pulito il codice. Un modo per gestire questa operazione consiste nell'usare modelli attivi come mezzo per raggruppare le funzionalità che circondano un caso di errore con un'eccezione stessa. Ad esempio, è possibile usare un'API che, quando genera un'eccezione, racchiude informazioni preziose nei metadati dell'eccezione. Annullare il wrapping di un valore utile nel corpo dell'eccezione acquisita all'interno del modello attivo e restituire tale valore può essere utile in alcune situazioni.

Non usare la gestione degli errori monadic per sostituire le eccezioni

Le eccezioni sono spesso considerate tabù nel paradigma funzionale puro. Infatti, le eccezioni violano la purezza, quindi è sicuro considerarle non abbastanza funzionalmente pure. Tuttavia, questo ignora la realtà di dove deve essere eseguito il codice e che possono verificarsi errori di runtime. In generale, scrivere codice sul presupposto che la maggior parte delle cose non siano pure o totali, per ridurre al minimo le sorprese spiacevoli (simile a vuoto catch in C# o gestione errata dell'analisi dello stack, ignorando le informazioni).

È importante considerare i punti di forza/aspetti principali seguenti delle eccezioni in relazione alla pertinenza e all'adeguatezza nel runtime .NET e nell'ecosistema multi-linguaggio nel suo complesso:

  • Contengono informazioni di diagnostica dettagliate, utili per il debug di un problema.
  • Sono ben compresi dal runtime e da altri linguaggi .NET.
  • Possono ridurre significativamente boilerplate rispetto al codice che esce dal suo modo per evitare eccezioni implementando alcuni subset della loro semantica su base ad hoc.

Questo terzo punto è fondamentale. Per le operazioni complesse nontriviali, la mancata utilizzo delle eccezioni può comportare la gestione di strutture simili alle seguenti:

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

Ciò può causare facilmente codice fragile, ad esempio criteri di ricerca per gli errori "tipizzato in modo stringa":

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?

Inoltre, può essere tentata di inghiottire qualsiasi eccezione nel desiderio di una funzione "semplice" che restituisce un tipo "più bello":

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

Sfortunatamente, tryReadAllText può generare numerose eccezioni in base alla miriade di cose che possono verificarsi in un file system e questo codice elimina eventuali informazioni su ciò che potrebbe effettivamente andare storto nell'ambiente. Se si sostituisce questo codice con un tipo di risultato, si torna all'analisi del messaggio di errore "tipizzato in modo stringa":

// 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 ...

L'inserimento dell'oggetto eccezione stesso nel Error costruttore impone di gestire correttamente il tipo di eccezione nel sito di chiamata anziché nella funzione . Questa operazione crea in modo efficace eccezioni controllate, che sono notoriamente non funzionali per gestire come chiamante di un'API.

Una buona alternativa agli esempi precedenti consiste nell'intercettare eccezioni specifiche e restituire un valore significativo nel contesto di tale eccezione. Se si modifica la tryReadAllText funzione come indicato di seguito, None ha un significato maggiore:

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

Invece di funzionare come catch-all, questa funzione ora gestirà correttamente il caso quando un file non è stato trovato e assegna tale significato a una restituzione. Questo valore restituito può essere mappato a tale caso di errore, senza eliminare informazioni contestuali o forzare i chiamanti a gestire un caso che potrebbe non essere rilevante in quel punto del codice.

I tipi come Result<'Success, 'Error> sono appropriati per le operazioni di base in cui non sono annidati e i tipi facoltativi F# sono perfetti per rappresentare quando qualcosa potrebbe restituire qualcosa o nulla. Non sono tuttavia una sostituzione per le eccezioni e non devono essere usate nel tentativo di sostituire le eccezioni. Piuttosto, devono essere applicati in modo succoso per affrontare aspetti specifici dei criteri di gestione delle eccezioni e degli errori in modi mirati.

Programmazione parziale e senza punti

F# supporta l'applicazione parziale e quindi vari modi per programmare in uno stile senza punti. Ciò può essere utile per il riutilizzo del codice all'interno di un modulo o l'implementazione di un elemento, ma non è qualcosa da esporre pubblicamente. In generale, la programmazione senza punti non è una virtù in e di se stessa, e può aggiungere una barriera cognitiva significativa per le persone che non sono immersi nello stile.

Non usare l'applicazione parziale e il currying nelle API pubbliche

Con poche eccezioni, l'uso di un'applicazione parziale nelle API pubbliche può generare confusione per i consumer. In genere, leti valori associati a -nel codice F# sono valori, non valori di funzione. La combinazione di valori e valori di funzione può comportare il salvataggio di poche righe di codice in cambio di un po' di sovraccarico cognitivo, soprattutto se combinato con operatori come >> la composizione di funzioni.

Prendere in considerazione le implicazioni degli strumenti per la programmazione senza punti

Le funzioni curried non etichettano i relativi argomenti. Questo ha implicazioni per gli strumenti. Si considerino le due funzioni seguenti:

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!"

Entrambe sono funzioni valide, ma funcWithApplication è una funzione curried. Quando si passa il puntatore del mouse sui relativi tipi in un editor, viene visualizzato quanto segue:

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

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

Nel sito di chiamata, le descrizioni comando negli strumenti, ad esempio Visual Studio, forniscono la firma del tipo, ma poiché non sono definiti nomi, non visualizzeranno nomi. I nomi sono fondamentali per una progettazione efficace dell'API perché aiutano i chiamanti a comprendere meglio il significato dietro l'API. L'uso di codice senza punti nell'API pubblica può rendere più difficile per i chiamanti comprendere.

Se si verifica codice senza punti come funcWithApplication questo è pubblicamente utilizzabile, è consigliabile eseguire un'espansione completa η in modo che gli strumenti possano raccogliere nomi significativi per gli argomenti.

Inoltre, il debug di codice senza punti può essere complesso, se non impossibile. Gli strumenti di debug si basano sui valori associati ai nomi ,ad esempio le associazioni, let in modo da poter esaminare i valori intermedi a metà dell'esecuzione. Quando il codice non ha valori da esaminare, non è necessario eseguire il debug. In futuro, gli strumenti di debug possono evolversi per sintetizzare questi valori in base ai percorsi eseguiti in precedenza, ma non è consigliabile coprire le scommesse sulle potenziali funzionalità di debug.

Considerare l'applicazione parziale come tecnica per ridurre il boilerplate interno

A differenza del punto precedente, l'applicazione parziale è uno strumento meraviglioso per ridurre il boilerplate all'interno di un'applicazione o gli interni più profondi di un'API. Può essere utile per eseguire unit test dell'implementazione di API più complesse, in cui boilerplate è spesso un problema da affrontare. Ad esempio, il codice seguente illustra come eseguire le operazioni che offrono la maggior parte dei framework fittizi senza assumere una dipendenza esterna da tale framework e dover apprendere un'API personalizzata correlata.

Si consideri, ad esempio, la seguente topografia della soluzione:

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

ImplementationLogic.fsproj potrebbe esporre codice come:

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

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

L'esecuzione di unit test Transactions.doTransaction in ImplementationLogic.Tests.fsproj è semplice:

namespace TransactionsTestingUtil

open Transactions

module TransactionsTestable =
    let getTestableTransactionRoutine mockContext = Transactions.doTransaction mockContext

L'applicazione doTransaction parziale con un oggetto contesto fittizio consente di chiamare la funzione in tutti gli unit test senza dover costruire un contesto fittizio ogni volta:

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)

Non applicare questa tecnica universalmente all'intera codebase, ma è un buon modo per ridurre boilerplate per complessi interni e unit test di tali elementi interni.

Controllo di accesso

F# include più opzioni per il controllo di accesso, ereditate da ciò che è disponibile nel runtime .NET. Questi non sono utilizzabili solo per i tipi. È anche possibile usarli per le funzioni.

Procedure consigliate nel contesto delle librerie ampiamente utilizzate:

  • public Preferire tipi e membri non fino a quando non è necessario che siano di consumo pubblicamente. Ciò riduce anche al minimo le coppie di consumatori.
  • Cercare di mantenere tutte le funzionalità privatehelper .
  • Prendere in considerazione l'uso di [<AutoOpen>] in un modulo privato di funzioni helper se diventano numerose.

Inferenza dei tipi e generics

L'inferenza dei tipi consente di risparmiare digitando un sacco di boilerplate. E la generalizzazione automatica nel compilatore F# consente di scrivere codice più generico senza alcun sforzo aggiuntivo da parte dell'utente. Tuttavia, queste caratteristiche non sono universalmente buone.

  • Prendere in considerazione l'assegnazione di etichette ai nomi degli argomenti con tipi espliciti nelle API pubbliche e non si basano sull'inferenza dei tipi.

    Il motivo è che è necessario avere il controllo della forma dell'API, non del compilatore. Anche se il compilatore può eseguire un processo corretto per dedurre i tipi, è possibile modificare la forma dell'API se gli elementi interni su cui si basa sono stati modificati i tipi. Questo può essere ciò che vuoi, ma quasi certamente comporterà una modifica dell'API di rilievo che i consumer downstream dovranno quindi gestire. Se invece si controlla in modo esplicito la forma dell'API pubblica, è possibile controllare queste modifiche di rilievo. In termini DDD, questo può essere considerato come un livello anti-danneggiamento.

  • Valutare la possibilità di assegnare un nome significativo agli argomenti generici.

    A meno che non si stia scrivendo codice veramente generico non specifico di un determinato dominio, un nome significativo può aiutare altri programmatori a comprendere il dominio in cui lavorano. Ad esempio, un parametro di tipo denominato 'Document nel contesto dell'interazione con un database di documenti rende più chiaro che i tipi di documento generici possono essere accettati dalla funzione o dal membro in uso.

  • Prendere in considerazione la denominazione dei parametri di tipo generico con PascalCase.

    Questo è il modo generale per eseguire operazioni in .NET, quindi è consigliabile usare PascalCase anziché snake_case o camelCase.

Infine, la generalizzazione automatica non è sempre un elemento boon per gli utenti che non hanno alcuna conoscenza di F# o di una codebase di grandi dimensioni. È previsto un sovraccarico cognitivo nell'uso di componenti generici. Inoltre, se le funzioni generalizzate automaticamente non vengono usate con tipi di input diversi (per lo meno se sono destinate a essere usate come tali), allora non vi è alcun vantaggio reale per loro essere generici. Considerare sempre se il codice che si sta scrivendo trarrà vantaggio dall'essere generico.

Prestazioni

Prendere in considerazione gli struct per i tipi di piccole dimensioni con tassi di allocazione elevati

L'uso di struct (detti anche tipi valore) può spesso comportare prestazioni più elevate per un codice perché in genere evita l'allocazione di oggetti. Tuttavia, gli struct non sono sempre un pulsante "vai più veloce": se le dimensioni dei dati in uno struct superano i 16 byte, la copia dei dati può spesso comportare più tempo della CPU rispetto all'uso di un tipo riferimento.

Per determinare se è necessario usare uno struct, considerare le condizioni seguenti:

  • Se le dimensioni dei dati sono pari a 16 byte o inferiori.
  • Se è probabile che molte istanze di questi tipi si trovino in memoria in un programma in esecuzione.

Se si applica la prima condizione, in genere è consigliabile usare uno struct. Se entrambi si applicano, è consigliabile usare quasi sempre uno struct. In alcuni casi è possibile che si applichino le condizioni precedenti, ma l'uso di uno struct non è migliore o peggiore rispetto all'uso di un tipo riferimento, ma probabilmente è raro. È importante misurare sempre quando si apportano modifiche come questa, tuttavia, e non si opera su presupposto o intuizione.

Prendere in considerazione le tuple di struct quando si raggruppano tipi di valore ridotto con tassi di allocazione elevati

Si considerino le due funzioni seguenti:

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)

Quando si esegue il benchmarking di queste funzioni con uno strumento di benchmarking statistico come BenchmarkDotNet, si scopre che la runWithStructTuple funzione che usa tuple di struct esegue il 40% più velocemente e non alloca memoria.

Tuttavia, questi risultati non saranno sempre il caso nel codice. Se si contrassegna una funzione come inline, il codice che usa tuple di riferimento potrebbe ottenere alcune ottimizzazioni aggiuntive o il codice che allocare potrebbe essere semplicemente ottimizzato. È consigliabile misurare sempre i risultati ogni volta che si preoccupano le prestazioni e non operare mai in base all'ipotesi o all'intuizione.

Prendere in considerazione i record struct quando il tipo è piccolo e ha tassi di allocazione elevati

La regola generale descritta in precedenza contiene anche i tipi di record F#. Considerare i tipi di dati e le funzioni seguenti che li elaborano:

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)

È simile al codice di tupla precedente, ma questa volta l'esempio usa record e una funzione interna inlined.

Quando si esegue il benchmarking di queste funzioni con uno strumento di benchmarking statistico come BenchmarkDotNet, si scopre che processStructPoint viene eseguito quasi il 60% più velocemente e non alloca nulla nell'heap gestito.

Si considerino unioni discriminate struct quando il tipo di dati è ridotto con tassi di allocazione elevati

Le osservazioni precedenti sulle prestazioni con tuple struct e record sono incluse anche per le unioni discriminate F#. Osservare il codice seguente:

    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

È comune definire unioni discriminate a maiuscole e minuscole come questa per la modellazione del dominio. Quando si esegue il benchmarking di queste funzioni con uno strumento di benchmarking statistico come BenchmarkDotNet, si scoprirà che structReverseName viene eseguito circa il 25% più velocemente rispetto reverseName alle stringhe di piccole dimensioni. Per stringhe di grandi dimensioni, entrambe eseguono la stessa operazione. Quindi, in questo caso, è sempre preferibile usare uno struct. Come accennato in precedenza, misurare sempre e non operare su presupposti o intuizioni.

Anche se l'esempio precedente ha mostrato che un'unione discriminante struct ha restituito prestazioni migliori, è comune avere unioni discriminate più grandi durante la modellazione di un dominio. I tipi di dati di dimensioni maggiori, come questo, potrebbero non essere eseguiti anche se sono struct a seconda delle operazioni su di essi, perché potrebbero essere coinvolti più copie.

Immutabilità e mutazione

I valori F# sono non modificabili per impostazione predefinita, che consente di evitare determinate classi di bug (in particolare quelle che coinvolgono concorrenza e parallelismo). Tuttavia, in alcuni casi, per ottenere un'efficienza ottimale (o persino ragionevole) del tempo di esecuzione o delle allocazioni di memoria, un intervallo di lavoro può essere implementato meglio usando la mutazione sul posto dello stato. Ciò è possibile in base al consenso esplicito con F# con la mutable parola chiave .

L'uso di mutable in F# può sembrare in contrasto con la purezza funzionale. Questo è comprensibile, ma la purezza funzionale ovunque può essere in contrasto con gli obiettivi di prestazioni. Un compromesso consiste nell'incapsulare la mutazione in modo che i chiamanti non debbano preoccuparsi di cosa accade quando chiamano una funzione. In questo modo è possibile scrivere un'interfaccia funzionale su un'implementazione basata su mutazione per il codice critico per le prestazioni.

Inoltre, i costrutti di associazione F# let consentono di annidare le associazioni in un'altra, che può essere sfruttata per mantenere l'ambito della mutable variabile vicino o al più piccolo.

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

Nessun codice può accedere alla modificabile completed usata solo per inizializzare il data valore associato let.

Eseguire il wrapping del codice modificabile nelle interfacce non modificabili

Con la trasparenza referenziale come obiettivo, è fondamentale scrivere codice che non esponga l'etichetta sottobele delle funzioni critiche per le prestazioni. Ad esempio, il codice seguente implementa la Array.contains funzione nella libreria 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

La chiamata a questa funzione più volte non modifica la matrice sottostante, né richiede di mantenere qualsiasi stato modificabile durante l'utilizzo. È referenzialemente trasparente, anche se quasi ogni riga di codice all'interno usa la mutazione.

Prendere in considerazione l'incapsulamento di dati modificabili nelle classi

Nell'esempio precedente è stata usata una singola funzione per incapsulare le operazioni usando dati modificabili. Questo non è sempre sufficiente per set di dati più complessi. Considerare i set di funzioni seguenti:

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

Questo codice è efficiente, ma espone la struttura dei dati basata su mutazione che i chiamanti sono responsabili della gestione. È possibile eseguire il wrapping all'interno di una classe senza membri sottostanti che possono cambiare:

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 incapsula la struttura dei dati basata su mutazione sottostante, evitando così ai chiamanti di mantenere la struttura dei dati sottostante. Le classi sono un modo efficace per incapsulare dati e routine basate su mutazioni senza esporre i dettagli ai chiamanti.

Preferisce let mutableref

Le celle di riferimento sono un modo per rappresentare il riferimento a un valore anziché il valore stesso. Anche se possono essere usate per il codice critico per le prestazioni, non sono consigliate. Si consideri l'esempio seguente:

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'uso di una cella di riferimento ora "inquina" tutto il codice successivo con la necessità di dereferenziare e fare nuovamente riferimento ai dati sottostanti. Si consideri let mutableinvece :

let kernels =
    let mutable acc = Set.empty

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

    acc |> Seq.toList

A parte il singolo punto di mutazione al centro dell'espressione lambda, tutto l'altro codice che tocca acc può farlo in modo che non sia diverso dall'utilizzo di un valore non modificabile con associazione normale let. In questo modo sarà più semplice cambiare nel tempo.

Valori Null e valori predefiniti

I valori Null devono essere in genere evitati in F#. Per impostazione predefinita, i tipi dichiarati da F#non supportano l'uso null del valore letterale e vengono inizializzati tutti i valori e gli oggetti. Tuttavia, alcune API .NET comuni restituiscono o accettano valori Null e alcune comuni . I tipi dichiarati da NET, ad esempio matrici e stringhe, consentono valori Null. Tuttavia, l'occorrenza dei null valori è molto rara nella programmazione F# e uno dei vantaggi dell'uso di F# consiste nell'evitare errori di riferimento Null nella maggior parte dei casi.

Evitare l'uso dell'attributo AllowNullLiteral

Per impostazione predefinita, i tipi dichiarati da F#non supportano l'uso del null valore letterale. È possibile annotare manualmente i tipi F# con AllowNullLiteral per consentire questa operazione. Tuttavia, è quasi sempre meglio evitare di farlo.

Evitare l'uso dell'attributo Unchecked.defaultof<_>

È possibile generare un null valore inizializzato o zero per un tipo F# usando Unchecked.defaultof<_>. Ciò può essere utile durante l'inizializzazione dell'archiviazione per alcune strutture di dati o in un modello di codifica ad alte prestazioni o nell'interoperabilità. È tuttavia consigliabile evitare l'uso di questo costrutto.

Evitare l'uso dell'attributo DefaultValue

Per impostazione predefinita, i record e gli oggetti F# devono essere inizializzati correttamente durante la costruzione. L'attributo DefaultValue può essere usato per popolare alcuni campi di oggetti con un null valore inizializzato o zero. Questo costrutto è raramente necessario e il relativo uso deve essere evitato.

Se si verifica la presenza di input Null, generare eccezioni alla prima opportunità

Quando si scrive nuovo codice F#, in pratica non è necessario verificare la presenza di input Null, a meno che non si prevede che il codice venga usato da C# o da altri linguaggi .NET.

Se si decide di aggiungere controlli per gli input Null, eseguire i controlli alla prima opportunità e generare un'eccezione. Ad esempio:

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

Per motivi legacy, alcune funzioni stringa in FSharp.Core considerano comunque i valori Null come stringhe vuote e non hanno esito negativo sugli argomenti Null. Tuttavia, non accettarlo come linee guida e non adottare modelli di codifica che assegnano alcun significato semantico a "null".

Programmazione di oggetti

F# offre il supporto completo per gli oggetti e i concetti di OO (Object-Oriented). Anche se molti concetti di OO sono potenti e utili, non tutti sono ideali per l'uso. Gli elenchi seguenti offrono indicazioni sulle categorie di funzionalità OO di alto livello.

Prendere in considerazione l'uso di queste funzionalità in molte situazioni:

  • Notazione punto (x.Length)
  • Membri dell'istanza
  • Costruttori impliciti
  • Membri statici
  • Notazione dell'indicizzatore (arr[x]), definendo una Item proprietà
  • Slicing notation (arr[x..y], arr[x..], arr[..y]), definendo GetSlice i membri
  • Argomenti denominati e facoltativi
  • Interfacce e implementazioni dell'interfaccia

Non raggiungere prima queste funzionalità, ma applicarle in modo appropriato quando sono utili per risolvere un problema:

  • Overload di un metodo
  • Dati modificabili incapsulati
  • Operatori sui tipi
  • Proprietà automatiche
  • Implementazione IDisposable e IEnumerable
  • Estensioni dei tipi
  • Eventi
  • Struct
  • Delegati
  • Enumerazioni

In genere evitare queste funzionalità a meno che non sia necessario usarle:

  • Gerarchie di tipi basate sull'ereditarietà e ereditarietà dell'implementazione
  • Valori Null e Unchecked.defaultof<_>

Preferire la composizione rispetto all'ereditarietà

La composizione sull'ereditarietà è un linguaggio di lunga data che un buon codice F# può rispettare. Il principio fondamentale è che non è necessario esporre una classe di base e forzare i chiamanti a ereditare da tale classe di base per ottenere funzionalità.

Usare espressioni di oggetto per implementare le interfacce se non è necessaria una classe

Le espressioni di oggetto consentono di implementare interfacce in tempo reale, associando l'interfaccia implementata a un valore senza dover eseguire questa operazione all'interno di una classe. Ciò è utile, soprattutto se è sufficiente implementare l'interfaccia e non è necessario disporre di una classe completa.

Ecco ad esempio il codice eseguito in Ionide per fornire un'azione di correzione del codice se è stato aggiunto un simbolo per cui non si dispone di un'istruzione open per:

    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
        }

Poiché non è necessaria una classe quando si interagisce con l'API di Visual Studio Code, le espressioni di oggetto sono uno strumento ideale per questo. Sono utili anche per gli unit test, quando si vuole stubare un'interfaccia con routine di test in modo improvvisato.

Prendere in considerazione le abbreviazioni dei tipi per abbreviare le firme

Le abbreviazioni dei tipi sono un modo pratico per assegnare un'etichetta a un altro tipo, ad esempio una firma di funzione o un tipo più complesso. Ad esempio, l'alias seguente assegna un'etichetta a ciò che è necessario per definire un calcolo con CNTK, una libreria di Deep Learning:

open CNTK

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

Il Computation nome è un modo pratico per indicare qualsiasi funzione che corrisponda alla firma che sta aliasando. L'uso di abbreviazioni di tipo come questo è pratico e consente un codice più conciso.

Evitare di usare le abbreviazioni dei tipi per rappresentare il dominio

Anche se le abbreviazioni dei tipi sono utili per assegnare un nome alle firme di funzione, possono generare confusione quando si abbreviatano altri tipi. Si consideri questa abbreviazione:

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

Ciò può generare confusione in diversi modi:

  • BufferSize non è un'astrazione; è solo un altro nome per un numero intero.
  • Se BufferSize viene esposto in un'API pubblica, può essere facilmente interpretato erroneamente per significare più che solo int. In genere, i tipi di dominio hanno più attributi e non sono tipi primitivi come int. Questa abbreviazione viola tale presupposto.
  • La combinazione di maiuscole e minuscole ( BufferSize PascalCase) implica che questo tipo contiene più dati.
  • Questo alias non offre maggiore chiarezza rispetto alla fornitura di un argomento denominato a una funzione.
  • L'abbreviazione non si manifesterà nel codice IL compilato; è solo un numero intero e questo alias è un costrutto in fase di compilazione.
module Networking =
    ...
    let send data (bufferSize: int) = ...

In sintesi, l'insidie con le abbreviazioni dei tipi è che non sono astrazioni sui tipi che stanno abbreviato. Nell'esempio precedente, BufferSize è solo un sotto int copertura, senza dati aggiuntivi, né alcun vantaggio dal sistema di tipi oltre a quello int che già ha.

Un approccio alternativo all'uso delle abbreviazioni dei tipi per rappresentare un dominio consiste nell'usare unioni discriminate a maiuscole e minuscole. L'esempio precedente può essere modellato come segue:

type BufferSize = BufferSize of int

Se si scrive codice che opera in termini di BufferSize e il relativo valore sottostante, è necessario crearne uno anziché passare qualsiasi numero intero arbitrario:

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

In questo modo si riduce la probabilità di passare erroneamente un intero arbitrario nella send funzione, perché il chiamante deve costruire un tipo per eseguire il wrapping di un BufferSize valore prima di chiamare la funzione.