F#-kodningskonventioner

Följande konventioner är formulerade av erfarenhet av att arbeta med stora F#-kodbaser. De fem principerna för bra F#-kod är grunden för varje rekommendation. De är relaterade till designriktlinjerna för F#-komponenter, men gäller för alla F#-kod, inte bara komponenter som bibliotek.

Organisera kod

F# har två huvudsakliga sätt att organisera kod: moduler och namnområden. Dessa är liknande, men har följande skillnader:

  • Namnområden kompileras som .NET-namnområden. Moduler kompileras som statiska klasser.
  • Namnområden är alltid på den översta nivån. Moduler kan vara på toppnivå och kapslade i andra moduler.
  • Namnområden kan sträcka sig över flera filer. Moduler kan inte.
  • Moduler kan dekoreras med [<RequireQualifiedAccess>] och [<AutoOpen>].

Följande riktlinjer hjälper dig att använda dessa för att organisera din kod.

Föredrar namnområden på den översta nivån

För all offentligt förbrukningsbar kod är namnområden företrädesvis för moduler på den översta nivån. Eftersom de kompileras som .NET-namnområden kan de användas från C# utan att using statictillgripa .

// Recommended.
namespace MyCode

type MyClass() =
    ...

Att använda en modul på den översta nivån kanske inte ser annorlunda ut när de bara anropas från F#, men för C#-konsumenter kan uppringare bli förvånade över att behöva kvalificera sig MyClass för modulen MyCode när de inte känner till den specifika using static C#-konstruktionen.

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

type MyClass() =
    ...

Applicera noggrant [<AutoOpen>]

Konstruktionen [<AutoOpen>] kan förorena omfattningen av vad som är tillgängligt för uppringare, och svaret på var något kommer ifrån är "magi". Det här är inte bra. Ett undantag till den här regeln är själva F#Core-biblioteket (även om detta faktum också är lite kontroversiellt).

Det är dock en bekvämlighet om du har hjälpfunktioner för ett offentligt API som du vill organisera separat från det offentliga API:et.

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

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

        helper1 x y z

På så sätt kan du skilja implementeringsinformationen från det offentliga API:et för en funktion utan att behöva kvalificera en support varje gång du anropar den.

Dessutom kan exponera tilläggsmetoder och uttrycksbyggare på namnområdesnivå uttryckas snyggt med [<AutoOpen>].

Använd [<RequireQualifiedAccess>] när namn kan vara i konflikt eller om du känner att det hjälper till med läsbarhet

[<RequireQualifiedAccess>] Att lägga till attributet i en modul anger att modulen kanske inte öppnas och att referenser till elementen i modulen kräver explicit kvalificerad åtkomst. Modulen Microsoft.FSharp.Collections.List har till exempel det här attributet.

Detta är användbart när funktioner och värden i modulen har namn som sannolikt kommer att vara i konflikt med namn i andra moduler. Att kräva kvalificerad åtkomst kan avsevärt öka långsiktig underhållbarhet och möjligheten för ett bibliotek att utvecklas.

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

...

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

Sorteringsuttryck open topologiskt

I F#är ordningen för deklarationer viktig, inklusive med open uttalanden (och open type, som bara refereras längre open ned). Detta är till skillnad från C#, där effekten av using och using static är oberoende av ordningen på dessa instruktioner i en fil.

I F# kan element som öppnas i ett omfång skugga andra som redan finns. Det innebär att omordningssatser open kan ändra innebörden av kod. Därför rekommenderas inte godtycklig sortering av alla open instruktioner (till exempel alfanumeriskt) så att du inte genererar ett annat beteende som du kan förvänta dig.

I stället rekommenderar vi att du sorterar dem topologiskt, dvs. beställer dina open instruktioner i den ordning som lager i systemet definieras. Alfanumerisk sortering i olika topologiska lager kan också övervägas.

Här är till exempel den topologiska sorteringen för den offentliga API-filen för F#-kompilatortjänsten:

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

