无毒副作用

每当提到如何『评价』面向对象技术时,很多人的直接反应就是上升到世界观层次。说它让软件设计和客观世界更好的一一对应云云。我非常怀疑这种思考方式。客观世界是多维度的,面向对象的软件系统只能选取一个维度进行描述,同时为兼顾其它维度要做各种 leaky 的妥协。因此,对一个客观场景总有很多个可能的面向对象『设计』存在,每种『设计』都不能被认为是错的,甚至不能被认为是差的。这种一对多的情景很难让我信服面向对象技术比其它手段能更『自然地』描述世界。对我来说『自然地』只有一种标准 —— 容易取舍的单一选择。

面向对象技术没有脱离面向过程和命令式( imperative )的思维。有经验的 C++ 、Java 、Objective-C 程序员在阅读和编写代码时能熟练到近乎反射地把 this 或者 self 作为一个参数来思考(比如 0->func() 这样的调用为什么在某些情况下是可行的),或者把函数 override 作为函数指针的另一种形式(比如 0->func() 为什么在某些情况下是不行的)。这里没有任何世界观的升华。面向对象真正的便利仅限于消除负面副作用( negative side-effects )。

举个例子来说明什么叫负面副作用。假设在程序中有个二维的『单位向量( normalized vector )』,其方向任意,长度必须为 1 。这个数据通常表式为 x 分量和 y 分量。作为单位向量,必须满足 x 和 y 的平方和始终为 1 ,这种条件叫做对象数据的不变式( invariation )。不小心打破不变式,比如把 x 和 y 都设成了 1 (平方和变成了 2 ),这种行为就叫负面副作用。在数据暴露给所有算法代码的编程方法中,不变式是通过文档由程序员人工保证的。面向对象方法认为人工保证容易出错,所以通过隐藏对象的数据,只允许有限的成员函数访问它们来保证不变式。这样需要精心维护的涉及不变式的代码仅限于少量成员函数。类似的例子还有如何表示一个钢体(内部没有复杂结构,各部分的相对位置不会变化的物体)在三维空间的位置和姿态。钢体本身的形状一般用一组顶点表示,它在三维空间的位置和姿态用一个 4 x 4 的变换矩阵表示。熟悉 3D 几何的人知道,4 x 4 矩阵可以表示拉伸、平移、旋转、甚至扭曲等任意的变换。但是对于钢体来说,只有旋转和平移是有物理意义的。所以必须对这个数据结构中的 4 x 4 矩阵做出限制,即钢体对象的不变式。

从另一个角度来看,面向对象保证不变式的方式未必是理想的。首先,人工约束不变式并非那么难。Linux kernel 核心数据结构的不变式就是通过活跃的讨论和 code review 保证的。其次,人工约束不变式可以让设计者天然地保持警觉,把核心数据结构的数量最小化。最后,不变式可以通过合理的设计数据结构来减小甚至消除。比如上面单位向量的例子里,可以把单位向量用一个夹角 —— 即它和坐标系的某个轴的夹角来表示。这样的表示法不再需要额外维护不变式。类似地,在钢体的例子里用一个平移向量和一个旋转向量代替 4 x 4 矩阵表示位置和姿态,可以减少要维护的不变式。

这两个例子都说明,不变式来自数据结构的信息冗余。当然,表示复杂的对象很难做到完全消除信息冗余。若底层 framework 的 API 直接提供 4 x 4 矩阵运算或者 (x, y) 二维向量运算,我们就要考虑在这样的系统上构建对象的简便性和复杂度,未必要舍近求远用平移、旋转和夹角来表示。同时,保持冗余信息也是提高性能的手段,很多操作都需要 x / y 分量或者 4 x 4 矩阵,如果不直接存储它们,就要在每次需要的时候临时计算。所以造成不变式的信息冗余又来自两处:一是性能优化的需要;二是支持我们构建对象的底层系统中可以被直接操作的模块粒度不够小。这就像现实中我们没法构建一个无需仓库的物流,也无法用纳米亚原子直接打造机器。

前面提到用非面向对象技术消除副作用并非难如登天。另一方面,复杂系统的信息冗余不可能完全消除决定了面向对象技术多少是有用的。这里的结论是不要把一种消除副作用的便宜之计吹捧成改变世界观的灵丹妙药。特别是在一叶障目之后,容易忽视背后的更本质更有效的手段。很多吹捧面向对象技术的人往往喜欢把系统的核心数据结构设计的非常庞大,信息非常冗余。从编写复杂的保持不变式的代码中获得不真实的成就感。这种过度吹捧在面向对象以外也有例子。并行化技术最大的负面副作用是不同线程同时修改一份数据,即 race condition 。消除这个副作用有几个方法,其一是被诟病的 lock 技术。Lock 的问题在于为了试图保证数据的不变式引入了时序上需要额外保证的不变式。在《多核与锁》里有所讨论,这里不过多涉及。

接下来是两种直接保证并行处理中数据不变式的手段。一个方法是只用栈变量,因为栈是每个线程独立的。更精确的说是只使用 per-thread 数据,因为有些 per-thread 数据其实在全局堆里,只是通过 per-thread 的引用来管理,而且后面马上说到有些系统的 per-thread 数据不是基于栈。这方面有一个最近成为热门的例子 —— GPU 运算。OpenGL 的 GLSL( shader language ),CUDA 和 OpenCL 的编程语言处理的数据都仅限于当前 GPU core 使用的 per-thread 变量。另一种方法是根本不用变量,那就是一小撮人推崇备至的函数式编程( functional programming )。在《并行计算的解药》里我讨论过,前一种手段里分割输入数据才是关键。而后一种,我认为根本是步子迈的有点大,扯着了。对这两种手段的评价,都只能限于消除副作用的影响,解决问题的真正关键在于更高层次的数据分割。

一项技术技术是否能被主流软件开发社区采用,不在于世界观和哲学那类东西,而是更实际的 —— 在消除副作用的同时保持原有的思维方式;至少增加的额外思考不能超过熟练程序员的反射弧。要求改变世界观的技术一开始就失败了一半。

发表评论

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  更改 )

Google+ photo

You are commenting using your Google+ account. Log Out /  更改 )

Twitter picture

You are commenting using your Twitter account. Log Out /  更改 )

Facebook photo

You are commenting using your Facebook account. Log Out /  更改 )

w

Connecting to %s


%d 博主赞过: