Соглашения о написании кода на F#

Следующие соглашения формулируются на основе опыта работы с большими базами кода F#. Пять принципов хорошего кода F# являются основой каждой рекомендации. Они связаны с рекомендациями по проектированию компонентов F#, но применимы к любому коду F#, а не только к компонентам, таким как библиотеки.

Упорядочение кода

F# имеет два основных способа упорядочивания кода: модулей и пространств имен. Они похожи, но имеют следующие отличия:

  • Пространства имен компилируются как пространства имен .NET. Модули компилируются как статические классы.
  • Пространства имен всегда являются верхним уровнем. Модули могут быть вложены в верхний уровень и вложены в другие модули.
  • Пространства имен могут охватывать несколько файлов. Модули не могут.
  • Модули можно декорировать и [<RequireQualifiedAccess>][<AutoOpen>].

Приведенные ниже рекомендации помогут вам упорядочить код.

Предпочитать пространства имен на верхнем уровне

Для любого общедоступно используемого кода пространства имен являются привилегированными для модулей верхнего уровня. Так как они компилируются как пространства имен .NET, они используются из C# без использования using static.

// Recommended.
namespace MyCode

type MyClass() =
    ...

Использование модуля верхнего уровня может не отличаться при вызове только из F#, но для потребителей C# вызывающие пользователи могут быть удивлены необходимостью квалифицироваться MyClass с MyCode модулем, если не знать о конкретной using static конструкции C#.

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

type MyClass() =
    ...

Тщательно примените [<AutoOpen>]

Конструкция [<AutoOpen>] может загрязнять область того, что доступно вызывающим, и ответ на то, откуда что-то исходит от "магии". Это не хорошая вещь. Исключением из этого правила является сама библиотека ядра F# (хотя этот факт также является немного спорным).

Однако это удобно, если у вас есть вспомогательные функции для общедоступного API, который вы хотите упорядочить отдельно от этого общедоступного API.

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

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

        helper1 x y z

Это позволяет четко отделять сведения о реализации от общедоступного API функции без полной квалификации вспомогательного средства при каждом вызове.

Кроме того, предоставление методов расширения и построителей выражений на уровне пространства имен может быть четко выражено с [<AutoOpen>]помощью.

Используйте [<RequireQualifiedAccess>] всякий раз, когда имена могут конфликтуть или вы чувствуете, что это помогает с удобочитаемостью

Добавление атрибута [<RequireQualifiedAccess>] в модуль указывает, что модуль не может быть открыт, и что ссылки на элементы модуля требуют явного квалифицированного доступа. Например, модуль Microsoft.FSharp.Collections.List имеет этот атрибут.

Это полезно, если функции и значения в модуле имеют имена, которые, скорее всего, конфликтуют с именами в других модулях. Требование квалифицированного доступа может значительно увеличить долгосрочное обслуживание и способность библиотеки развиваться.

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

...

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

Сортировка open операторов топологии

В F#порядок объявлений имеет значение, в том числе с open операторами (и open type, как правило, называется open дальше вниз). Это в отличие от C#, где эффект using и using static не зависит от порядка этих операторов в файле.

В F#элементы, открытые в область, могут тень других уже присутствующих. Это означает, что операторы переупорядочения open могут изменить смысл кода. В результате любое произвольное сортировка всех open операторов (например, буквенно-цифровые) не рекомендуется, поэтому вы создаете другое поведение, которое может потребоваться.

Вместо этого рекомендуется отсортировать их топологию; то есть упорядочить open инструкции в порядке определения уровней системы. Кроме того, можно рассмотреть возможность сортировки буквенно-цифровых символов в разных топологических слоях.

Например, вот топологическая сортировка для общедоступного API-файла службы компилятора 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

Разрыв линии отделяет топологические слои, при этом каждый слой отсортирован альфа-числом после этого. Это чисто упорядочивает код без случайного теневого значения.

Использование классов для хранения значений, имеющих побочные эффекты

При инициализации значения может возникать побочные эффекты, например создание экземпляра контекста в базе данных или другом удаленном ресурсе. Это заманчиво инициализировать такие вещи в модуле и использовать его в последующих функциях:

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

Это часто проблематично по нескольким причинам:

Во-первых, конфигурация приложения отправляется в базу кода и dep1dep2. Это трудно поддерживать в больших базах кода.