En radbrytning separerar topologiska lager, där varje lager sorteras alfanumeriskt efteråt. Detta ordnar kod utan att oavsiktligt skugga värden.

Använda klasser för att innehålla värden som har biverkningar

Det finns många gånger när initiering av ett värde kan ha biverkningar, till exempel instansiering av en kontext till en databas eller annan fjärrresurs. Det är frestande att initiera sådana saker i en modul och använda den i efterföljande funktioner:

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

Detta är ofta problematiskt av några skäl:

Först skickas programkonfigurationen till kodbasen med dep1 och dep2. Det här är svårt att underhålla i större kodbaser.

För det andra bör statiskt initierade data inte innehålla värden som inte är trådsäkra om komponenten själv använder flera trådar. Detta kränks tydligt av dep3.

Slutligen kompileras modulinitiering till en statisk konstruktor för hela kompileringsenheten. Om ett fel uppstår vid initiering av let-bound-värden i modulen visas det som ett TypeInitializationException som sedan cachelagras under hela programmets livslängd. Det kan vara svårt att diagnostisera. Det finns vanligtvis ett inre undantag som du kan försöka resonera om, men om det inte finns det finns det ingen som vet vad rotorsaken är.

Använd i stället bara en enkel klass för att hålla beroenden:

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

På så sätt kan du göra följande:

  1. Push-överför beroende tillstånd utanför själva API:et.
  2. Konfigurationen kan nu göras utanför API:et.
  3. Fel vid initiering av beroende värden kommer sannolikt inte att visas som en TypeInitializationException.
  4. API:et är nu enklare att testa.

Felhantering

Felhantering i stora system är en komplex och nyanserad strävan, och det finns inga silverkulor för att säkerställa att dina system är feltoleranta och fungerar bra. Följande riktlinjer bör ge vägledning när du navigerar i det här svåra utrymmet.

Representera felfall och ogiltigt tillstånd i typer som är inbyggda i din domän

Med diskriminerade fackföreningar ger F# dig möjlighet att representera felaktigt programtillstånd i ditt typsystem. Till exempel:

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

I det här fallet finns det tre kända sätt att ta ut pengar från ett bankkonto kan misslyckas. Varje felfall representeras i typen och kan därför hanteras på ett säkert sätt i hela programmet.

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"

I allmänhet, om du kan modellera olika sätt som något kan misslyckas i din domän, behandlas inte längre felhanteringskod som något du måste hantera utöver det vanliga programflödet. Det är helt enkelt en del av det normala programflödet och anses inte vara exceptionellt. Det finns två huvudsakliga fördelar med detta:

  1. Det är enklare att underhålla när domänen ändras över tid.
  2. Felfall är enklare att enhetstesta.

Använd undantag när fel inte kan representeras med typer

Alla fel kan inte representeras i en problemdomän. Den här typen av fel är exceptionella till sin natur, därav möjligheten att skapa och fånga undantag i F#.

Först rekommenderar vi att du läser riktlinjerna för undantagsdesign. Dessa gäller även för F#.

De viktigaste konstruktionerna som är tillgängliga i F# för att skapa undantag bör beaktas i följande ordning:

Funktion Syntax Syfte
nullArg nullArg "argumentName" Genererar ett System.ArgumentNullException med det angivna argumentnamnet.
invalidArg invalidArg "argumentName" "message" Genererar ett System.ArgumentException med ett angivet argumentnamn och meddelande.
invalidOp invalidOp "message" Genererar en System.InvalidOperationException med det angivna meddelandet.
raise raise (ExceptionType("message")) Generell mekanism för att utlösa undantag.
failwith failwith "message" Genererar en System.Exception med det angivna meddelandet.
failwithf failwithf "format string" argForFormatString Genererar ett System.Exception med ett meddelande som bestäms av formatsträngen och dess indata.

Använd nullArg, invalidArg, och invalidOp som mekanism för att kasta ArgumentNullException, ArgumentExceptionoch InvalidOperationException när det är lämpligt.

