迭代函数和自定义聚合运算

我们讨论过的常规聚合运算如 SUM/COUNT 和非常规聚合运算如 maxp/top,都是事先设计好的聚合函数。但如果我们想实现一个以前没有定义过的运算怎么办?比如想做连乘运算,显然这也算是一种聚合,是不是只能自己用循环写出来,能不能用已有的语法和函数组合出来?(题外话:连乘可以用 exp(SUM(ln(x))) 来做,不过这有点耍赖了,而且这还对付不了成员是负数的情况)。

要设计这样的语法方案,我们先要来观察这些常见的聚合函数都是怎么算出来的。
SUM:先设置一个初始值 0,然后遍历集合的每个成员,每次将成员值加到初始值上,直到成员被遍历完。
COUNT:设置初始值 0,遍历集合成员,每次碰到非空成员将初始值加 1,直到遍历完。
AVERAGE:这个不能边遍历边计算了,不过 AVERAGE=SUM/COUNT,算是个导出函数,不用考察了。
MAX:设置初始值为无穷小,遍历集合成员,每碰比初始值更大的成员值则替换初值,直到遍历完。
MIN:和 MAX 一样,只是初始值和比较方向是反的。

我们发现,这些基本聚合运算的实现方案都有相同的过程:先设置一个初始值,然后遍历集合成员,让当前成员和初始值计算得到一个新的初始值,再进入下一轮循环,直到遍历完整个集合,那个初始值就变成我们需要聚合值了。
于是,SPL 设计了一个实现迭代计算的聚合函数:

A.iterate( x, r )

以 r 为初始值遍历集合 A,每次用当前初始值和当前成员计算表达式 x,得到的结果替换当前初始值,直到遍历完成,返回最后的 x 值。
这里有个问题,在 x 中需要标识符或符号来表示当前初始值和当前成员。当前成员可以用 ~ 符号,当前初始值要再找个符号,SPL 中两个 ~ 来表示。

前面那些聚合运算就都可以用这个迭代函数来表示:

A.sum() = A.iterate( ~~+~, 0 )
A.count() = A.iterate( ~+if(~~,1,0),0 )
A.max() = A.iterate( if(~>~~,~,~~), -inf )
A.min() = A.iterate( if( ~<~~,~,~~), inf )

连乘当然也很简单了:

A.iterate( ~~*~, 1 )

我们提到过的非常规聚合也可以,比如返回单值的 maxp 可以写成

A.maxp(F) = A.iterate( if (~==null || ~.F>>~~.F,~,~~), null )		这是F表示字段名

但返回集合的情况要麻烦一些:

A.maxp@a(F) = A.iterate( if(~==null || ~.F>~~.F,~,if(~.F==~~.F,~~|~,~~)), null )	|表示集合的并运算
A.top( n ) = A.iterate( merge(~~,~).to(n), [inf]*n )	merge函数指归并排序运算,to(n)选出集合的前n个成员
A.distinct() = A.iterate( if(~~.contain(~),~,~~|~), [] )	contain函数判断集合是否包含某成员

不过,不是所有的聚合运算都容易用 iterate 来描述,毕竟聚合运算的定义太宽泛了,比如中位数就不合适用 itertate 描述。另外,象取第 1 个 / 最后 1 个这种聚合运算不需要遍历,也没必要用 iterate 描述。

用迭代函数能帮助我们写出一些新的聚合运算,而且它本身就是一种遍历方法。能用迭代函数写出来的聚合运算,都可以一边遍历一边计算,遍历完了就得到聚合值,目标集合只需要遍历一次。
我们知道,计算机不能直接针对外存计算,当数据量很大而不能全部加载进内存时,迭代函数的计算方法可以只需要较小的内存(能够放下聚合值)就可以完成大数据量的聚合。这对于实现分组后的聚合运算很有意义。

分组子集的总体数据规模和原集是一样大的,基于拆分后的子集再做聚合运算就意味着要遍历两次,对于纯内存运算还不是大问题,但如果数据量大到内存放不下时,就会发生外存倒换的现象,这样效率是非常低的。iterate 作为一种聚合运算当然可以用在分组之后,分组后进行能够被 iterate 描述的聚合运算时,就不需要对原数据遍历两次了,可以一边分组一边聚合,只需要较小的内存(能保存下结果集)就可以完成。
这大概也是 SQL 没有提供分组子集的原因之一,在发明 SQL 的那个年代内存很小,绝大多数原始数据都不可能放入内存,先产生分组子集后再聚合的运算效率难以容忍,而分组后直接聚合,且聚合运算都可以用 iterate 描述,大多数情况都可以一次遍历计算出来而不必全读内存了,除非结果集也大到内存装不下内存的情况。后期机器内存变大了,SQL 的这个规则却一直被作为标准延用下来了。