C# — konwencje kodowania

Poniższe konwencje są sformułowane z doświadczenia w pracy z dużymi bazami kodu języka F#. Pięć zasad dobrego kodu języka F# jest podstawą każdego zalecenia. Są one powiązane z wytycznymi projektowania składników języka F#, ale mają zastosowanie do dowolnego kodu języka F#, a nie tylko składników, takich jak biblioteki.

Organizowanie kodu

Język F# oferuje dwa podstawowe sposoby organizowania kodu: moduły i przestrzenie nazw. Są one podobne, ale mają następujące różnice:

  • Przestrzenie nazw są kompilowane jako przestrzenie nazw platformy .NET. Moduły są kompilowane jako klasy statyczne.
  • Przestrzenie nazw są zawsze najwyższym poziomem. Moduły mogą być najwyższego poziomu i zagnieżdżone w innych modułach.
  • Przestrzenie nazw mogą obejmować wiele plików. Moduły nie mogą.
  • Moduły można dekorować za pomocą [<RequireQualifiedAccess>] elementów i [<AutoOpen>].

Poniższe wskazówki ułatwią organizowanie kodu.

Preferuj przestrzenie nazw na najwyższym poziomie

W przypadku każdego publicznie eksploatacyjnego kodu przestrzenie nazw są preferencyjne dla modułów na najwyższym poziomie. Ponieważ są one kompilowane jako przestrzenie nazw platformy .NET, są one eksploatacyjne z języka C# bez uciekania się do using static.

// Recommended.
namespace MyCode

type MyClass() =
    ...

Użycie modułu najwyższego poziomu może nie pojawiać się inaczej, gdy jest wywoływany tylko z języka F#, ale w przypadku użytkowników języka C# osoby wywołujące mogą być zaskoczeni koniecznością zakwalifikowania MyClass się do modułu MyCode , gdy nie są świadomi określonej using static konstrukcji języka C#.

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

type MyClass() =
    ...

Dokładnie zastosuj [<AutoOpen>]

Konstrukcja [<AutoOpen>] może zanieczyszczać zakres tego, co jest dostępne dla rozmówców, a odpowiedź na to, skąd coś pochodzi, to "magia". To nie jest dobra rzecz. Wyjątkiem od tej reguły jest sama biblioteka podstawowa języka F# (choć ten fakt jest również nieco kontrowersyjny).

Jednak jest to wygoda, jeśli masz funkcje pomocnicze dla publicznego interfejsu API, który chcesz zorganizować oddzielnie od tego publicznego interfejsu API.

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

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

        helper1 x y z

Dzięki temu można całkowicie oddzielić szczegóły implementacji od publicznego interfejsu API funkcji bez konieczności pełnego kwalifikowania pomocnika za każdym razem, gdy go wywołasz.

Ponadto uwidacznianie metod rozszerzeń i konstruktorów wyrażeń na poziomie przestrzeni nazw może być starannie wyrażone za pomocą [<AutoOpen>]polecenia .

Używaj [<RequireQualifiedAccess>] zawsze, gdy nazwy mogą powodować konflikt lub czujesz, że pomaga to w czytelności

Dodanie atrybutu do modułu [<RequireQualifiedAccess>] wskazuje, że moduł może nie być otwarty i że odwołania do elementów modułu wymagają jawnego kwalifikowanego dostępu. Na przykład Microsoft.FSharp.Collections.List moduł ma ten atrybut.

Jest to przydatne, gdy funkcje i wartości w module mają nazwy, które mogą powodować konflikt z nazwami w innych modułach. Wymaganie dostępu kwalifikowanego może znacznie zwiększyć długoterminową konserwację i możliwość rozwoju biblioteki.

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

...

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

Sortowanie open instrukcji topologicznych

W języku F# kolejność deklaracji ma znaczenie, w tym z oświadczeniami open (i open type, po prostu nazywana dalej open ). Jest to w przeciwieństwie do języka C#, gdzie efekt using i using static jest niezależny od kolejności tych instrukcji w pliku.

W języku F# elementy otwarte w zakresie mogą wyciemnić inne już obecne. Oznacza to, że zmiana kolejności open instrukcji może zmienić znaczenie kodu. W związku z tym dowolne sortowanie wszystkich open instrukcji (na przykład alfanumerycznie) nie jest zalecane, aby wygenerować inne zachowanie, którego można oczekiwać.

Zamiast tego zalecamy sortowanie ich topologicznie, czyli kolejność open instrukcji w kolejności, w jakiej są zdefiniowane warstwy systemu. Można również rozważyć sortowanie alfanumeryczne w różnych warstwach topologicznych.

Oto przykład sortowania topologicznego dla pliku publicznego interfejsu API usługi kompilatora języka 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

Podział linii oddziela warstwy topologiczne, a każda warstwa jest sortowana alfanumerycznie później. Dzięki temu kod jest uporządkowany bez przypadkowego cieniowania wartości.

Używanie klas do zawierania wartości, które mają skutki uboczne

Istnieje wiele przypadków, gdy inicjowanie wartości może mieć skutki uboczne, takie jak utworzenie wystąpienia kontekstu do bazy danych lub innego zasobu zdalnego. Kuszące jest zainicjowanie takich rzeczy w module i użycie go w kolejnych funkcjach:

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

Jest to często problematyczne z kilku powodów:

Najpierw konfiguracja aplikacji jest wypychana do bazy kodu za pomocą polecenia dep1 i dep2. Jest to trudne do utrzymania w większych bazach kodu.

Po drugie statycznie zainicjowane dane nie powinny zawierać wartości, które nie są bezpieczne wątkowo, jeśli sam składnik będzie używać wielu wątków. Jest to wyraźnie naruszone przez dep3.

Na koniec inicjowanie modułu kompiluje się w konstruktor statyczny dla całej jednostki kompilacji. Jeśli w tym module wystąpi jakikolwiek błąd podczas inicjowania wartości let-bound, manifestuje się jako element, który TypeInitializationException jest następnie buforowany przez cały okres istnienia aplikacji. Może to być trudne do zdiagnozowania. Zazwyczaj istnieje wyjątek wewnętrzny, którego można podjąć próbę z powodu, ale jeśli nie ma, nie ma mowy o tym, co jest główną przyczyną.

Zamiast tego wystarczy użyć prostej klasy do przechowywania zależności:

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

Umożliwia to wykonanie następujących czynności:

  1. Wypychanie dowolnego stanu zależnego poza samym interfejsem API.
  2. Konfigurację można teraz wykonać poza interfejsem API.
  3. Błędy inicjowania wartości zależnych prawdopodobnie nie będą manifestowane jako TypeInitializationException.
  4. Interfejs API jest teraz łatwiejszy do przetestowania.

Zarządzanie błędami

Zarządzanie błędami w dużych systemach jest złożonym i zniuansowanym przedsięwzięciem i nie ma srebrnych punktorów w celu zapewnienia, że systemy są odporne na uszkodzenia i działają dobrze. Poniższe wskazówki powinny zawierać wskazówki dotyczące poruszania się po tej trudnej przestrzeni.

Reprezentowanie przypadków błędów i niedozwolonego stanu w typach wewnętrznych domeny

W przypadku związków dyskryminowanych język F# daje możliwość reprezentowania stanu wadliwego programu w systemie typów. Na przykład:

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

W tym przypadku istnieją trzy znane sposoby, które wypłaty pieniędzy z konta bankowego mogą zakończyć się niepowodzeniem. Każdy przypadek błędu jest reprezentowany w typie i w związku z tym może być traktowany bezpiecznie w całym programie.

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"

Ogólnie rzecz biorąc, jeśli możesz modelować różne sposoby, które coś może zakończyć się niepowodzeniem w domenie, kod obsługi błędów nie jest już traktowany jako coś, z czym musisz poradzić sobie oprócz zwykłego przepływu programu. Jest to po prostu część normalnego przepływu programu i nie jest uważana za wyjątkową. Istnieją dwie podstawowe korzyści z tego:

  1. Łatwiej jest zachować zmiany domeny w miarę upływu czasu.
  2. Przypadki błędów są łatwiejsze do testowania jednostkowego.

Używaj wyjątków, gdy błędy nie mogą być reprezentowane z typami

Nie wszystkie błędy mogą być reprezentowane w domenie problemu. Tego rodzaju błędy są wyjątkowe w naturze, dlatego możliwość zgłaszania i przechwytywania wyjątków w języku F#.

Najpierw zaleca się przeczytanie wytycznych dotyczących projektowania wyjątków. Dotyczy to również języka F#.

Główne konstrukcje dostępne w języku F# do celów wywoływania wyjątków powinny być brane pod uwagę w następującej kolejności preferencji:

Function Składnia Purpose
nullArg nullArg "argumentName" Wywołuje wartość System.ArgumentNullException z określoną nazwą argumentu.
invalidArg invalidArg "argumentName" "message" Wywołuje wartość System.ArgumentException z określoną nazwą argumentu i komunikatem.
invalidOp invalidOp "message" Wywołuje element System.InvalidOperationException z określonym komunikatem.
raise raise (ExceptionType("message")) Mechanizm ogólnego przeznaczenia do zgłaszania wyjątków.
failwith failwith "message" Wywołuje element System.Exception z określonym komunikatem.
failwithf failwithf "format string" argForFormatString System.Exception Wywołuje komunikat z komunikatem określonym przez ciąg formatu i jego dane wejściowe.

Użyj nullArg, invalidArgi invalidOp jako mechanizmu, aby zgłosić ArgumentNullException, ArgumentExceptioni InvalidOperationException w razie potrzeby.

Funkcje failwith i failwithf powinny być zwykle unikane, ponieważ zgłaszają typ podstawowy Exception , a nie określony wyjątek. Zgodnie z wytycznymi dotyczącymi projektowania wyjątków chcesz zgłaszać bardziej szczegółowe wyjątki, gdy możesz.

Używanie składni obsługi wyjątków

Język F# obsługuje wzorce wyjątków za pomocą try...with składni:

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

Uzgadnianie funkcji do wykonania w obliczu wyjątku z dopasowywaniem wzorców może być nieco trudne, jeśli chcesz zachować czysty kod. Jednym z takich sposobów obsługi jest użycie aktywnych wzorców jako środka do grupowania funkcjonalności otaczającej przypadek błędu z samym wyjątkiem. Na przykład możesz użyć interfejsu API, który podczas zgłaszania wyjątku zawiera cenne informacje w metadanych wyjątku. Usunięcie przydatnej wartości w treści przechwyconego wyjątku wewnątrz aktywnego wzorca i zwrócenie tej wartości może być przydatne w niektórych sytuacjach.

Nie używaj obsługi błędów monadic do zastępowania wyjątków

Wyjątki są często postrzegane jako tabu w czystym modelu funkcjonalnym. Rzeczywiście, wyjątki naruszają czystość, więc można bezpiecznie rozważyć je nie całkiem funkcjonalnie czyste. Jednak ignoruje to rzeczywistość, w której należy uruchomić kod, i że mogą wystąpić błędy środowiska uruchomieniowego. Ogólnie rzecz biorąc, napisz kod przy założeniu, że większość rzeczy nie jest czysta lub całkowita, aby zminimalizować nieprzyjemne niespodzianki (podobnie jak catch puste w języku C# lub niewłaściwego zarządzania śladem stosu, odrzucając informacje).

Ważne jest, aby wziąć pod uwagę następujące podstawowe mocne strony/aspekty wyjątków w odniesieniu do ich istotności i odpowiedniości w środowisku uruchomieniowym platformy .NET i ekosystemie między językami jako całości:

  • Zawierają szczegółowe informacje diagnostyczne, które są przydatne podczas debugowania problemu.
  • Są one dobrze zrozumiałe dla środowiska uruchomieniowego i innych języków platformy .NET.
  • Mogą one zmniejszyć znaczną cykliczność w porównaniu z kodem, który wykracza poza jego sposób, aby uniknąć wyjątków, implementując niektóre podzbiór ich semantyki na zasadzie ad hoc.

Ten trzeci punkt ma kluczowe znaczenie. W przypadku nietriwialnych złożonych operacji nieuprawnienie wyjątków może spowodować radzenie sobie ze strukturami w następujący sposób:

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

Co może łatwo prowadzić do kruchego kodu, takiego jak dopasowywanie wzorca w przypadku błędów "wpisanych ciągowo":

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?

Ponadto może być kuszące połykanie dowolnego wyjątku w pragnieniu "prostej" funkcji zwracającej typ "nicer":

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

Niestety, tryReadAllText może zgłaszać liczne wyjątki w oparciu o niezliczone rzeczy, które mogą wystąpić w systemie plików, a ten kod odrzuca wszelkie informacje o tym, co może rzeczywiście być błędne w danym środowisku. Jeśli zastąpisz ten kod typem wyniku, wróć do "ciągowo wpisanego" komunikatu o błędzie podczas analizowania:

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

Umieszczenie samego obiektu wyjątku w konstruktorze Error wymusza prawidłowe radzenie sobie z typem wyjątku w lokacji wywołania, a nie w funkcji. W ten sposób można skutecznie utworzyć sprawdzone wyjątki, które są notorycznie niefunkcjonalne do obsługi jako obiekt wywołujący interfejs API.

Dobrą alternatywą dla powyższych przykładów jest przechwycenie określonych wyjątków i zwrócenie znaczącej wartości w kontekście tego wyjątku. Jeśli zmodyfikujesz tryReadAllText funkcję w następujący sposób, None ma to większe znaczenie:

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

Zamiast działać jako catch-all, ta funkcja będzie teraz prawidłowo obsługiwać przypadek, gdy nie znaleziono pliku i przypisać to znaczenie do powrotu. Ta wartość zwracana może być mapowa na ten przypadek błędu, nie odrzucając żadnych informacji kontekstowych ani zmuszając wywołujących do radzenia sobie z przypadkiem, który może nie być istotny w tym momencie w kodzie.

Typy, takie jak Result<'Success, 'Error> , są odpowiednie dla operacji podstawowych, w których nie są zagnieżdżone, a opcjonalne typy języka F# są idealne do reprezentowania, gdy coś może zwrócić coś lub nic. Nie są one jednak zamiennikiem wyjątków i nie powinny być używane w celu zastąpienia wyjątków. Zamiast tego należy je stosować w sposób rozsądny, aby rozwiązać określone aspekty zasad zarządzania wyjątkami i błędami w sposób ukierunkowany.

Częściowe stosowanie i programowanie bez punktów

Język F# obsługuje częściową aplikację, a tym samym różne sposoby programowania w stylu bez punktu. Może to być korzystne w przypadku ponownego użycia kodu w module lub implementacji czegoś, ale nie jest to coś, co można uwidocznić publicznie. Ogólnie rzecz biorąc, programowanie bez punktów nie jest cnotą w sobie i może dodać znaczącą barierę poznawczą dla osób, które nie są zanurzone w stylu.

Nie używaj częściowej aplikacji i currying w publicznych interfejsach API

Z niewielkim wyjątkiem użycie częściowej aplikacji w publicznych interfejsach API może być mylące dla konsumentów. letZazwyczaj wartości -bound w kodzie języka F# to wartości, a nie wartości funkcji. Łączenie wartości i wartości funkcji może spowodować zapisanie kilku wierszy kodu w zamian za sporo nakładu pracy poznawczej, zwłaszcza jeśli w połączeniu z operatorami, takimi jak >> tworzenie funkcji.

Rozważ implikacje narzędzi dla programowania bez punktów

Funkcje curried nie oznaczają ich argumentów. Ma to wpływ na narzędzia. Rozważ następujące dwie funkcje:

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

Oba są prawidłowymi funkcjami, ale funcWithApplication jest funkcją curried. Po umieszczeniu wskaźnika myszy na ich typach w edytorze zobaczysz następujące elementy:

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

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

W witrynie wywołania etykietki narzędzi, takie jak program Visual Studio, dają podpis typu, ale ponieważ nie zdefiniowano nazw, nie będzie wyświetlać nazw. Nazwy mają kluczowe znaczenie dla dobrego projektu interfejsu API, ponieważ pomagają osobom wywołującym lepiej zrozumieć znaczenie interfejsu API. Korzystanie z kodu bez punktu w publicznym interfejsie API może utrudnić osobom wywołującym zrozumienie.

Jeśli napotkasz kod wolny od punktu, taki jak funcWithApplication ten, który jest publicznie używany, zaleca się wykonanie pełnej η rozszerzania, aby narzędzia mogły odbierać znaczące nazwy argumentów.

Ponadto debugowanie kodu bez punktu może być trudne, jeśli nie jest niemożliwe. Narzędzia do debugowania opierają się na wartościach powiązanych z nazwami (na przykład let powiązaniami), dzięki czemu można sprawdzić wartości pośrednie w połowie wykonywania. Jeśli kod nie ma żadnych wartości do sprawdzenia, nie ma nic do debugowania. W przyszłości narzędzia debugowania mogą ewoluować w celu zsyntetyzowania tych wartości na podstawie wcześniej wykonanych ścieżek, ale nie jest dobrym pomysłem, aby zabezpieczyć zakłady na potencjalne funkcje debugowania.

Rozważ częściowe zastosowanie jako technikę, aby zmniejszyć wewnętrzną płytę kotłową

W przeciwieństwie do poprzedniego punktu, częściowa aplikacja jest wspaniałym narzędziem do redukcji standardowych elementów wewnątrz aplikacji lub głębszych wewnętrznych elementów interfejsu API. Może to być pomocne w przypadku testowania jednostkowego implementacji bardziej skomplikowanych interfejsów API, w przypadku których standardowy układ często jest trudny do rozwiązania. Na przykład poniższy kod pokazuje, jak można osiągnąć, co większość pozornych struktur daje bez podejmowania zależności zewnętrznej od takiej platformy i konieczności uczenia się powiązanego interfejsu API na zamówienie.

Rozważmy na przykład następującą topografię rozwiązania:

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

ImplementationLogic.fsproj może uwidaczniać kod, taki jak:

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

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

Testowanie jednostkowe Transactions.doTransaction w programie ImplementationLogic.Tests.fsproj jest łatwe:

namespace TransactionsTestingUtil

open Transactions

module TransactionsTestable =
    let getTestableTransactionRoutine mockContext = Transactions.doTransaction mockContext

Częściowe doTransaction zastosowanie za pomocą pozorowanego obiektu kontekstu umożliwia wywołanie funkcji we wszystkich testach jednostkowych bez konieczności konstruowania pozorowanego kontekstu za każdym razem:

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)