Funktionerna failwith och failwithf bör vanligtvis undvikas eftersom de höjer bastypen Exception , inte ett specifikt undantag. Enligt riktlinjerna för undantagsdesign vill du skapa mer specifika undantag när du kan.

Använda syntax för undantagshantering

F# stöder undantagsmönster via syntaxen try...with :

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

Det kan vara lite svårt att förena funktioner för att utföra ett undantag med mönstermatchning om du vill hålla koden ren. Ett sådant sätt att hantera detta är att använda aktiva mönster som ett sätt att gruppera funktioner kring ett felfall med ett undantag. Du kan till exempel använda ett API som, när det utlöser ett undantag, omger värdefull information i undantagsmetadata. Att packa upp ett användbart värde i brödtexten för det insamlade undantaget i det aktiva mönstret och returnera det värdet kan vara användbart i vissa situationer.

Använd inte monadisk felhantering för att ersätta undantag

Undantag ses ofta som tabu i det rena funktionella paradigmet. Undantag bryter faktiskt mot renhet, så det är säkert att betrakta dem som inte riktigt funktionellt rena. Detta ignorerar dock verkligheten där kod måste köras och att körningsfel kan inträffa. I allmänhet skriver du kod med antagandet att det mesta inte är rent eller totalt, för att minimera obehagliga överraskningar (liknar tomma catch i C# eller felhanterar stackspårningen, tar bort information).

Det är viktigt att tänka på följande grundläggande styrkor/aspekter av undantag när det gäller deras relevans och lämplighet i .NET-körningen och ekosystemet mellan språk som helhet:

  • De innehåller detaljerad diagnostikinformation, vilket är användbart när du felsöker ett problem.
  • De är väl förstådda av körningen och andra .NET-språk.
  • De kan minska betydande pannplåt jämfört med kod som inte används för att undvika undantag genom att implementera vissa delmängder av deras semantik på ad hoc-basis.

Den tredje punkten är kritisk. Om du inte utför komplexa åtgärder kan det leda till att du hanterar strukturer som den här om du inte använder undantag:

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

Vilket enkelt kan leda till bräcklig kod som mönstermatchning vid "strängtypade" fel:

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?

Dessutom kan det vara frestande att svälja alla undantag i önskan om en "enkel" funktion som returnerar en "trevligare" typ:

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

tryReadAllText Tyvärr kan du utlösa många undantag baserat på en mängd saker som kan hända i ett filsystem, och den här koden tar bort all information om vad som faktiskt kan gå fel i din miljö. Om du ersätter den här koden med en resultattyp återgår du till felmeddelandet "strängtypad" och parsar:

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

Och att placera själva undantagsobjektet Error i konstruktorn tvingar dig bara att korrekt hantera undantagstypen på anropsplatsen i stället för i funktionen. Detta skapar effektivt kontrollerade undantag, som är notoriskt ofunna att hantera som anropare av ett API.

Ett bra alternativ till ovanstående exempel är att fånga specifika undantag och returnera ett meningsfullt värde i samband med undantaget. Om du ändrar funktionen enligt tryReadAllText följande har None mer betydelse:

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

I stället för att fungera som en catch-all hanterar den här funktionen nu ärendet korrekt när en fil inte hittades och tilldelar den innebörden till en retur. Det här returvärdet kan mappas till det felfallet, utan att ta bort någon sammanhangsbaserad information eller tvinga anropare att hantera ett ärende som kanske inte är relevant vid den tidpunkten i koden.

Typer som Result<'Success, 'Error> är lämpliga för grundläggande åtgärder där de inte är kapslade och F#-valfria typer är perfekta för att representera när något antingen kan returnera något eller ingenting. De ersätter dock inte undantag och bör inte användas i ett försök att ersätta undantag. De bör i stället tillämpas på ett omdömesgillt sätt för att hantera specifika aspekter av undantags- och felhanteringsprincipen på målinriktade sätt.

Partiell program- och punktfri programmering

F# stöder delvis program och därmed olika sätt att programmera i ett punktfritt format. Detta kan vara fördelaktigt för återanvändning av kod i en modul eller implementeringen av något, men det är inte något att exponera offentligt. I allmänhet är punktfri programmering inte en dygd i sig själv, och kan lägga till en betydande kognitiv barriär för människor som inte är nedsänkta i stilen.

Använd inte delvis program och currying i offentliga API:er

Med lite undantag kan användningen av partiella program i offentliga API:er vara förvirrande för konsumenterna. letVanligtvis är -bound-värden i F#-kod värden, inte funktionsvärden. Genom att blanda ihop värden och funktionsvärden kan du spara några rader kod i utbyte mot en hel del kognitiva omkostnader, särskilt om de kombineras med operatorer som >> att skapa funktioner.

Överväg verktygskonsekvenserna för punktfri programmering

Curryfunktioner märker inte sina argument. Detta har verktygskonsekvenser. Tänk på följande två funktioner:

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

Båda är giltiga funktioner, men funcWithApplication är en curryfunktion. När du hovra över deras typer i en redigerare ser du följande:

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

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

På anropswebbplatsen ger knappbeskrivningar i verktyg som Visual Studio typsignaturen, men eftersom inga namn har definierats visas inga namn. Namn är viktiga för bra API-design eftersom de hjälper anropare att bättre förstå innebörden bakom API:et. Att använda punktfri kod i det offentliga API:et kan göra det svårare för anropare att förstå.

Om du stöter på punktfri kod som funcWithApplication den som är offentligt förbrukningsbar rekommenderar vi att du gör en fullständig η-expansion så att verktyg kan hämta meningsfulla namn för argument.

Dessutom kan det vara svårt att felsöka punktfri kod, om inte omöjligt. Felsökningsverktyg förlitar sig på värden som är bundna till namn (till exempel let bindningar) så att du kan inspektera mellanliggande värden halvvägs genom körningen. När koden inte har några värden att inspektera finns det inget att felsöka. I framtiden kan felsökningsverktyg utvecklas för att syntetisera dessa värden baserat på tidigare utförda sökvägar, men det är inte en bra idé att säkra dina satsningar på potentiella felsökningsfunktioner.

Överväg partiellt program som en teknik för att minska den interna pannplåten

Till skillnad från föregående punkt är partiellt program ett underbart verktyg för att minska pannplåten inuti ett program eller de djupare internerna i ett API. Det kan vara användbart för enhetstestning av implementering av mer komplicerade API:er, där pannplåt ofta är en plåga att hantera. Följande kod visar till exempel hur du kan åstadkomma det som de flesta hånfulla ramverk ger dig utan att behöva använda ett externt beroende av ett sådant ramverk och behöva lära dig ett relaterat anpassat API.

Tänk till exempel på följande lösningstopografi:

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

ImplementationLogic.fsproj kan exponera kod som:

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

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

Enhetstestning Transactions.doTransaction i ImplementationLogic.Tests.fsproj är enkelt:

namespace TransactionsTestingUtil

open Transactions

module TransactionsTestable =
    let getTestableTransactionRoutine mockContext = Transactions.doTransaction mockContext

När du delvis använder doTransaction ett hånfullt kontextobjekt kan du anropa funktionen i alla enhetstester utan att behöva skapa en simulerad kontext varje gång:

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)

