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>/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
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:
- Wypychanie dowolnego stanu zależnego poza samym interfejsem API.
- Konfigurację można teraz wykonać poza interfejsem API.
- Błędy inicjowania wartości zależnych prawdopodobnie nie będą manifestowane jako
TypeInitializationException
. - 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:
- Łatwiej jest zachować zmiany domeny w miarę upływu czasu.
- 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
, invalidArg
i invalidOp
jako mechanizmu, aby zgłosić ArgumentNullException
, ArgumentException
i 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. let
Zazwyczaj 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 inne
public
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
private
pomocnicze . - 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 mutable
ref
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 let
wartoś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 zdefiniowanieItem
właściwości - Notacja fragmentowania (
arr[x..y]
,arr[x..]
,arr[..y]
), definiującGetSlice
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
iIEnumerable
- 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ż tylkoint
. Ogólnie rzecz biorąc, typy domen mają wiele atrybutów do nich i nie są typami pierwotnymi, takimi jakint
. 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 nie są abstrakcjami 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.