Sdílet prostřednictvím


Zásady kódování jazyka F#

Následující konvence jsou formulovány z zkušeností při práci s velkými základy kódu jazyka F#. Základem každého doporučení je pět principů dobrého kódu jazyka F#. Vztahují se k pokynům pro návrh komponent jazyka F#, ale platí pro jakýkoli kód jazyka F#, nejen pro komponenty, jako jsou knihovny.

Uspořádání kódu

Jazyk F# nabízí dva primární způsoby uspořádání kódu: moduly a obory názvů. Jsou podobné, ale mají následující rozdíly:

  • Jmenné prostory se kompilují jako jmenné prostory .NET. Moduly se kompilují jako statické třídy.
  • Obory názvů jsou vždy nejvyšší úrovní. Moduly můžou být nejvyšší úrovně a vnořené do jiných modulů.
  • Jmenné prostory mohou zahrnovat více souborů. Moduly nemohou.
  • Moduly mohou být zdobeny [<RequireQualifiedAccess>] a [<AutoOpen>].

Následující pokyny vám pomůžou tyto informace použít k uspořádání kódu.

Preferujte obory názvů na nejvyšší úrovni.

Pro jakýkoli veřejně použitelný kód jsou obory názvů přednostní pro moduly na nejvyšší úrovni. Vzhledem k tomu, že jsou kompilovány jako obory názvů .NET, jsou využitelné z jazyka C# bez použití using static.

// Recommended.
namespace MyCode

type MyClass() =
    ...

Když se používá modul nejvyšší úrovně, nemusí se při volání pouze z F# jevit jinak, ale pro uživatele C# může být překvapením, že musí MyClass kvalifikovat pomocí modulu MyCode, pokud si nejsou vědomi konkrétní konstrukce using static v jazyce C#.

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

type MyClass() =
    ...

Pečlivě aplikujte [<AutoOpen>]

Konstrukce [<AutoOpen>] může znečistit rozsah toho, co je k dispozici volajícím, a odpověď na to, odkud něco pochází, je "magie". To není dobrá věc. Výjimkou tohoto pravidla je samotná knihovna F# Core (i když je tato skutečnost také trochu rozporuplná).

Je to ale pohodlí, pokud máte pomocné funkce pro veřejné rozhraní API, které chcete uspořádat odděleně od veřejného rozhraní API.

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

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

        helper1 x y z

Díky tomu můžete čistě oddělit podrobnosti implementace od veřejného rozhraní API funkce, aniž byste museli plně kvalifikovat pomocnou funkci při každém volání funkce.

Kromě toho může být exponování rozšiřujících metod a konstruktoru výrazů na úrovni oboru názvů elegantně vyjádřeno pomocí [<AutoOpen>].

Používejte [<RequireQualifiedAccess>] vždy, když se názvy můžou shodovat nebo cítíte, že vám to pomůže s čitelností.

Přidání atributu [<RequireQualifiedAccess>] do modulu označuje, že modul nemusí být otevřen a že odkazy na prvky modulu vyžadují explicitní kvalifikovaný přístup. Například modul Microsoft.FSharp.Collections.List má tento atribut.

To je užitečné, když funkce a hodnoty v modulu mají názvy, které jsou pravděpodobně v konfliktu s názvy v jiných modulech. Vyžadování kvalifikovaného přístupu může výrazně zvýšit dlouhodobou udržovatelnost a schopnost knihovny vyvíjet.

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

...

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

Topologické řazení open příkazů

V jazyce F# záleží na pořadí deklarací, včetně prohlášení open (a open type, označovaného stejně jako open dále). Je to na rozdíl od jazyka C#, kde účinek using a using static je nezávislý na pořadí těchto příkazů v souboru.

V jazyce F# můžou prvky otevřené v oboru stínovat ostatní, které už existují. To znamená, že změna pořadí open příkazů by mohla změnit význam kódu. V důsledku toho se nedoporučuje jakékoli libovolné řazení všech open příkazů (například alfanumericky), aby se negenerovalo jiné chování, které byste mohli očekávat.

Místo toho doporučujeme řadit je topologickým způsobem. To znamená pořadí příkazů open v pořadí, ve kterém jsou definované vrstvy systému. Je také možné zvážit alfanumerické řazení v různých topologických vrstvách.

Tady je například topologické řazení veřejného souboru rozhraní API služby kompilátoru jazyka F#:

namespace Microsoft.FSharp.Compiler.SourceCodeServices

