F# 11 中的新增功能

欢迎使用 F# 11! 此版本可帮助你明确区分领域模型、复用通用算法、协调异步任务,并降低编译器的内存占用。

感谢 F# 社区在这些更改背后的建议、实现、评审和测试。 参与者链接附带在以下内容中。

Note

这些示例需要 F# 11 编译器和 FSharp.Core 11,并且具备所示功能。 在 .NET 11.0.100 SDK 中,F# 11.2 功能需要 <LangVersion>preview</LangVersion>;请参阅 语言设置和计划中的 SDK 支持。 runtime-async 示例具有额外的运行时要求;仅有较早版本的 .NET 11 SDK 可能并未包含所有更改。

记录展开:不重复映射的不同模型

为了保持存储和应用程序模型彼此分离,记录展开可复用其共享字段,而无需重复声明。 这些模型共享客户详细信息,但使用不同的标识符类型:

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(而不是 spread)验证标识符;格式不正确的输入会产生 Error。

这解决了应用层映射问题。 值展开还可结合命名记录和匿名记录;类型展开只能创建命名记录。 后面的字段优先,各源按顺序各运行一次。 有关组合示例和共享演变风险,请参阅记录扩展运算符。

感谢 @brianrourkeboll 提供记录差值。

用于泛型算法、运算符和元组的扩展成员

若要跨现有类型共享算法,F# 11.2 允许 扩展成员满足静态解析的类型参数 (SRTP) 约束。 例如,一个转换可以处理列表和选项,而无需包装器类型。

Note

在 .NET 11.0.100 SDK 中,在解析扩展的位置启用 <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 实现从一个片段更新这两个说明。 另外,工具链解析器会公开 <inheritdoc/> 的基础描述;编译器会保留该标签,供文档使用者使用。 有关示例和解析和处理规则,请参阅 XML 文档。

感谢 FSharp.Core 中的 @T-Gro 共享 XML 文档。

在非优化生成中调试内联函数

若要检查内联帮助程序参数,请在非优化版本中,在 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。

异步和任务协调

若要使用基于任务的 API 连接 F# 工作流,FSharp.Core 11 添加了支持取消的桥接机制、有界批处理和结果转换。

受未完成 I/O 限制,而不仅仅是结果集合

大量的读取操作可以启动比应用程序应有的未完成工作量还要多。 若要确定何时开始基于任务的工时,协调器需要 工厂方法,而不是已经开始的任务:

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

最多四个读取操作并发执行;结果会保留输入顺序。 发生失败时,会通过提供的令牌请求取消同级项。 这会限制并发性,而不是每秒请求数。 有关顺序变体和返回 unit 的变体,请参阅 Async 和任务函数。

跨越边界时保持取消状态及其基础异常

当工作流启动外部工作时,其令牌应随该工作一起传输。 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 一起传递到此处理程序。 对于已创建的任务或值任务,请使用 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;缺少的文件会产生一个包含 FileNotFoundException 的 Error。 取消仍会传递。 Async 和 ValueTask 提供匹配的 map、bind 和错误辅助函数;请参阅 Async 和任务函数。 对分支或多个依赖值使用计算表达式。

ValueTask 帮助程序需要 Core netstandard2.1 或 net10.0 生成以及 单次使用。

感谢 @bartelink 对 async/task API 的贡献。

异步失败报告后重新引发

操作日志记录应添加信息,而不更改调用方捕获的内容,也不隐藏失败发生的源头。 计算表达式处理程序现在可以在等待 logger 后使用 reraise ():

Note

计算表达式 rethrow 是 F# 11.2 的一项功能。 在 .NET 11.0.100 SDK 中,启用 <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 ()
    }

成功记录日志后,相同的异常和原始引发位置信息将传播。 日志记录器故障可能会取代原始项。 这满足了异步可观测性需求,且无需运行时异步。 请参阅 在计算表达式中使用 Rethrow。

感谢 @xperiandri 的支持。

FSharp.Core 可基于现代 .NET 进行构建

若要在不放弃对较旧目标的支持的情况下使用现代 API,FSharp.Core 会< c3>同时添加和 NuGet 会选择兼容的版本。 更新后的工具打包方式还支持在 F# Interactive 中使用基于任务的异步释放,这与在已编译项目中的情况相同。 请参见 FSharp.Core 目标框架。

感谢@T-Gro提供的现代 Core 定向和SDK 工具打包。