Nie stosuj tej techniki w sposób uniwersalny do całej bazy kodu, ale jest to dobry sposób na zmniejszenie standardu dla skomplikowanych wewnętrznych i testów jednostkowych tych wewnętrznych.

Kontrola dostępu

Język F# ma wiele opcji kontroli dostępu odziedziczonych po tym, co jest dostępne w środowisku uruchomieniowym platformy .NET. Nie są one po prostu użyteczne dla typów — można ich używać również do funkcji.

Dobre rozwiązania w kontekście bibliotek, które są powszechnie używane:

  • Preferuj typy innepublic niż i elementy członkowskie, dopóki nie będą one publicznie używane. Minimalizuje to również to, co konsumenci para do.
  • Staraj się zachować wszystkie funkcje privatepomocnicze .
  • Rozważ użycie funkcji [<AutoOpen>] pomocnika w prywatnym module, jeśli staną się liczne.

Wnioskowanie typów i typy ogólne

Wnioskowanie typu pozwala zaoszczędzić na wpisywaniu dużej ilości kotłów. Automatyczna uogólnienie w kompilatorze języka F# może pomóc w pisaniu bardziej ogólnego kodu bez dodatkowego nakładu pracy. Jednak te funkcje nie są powszechnie dobre.

  • Rozważ etykietowanie nazw argumentów z jawnymi typami w publicznych interfejsach API i nie polegaj na wnioskowaniu typów w tym celu.

    Przyczyną tego jest to, że należy kontrolować kształt interfejsu API, a nie kompilatora. Mimo że kompilator może wykonać odpowiednie zadanie podczas wnioskowania typów, istnieje możliwość zmiany kształtu interfejsu API, jeśli elementy wewnętrzne, na których polega, zmieniły się typy. Może to być to, czego potrzebujesz, ale prawie na pewno spowoduje to niezgodną zmianę interfejsu API, z którą będą musieli poradzić sobie odbiorcy podrzędni. Zamiast tego, jeśli jawnie kontrolujesz kształt publicznego interfejsu API, możesz kontrolować te zmiany powodujące niezgodność. W kategoriach DDD można to traktować jako warstwę antykorupcyjną.

  • Rozważ nadanie znaczącej nazwy argumentom ogólnym.

    Jeśli nie piszesz naprawdę ogólnego kodu, który nie jest specyficzny dla określonej domeny, znacząca nazwa może pomóc innym programistom zrozumieć domenę, w której pracują. Na przykład parametr typu o nazwie 'Document w kontekście interakcji z bazą danych dokumentów sprawia, że typy dokumentów ogólnych mogą być akceptowane przez funkcję lub element członkowski, z którym pracujesz.

  • Rozważ nazewnictwo parametrów typu ogólnego za pomocą metody PascalCase.

    Jest to ogólny sposób wykonywania czynności na platformie .NET, dlatego zaleca się użycie PascalCase, a nie snake_case lub camelCase.

