Codekonventionen für F#
Die folgenden Konventionen sind das Ergebnis der Arbeit mit großen F#-Codebasen. Die fünf Prinzipien guten F#-Codes bilden die Grundlage jeder Empfehlung. Sie beziehen sich auf die Entwurfsrichtlinien für F#-Komponenten, gelten aber für jeden F#-Code, nicht nur für Komponenten wie Bibliotheken.
Organisieren von Code
F# bietet zwei primäre Möglichkeiten zum Organisieren von Code: Module und Namespaces. Diese sind ähnlich, weisen jedoch die folgenden Unterschiede auf:
- Namespaces werden als .NET-Namespaces kompiliert. Module werden als statische Klassen kompiliert.
- Namespaces befinden sich stets auf der obersten Ebene. Module können sich auf der obersten Ebene befinden und in anderen Modulen geschachtelt sein.
- Namespaces können mehrere Dateien umfassen, Module nicht.
- Module können mit
[<RequireQualifiedAccess>]
und[<AutoOpen>]
ergänzt werden.
Die folgenden Richtlinien helfen Ihnen bei der Verwendung dieser Elemente zur Organisation Ihres Codes.
Bevorzugen von Namespaces auf oberster Ebene
Für jeden öffentlich nutzbaren Code werden Namespaces gegenüber Modulen auf oberster Ebene bevorzugt. Da sie als .NET-Namespaces kompiliert sind, können sie von C# aus konsumiert werden, ohne auf using static
zu greifen.
// Recommended.
namespace MyCode
type MyClass() =
...
Die Verwendung eines Moduls auf oberster Ebene mag nicht anders aussehen, wenn es nur von F# aus aufgerufen wird, aber für C#-Consumer kann es eine Überraschung sein, dass sie sich MyClass
mit dem MyCode
-Modul qualifizieren müssen, wenn sie das spezifische using static
-C#-Konstrukt nicht kennen.
// Will be seen as a static class outside F#
module MyCode
type MyClass() =
...
[<AutoOpen>]
überlegt verwenden
Das Konstrukt [<AutoOpen>]
kann den Umfang dessen, was dem Aufrufer zur Verfügung steht, beeinträchtigen, und die Antwort auf die Frage, woher etwas stammt, ist „magisch“. Das ist nicht gut. Eine Ausnahme dieser Regel ist die F#-Kernbibliothek selbst (obwohl diese Tatsache auch etwas kontrovers ist).
Es ist jedoch praktisch, wenn Sie Hilfsfunktionen für eine öffentliche API haben, die Sie getrennt von dieser öffentlichen API organisieren möchten.
module MyAPI =
[<AutoOpen>]
module private Helpers =
let helper1 x y z =
...
let myFunction1 x =
let y = ...
let z = ...
helper1 x y z
Auf diese Weise können Sie die Details der Implementierung sauber von der öffentlichen API einer Funktion trennen, ohne dass Sie bei jedem Aufruf eine Hilfsfunktion vollständig qualifizieren müssen.
Darüber hinaus können Erweiterungsmethoden und Ausdrucks-Generatoren auf Namespace-Ebene mithilfe von [<AutoOpen>]
ausgedrückt werden.
Verwenden Sie [<RequireQualifiedAccess>]
immer dann, wenn Namen in Konflikt geraten könnten oder meinen, dass dies der Lesbarkeit dient
Das Hinzufügen des Attributs [<RequireQualifiedAccess>]
zu einem Modul gibt an, dass das Modul möglicherweise nicht geöffnet wird und dass Verweise auf die Elemente des Moduls expliziten qualifizierten Zugriff erfordern. Das Modul Microsoft.FSharp.Collections.List
verfügt beispielsweise über dieses Attribut.
Dies ist nützlich, wenn Funktionen und Werte im Modul Namen aufweisen, die wahrscheinlich mit Namen in anderen Modulen in Konflikt stehen. Die Anforderung eines qualifizierten Zugriffs kann die langfristige Wartbarkeit und die Fähigkeit einer Bibliothek, sich weiterzuentwickeln, erheblich verbessern.
[<RequireQualifiedAccess>]
module StringTokenization =
let parse s = ...
...
let s = getAString()
let parsed = StringTokenization.parse s // Must qualify to use 'parse'
Topologisches Sortieren von open
-Anweisungen
In F# ist die Reihenfolge der Deklarationen wichtig, einschließlich der open
Anweisungen (und open type
, die nur weiter unten genannt werden open
). Dies ist anders als bei C#, wo die Wirkung von using
und using static
unabhängig von der Reihenfolge dieser Anweisungen in einer Datei ist.
In F# können Elemente, die in einem Bereich geöffnet werden, ein Shadowing bereits vorhandener Elemente durchführen. Das bedeutet, dass das Neuanordnen von open
-Anweisungen die Bedeutung des Codes verändern kann. Daher ist eine willkürliche Sortierung aller open
-Anweisungen (z. B. alphanumerisch) nicht zu empfehlen, damit Sie nicht ein anderes Verhalten als das erwartete generieren.
Wir empfehlen Ihnen stattdessen, sie topologisch zu sortieren, d. h. Ihre open
-Anweisungen in der Reihenfolge anzuordnen, in der die Ebenen Ihres Systems definiert sind. Die alphanumerische Sortierung innerhalb verschiedener topologischer Ebenen kann ebenfalls erwägt werden.
Hier sehen Sie beispielsweise die topologische Sortierung für die öffentliche API-Datei des F#-Compilerdiensts:
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
Ein Zeilenumbruch trennt topologische Ebenen, wobei jede Ebene anschließend alphanumerisch sortiert wird. Dadurch wird Code sauber organisiert, ohne dass versehentlich ein Shadowing für Werte durchgeführt wird.
Verwenden von Klassen zur Aufnahme von Werten mit Nebenwirkungen
Die Initialisierung eines Werts kann häufig Nebenwirkungen haben, wie z. B. das Instanziieren eines Kontexts für eine Datenbank oder andere Remoteressource. Es ist verlockend, solche Dinge in einem Modul zu initialisieren und in nachfolgenden Funktionen zu verwenden:
// 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
Dies ist aus einigen Gründen häufig problematisch:
Erstens wird die Anwendungskonfiguration mit dep1
und dep2
in die Codebasis gepusht. Dies ist bei größeren Codebasen schwierig zu verwalten.
Zweitens sollten statisch initialisierte Daten keine Werte enthalten, die nicht threadsicher sind, wenn Ihre Komponente selbst mehrere Threads verwendet. Dies wird eindeutig durch dep3
verletzt.
Schließlich wird die Modulinitialisierung in einen statischen Konstruktor für die gesamte Kompilierungseinheit kompiliert. Wenn bei der Initialisierung von Bindungswerten des Typs „let“ in diesem Modul ein Fehler auftritt, manifestiert sich dieser als TypeInitializationException
, der dann für die gesamte Lebensdauer der Anwendung zwischengespeichert wird. Dies kann schwierig zu diagnostizieren sein. In der Regel gibt es eine innere Ausnahme, die Sie zu erklären versuchen können, aber wenn es keine gibt, können Sie nicht sagen, was die eigentliche Grundursache ist.
Verwenden Sie stattdessen einfach eine einfache Klasse zum Aufnehmen von Abhängigkeiten:
type MyParametricApi(dep1, dep2, dep3) =
member _.Function1 arg1 = doStuffWith dep1 dep2 dep3 arg1
member _.Function2 arg2 = doStuffWith dep1 dep2 dep3 arg2
Dies ermöglicht Folgendes:
- Das Pushen eines abhängigen Zustands außerhalb der API selbst.
- Die Konfiguration kann jetzt außerhalb der API erfolgen.
- Fehler bei der Initialisierung für abhängige Werte manifestieren sich wahrscheinlich nicht als
TypeInitializationException
. - Die API ist jetzt einfacher zu testen.
Fehlerverwaltung
Die Fehlerverwaltung in großen Systemen ist ein komplexes und nuanciertes Unterfangen, und es gibt keine Patentrezepte, um sicherzustellen, dass Ihre Systeme fehlertolerant sind und sich einwandfrei verhalten. Die folgenden Leitlinien sollen Ihnen dabei helfen, sich in diesem schwierigen Umfeld zurechtzufinden.
Darstellen von Fehlerfällen und unzulässigen Zuständen in Typen, die Ihrer Domäne eigen sind
Mit diskriminierten Unions gibt Ihnen F# die Möglichkeit, fehlerhafte Programmzustände in Ihrem Typsystem darzustellen. Beispiel:
type MoneyWithdrawalResult =
| Success of amount:decimal
| InsufficientFunds of balance:decimal
| CardExpired of DateTime
| UndisclosedFailure
In diesem Fall gibt es drei bekannte Möglichkeiten, wie das Abheben von Geld von einem Konto fehlschlagen kann. Jeder Fehlerfall wird im Typ dargestellt und kann somit im gesamten Programm sicher behandelt werden.
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"
Wenn Sie die verschiedenen Möglichkeiten modellieren können, wie etwas in Ihrer Domäne fehlschlagen kann, wird Code zur Fehlerbehandlung im Allgemeinen nicht mehr als etwas behandelt, mit dem Sie sich zusätzlich zum regulären Programmablauf beschäftigen müssen. Er ist dann einfach ein Teil des normalen Programmablaufs und gilt nicht als Ausnahme. Dafür sprechen zwei Hauptvorteile:
- Die Verwaltung ist einfacher, sobald sich Ihre Domäne im Laufe der Zeit ändert.
- Für Fehlerfälle sind Komponententests einfacher.
Verwenden von Ausnahmen, wenn Fehler nicht mit Typen dargestellt werden können
Nicht alle Fehler können in einer Problemdomäne dargestellt werden. Diese Arten von Fehlern sind Ausnahmen, weshalb es in F# möglich ist, Ausnahmen zu erzeugen und abzufangen.
Zunächst wird empfohlen, die Leitlinien für den Entwurf von Ausnahmen zu lesen. Diese gelten auch für F#.
Die wichtigsten Konstrukte, die in F# zum Auslösen von Ausnahmen zur Verfügung stehen, sollten in der folgenden Rangfolge berücksichtigt werden:
Funktion | Syntax | Zweck |
---|---|---|
nullArg |
nullArg "argumentName" |
Löst eine System.ArgumentNullException mit dem angegebenen Argumentnamen aus. |
invalidArg |
invalidArg "argumentName" "message" |
Löst eine System.ArgumentException mit dem angegebenen Argumentnamen samt Nachricht aus. |
invalidOp |
invalidOp "message" |
Löst eine System.InvalidOperationException mit der angegebenen Nachricht aus. |
raise |
raise (ExceptionType("message")) |
Allgemeiner Mechanismus zum Auslösen von Ausnahmen. |
failwith |
failwith "message" |
Löst eine System.Exception mit der angegebenen Nachricht aus. |
failwithf |
failwithf "format string" argForFormatString |
Löst eine System.Exception mit einer Nachricht aus, die durch die Formatzeichenfolge und ihre Eingaben bestimmt wird. |
Verwenden Sie nullArg
, invalidArg
und invalidOp
als Mechanismus zum Auslösen von ArgumentNullException
, ArgumentException
und InvalidOperationException
, sofern angebracht.
Die Funktionen failwith
und failwithf
sollten im Allgemeinen vermieden werden, da sie den Basistyp Exception
auslösen und keine spezifische Ausnahme. Gemäß den Leitlinien für den Entwurf von Ausnahmen möchten Sie nach Möglichkeit spezifischere Ausnahmen auslösen.
Verwenden von Syntax zur Behandlung von Ausnahmen
F# unterstützt Ausnahmemuster über die Syntax 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
Die Abstimmung von Funktionalität, die im Falle einer Ausnahme benötigt wird, mit einem Musterabgleich kann etwas schwierig sein, wenn Sie den Code sauber halten wollen. Eine dieser Möglichkeiten hierfür ist der Einsatz aktiver Muster als Instrument zur Gruppierung von Funktionen rund um einen Fehlerfall mit einer Ausnahme selbst. Sie können beispielsweise eine API nutzen, die, wenn sie eine Ausnahme auslöst, wertvolle Informationen in den Metadaten der Ausnahme enthält. Das Aufheben des Umbruchs eines nützlichen Werts im Textkörper der abgefangenen Ausnahme innerhalb des aktiven Musters und die Rückgabe dieses Werts kann in einigen Situationen hilfreich sein.
Keine monadische Fehlerbehandlung verwenden, um Ausnahmen zu ersetzen
Ausnahmen werden oft als Tabu im reinen funktionalen Paradigma betrachtet. In der Tat verletzen Ausnahmen die Reinheitsvorschriften, sodass Sie diese als nicht unbedingt funktional betrachten können. Dies ignoriert jedoch die Realität, nämlich dass Code ausgeführt werden muss und Laufzeitfehler auftreten können. Generell sollten Sie beim Schreiben von Code davon ausgehen, dass die meisten Dinge nicht rein oder vollständig sind, um unangenehme Überraschungen auf ein Minimum zu reduzieren (wie z. B. leere catch
in C# oder die falsche Verwaltung der Stapelüberwachung, das Verwerfen von Informationen).
Es ist wichtig, die folgenden zentralen Stärken und Aspekte von Ausnahmen im Hinblick auf ihre Relevanz und Angemessenheit in der .NET-Runtime und dem sprachübergreifenden Ökosystem insgesamt zu berücksichtigen:
- Sie enthalten detaillierte Diagnoseinformationen, die beim Debuggen eines Problems hilfreich sind.
- Sie werden von der Runtime und anderen .NET-Sprachen bestens verstanden.
- Sie können im Vergleich zu Code, der sich die Mühe macht, Ausnahmen zu vermeiden, indem eine Teilmenge ihrer Semantik ad hoc implementiert wird, eine erhebliche Menge an Bausteinen reduzieren.
Dieser dritte Punkt ist kritisch. Bei nicht trivialen, komplexen Vorgängen kann der Verzicht auf Ausnahmen dazu führen, dass Sie es mit Strukturen wie dieser zu tun haben:
Result<Result<MyType, string>, string list>
Das kann leicht zu instabilem Code führen, wie Musterabgleich bei Fehlern des Typs „als Zeichenfolgen typisiert“:
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?
Außerdem kann es verlockend sein, jede Ausnahme mit dem Wunsch nach einer „einfachen“ Funktion, die einen „angenehmeren“ Typ zurückgibt, zu schlucken:
// Can be problematic due to discarding the cause of error.
let tryReadAllText (path : string) =
try System.IO.File.ReadAllText path |> Some
with _ -> None
Leider kann tryReadAllText
zahlreiche Ausnahmen auslösen, die auf den unterschiedlichsten Dingen beruhen, die in einem Dateisystem passieren können. Dieser Code verwirft jegliche Informationen darüber, was in Ihrer Umgebung tatsächlich schief laufen könnte. Wenn Sie diesen Code durch einen Ergebnistyp ersetzen, kehren Sie zur Analyse der Fehlermeldung „als Zeichenfolgen typisiert“ zurück:
// 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 ...
Und wenn Sie das Objekt der Ausnahme selbst in den Konstruktor Error
aufnehmen, sind Sie gezwungen, den Typ der Ausnahme am Ort des Aufrufs statt in der Funktion zu behandeln. Auf diese Weise werden kontrollierte Ausnahmen erstellt, mit denen der Aufrufer einer API bekanntermaßen nichts anfangen kann.
Eine bessere Alternative zu den obigen Beispielen ist es, spezifische Ausnahmen abzufangen und einen sinnvollen Wert im Kontext der jeweiligen Ausnahme zurückzugeben. Wenn Sie die Funktion tryReadAllText
wie folgt modifizieren, hat None
mehr Bedeutung:
let tryReadAllTextIfPresent (path : string) =
try System.IO.File.ReadAllText path |> Some
with :? FileNotFoundException -> None
Anstatt als Catch-All-Klausel zu fungieren, behandelt diese Funktion nun den Fall, dass eine Datei nicht gefunden wurde, richtig und weist diese Bedeutung einer Rückgabe zu. Dieser Rückgabewert kann diesem Fehlerfall zugeordnet werden, ohne dabei Kontextinformationen zu verwerfen oder den Aufrufer zu zwingen, sich mit einem Fall zu befassen, der an dieser Stelle im Code möglicherweise nicht relevant ist.
Typen wie Result<'Success, 'Error>
eignen sich für einfache Vorgänge, bei denen sie nicht geschachtelt sind. Optionale F#-Typen sind perfekt, um darzustellen, wenn etwas entweder irgendetwas oder nichts zurückgeben könnte. Sie sind jedoch kein Ersatz für Ausnahmen und sollten nicht als Ersatz für Ausnahmen dienen. Vielmehr sollten sie mit Bedacht eingesetzt werden, um bestimmte Aspekte der Richtlinie für die Ausnahme- und Fehlerverwaltung gezielt anzugehen.
Teilweise Anwendung und punktfreie Programmierung
F# unterstützt die teilweise Anwendung und somit verschiedene Möglichkeiten, in einem punktfreien Stil zu programmieren. Dies kann für die Wiederverwendung von Code innerhalb eines Moduls oder der Implementierung von bestimmten Elementen von Vorteil sein, ist aber nichts, was Sie öffentlich verfügbar machen sollten. Im Allgemeinen ist die punktfreie Programmierung an sich keine Tugend und kann für Personen, die nicht mit diesem Stil vertraut sind, eine erhebliche kognitive Hürde darstellen.
Keine teilweise Anwendung und kein Currying in öffentlichen APIs verwenden
Mit wenigen Ausnahmen kann die Verwendung der teilweise Anwendung in öffentlichen APIs für Consumer verwirrend sein. Normalerweise sind mit let
gebundene Werte in F#-Code Werte und nicht etwa Funktionswerte. Das Kombinieren von Werten und Funktionswerten kann dazu führen, dass Sie ein paar Codezeilen sparen und dafür einen ziemlichen kognitiven Zusatzaufwand in Kauf nehmen müssen, insbesondere in Kombination mit Operatoren wie >>
, um Funktionen zusammenzusetzen.
Berücksichtigen der Auswirkungen von Tools für die punktfreie Programmierung
Funktionen mit Currying bezeichnen ihre Argumente nicht. Dies hat Auswirkungen auf Tools. Betrachten Sie die folgenden beiden Funktionen:
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 sind gültige Funktionen, aber funcWithApplication
ist eine Funktionen mit Currying. Wenn Sie in einem Editor auf deren Typen zeigen, sehen Sie Folgendes:
val func : name:string -> age:int -> unit
val funcWithApplication : (string -> int -> unit)
Am Aufrufort zeigen Ihnen QuickInfos in Tools wie Visual Studio die Typsignatur an, aber da keine Namen definiert sind, werden keine Namen angezeigt. Namen sind für ein gelungenes API-Design von entscheidender Bedeutung, da sie dem Aufrufer helfen, die Bedeutung der API besser zu verstehen. Die Verwendung von punktfreiem Code in der öffentlichen API kann das Verständnis für Aufrufer erschweren.
Wenn Sie auf punktfreien Code wie funcWithApplication
stoßen, der öffentlich nutzbar ist, sollten Sie eine vollständige η-Erweiterung durchführen, damit das Tool sinnvolle Namen für Argumente erkennen kann.
Außerdem kann das Debuggen von punktfreiem Code schwierig, wenn nicht gar unmöglich sein. Tools zum Debuggen sind auf an Namen gebundene Werte angewiesen (z. B. let
-Bindungen), damit Sie Zwischenwerte auf halbem Wege der Ausführung überprüfen können. Wenn Ihr Code keine zu überprüfenden Werte enthält, gibt es auch nichts zu debuggen. In der Zukunft werden möglicherweise Tools zum Debuggen entwickelt, die diese Werte auf Grundlage zuvor ausgeführter Pfade synthetisieren, aber es ist keine gute Idee, sich auf potenzielle Debugfunktionalität zu verlassen.
Erwägen der teilweisen Anwendung als Technik zum Verringern interner Bausteine
Im Gegensatz zum vorherigen Punkt ist die teilweise Anwendung ein gut geeignetes Tool, um Bausteine innerhalb einer Anwendung oder die tieferen internen Elemente einer API zu reduzieren. Sie kann für Komponententests bei der Implementierung komplizierterer APIs hilfreich sein, bei denen Bausteine oft mühsam zu handhaben sind. Der folgende Code zeigt beispielsweise, wie Sie das erreichen können, was die meisten Simulationsframeworks Ihnen bieten, ohne eine externe Abhängigkeit von einem solchen Framework in Kauf nehmen und eine damit verbundene spezifische API erlernen zu müssen.
Betrachten Sie zum Beispiel die folgende Lösungstopografie:
MySolution.sln
|_/ImplementationLogic.fsproj
|_/ImplementationLogic.Tests.fsproj
|_/API.fsproj
ImplementationLogic.fsproj
kann Code verfügbar machen, wie z. B.:
module Transactions =
let doTransaction txnContext txnType balance =
...
type Transactor(ctx, currentBalance) =
member _.ExecuteTransaction(txnType) =
Transactions.doTransaction ctx txnType currentBalance
...
Komponententests von Transactions.doTransaction
in ImplementationLogic.Tests.fsproj
sind einfach:
namespace TransactionsTestingUtil
open Transactions
module TransactionsTestable =
let getTestableTransactionRoutine mockContext = Transactions.doTransaction mockContext
Durch die teilweise Anwendung von doTransaction
mit einem simulierten Kontextobjekt können Sie die Funktion in allen Ihren Komponententests aufrufen, ohne jedes Mal einen simulierten Kontext konstruieren zu müssen:
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)
Wenden Sie diese Technik nicht universell auf Ihre gesamte Codebasis an. Sie ist jedoch eine gute Möglichkeit, Codebausteine für komplizierte interne Elemente und Komponententests dieser internen Elemente zu reduzieren.
Zugriffssteuerung
F# bietet mehrere Optionen für die Zugriffssteuerung, die von den in der .NET-Runtime verfügbaren Optionen geerbt wurden. Diese sind nicht nur für Typen geeignet, sondern auch für Funktionen.
Bewährte Methoden im Kontext von Bibliotheken, die häufig genutzt werden:
- Bevorzugen Sie Nicht-
public
-Typen und -Member, solange Sie diese nicht öffentlich nutzbar machen müssen. Dies minimiert auch, woran Consumer koppeln müssen. - Bemühen Sie sich, alle Hilfsfunktionen „
private
“ zu halten. - Erwägen Sie die Verwendung von
[<AutoOpen>]
in einem privaten Modul von Hilfsfunktionen, sollten sie zahlreich werden.
Typrückschluss und Generics
Typrückschluss kann Ihnen das Eingeben einer Menge von Bausteinen ersparen. Und die automatische Generalisierung im F#-Compiler kann Ihnen helfen, generischeren Code fast ohne zusätzlichen Aufwand zu schreiben. Diese Features sind jedoch nicht universell gut geeignet.
Erwägen Sie die Bezeichnung von Argumentnamen mit expliziten Typen in öffentlichen APIs, und setzen Sie dabei nicht auf Typrückschluss.
Der Grund dafür ist, dass Sie die Kontrolle über die Form Ihrer API haben sollten und nicht der Compiler. Obwohl der Compiler beim Rückschluss von Typen gute Arbeit leisten kann, ist es möglich, dass sich die Form Ihrer API ändert, wenn die internen Elemente, auf die sie sich stützt, andere Typen haben. Dies ist möglicherweise das, was Sie möchten, aber es wird mit ziemlicher Sicherheit zu einer Änderung der API führen, mit der nachgeschaltete Consumer dann umgehen müssen. Wenn Sie stattdessen explizit die Form Ihrer öffentlichen API steuern, können Sie diese Breaking Changes unter Kontrolle halten. In DDD-Begriffen kann dies als Antikorruptionsebene betrachtet werden.
Erwägen Sie, Ihren generischen Argumenten einen aussagekräftigen Namen zu geben.
Sofern Sie nicht tatsächlich generischen Code schreiben, der nicht spezifisch für eine bestimmte Domäne gilt, kann ein aussagekräftiger Name anderen Programmierern helfen, die Domäne zu verstehen, in der sie arbeiten. Ein Typparameter namens
'Document
im Kontext der Interaktion mit einer Dokumentendatenbank verdeutlicht beispielsweise, dass die Funktion oder das Member, mit der/dem Sie arbeiten, generische Dokumententypen akzeptieren kann.Erwägen Sie, generische Typparameter mit PascalCase zu benennen.
Dies ist die allgemeine Vorgehensweise in .NET. Daher wird empfohlen, PascalCase anstelle von snake_case oder camelCase zu verwenden.
Schließlich ist die automatische Generalisierung nicht immer ein Segen für Personen, die noch nicht mit F# oder einer großen Codebasis vertraut sind. Bei Verwendung generischer Komponenten gibt es kognitiven Mehraufwand. Wenn darüber hinaus automatisch generalisierte Funktionen nicht mit verschiedenen Eingabetypen verwendet werden (geschweige denn, wenn sie als solche verwendet werden sollen), gibt es keinen wirklichen Vorteil, generisch zu sein. Überlegen Sie stets, ob der Code, den Sie schreiben, tatsächlich davon profitiert, generisch zu sein.
Leistung
Berücksichtigen von Strukturen für kleine Typen mit hohen Zuteilungsraten
Die Verwendung von Strukturen (auch als Werttypen bezeichnet) kann häufig zu einer höheren Leistung für Code führen, da die Zuteilung von Objekten in der Regel vermieden wird. Strukturen sind jedoch nicht stets ein Garant für mehr Schnelligkeit: Wenn die Größe der Daten in einer Struktur 16 Byte überschreitet, kann das Kopieren der Daten oft zu einem höheren CPU-Aufwand führen als die Verwendung eines Verweistyps.
Um festzustellen, ob Sie eine Struktur verwenden sollten, prüfen Sie die folgenden Bedingungen:
- Wenn die Größe Ihrer Daten maximal 16 Bytes ist.
- Wenn es wahrscheinlich ist, dass in einem ausgeführten Programm viele Instanzen dieser Typen im Arbeitsspeicher vorhanden sind.
Wenn die erste Bedingung zutrifft, sollten Sie generell eine Struktur verwenden. Wenn beides zutrifft, sollten Sie fast immer eine Struktur verwenden. Es kann zwar einige Fälle geben, in denen die oben genannten Bedingungen zutreffen, aber die Verwendung einer Struktur nicht besser oder schlechter als die Verwendung eines Verweistyps ist, aber diese Fälle sind eher selten. Es ist jedoch wichtig, dass Sie bei solchen Änderungen immer Messungen vornehmen und sich nicht auf Annahmen oder Ihre Intuition verlassen.
Erwägen von Strukturtupeln, wenn kleine Werttypen mit hohen Zuteilungsraten gruppiert werden
Betrachten Sie die folgenden beiden Funktionen:
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)
Wenn Sie diese Funktionen mit einem statistischen Benchmarktool wie BenchmarkDotNet vergleichen, werden Sie feststellen, dass die runWithStructTuple
-Funktion, die Strukturtupel verwendet, 40 % schneller ausgeführt wird und keinen Arbeitsspeicher zuteilt.
Allerdings werden diese Ergebnisse in Ihrem eigenen Code nicht stets der Fall sein. Wenn Sie eine Funktion als inline
markieren, kann Code, der Verweistupel verwendet, einige zusätzliche Optimierungen erhalten, oder Code, der Zuteilungen vornehmen würde, kann einfach wegoptimiert werden. Sie sollten stets die Ergebnisse messen, wenn es um Leistung geht, und keinesfalls auf der Grundlage von Annahmen oder Ihrer Intuition handeln.
Erwägen von Strukturdatensätzen, wenn der Typ klein ist und hohe Zuteilungsraten aufweist
Die weiter oben beschriebene Faustregel gilt auch für F#-Datensatztypen. Betrachten Sie die folgenden Datentypen und Funktionen, die sie verarbeiten:
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)
Dies ähnelt dem vorherigen Tupelcode, aber dieses Mal werden im Beispiel Datensätze und eine innere Inlinefunktion verwendet.
Wenn Sie diese Funktionen mit einem statistischen Benchmarktool wie BenchmarkDotNet vergleichen, werden Sie feststellen, dass processStructPoint
fast 60 % schneller ausgeführt wird und nichts dem verwalteten Heap zuteilt.
Erwägen von Strukturen des Typs „Diskriminierte Unions“, wenn der Datentyp klein ist und hohe Zuteilungsraten hat
Die vorherigen Beobachtungen zur Leistung mit Strukturtupeln und -datensätzen gelten auch für diskriminierte Unions in F#. Betrachten Sie folgenden 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
Es ist üblich, für die Domänenmodellierung diskriminierte Unions für Einzelfälle wie diese zu definieren. Wenn Sie diese Funktionen mit einem statistischen Benchmarktool wie BenchmarkDotNet vergleichen, werden Sie feststellen, dass structReverseName
für kleine Zeichenfolgen etwa 25 % schneller ausgeführt wird als reverseName
. Bei großen Zeichenfolgen schneiden beide etwa gleich gut ab. In diesem Fall ist es also eine Struktur stets vorzuziehen. Wie bereits erwähnt, sollten Sie stets Messungen durchführen und sich nicht auf Annahmen oder Ihre Intuition stützen.
Obwohl das vorherige Beispiel gezeigt hat, dass eine Struktur des Typs „Diskriminierte Unions“ eine bessere Leistung erzielt, sind bei der Modellierung einer Domäne größere diskriminierte Unions üblich. Größere Datentypen wie diese funktionieren möglicherweise nicht so gut, wenn es sich um Strukturen handelt, die von den auf sie angewendeten Vorgängen abhängen, da mehr Kopiervorgänge erforderlich sein könnten.
Unveränderlichkeit und Mutation
F#-Werte sind standardmäßig unveränderlich, wodurch Sie bestimmte Fehlerklassen vermeiden können (insbesondere solche, die mit Gleichzeitigkeit bzw. Parallelität zu tun haben). In bestimmten Fällen kann eine Arbeitseinheit jedoch am besten durch direkte Mutation des Zustands implementiert werden, um eine optimale (oder sogar vernünftige) Effizienz der Ausführungszeit oder Zuteilung von Arbeitsspeicher zu erreichen. Dies ist in F# mit dem Schlüsselwort mutable
auf Optionsbasis möglich.
Die Verwendung von mutable
in F# steht womöglich im Widerspruch zur funktionalen Reinheit. Das ist verständlich, aber funktionale Reinheit kann überall im Widerspruch zu Leistungszielen stehen. Ein Kompromiss besteht darin, die Mutation so zu kapseln, dass der Aufrufer sich nicht darum kümmern muss, was passiert, wenn er eine Funktion aufruft. Dies ermöglicht Ihnen, eine Funktionsschnittstelle über eine mutationsbasierte Implementierung für leistungskritischen Code zu schreiben.
Außerdem ermöglichen F# let
-Bindungskonstrukte das Schachteln von Bindungen in eine andere. Dies kann verwendet werden, um den Bereich der mutable
Variablen schließen oder am kleinsten zu halten.
let data =
[
let mutable completed = false
while not completed do
logic ()
// ...
if someCondition then
completed <- true
]
Kein Code kann auf die Stummschaltung completed
zugreifen, die nur zum Initialisieren data
des gebundenen Werts verwendet wurde.
Umschließen von veränderlichem Code in unveränderlichen Schnittstellen
Mit dem Ziel referenzieller Transparenz ist es von entscheidender Bedeutung, Code zu schreiben, der die veränderliche Schattenseite leistungsrelevanter Funktionen nicht verfügbar macht. Beispielsweise implementiert der folgende Code die Funktion Array.contains
in der F#-Kernbibliothek:
[<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
Ein mehrfacher Aufruf dieser Funktion ändert weder das zugrunde liegende Array, noch müssen Sie einen veränderlichen Zustand beibehalten, wenn Sie es nutzen. Es ist referenziell transparent, auch wenn in nahezu jeder Codezeile Mutationen verwendet werden.
Erwägen des Kapselns veränderlicher Daten in Klassen
Im vorherigen Beispiel wurde eine einzelne Funktion verwendet, um Vorgänge mit veränderlichen Daten zu kapseln. Dies ist für komplexere Datensets nicht immer ausreichend. Betrachten Sie die folgenden Funktionsgruppen:
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
Dieser Code ist leistungsfähig, macht aber die mutationsbasierte Datenstruktur verfügbar, für deren Wartung Aufrufer verantwortlich sind. Diese kann innerhalb einer Klasse ohne zugrunde liegende Member umschlossen werden, die sich ändern können:
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 die zugrunde liegende mutationsbasierte Datenstruktur, sodass der Aufrufer nicht gezwungen ist, die zugrunde liegende Datenstruktur zu warten. Klassen sind eine leistungsstarke Möglichkeit, mutationsbasierte Daten und Routinen zu kapseln, ohne die Details für den Aufrufer verfügbar zu machen.
Bevorzugen von let mutable
gegenüber ref
Verweiszellen sind eine Möglichkeit, den Verweis auf einen Wert und nicht den Wert selbst darzustellen. Obwohl sie für leistungskritischen Code verwendet werden können, werden sie nicht empfohlen. Betrachten Sie das folgenden Beispiel:
let kernels =
let acc = ref Set.empty
processWorkList startKernels (fun kernel ->
if not ((!acc).Contains(kernel)) then
acc := (!acc).Add(kernel)
...)
!acc |> Seq.toList
Die Verwendung einer Verweiszelle „verunreinigt“ den gesamten nachfolgenden Code, wobei die zugrunde liegenden Daten dereferenziert und erneut referenziert werden müssen. Erwägen Sie stattdessen 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
Abgesehen von dem einzelnen Mutationspunkt in der Mitte des Lambdaausdrucks kann jeder andere Code, der acc
berührt, dies auf eine Weise tun, die sich nicht von der Verwendung eines normalen mit let
gebundenen unveränderlichen Werts unterscheidet. Dadurch werden Änderungen mit der Zeit einfacher.
NULL- und Standardwerte
NULL-Werte sollten in F# generell vermieden werden. Standardmäßig unterstützen in F# deklarierte Typen die Verwendung des Literals null
nicht, und alle Werte und Objekte werden initialisiert. Einige gängige .NET-APIs geben jedoch NULL-Werte zurück oder akzeptieren diese, und einige gängige in .NET deklarierte Typen wie Arrays und Zeichenfolgen lassen NULL-Werte zu. Das Vorkommen von null
-Werten ist in der F#-Programmierung jedoch sehr selten, und einer der Vorteile von F# ist die Vermeidung von Fehlern durch NULL-Verweise in den meisten Fällen.
Vermeiden der Verwendung des Attributs AllowNullLiteral
Standardmäßig unterstützen in F# deklarierte Typen die Verwendung des Literals null
nicht. Sie können F#-Typen manuell mit AllowNullLiteral
kommentieren, um dies zuzulassen. Allerdings ist es fast immer besser, dies zu vermeiden.
Vermeiden der Verwendung des Attributs Unchecked.defaultof<_>
Es ist möglich, einen null
- oder mit 0 initialisierten Wert für einen F#-Typ zu generieren, indem Sie Unchecked.defaultof<_>
verwenden. Dies kann bei der Initialisierung von Speicherplatz für bestimmte Datenstrukturen, bei einem leistungsstarken Programmiermuster oder im Hinblick auf Interoperabilität nützlich sein. Die Verwendung dieses Konstrukts sollte jedoch vermieden werden.
Vermeiden der Verwendung des Attributs DefaultValue
Standardmäßig müssen F#-Datensätze und -Objekte beim Erstellen ordnungsgemäß initialisiert werden. Das Attribut DefaultValue
kann verwendet werden, um einige Felder von Objekten mit einem null
- oder mit 0 initialisierten Wert aufzufüllen. Dieses Konstrukt wird selten benötigt, und seine Verwendung sollte vermieden werden.
Bei der Prüfung auf NULL-Eingaben sollten Sie bei der ersten Gelegenheit eine Ausnahme auslösen
Wenn Sie neuen F#-Code schreiben, müssen Sie in der Praxis nicht auf NULL-Eingaben prüfen, es sei denn, Sie erwarten, dass dieser Code von C# oder anderen .NET-Sprachen verwendet wird.
Wenn Sie sich entschließen, Prüfungen auf NULL-Eingaben hinzuzufügen, führen Sie die Prüfungen bei der ersten Gelegenheit durch, und lösen Sie eine Ausnahme aus. Beispiel:
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
Aus Legacygründen behandeln einige Zeichenfolgenfunktionen in FSharp.Core NULL-Werte nach wie vor als leere Zeichenfolgen und schlagen bei NULL-Argumenten nicht fehl. Nehmen Sie dies jedoch nicht als Richtschnur, und verwenden Sie keine Programmiermuster, die NULL eine beliebige semantische Bedeutung zuschreiben.
Objektprogrammierung
F# bietet vollständige Unterstützung für Objekte und objektorientierte Konzepte. Obwohl viele objektorientierte Konzepte leistungsstark und nützlich sind, lassen sich nicht alle von ihnen ideal verwenden. Die folgenden Listen bieten allgemeine Anleitungen zu Kategorien objektorientierter Features.
Erwägen Sie die Verwendung dieser Features in verschiedenen Situationen:
- Punktnotation (
x.Length
) - Instanzmember
- Implizite Konstruktoren
- Statische Member
- Indexernotation (
arr[x]
) durch Definieren einerItem
-Eigenschaft - Slicenotation (
arr[x..y]
,arr[x..]
,arr[..y]
) durch Definieren vonGetSlice
-Membern - Benannte und optionale Argumente
- Schnittstellen und Schnittstellenimplementierungen
Setzen Sie nicht von vornherein auf diese Features, sondern nutzen Sie sie mit Bedacht, wenn sie zur Lösung eines Problems geeignet sind:
- Methodenüberladung
- Gekapselte veränderliche Daten
- Operatoren für Typen
- Automatische Eigenschaften
- Implementieren von
IDisposable
undIEnumerable
- Typerweiterungen
- Ereignisse
- Strukturen
- Delegaten
- Enumerationen
Vermeiden Sie diese Features im Allgemeinen, es sei denn, Sie müssen sie verwenden:
- Vererbungsbasierte Typhierarchien und Implementierungsvererbung
- NULL-Werte und
Unchecked.defaultof<_>
Bevorzugen von Komposition gegenüber Vererbung
Komposition vor Vererbung ist eine seit langem bestehende Praxis, an die sich guter F#-Code halten kann. Das Grundprinzip ist, dass Sie keine Basisklasse verfügbar machen und die Aufrufer zwingen sollten, von dieser Basisklasse zu erben, um die Funktionalität zu erhalten.
Verwenden von Objektausdrücken zum Implementieren von Schnittstellen, wenn Sie keine Klasse benötigen
Mit Objektausdrücken können Sie Schnittstellen im laufenden Betrieb implementieren und die implementierte Schnittstelle an einen Wert binden, ohne dies innerhalb einer Klasse tun zu müssen. Das ist vor allem dann praktisch, wenn Sie nur die Schnittstelle implementieren müssen und keine vollständige Klasse benötigen.
Hier ist zum Beispiel der Code, der in Ionide ausgeführt wird, um eine Codekorrekturaktion bereitzustellen, wenn Sie ein Symbol hinzugefügt haben, für das es keine open
-Anweisung gibt:
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
}
Da bei der Interaktion mit der Visual Studio Code-API keine Klasse erforderlich ist, sind Objektausdrücke ein ideales Tool dafür. Sie sind auch für Komponententests nützlich, wenn Sie eine Schnittstelle auf improvisierte Weise mit Testroutinen ausstatten möchten.
Erwägen von Typabkürzungen zum Verkürzen von Signaturen
Typabkürzungen sind eine praktische Möglichkeit, eine Bezeichnung einem anderen Typ zuzuweisen, z. B. einer Funktionssignatur oder einem komplexeren Typ. Der folgende Alias weist beispielsweise eine Bezeichnung zu, die für die Definition einer Berechnung mit CNTK, einer Deep Learning-Bibliothek, benötigt wird:
open CNTK
// DeviceDescriptor, Variable, and Function all come from CNTK
type Computation = DeviceDescriptor -> Variable -> Function
Der Name Computation
ist eine praktische Möglichkeit, jede Funktion zu bezeichnen, die mit der Signatur übereinstimmt, die sie mit einem Alias versieht. Die Verwendung von Typabkürzungen wie dieser ist praktisch und ermöglicht einen prägnanteren Code.
Vermeiden der Verwendung von Typabkürzungen zur Darstellung Ihrer Domäne
Typabkürzungen sind zwar praktisch, um Funktionssignaturen einen Namen zu geben, können jedoch beim Abkürzen anderer Typen verwirrend sein. Betrachten Sie diese Abkürzung:
// Does not actually abstract integers.
type BufferSize = int
Diese kann auf mehrere Arten verwirrend sein:
BufferSize
ist keine Abstraktion, sondern nur ein anderer Name für eine ganze Zahl.- Wenn
BufferSize
in einer öffentlichen API verfügbar gemacht wird, kann dies leicht dahingehend fehlinterpretiert werden, dass es mehr als nurint
bedeutet. Im Allgemeinen verfügen Domänentypen über mehrere Attribute und sind keine primitiven Typen wieint
. Diese Abkürzung verstößt gegen diese Annahme. - Die Schreibung von
BufferSize
(PascalCase) impliziert, dass dieser Typ mehr Daten enthält. - Dieser Alias bietet keine größere Klarheit im Vergleich zum Bereitstellen eines benannten Arguments für eine Funktion.
- Die Abkürzung manifestiert sich nicht in kompilierter Zwischensprache. Sie ist bloß eine ganze Zahl, und dieser Alias ist ein Kompilierzeitkonstrukt.
module Networking =
...
let send data (bufferSize: int) = ...
Zusammenfassend lässt sich sagen, dass der Fallstrick bei Typabkürzungen darin besteht, dass sie nicht Abstraktionen der Typen sind, die sie abkürzen. Im vorigen Beispiel ist BufferSize
eigentlich einfach nur ein int
, ohne zusätzliche Daten und ohne weitere Vorteile des Typsystems als dem, was int
bereits hat.
Ein alternativer Ansatz zur Verwendung von Typabkürzungen zur Darstellung einer Domäne ist die Verwendung von diskriminierten Unions für Einzelfälle. Das vorherige Beispiel kann wie folgt modelliert werden:
type BufferSize = BufferSize of int
Wenn Sie Code schreiben, der mit BufferSize
und dem zugrunde liegenden Wert arbeitet, müssen Sie einen solchen konstruieren, anstatt eine beliebige ganze Zahl einzugeben:
module Networking =
...
let send data (BufferSize size) =
...
Dadurch verringert sich die Wahrscheinlichkeit, dass versehentlich eine beliebige ganze Zahl an die Funktion send
übergeben wird, da der Aufrufer erst den Typ BufferSize
konstruieren muss, um einen Wert zu umschließen, ehe er die Funktion aufrufen kann.