F#-kódolási konvenciók

A következő konvenciók a nagy F#-kódbázisok használata során szerzett tapasztalatokból vannak kialakítva. A jó F#-kód öt alapelve az egyes javaslatok alapja. Ezek az F#-összetevők tervezési irányelveihez kapcsolódnak, de minden F#-kódra alkalmazhatók, nem csak az összetevőkre, például a kódtárakra.

Kód rendszerezése

Az F# a kód rendszerezésének két elsődleges módját tartalmazza: a modulokat és a névtereket. Ezek hasonlóak, de a következő különbségek vannak:

  • A névterek .NET-névterekként vannak lefordítva. A modulok statikus osztályokként vannak lefordítva.
  • A névterek mindig legfelső szintűek. A modulok legfelső szintűek és más modulokba ágyazhatók.
  • A névterek több fájlra is kiterjedhetnek. A modulok nem.
  • A modulok [<RequireQualifiedAccess>] és [<AutoOpen>]-gyel díszíthetők.

Az alábbi irányelvek segítségével rendszerezheti a kódot.

Névterek előnyben részesítése a legfelső szinten

A nyilvánosan használható kódok esetében a névterek előnyben részesíthetők a legfelső szintű modulok számára. Mivel .NET-névterekként vannak lefordítva, C#-ból közvetlenül is használhatók using static nélkül.

// Recommended.
namespace MyCode

type MyClass() =
    ...

A legfelső szintű modul használata nem feltétlenül tűnik másnak, ha csak F#-ból van meghívva, de a C#-felhasználók számára meglepő lehet, hogy a MyClass-t a MyCode modulhoz kell kapcsolni, ha nem ismerik a kifejezetten C#-beli using static szerkezetet.

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

type MyClass() =
    ...

Óvatosan alkalmazza [<AutoOpen>]

A [<AutoOpen>] szerkezet szennyezi a hívók számára elérhető hatókört, és a válasz arra, hogy honnan származik valami, a "varázslat". Ez nem jó dolog. A szabály alól kivételt képez maga az F# Core-kódtár (bár ez a tény egy kicsit ellentmondásos).

Ez azonban kényelmes, ha rendelkezik a nyilvános API-k segédfunkcióival, amelyeket az adott nyilvános API-tól elkülönítve szeretne rendszerezni.

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

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

        helper1 x y z

Ez lehetővé teszi, hogy tisztán elkülönítse a megvalósítás részleteit egy függvény nyilvános API-jától anélkül, hogy minden híváskor teljes mértékben ki kellene minősítenie egy segítőt.

Emellett a névtér szintjén a kiterjesztési metódusok és kifejezésszerkesztők felfedése jól kifejezhető a következővel [<AutoOpen>]: .

Használjon [<RequireQualifiedAccess>] minden olyan esetet, amikor a nevek ütközhetnek, vagy ha úgy érzi, hogy segít az olvashatóságban

Az [<RequireQualifiedAccess>] attribútum modulhoz való hozzáadása azt jelzi, hogy a modul nem nyitható meg, és a modul elemeire mutató hivatkozások explicit minősített hozzáférést igényelnek. A modul például ezt az Microsoft.FSharp.Collections.List attribútumot tartalmazza.

Ez akkor hasznos, ha a modul függvényei és értékei olyan neveket tartalmaznak, amelyek valószínűleg ütköznek más modulok neveivel. A minősített hozzáférés megkövetelése jelentősen növelheti a hosszú távú karbantarthatóságot és a tárak fejlődési képességét.

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

...

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

Rendezd az utasításokat topológiailag open

Az F#-ban a deklarációk sorrendje számít, beleértve az open utasításokat is (és később egyszerűen csak open-ként utalunk rá open type). Ez ellentétben áll a C#-jal, ahol az adott fájlban lévő utasítások hatása using és using static sorrendje független.

Az F#-ban a hatókörbe megnyitott elemek árnyékot adhatnak a már meglévőknek. Ez azt jelenti, hogy az utasítások átrendezése open megváltoztathatja a kód jelentését. Ennek eredményeképpen az összes open utasítás tetszőleges rendezése (például alfanumerikusan) nem ajánlott, ne pedig eltérő viselkedést generáljon, amelyet elvárhat.

Ehelyett azt javasoljuk, hogy topológiai sorrendben rendezze őket; azaz az utasításokat abban a sorrendben sorolja fel, amelyben a rendszer rétegei meghatározásra kerülnek. A különböző topológiai rétegeken belüli alfanumerikus rendezés is megfontolható.

Példaként itt látható az F# fordítószolgáltatás nyilvános API-fájljának topológiai rendezése:

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

A vonaltörések elválasztják a topológiai rétegeket, és az egyes rétegek alfanumerikusan vannak rendezve. Ez tisztán rendszerezi a kódot az értékek véletlen árnyékolása nélkül.

Osztályok használata olyan értékek tárolásához, amelyek mellékhatásokkal rendelkeznek