Na koniec automatyczna uogólnianie nie zawsze jest wartością logiczną dla osób, które są nowe w języku F# lub dużej bazie kodu. Istnieje obciążenie poznawcze związane z używaniem składników ogólnych. Ponadto jeśli funkcje uogólnione automatycznie nie są używane z różnymi typami danych wejściowych (nie mówiąc już o tym, czy mają być używane jako takie), nie ma żadnych rzeczywistych korzyści, aby były wtedy ogólne. Zawsze należy wziąć pod uwagę, czy kod, który piszesz, rzeczywiście skorzysta z bycia ogólnym.

Wydajność

Rozważ struktury dla małych typów o wysokich stawkach alokacji

Użycie struktur (nazywanych również typami wartości) często może spowodować wyższą wydajność dla kodu, ponieważ zwykle unika przydzielania obiektów. Jednak struktury nie zawsze są przyciskiem "przejdź szybciej": jeśli rozmiar danych w strukturę przekracza 16 bajtów, kopiowanie danych może często spowodować większe wydatki procesora CPU niż użycie typu odwołania.

Aby określić, czy należy użyć struktury, rozważ następujące warunki:

  • Jeśli rozmiar danych wynosi 16 bajtów lub mniejszy.
  • Jeśli prawdopodobnie masz wiele wystąpień tego typu rezydentów w pamięci w uruchomionym programie.

Jeśli pierwszy warunek ma zastosowanie, zazwyczaj należy użyć struktury. Jeśli oba te elementy mają zastosowanie, prawie zawsze należy użyć struktury. Mogą wystąpić pewne przypadki, w których mają zastosowanie poprzednie warunki, ale użycie struktury nie jest lepsze lub gorsze niż użycie typu odwołania, ale mogą być rzadkie. Ważne jest, aby zawsze mierzyć się podczas wprowadzania zmian w ten sposób, a nie operować na założeniu lub intuicji.

Rozważ krotki struktury podczas grupowania małych typów wartości z wysokimi współczynnikami alokacji

Rozważ następujące dwie funkcje:

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)

Podczas testów porównawczych tych funkcji za pomocą statystycznego narzędzia do porównywania porównawczego, takiego jak BenchmarkDotNet, przekonasz się, że runWithStructTuple funkcja korzystająca z krotek struktury działa 40% szybciej i nie przydziela żadnej pamięci.