Använd inte den här tekniken universellt för hela kodbasen, men det är ett bra sätt att minska pannplåten för komplicerade interna enheter och enhetstestning av dessa interna objekt.

Åtkomstkontroll

F# har flera alternativ för åtkomstkontroll, ärvda från det som är tillgängligt i .NET-körningen. Dessa kan inte bara användas för typer – du kan också använda dem för funktioner.

Bra metoder i samband med bibliotek som används i stor utsträckning:

  • Föredrar icke-typerpublic och medlemmar tills du behöver dem för att vara offentligt förbrukningsbara. Detta minimerar också vad konsumenter par till.
  • Sträva efter att behålla alla hjälpfunktioner private.
  • Överväg att använda [<AutoOpen>] i en privat modul med hjälpfunktioner om de blir många.

Typinferens och generiska objekt

Typinferens kan spara dig från att skriva en hel del pannplåt. Och automatisk generalisering i F#-kompilatorn kan hjälpa dig att skriva mer allmän kod med nästan ingen extra ansträngning från din sida. Dessa funktioner är dock inte universellt bra.

  • Överväg att märka argumentnamn med explicita typer i offentliga API:er och förlita dig inte på typinferens för detta.

    Anledningen till detta är att du ska ha kontroll över formen på ditt API, inte kompilatorn. Även om kompilatorn kan göra ett bra jobb med att härleda typer åt dig, är det möjligt att ändra formen på api:et om de interna som den förlitar sig på har ändrat typer. Detta kan vara vad du vill, men det kommer nästan säkert att resultera i en icke-bakåtkompatibel API-ändring som nedströmskonsumenter sedan måste hantera. Om du i stället uttryckligen styr formen på ditt offentliga API kan du styra de här icke-bakåtkompatibla ändringarna. I DDD-termer kan detta betraktas som ett lager för korruptionsbekämpning.

  • Överväg att ge ett meningsfullt namn till dina allmänna argument.

    Om du inte skriver riktigt allmän kod som inte är specifik för en viss domän kan ett meningsfullt namn hjälpa andra programmerare att förstå den domän de arbetar i. En typparameter med namnet 'Document i samband med interaktion med en dokumentdatabas gör det till exempel tydligare att allmänna dokumenttyper kan accepteras av den funktion eller medlem som du arbetar med.

  • Överväg att namnge generiska typparametrar med PascalCase.

    Det här är det allmänna sättet att göra saker i .NET, så vi rekommenderar att du använder PascalCase i stället för snake_case eller camelCase.