Az érték inicializálása számos esetben okozhat mellékhatásokat, például amikor egy kontextust hozunk létre egy adatbázishoz vagy más távoli erőforráshoz. Csábító, hogy inicializálja az ilyen dolgokat egy modulban, és használja azt a következő függvényekben:

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

Ez több okból is gyakran problémás:

Először az alkalmazáskonfiguráció le lesz küldve a kódbázisba a következővel dep1 : és dep2. Ez a nagyobb kódbázisokban nehezen tartható fenn.

Másodszor, a statikusan inicializált adatok nem tartalmazhatnak olyan értékeket, amelyek nem szálbiztosak, ha az összetevő maga is több szálat fog használni. Ezt egyértelműen megsérti a dep3.

Végül a modul inicializálása a teljes fordítási egység statikus konstruktorává alakul. Ha bármilyen hiba történik az adott modul let-bound értékének inicializálásakor, az a TypeInitializationException az alkalmazás teljes élettartamára nézve cache-elt lesz. Ezt nehéz lehet diagnosztizálni. Általában van egy belső kivétel, amelyről megpróbálhat érvelni, de ha nincs, akkor nem lehet megmondani, hogy mi a kiváltó ok.

Ehelyett egyszerűen használjon egy egyszerű osztályt a függőségek tárolásához:

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

Ez lehetővé teszi a következőket:

  1. Bármely függő állapot leküldése az API-n kívülre.
  2. A konfiguráció mostantól az API-n kívül is elvégezhető.
  3. A függő értékek inicializálási hibái valószínűleg nem jelennek meg TypeInitializationException.
  4. Az API-t most már könnyebb tesztelni.

Hibakezelés

A nagy rendszerek hibakezelése összetett és árnyalt törekvés, és nincsenek ezüstjelek annak biztosításában, hogy a rendszerek hibatűrőek és jól viselkedjenek. Az alábbi irányelveknek útmutatást kell nyújtaniuk e nehéz terület navigálásához.

Hibaeseteket és érvénytelen állapotokat jelölhet a tartományhoz tartozó típusokban

A diszkriminált unionok esetén az F# lehetővé teszi, hogy hibás programállapotokat jelöljön a típusrendszerben. Példa:

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

Ebben az esetben három ismert módszer létezik arra, hogy a bankszámláról történő pénzfelvétel meghiúsuljon. Az egyes hibaeseteket a rendszer a típusban jeleníti meg, így a program egész területén biztonságosan kezelhető.

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"

Általánosságban elmondható, hogy ha modellezheti azokat a különböző módszereket, amelyekkel valami meghiúsulhat a tartományban, akkor a hibakezelő kód már nem olyanként lesz kezelve, amellyel a szokásos programfolyamat mellett foglalkoznia kell. Ez egyszerűen a normál programfolyamat része, és nem tekinthető kivételesnek. Ennek két elsődleges előnye van:

  1. Idővel változó domain-környezetben könnyebb a karbantartás.
  2. A hibaeseteket könnyebb egységtesztelni.

Kivételek használata, ha a hibák nem jeleníthetők meg típusokkal

Nem minden hiba jeleníthető meg egy problémás tartományban. Az ilyen típusú hibák kivételes jellegűek, ezért van lehetőség kivételek emelésére és elkapására F#-ban.

Először is ajánlott elolvasni a kivételtervezési irányelveket. Ezek az F#-ra is érvényesek.

A kivételek emelése céljából az F#-ban elérhető fő szerkezeteket a következő sorrendben kell figyelembe venni:

Függvény Szemantika Cél
nullArg nullArg "argumentName" A megadott argumentumnévvel emel ki egy System.ArgumentNullException értéket.
invalidArg invalidArg "argumentName" "message" System.ArgumentException Olyan eseményt indít el, amely rendelkezik megadott argumentumnévvel és üzenettel.
invalidOp invalidOp "message" A megadott üzenettel együtt ad meg egy System.InvalidOperationException üzenetet.
raise raise (ExceptionType("message")) Általános célú mechanizmus a kivételek kivetésére.
failwith failwith "message" A megadott üzenettel együtt ad meg egy System.Exception üzenetet.
failwithf failwithf "format string" argForFormatString Az üzenet előállítása, amelyet a formátum string és a bemenetek határozzák meg, egy System.Exception kiváltását okozza.

Használja a nullArg, invalidArg és invalidOp elemeket mechanizmusként a ArgumentNullException, ArgumentException és InvalidOperationException dobásához, ha szükséges.

A failwith és failwithf függvényeket általában kerülni kell, mert ezek az alaptípust Exception kiváltják, és nem egy adott kivételt. A kivételtervezési irányelveknek megfelelően konkrétabb kivételeket szeretne létrehozni, amikor csak lehet.

Kivételkezelési szintaxis használata

Az F# a szintaxison keresztül támogatja a try...with kivételmintákat:

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