Jednak te wyniki nie zawsze będą takie same w twoim kodzie. Jeśli oznaczysz funkcję jako inline, kod, który używa krotki odwołań, może uzyskać dodatkowe optymalizacje lub kod, który przydzieliłby, można po prostu zoptymalizować. Zawsze należy mierzyć wyniki za każdym razem, gdy wydajność jest zaniepokojona, i nigdy nie działa na podstawie założenia lub intuicji.

Rozważ rekordy struktury, gdy typ jest mały i ma wysokie współczynniki alokacji

Reguła kciuka opisana wcześniej jest również przechowywana dla typów rekordów języka F#. Rozważ następujące typy danych i funkcje, które je przetwarzają:

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)

Jest to podobne do poprzedniego kodu krotki, ale tym razem w przykładzie użyto rekordów i wbudowanej funkcji wewnętrznej.

Podczas testów porównawczych tych funkcji za pomocą statystycznego narzędzia do porównywania porównawczego, takiego jak BenchmarkDotNet, przekonasz się, że processStructPoint działa prawie 60% szybciej i nie przydziela nic na zarządzanej stercie.

Rozważ struktury dyskryminowane związki, gdy typ danych jest mały z wysokimi współczynnikami alokacji

Poprzednie obserwacje dotyczące wydajności z krotkami struktury i rekordami są również przechowywane dla unii dyskryminowanych języka F#. Spójrzmy na poniższy kod:

    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

Często definiowanie związków dyskryminowanych z pojedynczymi przypadkami jest takie jak w przypadku modelowania domeny. Podczas testów porównawczych tych funkcji za pomocą statystycznego narzędzia do porównywania porównawczego, takiego jak BenchmarkDotNet, przekonasz się, że structReverseName działa około 25% szybciej niż reverseName w przypadku małych ciągów. W przypadku dużych ciągów oba działają mniej więcej tak samo. Dlatego w tym przypadku zawsze zaleca się użycie struktury. Jak wspomniano wcześniej, zawsze mierz i nie działa na założeniach ani intuicji.

Mimo że w poprzednim przykładzie pokazano, że struktura Dyskryminowana Unia przyniosła lepszą wydajność, często zdarza się, że podczas modelowania domeny występują większe związki dyskryminujące. Większe typy danych, takie jak te, mogą nie działać tak dobrze, jeśli są strukturami w zależności od operacji na nich, ponieważ może być zaangażowanych więcej kopii.

Niezmienność i mutacja

Wartości języka F# są domyślnie niezmienne, co pozwala uniknąć niektórych klas usterek (zwłaszcza tych obejmujących współbieżność i równoległość). Jednak w niektórych przypadkach, aby osiągnąć optymalną (a nawet rozsądną) wydajność czasu wykonywania lub alokacji pamięci, zakres pracy może być najlepiej zaimplementowany przy użyciu mutacji stanu w miejscu. Jest to możliwe w zasadzie zgody w języku F# ze mutable słowem kluczowym .

Użycie w mutable języku F# może być sprzeczne z czystością funkcjonalną. Jest to zrozumiałe, ale funkcjonalna czystość wszędzie może być sprzeczna z celami wydajności. Kompromisem jest hermetyzacja mutacji, tak aby wywołujący nie dbali o to, co się dzieje, gdy nazywają funkcję. Dzięki temu można napisać interfejs funkcjonalny w implementacji opartej na mutacji dla kodu o znaczeniu krytycznym dla wydajności.

Ponadto konstrukcje powiązań języka F# let umożliwiają zagnieżdżanie powiązań do innego, dzięki czemu można zachować zakres zmiennej mutable blisko lub najdrobniejszych.

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

Żaden kod nie może uzyskać dostępu do modyfikowalnego completed elementu, który został użyty tylko do zainicjowania data wartości let bound.

Zawijanie modyfikowalnego kodu w niezmiennych interfejsach

W przypadku przezroczystości odniesienia jako celu kluczowe jest napisanie kodu, który nie uwidacznia modyfikowalnego podbrzuszenia funkcji krytycznych dla wydajności. Na przykład poniższy kod implementuje funkcję w podstawowej bibliotece Array.contains języka 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

Wywołanie tej funkcji wiele razy nie zmienia tablicy bazowej ani nie wymaga utrzymania jakiegokolwiek modyfikowalnego stanu w jego użyciu. Jest on referenticznie przezroczysty, mimo że prawie każdy wiersz kodu w nim używa mutacji.

Rozważ hermetyzowanie danych modyfikowalnych w klasach

W poprzednim przykładzie użyto pojedynczej funkcji do hermetyzacji operacji przy użyciu danych modyfikowalnych. Nie zawsze jest to wystarczające w przypadku bardziej złożonych zestawów danych. Rozważmy następujące zestawy funkcji:

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