Slutligen är automatisk generalisering inte alltid en välsignelse för personer som är nya i F# eller en stor kodbas. Det finns kognitiva kostnader för att använda komponenter som är generiska. Dessutom, om automatiskt generaliserade funktioner inte används med olika indatatyper (än mindre om de är avsedda att användas som sådana), finns det ingen verklig fördel med att de är generiska då. Tänk alltid på om koden du skriver faktiskt kommer att ha nytta av att vara generisk.

Prestanda

Överväg structs för små typer med hög allokeringsgrad

Användning av structs (kallas även värdetyper) kan ofta resultera i högre prestanda för viss kod eftersom det vanligtvis undviker allokering av objekt. Men structs är inte alltid en "gå snabbare"-knapp: om storleken på data i en struct överskrider 16 byte kan kopiering av data ofta resultera i mer CPU-tid än att använda en referenstyp.

Tänk på följande villkor för att avgöra om du ska använda en struct:

  • Om storleken på dina data är 16 byte eller mindre.
  • Om det är troligt att du har många instanser av dessa typer som finns i minnet i ett program som körs.

Om det första villkoret gäller bör du vanligtvis använda en struct. Om båda gäller bör du nästan alltid använda en struct. Det kan finnas vissa fall där de tidigare villkoren gäller, men att använda en struct är inte bättre eller sämre än att använda en referenstyp, men de är sannolikt sällsynta. Det är dock viktigt att alltid mäta när du gör ändringar som detta och inte arbeta utifrån antaganden eller intuition.

Överväg struct tupplar när du grupperar små värdetyper med höga allokeringshastigheter

Tänk på följande två funktioner:

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)

När du jämför dessa funktioner med ett statistiskt benchmarkingverktyg som BenchmarkDotNet ser du att funktionen runWithStructTuple som använder struct-tupplar körs 40 % snabbare och inte allokerar något minne.