Во-вторых, статически инициализированные данные не должны включать значения, которые не являются потокобезопасными, если сам компонент будет использовать несколько потоков. Это явно нарушается dep3.

Наконец, инициализация модуля компилируется в статический конструктор для всей единицы компиляции. Если в этом модуле возникает какая-либо ошибка, связанная с инициализацией значений, она манифестируется как кэшируемая TypeInitializationException в течение всего времени существования приложения. Это может быть трудно диагностировать. Как правило, существует внутреннее исключение, о чем вы можете попытаться подумать, но если нет, то нет никаких подсказок о том, что является первопричиной.

Вместо этого просто используйте простой класс для хранения зависимостей:

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

Это подходит для следующего случая:

  1. Отправка любого зависимого состояния за пределы самого API.
  2. Теперь можно выполнить настройку за пределами API.
  3. Ошибки при инициализации для зависимых значений, скорее всего, не проявляются в виде TypeInitializationException.
  4. Теперь API проще протестировать.

Управление ошибками

Управление ошибками в крупных системах является сложной и нюансной задачей, и нет серебряных пулей в обеспечении отказоустойчивости ваших систем и вести себя хорошо. Следующие рекомендации должны предложить рекомендации по навигации по этому сложному пространству.

Представление случаев ошибок и незаконного состояния в типах, встроенных в домен

С дискриминированными профсоюзами F# дает возможность представлять состояние неисправной программы в вашей системе типов. Например:

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

В этом случае существует три известных способа вывода денег с банковского счета может завершиться сбоем. Каждый случай ошибки представлен в типе и, таким образом, может быть безопасно рассмотрен во всей программе.

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"

Как правило, если вы можете моделировать различные способы, которые могут завершиться сбоем в вашем домене, код обработки ошибок больше не рассматривается как то, что необходимо иметь в дополнение к обычному потоку программ. Это просто часть нормального потока программы, и не считается исключительным. Ниже приведены два основных преимущества:

  1. С течением времени проще поддерживать изменения домена.
  2. Случаи ошибок проще модульного теста.

Используйте исключения, если ошибки не могут быть представлены с типами

Не все ошибки могут быть представлены в домене проблем. Такие ошибки являются исключительными в природе, поэтому способность создавать и перехватывать исключения в F#.

Во-первых, рекомендуется ознакомиться с рекомендациями по проектированию исключений. Это также применимо к F#.

Основные конструкции, доступные в F# для создания исключений, должны рассматриваться в следующем порядке предпочтений:

Function Синтаксис Характер использования
nullArg nullArg "argumentName" System.ArgumentNullException Вызывает имя указанного аргумента.
invalidArg invalidArg "argumentName" "message" Вызывает указанное System.ArgumentException имя аргумента и сообщение.
invalidOp invalidOp "message" System.InvalidOperationException Вызывает сообщение с указанным сообщением.
raise raise (ExceptionType("message")) Механизм общего назначения для создания исключений.
failwith failwith "message" System.Exception Вызывает сообщение с указанным сообщением.
failwithf failwithf "format string" argForFormatString System.Exception Вызывает сообщение, определенное строкой формата и его входными данными.

Используйте nullArg, invalidArgа invalidOp также в качестве механизма для создания ArgumentNullExceptionи ArgumentExceptionInvalidOperationException при необходимости.

failwithf Как failwith правило, следует избегать функций, так как они вызывают базовый Exception тип, а не конкретное исключение. В соответствии с рекомендациями по проектированию исключений необходимо создать более конкретные исключения, когда это возможно.

Использование синтаксиса обработки исключений

F# поддерживает шаблоны исключений с помощью синтаксиса try...with :

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

Согласование функциональных возможностей для выполнения в случае исключения с сопоставлением шаблонов может быть немного сложно, если вы хотите сохранить код чистым. Один из таких способов обработки заключается в том, чтобы использовать активные шаблоны в качестве средства для группировки функциональных возможностей, связанных с случаем ошибки с самим исключением. Например, вы можете использовать API, который при вызове исключения заключает ценные сведения в метаданные исключения. Распакуйте полезное значение в тексте захваченного исключения в активном шаблоне и возврате этого значения может оказаться полезным в некоторых ситуациях.

Не используйте обработку ошибок monadic для замены исключений