Ten kod jest wydajny, ale uwidacznia strukturę danych opartą na mutacji, która wywołuje osoby odpowiedzialne za utrzymanie. Można to opakować wewnątrz klasy bez składowych bazowych, które mogą ulec zmianie:

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 hermetyzuje podstawową strukturę danych opartą na mutacji, a tym samym nie zmusza wywołujących do utrzymania podstawowej struktury danych. Klasy to zaawansowany sposób hermetyzacji danych i procedur opartych na mutacjach bez ujawniania szczegółów obiektom wywołującym.

Preferuj let mutableref

Komórki odwołania to sposób reprezentowania odwołania do wartości, a nie samej wartości. Chociaż można ich używać w kodzie krytycznym dla wydajności, nie są one zalecane. Rozważmy następujący przykład:

let kernels =
    let acc = ref Set.empty

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

    !acc |> Seq.toList

Użycie komórki referencyjnej teraz "zanieczyszcza" cały kolejny kod z koniecznością wyłuskania i ponownego odwołowania się do danych bazowych. Zamiast tego rozważ następujące kwestie 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

Oprócz pojedynczego punktu mutacji w środku wyrażenia lambda, każdy inny kod, który dotyka acc , może to zrobić w sposób, który nie różni się od użycia normalnej letwartości niezmiennej . Ułatwi to zmianę w czasie.

Wartości null i wartości domyślne

Wartości null należy zwykle unikać w języku F#. Domyślnie typy zadeklarowane w języku F#nie obsługują użycia null literału, a wszystkie wartości i obiekty są inicjowane. Jednak niektóre typowe interfejsy API platformy .NET zwracają lub akceptują wartości null, a niektóre typowe . Typy zadeklarowane przez platformę NET, takie jak tablice i ciągi, zezwalają na wartości null. Jednak występowanie null wartości jest bardzo rzadkie w programowaniu języka F#, a jedną z zalet korzystania z języka F# jest unikanie błędów odwołania o wartości null w większości przypadków.

Unikaj używania atrybutu AllowNullLiteral

Domyślnie typy zadeklarowane w języku F#nie obsługują użycia null literału. Możesz ręcznie dodawać adnotacje do typów języka F#, AllowNullLiteral aby zezwolić na to. Jednak prawie zawsze lepiej jest tego uniknąć.

Unikaj używania atrybutu Unchecked.defaultof<_>

Istnieje możliwość wygenerowania wartości inicjowanej null lub zerowej dla typu języka F# przy użyciu polecenia Unchecked.defaultof<_>. Może to być przydatne podczas inicjowania magazynu dla niektórych struktur danych lub w niektórych wzorcach kodowania o wysokiej wydajności lub we współdziałaniu. Należy jednak unikać używania tej konstrukcji.

Unikaj używania atrybutu DefaultValue

Domyślnie rekordy i obiekty języka F# muszą być prawidłowo inicjowane na konstrukcji. Atrybut DefaultValue może służyć do wypełniania niektórych pól obiektów wartością null inicjowaną lub zero. Ta konstrukcja jest rzadko potrzebna i należy unikać jej użycia.

Jeśli sprawdzasz dane wejściowe o wartości null, zgłaszaj wyjątki przy pierwszej szansie sprzedaży

Podczas pisania nowego kodu języka F# w praktyce nie ma potrzeby sprawdzania danych wejściowych o wartości null, chyba że oczekujesz, że ten kod będzie używany z języka C# lub innych języków platformy .NET.

Jeśli zdecydujesz się dodać kontrole dla danych wejściowych o wartości null, przeprowadź kontrole przy pierwszej okazji i zgłoś wyjątek. Na przykład:

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

Ze starszych powodów niektóre funkcje ciągów w języku FSharp.Core nadal traktują wartości null jako puste ciągi i nie kończą się niepowodzeniem w argumentach null. Nie należy jednak przyjmować tego jako wskazówek i nie przyjmują wzorców kodowania, które przypisują jakiekolwiek semantyczne znaczenie "null".

Programowanie obiektów

Język F# ma pełną obsługę pojęć związanych z obiektami i obiektami (OO). Chociaż wiele koncepcji OO jest zaawansowanych i przydatnych, nie wszystkie z nich są idealne do użycia. Poniżej wymieniono wskazówki dotyczące kategorii funkcji OO na wysokim poziomie.

Rozważ użycie tych funkcji w wielu sytuacjach:

  • Notacja kropkowa (x.Length)
  • Elementy członkowskie wystąpienia
  • Konstruktory niejawne
  • Statyczne elementy członkowskie
  • Notacja indeksatora (arr[x]) przez zdefiniowanie Item właściwości
  • Notacja fragmentowania (arr[x..y], arr[x..], arr[..y]), definiując GetSlice elementy członkowskie
  • Argumenty nazwane i opcjonalne
  • Interfejsy i implementacje interfejsów