A mintaegyeztetéssel kapcsolatos kivételekkel szemben végrehajtandó funkciók egyeztetése kissé bonyolult lehet, ha tisztán szeretné tartani a kódot. Ennek egyik módja az aktív minták használata a hibaeseteket körülölelő funkciók csoportosítására egy kivétellel. Előfordulhat például, hogy olyan API-t használ, amely kivétel esetén értékes információkat tartalmaz a kivétel metaadataiban. Az aktív mintában rögzített kivétel törzsében lévő hasznos érték kibontása és az érték visszaadása bizonyos helyzetekben hasznos lehet.

Ne használjon monádikus hibakezelést a kivételek helyettesítésére

A kivételeket gyakran tabunak tekintik a tiszta funkcionális paradigmában. A kivételek valóban sértik a tisztaságot, ezért nyugodtan tekintheti őket nem teljesen funkcionálisan tisztanak. Ez azonban figyelmen kívül hagyja a kód futtatásának helyét, és hogy futásidejű hibák léphetnek fel. Általában írjon kódot arra a feltételezésre, hogy a legtöbb dolog nem tiszta vagy teljes, hogy minimalizálja a kellemetlen meglepetéseket (hasonló az üres `catch` az C#-ban, vagy a verem nyomkövetés helytelen kezelése, ami információ elvetéséhez vezethet).

Fontos figyelembe venni a kivételek alábbi alapvető erősségeit/szempontjait a .NET-futtatókörnyezetben és a nyelvközi ökoszisztémában való relevanciájuk és megfelelőségük szempontjából:

  • Részletes diagnosztikai információkat tartalmaznak, ami hasznos lehet egy probléma hibakeresése során.
  • Ezeket jól ismerik a futtatókörnyezet és más .NET-nyelvek.
  • Csökkenthetik a jelentős sablonszöveget olyan kódokkal összehasonlítva, amelyek minden áron kerülik a kivételek használatát azáltal, hogy a szemantikai elemek bizonyos részhalmazát eseti alapon valósítják meg.

Ez a harmadik pont kritikus fontosságú. Nemtriviális összetett műveletek esetén a kivételek használatának elmulasztása az alábbihoz hasonló struktúrák kezelését eredményezheti:

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

Ami könnyen törékeny kódhoz vezethet, például a "sztringbegépelt" hibákhoz hasonló mintamegfeleltetéshez:

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?

Emellett csábító lehet lenyelni minden kivételt azzal a vággyal, hogy egy "egyszerű" függvényt hozzunk létre, amely "szebb" típust ad vissza.

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

tryReadAllText Sajnos számos kivételt vethet ki a fájlrendszeren előforduló számos dolog alapján, és ez a kód elvet minden információt arról, hogy mi lehet a hiba a környezetben. Ha ezt a kódot eredménytípusra cseréli, akkor a "sztringen beírt" hibaüzenet elemzéséhez visszatér:

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

És magának a kivétel objektumnak a konstruktornál Error való elhelyezése arra kényszerít, hogy a hívási helyen megfelelően, és ne a függvényben, kezelje a kivételtípust. Ez hatékonyan ellenőrzött kivételeket hoz létre, amelyek hírhedten kellemetlenek az API hívóként való kezeléskor.

A fenti példák jó alternatívája az adott kivételek elfogása és a kivétel kontextusában értelmezhető érték visszaadása. Ha az tryReadAllText függvényt az alábbiak szerint módosítja, a None jobban értelmezhető.

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

Ahelyett, hogy mindenhatóként működjön, ez a függvény most már megfelelően kezeli az esetet, amikor egy fájl nem található, és ezt a jelentést egy visszatéréshez rendeli. Ez a visszatérési érték megfeleltethető erre a hibaesetre, miközben nem vet el semmilyen környezeti információt, és nem kényszeríti a hívókat egy olyan eset kezelésére, amely nem feltétlenül releváns a kód azon pontján.

Az olyan típusok, mint , megfelelőek az alapműveletekhez, ahol nem ágyazottak, és az F# választható típusai tökéletesek, ha valami visszaadhat valamit vagy semmit . Ezek azonban nem helyettesítik a kivételeket, és nem használhatók a kivételek helyettesítésére tett kísérletekben. Ehelyett célszerű megfontoltan alkalmazni őket a kivétel- és hibakezelési szabályzat bizonyos aspektusainak célzott kezelésére.

Részleges alkalmazás- és pontmentes programozás

Az F# támogatja a részleges alkalmazásokat, és így a programozás különböző módjait pontmentes stílusban. Ez hasznos lehet a kód újrafelhasználása egy modulon belül vagy valami implementációja esetén, de ezt nem lehet nyilvánosan elérhetővé tenni. Általánosságban elmondható, hogy a pontmentes programozás önmagában nem erény, és jelentős kognitív akadályt adhat azoknak, akik nem merülnek el a stílusban.

Ne használjon részleges alkalmazást és curryingot nyilvános API-kban.

A részleges alkalmazás nyilvános API-kban való használata kis kivétellel zavaró lehet a fogyasztók számára. letAz F#-kódban a -bound értékek általában értékek, nem függvényértékek. Az értékek és a függvényértékek összevonása néhány sornyi kód mentését eredményezheti a kognitív többletterhelésekért cserébe, különösen akkor, ha olyan operátorokkal kombinálva, mint a >> függvények írása.

Fontolja meg a pontmentes programozás eszközhasználati következményeit

A curried függvények nem címkézik meg az argumentumaikat. Ennek eszközhatásai vannak. Vegye figyelembe a következő két függvényt:

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

Mindkettő érvényes függvény, de funcWithApplication egy curried függvény. Amikor egy szerkesztőben a típusuk fölé viszi az egérmutatót, a következő látható:

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

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

A hívási helyen az eszközleírások, mint például a Visual Studio-ban, megadják a típus szignatúrát, de mivel nincsenek definiálva nevek, nem jeleníti meg a neveket. A nevek kritikus fontosságúak a jó API-tervezés szempontjából, mivel segítenek a hívóknak jobban megérteni az API mögötti jelentést. A nyilvános API-ban pontmentes kód használata megnehezítheti a hívók számára a megértést.

Ha olyan pont nélküli kóddal találkozik, mint funcWithApplication ami nyilvánosan fogyasztható, javasoljuk, hogy teljes η bővítést hajtson végre, hogy az eszközök értelmes neveket vehessenek fel az argumentumok számára.

Emellett a pontmentes kód hibakeresése kihívást jelenthet, ha nem lehetetlen. A hibakeresési eszközök a nevekhez (például kötésekhez) kötött értékekre támaszkodnak, let így a végrehajtás során félúton megvizsgálhatja a köztes értékeket. Ha a kód nem tartalmaz vizsgálandó értékeket, nincs hibakeresés. A jövőben a hibakeresési eszközök fejlődhetnek, hogy ezeket az értékeket a korábban végrehajtott útvonalak alapján szintetizálják, de nem érdemes a lehetséges hibakeresési funkciókra alapozni.

Fontolja meg a részleges alkalmazást, mint a belső kazánlemez csökkentésének technikáját

Az előző ponttal ellentétben a részleges alkalmazás nagyszerű eszköz az alkalmazáson belüli vagy az API mélyebb belső rétegeinek csökkentésére. Segíthet az összetettebb API-k megvalósításának egységtesztelésében, ahol a sablonkódok kezelése gyakran problémás. Az alábbi kód például bemutatja, hogyan érheti el azt, amit a legtöbb mockoló keretrendszer kínál, anélkül, hogy külső függőségre lenne szükség egy ilyen keretrendszerhez, és hogy meg kellene tanulnia egy ezekhez kapcsolódó egyedi API-t.

Vegyük például a következő megoldástopográfiát:

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

ImplementationLogic.fsproj olyan kódot tehet közzé, mint például:

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

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

Az egységtesztelés Transactions.doTransactionImplementationLogic.Tests.fsproj egyszerű:

namespace TransactionsTestingUtil

open Transactions

module TransactionsTestable =
    let getTestableTransactionRoutine mockContext = Transactions.doTransaction mockContext

Részleges alkalmazás doTransaction használatával egy szimulált környezeti objektummal lehetővé teszi, hogy az összes egységtesztben meghívja a függvényt anélkül, hogy minden egyes alkalommal létre kellene hoznia egy szimulált környezetet.

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)

Ne használja ezt a technikát univerzálisan a teljes kódbázisban, de ez egy jó módszer a bonyolult belső műveletek sablonos kódjának csökkentésére és azok egységtesztelésére.

Hozzáférés-vezérlés

Az F# több lehetőséggel is rendelkezik a hozzáférés-vezérléshez, amely a .NET-futtatókörnyezetben elérhető lehetőségektől öröklődik. Ezek nem csak a típusok esetében használhatók – függvényekhez is használhatja őket.

Széles körben használt kódtárakkal kapcsolatos jó gyakorlatok:

  • Nempublic típusokat és tagokat érdemes előnyben részesíteni, amíg nem válik szükségessé, hogy nyilvánosan felhasználhatók legyenek. Ez minimalizálja azt is, amihez a fogyasztók csatlakoznak.
  • Törekedjen az összes segítő funkció privatemegtartására.
  • Fontolja meg a [<AutoOpen>] használatát a segédfüggvények privát moduljában, ha azok nagy számúvá válnak.

Típuskövetkeztetés és általános adatok

A típuskövetkeztetés megkíméli attól, hogy sok sablonszöveget írjon be. Az F#-fordító automatikus általánosítása pedig segíthet az általánosabb kód írásában, és szinte semmilyen extra erőfeszítést nem igényel. Ezek a funkciók azonban nem általánosan jóak.

  • Fontolja meg az argumentumnevek explicit típusokkal való címkézését a nyilvános API-kban, és ne támaszkodhat erre a típuskövetkeztetésre.

    Ennek az az oka, hogy önnek kell szabályoznia az API alakját, nem pedig a fordítónak. Bár a fordító nagyszerű munkát végezhet a típusok következtetésénél, az API-nak az alakja megváltozhat, ha az általa használt belső típusok módosultak. Lehet, hogy ez az, amit szeretne, de ez szinte biztosan egy kompatibilitástörő API-változást fog eredményezni, amellyel az alsóbb rétegbeli felhasználóknak foglalkozniuk kell. Ehelyett, ha explicit módon szabályozza a nyilvános API alakját, akkor szabályozhatja ezeket a kompatibilitástörő változásokat. A DDD szempontjából ez egy korrupcióellenes rétegnek tekinthető.

  • Érdemes lehet értelmes nevet adni az általános argumentumoknak.

    Hacsak nem olyan általános kódot ír, amely nem egy adott tartományra vonatkozik, egy értelmes név segíthet más programozóknak megérteni azt a tartományt, amelyben dolgoznak. Egy dokumentum-adatbázissal való interakció kontextusában elnevezett 'Document típusparaméter például egyértelműbbé teszi, hogy az általános dokumentumtípusokat a használt függvény vagy tag elfogadhatja.

  • Fontolja meg az általános típusparaméterek elnevezését a PascalCase használatával.

    Ez az általános módszer a .NET-ben történő műveletekre, ezért ajánlott a PascalCaset használni snake_case vagy camelCase helyett.

