我们都听过“程序=数据结构+算法”这句话,我现在越来越意识到程序除了数据结构和算法之外还真没有别的东西,源代码是数据结构,以File或String的object形式存在,程序的运行是把扁平化的数据结构变成立体的,比如抽象语法树(AST),这个转换过程是算法,程序运行的规则是数据结构和算法,运行的结果也是数据结构。。。
软件测试目前存在的困难,据今天群里讨论听闻,大概可以总结成:
单元测试——手动生成用例、覆盖率(各种情况的覆盖程度);
集成和系统测试——更复杂的用例、单元测试正常而集成测试异常并由此带来回溯困难的情况。
测试的基础是输入和生成正确的和错误的数据/对象,并分别论断其结果,单元测试针对的是与其他模块解藕和程度高的局部代码,正确性容易判断,而判断难度会随着系统复杂度上升而迅速增大。
复杂系统存在涌现效应(emerging effect),计算机程序作为硅基非生命物质是否存在涌现不清楚,但与程序互动的事物(人、自然界、一些生物系统)显然是难以确定的,而程序设计过程体现了人的思维和判断,是其复杂性的根本来源,所以我们可以多从程序—人的理念角度看,试图找到降低软件复杂性和不确定性从而减少测试负担的可能。
虽然抽象数据类型(ADT)引人注目也是学习编程的重点,直接体现计算过程的也更接近硬件底层的是原始数据结构,比如number、int(integer)、double(Double)、char(ANSI character)、String(x86处理器已经直接支持字符串操作)而且后者在不同编程语言之间差别比抽象数据类型要小,我发现人们可能缺少对这些原始数据类型的足够关注。
一种情况是取值范围,这经常和业务逻辑向契合,比如int age这个变量,一般在0-100之间,有可能超过100,但绝对不能小于0。超过100的情况呢,114有可能是人高寿或者多输入一个1,194常理上不可能,可以认为是错误。问题在于,一些程序缺少这种“理念信息”在数据结构层面的体现。我做一个简陋也未必合理,但试图解决这个问题的尝试:
class Age{
int declaredAge;
enum Possiblitiy{
HIGH,//一般情况
LOW,//比如92
IMPOSSIBLE//比如200岁
}
int allowedInterval =1;
//other things........
}
这个Age让对年龄可取值范围的判断从与它密切相关的业务逻辑中脱离开来,减少了Person这个类的复杂性,同时更方面根据业务逻辑调整Age本身,比如某个做高寿保险的公司可能非常关注80-110岁之间的人。
另一方面,不只是取值范围,Age的对象会组成有名或无名的“年龄群体”(Ages ages),领域知识表明人群的年龄经常呈现正态分布,如果没有明显的使“群体值”发生扭曲(比如特定年龄段——但特定年龄段里也容易呈现有规律的分布),而群体值明显偏离通常情况,很可能数据有问题,直接或简介导致程序运行错误。
理念信息的加入让程序复杂性增加,但不确定性下降,从更高角度便于控制,减少了用法的复杂性。
第三,年龄作为int与有些同样用int表示的数据形式不同,年龄可以连续变化,34、35取值都是合法的,而有些变量不能连续取值或在一定条件下只有区间值(比如涉及到税率)。实际上读者可能认为的抽象数据结构表现业务逻辑是这个问题的最终解决方案,但单个AST的问题会传导到与之相关的模块。
class SomeNumericValue implement ManipulableAsAGroup {
int declaredValue;
enum Possiblitiy{
HIGH,
LOW,
IMPOSSIBLE}
int allowedInterval =2;
Convention commonDistribution;
boolean aggregationLimit;
//.......
}
这种传导的情况比较典型的是发布-订阅关系中的反向压力(backpressure),消息传递和流处理中经常遇到,一个通俗的比喻是“信箱已经满了,读信的速度赶不上信来的速度”,为此人们发明了一些复杂的方法来解决。从实际用法(pragmatics)角度,这种关系应当体现在抽象数据结构中。假如我已知X模块的数据传入速度(只接受一个数据源)最大是100Mb/s,为保证程序正常运行就需要保证X的上游数据生产速度不能超过这个速度,在发布-订阅模式中这种关系要提前预计并做好设计,而面对复杂的业务逻辑和数据结构,预先设计常常不现实。
我的设想是探索在程序中普遍加入涉及取值范围和条件、信息流方向、容量、模块组合关系的数据结构,所有数据结构都在有限(空间、时间)内考虑,并实现细粒度的反射(reflection),上下游除了基于业务逻辑和算法的数据通信之外还有运行状态。
据说几年前有个大公司发现JVM最多能支持192G内存,再多就不行了,而Java社区都不知道这个事情。类似地,我们会在编程中直接考虑内存空间的问题吗?——“会呀,我是C语言指针专家!”那程序的各个模块、不同模块间状态变化对内存的影响以及将内存变化当作时间序列(比如某个对象所占内存空间根据业务逻辑应该有规律地变化)来考虑呢?
以上问题总结为“状态透明度”问题。
编程语言为软件开发所提供的抽象和工具永远是有限的,它应该是为人服务而不是束缚人的,我想探索不大幅增加编程难度,在更好体现业务逻辑同时增加软件内部状态透明度,以便于测试和调试的方法。
状态的高度封装可以见Joe-E的设计(见参考3),在权限模型(参考4) 的计算机架构和操作系统中这种进一步的数据封装和抽象带来了很大便利,但也无形中带来了隐患,因为抽象会“僵化”/“定型”(见参考5最后一部分,作者用“bloat”一词),而且高度封装会增加硬件的压力(参见当年SmallTalk编译器情况)。
对此我的设想是分层方案,在Joe-E里体现为对象包裹(wrapped object),类似转换器、状态接收器,假设A请求B的状态,Object result = b. visit(a) (“访问者模式”),A和B的对象都要经过C、D中介而不会直接发生联系,这样增加强了信息流的单一,但与之前的权限模式不同,C、D不是单纯起包裹作用,也起日志,特别是非常细粒度的日志作用,以及(被用作)状态监控。它既增加了封装,又实现了全面的状态管理。
效果是,各个模块之间的信息流高度可还原,一个程序经历的内部状态变化以数据结构(“运行树”)形式记录,由此可以减轻测试过程中要深入模块内部、分析信息流和跨模块状态变化的负担。
如果不同模块之间的关系发生变化,调整对应状态管理的部分,比调整代表业务逻辑的部分更容易和有效。
换句话说,每个类/抽象数据结构同时是主功能(业务逻辑)载体和状态管理对象。
class Foo{
private int num;
StatusReserve statusOfFoo;
StatusHandler handlerForFoo;
public void setNum(int n, StatusHandler requester) {
handlerForFoo.handleRequest(int n, requester);
statusOfFoo.recordChange(int n, method); //method is from Foo
}
public int getNum(StatusHandler requester){
statusOfFoo.recordChange(method);
return handlerForFoo(requester).respond(statusOfFoo);
}
这个匆匆写就的例子看起来非常复杂,我们不会每个类都这么编写,而应该探索一种类似切面化编程(AOP)的方法,一个程序在业务逻辑方面实现,然后加入监控和状态管理的切面。
我不是很清楚目前各种编程语言和配套工具对此的支持情况,但引用群友的话“这种无穷小级logging的功能,只要性能上足够便宜是debug神器”。
参考
1 The Implementation of Functional Programming Languages
2 Modern Java in Action_ Lambda, streams, functional and reactive programming, Chapter 15.5
3 Mettler, A., Wagner, D.A. and Close, T., 2010, March. Joe-E: A Security-Oriented Subset of Java. In NDSS (Vol. 10, pp. 357-374).
4 Capability-Based Computer Systems
5 Lin, J., 2017. The lambda and the kappa. IEEE Internet Computing, 21(5), pp.60-66.
6 Hölzle, U. and Ungar, D., 1995, August. Do object-oriented languages need special hardware support?. In European Conference on Object-Oriented Programming (pp. 283-302). Springer, Berlin, Heidelberg.
ps:
写完之后发现我的想法好像已有实现,是Mirror,一个相关概念是pluggable type。