以数组语言进行思考:从命令式循环到隐式 K
使用像 K 这样的数组语言编程不仅仅是学习一种新语法;它还需要在问题解决的概念方式上发生根本性的转变。虽然大多数开发者接受的是命令式思维训练——定义一系列步骤并通过循环和变量来管理状态——但数组语言鼓励一种声明式方法,即一次性将操作应用于整个数据集。
这种转变通常被描述为一个持续简化的过程。目标是将一个庞大、笨重的模式压缩成一个更小、更具可读性且更具声明性的形式。为了说明这种演变,我们可以通过一个经典算法的转换来观察:矩阵乘法。
命令式翻译的陷阱
当开发者第一次遇到 K 时,本能往往是直接从 Wikipedia 或教科书等来源翻译已知的算法。对于矩阵乘法,迭代算法非常直观:
- 初始化结果矩阵 $C$。
- 遍历 $A$ 的行 ($i$)。
- 遍历 $B$ 的列 ($j$)。
- 使用第三个循环 ($k$) 计算 $A$ 的第 $i$ 行与 $B$ 的第 $j$ 列的点积。
- 将结果存储在 $C_{ij}$ 中。
直接翻译成 K 看起来像这样:
matmul: {
A::x
B::y
n::#A
m::#*A
p::#*B
C::(n;p)#0
i::0
j::0
k::0
sum::0
{
i::x
{
j::x
sum::0
{
k::x
sum::sum+A[i;k]*B[k;j]
}'!m
C[i;j]::sum
}'!p
}'!n
C}
这被认为是“最差情况”的 K 代码。它存在三个主要问题:过多的全局赋值、深度嵌套的循环以及对状态的持续修改。在数组语言中,这些都是反模式。
简化的路径
改进这段代码涉及系统地移除命令式脚手架。
第 1 步:用折叠(Folds)替换循环
与其手动管理 sum 变量和循环,K 提供了折叠操作符 (/)。最内层的循环可以压缩成一行:
C[i;j]::+/{ k::x; A[i;k]*B[k;j] }'!m
第 2 步:消除状态和中间变量
由于 each 操作符 (') 返回一个数组,因此无需预分配矩阵 $C$ 并对其进行修改。嵌套循环可以直接返回它们的值:
{ i::x; { j::x; +/{ k::x; A[i;k]*B[k;j] }'!m }'!p }'!n
第 3 步:利用数组配对
索引变量 $i$、$j$ 和 $k$ 本质上是中间人。通过识别出我们正在将 $A$ 的行与 $B$ 的列进行配对,我们可以完全消除 $k$ 循环:
{ j::x; +/A[i]*B[;j] }'!p }'!n
第 4 步:转置与高阶函数
为了移除剩余的索引,我们可以转置矩阵 $B$ 并使用 eachleft (/:) 和 eachright 来直接配对数据:
matmul: { A::x; B::y; A{+/x*y}/:">+B }
最终形式:隐式编程(Tacit Programming)
一旦全局变量消失了,代码可以进一步压缩。通过移除昂贵的转置操作并使用隐式对齐(K 会自动对齐不同形状的数组),函数变为:
matmul: {x{+/x*y}\:y}
最后,通过应用“训练”(trains,即不带显式参数的函数组合)的规则,我们得到了终极的隐式版本:
matmul: (+/*)\:
对数组语言的看法
虽然 K 的强大之处在于其简洁性,但它并非没有争议。社区经常将数组语言与正则表达式进行比较:对于交互式探索和快速原型设计来说极其强大,但如果过度压缩,可能会变成“仅能编写”的代码。
正如一位 Hacker News 观察者所言:
我仍然没有使用 K/Q/etc.,因为它们看起来很疯狂……它们基本上是数学领域的正则表达式。超级精简且强大。几乎是仅能编写的。对于交互式使用,绝对非常有用。但如果你发现自己在对一个正则表达式按下“保存”键,那这就是一个危险信号……
尽管如此,“用 K 思考”的过程是一个旅程,旨在实现模式简化。通过从命令式转向声明式,开发者可以用寥寥数字符来表达复杂的数学运算,将关注点从“如何”计算转向“正在计算什么”。