Végül pedig az automatikus általánosítás nem mindig boon az F# vagy egy nagy kódbázis új felhasználói számára. Az általános összetevők használata kognitív többletterhelést jelent. Továbbá, ha az automatikusan általánosított függvényeket nem használják különböző bemeneti típusokkal (nem is beszélve arról, hogy ezeket a függvényeket ilyenként kívánják használni), akkor nincs valódi előnye annak, hogy általánosak legyenek. Mindig gondolja át, hogy az éppen megírt kód valóban hasznos-e az általánosság szempontjából.

Teljesítmény

Fontolja meg a struktúrák használatát a nagy foglalási arányú kis típusok esetében.

A szerkezetek (más néven értéktípusok) használata gyakran nagyobb teljesítményt eredményezhet bizonyos kódok esetében, mivel általában elkerüli az objektumok kiosztását. A struktúra azonban nem mindig "gyorsabb" gomb: ha egy struktúra adatainak mérete meghaladja a 16 bájtot, az adatok másolása gyakran több processzoridőt eredményezhet, mint egy referenciatípus használata.

Annak megállapításához, hogy használnia kell-e egy szerkezetet, vegye figyelembe a következő feltételeket:

  • Ha az adatok mérete 16 bájt vagy kisebb.
  • Ha valószínűleg sok ilyen típusú példány található a memóriában egy futó programban.

Ha az első feltétel érvényes, általában egy struktúrát kell használnia. Ha mindkettő alkalmazható, szinte mindig használjon structot. Előfordulhatnak olyan esetek, amikor a korábbi feltételek érvényesek, de a szerkezet használata nem jobb vagy rosszabb, mint egy referenciatípus használata, de valószínűleg ritkán fordulnak elő. Fontos, hogy mindig mérjük az ilyen változtatásokat, és ne feltételezésekre vagy intuícióra hagyatkozzunk.

Fontolja meg a struktúra-tuple-ök használatát, amikor kis értéktípusokat csoportosít magas lefoglalási arányú helyzetekben.

Vegye figyelembe a következő két függvényt:

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)

Ha ezeket a függvényeket egy olyan statisztikai teljesítménytesztelő eszközzel méri fel, mint a BenchmarkDotNet, azt fogja tapasztalni, hogy a runWithStructTuple strukturált tupleseket használó függvény 40%-kal gyorsabban fut, és nem foglal le memóriát.

Ezek az eredmények azonban nem mindig lesznek a saját kódodban. Ha megjelöl egy függvényt inline, a referencia-tuple-öket használó kód további optimalizálásokat kaphat, vagy a memóriát lefoglaló kód egyszerűen optimalizálható lehet. Mindig mérnie kell az eredményeket, amikor teljesítményről van szó, és soha ne alapuljon feltételezésen vagy intuíción.

Érdemes lehet strukturálni a rekordokat, ha a típus kicsi és magas foglalási arányokkal rendelkezik

A korábban ismertetett hüvelykujjszabály az F# rekordtípusokra is érvényes. Vegye figyelembe az őket feldolgozó alábbi adattípusokat és függvényeket:

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)

Ez hasonló az előző tömb kódhoz, de ezúttal az példában rekordok és egy egybeszerkesztett belső függvény használatát alkalmazza.

Ha ezeket a függvényeket egy olyan statisztikai teljesítménytesztelő eszközzel méri fel, mint a BenchmarkDotNet, azt fogja tapasztalni, hogy processStructPoint közel 60%-kal gyorsabban fut, és semmit sem foglal le a felügyelt halomon.

Fontolja meg a diszkriminált uniók szerkezetét, ha az adattípus kicsi, és magas a kiosztási arány

