zoco

程序的本质复杂性和元语言抽象

2017-03-01


参考: http://www.cnblogs.com/weidagang2046/p/the-nature-of-meta.html

如果目标还是代码“简短、优雅、易理解、易维护”,那么代码优化是否有一个理论极限?这个极限是由什么决定的?普通代码比起最优代码多出来的“冗余部分”到底干了些什么事情?

回答这个问题要从程序的本质说起。Pascal语言之父Niklaus Wirth在70年代提出:

Program = Data Structure + Algorithm

随后逻辑学家和计算机科学家R Kowalski进一步提出:

Algorithm = Logic + Control

谁更深刻更有启发性?当然是后者!而且我认为数据结构和算法都属于控制策略,程序包含了逻辑和控制两个维度。

逻辑就是问题的定义,比如,对于排序问题来讲,逻辑就是“什么叫做有序,什么叫大于,什么叫小于,什么叫相等”?控制就是如何合理地安排时间和空间资源去实现逻辑。逻辑是程序的灵魂,它定义了程序的本质;控制是为逻辑服务的,是非本质的,可以变化的,如同排序有几十种不同的方法,时间空间效率各不相同,可以根据需要采用不同的实现。

程序的复杂性包含了本质复杂性和非本质复杂性两个方面。套用这里的术语, 程序的本质复杂性就是逻辑,非本质复杂性就是控制。逻辑决定了代码复杂性的下限,也就是说不管怎么做代码优化,Office程序永远比Notepad程序复杂,这是因为前者的逻辑就更为复杂。如果要代码简洁优雅,任何语言和技术所能做的只是尽量接近这个本质复杂性,而不可能超越这个理论下限。

理解"程序的本质复杂性是由逻辑决定的"从理论上为我们指明了代码优化的方向:让逻辑和控制这两个维度保持正交关系。来看Java的Collections.sort方法的例子:

interface Comparator<T> {
    int compare(T o1, T o2);
}
public static <T> void sort(List<T> list, Comparator<? super T> comparator)

使用者只关心逻辑部份,即提供一个Comparator对象表明序在类型T上的定义;控制的部分完全交给方法实现者,可以有多种不同的实现,这就是逻辑和控制解耦。同时,我们也可以断定,这个设计已经达到了代码优化的理论极限,不会有比本质上比它更简洁的设计(忽略相同语义的语法差异),为什么呢?因为逻辑决定了它的本质复杂度,Comparator和Collections.sort的定义完全是逻辑的体现,不包含任何非本质的控制部分。

另外需要强调的是,上面讲的“控制是非本质复杂性”并不是说控制不重要,控制往往直接决定了程序的性能,当我们因为性能等原因必须采用某种控制的时候,实际上被固化的控制策略也是一种逻辑。比如,当你的需求是“从进程虚拟地址ptr1拷贝1024个字节到地址ptr2“,那么它就是问题的定义,它就是逻辑,这时,提供进程虚拟地址直接访问语义的底层语言就与之完全匹配,反而是更高层次的语言对这个需求无能为力。

介绍了逻辑和控制的关系,可能很多朋友已经开始意识到了上面二进制文件解析实现的问题在哪里,其实这也是 绝大多数程序不够简洁优雅的根本原因:逻辑与控制耦合。上面那个消息定义表格就是不包含控制的纯逻辑,我相信即使不是程序员也能读懂它;而相应的代码把逻辑和控制搅在一起之后就不那么容易读懂了。

元抽象表达

思考一个问题:

逻辑决定了程序的本质复杂性,但接口不是表达逻辑的通用方式,那么是否存在表达逻辑的通用方式呢?

答案是:有!这就是元(Meta),包括元语言(Meta Language)和元数据(Meta Data)两个方面。元并不神秘,我们通常所说的配置就是元,元语言就是配置的语法和语义,元数据就是具体的配置,它们之间的关系就是C语言和C程序之间的关系;但是,同时元又非常神奇,因为元既是数据也是代码,在表达逻辑和语义方面具有无与伦比的灵活性。至此,我们终于找到了让代码变得简洁、优雅、易理解、易维护的终极方法,这就是: 通过元语言抽象让逻辑和控制彻底解耦

比如,对于二进制消息解析,经典的做法是类似Google的Protocol Buffers,把消息结构特征抽象出来,定义消息描述元语言,再通过元数据描述消息结构。下面是Protocol Buffers元数据的例子,这个元数据是纯逻辑的表达,它的复杂度体现的是消息结构的本质复杂度,而如何序列化和解析这些控制相关的部分被Protocol Buffers编译器隐藏起来了。

message Person {
  required int32 id = 1;
  required string name = 2;
  optional string email = 3;
}