Nie należy najpierw korzystać z tych funkcji, ale należy je rozsądnie zastosować, gdy są wygodne do rozwiązania problemu:

  • Przeciążanie metody
  • Hermetyzowane dane modyfikowalne
  • Operatory na typach
  • Właściwości automatyczne
  • Implementowanie IDisposable i IEnumerable
  • Rozszerzenia typów
  • Zdarzenia
  • Struktury
  • Delegaci
  • Wyliczenia

Ogólnie unikaj tych funkcji, chyba że należy ich używać:

  • Hierarchie typów opartych na dziedziczeniu i dziedziczenie implementacji
  • Wartości null i Unchecked.defaultof<_>

Preferuj kompozycję nad dziedziczeniem

Kompozycja nad dziedziczeniem jest długotrwałym idiomem, do którego może stosować się dobry kod języka F#. Podstawową zasadą jest to, że nie należy uwidaczniać klasy bazowej i wymuszać dziedziczenie z tej klasy bazowej w celu uzyskania funkcjonalności.

Używanie wyrażeń obiektów do implementowania interfejsów, jeśli nie potrzebujesz klasy

Wyrażenia obiektów umożliwiają implementowanie interfejsów na bieżąco, powiązanie zaimplementowanego interfejsu z wartością bez konieczności wykonywania tych czynności wewnątrz klasy. Jest to wygodne, zwłaszcza jeśli trzeba zaimplementować interfejs i nie ma potrzeby pełnej klasy.

Oto na przykład kod, który jest uruchamiany w Ionide , aby podać akcję poprawki kodu, jeśli dodano symbol, dla którego nie masz open instrukcji:

    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
        }

Ponieważ nie ma potrzeby używania klasy podczas interakcji z interfejsem API programu Visual Studio Code, wyrażenia obiektów są idealnym narzędziem. Są one również cenne w przypadku testów jednostkowych, gdy chcesz wyprzeć interfejs z procedurami testowymi w improwizowany sposób.

Rozważ skróty typów, aby skrócić podpisy

Skróty typów to wygodny sposób przypisywania etykiety do innego typu, takiego jak podpis funkcji lub bardziej złożony typ. Na przykład następujący alias przypisuje etykietę do tego, co jest potrzebne do zdefiniowania obliczeń za pomocą cnTK, biblioteki uczenia głębokiego:

open CNTK

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

Nazwa Computation jest wygodnym sposobem oznaczania dowolnej funkcji zgodnej z podpisem, który jest aliasem. Używanie takich skrótów typów jest wygodne i umożliwia bardziej zwięzły kod.

Unikaj używania skrótów typów do reprezentowania domeny

Mimo że skróty typów są wygodne do nadania nazwy podpisom funkcji, mogą być mylące podczas skracania innych typów. Rozważmy ten skrót:

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

Może to być mylące na wiele sposobów:

  • BufferSize nie jest abstrakcją; jest to po prostu inna nazwa liczby całkowitej.
  • Jeśli BufferSize jest uwidoczniony w publicznym interfejsie API, można go łatwo błędnie interpretować, aby oznaczać więcej niż tylko int. Ogólnie rzecz biorąc, typy domen mają wiele atrybutów do nich i nie są typami pierwotnymi, takimi jak int. Ten skrót narusza to założenie.
  • Wielkość liter BufferSize (PascalCase) oznacza, że ten typ przechowuje więcej danych.
  • Ten alias nie zapewnia większej jasności w porównaniu z udostępnieniem nazwanego argumentu funkcji.
  • Skrót nie będzie manifestować w skompilowanym il; jest to tylko liczba całkowita, a alias jest konstrukcją czasu kompilacji.
module Networking =
    ...
    let send data (bufferSize: int) = ...

Podsumowując, pułapka ze skrótami typów polega na tym, że nieabstrakcjami dla typów, które są skracane. W poprzednim przykładzie BufferSize znajduje się tylko pod int osłonami, bez dodatkowych danych ani żadnych korzyści z systemu typów, oprócz tego, co int już ma.

Alternatywną metodą używania skrótów typów do reprezentowania domeny jest użycie związków dyskryminowanych z jedną literą. Poprzedni przykład można modelować w następujący sposób:

type BufferSize = BufferSize of int

Jeśli piszesz kod, który działa pod względem i jego wartości bazowej BufferSize , musisz skonstruować jeden, a nie przekazać dowolnej liczby całkowitej:

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

Zmniejsza to prawdopodobieństwo błędnego przekazania dowolnej liczby całkowitej do send funkcji, ponieważ obiekt wywołujący musi skonstruować BufferSize typ, aby opakować wartość przed wywołaniem funkcji.