A korábbi megfigyelések a struct tuple-ök és rekordok teljesítményével kapcsolatban az F# Diszkriminált Uniókra is érvényesek. Tekintse meg az alábbi kódot:

    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

A tartománymodellezéshez gyakran definiálunk ilyen egyedi diszkriminált uniókat. Ha ezeket a függvényeket egy statisztikai teljesítménytesztelő eszközzel, például a BenchmarkDotNettel méri össze, az structReverseName körülbelül 25%-kal gyorsabban fut, mint reverseName a kis sztringek esetében. Nagy sztringek esetén mindkettő nagyjából azonos teljesítményt nyújt. Tehát ebben az esetben mindig előnyösebb egy szerkezetet használni. Ahogy korábban említettük, mindig mérje és ne használja a feltételezéseket vagy az intuíciót.

Bár az előző példa azt mutatta, hogy a struct Diszkriminált Unió jobb teljesítményt nyújtott, gyakori, hogy nagyobb diszkriminált uniók vannak egy tartomány modellezése során. Az ilyen nagyobb adattípusok nem feltétlenül fognak megfelelően teljesíteni, ha a rajtuk végzett műveletektől függően strukturálódnak, mivel több másolás is lehetséges.

Nem módosíthatóság és mutáció

Az F#-értékek alapértelmezés szerint nem módosíthatók, így elkerülheti a hibák bizonyos osztályait (különösen az egyidejűséget és a párhuzamosságot). Bizonyos esetekben azonban a végrehajtási idő vagy a memóriafoglalás optimális (vagy akár ésszerű) hatékonyságának elérése érdekében a legjobban a helyszíni állapotmutációval lehet megvalósítani a munkát. Ez az F# kulcsszóval mutable történő jóváhagyással lehetséges.

Az mutable használata az F#-ban szemben érezhető a funkcionális tisztasággal. Ez érthető, de a funkcionális tisztaság mindenhol ellentétes lehet a teljesítménycélokkal. Az a kompromisszum, hogy a mutációt úgy kapszulázzák, hogy a hívóknak ne kelljen törődniük azzal, mi történik, amikor függvényt hívnak. Ez lehetővé teszi, hogy funkcionális felületet írjon egy mutációalapú implementáción a teljesítmény szempontjából kritikus kódhoz.

Az F# let -kötési szerkezetek lehetővé teszik a kötések beágyazását egy másikba is, így a változók hatóköre mutable közel vagy elméletileg a legkisebb szinten tartható.

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

Egyetlen kód sem fér hozzá a csak a let bound érték inicializálásához completed használt mutable-hozdata.

Körbezárja a módosítható kódot nem módosítható interfészekbe

Mivel a hivatkozási átláthatóság a cél, kritikus fontosságú olyan kódot írni, amely nem teszi láthatóvá a teljesítménykritikus függvények változtatható belső részét. Az alábbi kód például az Array.contains F#-magtárban implementálja a függvényt:

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

A függvény többszöri meghívása nem változtatja meg a mögöttes tömböt. Emellett nem igényli, hogy bármilyen állapotot fenntartson a használat során. Ez hivatkozásilag transzparens, annak ellenére, hogy a benne lévő kódsorok szinte minden sora mutációt használ.

Fontolja meg a módosítható adatok beágyazását osztályokba

Az előző példa egyetlen függvényt használt a műveletek beágyazásához a mutable data használatával. Ez nem mindig elegendő összetettebb adathalmazokhoz. Vegye figyelembe a következő függvénykészleteket:

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

Ez a kód nagy teljesítményű, de feltárja a hívók által karbantartandó mutációalapú adatstruktúrát. Ez egy olyan osztályba csomagolható, amely nem rendelkezik olyan mögöttes tagokkal, amelyek módosíthatók:

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 beágyazza a mögöttes mutációalapú adatstruktúrát, így nem kényszeríti a hívókat a mögöttes adatstruktúra fenntartására. Az osztályok hatékony módszert jelentenek a mutáción alapuló adatok és rutinok beágyazására anélkül, hogy a hívóknak felfedné a részleteket.

Jobban kedveli let mutable a ref-vel szemben.

A referenciacellák az értékre mutató hivatkozást jelölik, nem pedig magát az értéket. Bár teljesítménykritikus kódhoz is használhatók, nem ajánlott. Vegyük a következő példát:

let kernels =
    let acc = ref Set.empty

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

    !acc |> Seq.toList

A referenciacella használata mostantól "szennyezi" az összes további kódot, mivel el kell halasztani és újra kell hivatkozni a mögöttes adatokra. Ehelyett fontolja meg a következőt 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

A lambda kifejezés közepén lévő egyetlen mutációs ponton kívül minden egyéb kód, amely acc-t érint, úgy teheti ezt meg, hogy az nem különbözik a normál let-kötött megváltoztathatatlan értékek használatától. Ez megkönnyíti az idő múlásával történő módosítást.

