我最喜欢的编程语言特性:Pipelining
MOND←TECH MAGAZINE
这是一个网站,这意味着它有时会离线 menu
我最喜欢的编程语言特性:Pipelining
独立的函数调用语法被认为有点次优。
作者:Mond
认知状态: 别太当真。 或者当真也行。 我不知道,我阻止不了你。
流行文化引用。
切换原始/抖动图像
Pipelining 也许是我最喜欢的编程语言特性。
什么是 Pipelining? Pipelining 是一种特性,它允许你从参数列表中省略单个参数,而是传递前一个值。
当我说 Pipelining 时,我指的是能够编写像这样的代码:
fn get_ids(data: Vec<Widget>)-> Vec<Id>{data.iter()// 获取列表元素的迭代器
.filter(|w|w.alive)// 使用 lambda 忽略已删除的 widgets
.map(|w|w.id)// 从 widgets 中提取 ids
.collect()// 将迭代器组装成数据结构 (Vec)
}
而不是像这样的代码。(这不是真正的 Rust 代码。给好奇的 Rustacean 的一个小挑战,你能解释一下为什么我们不能像这样重写上面的代码,即使我们导入了所有的符号?)
fn get_ids(data: Vec<Widget>)-> Vec<Id>{collect(map(filter(iter(data),|w|w.alive),|w|w.id))}
我真的觉得这应该是不言而喻的,甚至不应该被讨论。第一个代码示例——它那漂亮的‘Pipelining’或‘方法链’或随便你想怎么称呼它——它就是好用。 它可以逐行阅读。 很容易用注释对其进行注释。 它不需要引入新的变量来提高可读性,因为它本身已经具有可读性。
与...相比,你知道的,描述我们的函数执行的最终操作的行的第一个单词。
让我非常清楚地说明:这是一篇关于语法的 ~~文章~~ 犀利评论。 实际上,语义每天都胜过语法。 换句话说,别太当真。
其次,这与命令式编程与函数式编程无关。 本文默认你已经熟悉诸如‘map’和‘filter’之类的概念。 可能会过度使用这种风格,但我不会在这里讨论它。
你已经同意我的观点
这是一个现代编程语言中非常标准的功能,它几乎感觉不像一个功能。 使用我们心爱的朋友 .
运算符访问结构或类的成员。
这是一种 Pipelining 形式。 它将数据放在第一位,运算符放在中间,最后是操作(限制为成员字段)。 这就是我所说的 Pipelining 的一个实例。
type Bar struct {
field int
}
func get_field(bar Bar) int {
return bar.field
}
// 与 Python 的 `getattr` 函数的语法相比
func get_field(bar Bar) int {
return getattr(bar, "field")
}
你明白我的意思了吗? 这是相同的原理。 x.y
风格的成员访问语法(以及 x.y()
风格的方法调用语法!)流行的原因之一是因为它易于阅读且易于链接。
让我们使比较稍微公平一些,并假设我们必须编写 x.get(y)
。 比较:
fizz.get(bar).get(buzz).get(foo)
// vs.
get(get(get(fizz, bar), buzz), foo)
哪一个更容易阅读? 显然是 Pipelining 语法。 无论哪种方式,这个例子都很容易解析,但想象一下你想淡化一些信息并纯粹关注最终操作。
<previous stuff>.get(foo)
// vs.
get(<previous stuff>, foo)
你看到问题了吗? 在第一个例子中,我们有‘所有先前的部分’,然后 对其应用另一个操作。 在第二个例子中,我们想要执行的操作 (get
) 和新的操作数 (foo
) 被散布开来,而‘所有先前的部分’位于它们之间。
回顾我们的原始示例,问题应该很明显:
fn get_ids(data: Vec<Widget>)-> Vec<Id>{collect(map(filter(iter(data),|w|w.alive),|w|w.id))}-----------------------------1// 解析整行以找到开头很有趣
------------------------2-----------------3---------------------------------------4// 一路往回找到第二个参数
-------------5------------------------------------------------------6// 然后再次一路往回
-----7// 好的,最后一步是该行中第一个有意义的词
我不能否认这些指控:只要存在明显更好的选择,我就不认为像这样编写代码是有意义的。
为什么我必须解析整行才能弄清楚我的输入来自哪里,以及为什么数据流是‘从内到外’的? 如果你问我,这有点傻。
编辑优势
试图在 Python 中使用 Pipelining 语法的体验。
切换原始/抖动图像
可读性很好,我可以添加一整节来抱怨 Python 的‘函数’功能造成的混乱。
但是,让我们退一步谈谈编辑的容易程度。 回到上面的例子,想象一下你想在中间添加另一个 map
(或任何其他函数调用)。 这有多容易?
fn get_ids(data: Vec<Widget>)-> Vec<Id>{collect(map(filter(map(iter(data),|w|w.toWingding()),|w|w.alive),|w|w.id))}
考虑:
- 你必须解析该行,计算逗号和括号以找到添加右括号的确切位置。
- 这样的
git diff
基本上是无法读取的,所有内容都挤在一行上。 - 这行代码越来越长且难以阅读,到那时你无论如何都想重构它!
fn get_ids(data: Vec<Widget>)-> Vec<Id>{data.iter().map(|w|w.toWingding()).filter(|w|w.alive).map(|w|w.id).collect()}
这是添加一行代码。 无需计算括号。 简单明了。 易于编写且易于审查。 也许最重要的是,它在任何你正在使用的编辑器或代码浏览工具的 blame
层中都_非常好地_显示出来。
你可能认为这个问题_只是_关于试图将所有内容都塞进一行,但坦率地说,试图摆脱它并没有多大帮助。 它仍然会搞砸你的 git diff 和 blame 层。
你当然可以将每个 filter
和 map
调用的结果分配给一个辅助变量,我会(不情愿地)承认这有效,并且_明显_好于试图进行荒谬的嵌套级别。
代码发现
当你在你的 IDE 中按下 .
时,它会显示一个整洁的小弹出窗口,告诉你你可以调用哪些方法或可以访问哪些字段。
这可能是价值最高的单个 IDE 功能,如果不是的话,至少是使用频率最高的。 有些人会告诉你,在 AI 自动完成和氛围编码的时代,用于命名空间或模块级代码发现的静态分析毫无用处,但我非常不同意。1
一位智者曾经说过:
“格鲁格非常喜欢类型系统,使编程更容易。 对于格鲁格来说,类型系统最有价值的是,当格鲁格在键盘上点击点时,格鲁格可以做的事情列表神奇地弹出。 这对于格鲁格来说占类型系统价值的 90% 或更多”——格鲁格
值得遵循的话。 他在这里描述的是本质上_需要_ Pipelining 才能工作的东西。(以及类型或类型注释,但拥有这些是行业发展的方向。)
无论是值得信赖的 .
运算符、C++ 的 ->
,还是更定制化的东西,例如 Elm 或 Gleam 的 |>
或 Haskell 的 &
。 归根结底,它是一个管道运算符——相同的原理适用。 如果你的 LSP 知道左侧的类型,原则上它_应该_能够提供有关下一步做什么的建议。
如果你的首选语言的 LSP/IDE 在 Pipelining 期间提供建议方面做得不好,那么可能是以下原因之一:
- 你甚至不知道自己持有哪种类型。 当语言是动态类型的,静态分析很难推断出‘类型’,并且你在没有类型注释的情况下接触/编写代码时,这种情况最常发生。 (例如 Python)
- 生态系统和 LSP 只是没有投入足够的时间,或者大多数活跃用户不够关心。 (例如,任何足够晦涩的语言)
- 你处于一种即使查找哪些方法可用也很困难的情况,这通常是由于定制的构建过程会使编辑器感到困惑。 (例如,基本上任何代码的构建或运行时生成,或库的定制加载/选择)。
无论哪种情况,出色的编辑器/LSP 支持或多或少被认为是现代编程语言的强制性要求。 当然,这就是 Pipelining 发光的地方。
询问任何 IDE,自动完成 fizz.bu... -> fizz.buzz()
比自动完成 bu... -> buzz(...)
容易得多,原因很明显,因为在第二个示例中你_甚至没有编写 fizz
_,因此你的编辑器拥有的信息较少。
SQL
Pipelining 在数据处理方面_非常出色_,它允许你将通常使用‘由内而外’控制流编写的代码转换为‘逐行’转换。
在 SQL 中,这可能比任何地方都更清楚,SQL 大概是用于查询和聚合复杂的大规模数据集的最重要的语言?
你很高兴听到,是的,实际上人们正在努力将 Pipelining 引入 SQL。(它是否真的会以这种特定形式发生是一个不同的问题,让我们不要太得意忘形。)
除非你是那些花费大量时间处理 SQL 以至于它已成为第二天性的人之一,并且认为对于普通非数据库工程师来说,嵌套查询的控制流难以理解的想法对你来说是难以理解的,我猜。
就我个人而言,我是粉丝。 无论如何,如果你有兴趣,请收听这段在 HYTRADBOI 2025 上发表的十分钟谈话。
为了方便起见,我将把他们关于如何简化标准嵌套查询的示例放在这里:
SELECTc_count,COUNT(*)AScustdistFROM(SELECTc_custkey,COUNT(o_orderkey)c_countFROMcustomerLEFTOUTERJOINordersONc_custkey=o_custkeyANDo_commentNOTLIKE'%unusual%'GROUPBYc_custkey)ASc_ordersGROUPBYc_countORDERBYcustdistDESC;
与她告诉你不用担心的 SQL 语法相比:
FROMcustomer|>LEFTOUTERJOINordersONc_custkey=o_custkeyANDo_commentNOTLIKE'%unusual%'|>AGGREGATECOUNT(o_orderkey)ASc_countGROUPBYc_custkey|>AGGREGATECOUNT(*)AScustdistGROUPBYc_count|>ORDERBYcustdistDESC;
更少的嵌套。 更符合其他语言和 LINQ。 可以轻松逐行阅读。
这是一个更怀疑的声音(警告,LinkedIn!)。 Franck Pachot 提出了一个很好的观点,即查询顶部的 SELECT
语句(本质上)是其函数签名,并指定了返回类型。 使用管道语法,你会丢失一些可读性。
我同意,但这似乎是一个可以解决的问题。
Builder 模式
一张中间放了一些管道的照片,以打破文本并使文章不那么单调。
切换原始/抖动图像
在四人帮的 设计模式 中,builder 模式 是一种并非完全不可救药的模式。
并且——惊喜,惊喜——它非常适合 Pipelining。 在任何你需要构造复杂的、有状态的对象(例如,客户端或运行时)的情况下,这都是将复杂的、可选的参数馈送到对象中的好方法。
有些人说他们更喜欢可选/命名参数,但老实说,我不明白为什么:可选的命名 foo
参数比 .setFoo()
builder 函数的所有实例更难在代码中跟踪(也更难标记为已弃用!)。
如果你不知道我在说什么,这就是我所说的模式类型。 你有一个‘builder’对象,调用它的一些方法来配置它,最后 build()
你真正感兴趣的对象。
usetokio::runtime::Builder;fn main(){// build runtime
letruntime=Builder::new_multi_thread().worker_threads(4).thread_name("my-custom-name").thread_stack_size(3*1024*1024).build().unwrap();// use runtime ...
}
这也是 Pipelining。
让 Haskell(稍微)更具可读性
Haskell 很难阅读。
它有像 <$>
, <*>
, $
, 或 >>=
这样奇怪的运算符,当你询问 Haskell 程序员它们的含义时,他们会说“哦,这只是广义 Kleisli Monad 运算符 >=>
在局部小偏序集上的内 Pro-Applicatives 类别中的一个特例”,并且你的眼睛在他们完成句子之前就已经麻木了。
(Haskell 允许你随意定义自定义运算符也无济于事。)
如果你想知道“一种语言怎么会有这么多定制的运算符?”,我的理解是,它们中的大多数只是告诉 Haskell 以非常高级的方式组合一些函数的奇特方式。 这是第二基本的2 示例,$
运算符。
想象一下你有函数 foo
、bar
和一些值 data
。 在一个“““正常”””的语言中,你可能会写 foo(data)
。 在 Haskell 中,这被写成 foo data
。 这是因为 foo
会自动‘抓取’右侧的值作为其参数,因此你不需要括号。
这样做的结果是 bar(foo(data))
在 Haskell 中被写成 bar (foo data)
。 如果你写 bar foo data
,编译器会将其解释为 bar(foo)(data)
,这将是错误的。 这就是人们所说的 Haskell 的函数调用语法是左结合的原因。
$
运算符_只不过是语法糖_,它允许你编写 bar $ foo data
而不必编写 bar (foo data)
。 就是这样。 我猜人们已经厌倦了到处放置括号。
如果此时你的眼睛麻木了,我不能怪你。
让我们回到正轨。
谈论任何更奇特的运算符都远远超出了我的能力范围,所以我只坚持我在这篇文章中一直在说的话。 这是一个矫揉造作的 Haskell 玩具示例,故意没有用 pointfree 风格编写。
-- 获取输入字符串 `content`
-- 分成行,检查每一行是否是回文并字符串化
-- 例如 "foo\nradar" -> "False\nTrue"
checkPalindromes :: String -> String
checkPalindromes content = unlines $ map (show . isPalindrome) $ lines $ map toLower content
where
isPalindrome xs = xs == reverse xs
如果你想弄清楚数据的流向,则必须_从右到左_读取整个函数体。
为了使事情更有趣,你需要从 where
子句开始,以弄清楚正在定义哪些局部“变量”。 无论出于何种原因,这发生在函数的末尾而不是开头。(将 isPalindrome
称为变量具有误导性,但这无关紧要。)
此时你可能想知道 Haskell 是否有某种 Pipelining 运算符,是的,事实证明 在 2014 年添加了一个! 考虑到 Haskell 自 1990 年以来就存在,这已经很晚了。 这允许我们按如下方式重构上面的代码:
checkPalindromes :: String -> String
checkPalindromes content =
content
& map toLower
& lines
& map (show . isPalindrome)
& unlines
where
isPalindrome xs = xs == reverse xs
这难道不是更容易阅读吗?
_这是_你可以向企业 Java 程序员展示的代码,告诉他们他们正在查看具有稍微奇怪语法的 Java Streams,他们就会明白。
当然,在现实中,没有什么是简单的。 Haskell 生态系统似乎在 $
的用户、&
的用户和 Flow-提供的运算符的用户之间进行划分,这些运算符允许相同的功能,但允许你编写 |>
而不是 &
。3
我不知道该怎么说,除了——并非完全不像 C++——Haskell 也有其自身与运算符相关的和文化历史包袱,以及一个分裂的生态系统,这使得该语言的可访问性远低于它应有的水平。
Rust 的 Pipelining 非常棒
流行(?)文化引用。
切换原始/抖动图像
一开始我说过“Pipelining 是一种特性,它允许你从参数列表中省略单个参数,而是传递前一个值”。
我仍然认为这是真的,但这并没有涵盖整个情况。 如果你在前面的章节中注意到了,你就会注意到 object.member
和 iterator & map
除了操作顺序之外,基本上_没有任何_共同之处。
在第一种情况下,我们正在访问一个_作用域_限定到对象的值。 在第二种情况下,我们‘只是’将一个表达式传递给一个独立的函数。
或者换句话说,Pipelining 与 Pipelining 不同。 即使从 IDE 的角度来看,它们也是不同的。 在 Java 中,你的编辑器将查找与对象关联的方法并向上遍历继承链。 在 Haskell 中,你的编辑器将放置一个所谓的‘类型孔’,并尝试使用 Hindley-Milner 类型推断来推断哪些函数具有‘适合’该孔的类型。
就我个人而言,我喜欢类型推断(和类型类),但我也喜欢如果类型有一个附加到它的命名空间,其中包含方法和关联函数。 我就是这么务实。
我喜欢 Rust 的原因是它给了我两全其美:你可以获得特征和类型推断,而无需围绕完全函数式、不可变、惰性、单子驱动的编程范例进行思考,并且你可以获得方法和关联值,而无需复杂的继承链或 AbstractBeanFactoryConstructors 的绝对垃圾箱大火。
我还没有看到任何其他语言能够接近 Rust 的管道的便利性,并且它缺乏更高种类的类型或继承并没有阻止它。 恰恰相反,如果有什么不同的话。
结论
我喜欢 Pipelining。 如果你一直读完这篇文章,那么这是绝对应该很明显的一件事。
我只是觉得它们很棒,你知道吗?
我喜欢从上到下、从左到右地阅读我的代码,而不是从内到外。 我喜欢在我不需要计算参数和括号来弄清楚哪个值是第二个函数的第一个参数,哪个是第一个函数的第二个参数的时候。
我喜欢当我的编辑器可以在我按下键盘上的 .
时向我显示结构的所有字段以及与值关联的所有方法或函数时。 这太棒了。
我喜欢当 git diff
和代码存储库的 blame
层看起来不像完全的混蛋时。
我喜欢在流程中间添加一个函数调用不需要我解析整行来添加右括号,也不需要我调整整个块的嵌套时。
我喜欢我的函数区分‘我们正在对其执行操作的主要值’和‘辅助参数’,而不是将它们都视为相同。
我喜欢我不必用大量的辅助变量或我必须从某个地方拉入的独立函数来污染我的命名空间时。
如果你正在编写 Pipelining 代码——并且没有过于努力地将所有内容都塞进一个单一的、复杂的、嵌套的管道中——那么你的函数自然会分成几个管道块。
每个块都以一段“主要数据”开始,这些数据在传送带上运行,每一行都执行一项操作来转换它。 最后,一个单一的值在最后出来并获得自己的名称,以便以后可以使用它。
而且——依我拙见——这正是它应该的方式。 整洁、方便、分离的‘块’,每个块都可以很容易地被理解。
感谢 kreest 校对本文。
- 记录在案,诸如“如果你过度依赖你的 IDE,你将无法正确地学习如何编码”之类的论点比“氛围编码”更古老。↩︎
- 最基本的一个是
.
运算符,它只是组合了两个函数。 因此,f . g
是一个新的头等函数值,它首先将一个值传递给g
,然后将结果传递给f
。 柯里化!↩︎ - 如果你非常关注或了解 Haskell,你就会注意到
&
(|>
) 和$
(<|
) 本质上是相同的。 它们都是 Flow 的apply
运算符的实例,只是以不同的方向“移动”数据。 一旦你真正内化了这一点,第一个 Haskell 示例就会变得_易于阅读_,并且你已经爬上了通往 Haskell 启蒙的阶梯的一级。 在那里要小心。↩︎
80.67KB ↑
-
MOND←TECH MAGAZINE
- © Mond contact@herecomesthemoon.net
- 本网站上表达的任何意见均为我自己的意见。
- 2025 年 4 月 21 日,12:09 UTC 0fb1fbb 完成 Pipelining 文章