以数组语言进行思考:从命令式循环到隐式 K

使用像 K 这样的数组语言编程不仅仅是学习一种新语法;它还需要在问题解决的概念方式上发生根本性的转变。虽然大多数开发者接受的是命令式思维训练——定义一系列步骤并通过循环和变量来管理状态——但数组语言鼓励一种声明式方法,即一次性将操作应用于整个数据集。

这种转变通常被描述为一个持续简化的过程。目标是将一个庞大、笨重的模式压缩成一个更小、更具可读性且更具声明性的形式。为了说明这种演变,我们可以通过一个经典算法的转换来观察:矩阵乘法。

命令式翻译的陷阱

当开发者第一次遇到 K 时,本能往往是直接从 Wikipedia 或教科书等来源翻译已知的算法。对于矩阵乘法,迭代算法非常直观:

  1. 初始化结果矩阵 $C$。
  2. 遍历 $A$ 的行 ($i$)。
  3. 遍历 $B$ 的列 ($j$)。
  4. 使用第三个循环 ($k$) 计算 $A$ 的第 $i$ 行与 $B$ 的第 $j$ 列的点积。
  5. 将结果存储在 $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 思考”的过程是一个旅程,旨在实现模式简化。通过从命令式转向声明式,开发者可以用寥寥数字符来表达复杂的数学运算,将关注点从“如何”计算转向“正在计算什么”。

Sources