Null értékek és alapértelmezett értékek

A null értékeket általában kerülni kell az F#-ban. Alapértelmezés szerint az F#-deklarált típusok nem támogatják a null literál használatát, és minden érték és objektum inicializálva lesz. Egyes gyakori .NET API-k azonban null értéket adnak vissza vagy fogadnak el, és néhány gyakori .NET által deklarált típus, például tömbök és sztringek null értékeket engedélyeznek. Az értékek előfordulása null azonban nagyon ritka az F# programozásban, és az F# használatának egyik előnye a nullhivatkozási hibák elkerülése a legtöbb esetben.

Az attribútum használatának AllowNullLiteral elkerülése

Alapértelmezés szerint az F#-deklarált típusok nem támogatják a null literál használatát. Ennek engedélyezéséhez manuálisan megjegyzéseket fűzhet az F#-típusokhoz AllowNullLiteral . Azonban szinte mindig jobb elkerülni ezt.

Az attribútum használatának Unchecked.defaultof<_> elkerülése

Az F#-típushoz egy null vagy nullával inicializált értéket hozhat létre Unchecked.defaultof<_> használatával. Ez hasznos lehet bizonyos adatstruktúrák tárolójának inicializálásakor, vagy valamilyen nagy teljesítményű kódolási mintában vagy az együttműködésben. Ennek a szerkezetnek a használatát azonban el kell kerülni.

Az attribútum használatának DefaultValue elkerülése

Alapértelmezés szerint az F# rekordokat és objektumokat megfelelően kell inicializálni az építéskor. Az DefaultValue attribútum használható objektumok egyes mezőinek null kitöltésére egy vagy nulla inicializált értékkel. Erre a szerkezetre ritkán van szükség, és a használatát el kell kerülni.

Ha null értékű bemeneteket keres, az első lehetőségnél kivételeket emelhet ki

Új F#-kód írásakor a gyakorlatban nem kell null bemeneteket keresni, hacsak nem számít arra, hogy a kódot C# vagy más .NET-nyelvekből fogják használni.

Ha úgy dönt, hogy null értékű bemeneteket ad hozzá, végezze el az ellenőrzéseket az első lehetőségnél, és tegyen kivételt. Példa:

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

Régi okokból az FSharp.Core egyes sztringfüggvényei továbbra is üres sztringekként kezelik a null értékeket, és nem hiúsulnak meg null argumentumokon. Ezt azonban ne tekintse útmutatásnak, és ne használjon olyan kódolási mintákat, amelyek bármilyen szemantikai jelentést "nullnak" tulajdonítanak.

F# 9 null szintaxis használata az API-határoknál

Az F# 9 szintaxist ad hozzá, hogy explicit módon kijelentse, hogy egy érték null értékű lehet. Úgy lett kialakítva, hogy az API-határoknál használható legyen, hogy a fordító jelezze azokat a helyeket, ahol a null kezelés hiányzik.

Íme egy példa a szintaxis érvényes használatára:

type CustomType(m1, m2) =
    member _.M1 = m1
    member _.M2 = m2

    override this.Equals(obj: obj | null) =
        match obj with
        | :? CustomType as other -> this.M1 = other.M1 && this.M2 = other.M2
        | _ -> false

    override this.GetHashCode() =
        hash (this.M1, this.M2)

Kerülje el a null értékek továbbadását az F# kódban.

let getLineFromStream (stream: System.IO.StreamReader) : string | null =
    stream.ReadLine()

Ehelyett használjon idiomatikus F#-eszközöket (például beállításokat):

let getLineFromStream (stream: System.IO.StreamReader) =
    stream.ReadLine() |> Option.ofObj

A nullhoz kapcsolódó kivételek emeléséhez speciális nullArgCheck és nonNull függvényeket használhat. Azért is hasznosak, mert ha az érték nem null, akkor a megtisztított értékkel "árnyékolják" az argumentumot – így a további kódból már nem érhetők el a lehetséges nullmutatók.

let inline processNullableList list =
   let list = nullArgCheck (nameof list) list   // throws `ArgumentNullException`
   // 'list' is safe to use from now on
   list |> List.distinct

let inline processNullableList' list =
    let list = nonNull list                     // throws `NullReferenceException`
    // 'list' is safe to use from now on
    list |> List.distinct

Objektumprogramozás

Az F# teljes mértékben támogatja az objektumokat és az objektumorientált (OO) fogalmakat. Bár számos OO-fogalom hatékony és hasznos, nem mindegyik ideális a használatra. Az alábbi listák útmutatást nyújtanak az OO-funkciók magas szintű kategóriáihoz.

Ezeket a funkciókat számos esetben érdemes lehet használni:

  • Pont jelölése (x.Length)
  • Példánytagok
  • Implicit konstruktorok
  • Statikus tagok
  • Indexelő jelölés (arr[x]), egy Item tulajdonság definiálásával
  • Szeletelő jelölés (arr[x..y], arr[x..], arr[..y]), a GetSlice tagok definiálásával
  • Névvel ellátott és nem kötelező argumentumok
  • Interfészek és felületi implementációk