面向生成器作者的运行时异步支持

Builder 作者可以让运行时挂起并继续执行带有标记的方法,而不是使用编译器生成的状态机。 一个由库定义的实验性构建器将该机制作为计算表达式提供出来:

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

Note

此实验性构建器使用了 F# 11.2 的一项特性,可在其中定义或展开其内联实现;在 .NET 11.0.100 SDK 中,需要启用预览功能。 它还需要 Core 11 的 net10.0 构建和引用,以及具有运行时异步支持的运行时。 此处已验证的运行时/引用组合是 .NET 11 RC1。 仅以 .NET 10 为目标还不够。 请参阅 运行时异步实验。

FSharp.Core 不会将此示例作为通用构建器提供。 普通 task 和 async 代码不会自动切换到它,并且已编译的普通任务返回 API 的调用方本质上不需要启用预览。

感谢 @majocha 对 runtime async 的贡献。

原生 AOT 友好文本和二维数组

为了支持 Native AOT,F# 11 从更常见的文本和数组操作中删除基于反射的格式和动态分配要求。

如果已经有一个字符串,则不需要使用 "%s" 格式来表示它。 新核心函数 string 和 print 通过 F# 的 printn 转换接受值;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。 诸如 {total:F2} 的 .NET 格式占位符使用固定区域性格式,并会对该值进行装箱;%A 和带精度的 printf 形式仍可能需要 printf 格式化。 为本地化输出选择显式区域性语言环境。

使用 --reflectionfree时,记录和联合也会获得生成的 ToString 输出。 有关区域性示例和支持的路径,请参阅 插值和本机 AOT。

感谢 @bbatsov 提供打印函数,以及感谢 @charlesroddie 实现了插值功能。

保留普通的从零开始的网格操作

从 0 开始索引的网格不应要求替换 Array2D 模块,仅仅为了避免动态代码分配路径。 更新的核心实现支持熟悉的初始化方式和转换:

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可在net10.0版本中使用较新的 .NET 分配 API;.NET 标准版本会保留回退方案。 不弃用非从零开始的数组,但其本机分配限制保持不变。

有关分配路径和剩余限制,请参阅 二维数组和 Native AOT。 检查完整的应用程序的发布诊断。

感谢 @T-Gro 的 Array2D 改进。

更少的保留内存和更少的重复工作

F# 11 可减少重复的编译器分析和应用程序中的某些内存分配。 这些影响不同的工作负载。

跨项目共享引用的库

为了避免重复复制共享二进制依赖项,语言服务可以跨项目共享符合条件的导入库图。

在实现该 PR 的保留内存试验中,在启用共享的情况下,10 个项目的 ReSharper.FSharp 解决方案从 1,547.2 MB 降至 1,002.2 MB。 这是针对该工作负荷的开/关测量值,而不是普遍的 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

其他分配可以保留。 有关要求,请参阅Optimize curried callbacks。

空结果是工作成功的另一种常见情况。 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 功能;在 .NET 11.0.100 SDK 中,启用预览版。 还需要一个可用的 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 功能;在 .NET 11.0.100 SDK 中,启用预览版。

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.

未作更改的调用会选择具有行感知能力的重载版本。 有关范围和兼容性规则,请参阅 重载选择 。

使用 .NET 的条件性可空性承诺

如果文件路径为非 null,则路径 API 的可为 null 结果不应强制进行其自身协定已排除的检查。F# 11 使用现有的 .NET NotNullIfNotNull 承诺:非 null 命名输入表示非 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 的处理;请参阅 条件返回约定。 对于无约束的 C# T? API,请使用单独的 Unchecked.withNull 帮助程序。

感谢 @kerams 提供条件可空性支持。

较小的语言改进

若要使用 .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 的类型推断可以保留更一般的约束。
实例方法委托 在直接构造之前验证接收器,因为现在会在此时发生空接收器故障。
本地化内插输出 .NET 格式项使用不变区域性。 当预期进行本地化输出时,请选择显式区域设置。
使用 final 的自定义构建器 do! 提供 绑定和单元补全;final do! 不再选择转发 final 的成员。
未完成 let! 添加以下计算正文;不完整的绑定现在具有更直接的诊断信息。
旧版语言设置 支持的编号模式从 F# 8 开始;已移除非严格缩进模式。 请参阅 语言版本和缩进。
枚举位运算 使用 具有整型基础类型的枚举;拒绝无效的基础类型。