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

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

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

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

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

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

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

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

// Good!
namespace MyCode

type MyClass() =
    ...

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

// Bad!
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 операторами . Это отличается от 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 = doStuffWith dep1 dep2 dep3 arg

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

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

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

Функция Синтаксис Назначение
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# в коде 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 на заказ.

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

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.

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

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

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

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

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

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

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

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

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

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

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

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

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