open System
open System.Collections.Generic
open System.Collections.Concurrent
open System.Diagnostics
open System.IO
open System.Reflection
open System.Text

open FSharp.Compiler
open FSharp.Compiler.AbstractIL
open FSharp.Compiler.AbstractIL.Diagnostics
open FSharp.Compiler.AbstractIL.IL
open FSharp.Compiler.AbstractIL.ILBinaryReader
open FSharp.Compiler.AbstractIL.Internal
open FSharp.Compiler.AbstractIL.Internal.Library

open FSharp.Compiler.AccessibilityLogic
open FSharp.Compiler.Ast
open FSharp.Compiler.CompileOps
open FSharp.Compiler.CompileOptions
open FSharp.Compiler.Driver

open Internal.Utilities
open Internal.Utilities.Collections

Konec čáry odděluje topologické vrstvy, přičemž každá vrstva je seřazena alfanumericky poté. Tím se kód uspořádá bez náhodného stínování hodnot.

Použití tříd k zahrnutí hodnot, které mají vedlejší účinky

Při inicializaci hodnoty často může mít vedlejší účinky, například instanciace kontextu do databáze nebo jiného vzdáleného prostředku. Je lákavé inicializovat takové věci v modulu a používat je v následujících funkcích:

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

To je často problematické z několika důvodů:

Nejprve je konfigurace aplikace vložena do základu kódu s dep1 a dep2. To je obtížné udržovat ve větších základech kódu.

Za druhé, staticky inicializovaná data by neměla obsahovat hodnoty, které nejsou bezpečné pro vlákno, pokud vaše komponenta bude používat více vláken. Je to jasně porušeno dep3.

Nakonec se inicializace modulu zkompiluje do statického konstruktoru pro celou kompilační jednotku. Pokud dojde k nějaké chybě v inicializaci hodnoty vázané příkazem let v daném modulu, projeví se jako TypeInitializationException, který je pak uložen do mezipaměti po celou dobu životnosti aplikace. To může být obtížné diagnostikovat. Obvykle existuje vnitřní výjimka, kterou se můžete pokusit zhodnotit, ale pokud neexistuje, pak nelze určit, co je hlavní příčinou.

Místo toho stačí použít jednoduchou třídu k uložení závislostí:

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

To umožňuje následující:

  1. Posunutí jakéhokoli závislého stavu ven z rozhraní API.
  2. Konfiguraci je teď možné provést mimo rozhraní API.
  3. Chyby inicializace závislých hodnot se pravděpodobně neprojeví jako TypeInitializationException.
  4. Teď je rozhraní API jednodušší testovat.

Správa chyb

Správa chyb ve velkých systémech je složitou a jemnou záležitostí a neexistuje žádné magické řešení, které by zajistilo, že vaše systémy jsou odolné proti chybám a chovají se dobře. Následující pokyny by měly obsahovat pokyny pro navigaci v tomto obtížném prostoru.

Reprezentovat chybové případy a nepřípustné stavy v typech, které jsou vlastní vaší doméně.

Díky diskriminovaným uniím vám jazyk F# umožňuje znázorňovat chybný stav programu ve vašem systému typů. Příklad:

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

V tomto případě existují tři známé způsoby, jak může selhat výběr peněz z bankovního účtu. Každý případ chyby je reprezentován v typu, a proto je možné je bezpečně zpracovat v rámci programu.

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"

Obecně platí, že pokud můžete modelovat různé způsoby, jak něco může selhat ve vaší doméně, pak se kód zpracování chyb už nebude považovat za něco, co musíte řešit kromě běžného toku programu. Je to prostě součást normálního toku programu a nepovažuje se za výjimečné. Existují dvě hlavní výhody:

  1. S tím je jednodušší údržba, jak se vaše doména mění v průběhu času.
  2. Případy chyb se snáze testují pomocí jednotkových testů.

Použití výjimek v případech, kdy chyby nelze reprezentovat pomocí typů

Ne všechny chyby mohou být reprezentovány v problémové doméně. Tyto druhy chyb jsou v přírodě výjimečné , takže schopnost vyvolat a zachytit výjimky v jazyce F#.

Nejprve doporučujeme, abyste si přečetli pokyny pro návrh výjimek. Platí také pro jazyk F#.

Hlavní konstrukce dostupné v jazyce F# pro účely vyvolání výjimek by se měly brát v úvahu v následujícím pořadí:

Funkce Syntaxe Účel
nullArg nullArg "argumentName" Vyvolá System.ArgumentNullException se zadaným názvem argumentu.
invalidArg invalidArg "argumentName" "message" Vyvolá System.ArgumentException se zadaným názvem argumentu a zprávou.
invalidOp invalidOp "message" Vyvolá zprávu se zadanou zprávou System.InvalidOperationException .
raise raise (ExceptionType("message")) Mechanismus pro obecné účely pro vyvolání výjimek
failwith failwith "message" Vyvolá zprávu se zadanou zprávou System.Exception .
failwithf failwithf "format string" argForFormatString Vyvolá System.Exception se zprávou určenou formátovacím řetězcem a jeho vstupy.

Použijte nullArg, invalidArga invalidOp jako mechanismus vyvolat ArgumentNullException, ArgumentExceptiona InvalidOperationException pokud je to vhodné.

Funkce failwith a failwithf by se obvykle měly vyhnout, protože zvyšují základní Exception typ, nikoli konkrétní výjimku. Podle pokynů k návrhu výjimek chcete v případě potřeby vyvolat konkrétnější výjimky.

Použití syntaxe zpracování výjimek

Jazyk F# podporuje vzory výjimek prostřednictvím try...with syntaxe:

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

Sladění funkcí, které se mají provést v případě výjimky s porovnávání vzorů, může být trochu složité, pokud chcete zachovat kód čistý. Jedním z takových způsobů, jak to vyřešit, je použít aktivní vzory jako prostředek k seskupení funkcí obklopujících případ chyby se samotnou výjimkou. Můžete například využívat rozhraní API, které při vyvolání výjimky uzavře cenné informace do metadat výjimek. Rozbalení užitečné hodnoty v těle zachycené výjimky uvnitř Aktivního Vzoru a vrácení této hodnoty může být užitečné za určitých podmínek.

Nepoužívejte monadické zpracování chyb k nahrazení výjimek.

