为什么使用 F# 进行脚本编写和自动化?
摘要
本文解释了为什么 F# 是脚本编写和自动化任务的绝佳选择,重点介绍了其有益的类型系统和自然的管道操作符,这些使代码可预测且可靠。
<p><a href="https://lobste.rs/s/yvm1dh/why_use_f_for_scripting_automation">评论</a></p>
查看缓存全文
缓存时间: 2026/05/15 10:59
# 为什么用 F# 写脚本和自动化? | ian erik varatalu
来源:https://iev.ee/blog/why-use-fsharp/
我写 F# 已经有一阵子了,而且经常回到用它来做脚本和自动化任务。不是因为流行(它绝对不流行),而是因为它确实让我的生活更轻松。下面我来解释原因。
## 类型系统真的有帮助
大多数脚本语言给你自由,但也带来运行时的意外。根据我的经验,大多数 bug 其实不在代码本身,而是来自于**周围世界的变化**。数据格式变了、协议变了、依赖变了——地面不稳定。
用 F#,你可以重看几年前的脚本,并且确信它要么还能用,要么在一分钟内你就能知道哪部分需要更新。而且“马上能用”也不是我们想要的——如果世界变了,脚本也该变。如果出了错,你会得到一个**红色波浪线**作为**反馈**,而不是静默失败,甚至更糟——数据损坏。
在最近的 LLM 热潮中,你的编辑器会把这些错误反馈给 LLM,这是一个重要的现实检查,让它能自己把事情做对。
## 管道用起来很自然
管道操作符(`|>`)认知负荷非常低。你从左到右、从上到下阅读,数据以相同的顺序流动。你可以**忘掉其他所有东西**,只关注两件事:
- **我现在有什么** `|>` **下一步我想要什么**
而在 Python 中,你真的不知道数据是往**左**、往**右**、往**上**、还是往**下**流动。看看这段小代码——不用理解它,只需试着搞清楚数据往哪流:
```python
b = [(0,1), (1,2), (2,3)]
c = [ (lambda p: ((u := p[0], v := p[1])[1]) if (p[0] % 2 == 0) else ((lambda w: (w[0] * 10, w[1] * 10))(p))[0] )(q) for q in ((lambda x: (x[0] * 2, x[1] * 2))(x) for x in b) ]
```
第一行我们定义了一些数据,还不错。但第二行,我们得搞清楚数据往哪去了:是进入 `q`、`p`、`w` 还是 `x`?进入内层 lambda 还是外层 lambda?进入列表推导式还是生成器表达式?是从左到右还是从右到左?从上到下还是从下到上?文件底部有没有定义某个函数改变这段代码的行为?
你得同时在大脑里做这么多体操——读不懂这个挑战也没关系。关键是,我喜欢有保障和可预测性的安全网。而 Python 对我来说读起来像希伯来语——既因为它从右到左,也因为我不会希伯来语。
上面是个极端的例子,但不可预测的数据流的思想是内建在 Python 核心特性和**思维模式**中的。你只是按随机顺序写东西。
特别要提一下深度嵌套的三元表达式,`then-if-else-then-if-else` 这样子。我在实际代码中见过很多,我相信你也见过。三元表达式在 C 系语言里也存在,你甚至可能说它们很好(比如倾向于用`表达式`而不是`语句`),但问题是这种解决方案是打补丁在现有的、并非从一开始就为它们设计的语言之上。
管道的思想早在 ML 和 Unix 早期就有了,F# 在 2000 年代初开始推广 `|>`,从那时起许多语言都采用了这种风格,包括 OCaml、Elixir、Julia、R、Elm,也许将来甚至会出现在 JavaScript 中(https://github.com/tc39/proposal-pipeline-operator)。
在管道风格中,上面的代码永远不会让你回头看。当到达下一个 `|>` 时,你可以完全忘记前一行。数据是怎么来的根本不重要。
```fsharp
[ (0, 1); (1, 2); (2, 3) ]
|> List.map (fun (x, y) -> (x * 2, y * 2))
|> List.map (fun (x, y) -> if x % 2 = 0 then y else x * 10)
|> printfn "the result is %A"
```
现在考虑一个查询天气预报、获取每日最高最低温度并打印出来的例子:
```fsharp
// weather.fsx
#r "nuget: fsharp.data, 6.6.0"
open FSharp.Data
[<Literal>]
let API_URL = """https://api.open-meteo.com/v1/forecast?latitude=59.4165&longitude=24.7994&hourly=temperature_2m"""
type api = JsonProvider<API_URL>
let api_response = api.Load(API_URL)
let temps_by_date =
Seq.zip api_response.Hourly.Time api_response.Hourly.Temperature2m
|> Seq.groupBy (fun (time, _) -> time.Date)
temps_by_date
|> Seq.iter (fun (date, times_and_temps) ->
let date_formatted = date.ToString("yyyy-MM-dd")
let temps_only = times_and_temps |> Seq.map (fun (time, temp) -> temp)
let daily_highest = temps_only |> Seq.max
let daily_lowest = temps_only |> Seq.min
stdout.WriteLine $"{date_formatted}: {daily_lowest}C {daily_highest}C")
```
这里有很多让代码用起来舒服的地方:
1. 脚本**包含了运行所需的所有代码**,包括依赖和版本。你可以把它保存到任何平台或架构的任何位置,然后用 `dotnet fsi weather.fsx` 运行,它会输出天气预报(如下所示)。把它作为一次性脚本复制到服务器上运行。它是一个自包含的单元——我怎么强调这一点都不为过。
```
❯ dotnet fsi weather.fsx
2026-02-03: -9.5C -6.5C
2026-02-04: -17.1C -8.2C
2026-02-05: -18.8C -10.0C
2026-02-06: -15.0C -8.9C
2026-02-07: -11.4C -7.9C
2026-02-08: -10.9C -7.5C
2026-02-09: -13.0C -7.6C
```
2. 你可以逐行执行,边执行边检查值。忘记格式化了?想要 65536*8 的计算器功能?想要查看完整的 `api_response`?只需在该行上按 `Alt+Enter`,就可以看到下一步管道将处理的数据是什么:

3. 每一步都是强类型的,无需注解。如果你把 `#r "nuget: fsharp.data, 6.6.0"` 改成 `#r "nuget: fsharp.data, 6.5.0"`,并且库发生了破坏性变更,它会立即指向出错的地方。如果 API 端点变化,温度字段被重命名,编辑器会立即通知你。而且有哪些字段可用?你甚至不需要看响应——编辑器会为你自动补全。无缝、即时、好用。

这些字段是从哪来的?`JsonProvider`(第 9 行)根据 URL **自动生成它们**——在你打字的时候,它会在后台查询 API 一次。你可以把它指向很多不同的来源:本地 JSON 文件、CSV 文件、XML 文件、Excel 文件、实时数据库连接等等。它是一个个人代码生成器,总是知道最新的模式,不会产生幻觉。在 F# 中,这叫做“类型提供器”(type provider),是 F# 在脚本编写方面的一个杀手级特性。
## 语言的效用与用户数量成指数关系
F# 运行在 .NET 上,这意味着你可以访问一个庞大的库和工具生态系统。需要一个快速的 HTTP 服务器?aspnetcore*(如果你想从一个 .fsx 脚本运行一个完整的 aspnet 服务器,那么你可以使用 TheAngryByrd/IcedTasks(https://github.com/TheAngryByrd/IcedTasks/blob/master/generate-sdk-references.fsx)中的脚本来生成所有依赖列表)。需要为你的应用做身份验证?所有主要提供商都有 `OAuth2` 库可用。需要处理图像、音频、视频或与数据库交互?都有库可用。
而且因为 F# 编译成与 C# 相同的 IL,你可以使用几乎任何 .NET 库,性能与 C# 相同,在许多任务上比 Python 快 20 倍以上。另一个优势是 C# 拥有一个庞大且维护良好的标准库,F# 可以直接使用,所以很多应用甚至可以不需要第三方库。
## 性能
F# 不是解释型语言,即使感觉像。当你在 fsi 中运行一行代码时,该行代码会使用与 C# 相同的优化 JIT 编译器即时编译成机器码。我不会说它“和 C 一样快”——但这不是这里的竞争点。如果你想要绝对最高的性能,那么你应该写一个 C 程序,甚至可能用汇编手动优化。再多的 .NET 魔法也打不过它。但是用更快的语言写东西不会像 F# 那样流畅,你被迫考虑内存在哪里重用、生命周期、所有权等,最重要的是,没有像 fsi 这样的交互式开发体验。起步的成本更高。
F# 让你用**10% 的代码**获得**本机性能的 50%**。F# 的真正竞争对手是 Python、bash 和其他脚本语言,在这方面,F# 在 CPU 密集型任务上会把它们远远甩在后面,同时写起来同样简单,并且**更容易维护**。
精心编写的 F# 脚本可以在许多任务上媲美本机代码的性能,但代价是要写看起来更像“F# 口味的 C”的东西,以及笨重的互操作层。我在研究工作中对许多算法做过基准测试,F# 通常与 C 或 C++ 实现相差 2 倍以内。不可避免的性能损失的主要原因是默认值更差,例如 UTF-16 字符串而不是 UTF-8,这可以通过在需要时切换到 `Span` 等类型来缓解。至于垃圾回收和内存分配,关键在于预先分配所有内容并重用缓冲区,这是高性能 .NET 代码的常见实践。
## 启动时间
应用启动时间是使用 .NET 的一个主要缺点。冷启动可能需要一秒钟,这对于脚本来说不理想。记得以前运行 Visual Studio 2015 的一个项目,启动要 2 分钟以上——首先要编译编译器本身,然后编译所有库,最后才运行实际的应用。每次启动应用都要这样。
幸运的是,现代 .NET 有两个解决方案,可以让你获得比脚本语言更快的启动时间,但不如像 C 这样的编译二进制文件快。
第一个选项是 `ReadyToRun` 编译,它预先编译了框架和库的大部分内容,这样 JIT 在运行时的工作量更少。你可以通过将脚本转换为项目并传递 `-p:PublishReadyToRun=true` 给 `dotnet publish` 来启用它。这可以将许多应用的启动时间降至几乎不可察觉的水平。
第二个解决方案是使用 `NativeAOT` 编译成真正的本机代码,完全去掉 JIT。目前这对 F# 来说还有点粗糙,因为某些特性不被支持(反射、动态代码生成等),但对于不需要这些特性的任务来说,它是一个很好的选择。你可以通过传递 `-p:PublishAot=true` 给 `dotnet publish` 来启用它。NativeAOT 的启动时间接近本机二进制文件。
第三个选项,虽然支持度较低,是 `fflat`(https://github.com/ieviev/fflat),或者当 `fsnative`(https://github.com/FidelityFramework/Firefly)成熟时,它将直接将 F# 代码编译成本机代码,完全绕过 .NET 运行时,这会给你最好的性能,但代价是失去了对 .NET 生态系统的访问。我本人是 `fflat` 的作者,我将其用于小型工具和命令行程序——当我已经有了一个可用的 F# 脚本并想把它变成一个独立二进制文件时。`fsnative` 更雄心勃勃,旨在将 F# 移植到 LLVM,这将给你更广泛的平台支持和更好的优化机会,但它仍处于早期阶段。
### 从脚本到本机二进制文件
作为一个小型的本机示例,fflat 可以把我们示例中的天气脚本变成一个完全不依赖 dotnet 的本机二进制文件。但注意,一些 .NET 库,包括例子中 JsonProvider 使用的 JSON 解析器,在编译时会产生大量 AOT 分析警告,因为它们在底层使用了反射。(而且因为 `api.Load` 调用链接了 brotli 和 libz 进行解压,我还确保它们已安装)

但这些是**警告**,不是**错误**——最后我们仍然得到了一个可用的本机二进制文件,它动态链接到 libc 和其他一些本机库(如下)。

正如你所料,这个本机二进制文件的运行方式与脚本一样,但启动时间更快,内存使用更少,并且不需要在机器上安装 .NET。

由于 FSharp.Data 库使用了反射、printfn、泛型递归以及其他对 AOT 不友好的特性,二进制文件的大小比理想情况稍大,但对于一个本机二进制文件来说仍然合理(9MB)。如果再注意避免反射和动态特性,二进制文件大小可以进一步减小。作为对比,一个用 fflat 编译的最小 “hello world” F# 程序生成的二进制文件约为 1MB,或者如果你愿意完全去掉标准库,`bflat`(https://flattened.net/)编译器(`fflat` 在其基础上运行)可以生成仅有几 KB 的二进制文件,甚至可以在裸机上运行(!!)。
F# 生态系统已经成熟多年了。**十年前**,C# 的首席设计师兼 .NET 架构师在 .NET 语言策略(https://devblogs.microsoft.com/dotnet/the-net-language-strategy/#f#)中说过:
> ……我们将通过改善语言和工具体验、消除贡献障碍、解决痛点、缩小与 C# 和 VB 的体验差距,使 F# 成为市场上**工具链最好的函数式语言**。随着 C# 中出现新的语言特性,我们将确保它们也能与 F# 良好互操作。F# 将继续面向对其社区重要的平台。
十年后的今天,我认为这是真的。也许在语言特性方面,它仍然需要 `readonly static` 和 `allows ref struct` 才能与 C# 完全持平,但在工具方面,它确实做到了。我尝试过很多函数式语言,F# 的工具链体验更接近 C# 和 Java,而不是 Haskell 或 OCaml。
显然 F# 绝对不是微软的绝对优先项——你可以对大学里的 Microsoft Teams 选项卡占用 1.1GB 内存(真的)以及他们如何忙于推动“软件即垃圾”大加批评(我恨死了 Teams,它真是个垃圾,我觉得没什么人在全面改进它);但另一方面对微软长期以来支持像 F# 这样的小众语言,我有很多感激之情,而工具链体验是其中很大一部分。F# 拥有最新的 .NET 特性,并且语言本身仍在积极开发和改进,这说明他们关心这门语言及其用户。
对我来说最佳的体验是使用 VSCode(或 VSCodium)加 Ionide,它提供自动补全、内联错误、重构工具等。VSCode 中同样的语言服务器(`FsAutoComplete`)也可以用于 Neovim、Emacs 以及我最近喜欢的 Helix。如果你更喜欢重量级 IDE,Rider 很不错,尽管启动需要一点时间,所以不太适合脚本。对于命令行,`dotnet fsi` 就是你需要的一切——它包含在 .NET SDK 中,开箱即用。Visual Studio 在我上次使用时对 F# 的支持也很好,但我已经有一段时间没用 Windows 了,所以无法评论现在的体验。
## 生态系统规模
F# 是一门小众语言。但对于脚本和自动化任务来说,生态系统规模远没有与 .NET 库互操作的能力重要。如果你需要某个特定功能的库,已经有一个 .NET 库可以让你直接从 F# 中使用。如果没有,那是 .NET 的问题,不是 F# 的问题。
对于机器学习,我仍然推荐使用 Py
相似文章
为什么 AI 智能体几乎都用 TypeScript 编写?
本文探讨了为何 TypeScript 已成为构建 AI 智能体及智能体框架的主流语言,并追问为何 Rust 或 C++ 等替代方案没有得到更广泛的应用。
一种构建自动化的新型智能体方法
一种新工具允许用户屏幕录制自己执行任务;智能体学习该任务并构建确定性脚本以自动化执行,并在脚本出错时进行回退处理。
@TheVixhal: https://x.com/TheVixhal/status/2079274210367775052
本文解释了有限状态机的概念、其正式定义,以及为何它们是构建可靠系统的强大抽象,包括它们与当前代理框架的关系。
@djfarrelly: https://x.com/djfarrelly/status/2052779234234380479
本文主张,AI Agent 的开发应基于稳定的执行原语,而非会随新兴编排模式频繁更迭的僵化框架。文章强调,采用持久化步骤、持久状态、并行协调、事件驱动流程以及可观测性设计,可有效避免因最佳实践不断演进而付出的高昂重写代价。
AI 编码代理在大型代码仓库中的导航方式让我感到沮丧,于是我开始编写一些辅助脚本
作者分享了对于AI编码代理在大型代码库中低效导航的沮丧,并描述了构建PowerShell辅助脚本来优化仓库探索并减少上下文浪费。