Dessa resultat kommer dock inte alltid att vara fallet i din egen kod. Om du markerar en funktion som inlinekan kod som använder referenstupplar få ytterligare optimeringar, eller så kan kod som allokeras helt enkelt optimeras bort. Du bör alltid mäta resultat när det gäller prestanda och aldrig arbeta baserat på antaganden eller intuition.

Överväg structposter när typen är liten och har höga allokeringshastigheter

Tumregeln som beskrivs tidigare gäller även för F#-posttyper. Överväg följande datatyper och funktioner som bearbetar dem:

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)

Detta liknar den tidigare tuppelns kod, men den här gången använder exemplet poster och en inlined inre funktion.

När du jämför dessa funktioner med ett statistiskt benchmarkingverktyg som BenchmarkDotNet upptäcker du att det processStructPoint går nästan 60 % snabbare och inte allokerar något på den hanterade heapen.

Överväg struct-diskriminerade fackföreningar när datatypen är liten med hög allokeringsgrad

De tidigare observationerna om prestanda med struct tupplar och poster innehåller också för F# Discriminated Unions. Ta följande kod som exempel:

    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

Det är vanligt att definiera enstaka fall diskriminerade fackföreningar som detta för domänmodellering. När du jämför dessa funktioner med ett statistiskt benchmarkingverktyg som BenchmarkDotNet ser du att det structReverseName går ungefär 25 % snabbare än reverseName för små strängar. För stora strängar utför båda ungefär samma sak. Så i det här fallet är det alltid att föredra att använda en struct. Som tidigare nämnts mäter och fungerar alltid inte på antaganden eller intuition.

Även om det föregående exemplet visade att en struct Discriminated Union gav bättre prestanda, är det vanligt att ha större diskriminerade fackföreningar när man modellerar en domän. Större datatyper som dessa kanske inte fungerar lika bra om de är structs beroende på åtgärderna på dem, eftersom mer kopiering kan vara inblandade.

Oföränderlighet och mutation

F#-värden är oföränderliga som standard, vilket gör att du kan undvika vissa klasser av buggar (särskilt de som involverar samtidighet och parallellitet). Men i vissa fall, för att uppnå optimal (eller till och med rimlig) effektivitet för körningstid eller minnesallokeringar, kan en mängd arbete bäst genomföras med hjälp av på plats mutation av tillstånd. Detta är möjligt i en anmälningsbas med F# med nyckelordet mutable .

Användning av mutable i F# kan kännas i strid med funktionell renhet. Detta är förståeligt, men funktionell renhet överallt kan stå i strid med prestandamål. En kompromiss är att kapsla in mutation så att uppringare inte behöver bry sig om vad som händer när de kallar en funktion. På så sätt kan du skriva ett funktionellt gränssnitt över en mutationsbaserad implementering för prestandakritisk kod.

Med F# let -bindningskonstruktioner kan du också kapsla bindningar till en annan. Detta kan användas för att hålla variabelomfånget mutable nära eller som det är som det är som är det minsta.

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

Ingen kod kan komma åt det föränderliga completed som endast användes för att initiera data let bound-värdet.

Omsluta föränderlig kod i oföränderliga gränssnitt

Med referenstransparens som mål är det viktigt att skriva kod som inte exponerar det föränderliga underbältet för prestandakritiska funktioner. Följande kod implementerar Array.contains till exempel funktionen i F#-kärnbiblioteket:

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

Att anropa den här funktionen flera gånger ändrar inte den underliggande matrisen och kräver inte heller att du behåller något föränderligt tillstånd när du använder den. Det är referentiellt transparent, även om nästan varje kodrad i den använder mutation.

Överväg att kapsla in föränderliga data i klasser

I föregående exempel användes en enda funktion för att kapsla in åtgärder med hjälp av föränderliga data. Detta räcker inte alltid för mer komplexa datauppsättningar. Överväg följande uppsättningar funktioner:

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