Výjimky se často považují za tabu v čistě funkčním paradigmatu. Výjimky skutečně porušují čistotu, takže je bezpečné je považovat za nefunkčně čisté. To ale ignoruje realitu, kde se musí kód spouštět, a že může dojít k chybám za běhu. Obecně platí, že napište kód na předpoklad, že většina věcí není čistá nebo celková, aby se minimalizovala nepříjemná překvapení (podobně jako prázdné catch v jazyce C# nebo špatná správa trasování zásobníku, zahození informací).

Je důležité zvážit následující klíčové silné stránky a aspekty výjimek s ohledem na jejich význam a vhodnost v ekosystému .NET a ekosystému napříč jazyky jako celku:

  • Obsahují podrobné diagnostické informace, které jsou užitečné při ladění problému.
  • Dobře rozumí modulu runtime a dalším jazykům .NET.
  • V porovnání s kódem, který se snaží za každou cenu vyhnout výjimkám implementací určité podmnožiny jejich sémantiky ad hoc způsobem, mohou výrazně snížit zbytečný opakující se kód.

Tento třetí bod je kritický. V případě netriviálních složitých operací může neúspěšné použití výjimek vést k řešení struktur, jako je tato:

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

Což může snadno vést k křehkému kódu, jako je porovnávání vzorů u chyb typu stringly type:

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?

Navíc může být lákavé ignorovat jakoukoli výjimku se snahou o „jednoduchou“ funkci, která vrací „hezčí“ typ:

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

Bohužel může tryReadAllText vyvolat řadu výjimek na základě řady věcí, které se můžou stát v systému souborů, a tento kód zahodí všechny informace o tom, co se ve vašem prostředí může skutečně pokazit. Pokud tento kód nahradíte jedním z typů výsledků, vrátíte se ke zpracování chybové zprávy ve stylu "stringly typed".

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

A umístění samotného objektu výjimky do konstruktoru Error pouze vás přinutí správně zpracovat typ výjimky v místě volání, nikoli ve funkci. Tím se efektivně vytvoří kontrolované výjimky, které jsou pro uživatele API notoricky nepříjemné.

Dobrou alternativou k výše uvedeným příkladům je zachycení konkrétních výjimek a vrácení smysluplné hodnoty v kontextu této výjimky. Pokud funkci upravíte tryReadAllText následujícím způsobem, None má větší význam:

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

Místo toho, aby fungovala jako univerzální řešení, tato funkce nyní správně zpracovává případ, kdy soubor není nalezen, a vrátí tomu odpovídající hodnotu. Tato návratová hodnota se může mapovat na tento případ chyby, ale nezahodí žádné kontextové informace ani nenutí volající, aby se zabývali případem, který nemusí být relevantní v dané části kódu.

Typy, jako Result<'Success, 'Error> jsou vhodné pro základní operace, kde nejsou vnořené, a volitelné typy jazyka F# jsou ideální pro reprezentaci, když něco může vrátit něco nebo nic. Nejsou však náhradou za výjimky a neměly by být použity při pokusu o nahrazení výjimek. Místo toho by se měly použít uvážlivě k řešení konkrétních aspektů zásad správy výjimek a chyb cílovými způsoby.

Částečná aplikace a programování bez použití parametrů

Jazyk F# podporuje částečnou aplikaci, a proto různé způsoby programování ve stylu bez bodu. To může být užitečné pro opakované použití kódu v rámci modulu nebo implementace něčeho, ale není to něco, co veřejně zpřístupnit. Obecně platí, že point-free programování není samo o sobě ctností a může představovat významnou kognitivní bariéru pro lidi, kteří nejsou ponořeni do tohoto stylu.

Nepoužívejte částečnou aplikaci a kurrying ve veřejných rozhraních API.

S malou výjimkou může být použití částečné aplikace ve veřejných rozhraních API pro uživatele matoucí. letHodnoty vázané v kódu jazyka F# jsou obvykle hodnoty, nikoli hodnoty funkcí. Kombinování hodnot a hodnot funkcí může vést k úspoře několika řádků kódu během poměrně velké kognitivní režie, zejména v kombinaci s operátory, jako je >>, pro kompozici funkcí.

Zvažte důsledky pro nástroje při programování bez bodů

Curried funkce neoznačují argumenty. To má vliv na nástrojové vybavení. Vezměte v úvahu následující dvě funkce:

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

Obě jsou platné funkce, ale funcWithApplication jedná se o složenou funkci. Když v editoru najedete myší na jejich typy, zobrazí se toto:

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

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

V místě volání vám popisy v nástrojích, jako je Visual Studio, poskytnou typový podpis, ale vzhledem k tomu, že nejsou definována žádná jména, se názvy nezobrazí. Názvy jsou důležité pro dobrý návrh rozhraní API, protože volajícím pomáhají lépe porozumět významu rozhraní API. Použití kódu bez bodu ve veřejném rozhraní API může ztížit pochopení volajících.

Pokud narazíte na bezbodový kód, jako je funcWithApplication, který je veřejně přístupný, doporučujeme provést úplné η-rozšíření, aby nástroje mohly získat smysluplné názvy argumentů.

Ladění kódu bez bodu může být navíc náročné, pokud není nemožné. Nástroje pro ladění spoléhají na hodnoty vázané na názvy (například let vazby), abyste mohli zkontrolovat mezilehlé hodnoty uprostřed provádění. Pokud kód nemá žádné hodnoty ke kontrole, není potřeba nic ladit. V budoucnu se nástroje ladění mohou vyvinout tak, že budou syntetizovat tyto hodnoty na základě dříve provedených cest, ale není dobré spoléhat se na možné budoucí funkce ladění.

Zvažte částečné použití jako techniku pro snížení interní šablony.

Na rozdíl od předchozího bodu je částečná aplikace funkcí skvělým nástrojem pro snížení opakujícího se kódu uvnitř aplikace nebo hlubších úrovní API. Je užitečné pro jednotkové testování implementace složitějších API, které často způsobují potíže. Následující kód například ukazuje, jak můžete dosáhnout toho, co většina mockovacích frameworků nabízí, aniž byste se museli spoléhat na takový framework a učit se související specializované API.

Představte si například následující topografii řešení:

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

ImplementationLogic.fsproj může zveřejnit kód, například:

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

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

Jednotkové testování Transactions.doTransaction v ImplementationLogic.Tests.fsproj je snadné:

namespace TransactionsTestingUtil

open Transactions

module TransactionsTestable =
    let getTestableTransactionRoutine mockContext = Transactions.doTransaction mockContext

Částečné použití doTransaction pomocí napodobovaného objektu kontextu umožňuje volat funkci ve všech testech jednotek, aniž byste museli pokaždé vytvořit napodobený kontext:

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)

Tuto techniku nepoužívejte univerzálně na celý kód, ale je to dobrý způsob, jak snížit zbytečné opakování u složitých interních procesů a provádět jednotkové testování těchto interních částí.

Řízení přístupu

Jazyk F# má více možností řízení přístupu, zděděno z toho, co je k dispozici v modulu runtime .NET. Nejsou to jen použitelné pro typy – můžete je použít i pro funkce.

