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:

  • Obory názvů se kompilují jako obory názvů .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ů.
  • Obory názvů můžou 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() =
    ...

Pokud používáte modul nejvyšší úrovně, nemusí se při volání pouze z jazyka F# zobrazovat jinak, ale pro uživatele jazyka C# může být volající překvapen tím, že se musí k modulu při vědomí konkrétní using static konstrukce jazyka C# kvalifikovat MyClassMyCode s modulem.

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

type MyClass() =
    ...

Pečlivě použijte [<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 pomocné rutiny při každém volání.

Kromě toho může být zveřejnění rozšiřujících metod a tvůrce výrazů na úrovni oboru názvů přehledně 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. Modul má například Microsoft.FSharp.Collections.List 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, které se právě označují směrem open dolů). 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 může mít často vedlejší účinky, například vytvoření instance 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>/connectionstring.txt"
    let dep2 = Environment.GetEnvironmentVariable "DEP_2"

    let private r = Random()
    let dep3() = r.Next() // Problematic if multiple threads use this

    let function1 arg = doStuffWith dep1 dep2 dep3 arg
    let function2 arg = doStuffWith dep1 dep2 dep3 arg

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. To je 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 let-bound hodnoty v daném modulu, manifestuje se jako TypeInitializationException ten, který se pak uloží 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 o důvod, ale pokud neexistuje, pak neexistuje žádná informace o tom, co je původní příčina.

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. Nasdílením libovolného závislého stavu mimo samotné 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. Rozhraní API je teď jednodušší testovat.

Správa chyb

Správa chyb ve velkých systémech je složitá a nuanční úsilí a neexistují žádné stříbrné odrážky, které by zajistily, ž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.

Znázornění chybových případů a neplatného stavu ve vnitřních typech ve vaší doméně

S diskriminovanými sjednoceními 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, jak se vaše doména mění v průběhu času, je jednodušší.
  2. Chybové případy jsou jednodušší pro testování jednotek.

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í:

Function Syntaxe Účel
nullArg nullArg "argumentName" Vytvoří název System.ArgumentNullException zadaného argumentu.
invalidArg invalidArg "argumentName" "message" Vyvolá název System.ArgumentException zadaného argumentu a zprávu.
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 System.Exception Vyvolá zprávu 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 funkce by se obecně měly vyhnout, protože vyvolávají 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é v některých situacích.

Nepoužívejte zpracování chyb monadic 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 vyhne výjimkám, mohou snížit významné často používané funkce, aby se zabránilo výjimkám implementací určité podmnožiny jejich sémantiky na ad hoc bázi.

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é spolknout jakoukoli výjimku v přání "jednoduché" funkce, 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 typem výsledku, vrátíte se k chybové zprávě s řetězcovým typem analýzy:

// 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 vynutí, abyste správně vyřešili typ výjimky v lokalitě volání, nikoli ve funkci. Tím se efektivně vytvoří kontrolované výjimky, které jsou notoricky unfun, aby se s ním mohli vypořádat jako s volajícím rozhraní API.

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 zachytávání, bude tato funkce nyní správně zpracovávat případ, když nebyl nalezen soubor a přiřadí tento význam návratu. Tato návratová hodnota se může mapovat na tento případ chyby, ale nezahodí žádné kontextové informace nebo vynutí volající, aby se zabývali případem, který nemusí být v daném okamžiku v kódu relevantní.

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é programování a programování bez bodu

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 bez point-free programování není předností a sám o sobě, a může přidat významnou kognitivní bariéru pro lidi, kteří nejsou ponořili do stylu.

Nepoužívejte částečnou aplikaci a currying 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 uložení několika řádků kódu na výměnu za poměrně velkou kognitivní režii, zejména v kombinaci s operátory, jako >> je vytváření funkcí.

Zvažte důsledky pro programové programování bez bodu

Curried funkce neoznačí jejich argumenty. To má vliv na nástroje. 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)

Popisy v nástrojích, jako je například Visual Studio, vám na webu volání poskytnou podpis typu, ale vzhledem k tomu, že nejsou definovány žádné názvy, nezobrazí se názvy. 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 kód bez bodu, jako funcWithApplication je veřejně použitelný, 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í můžou vyvíjet tak, aby syntetizovaly tyto hodnoty na základě dříve provedených cest, ale není vhodné zabezpečit vaše sázky na potenciální funkce ladění.

Zvažte částečnou aplikaci jako techniku pro snížení interního standardu.

Na rozdíl od předchozího bodu je částečná aplikace nádherným nástrojem pro snížení často používané desky uvnitř aplikace nebo hlubších vnitřních vlastností rozhraní API. Může být užitečné při testování jednotek implementaci složitějších rozhraní API, kde často dochází k bolesti. Následující kód například ukazuje, jak můžete dosáhnout toho, co vám nejvíce napodobování architektur dává, aniž byste na takové rozhraní museli brát externí závislost a naučit se související rozhraní 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
        ...

Testování Transactions.doTransactionImplementationLogic.Tests.fsproj jednotek 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í pro celý základ kódu, ale je to dobrý způsob, jak snížit často používané interní funkce a testování jednotek.