Den här koden är performant, men den exponerar den mutationsbaserade datastrukturen som anroparna ansvarar för att underhålla. Detta kan omslutas i en klass utan underliggande medlemmar som kan ändras:

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 kapslar in den underliggande mutationsbaserade datastrukturen, vilket inte tvingar anropare att upprätthålla den underliggande datastrukturen. Klasser är ett kraftfullt sätt att kapsla in data och rutiner som är mutationsbaserade utan att exponera informationen för uppringare.

Föredrar let mutable att ref

Referensceller är ett sätt att representera referensen till ett värde i stället för själva värdet. Även om de kan användas för prestandakritisk kod rekommenderas de inte. Ta följande som exempel:

let kernels =
    let acc = ref Set.empty

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

    !acc |> Seq.toList

Användningen av en referenscell "förorenar" nu all efterföljande kod med att behöva avrefereras och referera till underliggande data igen. Överväg let mutablei stället :

let kernels =
    let mutable acc = Set.empty

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

    acc |> Seq.toList

Förutom den enda mutationspunkten i mitten av lambda-uttrycket kan all annan kod som berör acc göra det på ett sätt som inte skiljer sig från användningen av ett normalt let- bundet oföränderligt värde. Detta gör det enklare att ändra över tid.

Nullvärden och standardvärden

Null-värden bör vanligtvis undvikas i F#. Som standard stöder F#-deklarerade typer inte användningen av literalen null , och alla värden och objekt initieras. Men vissa vanliga .NET-API:er returnerar eller accepterar null-värden och några vanliga . NET-deklarerade typer som matriser och strängar tillåter null-värden. Förekomsten av null värden är dock mycket sällsynt i F#-programmering och en av fördelarna med att använda F# är att undvika null-referensfel i de flesta fall.

Undvik att använda attributet AllowNullLiteral

Som standard stöder F#-deklarerade typer inte användningen av literalen null . Du kan kommentera F#-typer manuellt med AllowNullLiteral för att tillåta detta. Det är dock nästan alltid bättre att undvika detta.

Undvik att använda attributet Unchecked.defaultof<_>

Du kan generera ett null eller nollinitierat värde för en F#-typ med hjälp Unchecked.defaultof<_>av . Detta kan vara användbart när du initierar lagring för vissa datastrukturer, i ett kodningsmönster med höga prestanda eller i samverkan. Du bör dock undvika att använda den här konstruktionen.

Undvik att använda attributet DefaultValue

Som standard måste F#-poster och objekt initieras korrekt vid konstruktion. Attributet DefaultValue kan användas för att fylla i vissa fält med objekt med ett null eller nollinitierat värde. Den här konstruktionen behövs sällan och dess användning bör undvikas.

Om du söker efter null-indata skapar du undantag vid första tillfället

När du skriver ny F#-kod behöver du i praktiken inte söka efter null-indata, såvida du inte förväntar dig att koden ska användas från C# eller andra .NET-språk.

Om du väljer att lägga till kontroller för null-indata utför du kontrollerna vid första tillfället och skapar ett undantag. Till exempel:

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

Av äldre skäl behandlar vissa strängfunktioner i FSharp.Core fortfarande null-värden som tomma strängar och misslyckas inte på null-argument. Men ta inte detta som vägledning och anta inte kodningsmönster som tillskriver någon semantisk betydelse till "null".

Objektprogrammering

F# har fullt stöd för objekt och objektorienterade begrepp (OO). Även om många OO-begrepp är kraftfulla och användbara är inte alla idealiska att använda. Följande listor ger vägledning om kategorier av OO-funktioner på hög nivå.

Överväg att använda dessa funktioner i många situationer:

  • Punkt notation (x.Length)
  • Instansmedlemmar
  • Implicita konstruktorer
  • Statiska medlemmar
  • Indexerarens notation (arr[x]) genom att definiera en Item egenskap
  • Segmentering av notation (arr[x..y], arr[x..], arr[..y]), genom att GetSlice definiera medlemmar
  • Namngivna och valfria argument
  • Gränssnitt och gränssnittsimplementeringar