Osvědčené postupy v kontextu knihoven, které se široce využívají:

  • Preferujte jinépublic typy a členy, dokud je nepotřebujete, aby byly veřejně dostupné. Tím se také minimalizuje to, k čemu se spotřebitelé připojují.
  • Snažte se zachovat všechny pomocné funkce private.
  • Zvažte použití [<AutoOpen>] na soukromý modul pomocných funkcí, pokud se jich stane mnoho.

Odvození typů a obecné typy

Odvozování typu vám může ušetřit zápis velkého množství opakujícího se kódu. Automatická generalizace v kompilátoru jazyka F# vám může pomoct psát obecnější kód bez téměř žádného dalšího úsilí na vaši část. Tyto funkce ale nejsou univerzální.

  • Zvažte označování názvů argumentů explicitními typy ve veřejných rozhraních API a nespoléhejte na odvození typu.

    Důvodem je, že byste měli mít kontrolu nad tvarem rozhraní API, nikoli kompilátorem. I když kompilátor dokáže efektivně odvozovat typy za vás, může dojít ke změně struktury vašeho API, pokud se změnily typy u interních prvků, na které se spoléhalo. To může být to, co chcete, ale téměř jistě povede ke změně rozhraní API způsobující chybu, se kterou se pak budou muset vypořádat podřízení příjemci. Pokud místo toho explicitně řídíte tvar veřejného rozhraní API, můžete tyto zásadní změny řídit. V DDD se to dá považovat za vrstvu proti korupci.

  • Zvažte smysluplný název obecných argumentů.

    Pokud nepíšete skutečně obecný kód, který není specifický pro konkrétní doménu, může smysluplný název pomoct ostatním programátorům pochopit doménu, ve které pracují. Například parametr typu pojmenovaný 'Document v kontextu interakce s databází dokumentů usnadňuje přijetí obecných typů dokumentů funkcí nebo člena, se kterým pracujete.

  • Zvažte pojmenování parametrů obecného typu pomocí PascalCase.

    Toto je obecný způsob, jak dělat věci v .NET, takže se doporučuje používat PascalCase místo snake_case nebo camelCase.

A konečně, automatická generalizace není vždy přínosem pro lidi, kteří se s jazykem F# nebo velkým kódovým základem teprve seznamují. Při používání obecných komponent dochází ke kognitivní režii. Navíc, pokud se automaticky generalizované funkce nepoužívají s různými vstupními typy (ať už jen v případě, že jsou určeny k použití jako takové), pak pro ně neexistuje skutečný přínos. Vždy zvažte, jestli kód, který píšete, bude skutečně mít prospěch z toho, že je obecného charakteru.

Výkon

Zvažte struktury malých typů s vysokými rychlostmi alokace.

Použití struktur (označovaných také jako typy hodnot) může často způsobit vyšší výkon pro určitý kód, protože obvykle zabraňuje přidělování objektů. Struktury ale nejsou vždy tlačítkem "přejít rychleji": pokud velikost dat ve struktuře překročí 16 bajtů, může kopírování dat často způsobit více času stráveného procesorem než použití referenčního typu.

Pokud chcete zjistit, jestli byste měli použít strukturu, zvažte následující podmínky:

  • Pokud je velikost dat 16 bajtů nebo menší.
  • Pokud je pravděpodobné, že budete mít mnoho instancí těchto typů, které jsou residentní v paměti v běžícím programu.

Pokud se použije první podmínka, měli byste obecně použít strukturu. Pokud platí oba případy, měli byste téměř vždy strukturu použít. V některých případech mohou platit předchozí podmínky, ale použití struktury není lepší nebo horší než použití referenčního typu, ale pravděpodobně budou vzácné. Při provádění podobných změn je vždy důležité měřit a nespoléhat se na předpoklady nebo intuici.

Zvažte použití struktur typu tuples pro seskupování malých hodnotových typů s vysokou frekvencí alokace.

Vezměte v úvahu následující dvě funkce:

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)

Při srovnávacím testování těchto funkcí pomocí statistického srovnávacího nástroje, jako je BenchmarkDotNet, zjistíte, že runWithStructTuple funkce, která používá řazené kolekce členů struktury, běží o 40 % rychleji a nepřiděluje žádnou paměť.

Může se však stát, že tyto výsledky nebudou vždy platné ve vašem vlastním kódu. Pokud funkci označíte jako inline, kód, který používá referenční řazené kolekce členů, může získat další optimalizace nebo kód, který by přiděloval, by mohl být jednoduše optimalizován. Vždy byste měli měřit výsledky, kdykoli se jedná o výkon, a nikdy nepracujte na základě předpokladu nebo intuitivně.

