是的!你没看错,SPL,Structured Process Language,就是这样一种写在格子里的开源程序设计语言,专门用于处理结构化数据。
我们知道,几乎所有编程语言都是写成文本的,那写在格子里的 SPL 是什么样子呢?写在格子里的代码又有哪些不同呢?我们先一睹 SPL 的编程环境。
SPL 特性
格子代码
中间部分就是 SPL 的网格代码了。
把代码写到格子里有什么好处呢?
我们编程时总要用到中间变量,也就要给变量起个名字,但在 SPL 中经常是不需要的。后面步骤中可以直接使用前面单元格名(如 A1)以引用该格的计算结果,如:=A2.align@a(A6:~,date(Datetime)) ,这样可以避免费劲定义变量(变量名经常要起得有意义,也挺烦);当然 SPL 也支持定义变量,无需定义变量类型,随起随用,如: =cod=A4.new(ID:Commodity,0:Stock,:OosTime,0:TotalOosTime) ,也可以在表达式中临时定义变量并使用,如:= A1.group@o(a+=if(x,1,0)) 。
使用格子名作为变量名会有一个问题,插入或删除行列后格子的名称(位置)会变化,再使用原来的名称就不对了。别担心,这点 SPL IDE 早就考虑到了,插入删除行后格子名称会自动变迁。这里插入了一行,下面的格子名就变了。
格子代码给人的感觉会非常规整,因为格子的原因,代码天然对齐。其中,代码块的标识采用了格子缩进的方式(A12 到 A18 的 for 循环),不需要使用修饰符,十分整齐直观。而且,当某格处理细碎任务的代码很长时,代码也只会占一个格子,不影响阅读整个代码的结构(不会让某个格子里太长的代码写出格子而影响到右边和下边的代码阅读)。相比之下文本代码就得全显示出来而没有这个好处了。
再有就是调试功能了。我们继续看一下这个编程环境,除了中间的代码区以外,上方工具栏提供了多种执行 / 调试按钮,执行、调试执行、执行到光标、单步执行,后面还有设置断点、计算当前格等功能,可以满足编辑调试程序的需要。每次一格,代码定位很清楚,不像文本式的代码同一行可能有多个动作不容易区分,有些句子太长时变成多行也不容易定位。
右侧还有一个结果面板也值得重点关注,由于 SPL 采用了网格式编程,在执行 / 调试后每步(格)的结果都被保留下来,程序员点击某个格子就可以实时查看该步(格)的计算结果,计算正确与否一目了然。不需要手动输出,每步结果实时查看,这进一步增强了调试的便利性。
多层结果集
函数选项
每种编程语言都会内置大量的库函数,库函数越丰富我们在功能实现上就越方便。函数会采用不同的名称或参数(以及参数类型)进行区分,但有时通过参数类型也无法区分时就要显示地再增加选项参数,以便告诉编译器或解释器你要实现的功能。比如 Java 在操作文件时有多个 OpenOption,当我们想要创建一个不存在的文件可以使用:
Files.write(path, DUMMY_TEXT.getBytes(), StandardOpenOption.CREATE_NEW);
但如果文件存在时打开,不存在时新建则需要使用:
Files.write(path, DUMMY_TEXT.getBytes(), StandardOpenOption.CREATE);
而向文件追加数据并保证系统崩溃时数据不会丢失需要使用:
Files.write(path,ANOTHER_DUMMY_TEXT.getBytes(), StandardOpenOption.APPEND, StandardOpenOption.WRITE, StandardOpenOption.SYNC)
同一个函数实现不同功能需要用选项来控制,通常也就是把选项作为一个参数,这会造成了使用上的复杂度,经常搞不清这些参数的真实用途,对于某些参数数量不确定的函数还没有办法再用参数来表示选项了。
SPL 提供了非常独特的函数选项,使功能相似的函数可以共用一个函数名,然后函数选项区分差别,做到了把函数选项落到实处,在表现上更趋近一种二层结构,这样不管记忆还是使用都很方便。比如,pos 函数的功能是查找母串中子串的位置,如果从后往前查找可以使用选项 @z:
pos@z("abcdeffdef","def")
忽略带小写还可以使用 @c 选项:
pos@c("abcdef","Def")
两个选项还可以组合使用
pos@zc("abcdeffdef","Def")
有了函数选项,我们只需要熟悉更少的函数即可,当使用到同类但不同功能时再查找相应选项即可,相当于 SPL 给函数也做了层级划分,查找和使用更加方便。
层次参数
有些函数的参数很复杂,可能会分成多层。常规程序语言对此并没有特别的语法方案,只能生成多层结构数据对象再传入,非常麻烦。比如我们使用 Java 做 join 运算(对 Orders 表和 Employee 表进行内关联):
Map<Integer, Employee> EIds = Employees.collect(Collectors.toMap(Employee::EId, Function.identity()));
record OrderRelation(int OrderID, String Client, Employee SellerId, double Amount, Date OrderDate){
}
Stream<OrderRelation> ORS=Orders.map(r -> {
Employee e=EIds.get(r.SellerId);
OrderRelation or=new OrderRelation(r.OrderID,r.Client,e,r.Amount,r.OrderDate);
return or;
}).filter(e->e.SellerId!=null);
可以看到实现关联计算时需要给 Map 传递一个多层(段)的参数,即使是读起来也很费劲,就更别提写了。我们再做多一点计算,因为关联完往往还会有其他计算,这里再对 Employee.Dept 进行分组,对 Orders.Amount 求和:
Map<String, DoubleSummaryStatistics> c=ORS.collect(Collectors.groupingBy(r->r.SellerId.Dept,Collectors.summarizingDouble(r->r.Amount)));
for(String dept:c.keySet()){
DoubleSummaryStatistics r =c.get(dept);
System.out.println("group(dept):"+dept+" sum(Amount):"+r.getSum());
}
这种函数的使用复杂度不用再多解释了,程序员都有很深体会。相比之下,SQL 就直观简单多了。
select Dept,sum(Amount) from Orders r inner join Employee e on r.SellerId=e. SellerId group by Dept
SQL 使用了一些关键字(from、join 等)将计算的各个部分进行了分隔,分隔的各个部分也可以理解成多层参数,只不过伪装成英语会有更好的易读性。但这种做法的通用性较差,要为每个语句选择专门的关键字,会使语句结构不统一。
SPL 没有采用 SQL 这种使用关键字进行分隔的方式,也没有像 Java 一样需要嵌套多层,而是创造性地发明了层次参数。约定支持三层参数,分别用分号、逗号和冒号来分隔。分号是第一级,分号隔开的参数是一组,这个组内如果还有下一层参数则用逗号分隔,再下一层参数则用冒号分隔。前面的关联计算用 SPL 来写是这样:
join(Orders:o,SellerId ; Employees:e,EId).groups(e.Dept;sum(o.Amount))
简单明了,没有嵌套,也不会出现语句结构不统一的情况。实践表明,三层基本够用了,很少用这种写法还无法描述清楚的参数关系了。
格子代码、选项语法、层次参数,这些特性让 SPL 看起来很好玩。但发明 SPL 并不是为了好玩,而是高效处理数据。SPL 是一种专业处理结构化数据的低代码,这在 SPL 的全称上就可以看出来:结构化数据处理语言。
为什么发明 SPL?
现在处理结构化数据的程序语言有很多,像常用的 SQL、Java、Python 等。为什么还要再发明 SPL 呢?
因为这些代码还不够低,也就是用于处理结构化数据的代码不够简洁。
SPL 与其他语言对比
我们看几个例子。
JAVA与 SPL
针对两个字段进行分组汇总,Java 实现:
Calendar cal=Calendar.getInstance();
Map<Object, DoubleSummaryStatistics> c=Orders.collect(Collectors.groupingBy(
r->{
cal.setTime(r.OrderDate);
return cal.get(Calendar.YEAR)+"_"+r.SellerId;
},
Collectors.summarizingDouble(r->{
return r.Amount;
})));
for(Object sellerid:c.keySet()){
DoubleSummaryStatistics r =c.get(sellerid);
String year_sellerid[]=((String)sellerid).split("_");
System.out.println("group is (year):"+year_sellerid[0] +
"\t (sellerid):"+year_sellerid[1]+"\t sum is:“ +
r.getSum()+"\t count is:"+r.getCount());
}
简单的分组汇总就要写这么多行,过于繁琐。同样的计算,SPL 一句就能搞定:
SQL与 SPL
计算某支股票的最长连涨天数,SQL 写法:
SELECT CODE, MAX(con_rise) AS longest_up_days
FROM (
SELECT CODE, COUNT(*) AS con_rise
FROM (
SELECT CODE, DT, SUM(updown_flag) OVER (PARTITION BY CODE ORDER BY CODE, DT) AS no_up_days
FROM (
SELECT CODE, DT,
CASE WHEN CL > LAG(CL) OVER (PARTITION BY CODE ORDER BY CODE, DT) THEN 0
ELSE 1 END AS updown_flag
FROM stock
)
)
GROUP BY CODE, no_up_days
)
GROUP BY CODE
SQL 用三层嵌套的子查询实现,别说写,看懂恐怕都要好一会。而 SPL 还是一句:
可以表达 SQL 同样的运算逻辑,即使没学习过 SPL 语法,也能看懂七八成。
Python与 SPL
还是上面的任务, 用 Python 来做:
import pandas as pd
stock = pd.read_excel(‘stock.xlsx’)
inc_days=0
max_inc_days=0
for i in stock ['price'].shift(0)>stocl[‘price’].shift(1):
inc_days =0 if i==False else inc_days+1
max_inc_days = inc_days if max_inc_days < inc_days else max_inc_days
print(max_inc_days)
倒是没那么绕了,但还要循环硬写,代码量并不低。同样的逻辑,SPL 完全不需要写循环语句,很简洁:
为什么这些语言写起来都很麻烦?
因为它们没能同时拥有足够的集合化和离散性特性!
集合化,离散性?这是什么东西?
我们用 Java 和 SQL 这两个风格正好相反的语言来解释。
集合化
结构化数据通常是批量的,也就是以集合形式出现的,要方便计算这类数据,程序语言需要提供足够的集合运算能力。
但 Java 并没有提供足够的集合运算类库,使得完成这类计算写起来很麻烦,动辄几十上百行。相比之下 SQL 则有比较丰富的集合运算,where 过滤,group 分组,sum 聚合等,写起来简单很多。
那给 Java 补一些集合运算的库函数不就行了吗?
没那么简单!
我们用排序运算举例:
SELECT * FROM T ORDER BY price
在 SQL 中排序简单写成 ORDER BY price 就可以了,你不用关心这个 PRICE 的数据类型。
Java 是一种类型严格的编译语言,同一个函数不能针对不同数据类型工作,就要为不同数据类型分别写一遍排序函数,整数、实数、字符串各自不同。
// 整数
public static void sortIntegers(int[] integers) {
Arrays.sort(integers);
}
// 实数
public static void sortDoubles(double[] doubles) {
Arrays.sort(doubles);
}
// 字符串
public static void sortStrings(String[] strings) {
Arrays.sort(strings);
}
只是麻烦库函数开发者也就罢了,问题是使用者也要指明数据类型,编译器才能找到函数。
Java 已经发明了泛型语法来简化写法:
public static <T extends Comparable<T>> void sortArray(T[] array) {
Arrays.sort(array);
}
但代码中仍然会有一堆尖括号,看着就很乱,影响对业务的理解。
排序可能面对多个参数,比如:
SELECT * FROM T ORDER BY price, quantity
SQL 中写 ORDER BY price, quantity。
这个事对 Java 又是个问题。参数个数不同的函数不能混用,这总不能像对付数据类型那样事先把所有参数个数的可能性都穷举一遍。通常的办法就是写个单参数函数,碰到多参数时再临时转换成单参数,比如把这里的 price 和 quantity 拼成一个参数再排序。或者支持集合参数,引用时也得把参数凑成一个集合形式多搞一层。写起来是相当的麻烦。
public static <T> Comparator<T> chainingComparator(List<Function<T, Comparable<?>>> keyExtractors) {
return keyExtractors.stream()
.map(Comparator::comparing)
.reduce(Comparator::thenComparing)
.orElseThrow(() -> new IllegalArgumentException("At least one key extractor must be provided"));
}
// 定义排序
List<Function<Order, Comparable<?>>> keyExtractors = Arrays.asList(
Order::getPrice,
Order::getQuantity
);
// 排序
Collections.sort(orders, chainingComparator(keyExtractors));
SQL 没有这样的事,解释型语言可以动态根据数据类型以及个数来决定怎么做。
事还没完,排序还可能针对一个计算式,比如:
SELECT * FROM T ORDER BY price*quantity
SQL 中写 ORDER BY price quantity。这个 price quantity 并不是在执行这个 SQL 语句之前先计算好的,而是在遍历集合成员才计算的。本质上,price*quantity 是个函数,是一个以当前集合成员为参数的函数,也就是相当于把一个用表达式定义的函数用作了排序运算的参数。
Java 中如果把表达式写到函数的参数中,会在调用前就先计算出来,而不是针对集合成员分别计算。Java 当然允许把一个函数作为参数传递给另一个函数,但写法要麻烦很多,需要事先定义一个函数。
// 辅助方法
public double getTotal() {
return price * quantity;
}
// Lambda表达式比较器
Collections.sort(orders, Comparator.comparingDouble(Order::getTotal));
// 输出
orders.forEach(System.out::println);
把函数当参数传,又懒得事先定义,这不就是 Lambda 语法吗,Java 现在也支持了啊。
是的,Java 现在有了 Lambda 语法,可以在参数中直接定义匿名函数了。
Collections.sort(orders, Comparator.comparingDouble(order -> order.price * order.quantity));
但显然不能写成简单的计算式,编译器无法区别时就会直接给算出来。Lambda 语法仍然是常规函数那一套,要定义参数甚至类型,也有个明显的函数体,只是不起名字而已。而且由于刚才说的数据类型和参数个数问题常常和这个 Lambda 语法搅合到一起,代码更为混乱。
SQL 则把 Lambda 语法化于无形了,甚至都没人把 SQL 这种语法称为 Lambda 语法,但它确实是妥妥地用一个计算式定义了一个函数当参数用。
Java 的麻烦还在于无法直接引用字段。结构化数据并非简单的单值,而是带有字段的记录。SQL 只有出现多个同名字段时才需要指定表名,但 Java 必须写成“对象. 成员”的啰嗦形式,因为 Java 的 Lambda 语法并不天然认得记录,对它来讲就是个参数,取记录的字段要用 dot 操作符。
只有直接引用字段的语法机制,才可以说是专业面向结构化数据计算的语言。
Java 还不支持动态数据结构。
结构化数据计算中,计算结果经常也是有结构的数据,它的结构和运算相关,没办法在代码编写之前就先准备好。所以需要支持动态数据结构的能力。
SQL 中任何一个 SELECT 语句都可能产生一个新的数据结构,在代码中可以随意添加删除字段,而不必事先定义结构。Java 这类编译语言又不行,在代码编译前就要把用到的结构都定义好:
class Order {
double price;
int quantity;
public Order(double price, int quantity) {
this.price = price;
this.quantity = quantity;
}
class Order {
double price;
int quantity;
double total;
public Order(double price, int quantity) {
this.price = price;
this.quantity = quantity;
this.total = price * quantity;
}
原则上不能在执行过程中动态产生新的结构。
我们总结一下:集合运算类库,其中参数的类型和数量可以是动态的;化于无形的 Lambda 语法,在其中可以直接引用记录的字段;动态数据结构。
这些我们通称为程序语言的集合化特性!有了集合化特性,才能方便地处理批量的结构化数据。
解释执行的 SQL 在这方面没什么问题,但编译语言的 Java 则很难满足集合化特性,这是由编译语言的特性决定的,不仅 Java 不行,Kotlin、Scala 这些也同样不满足。
离散性
我们说完了集合化,再来看离散性。前面提到,SQL 由于缺乏离散性,代码同样不低。这个离散性又是什么呢?
我们还是从例子出发,来计算一个数列的中位数。这里就不用结构化数据了,只用个简单数组,否则会打到 Java 的软肋上,Java 代码过长时就把关键问题掩盖了。
//SQL:
WITH TN AS (SELECT ROWNUMBER() OVER (ORDER BY V) RN,V FROM T),
N AS (SELECT COUNT(*) N FROM T)
SELECT AVERAGE(V) FROM TN,N
WHERE RN>=N.N/2 AND RN<=N.N/2+1
//Java:
Array.sort(v);
return (v[(v.length-1)/2] + v[v.length/2])/2;
不涉及结构化数据及 Lambda 语法时,Java 常常就会显得比 SQL 简洁了。而且仔细理解这两段代码的计算过程还能发现,Java 代码不仅简洁,效率也更好。
表面上看,SQL 的困难在于是不能直接用序号取出成员,它就没有序号的概念,要硬造个序号列,这里还用了窗口函数,否则序列还很难造。而 Java 则可以方便地用序号从数组取出成员来计算。
这里的根源在于 Java 和 SQL 中数据模型的不同。
Java 等高级程序语言中的数据都是以一些不可以再拆分的原子数据为基础的,比如数、串等。原子数据可以构成集合和记录等较复杂的数据,集合和记录也是某种数据,也可以再组合成更大的集合和记录。而构成集合和记录的数据并不依附于集合和记录,可以独立存在和参与计算,自然会提供从集合和记录中拆解出成员的操作(用序号和字段取成员)。这种自由的数据组织形式,我们称为离散性。支持了离散性后,可以轻易地构造出集合的集合、以及字段取值是记录的记录。
SQL 则把表(也就是记录的集合)作为一种原子数据,它并不是由更基础的原子数据组合成的。SQL 的数据也没有可组合性,集合的集合和记录的记录在 SQL 中是不存在的。SQL 中记录必须依附于表,不能独立存在,也就是成员必须依附于集合,拆解集合成员的操作是没有意义的,因为拆出来也没有对应的数据类型来承载。所谓拆解,其实是用 WHERE 这种过滤运算,这就会显得有点绕。过滤运算本质上是在计算子集,结果仍然是表(集合),单条记录实质上是只有一条记录的表(只有一个成员的集合)。SQL 这种数据组织方式很不自由,缺失了离散性。
几乎所有高级语言都天然支持离散性,然而 SQL 没有。
离散性是个很自然的特性,事物本来也是从简单到复杂发展的,这符合人们的自然思维。业务逻辑并不完全是针对集合整体的,还有很多针对具体集合成员或集合外游离数据的操作。缺失离散性,会加大这类不是整体集合相关运算的难度,也就是出现“绕”。
举个简单的结构化数据的例子:比如要列出年龄和收入都大于张三的员工,按自然思路想当然地写出 SQL 会是这样:
WITH A AS (SELECT * FROM employee WHERE name=...)
SELECT * FROM employee WHERE age>A.age AND salary>A.salary
但可惜这个 SQL 是非法的,它的后半截要用 JOIN 来写:
SELECT T.* FROM employee T,A WHERE T.age>A.age AND T.salary>A.salary
有点绕吧。这可以用来理解 SQL 中表和记录的差异,它没有一种数据类型可以承载记录。
Java 对结构化数据这些集合运算支持不好,我们改用集合化特性更好的 SPL 来完成同样的运算:
这就是自然思维了。
集合化是语法形式,对应代码的繁度;离散性是数据模型,对应代码的难度;缺失集合化的 Java 写出来的代码很繁,缺失离散性的 SQL 写出来的代码倒不见得很长,但是会很绕,难度变大。
我们再回顾前面计算股票连涨天数的例子。
SELECT CODE, MAX(con_rise) AS longest_up_days
FROM (
SELECT CODE, COUNT(*) AS con_rise
FROM (
SELECT CODE, DT, SUM(updown_flag) OVER (PARTITION BY CODE ORDER BY CODE, DT) AS no_up_days
FROM (
SELECT CODE, DT,
CASE WHEN CL > LAG(CL) OVER (PARTITION BY CODE ORDER BY CODE, DT) THEN 0
ELSE 1 END AS updown_flag
FROM stock
)
)
GROUP BY CODE, no_up_days
)
GROUP BY CODE
这里涉及的有序计算是典型的离散性和集合化的结合物,成员的次序在集合中才有意义,这要求集合化,有序计算时又要将每个成员与相邻成员区分开,会强调离散性。有了离散性,才有更彻底的集合化;而有了集合化,离散性才能发挥更广泛的作用。SQL 缺乏离散性,而且集合化也不彻底,最终导致写出来的代码很绕,看都看不懂。
发明 SPL
集合化是语法形式,对应代码的繁度;离散性是数据模型,对应代码的难度;缺失集合化的 Java 写出来的代码很繁,缺失离散性的 SQL 写出来的代码倒不见得很长,但是会很绕,难度变大。这就是为什么说 Java 和 SQL 的代码都不“低”的原因。
Python 怎么样呢?
前面我们说到,SQL 和 Java 在离散性或集合化方面短板明显,导致整体能力差,而 Python 在这两方面提升了很多。但仍不够好,像实现有序关联这些运算仍然繁琐。我们期望的目标是集合化和离散性都突出,这样整体能力才更强。
通过前面的内容我们可以很容易得到这样的结论,想要低代码只要把 Java 的离散性和 SQL 的集合化有效结合起来就行了。
这就是发明 SPL 的初衷,为了更好地结合集合化和离散性,专门设计的面向结构化计算的低代码程序语言。
SPL 提供了丰富的集合计算类库、化于无形的 Lambda 语法,支持动态数据结构、支持有序计算、支持过程计算,…,把 Java 和 SQL 的优点都继承了,能提供更彻底的集合化,这才是具备低代码特征的程序语言。
相较于其他结构化数据处理语言,SPL 的能力更加完善和突出。再回顾一次计算股票连涨天数的例子,看看 SPL 的语法风格。
支持有序计算和强 Lambda 语法风格的循环函数表达很简洁,不需要循环语句,更不需要嵌套,单句就写出来了。
还有这个电商漏斗计算的实际案例:
WITH e1 AS (
SELECT uid,1 AS step1, MIN(etime) AS t1
FROM events
WHERE etime>=end_date-14 AND etime<end_date AND etype='etype1'
GROUP BY uid),
e2 AS (
SELECT uid,1 AS step2, MIN(e1.t1) as t1, MIN(e2.etime) AS t2
FROM events AS e2 JOIN e1 ON e2.uid = e1.uid
WHERE e2.etime>=end_date-14 AND e2.etime<end_date AND e2.etime>t1 AND e2.etime<t1+7 AND etype='etype2'
GROUP BY uid),
e3 as (
SELECT uid,1 AS step3, MIN(e2.t1) as t1, MIN(e3.etime) AS t3
FROM events AS e3 JOIN e2 ON e3.uid = e2.uid
WHERE e3.etime>=end_date-14 AND e3.etime<end_date AND e3.etime>t2 AND e3.etime<t1+7 AND etype='etype3'
GROUP BY uid)
SELECT SUM(step1) AS step1, SUM(step2) AS step2, SUM(step3) AS step3
FROM e1 LEFT JOIN e2 ON e1.uid = e2.uid LEFT JOIN e3 ON e2.uid = e3.uid
SPL:
SPL 不仅写得简单,而且程序更为通用,可以处理任意步骤的漏斗计算,这都是低代码特性带来的好处。
有了结合了集合化与离散性的 SPL,代码就真地可以“低”了。 SPL SQL Python 代码示例对比 还有更多的 SPL 与 SQL、Python 的代码对比示例,可以进一步感受 SPL 代码的简洁性,以及理解为什么说 Python 虽然有巨大进步但仍然不够理想的说法。
还有高性能
我们再说一下性能,数据处理看起来是写得简单和跑得快这两件事儿。
我们知道,软件改变不了硬件的性能,CPU 和硬盘该多快就多快。不过,我们可以设计出低复杂度的算法,也就是计算量更小的算法,计算机执行的动作变少,自然也就会快了。
但算法不仅要想得出,还要能写出来才行,也就是低复杂度算法要容易实现才有可操作性。从这个角度来说,写得简单和跑得快其实是一回事!
SPL 有低代码特性,容易实现低复杂度算法,从而获得高性能。而 Java 和 SQL 想要实现同样的计算就很难,有些甚至写不出,最终只能忍受低性能。
SPL 有很多针对数据库 SQL 的优化案例,经常能获得数十倍的性能提升,原因就在这里。更多SPL相关内容欢迎前往乾学院沟通交流!