Ří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, co spotřebitelé párují.
  • Snažte se zachovat všechny pomocné funkce private.
  • Zvažte použití [<AutoOpen>] soukromého modulu pomocných funkcí, pokud se stanou řadou.

Odvození typů a obecné typy

Odvozování typu vám může ušetřit od psaní velkého množství často používaných typů. 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 může provádět jemné úlohy při odvozování typů za vás, je možné, aby se tvar rozhraní API změnil, pokud interní závislosti na změně typů závisí. 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 poškození.

  • 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é zobecnění není vždy logická hodnota pro lidi, kteří s jazykem F# nebo velkým základem kódu začíná. Při používání obecných komponent dochází k 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 mít ve skutečnosti prospěch z obecné.

Výkon

Zvažte struktury malých typů s vysokými mírami přidělení.

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 pravděpodobně máte mnoho instancí těchto typů, které jsou v paměti v běžícím programu.

Pokud se použije první podmínka, měli byste obecně použít strukturu. Pokud platí obojí, měli byste téměř vždy použít strukturu. 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 ale důležité vždy měřit a nepracujte na předpokladu nebo inicii.

Zvažte strukturování řazených kolekcí členů při seskupování malých typů hodnot s vysokými mírami přidělení.

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ěť.

Tyto výsledky ale nebudou vždy případem 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 záznamy struktury, pokud je typ malý a má vysokou míru přidělení.

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 řazené kolekce členů, ale tentokrát příklad používá záznamy a vloženou 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 řazenými kolekcemi členů a záznamy také uchovává pro F# Diskriminované sjednocení. 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ů se oba provádějí 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 struktura Diskriminovaná unie přinesla lepší výkon, je běžné, že při modelování domény mají větší diskriminované sjednocení. Větší datové typy, jako je to, nemusí fungovat stejně, pokud jsou struktury v závislosti na operacích na nich, protože může být zapojeno další 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 je však možné zajistit optimální (nebo dokonce rozumnou) efektivitu doby provádění nebo přidělení paměti, je možné rozsah práce nejlépe implementovat pomocí místní mutace stavu. To je možné v rámci výslovného souhlasu s jazykem F# s klíčovým slovem mutable .

mutable Použití v jazyce F# se může cítit 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ření mutaci tak, aby 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é, je možné ji využít k zachování rozsahu mutable proměnné blízko nebo v nejmenším rozsahu.

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.

Zabalení proměnlivého kódu v neměnných rozhraních

S referenční transparentností jako cílem je důležité napsat kód, který nezpřístupňuje proměnlivé podzvěsti kritických funkcí výkonu. 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 jednu funkci k zapouzdření operací pomocí 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 zveřejňuje datovou strukturu založenou na mutaci, kterou volající zodpovídají za údržbu. To lze zabalit uvnitř třídy bez podkladových členů, které mohou změ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 mutační sadě, čímž nevynucuje volajícím udržovat 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 mutableref

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 všechny ostatní kódy acc , které se dotkne, provést způsobem, který se nijak neliší od použití normální neměnné hodnoty vázané na hranice let. To vám usnadní změnu v průběhu času.

Hodnoty Null a výchozí hodnoty

Hodnoty Null by se obecně měly v jazyce F# vyhnout. 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 . Pokud to chcete povolit, můžete ručně anotovat typy AllowNullLiteral jazyka F#. 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 konstruktoru by se však mělo 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 nebo inicializovanou nulou null . Tento konstruktor je zřídka nutný a jeho použití by se mělo vyhnout.

Pokud zkontrolujete vstupy s hodnotou null, vyvolání výjimek při první příležitosti

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. Nepřebíjejte to jako pokyny a nepřebírají vzory kódování, které přiřazují jakýkoli sémantický význam "null".

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 Item vlastnosti
  • Zápis řezů (arr[x..y], arr[x..], arr[..y]), definováním GetSlice členů
  • 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 u typů
  • Automatické vlastnosti
  • Implementace IDisposable a IEnumerable
  • Rozšíření typů
  • Událost
  • 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 dlouhodobý idiom, který dobrý kód F# může dodržovat. Základním principem je, že byste neměli vystavit základní třídu a vynutit volající dědit z této základní třídy získat funkce.

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 testování jednotek, když chcete zocelit rozhraní s testovacími rutinami improvizovaný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.
  • Velikost písmen BufferSize (PascalCase) znamená, že tento typ obsahuje více dat.
  • 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 konstruktor kompilace.
module Networking =
    ...
    let send data (bufferSize: int) = ...

Stručně řečeno, úskalí zkratek typu je, že nejsou abstrakce nad typy, které jsou zkráceny. V předchozím příkladu BufferSize je to jen int pod kryty, bez dalších dat, ani výhody systému typů kromě toho, co int už má.

Alternativním přístupem k používání zkratek typů k reprezentaci domény je použití diskriminovaných sjednocení s jedním případem. 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 z hlediska BufferSize jeho podkladové hodnoty, musíte místo předání libovolného celého čísla vytvořit jeden kód:

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.