Zvažte struktury záznamů, pokud je typ malý a má vysokou míru alokace.

Pravidlo popsané výše platí také pro typy záznamů jazyka F#. Vezměte v úvahu následující datové typy a funkce, které je zpracovávají:

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)

To se podobá předchozímu kódu s n-ticemi, ale tentokrát příklad používá záznamy a inlajnovanou vnitřní funkci.

Při srovnávacím testování těchto funkcí pomocí statistického srovnávacího nástroje, jako je BenchmarkDotNet, zjistíte, že processStructPoint běží téměř o 60 % rychleji a nepřiděluje nic na spravované haldě.

Zvažte diskriminované sjednocení struktur, pokud je datový typ malý s vysokou mírou přidělování.

Předchozí pozorování o výkonu se strukturovanými n-ticemi a záznamy také platí pro F# diskriminované unie. Uvažujte následující kód:

    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

Pro modelování domén je běžné definovat diskriminované sjednocení s jedním případem. Při srovnávacím testování těchto funkcí pomocí statistického srovnávacího nástroje, jako je BenchmarkDotNet, zjistíte, že structReverseName běží přibližně o 25 % rychleji než reverseName u malých řetězců. U velkých řetězců fungují oba přibližně stejně. V tomto případě je proto vždy vhodnější použít strukturu. Jak jsme už zmínili dříve, vždy změřte a nepracujte na předpokladech nebo intuitivně.

I když předchozí příklad ukázal, že diskriminovaná unie přinesla lepší výkon, je běžné, že při modelování domény mají diskriminované unie větší velikost. Větší datové typy, jako jsou takové, nemusí být stejně efektivní, pokud jsou struktury, podle prováděných operací, protože může být zapotřebí více kopírování.

Neměnnost a mutovatelnost

Hodnoty F# jsou ve výchozím nastavení neměnné, což umožňuje vyhnout se určitým třídám chyb (zejména těch, které zahrnují souběžnost a paralelismus). V některých případech může být pro dosažení optimální (nebo alespoň rozumné) efektivity času provádění nebo přidělení paměti nejlepší implementovat rozsah práce pomocí úprav v místě stavu. To je možné v rámci výslovného souhlasu s jazykem F# s klíčovým slovem mutable .

Použití mutable v jazyce F# může působit v rozporu s funkční čistotou. To je pochopitelné, ale funkční čistota všude může být v rozporu s výkonnostními cíli. Kompromisem je zapouzdřit mutaci tak, aby se volající nemuseli starat o to, co se stane, když volají funkci. To vám umožní napsat funkční rozhraní přes implementaci založenou na mutaci pro kritický kód výkonu.

Konstrukty vazeb jazyka F# let také umožňují vnořit vazby do jiné vazby, což lze využít k zachování rozsahu proměnné mutable blízko nebo na jeho teoreticky nejmenší úrovni.

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

K proměnlivé hodnotě completed , která byla použita pouze k inicializaci data vázané hodnoty, nemá přístup žádný kód.

Zabalte mutabilní kód do neměnných rozhraní

S cílem dosažení referenční transparentnosti je důležité napsat kód, který nezpřístupňuje proměnlivé části výkonově kritických funkcí. Následující kód například implementuje funkci v základní knihovně jazyka Array.contains F#:

[<CompiledName("Contains")>]
let inline contains value (array:'T[]) =
    checkNonNull "array" array
    let mutable state = false
    let mutable i = 0
    while not state && i < array.Length do
        state <- value = array[i]
        i <- i + 1
    state

Volání této funkce několikrát nezmění podkladové pole, ani nevyžaduje, abyste zachovali jakýkoli proměnlivý stav při jeho využívání. Je odkazově transparentní, i když téměř každý řádek kódu v něm používá mutaci.

Zvažte zapouzdření proměnlivých dat ve třídách.

Předchozí příklad použil jedinou funkci k zapouzdření operací s použitím proměnlivých dat. To není vždy dostatečné pro složitější sady dat. Vezměte v úvahu následující sady funkcí:

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

Tento kód je výkonný, ale odhaluje datovou strukturu založenou na mutaci, o jejíž údržbu se volající musí starat. To lze zabalit uvnitř třídy bez jakýchkoli podkladových členů, které se nemohou měnit.

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 zapouzdřuje podkladovou datovou strukturu založenou na mutacích, čímž nenutí volající, aby udržovali podkladovou datovou strukturu. Třídy představují účinný způsob zapouzdření dat a rutin, které jsou založené na mutaci, aniž by volajícím vystavily podrobnosti.

Preferovat let mutable než ref

Odkazové buňky představují způsob, jak místo samotné hodnoty znázorňovat odkaz na hodnotu. I když je možné je použít pro kód kritický pro výkon, nedoporučuje se. Představte si následující příklad:

let kernels =
    let acc = ref Set.empty

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

    !acc |> Seq.toList

Použití referenční buňky nyní "znečišťuje" veškerý následný kód s tím, že musí odvodit a znovu odkazovat na podkladová data. Místo toho zvažte 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

Kromě jediného bodu mutace uprostřed výrazu lambda může jakýkoli jiný kód, který pracuje s acc, tak činit způsobem, který se nijak neliší od použití normální neměnné hodnoty vázané na let. To vám usnadní změnu v průběhu času.

Hodnoty Null a výchozí hodnoty

Obecně by se mělo vyhnout používání null v jazyce F#. Ve výchozím nastavení F# deklarované typy nepodporují použití literálu null a všechny hodnoty a objekty jsou inicializovány. Některá běžná rozhraní API .NET však vrací nebo přijímají hodnoty null a některá běžná . Net deklarované typy, jako jsou pole a řetězce, umožňují hodnoty null. Výskyt null hodnot je však v programování jazyka F# velmi vzácný a jednou z výhod použití jazyka F# je vyhnout se chybám odkazů na hodnotu null ve většině případů.

Vyhněte se použití atributu AllowNullLiteral

Ve výchozím nastavení typy deklarované jazykem F# nepodporují použití literálu null . Můžete ručně anotovat typy jazyka F# pomocí AllowNullLiteral, abyste to umožnili. Je však téměř vždy lepší se tomu vyhnout.

Vyhněte se použití atributu Unchecked.defaultof<_>

K vygenerování null nebo nulové inicializované hodnoty pro typ jazyka F# je možné použít Unchecked.defaultof<_>. To může být užitečné při inicializaci úložiště pro některé datové struktury nebo v některém vysoce výkonném modelu kódování nebo při interoperabilitě. Použití tohoto konstruktu by se mělo však vyhnout.

Vyhněte se použití atributu DefaultValue

Ve výchozím nastavení musí být záznamy a objekty F# správně inicializovány při konstrukci. Atribut DefaultValue lze použít k naplnění některých polí objektů hodnotou null nebo nulovou inicializací. Tato konstrukce je zřídka potřebná a mělo by se vyhnout jejímu použití.

Pokud zkontrolujete vstupy s hodnotou null, vyvolejte výjimku co nejdříve.

Při psaní nového kódu jazyka F# není v praxi nutné kontrolovat vstupy s hodnotou null, pokud neočekáváte, že se bude kód používat z jazyka C# nebo jiných jazyků .NET.

Pokud se rozhodnete přidat kontroly pro vstupy s hodnotou null, proveďte kontroly při první příležitosti a vyvolejte výjimku. Příklad:

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

Z starších důvodů některé řetězcové funkce v FSharp.Core stále považují hodnoty null za prázdné řetězce a neulžou u argumentů null. Neberte to jako pokyny a nepřejímejte vzory kódování, které přiřazují jakýkoliv sémantický význam "null".

Využití syntaxe F# 9 null na hranicích rozhraní API

F# 9 přidává syntaxi , která explicitně uvádí, že hodnota může být null. Je navržená tak, aby se používala na hranicích rozhraní API, aby kompilátor indikoval místa, kde chybí zpracování null.

Tady je příklad platného použití syntaxe:

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)

Vyhněte se šíření hodnot null v rámci kódu jazyka F#:

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

Místo toho použijte idiomaticické F# prostředky (například možnosti):

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

Pro vyvolání výjimek souvisejících s hodnotou null můžete použít speciální funkce nullArgCheck a nonNull. Jsou užitečné také proto, že pokud hodnota není null, zakryjí argument svoji očištěnou hodnotou – další kód už nemá přístup k možným ukazatelům null.

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

Programování objektů

Jazyk F# má plnou podporu pro koncepty objektů a objektů orientovaných na objekty (OO). I když mnoho konceptů OO je výkonných a užitečných, ne všechny z nich jsou ideální pro použití. Následující seznamy obsahují pokyny k kategoriím funkcí OO na vysoké úrovni.

