F#-coderingsconventies
De volgende conventies worden geformuleerd op basis van ervaring met het werken met grote F#-codebases. De vijf principes van goede F#-code vormen de basis van elke aanbeveling. Ze zijn gerelateerd aan de ontwerprichtlijnen voor F#-onderdelen, maar zijn van toepassing op F#-code, niet alleen voor onderdelen zoals bibliotheken.
Code ordenen
F#bevat twee primaire manieren om code te ordenen: modules en naamruimten. Dit zijn vergelijkbaar, maar hebben wel de volgende verschillen:
- Naamruimten worden gecompileerd als .NET-naamruimten. Modules worden gecompileerd als statische klassen.
- Naamruimten zijn altijd het hoogste niveau. Modules kunnen op het hoogste niveau en genest zijn in andere modules.
- Naamruimten kunnen meerdere bestanden omvatten. Modules kunnen niet.
- Modules kunnen worden ingericht met
[<RequireQualifiedAccess>]
en[<AutoOpen>]
.
Aan de hand van de volgende richtlijnen kunt u deze gebruiken om uw code te ordenen.
Geef de voorkeur aan naamruimten op het hoogste niveau
Voor elke openbaar verbruikbare code zijn naamruimten een voorkeur voor modules op het hoogste niveau. Omdat ze zijn gecompileerd als .NET-naamruimten, kunnen ze worden gebruikt vanuit C# zonder dat ze worden gebruikt.using static
// Recommended.
namespace MyCode
type MyClass() =
...
Het gebruik van een module op het hoogste niveau lijkt mogelijk niet anders wanneer deze alleen wordt aangeroepen vanuit F#, maar voor C#-gebruikers is het mogelijk dat bellers verrast zijn door in aanmerking te komen MyClass
met de module wanneer ze niet op de MyCode
hoogte zijn van de specifieke using static
C#-constructie.
// Will be seen as a static class outside F#
module MyCode
type MyClass() =
...
Pas zorgvuldig toe [<AutoOpen>]
De [<AutoOpen>]
constructie kan het bereik van wat beschikbaar is voor bellers vervuilen en het antwoord op waar iets vandaan komt is 'magie'. Dit is geen goede zaak. Een uitzondering op deze regel is de F# Core Library zelf (hoewel dit feit ook een beetje controversieel is).
Het is echter handig als u helperfunctionaliteit hebt voor een openbare API die u afzonderlijk van die openbare API wilt organiseren.
module MyAPI =
[<AutoOpen>]
module private Helpers =
let helper1 x y z =
...
let myFunction1 x =
let y = ...
let z = ...
helper1 x y z
Hiermee kunt u de implementatiedetails van de openbare API van een functie op een schone manier scheiden zonder dat u elke keer dat u deze aanroept, volledig in aanmerking hoeft te komen.
Daarnaast kunnen uitbreidingsmethoden en opbouwfuncties voor expressies op het niveau van de naamruimte netjes worden uitgedrukt met [<AutoOpen>]
.
Gebruik [<RequireQualifiedAccess>]
wanneer namen conflicteren of u denkt dat het helpt bij de leesbaarheid
Het toevoegen van het [<RequireQualifiedAccess>]
kenmerk aan een module geeft aan dat de module mogelijk niet wordt geopend en dat verwijzingen naar de elementen van de module expliciete gekwalificeerde toegang vereisen. De module heeft bijvoorbeeld Microsoft.FSharp.Collections.List
dit kenmerk.
Dit is handig wanneer functies en waarden in de module namen bevatten die waarschijnlijk conflicteren met namen in andere modules. Het vereisen van gekwalificeerde toegang kan de onderhoudbaarheid op lange termijn aanzienlijk vergroten en de mogelijkheid van een bibliotheek om te ontwikkelen.
[<RequireQualifiedAccess>]
module StringTokenization =
let parse s = ...
...
let s = getAString()
let parsed = StringTokenization.parse s // Must qualify to use 'parse'
Sorteerinstructies open
topologisch
In F# is de volgorde van declaraties van belang, inclusief met open
instructies (en open type
, net verderop genoemd open
). Dit is in tegenstelling tot C#, waarbij het effect van using
en using static
onafhankelijk is van de volgorde van deze instructies in een bestand.
In F# kunnen elementen die zijn geopend in een bereik, anderen schaduw geven die al aanwezig zijn. Dit betekent dat het opnieuw ordenen open
van instructies de betekenis van code kan wijzigen. Als gevolg hiervan wordt willekeurige sortering van alle open
instructies (bijvoorbeeld alfanumeriek) niet aanbevolen, zodat u geen ander gedrag genereert dat u zou verwachten.
In plaats daarvan raden we u aan deze topologisch te sorteren. Dat wil gezegd, uw open
instructies ordenen in de volgorde waarin lagen van uw systeem worden gedefinieerd. Het uitvoeren van alfanumerieke sortering binnen verschillende topologische lagen kan ook worden overwogen.
Hier volgt bijvoorbeeld de topologische sortering voor het openbare API-bestand van de F#-compilerservice:
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
Een lijneinde scheidt topologische lagen, waarbij elke laag daarna alfanumerisch wordt gesorteerd. Hiermee wordt code op een schone manier ingedeeld zonder per ongeluk waarden te schaduwen.
Klassen gebruiken om waarden te bevatten die bijwerkingen hebben
Het initialiseren van een waarde kan vaak bijwerkingen hebben, zoals het instantiëren van een context naar een database of een andere externe resource. Het is verleidelijk om dergelijke dingen in een module te initialiseren en te gebruiken in volgende functies:
// Not recommended, side-effect at static initialization
module MyApi =
let dep1 = File.ReadAllText "/Users/<name>/config-options.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
Dit is om enkele redenen vaak problematisch:
Eerst wordt de toepassingsconfiguratie naar de codebasis gepusht met dep1
en dep2
. Dit is moeilijk te onderhouden in grotere codebases.
Ten tweede mogen statisch geïnitialiseerde gegevens geen waarden bevatten die niet threadveilig zijn als uw onderdeel zelf meerdere threads gebruikt. Dit wordt duidelijk geschonden door dep3
.
Ten slotte wordt module-initialisatie gecompileerd in een statische constructor voor de gehele compilatie-eenheid. Als er een fout optreedt in de initialisatie van let-gebonden waarden in die module, wordt deze weergegeven als een TypeInitializationException
die vervolgens in de cache wordt opgeslagen voor de gehele levensduur van de toepassing. Dit kan moeilijk te diagnosticeren zijn. Er is meestal een interne uitzondering die u kunt proberen om te redeneren, maar als dat niet het is, is er geen informatie over wat de hoofdoorzaak is.
Gebruik in plaats daarvan een eenvoudige klasse om afhankelijkheden vast te houden:
type MyParametricApi(dep1, dep2, dep3) =
member _.Function1 arg1 = doStuffWith dep1 dep2 dep3 arg1
member _.Function2 arg2 = doStuffWith dep1 dep2 dep3 arg2
Dit maakt het volgende mogelijk:
- Pushen van afhankelijke status buiten de API zelf.
- De configuratie kan nu buiten de API worden uitgevoerd.
- Fouten in initialisatie voor afhankelijke waarden zullen waarschijnlijk niet als een
TypeInitializationException
. - De API is nu eenvoudiger te testen.
Foutbeheer
Foutbeheer in grote systemen is een complexe en genuanceerde inspanning en er zijn geen zilveren opsommingstekens om ervoor te zorgen dat uw systemen fouttolerant zijn en goed werken. De volgende richtlijnen moeten richtlijnen bieden voor het navigeren in deze moeilijke ruimte.
Vertegenwoordigen foutcases en ongeldige status in typen die intrinsiek zijn voor uw domein
Met gediscrimineerde unions geeft F# u de mogelijkheid om de status van het foutieve programma in uw typesysteem weer te geven. Voorbeeld:
type MoneyWithdrawalResult =
| Success of amount:decimal
| InsufficientFunds of balance:decimal
| CardExpired of DateTime
| UndisclosedFailure
In dit geval zijn er drie bekende manieren waarop het intrekken van geld van een bankrekening kan mislukken. Elke foutcase wordt weergegeven in het type en kan dus veilig worden afgehandeld in het hele 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"
Als u in het algemeen de verschillende manieren kunt modelleren waarop iets in uw domein kan mislukken , wordt foutafhandelingscode niet meer behandeld als iets waarmee u moet omgaan naast de normale programmastroom. Het is gewoon een onderdeel van de normale programmastroom en wordt niet beschouwd als uitzonderlijk. Er zijn twee primaire voordelen:
- Het is eenvoudiger om te onderhouden naarmate uw domein na verloop van tijd verandert.
- Foutcases zijn eenvoudiger om eenheidstests te testen.
Uitzonderingen gebruiken wanneer fouten niet kunnen worden weergegeven met typen
Niet alle fouten kunnen worden weergegeven in een probleemdomein. Dit soort fouten zijn uitzonderlijk van aard, vandaar de mogelijkheid om uitzonderingen in F# te genereren en te vangen.
Ten eerste is het raadzaam om de richtlijnen voor uitzonderingsontwerp te lezen. Deze zijn ook van toepassing op F#.
De belangrijkste constructies die beschikbaar zijn in F# voor het genereren van uitzonderingen, moeten in de volgende volgorde van voorkeur worden overwogen:
Functie | Syntaxis | Doel |
---|---|---|
nullArg |
nullArg "argumentName" |
Hiermee wordt een System.ArgumentNullException met de opgegeven argumentnaam weergegeven. |
invalidArg |
invalidArg "argumentName" "message" |
Hiermee wordt een System.ArgumentException met een opgegeven argumentnaam en een opgegeven bericht weergegeven. |
invalidOp |
invalidOp "message" |
Hiermee wordt een System.InvalidOperationException met het opgegeven bericht weergegeven. |
raise |
raise (ExceptionType("message")) |
Mechanisme voor algemeen gebruik voor het genereren van uitzonderingen. |
failwith |
failwith "message" |
Hiermee wordt een System.Exception met het opgegeven bericht weergegeven. |
failwithf |
failwithf "format string" argForFormatString |
Hiermee wordt een System.Exception bericht weergegeven dat wordt bepaald door de notatietekenreeks en de bijbehorende invoer. |
Gebruik nullArg
, invalidArg
en invalidOp
als het mechanisme om te gooien ArgumentNullException
, ArgumentException
en InvalidOperationException
indien nodig.
De failwith
en failwithf
functies moeten over het algemeen worden vermeden omdat ze het basistype Exception
verhogen, niet een specifieke uitzondering. Op basis van de ontwerprichtlijnen voor uitzonderingen wilt u specifiekere uitzonderingen genereren wanneer u dat kunt.
Syntaxis voor uitzonderingsafhandeling gebruiken
F# ondersteunt uitzonderingspatronen via de try...with
syntaxis:
try
tryGetFileContents()
with
| :? System.IO.FileNotFoundException as e -> // Do something with it here
| :? System.Security.SecurityException as e -> // Do something with it here
Het afstemmen van de functionaliteit die moet worden uitgevoerd in het gezicht van een uitzondering met patroonkoppeling, kan een beetje lastig zijn als u de code schoon wilt houden. Een dergelijke manier om dit te verwerken, is om actieve patronen te gebruiken als een manier om functionaliteit te groeperen rond een foutcase met een uitzondering zelf. U gebruikt bijvoorbeeld een API die, wanneer er een uitzondering wordt gegenereerd, waardevolle informatie in de metagegevens van de uitzondering plaatst. Het uitpakken van een nuttige waarde in de hoofdtekst van de vastgelegde uitzondering in het actieve patroon en het retourneren van die waarde kan in sommige situaties nuttig zijn.
Gebruik geen monadische foutafhandeling om uitzonderingen te vervangen
Uitzonderingen worden vaak gezien als taboe in het pure functionele paradigma. Uitzonderingen schenden inderdaad de zuiverheid, dus het is veilig om ze niet functioneel puur te beschouwen. Dit negeert echter de realiteit van waar code moet worden uitgevoerd en die runtimefouten kunnen optreden. Schrijf in het algemeen code op basis van de veronderstelling dat de meeste dingen niet puur of totaal zijn, om onaangename verrassingen te minimaliseren (vergelijkbaar met leeg catch
in C# of het misbeheer van de stack-trace, het negeren van informatie).
Het is belangrijk om rekening te houden met de volgende kernsterkten/aspecten van uitzonderingen met betrekking tot hun relevantie en geschiktheid in de .NET-runtime en het ecosysteem voor meerdere talen als geheel:
- Ze bevatten gedetailleerde diagnostische informatie, wat handig is bij het opsporen van fouten in een probleem.
- Ze zijn goed begrepen door de runtime en andere .NET-talen.
- Ze kunnen een aanzienlijk standaardplaatje verminderen in vergelijking met code die buiten de weg gaat om uitzonderingen te voorkomen door een subset van hun semantiek op ad-hocbasis te implementeren.
Dit derde punt is kritiek. Voor niet-triviale complexe bewerkingen kan het gebruik van uitzonderingen leiden tot het verwerken van structuren als volgt:
Result<Result<MyType, string>, string list>
Dit kan eenvoudig leiden tot fragiele code, zoals patroonkoppeling bij 'tekenreeksgetypeerde' fouten:
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?
Bovendien kan het verleidelijk zijn om een uitzondering in te slikken in de wens naar een 'eenvoudige' functie die een 'mooier' type retourneert:
// Can be problematic due to discarding the cause of error.
let tryReadAllText (path : string) =
try System.IO.File.ReadAllText path |> Some
with _ -> None
tryReadAllText
Helaas kunnen er talloze uitzonderingen optreden op basis van de talloze dingen die op een bestandssysteem kunnen gebeuren, en met deze code worden alle informatie over wat er in uw omgeving daadwerkelijk fout kan gaan, verwijderd. Als u deze code vervangt door een resultaattype, gaat u terug naar het foutbericht 'tekenreeks getypt' parseren:
// 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 ...
En het plaatsen van het uitzonderingsobject zelf in de Error
constructor dwingt u alleen om correct om te gaan met het uitzonderingstype op de oproepsite in plaats van in de functie. Als u dit doet, worden er effectief gecontroleerde uitzonderingen gemaakt, die notoir onbuchtig zijn om mee om te gaan als aanroeper van een API.
Een goed alternatief voor de bovenstaande voorbeelden is het vangen van specifieke uitzonderingen en het retourneren van een zinvolle waarde in de context van die uitzondering. Als u de tryReadAllText
functie als volgt wijzigt, None
heeft dit meer betekenis:
let tryReadAllTextIfPresent (path : string) =
try System.IO.File.ReadAllText path |> Some
with :? FileNotFoundException -> None
In plaats van als catch-all te werken, wordt met deze functie de case nu correct afgehandeld wanneer een bestand niet is gevonden en die betekenis aan een retour wordt toegewezen. Deze retourwaarde kan worden toegewezen aan die foutcase, terwijl er geen contextuele informatie wordt genegeerd of aanroepers wordt gedwongen om een case af te handelen die mogelijk niet relevant is op dat moment in de code.
Typen zoals Result<'Success, 'Error>
geschikt zijn voor basisbewerkingen waarbij ze niet zijn genest en F#-optionele typen zijn perfect voor weergave wanneer iets iets of niets kan retourneren. Ze zijn echter geen vervanging voor uitzonderingen en mogen niet worden gebruikt in een poging om uitzonderingen te vervangen. In plaats daarvan moeten ze zorgvuldig worden toegepast om specifieke aspecten van uitzonderings- en foutbeheerbeleid op gerichte manieren aan te pakken.
Gedeeltelijke toepassing en point-free programmering
F# ondersteunt gedeeltelijke toepassing en dus verschillende manieren om te programmeren in een puntvrije stijl. Dit kan nuttig zijn voor het hergebruik van code in een module of de implementatie van iets, maar het is niet iets om openbaar te maken. Over het algemeen is puntvrije programmering geen deugd op zichzelf en kan een aanzienlijke cognitieve barrière toevoegen voor mensen die niet in de stijl zijn onderdompeld.
Gebruik geen gedeeltelijke toepassing en kerrie in openbare API's
Met weinig uitzondering kan het gebruik van gedeeltelijke toepassingen in openbare API's verwarrend zijn voor consumenten. let
Meestal zijn -gebonden waarden in F#-code waarden, geen functiewaarden. Het combineren van waarden en functiewaarden kan ertoe leiden dat een paar regels code worden opgeslagen in ruil voor een behoorlijke hoeveelheid cognitieve overhead, vooral in combinatie met operators zoals >>
het opstellen van functies.
Houd rekening met de gevolgen voor hulpprogramma's voor puntloze programmering
Curriede functies labelen hun argumenten niet. Dit heeft gevolgen voor hulpprogramma's. Houd rekening met de volgende twee functies:
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!"
Beide zijn geldige functies, maar funcWithApplication
is een curriede functie. Wanneer u de muisaanwijzer op hun typen in een editor plaatst, ziet u het volgende:
val func : name:string -> age:int -> unit
val funcWithApplication : (string -> int -> unit)
Op de oproepsite geven knopinfo in hulpprogramma's zoals Visual Studio u het typehandtekening, maar omdat er geen namen zijn gedefinieerd, worden geen namen weergegeven. Namen zijn essentieel voor een goed API-ontwerp, omdat ze bellers helpen de betekenis achter de API beter te begrijpen. Het gebruik van puntvrije code in de openbare API kan het voor bellers moeilijker maken om ze te begrijpen.
Als u puntloze code zoals funcWithApplication
die openbaar verbruiksbaar tegenkomt, is het raadzaam om een volledige η-uitbreiding uit te voeren, zodat hulpprogramma's zinvolle namen voor argumenten kunnen ophalen.
Bovendien kan foutopsporing van puntvrije code lastig zijn, als dat niet onmogelijk is. Foutopsporingsprogramma's zijn afhankelijk van waarden die zijn gebonden aan namen (bijvoorbeeld let
bindingen), zodat u tussenliggende waarden halverwege de uitvoering kunt inspecteren. Wanneer uw code geen waarden heeft om te controleren, is er niets om fouten op te sporen. In de toekomst kunnen foutopsporingsprogramma's zich ontwikkelen om deze waarden te synthetiseren op basis van eerder uitgevoerde paden, maar het is geen goed idee om uw weddenschappen af te sluiten op mogelijke foutopsporingsfunctionaliteit.
Overweeg gedeeltelijke toepassing als techniek om interne standaard te verminderen
In tegenstelling tot het vorige punt is gedeeltelijke toepassing een prachtig hulpmiddel voor het verminderen van standaard in een toepassing of de diepere interne werking van een API. Het kan handig zijn voor eenheidstests van de implementatie van complexere API's, waarbij standaard vaak een pijn is om mee om te gaan. De volgende code laat bijvoorbeeld zien hoe u kunt bereiken wat de meeste mocking-frameworks u bieden zonder dat u een externe afhankelijkheid van een dergelijk framework hoeft te nemen en een gerelateerde op maat gemaakte API moet leren.
Denk bijvoorbeeld aan de volgende oplossingstopografie:
MySolution.sln
|_/ImplementationLogic.fsproj
|_/ImplementationLogic.Tests.fsproj
|_/API.fsproj
ImplementationLogic.fsproj
kan code beschikbaar maken, zoals:
module Transactions =
let doTransaction txnContext txnType balance =
...
type Transactor(ctx, currentBalance) =
member _.ExecuteTransaction(txnType) =
Transactions.doTransaction ctx txnType currentBalance
...
Het testen Transactions.doTransaction
van eenheden is ImplementationLogic.Tests.fsproj
eenvoudig:
namespace TransactionsTestingUtil
open Transactions
module TransactionsTestable =
let getTestableTransactionRoutine mockContext = Transactions.doTransaction mockContext
Gedeeltelijk toepassen doTransaction
met een gesimuleerd contextobject kunt u de functie in al uw eenheidstests aanroepen zonder dat u telkens een gesimuleerde context hoeft te maken:
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)
Pas deze techniek niet universeel toe op uw hele codebasis, maar het is een goede manier om het standaardplaatje te verminderen voor gecompliceerde interne functies en het testen van die interne functies.
Toegangsbeheer
F# heeft meerdere opties voor toegangsbeheer, overgenomen van wat beschikbaar is in de .NET-runtime. Deze zijn niet alleen bruikbaar voor typen. U kunt ze ook gebruiken voor functies.
Goede procedures in de context van bibliotheken die veel worden gebruikt:
- Geef de voorkeur aan niet-typen
public
en leden totdat u ze nodig hebt om openbaar te kunnen worden gebruikt. Dit minimaliseert ook het aantal consumenten. - Probeer alle helperfunctionaliteit
private
te behouden. - Overweeg het gebruik van
[<AutoOpen>]
een privémodule met helperfuncties als deze talrijk worden.
Typ deductie en generics
Typedeductie kan u besparen door veel standaard te typen. En automatische generalisatie in de F#-compiler kan u helpen bij het schrijven van meer algemene code zonder dat u extra moeite hoeft te doen. Deze functies zijn echter niet universeel goed.
Overweeg namen van argumenten met expliciete typen in openbare API's te labelen en niet te vertrouwen op typedeductie hiervoor.
De reden hiervoor is dat u de vorm van uw API moet beheren, niet de compiler. Hoewel de compiler een goede taak kan uitvoeren bij het uitstellen van typen voor u, is het mogelijk om de vorm van uw API te wijzigen als de interne kenmerken waarvan deze afhankelijk is, gewijzigde typen hebben. Dit kan wat u wilt, maar dit zal bijna zeker resulteren in een belangrijke API-wijziging waarmee downstreamgebruikers dan te maken hebben. Als u in plaats daarvan expliciet de vorm van uw openbare API bepaalt, kunt u deze belangrijke wijzigingen beheren. In DDD-termen kan dit worden beschouwd als een anti-corruptielaag.
Overweeg een zinvolle naam te geven aan uw algemene argumenten.
Tenzij u echt algemene code schrijft die niet specifiek is voor een bepaald domein, kan een zinvolle naam andere programmeurs helpen het domein te begrijpen waarin ze werken. Een typeparameter die wordt genoemd
'Document
in de context van interactie met een documentdatabase maakt het bijvoorbeeld duidelijker dat algemene documenttypen kunnen worden geaccepteerd door de functie of het lid waarmee u werkt.Overweeg algemene typeparameters te benoemen met PascalCase.
Dit is de algemene manier om dingen te doen in .NET, dus het is raadzaam dat u PascalCase gebruikt in plaats van snake_case of camelCase.
Ten slotte is automatische generalisatie niet altijd een zegen voor mensen die niet bekend zijn met F# of een grote codebasis. Er is cognitieve overhead bij het gebruik van onderdelen die algemeen zijn. Bovendien, als automatisch gegeneraliseerde functies niet worden gebruikt met verschillende invoertypen (laat staan als ze zijn bedoeld om als zodanig te worden gebruikt), is er geen echt voordeel voor hen algemeen dan. Houd er altijd rekening mee als de code die u schrijft, daadwerkelijk baat heeft bij algemeen gebruik.
Prestaties
Overweeg structs voor kleine typen met hoge toewijzingssnelheden
Het gebruik van structs (ook wel waardetypen genoemd) kan vaak leiden tot hogere prestaties voor bepaalde code, omdat objecten doorgaans niet worden toegewezen. Structs zijn echter niet altijd een knop 'sneller gaan': als de grootte van de gegevens in een struct groter is dan 16 bytes, kan het kopiëren van de gegevens vaak resulteren in meer CPU-tijd dan het gebruik van een verwijzingstype.
Als u wilt bepalen of u een struct moet gebruiken, moet u rekening houden met de volgende voorwaarden:
- Als de grootte van uw gegevens 16 bytes of kleiner is.
- Als u waarschijnlijk veel exemplaren van deze typen in het geheugen in een actief programma hebt.
Als de eerste voorwaarde van toepassing is, moet u over het algemeen een struct gebruiken. Als beide van toepassing zijn, moet u bijna altijd een struct gebruiken. Er kunnen enkele gevallen zijn waarin de vorige voorwaarden van toepassing zijn, maar het gebruik van een struct is niet beter of slechter dan het gebruik van een verwijzingstype, maar ze zijn waarschijnlijk zeldzaam. Het is belangrijk om altijd te meten bij het aanbrengen van wijzigingen zoals deze, en niet op aanname of intuïtieve wijze te werken.
Overweeg struct tuples bij het groeperen van kleine waardetypen met hoge toewijzingssnelheden
Houd rekening met de volgende twee functies:
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)
Wanneer u deze functies benchmarkt met een hulpprogramma voor statistische benchmarking, zoals BenchmarkDotNet, zult u merken dat de runWithStructTuple
functie die gebruikmaakt van struct tuples 40% sneller wordt uitgevoerd en geen geheugen toewijst.
Deze resultaten zijn echter niet altijd het geval in uw eigen code. Als u een functie markeert als inline
, kan code die gebruikmaakt van verwijzings-tuples extra optimalisaties krijgen, of code die zou worden toegewezen, eenvoudigweg worden geoptimaliseerd. U moet altijd resultaten meten wanneer de prestaties betrokken zijn en nooit werken op basis van veronderstellingen of intuïtiefheid.
Overweeg struct-records wanneer het type klein is en hoge toewijzingssnelheden heeft
De eerder beschreven vuistregel bevat ook voor F#-recordtypen. Houd rekening met de volgende gegevenstypen en functies waarmee ze worden verwerkt:
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)
Dit is vergelijkbaar met de vorige tuple-code, maar deze keer gebruikt het voorbeeld records en een inline binnenste functie.
Wanneer u deze functies benchmarkt met een hulpprogramma voor statistische benchmarking, zoals BenchmarkDotNet, zult u merken dat processStructPoint
deze bijna 60% sneller wordt uitgevoerd en niets toewijst aan de beheerde heap.
Overweeg gediscrimineerde samenvoegingen te geven wanneer het gegevenstype klein is met hoge toewijzingspercentages
De vorige waarnemingen over prestaties met struct tuples en records bevatten ook voor F# Gediscrimineerde unions. Kijk eens naar de volgende code:
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
Het is gebruikelijk om gediscrimineerde unions met één geval te definiëren, zoals deze voor domeinmodellering. Wanneer u deze functies benchmarkt met een hulpprogramma voor statistische benchmarking, zoals BenchmarkDotNet, vindt u dat structReverseName
ongeveer 25% sneller wordt uitgevoerd dan reverseName
voor kleine tekenreeksen. Voor grote tekenreeksen worden beide ongeveer hetzelfde uitgevoerd. In dit geval is het dus altijd beter om een struct te gebruiken. Zoals eerder vermeld, meet en niet op veronderstellingen of intuïtieve wijze.
Hoewel uit het vorige voorbeeld blijkt dat een gediscrimineerde unie betere prestaties oplevert, is het gebruikelijk om grotere gediscrimineerde unies te hebben bij het modelleren van een domein. Grotere gegevenstypen zoals die kunnen niet zo goed presteren als ze structs zijn, afhankelijk van de bewerkingen erop, omdat er meer kopieerbewerkingen kunnen worden gebruikt.
Onveranderbaarheid en mutatie
F#-waarden zijn standaard onveranderbaar, waardoor u bepaalde klassen bugs kunt voorkomen (met name de klassen gelijktijdigheid en parallelle uitvoering). In bepaalde gevallen kan echter, om een optimale (of zelfs redelijke) efficiëntie van uitvoeringstijd of geheugentoewijzingen te bereiken, een periode van werk het beste worden geïmplementeerd met behulp van in-place mutatie van de toestand. Dit is mogelijk in een opt-in basis met F# met het mutable
trefwoord.
Het gebruik van mutable
in F# voelt zich mogelijk in onevenheid met functionele zuiverheid. Dit is begrijpelijk, maar functionele zuiverheid overal kan in strijd zijn met prestatiedoelen. Een compromis is het inkapselen van mutatie, zodat bellers niet hoeven te bepalen wat er gebeurt wanneer ze een functie aanroepen. Hiermee kunt u een functionele interface schrijven over een op mutatie gebaseerde implementatie voor prestatiekritieke code.
Met F# let
-bindingsconstructies kunt u bindingen in een andere nesten. Dit kan worden gebruikt om het bereik van mutable
de variabele dicht te houden of op de theoretische kleinste.
let data =
[
let mutable completed = false
while not completed do
logic ()
// ...
if someCondition then
completed <- true
]
Geen code heeft toegang tot de veranderlijke completed
code die alleen is gebruikt om de afhankelijke waarde te initialiseren data
.
Onveranderbare code verpakken in onveranderbare interfaces
Met referentiële transparantie als doel is het essentieel om code te schrijven die niet de onveranderbare onderbel van prestatiekritieke functies blootstelt. Met de volgende code wordt bijvoorbeeld de Array.contains
functie geïmplementeerd in de F#-kernbibliotheek:
[<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
Als u deze functie meerdere keren aanroept, wordt de onderliggende matrix niet gewijzigd en hoeft u ook geen onveranderbare status te behouden bij het verbruik ervan. Het is referentieel transparant, ook al maakt bijna elke coderegel in het gebruik van mutatie.
Overweeg het inkapselen van veranderlijke gegevens in klassen
In het vorige voorbeeld werd één functie gebruikt om bewerkingen in te kapselen met veranderlijke gegevens. Dit is niet altijd voldoende voor complexere gegevenssets. Houd rekening met de volgende sets functies:
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
Deze code is performant, maar toont de op mutatie gebaseerde gegevensstructuur die bellers verantwoordelijk zijn voor onderhoud. Dit kan worden verpakt in een klasse zonder onderliggende leden die kunnen veranderen:
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
kapselt de onderliggende gegevensstructuur op basis van mutatie in, waardoor bellers niet gedwongen worden om de onderliggende gegevensstructuur te behouden. Klassen zijn een krachtige manier om gegevens en routines in te kapselen die op mutatie zijn gebaseerd zonder de details aan bellers bloot te stellen.
Liever let mutable
ref
Verwijzingscellen zijn een manier om de verwijzing naar een waarde weer te geven in plaats van de waarde zelf. Hoewel ze kunnen worden gebruikt voor prestatiekritieke code, worden ze niet aanbevolen. Kijk een naar het volgende voorbeeld:
let kernels =
let acc = ref Set.empty
processWorkList startKernels (fun kernel ->
if not ((!acc).Contains(kernel)) then
acc := (!acc).Add(kernel)
...)
!acc |> Seq.toList
Het gebruik van een verwijzingscel vervuilt nu alle volgende code met het opnieuw afleiden en opnieuw verwijzen naar de onderliggende gegevens. In plaats daarvan kunt u overwegen let mutable
:
let kernels =
let mutable acc = Set.empty
processWorkList startKernels (fun kernel ->
if not (acc.Contains(kernel)) then
acc <- acc.Add(kernel)
...)
acc |> Seq.toList
Afgezien van het single point of mutatie in het midden van de lambda-expressie, kunnen alle andere code die aanraakt acc
dit doen op een manier die niet verschilt van het gebruik van een normale let
onveranderbare waarde. Hierdoor is het na verloop van tijd gemakkelijker te wijzigen.
Null-waarden en standaardwaarden
Null-waarden moeten over het algemeen worden vermeden in F#. Standaard bieden F#-gedeclareerde typen geen ondersteuning voor het gebruik van de null
letterlijke waarde en worden alle waarden en objecten geïnitialiseerd. Sommige veelgebruikte .NET-API's retourneren of accepteren null-waarden, en enkele veelvoorkomende. Net-gedeclareerde typen, zoals matrices en tekenreeksen, staan null-waarden toe. Het optreden van null
waarden is echter zeer zeldzaam in F#-programmering en een van de voordelen van het gebruik van F# is om null-verwijzingsfouten in de meeste gevallen te voorkomen.
Vermijd het gebruik van het AllowNullLiteral
kenmerk
Standaard bieden F#-gedeclareerde typen geen ondersteuning voor het gebruik van de null
letterlijke gegevens. U kunt handmatig aantekeningen toevoegen aan F#-typen om AllowNullLiteral
dit toe te staan. Het is echter bijna altijd beter om dit te voorkomen.
Vermijd het gebruik van het Unchecked.defaultof<_>
kenmerk
Het is mogelijk om een null
of nul geïnitialiseerde waarde te genereren voor een F#-type met behulp van Unchecked.defaultof<_>
. Dit kan handig zijn bij het initialiseren van de opslag voor bepaalde gegevensstructuren, in een codepatroon met hoge prestaties of interoperabiliteit. Het gebruik van deze constructie moet echter worden vermeden.
Vermijd het gebruik van het DefaultValue
kenmerk
Standaard moeten F#-records en -objecten correct worden geïnitialiseerd tijdens de constructie. Het DefaultValue
kenmerk kan worden gebruikt om bepaalde velden met objecten te vullen met een null
of nul-geïnitialiseerde waarde. Deze constructie is zelden nodig en het gebruik ervan moet worden vermeden.
Als u controleert op null-invoer, genereert u uitzonderingen bij de eerste verkoopkans
Bij het schrijven van nieuwe F#-code hoeft u in de praktijk niet te controleren op null-invoer, tenzij u verwacht dat die code wordt gebruikt vanuit C# of andere .NET-talen.
Als u besluit controles voor null-invoer toe te voegen, voert u de controles bij de eerste verkoopkans uit en genereert u een uitzondering. Voorbeeld:
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
Om oudere redenen behandelen sommige tekenreeksfuncties in FSharp.Core null-waarden nog steeds als lege tekenreeksen en mislukken ze niet op null-argumenten. Neem dit echter niet als richtlijn en gebruik geen coderingspatronen die een semantische betekenis aan 'null' toewijzen.
Objectprogrammering
F# biedt volledige ondersteuning voor objecten en objectgeoriënteerde (OO) concepten. Hoewel veel OO-concepten krachtig en nuttig zijn, zijn ze niet allemaal ideaal om te gebruiken. De volgende lijsten bevatten richtlijnen voor categorieën OO-functies op hoog niveau.
Overweeg het gebruik van deze functies in veel situaties:
- Punt notatie (
x.Length
) - Exemplaarleden
- Impliciete constructors
- Statische leden
- Notatie voor indexeerfunctie (
arr[x]
), door eenItem
eigenschap te definiëren - Segmenterings notatie (
arr[x..y]
,arr[x..]
,arr[..y]
), door leden te definiërenGetSlice
- Benoemde en optionele argumenten
- Interfaces en interface-implementaties
Bereik deze functies niet eerst, maar pas ze zorgvuldig toe wanneer ze handig zijn om een probleem op te lossen:
- Overbelasting van methode
- Onverkapselde onveranderbare gegevens
- Operators voor typen
- Automatische eigenschappen
- Implementeren
IDisposable
enIEnumerable
- Type-extensies
- gebeurtenis
- Structs
- Gedelegeerden
- Enums
Vermijd over het algemeen deze functies, tenzij u ze moet gebruiken:
- Op overname gebaseerde typehiërarchieën en overname van implementatie
- Null-waarden en
Unchecked.defaultof<_>
Voorkeur aan samenstelling boven overname
Samenstelling over overname is een langlopende idioom waaraan goede F#-code kan voldoen. Het fundamentele principe is dat u geen basisklasse beschikbaar moet maken en bellers dwingen om over te nemen van die basisklasse om functionaliteit te verkrijgen.
Objectexpressies gebruiken om interfaces te implementeren als u geen klasse nodig hebt
Met objectexpressies kunt u snel interfaces implementeren en de geïmplementeerde interface binden aan een waarde zonder dat u dit binnen een klasse hoeft te doen. Dit is handig, vooral als u alleen de interface hoeft te implementeren en geen volledige klasse nodig hebt.
Hier ziet u bijvoorbeeld de code die wordt uitgevoerd in Ionide om een actie voor het oplossen van code op te geven als u een symbool hebt toegevoegd waarvoor u geen instructie hebt open
:
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
}
Omdat er geen klasse nodig is bij interactie met de Visual Studio Code-API, zijn objectexpressies hiervoor een ideaal hulpmiddel. Ze zijn ook waardevol voor eenheidstests, als u een interface met testroutines op een geïmproviseerde manier wilt uitstikken.
Overweeg afkortingen te typen om handtekeningen te verkorten
Type afkortingen zijn een handige manier om een label toe te wijzen aan een ander type, zoals een functiehandtekening of een complexer type. Met de volgende alias wordt bijvoorbeeld een label toegewezen aan wat er nodig is voor het definiëren van een berekening met CNTK, een deep learning-bibliotheek:
open CNTK
// DeviceDescriptor, Variable, and Function all come from CNTK
type Computation = DeviceDescriptor -> Variable -> Function
De Computation
naam is een handige manier om een functie aan te geven die overeenkomt met de handtekening die het aliast. Het gebruik van type afkortingen zoals dit is handig en maakt meer beknopte code mogelijk.
Vermijd het gebruik van type afkortingen om uw domein weer te geven
Hoewel type afkortingen handig zijn voor het geven van een naam aan functiehandtekeningen, kunnen ze verwarrend zijn bij het afkorten van andere typen. Houd rekening met deze afkorting:
// Does not actually abstract integers.
type BufferSize = int
Dit kan op verschillende manieren verwarrend zijn:
BufferSize
is geen abstractie; het is gewoon een andere naam voor een geheel getal.- Als
BufferSize
deze wordt weergegeven in een openbare API, kan dit eenvoudig verkeerd worden geïnterpreteerd, zodat het meer betekent dan alleenint
. Over het algemeen hebben domeintypen meerdere kenmerken en zijn dit geen primitieve typen, zoalsint
. Deze afkorting schendt die veronderstelling. - De behuizing van
BufferSize
(PascalCase) impliceert dat dit type meer gegevens bevat. - Deze alias biedt geen betere duidelijkheid in vergelijking met het verstrekken van een benoemd argument aan een functie.
- De afkorting zal niet manifest in gecompileerde IL; het is slechts een geheel getal en deze alias is een compileertijdconstructie.
module Networking =
...
let send data (bufferSize: int) = ...
Kortom, de valkuil met type afkortingen is dat ze geen abstracties zijn van de typen die ze afkorten. In het vorige voorbeeld BufferSize
bevindt zich slechts een int
onder de dekking, zonder extra gegevens en geen voordelen van het typesysteem, naast wat int
er al is.
Een alternatieve methode voor het gebruik van afkortingen van het type dat een domein vertegenwoordigt, is het gebruik van gediscrimineerde unions in één geval. Het vorige voorbeeld kan als volgt worden gemodelleerd:
type BufferSize = BufferSize of int
Als u code schrijft die werkt in termen van BufferSize
en de onderliggende waarde, moet u er een maken in plaats van een willekeurig geheel getal door te geven:
module Networking =
...
let send data (BufferSize size) =
...
Dit vermindert de kans dat per ongeluk een willekeurig geheel getal in de send
functie wordt doorgegeven, omdat de aanroeper een BufferSize
type moet samenstellen om een waarde te verpakken voordat de functie wordt aangeroepen.