Ескертпе
Бұл бетке кіру үшін қатынас шегін айқындау қажет. Жүйеге кіруді немесе каталогтарды өзгертуді байқап көруге болады.
Бұл бетке кіру үшін қатынас шегін айқындау қажет. Каталогтарды өзгертуді байқап көруге болады.
Следующие соглашения формулируются на основе опыта работы с большими базами кода 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>/config-options.txt"
let dep2 = Environment.GetEnvironmentVariable "DEP_2"
let private r = Random()
let dep3() = r.Next() // Problematic if multiple threads use this
let function1 arg = doStuffWith dep1 dep2 dep3 arg
let function2 arg = doStuffWith dep1 dep2 dep3 arg
Это часто проблематично по нескольким причинам:
Во-первых, конфигурация приложения внедряется в базу кода с помощью 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
Это подходит для следующего случая:
- Отправка любого зависимого состояния за пределы самого API.
- Теперь можно выполнить настройку за пределами API.
- Ошибки при инициализации для зависимых значений, скорее всего, не проявляются в виде
TypeInitializationException. - Теперь 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"
Как правило, если вы можете моделировать различные способы, которые могут завершиться сбоем в вашем домене, код обработки ошибок больше не рассматривается как то, что необходимо иметь в дополнение к обычному потоку программ. Это просто часть нормального потока программы, и не считается исключительным. Ниже приведены два основных преимущества:
- С течением времени становится проще поддерживать ваш домен по мере его изменений.
- Случаи ошибок легче тестировать модульными тестами.
Используйте исключения, если ошибки не могут быть представлены с типами
Не все ошибки могут быть представлены в домене проблем. Такие ошибки являются исключительными в природе, поэтому способность создавать и перехватывать исключения в 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, который при вызове исключения заключает ценные сведения в метаданные исключения. Распаковка полезного значения из тела (корпуса) захваченного исключения в Активном шаблоне и возвращение этого значения может оказаться полезным в некоторых ситуациях.
Не используйте обработку ошибок через монады для замены исключений
Исключения часто рассматриваются как табу в чистой функциональной парадигме. Действительно, исключения нарушают чистоту, поэтому их можно считать не вполне функционально чистыми. Однако это игнорирует реальность того, где должен выполняться код, и могут возникать ошибки среды выполнения. Обычно код следует писать исходя из предположения, что большинство компонентов не являются чистыми или полными, чтобы свести к минимуму неприятные сюрпризы (подобно пустому 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
Вместо того чтобы работать как универсальная функция, эта функция теперь будет корректно обрабатывать случай отсутствия файла и присваивать соответствующее значение возвращаемому значению. Это возвращаемое значение может соответствовать тому случаю ошибки, не теряя контекстной информации и не вынуждая вызывающих обрабатывать случай, который может быть неактуален в данной части кода.
Такие типы, как 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. Использование кода в стиле point-free в общедоступном API может усложнить его понимание для вызывающих.
Если вы столкнулись с кодом без точек, например funcWithApplication, который является общедоступным, рекомендуется выполнить полное η-расширение, чтобы инструменты могли получить значимые имена для аргументов.
Кроме того, отладка кода в стиле point-free может быть сложной, если не невозможной. Средства отладки используют значения, привязанные к именам (например, 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 txnType 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. Они доступны не только для типов, но и для функций.
Лучшие практики в контексте программных библиотек, которые широко используются:
- Предпочитайте закрытые типы и члены вместо общедоступных, пока в этом не появится необходимость. Это также сводит к минимуму то, с чем соединяются потребители.
- Старайтесь сохранить все вспомогательные
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# может казаться противоречащим функциональной чистоте. Это понятно, но функциональная чистота везде может быть в противоречии с целями производительности. Компромисс заключается в том, чтобы инкапсулировать мутацию, так что вызывающие не должны заботиться о том, что происходит при вызове функции. Это позволяет создавать функциональный интерфейс над мутирующей реализацией для критически важного кода с точки зрения производительности.
Кроме того, конструкции привязки F# let позволяют вкладывать привязки одна в другую, что можно использовать для сохранения области переменной mutable в пределах его теоретически наименьшего размера.
let data =
[
let mutable completed = false
while not completed do
logic ()
// ...
if someCondition then
completed <- true
]
Код не имеет доступа к изменяемому completed, который использовался только для инициализации значения, привязанного с помощью let 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 mutable, а не ref
Ссылочные ячейки — это способ представления ссылки на значение, а не самого значения. Хотя их можно использовать для критически важных для производительности кода, они не рекомендуется. Рассмотрим следующий пример:
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# на практике нет необходимости проверять наличие пустых входных данных, если вы не ожидаете, что этот код будет использоваться на 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# 9 NULL в границах API
F# 9 добавляет синтаксис для явного указания, что значение может быть null. Он предназначен для использования на границах API, чтобы компилятор указывал места, в которых отсутствует обработка null.
Ниже приведен пример допустимого использования синтаксиса:
type CustomType(m1, m2) =
member _.M1 = m1
member _.M2 = m2
override this.Equals(obj: obj | null) =
match obj with
| :? CustomType as other -> this.M1 = other.M1 && this.M2 = other.M2
| _ -> false
override this.GetHashCode() =
hash (this.M1, this.M2)
Избегайте распространения значений NULL дальше по коду F#:
let getLineFromStream (stream: System.IO.StreamReader) : string | null =
stream.ReadLine()
Вместо этого используйте идиоматические средства F# (например, параметры):
let getLineFromStream (stream: System.IO.StreamReader) =
stream.ReadLine() |> Option.ofObj
Для создания исключений, связанных с null, можно использовать специальные nullArgCheck и nonNull функции. Они также удобны еще и потому, что в случае, если значение не равно NULL, они заменяют аргумент его очищенным значением - дальнейший код больше не может иметь доступ к возможным указателям NULL.
let inline processNullableList list =
let list = nullArgCheck (nameof list) list // throws `ArgumentNullException`
// 'list' is safe to use from now on
list |> List.distinct
let inline processNullableList' list =
let list = nonNull list // throws `NullReferenceException`
// 'list' is safe to use from now on
list |> List.distinct
Объектно-ориентированное программирование
F# имеет полную поддержку объектов и объектно-ориентированных концепций. Хотя многие концепции 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 для обёртывания значения перед вызовом функции.