元语言解决了逻辑表达问题,但是最终要与控制相结合成为具体实现,这就是元语言到目标语言的映射问题。通常有这两种方法:

  1. 元编程(Meta Programming),开发从元语言到目标语言的编译器,将元数据编译为目标程序代码;

  2. 元驱动编程(Meta Driven Programming),直接在目标语言中实现元语言的解释器。

这两种方法各有优势,元编程由于有静态编译阶段,一般产生的目标程序代码性能更好,但是这种方式混合了两个层次的代码,增加了代码配置管理的难度,一般还需要同时配备Build脚本把代码生成自动集成到Build过程中,此外,和IDE的集成也是问题;元驱动编程则相反,没有静态编译过程,元语言代码是动态解析的,所以性能上有损失,但是更加灵活,开发和代码配置管理的难度也更小。除非是性能要求非常高的场合,我推荐的是元驱动编程,因为它更轻量,更易于与目标语言结合。

下面是用元驱动编程解决二进制消息解析问题的例子,meta_message_x是元数据,parse_message是解释器:

var meta_message_x = {
    id: 'x',
    fields: [
        { name: 'message_type', type: int8, value: 0x01 },
        { name: 'payload_size', type: int16 },
        { name: 'payload', type: bytes, size: '$payload_size' },
        { name: 'crc', type: crc32, source: ['message_type', 'payload_size', 'payload'] }
    ]
}
var message_x = parse_message(meta_message_x, data, size);

这段代码我用的是JavaScript语法,因为对于支持Literal的类似JSON对象表示的语言中,实现元驱动编程最为简单。如果是Java或C++语言,语法上稍微繁琐一点,不过本质上是一样的,或者引入JSON配置文件,然后解析配置,或者定义MessageConfig类,直接把这个类对象作为配置信息。

二进制文件解析问题是一个经典问题,有Protocol Buffers、Android AIDL等大量的实例,所以很多人能想到引入消息定义元语言,但是如果我们把问题稍微变换,能想到采用这种方法的人就不多了。来看下面这个问题:

某网站有新用户注册、用户信息更新,和个性设置等Web表单。出于性能和用户体验的考虑,在用户点击提交表单时,会先进行浏览器端的验证,比如:name字段至少3个字符,password字段至少8个字符,并且和repeat password要一致,email要符合邮箱格式;通过浏览器端验证以后才通过HTTP请求提交到服务器。

普通的实现是这个样子的:

function check_form_x() {
    var name = $('#name').val();
    if (null == name || name.length <= 3) {
        return { status : 1, message: 'Invalid name' };
    }
    var password = $('#password').val();
    if (null == password || password.length <= 8) {
        return { status : 2, message: 'Invalid password' };
    }
    var repeat_password = $('#repeat_password').val();
    if (repeat_password != password.length) {
        return { status : 3, message: 'Password and repeat password mismatch' };
    }
    var email = $('#email').val();
    if (check_email_format(email)) {
        return { status : 4, message: 'Invalid email' };
    }
    ...
    return { status : 0, message: 'OK' };

}

上面的实现就是按照组件复用的思想封装了一下检测email格式之类的通用函数,这和刚才的二进制消息解析非常相似,没法在不同的表单之间进行大规模复用,很多细节都必须被重复编写。下面是用元语言抽象改进后的做法:

var meta_create_user = {
    form_id : 'create_user',
    fields : [
        { id : 'name', type : 'text', min-length : 3 },
        { id : 'password', type : 'password', min-length : 8 },
        { id : 'repeat-password', type : 'password', min-length : 8 },
        { id : 'email', type : 'email' }
    ]
};
var r = check_form(meta_create_user);

通过定义表单属性元语言,整个逻辑顿时清晰了,细节的处理只需要在check_form中编写一次,完全实现了“简短、优雅、易理解、以维护”的目标。其实,不仅Web表单验证可以通过元语言描述,整个Web页面从布局到功能全部都可以通过一个元对象描述,完全将逻辑和控制解耦。

最后,我们再来从代码长度的角度来分析一下元驱动编程和普通方法之间的差异。假设一个功能在系统中出现了n次,对于普通方法来讲,由于逻辑和控制的耦合,它的代码量是n * (L + C),而元驱动编程只需要实现一次控制,代码长度是C + n * L,其中L表示逻辑相关的代码量,C表示控制相关的代码量。通常情况下L部分都是一些配置,不容易引入bug,复杂的主要是C的部分,普通方法中C被重复了n次,引入bug的可能性大大增加,同时修改一个bug也可能要改n个地方。所以,对于重复出现的功能,元驱动编程大大减少了代码量,减小了引入bug的可能,并且提高了可维护性。