F# 11 的新增功能

歡迎來到 F# 11! 此版本有助於保持領域模型的區別性、重用通用演算法、協調非同步工作,並減少編譯器記憶體的使用。

感謝 F# 社群為這些變更所付出的提案、實作、審查與測試。 以下功能附有貢獻者連結。

Note

範例需要 F# 11 編譯器與具備上述功能的 FSharp.Core 11。 在 .NET 11.0.100 SDK 中,F# 11.2 功能需要 <LangVersion>preview</LangVersion>;詳見 語言設定及計畫中的 SDK 支援。 執行時非同步範例有額外的執行時需求;僅靠早期的 .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 提供欄位宣告。 在邊界處,會複製值,而轉換則解析識別碼:

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

在 .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 都是靜態解析型態。 在每次具體呼叫時,編譯器會選擇具備所需簽名的成員。 這些擴充功能並沒有新增介面實作。 完整應用程式會開啟兩個模組。

運算子、上下文與輸出類型

同樣機制也支援 元組運算子、上下文傳播 及 輸出導向建構。 原生元組標頭需要在其宣告處以及進行 extension-solving 的呼叫位置啟用 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 Minimal APIs 接著即可讀取路由、服務及標頭合約:

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 文件說明,了解範例與解析規則。

感謝 @T-Gro 分享 FSharp.Core 中的 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 控制項。

非同步與任務協調

為了將 F# 工作流程與基於任務的 API 連接起來,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

最多同時進行四個讀取作業;結果會保留輸入順序。 失敗時會要求透過提供的 token 取消同層級的作業。 這限制的是並行性,而非每秒請求數。 關於循序和 Unit 結果變體,請參閱 非同步與任務函數。

跨越邊界時保留取消狀態及其底層例外

當工作流程啟動外部作業時,其權杖應隨該作業一同傳遞。 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 和錯誤輔助工具;參見 非同步與任務函數。 對於分支或多個相依值,使用計算運算式。

ValueTask 助手需要核心 netstandard2.1 或 net10.0 建構和 單一消費。

感謝 @bartelink 對 async/task API 的貢獻。

非同步失敗回報後重新引發(例外)

營運日誌應該在不改變呼叫者所捕捉到的例外或隱藏故障起點的情況下,新增資訊。 計算表達式處理器現在即使在 await logger 後仍能使用 reraise ():

Note

Computation-expression 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 ()
    }

在成功記錄之後,相同的例外狀況和原始拋出位置資訊會繼續傳遞。 記錄器故障可能會取代原始項目。 這解決了非同步可觀測性請求的問題,而無需執行時的非同步。 參見 在計算運算式中重新擲回。

感謝 @xperiandri 提供這份支持。

FSharp.Core 可在現代 .NET 上建置

為了在不放棄較舊目標的情況下使用現代 API,FSharp.Core 會加入 net10.0,與 netstandard2.0 和 netstandard2.1 並列。 NuGet 會選擇相容的建置。 更新的工具封裝也支援 F# Interactive 中的任務型非同步釋放,類似編譯專案。 參見 FSharp.Core 目標架構。

感謝@T-Gro在面向現代 .NET 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 組建和參考,以及支援 runtime-async 的執行階段。 這裡驗證的執行階段/參考程序集組合是 .NET 11 RC1。 僅以 .NET 10 為目標並不足夠。 參見 執行時非同步實驗。

FSharp.Core 並未將此範例作為通用建構器隨附提供。 普通 task 和 async 程式碼不會自動切換到它,而已編譯好的普通任務回傳 API 的呼叫者本身也不需要預覽功能。

感謝 @majocha 提供 runtime async 功能。

原生 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 輸出。 請參見 插值與原生 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 標準建置則保留其備援。 非零基礎陣列不會被棄用,但其原生配置限制依然存在。

關於配置路徑與剩餘限制,請參見 二維陣列與原生AOT 。 請檢查完整應用程式的發佈診斷資訊。

感謝 @T-Gro 對 Array2D 的改進。

保留的記憶體較少,重複工作也較少

F# 11 減少重複的編譯器分析及部分應用程式配置。 這些會影響不同的工作量。

跨專案共享引用的函式庫

為避免重複複製共享的二進位相依關係,語言服務可在專案間共享合格的匯入函式圖庫圖。

在 實作 PR 的保留記憶體實驗中,十個專案的 ReSharper.FSharp 方案在啟用共享功能後,記憶體用量從 1,547.2 MB 降至 1,002.2 MB。 這是該工作負載的開/關測量值,不是一般從 F#10 降至 F#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 的驅動工作負載反覆合併了未發現任何警告的規則。 Core 現在在更多操作中重用共享的空陣列:

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>>。 關於規則的限制,請參見 Overload selection。

優先採用新的多載版本,不要移除舊的入口點

函式庫可能會保留舊有的診斷方法來處理現有二進位檔,但會加入包含呼叫者來源位置的多載。 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,Path 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>。 可空輸入仍需可空處理;參見 條件式傳回合約。 對於未受條件約束的 C# T? API,請使用獨立的 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 的型別推斷可保留較一般性的條件約束。
執行個體方法委派 在 直接建構前驗證接收器,當現在會發生空接收器失敗時。
本地化插值輸出 .NET 格式的佔位符使用不變文化特性。 當意圖在地化輸出時,選擇明確指定的地區設定。
帶有 final 的客製化建構器 do! 提供 繫結與單元完成; 最終 do! 不再選取 final 轉送成員。
未完成 let! 加入以下計算體;不完全繫結現在有更直接的診斷方式。
先前的語言設定 支援的編號模式自 F# 8 起;非嚴格縮排模式已移除。 參見 語言版本與縮排。
Enum 位元運算 使用 底層型別為整數的列舉;無效的基礎型別將遭拒絕。