Először ne nyúljon ezekhez a funkciókhoz, de célszerűen alkalmazza őket, ha kényelmesen meg tudják oldani a problémát:

  • Metódus túlterhelése
  • Beágyazott módosítható adatok
  • Típusok operátorai
  • Automatikus tulajdonságok
  • Bevezetés IDisposable és IEnumerable
  • Típuskiterjesztések
  • esemény
  • Struktúrák
  • Delegáltak
  • Enumok

Általában kerülje ezeket a funkciókat, hacsak nem kell használnia őket:

  • Öröklésalapú típusú hierarchiák és implementációöröklés
  • Null értékek és Unchecked.defaultof<_>

A kompozíció előnyben részesítése az öröklés helyett

Az összetételt az öröklődéssel szemben előtérbe helyező megközelítés egy hosszú múltra visszatekintő elv, amelyet a jó F#-kód képes betartani. Az alapelv az, hogy egy alaposztályt ne tegye nyilvánossá, és ne kényszerítse a programozókat, hogy annak öröklésével érjék el a kívánt funkciókat.

Objektumkifejezések használata interfészek implementálásához, ha nincs szüksége osztályra

Az objektumkifejezések lehetővé teszik, hogy menet közben implementáljon interfészeket, és a implementált felületet egy értékhez kötje anélkül, hogy ezt egy osztályon belül kellene megtennie. Ez kényelmes, különösen akkor, ha csak az interfészt kell implementálnia, és nincs szüksége teljes osztályra.

Az Ionide-ben futtatott kód például egy kódjavítási művelet megadására szolgál, ha olyan szimbólumot adott hozzá, amely nem rendelkezik open utasítással:

    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
        }

Mivel a Visual Studio Code API-val való interakcióhoz nincs szükség osztályra, az Object Expressions ideális eszköz erre. Emellett az egységtesztelés során is értékesek, amikor improvizálva szeretné lecserélni a felületet tesztelő rutinokkal.

Az aláírások rövidítéséhez fontolja meg a Típus rövidítések használatát

A típus-rövidítések kényelmesen hozzárendelhetők egy címkéhez egy másik típushoz, például egy függvény-aláíráshoz vagy egy összetettebb típushoz. Az alábbi alias például egy címkét rendel ahhoz, ami szükséges a CNTK-val, egy mélytanulási könyvtárral való számítás meghatározásához.

open CNTK

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

A Computation név egy kényelmes módja annak, hogy bármilyen függvényt jelöljön, amely megfelel az aliasként megadott aláírásnak. Az ilyen típusú rövidítések használata kényelmes, és tömörebb kódot tesz lehetővé.

Kerülje a type abbreviations használatát a tartomány ábrázolásához

Bár a típus-rövidítések kényelmesen adhatnak nevet a függvény-aláírásoknak, más típusok rövidítésekor zavaróak lehetnek. Fontolja meg ezt a rövidítést:

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

Ez több szempontból is zavaró lehet:

  • BufferSize nem absztrakció; ez csak egy egész szám neve.
  • Ha BufferSize nyilvános API-ban van közzétéve, könnyen félreérthető, hogy többet jelent, mint egyszerűen int. A tartománytípusok általában több attribútummal rendelkeznek, és nem primitív típusok, például int. Ez a rövidítés sérti ezt a feltételezést.
  • A (PascalCase) burkolata BufferSize azt jelenti, hogy ez a típus több adatot tárol.
  • Ez az alias nem biztosít nagyobb egyértelműséget ahhoz képest, hogy elnevezett argumentumot ad meg egy függvénynek.
  • A rövidítés nem jelenik meg a lefordított IL-ben; ez csak egy egész szám, és ez az alias egy fordítási idő szerkezet.
module Networking =
    ...
    let send data (bufferSize: int) = ...

Összefoglalásként a típus rövidítések buktatója az, hogy nem absztrakciók azokkal a típusokkal szemben, amelyeket rövidítenek. Az előző példában BufferSize csak egy belső int, melyhez nem tartoznak további adatok, és nem nyújt semmilyen további előnyt a típusrendszerből azon túl, amit int már kínál.

Egy olyan alternatív megközelítés, ami a típus rövidítéseinek használata helyett a tartomány ábrázolására az, hogy egyedi esetű megkülönböztetett uniókat használunk. Az előző minta a következőképpen modellelhető:

type BufferSize = BufferSize of int

Ha olyan kódot ír, amely BufferSize és annak mögöttes értékével kapcsolatos, akkor ahelyett, hogy tetszőleges egész számokat ad át, létre kell hoznia egyet.

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

Ez csökkenti annak a valószínűségét, hogy véletlenül egy tetszőleges egész számot ad át a send függvénynek, mivel a hívónak olyan típust BufferSize kell létrehoznia, amely a függvény meghívása előtt körbefuttat egy értéket.