Исключения часто рассматриваются как табу в чистой функциональной парадигме. Действительно, исключения нарушают чистоту, поэтому безопасно рассматривать их не совсем функционально чисто. Однако это игнорирует реальность того, где должен выполняться код, и могут возникать ошибки среды выполнения. Как правило, напишите код на предположении, что большинство вещей не являются чистыми или общими, чтобы свести к минимуму неприятные сюрпризы (сродни пустому catch в C# или неправильно управлять трассировкой стека, дис карта инга информации).

Важно учитывать следующие основные преимущества и аспекты исключений в отношении их релевантности и соответствия в среде выполнения .NET и межязычной экосистеме в целом:

  • Они содержат подробные диагностические сведения, полезные при отладке проблемы.
  • Они хорошо понимаются средой выполнения и другими языками .NET.
  • Они могут уменьшить значительный шаблон при сравнении с кодом, который выходит за пределы своего способа, чтобы избежать исключений, реализуя некоторые подмножества их семантики на нерегламентированной основе.

Эта третья точка является критической. Для нетривиальных сложных операций неиспользование исключений может привести к возникновению таких структур:

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

Это может легко привести к хрупкому коду, например сопоставлению шаблонов при ошибках "строкового типа":

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?

Кроме того, может быть заманчиво проглотить любое исключение в желании "простой" функции, которая возвращает "хороший" тип:

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

К сожалению, tryReadAllText может вызывать многочисленные исключения на основе множества вещей, которые могут произойти в файловой системе, и этот код не карта от каких-либо сведений о том, что на самом деле может произойти неправильно в вашей среде. Если заменить этот код типом результата, вы вернеесь к синтаксическому анализу сообщения об ошибке со строковым типом:

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

И размещение объекта исключения в Error конструкторе просто заставляет правильно работать с типом исключения на сайте вызова, а не в функции. Это эффективно создает проверка исключения, которые, как известно, не могут быть связаны с вызывающим API.

Хорошая альтернатива приведенным выше примерам заключается в том, чтобы перехватывать определенные исключения и возвращать понятное значение в контексте этого исключения. Если изменить функцию tryReadAllText следующим образом, None имеет больше значения:

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

Вместо того, чтобы работать как catch-all, эта функция теперь будет правильно обрабатывать случай, когда файл не найден и присваивает это значение возвращаемого значения. Это возвращаемое значение может сопоставиться с этим случаем ошибки, а не карта каких-либо контекстных сведений или принудительного вызова справиться с случаем, который может не быть актуальным в этом моменте в коде.

Такие типы, как Result<'Success, 'Error> подходят для базовых операций, в которых они не вложены, и необязательные типы F# идеально подходят для представления, когда что-то может возвращать что-то или ничего. Они не являются заменой исключений, однако и не должны использоваться в попытке заменить исключения. Скорее, они должны применяться разумно для решения конкретных аспектов политики исключения и управления ошибками в целевых способах.

Частичное программирование на основе приложений и точечных приложений

F# поддерживает частичное приложение и, следовательно, различные способы программирования в стиле без указателей. Это может быть полезно для повторного использования кода в модуле или реализации чего-либо, но это не то, что следует предоставлять публично. В общем, программирование без точки не является добродетелью и само по себе, и может добавить значительный когнитивный барьер для людей, которые не погружены в стиль.

Не используйте частичное приложение и карриинг в общедоступных API

За исключением того, что использование частичного приложения в общедоступных API может быть запутано для потребителей. Как правило, letзначения-привязанные в коде F# — это значения, а не значения функций. Сочетание значений и значений функций может привести к сохранению нескольких строк кода в обмен на довольно много когнитивных затрат, особенно если в сочетании с операторами, такими как >> создание функций.

Рассмотрите последствия для программирования без точек

Курриированные функции не метят их аргументы. Это имеет последствия для инструментов. Рассмотрим следующие две функции:

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

Оба являются допустимыми функциями, но funcWithApplication являются куриной функцией. При наведении указателя мыши на их типы в редакторе вы увидите следующее:

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

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

На сайте вызова подсказки в таких инструментах, как Visual Studio, дают подпись типа, но так как имена не определены, они не будут отображать имена. Имена критически важны для хорошего проектирования API, так как они помогают вызывающим людям лучше понять смысл API. Использование бесплатного кода в общедоступном API может сделать его более сложным для того, чтобы вызывающие могли понять.

Если вы столкнулись с кодом без точек, например funcWithApplication , общедоступным, рекомендуется выполнить полное η расширения, чтобы инструменты могли получить значимые имена для аргументов.

Кроме того, отладка бесплатного кода может быть сложной, если не невозможно. Средства отладки используют значения, привязанные к именам (например, let привязкам), чтобы проверить промежуточные значения в середине выполнения. Если код не имеет значений для проверки, отладка не требуется. В будущем средства отладки могут развиваться для синтеза этих значений на основе ранее выполненных путей, но не рекомендуется хеджировать ставки на потенциальные функции отладки.

Рассмотрим частичное применение в качестве метода, чтобы уменьшить внутренний шаблон

В отличие от предыдущей точки, частичное приложение является прекрасным инструментом для уменьшения шаблонов внутри приложения или более глубоких внутренних элементов API. Это может быть полезно для модульного тестирования реализации более сложных API, где шаблон часто боль в работе. Например, в следующем коде показано, как можно выполнить то, что большинство макетных платформ дает вам, не принимая внешнюю зависимость от такой платформы и необходимо узнать связанный API bespoke.

Например, рассмотрим следующую топологию решения:

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

ImplementationLogic.fsproj может предоставлять такой код, как:

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

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

Модульное тестирование Transactions.doTransactionImplementationLogic.Tests.fsproj легко:

namespace TransactionsTestingUtil

open Transactions

module TransactionsTestable =
    let getTestableTransactionRoutine mockContext = Transactions.doTransaction mockContext

Частичное применение doTransaction с макетным объектом контекста позволяет вызывать функцию во всех модульных тестах без необходимости создавать макетный контекст каждый раз:

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)

