Новые возможности F# 11

Добро пожаловать в F# 11! Эта версия помогает разграничивать модели предметной области, повторно использовать универсальные алгоритмы, координировать асинхронную работу и сократить потребление памяти компилятором.

Спасибо сообществу F# за предложения, реализации, проверки и тесты, стоящие за этими изменениями. Материалы, приведенные ниже, сопровождаются ссылками на авторов.

Note

В примерах требуется компилятор F# 11 и FSharp.Core 11 с показанными возможностями. В пакете SDK для .NET 11.0.100 для функций F# 11.2 требуется <LangVersion>preview</LangVersion>; см. параметры языка и плановую поддержку пакета SDK. Пример runtime-async имеет дополнительные требования к среде выполнения; более ранний пакет SDK для .NET 11 может содержать не все изменения.

Разворачивание записей: разные модели без повторяющихся сопоставлений

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

open System

type CustomerId = CustomerId of Guid

type CustomerDetails = { Name: string; PreferredLanguage: string }

type CustomerRow =
    { ...CustomerDetails
      Id: string
      InternalNote: string }

type Customer =
    { ...CustomerDetails
      Id: CustomerId }

...CustomerDetails вносит объявления полей. На границе ...row копирует значения, а преобразование анализирует идентификатор:

let toCustomer (row: CustomerRow) =
    match Guid.TryParse row.Id with
    | true, id -> Ok ({ ...row; Id = CustomerId id }: Customer)
    | false, _ -> Error "Invalid customer identifier."

Назначение сохраняет общие сведения, заменяет Idи исключает InternalNote. Guid.TryParse, а не оператор расширения, проверяет идентификатор; некорректный ввод приводит к Error.

Это решает проблему сопоставления на уровне приложения. Распределение значений также объединяет именованные и анонимные записи; распределение типов создает только именованные записи. Приоритет имеют поля, указанные позже, а источники обрабатываются по порядку только один раз. Ознакомьтесь с операторами распределения записей для примеров композиции и рисков совместной эволюции.

Спасибо @brianrourkeboll за рекордные спреды.

Члены-расширения для универсальных алгоритмов, операторов и кортежей

Чтобы использовать один и тот же алгоритм для существующих типов, F# 11.2 позволяет членам расширения удовлетворять ограничениям параметров типа, разрешаемых статически (SRTP). Например, одно преобразование может обрабатывать списки и опции без типов-обёрток.

Note

В пакете SDK .NET 11.0.100 включите <LangVersion>preview</LangVersion> в месте, где обнаруживаются расширения, и откройте их модуль. См. требования к библиотеке.

Поделиться преобразованием между списками и параметрами

При использовании map библиотеки приложение может очистить любое представление, изменить типы элементов и преобразовать преобразования создания:

let inline cleanNames values =
    map (fun (name: string) -> name.Trim().ToUpperInvariant()) values

let batch: string list = cleanNames [ " Ada "; " grace " ]
let optional: string option = cleanNames (Some " linus ")
let missing: string option = cleanNames (None: string option)
let lengths: int list = map String.length batch
let nested: int option list = map (map String.length) [ Some "abc"; None ]

printfn "%A; %A; %A" batch optional missing
printfn "%A; %A" lengths nested
// ["ADA"; "GRACE"]; Some "LINUS"; None
// [3; 5]; [Some 3; None]

Адаптеры в Structural.Adapters используют знакомые операции, а не вводят новые контейнеры:

type List<'T> with
    static member Map(source: 'T list, mapping: 'T -> 'U) : 'U list =
        List.map mapping source

type Option<'T> with
    static member Map(source: 'T option, mapping: 'T -> 'U) : 'U option =
        Option.map mapping source

Общая функция в Structural.Algorithms запрашивает у входного типа статический элемент Map:

let inline map (mapping: 'T -> 'U) (source: ^Input) : ^Output =
    (^Input :
        (static member Map: ^Input * ('T -> 'U) -> ^Output)
        (source, mapping))

^Input и ^Output являются статически определяемыми типами. При каждом конкретном вызове компилятор выбирает член с требуемой сигнатурой. Расширения не добавляют реализацию интерфейса. Полное приложение открывает оба модуля.

Операторы, контекст и типы выходных данных

Тот же механизм поддерживает операторы кортежа и распространение контекста и построение, направленное на выходные данные. Встроенные заголовки кортежей должны иметь включённую возможность F# 11.2 в объявлениях, а также в местах вызова, где выполняется разрешение расширений.

Отдельное дополнение, AllowOverloadOnReturnType, позволяет одной фабрике объявлять перегрузки, различающиеся по возвращаемым значениям:

type Inject =
    [<AllowOverloadOnReturnType>]
    static member Pure(value: 'T) : 'T list = [value]

    [<AllowOverloadOnReturnType>]
    static member Pure(value: 'T) : 'T option = Some value

let batch: string list = Inject.Pure "ready"
let optional: string option = Inject.Pure "ready"
// ["ready"] and Some "ready"

Этот атрибут Core 11 не требует предпросмотра. Аннотация результата выбирает перегрузку; без нее вызов остается неоднозначным. См. Выбрать по типу возврата.

Спасибо @gusty за расширение поддержки обобщённого программирования.

интеграция .NET и опыт разработки

Прямые делегаты сохраняют реальный контракт обработчика

Чтобы предоставить платформам имена обработчиков и атрибуты привязки, F# 11 может напрямую ссылаться на совместимую именованную функцию вместо оболочки. ASP.NET Core минимальные API-интерфейсы затем могут считывать контракт маршрута, службы и заголовка:

open System
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Mvc
open Microsoft.Extensions.DependencyInjection

type ShipmentStore() =
    member _.Status trackingId =
        if trackingId = "SHP-1042" then "Dispatched" else "Unknown"

let status
    (trackingId: string)
    ([<FromServices>] store: ShipmentStore)
    ([<FromHeader(Name = "X-Customer")>] customer: string) =
    $"{customer}: {trackingId} is {store.Status trackingId}"

let handler = Func<string, ShipmentStore, string, string>(status)

Ознакомьтесь с правилами запускаемого проекта и строительства и совместимости. Захваты и адаптация аргументов по-прежнему могут требовать оболочки.

Спасибо @kerams за прямые делегаты.

Повторное использование XML-документации

Чтобы сохранить примечания общего API один раз, поместите их в shared.xml:

<docs>
  <tracking>
    <remarks>An unrecognized tracking identifier has status Unknown.</remarks>
  </tracking>
</docs>

При включённом GenerateDocumentationFile F# 11 расширяет <include> в созданные записи членов:

/// <summary>Find the latest status of a shipment.</summary>
/// <include file="shared.xml" path="/docs/tracking/*"/>
let status trackingId =
    if trackingId = "SHP-1042" then "Dispatched" else "Unknown"

/// <summary>Find the latest statuses of several shipments.</summary>
/// <include file="shared.xml" path="/docs/tracking/*"/>
let statuses trackingIds =
    trackingIds |> List.map (fun id -> id, status id)

Реализация include обновляет оба описания из одного фрагмента. Отдельно резолвер tooling предоставляет базовые описания для <inheritdoc/>; компилятор оставляет этот тег для потребителей документации. Примеры и правила разрешения см. в XML-документации .

Спасибо @T-Gro за предоставленную XML-документацию для FSharp.Core.

Отлаживать встроенные функции в неоптимизированных сборках

Чтобы проверить аргументы встроенных вспомогательных функций, задайте точку останова на let total в неоптимизированной сборке и выполните шаг с заходом в addFee. F# 11 теперь предпочитает методы, допускающие вызов, в следующих сборках:

let inline addFee (fee: decimal) (amount: decimal) =
    let total = amount + fee
    total

let invoiceTotal = addFee 2m 40m
printfn "%M" invoiceTotal
// 42

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

Асинхронность и координация задач

Чтобы подключить рабочие процессы F# к API на основе задач, FSharp.Core 11 добавляет мосты с поддержкой отмены, ограниченные пакетные операции и преобразования результатов.

Связать незавершённый ввод-вывод, а не только коллекцию результатов

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

open System.IO
open System.Threading

let readFiles (paths: seq<string>) (ct: CancellationToken) =
    paths
    |> Seq.map (fun path ->
        fun token -> File.ReadAllTextAsync(path, token))
    |> Task.parallelLimit 4 ct

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

Сохраняйте отмену и базовое исключение через границу

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

open System.IO

let tryReadFile path =
    async {
        try
            let! contents =
                Async.StartTaskImmediate(fun ct -> File.ReadAllTextAsync(path, ct))
            return Some contents
        with :? FileNotFoundException ->
            return None
    }

Сбой одной задачи попадает в этот обработчик как базовый FileNotFoundException. Для уже созданной задачи или ValueTask используйте Async.Await; более старые Async.AwaitTask сохраняют поведение обёртывания исключений.

Task.startAsyncImmediate token workflow предоставляет обратный мост для вызывающих сторон, основанных на задачах. Он запускается в вызывающем потоке до первой асинхронной приостановки.

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

Чтобы преобразовать один результат без полного вычислительного выражения, используйте вспомогательные средства сопоставления.Microsoft.FSharp.Control Например, считывает файл, получает его длину и возвращает обычный сбой следующим образом Error:

open System.IO
open System.Threading

let fileLength (path: string) (ct: CancellationToken) =
    File.ReadAllTextAsync(path, ct)
    |> Task.map String.length
    |> Task.catch

Файл, содержащий Report 1, возвращает Ok 8; отсутствующий файл возвращает Error, содержащую FileNotFoundException. Отмена по-прежнему передается. Async и ValueTask предлагают соответствующие вспомогательные функции для сопоставления с образцом, , map и обработки ошибок; см. bind. Используйте выражение вычисления для ветвей или нескольких зависимых значений.

Вспомогательные средства ValueTask требуют использования Core netstandard2.1 или net10.0 сборки и однократного использования.

Спасибо @bartelink за вклад в API async/task.

Повторное выполнение после асинхронного отчета о сбоях

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

Note

Повторная генерация исключения в вычислительных выражениях — это возможность F# 11.2. В пакете SDK .NET 11.0.100 включите <LangVersion>preview</LangVersion>.

open System
open System.IO
open System.Threading.Tasks

let readReport (logFailure: exn -> Task) path =
    task {
        try
            return! File.ReadAllTextAsync(path)
        with :? IOException as error ->
            do! logFailure error
            return reraise ()
    }

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

Спасибо @xperiandri за эту поддержку.

FSharp.Core может опираться на современные .NET

Чтобы использовать современные API без удаления старых целевых объектов, FSharp.Core добавляет net10.0 вместе netstandard2.0 и netstandard2.1. NuGet выбирает совместимую сборку. Обновленная упаковка инструментов также включает асинхронное удаление на основе задач в F# Interactive, как и в скомпилированных проектах. См. целевые платформы FSharp.Core.

Благодаря @T-Gro за современную поддержку Core и упаковку инструментов SDK.

Асинхронная среда выполнения для авторов конструктора

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

let readManifest (path: string) =
    runtimeTask {
        let! text = System.IO.File.ReadAllTextAsync path
        return text.Length
    }

Note

Этот экспериментальный конструктор использует возможность F# 11.2, в которой его встроенная реализация определяется или разворачивается; в SDK .NET 11.0.100 включите поддержку предварительных версий. Для нее также требуются сборка и ссылки Core 11 net10.0, а также среда выполнения с поддержкой runtime-async. Проверенная здесь комбинация среды выполнения и целевой платформы — это .NET 11 RC1. Ориентации только на .NET 10 недостаточно. См. эксперименты с асинхронностью в среде выполнения.

FSharp.Core не поставляет этот пример как универсальный построитель. Обычный task и async код не переключается на него автоматически, а вызывающие функции уже скомпилированного ОБЫЧНОГО API возврата задач не требуют предварительного просмотра.

Благодаря @majocha для асинхронной среды выполнения.

Текст и двумерные массивы, совместимые с Native AOT

Для поддержки Native AOT F# 11 удаляет форматирование на основе рефлексии и требования к динамическому выделению памяти для более распространенных операций с текстом и массивами.

Если у вас уже есть строка, вам не нужен "%s" только формат для записи. Новые функции Core print и printn принимают значения через преобразование F# string; printn добавляет новую строку:

let report (processed: int) (total: decimal) (elapsed: TimeSpan) : string =
    $"{processed} items, total {total:F2}, elapsed {elapsed.TotalMilliseconds:F0} ms"

printn (report 12 145.50m (TimeSpan.FromMilliseconds 250.0))
// 12 items, total 145.50, elapsed 250 ms

Интерполяция строк использует прямое преобразование и String.Concat в большем числе случаев. .NET-заполнители формата, такие как {total:F2}, используют инвариантное форматирование и упаковывают значение; %A и формы printf с указанием точности по-прежнему могут требовать форматирования printf. Выберите явную культуру для локализованных выходных данных.

С использованием --reflectionfree для записей и объединений также генерируется вывод ToString. Примеры, связанные с языком и региональными параметрами, и поддерживаемые пути см. в Интерполяция и Native AOT.

Спасибо @bbatsov за функции печати и @charlesroddie за реализацию интерполяции.

Сохраняйте обычные операции сетки на основе нуля

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

let grid = Array2D.init 3 4 (fun row column -> row * 4 + column)
let doubled = grid |> Array2D.map (fun value -> value * 2)
let snapshot = Array2D.copy doubled

printn $"{snapshot.GetLength(0)} x {snapshot.GetLength(1)}; last = {snapshot[2, 3]}"
// 3 x 4; last = 22

Array2D.create, init и rebase используют путь выделения на основе нуля в сборках Core. map mapiСохранение границ и copy использование более нового API выделения .NET в сборкеnet10.0; .NET стандартные сборки сохраняют резервные копии. Массивы с ненулевой индексацией не считаются устаревшими, но их собственные ограничения на выделение остаются.

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

Спасибо @T-Gro за улучшения Array2D.

Меньше удерживаемой памяти и повторной работы

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

Совместное использование ссылочных библиотек в проектах

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

В PR, реализующем эксперимент по удержанию памяти, решение ReSharper.FSharp из десяти проектов сократилось с 1 547,2 МБ до 1 002,2 МБ при включенном совместном использовании. Это измерение в режиме выкл./вкл. для этой рабочей нагрузки, а не общее сокращение F# с 10 до 11. Решения, где преобладают ссылки на проекты, а не общие ссылки на двоичные файлы, могут давать значительно меньший прирост.

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

Спасибо @auduchinok за поддержку импорта общих библиотек.

Компиляция независимой работы параллельно по умолчанию

Параллельная компиляция теперь включена по умолчанию с детерминированными выходными данными и правилами порядка источников. Улучшения кэширования перегрузки и охраняемого шаблона позволяют избежать другой повторяющейся работы. Visual Studio/FCS также сокращают анализ неизменённых документов и ограничивают количество параллельных проверок; см. Производительность инструментов.

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

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

let inline foldItems
    ([<InlineIfLambda; OptimizeClosureIfNotInlined>] folder)
    state
    (items: 'T[]) =
    let mutable total = state
    for item in items do
        total <- folder total item
    total

Другие назначения могут сохраниться. Сведения о требованиях см. в статье Оптимизация каррированных обратных вызовов.

Пустые результаты — еще один распространенный пример успешной работы. Нагрузка, послужившая мотивом для создания FSharpLint неоднократно сочетала правила, которые не выдавали предупреждений. Ядро теперь повторно использует общие пустые массивы в большем числе операций:

let checkTitle (title: string) =
    if String.IsNullOrWhiteSpace title then [|"A title is required."|]
    else Array.empty

let warnings = ["Introduction"; "Installation"] |> List.toArray |> Array.collect checkTitle
let sharedEmpty = obj.ReferenceEquals(warnings, Array.empty<string>)
// true: no new empty result array

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

Благодаря @T-Gro за колбэки для встроенных коллекций и общие пустые массивы.

Более точные вызовы API и повседневные удобства

Требовать указания имён, если порядок генерируемых параметров может измениться

Чтобы защитить созданные API от изменений автоматического порядка параметров, автор может требовать именованные аргументы:

Note

Принудительное применение правил — это функция F# 11.2; в пакете SDK .NET 11.0.100 включите предварительные возможности. Также требуется наличие доступного определения System.Diagnostics.CodeAnalysis.RequireNamedArgumentsAttribute. Полный пример содержит определение совместимости для ссылок, в которых оно отсутствует.

open System.Diagnostics.CodeAnalysis

type Query =
    [<RequireNamedArguments>]
    static member Execute(numerator: decimal, denominator: decimal) =
        numerator / denominator

let ratio = Query.Execute(numerator = 3m, denominator = 4m)
// 0.75M; Query.Execute(3m, 4m) is rejected.

Изменение порядка элементов в объявлении сохраняет результат именованного вызова, 0.75M; вызов с позиционными аргументами отклоняется. В случае использования сгенерированного API поставщик должен выдать аннотацию.

Распознать уже упакованный универсальный аргумент

Чтобы избежать оборачивания существующей задачи в простое значение результата, новое правило перегрузки, применяемое в крайнем случае, отдает предпочтение Task<'T> перед простым 'T:

Note

Правила выбора наиболее конкретной перегрузки и приоритизации перегрузок относятся к возможностям F# 11.2; в пакете SDK .NET 11.0.100 включите предварительную версию.

open System.Threading.Tasks

let pending = Task.FromResult 42
let wrapped = ValueTask<_>(pending)
let answer: int = wrapped.Result
// ValueTask<int>, not ValueTask<Task<int>>

Результатом является ValueTask<int>, а не ValueTask<Task<int>>. Сведения об ограничениях правила см. в "Выбор перегрузки".

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

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

open System.Runtime.CompilerServices

type Check =
    static member That(condition: bool) =
        if condition then "OK" else "Check failed."

    [<OverloadResolutionPriority(1)>]
    static member That(condition: bool, [<CallerLineNumber>] ?line: int) =
        if condition then "OK" else $"Check failed at line {defaultArg line 0}."

let stock = 0
let diagnostic = Check.That(stock > 0)
// The diagnostic includes the caller's source line.

Без изменений, вызов выбирает перегрузку, учитывающую строки. См. "Выбор перегрузки" для правил совместимости и области.

Использование условных гарантий допуска значений NULL в .NET

Если путь к файлу не равен NULL, допускающий NULL результат API Path не должен вынуждать выполнять проверку, которую исключает его собственный контракт. F# 11 использует существующее обещание .NET NotNullIfNotNull: ненулевое именованное входное значение подразумевает результат, отличный от NULL.

open System.IO

let fileName: string = Path.GetFileName("reports/annual.csv")
let extension: string = Path.GetExtension(fileName)
printn (extension.ToUpperInvariant())
// .CSV

let fileNameOf (path: string | null) : string | null =
    Path.GetFileName path

Включите <Nullable>enable</Nullable>. Для входных данных, допускающих значение NULL, по-прежнему требуется обработка значений NULL; см. сведения о контрактах условного возврата. Для API C# без ограничений T? используйте отдельный Unchecked.withNull вспомогательный модуль.

Спасибо @kerams за поддержку условной допускаемости null.

Небольшие языковые улучшения

Чтобы вызвать тип записи с синтаксисом конструктора .NET, используйте конструкторы записей F# 11.2:

type Person = { Name: string; Age: int }
let person = Person(Name = "Ada", Age = 36)

Именованные аргументы позволяют избежать зависимости от порядка полей. Спасибо @charlesroddie за эту функцию.

Для условного источника #elif позволяет избежать вложенных групп #if:

#if EU_WAREHOUSE
let dispatchTimeZone = "Europe/Paris"
#elif US_WAREHOUSE
let dispatchTimeZone = "America/New_York"
#else
let dispatchTimeZone = "UTC"
#endif

System.Console.WriteLine(dispatchTimeZone)

Первая совпавшая ветвь срабатывает. Пример проекта определяет EU_WAREHOUSE и печатает Europe/Paris; если ни один из символов не определен, выходные данные: UTC.

Для связанных средств и сценариев интероперабельности:

Перед обновлением

При обновлении учитывайте следующие существенные изменения:

Затронутый код Action
Библиотеки с .fsi файлами Копируйте атрибуты принудительно применяемого контракта в сигнатуры; их отсутствие теперь вызывает ошибку FS3888.
Встроенные библиотеки с поддержкой расширения Просмотрите выведенные сигнатуры и требования производителя/потребителя. В F# 11.2 механизм вывода типов может сохранять более общие ограничения.
Делегаты методов экземпляра Проверяйте объекты-получатели перед непосредственным созданием, так как теперь возникают ошибки при null-объекте-получателе.
Локализованный интерполированный вывод Заполнители формата .NET используют инвариантную культуру. Выберите явно указанную культуру при назначении локализованных выходных данных.
Собственные билдеры с final do! Добавлены привязка и завершение единицы трансляции; do! final больше не выбирает члены, выполняющие перенаправление.
Незавершено let! Добавьте следующее тело вычисления; незавершенная привязка теперь имеет более прямую диагностику.
Предыдущие языковые настройки Поддерживаемые нумерованные режимы начинаются с F# 8; нестрогий режим отступов удаляется. См. Версия языка и отступы.
Побитовые операции с перечислениями Используйте целочисленные перечисления; недопустимые базовые типы отклоняются.