Sträck dig inte efter de här funktionerna först, men tillämpa dem klokt när de är praktiska att lösa ett problem:

  • Metodöverlagring
  • Inkapslade föränderliga data
  • Operatorer för typer
  • Automatiska egenskaper
  • Implementera IDisposable och IEnumerable
  • Typtillägg
  • Händelser
  • Strukturer
  • Delegeringar
  • Uppräkningar

Undvik vanligtvis dessa funktioner om du inte måste använda dem:

  • Arvsbaserade typhierarkier och implementeringsarv
  • Nulls och Unchecked.defaultof<_>

Föredrar sammansättning framför arv

Sammansättning över arv är ett långvarigt formspråk som bra F#-kod kan följa. Den grundläggande principen är att du inte ska exponera en basklass och tvinga anropare att ärva från den basklassen för att få funktioner.

Använda objektuttryck för att implementera gränssnitt om du inte behöver en klass

Med objektuttryck kan du implementera gränssnitt i farten och binda det implementerade gränssnittet till ett värde utan att behöva göra det i en klass. Detta är praktiskt, särskilt om du bara behöver implementera gränssnittet och inte behöver en fullständig klass.

Här är till exempel den kod som körs i Ionide för att ange en kodkorrigeringsåtgärd om du har lagt till en symbol som du inte har någon open instruktion för:

    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
        }

Eftersom det inte finns något behov av en klass när du interagerar med Visual Studio Code-API:et är objektuttryck ett idealiskt verktyg för detta. De är också värdefulla för enhetstestning, när du vill stub ut ett gränssnitt med testrutiner på ett improviserat sätt.

Överväg att skriva förkortningar för att förkorta signaturer

Skriv förkortningar är ett praktiskt sätt att tilldela en etikett till en annan typ, till exempel en funktionssignatur eller en mer komplex typ. Följande alias tilldelar till exempel en etikett till vad som behövs för att definiera en beräkning med CNTK, ett djupinlärningsbibliotek:

open CNTK

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

Namnet Computation är ett praktiskt sätt att ange alla funktioner som matchar signaturen som det är alias. Att använda typförkortningar som detta är praktiskt och ger mer kortfattad kod.

Undvik att använda typförkortningar för att representera din domän

Även om type abbreviations är praktiska för att ge ett namn till funktionssignaturer, kan de vara förvirrande när du förkortar andra typer. Tänk på den här förkortningen:

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

Detta kan vara förvirrande på flera sätt:

  • BufferSize är inte en abstraktion. det är bara ett annat namn för ett heltal.
  • Om BufferSize exponeras i ett offentligt API kan det enkelt misstolkas så att det betyder mer än bara int. I allmänhet har domäntyper flera attribut till dem och är inte primitiva typer som int. Den här förkortningen bryter mot det antagandet.
  • Höljet för BufferSize (PascalCase) innebär att den här typen innehåller mer data.
  • Det här aliaset ger inte ökad klarhet jämfört med att tillhandahålla ett namngivet argument till en funktion.
  • Förkortningen visas inte i kompilerad IL; det är bara ett heltal och det här aliaset är en kompileringstidskonstruktion.
module Networking =
    ...
    let send data (bufferSize: int) = ...

Sammanfattningsvis är fallgropen med typförkortningar att de inte är abstraktioner över de typer som de förkortar. I föregående exempel BufferSize är bara en int under täcket, utan extra data, eller några fördelar från typsystemet förutom det som int redan har.

En alternativ metod för att använda typförkortningar för att representera en domän är att använda enstaka falldiskriminerade fackföreningar. Föregående exempel kan modelleras på följande sätt:

type BufferSize = BufferSize of int

Om du skriver kod som fungerar i termer av BufferSize och dess underliggande värde måste du skapa en i stället för att skicka in godtyckliga heltal:

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

Detta minskar sannolikheten för att felaktigt skicka ett godtyckligt heltal till send funktionen, eftersom anroparen måste konstruera en BufferSize typ för att omsluta ett värde innan funktionen anropas.