Не применяйте этот метод универсально ко всей базе кода, но это хороший способ уменьшить стандартный подход для сложных внутренних и модульных тестов этих внутренних компонентов.

Управление доступом

F# имеет несколько вариантов управления доступом, унаследованных от доступных в среде выполнения .NET. Они доступны не только для типов, но и для функций.

Рекомендации в контексте библиотек, которые широко используются:

  • Предпочитайте неpublic типы и члены, пока они не будут общедоступными. Это также сводит к минимуму то, к чему относится потребитель.
  • Старайтесь сохранить все вспомогательные privateфункции.
  • Рассмотрите возможность использования [<AutoOpen>] в частном модуле вспомогательных функций, если они становятся многочисленными.

Вывод типов и универсальные шаблоны

Вывод типов позволяет сэкономить на вводе большого количества стандартных типов. Автоматическая обобщение в компиляторе F# позволяет создавать более универсальный код практически без дополнительных усилий в вашей части. Однако эти функции не являются универсальными.

  • Рекомендуется использовать имена аргументов с явными типами в общедоступных API и не полагаться на вывод типов для этого.

    Причиной этого является то, что вы должны контролировать форму API, а не компилятор. Хотя компилятор может выполнять тонкое задание при выводе типов, можно изменить форму API, если внутренние элементы, на которые он полагается, изменили типы. Это может быть то, что вы хотите, но это почти наверняка приведет к критическому изменению API, с которыми нисходящий потребитель будет иметь дело. Вместо этого, если вы явно управляете формой общедоступного API, вы можете управлять этими критическими изменениями. В терминах DDD это можно рассматривать как анти коррупционный уровень.

  • Рассмотрите возможность предоставления понятного имени универсальным аргументам.

    Если вы не пишете действительно универсальный код, не относящийся к определенному домену, понятное имя может помочь другим программистам понять домен, в который они работают. Например, параметр типа с именем 'Document в контексте взаимодействия с базой данных документов дает понять, что универсальные типы документов могут приниматься функцией или членом, с которыми вы работаете.

  • Рассмотрите возможность именования параметров универсального типа с помощью PascalCase.

    Это общий способ сделать вещи в .NET, поэтому рекомендуется использовать PascalCase, а не snake_case или верблюдьи Регистр.

Наконец, автоматическая обобщение не всегда является логическим для людей, которые не знакомы с F# или большой базой кода. При использовании компонентов, которые являются универсальными, есть когнитивные издержки. Кроме того, если автоматически обобщенные функции не используются с различными типами входных данных (не говоря уже о том, что они предназначены для использования таким образом), то нет никакого реального преимущества для них быть универсальными. Всегда учитывайте, что код, который вы пишете, на самом деле будет пользоваться универсальным.