Zvažte použití těchto funkcí v mnoha situacích:

  • Zápis tečky (x.Length)
  • Členové instance
  • Implicitní konstruktory
  • Statické členy
  • Zápis indexeru (arr[x]) definováním vlastnosti Item
  • Zápis řezů (arr[x..y], arr[x..], arr[..y]) definováním členů GetSlice
  • Pojmenované a volitelné argumenty
  • Rozhraní a implementace rozhraní

Nejdřív se k těmto funkcím nedosahujte, ale dobře je použijte, když jsou vhodné k vyřešení problému:

  • Přetížení metody
  • Zapouzdřená proměnlivá data
  • Operátory aplikované na typy
  • Automatické vlastnosti
  • Implementace IDisposable a IEnumerable
  • Rozšíření typů
  • Události
  • Struktury
  • Delegáti
  • Výčty

Obecně se těmto funkcím vyhněte, pokud je nebudete muset použít:

  • Hierarchie typů založené na dědičnosti a dědičnost implementace
  • Hodnoty Null a Unchecked.defaultof<_>

Preferovat složení před dědičností

Složení nad dědičností je tradiční idiom, kterým se dobrý kód F# může řídit. Základním principem je, že byste neměli vystavit základní třídu a nutit volající dědit z této základní třídy pro získání funkcionality.

Použití výrazů objektů k implementaci rozhraní, pokud nepotřebujete třídu

Objektové výrazy umožňují implementovat rozhraní za běhu, vytvořit vazbu implementovaného rozhraní na hodnotu, aniž by to bylo nutné provést uvnitř třídy. To je praktické, zejména pokud potřebujete implementovat pouze rozhraní a nemáte k dispozici úplnou třídu.

Tady je například kód, který se spouští v Ionide a poskytuje akci opravy kódu, pokud jste přidali symbol, pro který nemáte open příkaz:

    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
        }

Vzhledem k tomu, že při interakci s rozhraním API editoru Visual Studio Code není potřeba používat třídu, jsou pro to ideální nástroj objektové výrazy. Jsou také cenné pro jednotkové testování, když chcete nahradit rozhraní testovacími rutinami provizorním způsobem.

Zvažte zkratky typů pro zkrácení podpisů.

Zkratky typů představují pohodlný způsob, jak přiřadit popisek jinému typu, jako je podpis funkce nebo složitější typ. Například následující alias přiřadí popisek k tomu, co je potřeba k definování výpočtů pomocí CNTK, knihovny hlubokého učení:

open CNTK

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

Název Computation je pohodlný způsob, jak označit libovolnou funkci, která odpovídá podpisu, který aliasuje. Použití zkratek typů, jako je to, je pohodlné a umožňuje více stručnější kód.

Vyhněte se používání zkratek typů k reprezentaci vaší domény

I když jsou zkratky typů vhodné pro pojmenování podpisů funkcí, mohou být matoucí při zkracování jiných typů. Vezměte v úvahu tuto zkratku:

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

To může být matoucí několika způsoby:

  • BufferSize není abstrakce; je to jen další název celého čísla.
  • Pokud BufferSize je zveřejněné ve veřejném rozhraní API, může být snadno chybně interpretováno tak, aby znamenalo více než jen int. Obecně platí, že typy domén mají více atributů a nejsou primitivními typy jako int. Tato zkratka tento předpoklad porušuje.
  • Formátování písmen BufferSize (PascalCase) naznačuje, že tento typ obsahuje více údajů.
  • Tento alias nenabízí ve srovnání s poskytnutím pojmenovaného argumentu funkci větší přehlednost.
  • Zkratka se nebude projevovat v kompilovaném IL; je to jen celé číslo a tento alias je konstrukce času kompilace.
module Networking =
    ...
    let send data (bufferSize: int) = ...

Stručně řečeno, úskalí zkratek typu je, že nejsou abstrakcemi typů, které zkracují. V předchozím příkladu je BufferSize jen int pod pokličkou, bez dalších dat a žádné výhody ze systému typů mimo těch, které int už poskytuje.

Alternativním přístupem k používání zkratek typů k reprezentaci domény je použití jednopřípadových diskriminovaných sjednocení. Předchozí ukázku je možné modelovat následujícím způsobem:

type BufferSize = BufferSize of int

Pokud napíšete kód, který funguje ve smyslu BufferSize a jeho podkladové hodnoty, musíte jeden vytvořit, než předat libovolné celé číslo.

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

Tím se snižuje pravděpodobnost, že funkce omylem předá libovolné celé číslo send , protože volající musí vytvořit BufferSize typ pro zabalení hodnoty před voláním funkce.