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

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

Организация кода

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

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

Следующие рекомендации помогут вам использовать их для организации кода.

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

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

// Good!
namespace MyCode

type MyClass() =
    ...

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

// Bad!
module MyCode

type MyClass() =
    ...

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

[<AutoOpen>]Конструкция может засоряла область действия, которая доступна для вызывающих объектов, и ответ на то, что поступило от "Magic". Это не хорошая вещь. Исключением из этого правила является сама основная библиотека 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 Операторы With. Это не похоже на 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

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

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

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

// This is bad!
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 = doSutffWith dep1 dep2 dep3 arg

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

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

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

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

Функция Синтаксис Назначение
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 , ArgumentException и InvalidOperationException при необходимости.

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

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

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

Важно учитывать следующие основные сильные стороны и аспекты исключений в отношении их релевантности и адекватности в среде выполнения .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?

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

// This is bad!
let tryReadAllText (path : string) =
    try System.IO.File.ReadAllText path |> Some
    with _ -> None

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

// This is bad!
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 # являются значениями, а не значениями функций. Сочетание значений и значений функций может привести к сохранению нескольких строк кода в Exchange для довольно большого количества системных издержек, особенно в сочетании с операторами, такими как >> Создание функций.

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

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

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-интерфейс.

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

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 txtType currentBalance
        ...

Модульное тестирование Transactions.doTransaction в ImplementationLogic.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 или camelCase.

Наконец, автоматическое обобщение не всегда является boonом для тех, кто не знаком с 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)

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

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

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

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

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

Функциональное программирование и изменения

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

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

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

При использовании ссылочной прозрачности в качестве цели очень важно написать код, который не предоставляет изменяемую ненужные функции, критические для производительности. Например, следующий код реализует 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 not (t.ContainsKey(key)) then
        t.Add(key, value)
    else
        t[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 not (t.ContainsKey(key)) then
            t.Add(key, value)
        else
            t[key] <- value

    member _.Count = t.Count

    member _.Contains(key, value) =
        match t.TryGetValue(key) with
        | (true, v) -> v.Equals(value)
        | (false, _) -> false

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

Предпочитать let mutable ссылки на ячейки

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

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 неизменяемого значения с нормальным связыванием. Это упростит изменение со временем.

Программирование объектов

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

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

  • Точечная нотация ( 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
        }

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

Рекомендуется использовать сокращения типов для сокращения сигнатур

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