Производительность

Рассмотрим структуры для небольших типов с высоким уровнем распределения

Использование структур (также называемых типами значений) часто может привести к повышению производительности для некоторых кодов, так как обычно это позволяет избежать выделения объектов. Однако структуры не всегда являются кнопкой "перейти быстрее": если размер данных в структуре превышает 16 байт, копирование данных часто может привести к большему времени ЦП, чем при использовании ссылочного типа.

Чтобы определить, следует ли использовать структуру, рассмотрите следующие условия:

  • Если размер данных равен 16 байтам или меньше.
  • Если у вас может быть много экземпляров этих типов в памяти в работающей программе.

Если применяется первое условие, обычно следует использовать структуру. При применении обоих вариантов следует использовать структуру почти всегда. В некоторых случаях применяются предыдущие условия, но использование структуры не лучше или хуже, чем использование ссылочного типа, но они, скорее всего, будут редкими. Важно всегда измерять при внесении изменений, как это, однако, и не действовать на предположении или интуиции.

При группировке небольших типов значений с высоким уровнем распределения рекомендуется учитывать кортежи структур.

Рассмотрим следующие две функции:

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)

При тестировании этих функций с помощью средства статистического тестирования, например BenchmarkDotNet, вы обнаружите, что runWithStructTuple функция, использующая кортежи структур, выполняет 40 % быстрее и не выделяет память.

Однако эти результаты не всегда будут регистрироваться в собственном коде. Если вы помечаете функцию как inlineкод, использующий ссылочные кортежи, может получить некоторые дополнительные оптимизации или код, который будет выделяться, может быть просто оптимизирован. Всегда следует измерять результаты всякий раз, когда это касается, и никогда не работать на основе предположения или интуиции.

Рассмотрите записи структуры, если тип мал и имеет высокие показатели распределения

Правило большого пальца, описанное ранее, также содержит типы записей F#. Рассмотрим следующие типы данных и функции, которые обрабатывают их:

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)

Это аналогично предыдущему коду кортежа, но на этот раз пример использует записи и встроенную внутреннюю функцию.

При тестировании этих функций с помощью средства статистического тестирования, такого как BenchmarkDotNet, вы обнаружите, что processStructPoint выполняется почти 60 % быстрее и не выделяет ничего в управляемой куче.

Рассмотрите возможность дискриминации профсоюзов, когда тип данных мал с высокими ставками распределения

Предыдущие наблюдения за производительностью с кортежами структур и записями также содержатся для F# дискриминированных профсоюзов. Рассмотрим следующий код:

    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

Обычно для моделирования предметных областей определяются однократные профсоюзы, такие как это. При тестировании этих функций с помощью средства статистического тестирования, например BenchmarkDotNet, вы обнаружите, что structReverseName выполняется около 25 % быстрее, чем reverseName для небольших строк. Для больших строк оба выполняют примерно то же самое. Таким образом, в этом случае всегда предпочтительнее использовать структуру. Как ранее упоминание, всегда измерять и не действовать на предположениях или интуиции.

Хотя в предыдущем примере показано, что структуру дискриминации союз дал более высокую производительность, обычно при моделировании домена имеются более крупные различаемые профсоюзы. Более крупные типы данных, например, могут не выполняться так же, если они являются структурой в зависимости от операций с ними, так как может потребоваться больше копирования.

Неизменяемость и мутация

Значения F# являются неизменяемыми по умолчанию, что позволяет избежать определенных классов ошибок (особенно тех, кто включает параллелизм и параллелизм). Однако в некоторых случаях для достижения оптимальной (или даже разумной) эффективности выделения времени выполнения или выделения памяти лучше всего реализовать диапазон работы с помощью изменения состояния на месте. Это возможно в рамках подписки на F# с mutable помощью ключевое слово.

mutable Использование в F# может чувствовать себя в противоречии с функциональной чистотой. Это понятно, но функциональная чистота везде может быть в противоречии с целями производительности. Компромисс заключается в том, чтобы инкапсулировать мутацию, так что вызывающие не должны заботиться о том, что происходит при вызове функции. Это позволяет создавать функциональный интерфейс над реализацией на основе мутаций для критически важного кода для производительности.

Кроме того, конструкции привязки F# let позволяют вложить привязки в другую, это можно использовать, чтобы сохранить область переменной mutable близко или по своей теоретической наименьшей.

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

Код не может получить доступ к изменяемому completed объекту, который использовался только для инициализации data привязанного значения.

Перенос изменяемого кода в неизменяемые интерфейсы

С ссылочной прозрачностью в качестве цели важно написать код, который не предоставляет мутируемый подвербья критически важных функций производительности. Например, следующий код реализует функцию 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

Вызов этой функции несколько раз не изменяет базовый массив, а также не требует поддержания изменяемого состояния при его использовании. Она является прозрачной, хотя почти каждая строка кода в нем использует мутацию.

Рассмотрите возможность инкапсулирования изменяемых данных в классах

В предыдущем примере используется одна функция для инкапсулировать операции с использованием изменяемых данных. Это не всегда достаточно для более сложных наборов данных. Рассмотрим следующие наборы функций:

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

Этот код выполняется, но предоставляет структуру данных на основе мутаций, которые вызывающие несут ответственность за обслуживание. Это можно упаковать внутри класса без базовых элементов, которые могут измениться:

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 инкапсулирует базовую структуру данных на основе мутаций, тем самым не заставляя вызывающих поддерживать базовую структуру данных. Классы являются мощным способом инкапсулировать данные и подпрограммы, основанные на мутациях, не предоставляя сведения вызывающим.

Предпочитать let mutableref

Ссылочные ячейки — это способ представления ссылки на значение, а не самого значения. Хотя их можно использовать для критически важных для производительности кода, они не рекомендуется. Рассмотрим следующий пример:

let kernels =
    let acc = ref Set.empty

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

    !acc |> Seq.toList

Использование ссылочной ячейки теперь "загрязняет" весь последующий код с необходимостью разыменовать и повторно ссылаться на базовые данные. Вместо этого рассмотрим 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

Помимо одной точки мутации в середине лямбда-выражения, весь другой код, который касается acc , может сделать это таким образом, что не отличается от использования нормального letнеизменяемого значения. Это упрощает изменение с течением времени.

Значения NULL и значения по умолчанию

Значения NULL обычно следует избегать в F#. По умолчанию объявленные F#типы не поддерживают использование null литерала, а все значения и объекты инициализированы. Однако некоторые распространенные API .NET возвращают или принимают значения NULL, а также некоторые распространенные. Объявленные в NET типы, такие как массивы и строки, допускают значения NULL. Однако возникновение значений null очень редко используется в программировании F# и одним из преимуществ использования F# является предотвращение ошибок ссылок null в большинстве случаев.

Избегайте использования атрибута AllowNullLiteral

По умолчанию объявленные типы F#не поддерживают использование null литерала. Вы можете вручную добавлять типы F#, AllowNullLiteral чтобы разрешить это. Тем не менее, это почти всегда лучше, чтобы избежать этого.

Избегайте использования атрибута Unchecked.defaultof<_>

Можно создать или нулевое null инициализированное значение для типа F# с помощью Unchecked.defaultof<_>. Это может быть полезно при инициализации хранилища для некоторых структур данных или в некоторых шаблонах кода с высокой производительностью или в взаимодействии. Однако следует избежать использования этой конструкции.

Избегайте использования атрибута DefaultValue

По умолчанию записи и объекты F# должны быть правильно инициализированы при построении. Атрибут DefaultValue можно использовать для заполнения некоторых полей объектов с null нулевой инициализацией значения. Эта конструкция редко требуется, и ее использование следует избежать.

Если вы проверка для входных данных null, при первой возможности создайте исключения.

При написании нового кода F# на практике нет необходимости проверка для входных данных null, если только вы не ожидаете, что этот код будет использоваться на C# или других языках .NET.

Если вы решите добавить проверка для входных данных NULL, выполните проверка при первой возможности и создайте исключение. Например:

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

По устаревшим причинам некоторые строковые функции в FSharp.Core по-прежнему обрабатывают значения NULL как пустые строки и не завершаются ошибкой в аргументах NULL. Однако не следует принимать это в качестве рекомендаций и не принимать шаблоны кодирования, которые атрибутирует любое семантические значения значение null.

Объектно-ориентированное программирование

F# имеет полную поддержку объектов и концепций объектно-ориентированного (OO). Хотя многие концепции OO являются мощными и полезными, не все из них идеально подходят для использования. В следующих списках приведены рекомендации по категориям функций OO на высоком уровне.

Рассмотрите возможность использования этих функций во многих ситуациях:

  • Нотация точек (x.Length)
  • Элементы экземпляра
  • Неявные конструкторы
  • Статические участники
  • Нотация индексатора (arr[x]), определяя Item свойство
  • Прорезка нотации (arr[x..y], arr[x..], arr[..y]) путем GetSlice определения элементов
  • Именованные и необязательные аргументы
  • Интерфейсы и реализации интерфейса

Не дойдете до этих функций сначала, но тщательно применяйте их, когда они удобны для решения проблемы:

  • Перегрузка методов
  • Инкапсулированные изменяемые данные
  • Операторы типов
  • Автоматические свойства
  • Реализация IDisposable и IEnumerable
  • Расширения типов
  • События
  • Структуры
  • Делегаты
  • Перечисления

Как правило, избежать этих функций, если их не следует использовать:

  • Иерархии типов на основе наследования и наследование реализации
  • Значения NULL и Unchecked.defaultof<_>

Предпочитать композицию над наследованием

Композиция над наследованием является давним идиом, который хороший код F# может соответствовать. Основной принцип заключается в том, что не следует предоставлять базовый класс и принудительно вызывать вызывающих объектов от этого базового класса для получения функциональных возможностей.

Использование выражений объектов для реализации интерфейсов, если не требуется класс

Выражения объектов позволяют реализовать интерфейсы во время полета, привязывая реализованный интерфейс к значению без необходимости делать это внутри класса. Это удобно, особенно если вам нужно реализовать интерфейс и не требуется для полного класса.

Например, вот код, который выполняется в Ionide , чтобы предоставить действие исправления кода, если вы добавили символ, для которых у вас нет инструкции open :

    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
        }

Так как при взаимодействии с API Visual Studio Code не требуется класс, выражения объектов идеально подходят для этого. Они также полезны для модульного тестирования, когда вы хотите заглушить интерфейс с подпрограммами тестирования импровизированным образом.

Рассмотрим аббревиатуры типов для сокращения подписей

Сокращение типов — удобный способ назначения метки другому типу, например сигнатуре функции или более сложному типу. Например, следующий псевдоним назначает метку для определения вычислений с помощью CNTK, библиотеки глубокого обучения:

open CNTK

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

Имя Computation — это удобный способ указать любую функцию, которая соответствует подписи, которая является псевдонимом. Использование сокращенных типов, таких как это удобно, и позволяет использовать более краткий код.

Избегайте использования сокращенных типов для представления домена

Хотя аббревиатуры типов удобны для предоставления имени подписям функций, они могут быть запутаны при сокращении других типов. Рассмотрим это сокращение:

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

Это может быть запутано несколькими способами:

  • BufferSize не является абстракцией; это просто другое имя целого числа.
  • Если BufferSize он предоставляется в общедоступном API, он может быть легко неправильно интерпретирован, чтобы означать больше, чем просто int. Как правило, типы доменов имеют несколько атрибутов и не являются примитивными типами, такими как int. Это сокращение нарушает это предположение.
  • BufferSize Регистр (PascalCase) подразумевает, что этот тип содержит больше данных.
  • Этот псевдоним не обеспечивает повышенную ясность по сравнению с предоставлением именованного аргумента функции.
  • Сокращение не будет манифестироваться в скомпилированном IL; это просто целое число, и этот псевдоним является конструкцией во время компиляции.
module Networking =
    ...
    let send data (bufferSize: int) = ...

В целом, ловушка с сокращенными типами заключается в том, что они не абстракции по типам, которые они сокращены. В предыдущем примере BufferSize это просто под крышкой int , без дополнительных данных, ни каких-либо преимуществ от системы типов, кроме того, что int уже имеет.

Альтернативный подход к использованию аббревиаций типов для представления домена заключается в использовании однократных профсоюзов. Предыдущий пример можно моделировать следующим образом:

type BufferSize = BufferSize of int

Если вы пишете код, который работает с точки зрения BufferSize и его базовым значением, необходимо создать один, а не передать любое произвольное целое число:

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

Это снижает вероятность ошибочного передачи произвольного целого числа в send функцию, так как вызывающий объект должен создать BufferSize тип для упаковки значения перед вызовом функции.