每当我们受命维护或者扩充一个既存系统的时候,恰当地理解原有的系统成为关键。毫无疑问,我们没有足够的时间完全理解它,但是我们一定要找到那个见鬼的合适地方以加入我们的修改。那么是什么阻碍了我们快速理解一个系统呢?一般想来,常见的原因可能包括:
- 缺失必需的文档。没有合适文档是常态;有了文档,版本又是旧的;好不容易找到来的一个当前版本的,但是是以日文写成的(你熟悉日文的情况下,极有可能那是一个英文文档)
- 使用不熟悉的工具。如你习惯使用C++,而这个系统使用Java或perl实现的
- 基于不熟悉的平台。windows和非windows平台差异有时显著地影响着实现
- 不同的专业领域。虽然作为软件开发本身是一个独立的领域,但是它强烈依赖于问题域,亦即它要处理的问题所在的领域。一个专长于系统开发的资深工程师对于一个游戏引擎也是一筹莫展
- 实现模式的差异。习惯过程式编程的工程师可能开始理解基于对象或方面的编程模式
- 底层架构的差异。COM和CORBA都是解决同样的问题的,但是他们是如此地不同,以至于两方的开发人员都接触很少。JAVA 和.NET是一个例子,MFC和Qt是另外一个例子
- 没有有效的辅助工具用来分析厘清大量的代码。
- 实现风格糟糕的代码。就是那种你在阅读之前必需使用工具格式化的
- 设计糟糕的代码。那种到处是强耦合的代码
- 实现糟糕的代码。那种把本来是同层同级的业务逻辑散布到多个模块,不同层次的调用,还是用据说是教科书推荐的最佳设计模式的代码
这个列表可以很长,以至于维护代码成为软件开发行业中最具挑战性的工作并且可以挣到更高的薪水。借用列夫的话,那是良好实现的代码是相似的,而糟糕实现的代码则是各有不同。它们总是以最意想不到的方式挑战着你的忍受极限。我见过的一个例子是一个使用了近八年的通用的字符串实现类支持错误语义的赋值而使用赋值也没有任何的警示,同时语言的标准实现中已经有这样库!
更糟糕的是,这样的实现不是孤立的个案问题,也不是大学一年级的课后作业,而是在上万台服务器中运行着的据称有良好质量的商业应用程序的实现。我们期望工业软件有着更好的实现质量,气势情况远非如此。考虑到这还是经过严格的国际质量认证的制作流程出来的东西,我们不能不对大量的商用软件的实现质量表示怀疑。
工程师的尊严完全取决于其实现的东西。当一座建筑历经百年风雨而不倒,我们都要对其工程师表达尊高的敬意。当你参与的软件作用被竞争对手打击的无还手之力,你又颜面何存?尽管软件工程师仅仅自己还不能保证产品的胜利,但是作为一线的实现人员,产品的失败无疑是对个人成绩的否定。当一个细小的改变都非常困难的时候,我们该考虑我们的实现质量是否对得起我们作为工程师头衔。
让我们回归到最本质的问题,探寻软件复杂性的来源。良好的实现依赖于对现实以及其上模型的良好理解以及据此选择的合适工具。在深入讨论工业强度的实现之前,让我们综述一下软件为什么这么复杂。
定量地描述软件的复杂程度还是一个开放的问题。这里不会使用复杂度模型来对现实的软件建模,相反,我们从实际软件工程师每天面对的问题出发,从人因工程的角度,讨论究竟是什么导致了软件是如此的复杂。
先界定一下复杂性的范围。此处仅仅指实现过程中面临的复杂的问题,而不是解决一个的问题的复杂性,尽管复杂的问题也往往预示着实现的复杂。无论多么复杂的问题,其复杂度本身可以被看做一个常量。
首先,选用合适的工具非常重要。对于软件工程开说,最最重要的就是选择合适的实现语言,这个无需赘叙。一般来说,工业实现选用最多的是C/C++家族及其派生的语言。对于偏系统的,C/C++无疑是首选;对于偏应用的,Java和C#等凭借得力的应用库更受青睐。互联网服务需要大量的文字解析,组合处理,脚本语言对解决这类问题提供了无可比拟的优越性,所以类似PHP,perl等的语言占据着优势。毫无疑问,随着业务的不断扩展和细分,更多的更具针对性的语言必然会涌现出来。实际上,好些复杂的问题领域中,已经存在着几十种专门的语言。当语言以及合适的基础库选定后,问题的实现复杂也可以看做是一个常量了。要注意的是,现在的越来越明显的趋势是使用不同的语言联合解决特定的问题。由于传统的语言设计中,跨语言交互并不是设计重点,这里还有不少问题。
紧接其后的便是开发范式的选择。按照C++的风格,可以分为过程式编程(强调过程控制),基于对象的编程(强调数据封装),面向对象的编程(强调智能对象,它可以环境而改变行为)以及泛型编程(强调最小化类型依赖)等。其中过程式编程是一切的基础因为任何的复杂问题的解决最终都可分解为一些列的过程。这里分别具体讨论这个开发范式。
在过程式编程范式领域,最著名的莫过于等式“程序=算法+数据结构”。简化地说,数据加上过程就是最终的解决方案。我们把所有的非流程的性的东西都统称为数据并针对其编程。通过抽象合适的过程为函数或者过程,我们复用了这部分代码和实现。流程并不一定直接对应于对吗实现中的流程。对于复杂的确定的流程,一种常见的实现是使用规则的定义来描述,然后实现专门的流程来解释并且执行这些流程。这儿,流程体现为数据,这也是传统的文件驱动技术。这其实就是一般的编译器和解释器所做的工作。不过大部分的问题域的流程定义没有到编译器实现所要求的准确,完备和复杂。局部跳转是过程式编程的一大特征,但是由于其可能带来的极大问题,在非常多的办成规则中已被明令禁止;由于原生的基于栈的函数调用实现,针对错误的业务流程代码和针对正确的业务流程代码混杂在一起,让错误的统一处理非常困难。非局部跳转的引入部分地解决了这个问题,但是其副作用更是令人不安;过程式编程的最后稻草是借助于专门的错误通道,如C运行库中的errno以及Windows系统中的GetLastError系统调用。除去引入额外的耦合不说,大部分的这样的实现难以做到并行安全。当并线逐渐成为主流,这样的限制愈加明显。
把流程以外的所有东西都抽象为数据是非常简化和粗糙的。简单来说,它并不区分真正要处理的数据和中间产生的信息。由于这些信息的产生,作用范围,目的以及处理过程与用户数据有很大的区别,一致地处理它们务必导致流程的复杂。通过把中间数据和信息表征为状态,并且引入全新的概念——对象——来描述。状态依附于对象,不能独立存在,就如同中间信息的产生依赖于我们的实现,不会凭空产生一样。有了合适的对象,就可以以更大的粒度描述业务逻辑;同时,由于对象隐藏了其实现细节,对象实现的复杂性被很好隔离。然而,很里划分对象粒度却需要额外的工作,并且事实证明这个要比想象的困难:除非不断迭代演进,我们很难预先得到一个规范的粒度合适的对象系统,也正是这些矛盾,促成了诸如“极限”这样的编程运动的风起云涌。基于对象编程范式的口号是“一切皆对象”,这是一些语言以及架构实现中初始基类的由来。当然,我们现在知道,一些东西并能不能被容易建模为对象,把它们描述为对象引入的复杂性超过了其能带来的益处。在基于对象的编程模式中,代码复用的方式表现为对象的继承。
基于对象的编程模式的带来的颠覆性成果是完善资源化管理概念的建立和对错误处理革新。对象的建立和销毁过程中,运行时系统会做必要的准备和清理,这就给了我们合理处理资源的机会。此处的资源代指一切使用前需要申请,用毕需要归还的东西,如内存块,锁,各式句柄等。我们在准备过程中申请资源然后在清理过程中释放之,这样就做到了资源的自动使用。然而传统的内存拷贝模式完全不适用于资源对象的处理,我们必须通过计数或者资源复制来保证资源的合理有效使用。资源管理是一个很大的主题,后面将有专文论述。通过对错误的分类,我们发现有些错误出现的几率很低,如内存不足错误;有些则较高,如用户输入验证错误。在传统的基于过程的编程范式中,我们忽略了二者的差异而对其一视同仁地处理,这是不经济的。在对象的概念基础上,异常被正式引入,用来处理那些出现概率很低却至关重要的错误。同类的异常都会被直接转移到函数调用栈中预先设定的一个点来集中处理,其中涉及的资源由合适的包装对象和严格的作用域规则保证释放。尽管异常的出现和捕捉要比传统的错误传递后台要做更多的事情,从它能简化实现就已经值得这些付出了,更何况我们把异常尽量局限在小概率错误的处理上。
当我们稍微熟悉基于对象的编程之后,新的需求就出来了:第一个是如何存储继承自同类接口的不同对象?这些对象的大小可能并不相同,类别也很有差异,对于传统的容器来说,这是一个挑战。我们的方案是使用引用或者指针。紧接着,第二个问题也来了:那么怎么区别这些引用或者指针,使其在被调用的时候恰好转发到期望的接口?我们需要在每个对象中记录它实际对应的方法的地址(相对或者绝对),这样可以在被调用的时候恰当路由。这就是面向对象的编程中最重要的“多态”,加上原有的对象系统中支持的“封装”和“继承”,就构成了面向对象的编程三大基石。面向对象编程范式引入的复杂性包括:1. 程序的动态性增强,这无疑使程序的状态跟踪变得复杂;2. 面向对象范式本身属性的相互作用带来很大的复杂性,如继承和包含关系的区别,不同访问控制的含义,以及基于此的复杂设计模式的使用等等。
泛型编程模式的引入其实是源于传统的强类型语言的实现限制,比如,你不能在一个容器中放入不同类型东西。它并不对应解决什么实际的问题,同时完全依附于上述的过程范式和对象范式,尽管它在很多方面简化了实现。然而,合理地使用泛型却并不简单,这也增加了实现的复杂性。
一个典型的软件实现中,上述开发范式都会被频繁用到,尽管不同的开发策略会有所侧重。在不考虑滥用上述这些开发范式的情况下,问题的实现复杂度还可以被看做是一个常量。
有了开发的开发工具和明确的开发范式要求,我们进入具体的实现。这个领域完全是百家争鸣,城头变幻大王旗。有句话说,多好的语言也能写出烂代码,可见我们简直无法对实现应用的复杂性做合适归类。不过,根据一般的对实现的讨论,我们可以简略划分如下:
- 缩进和风格。现在已经没有人不理解恰当一致的缩进和编程风格的巨大作用(Python甚至把它作为一种强制的实现要求),尽管什么样的风格更具普遍性一直没有统一,但是一些基本的原则已有公论,如第一条就是保持一致。借助于集成开发环境的广泛使用,糟糕格式化的代码已不常见。过分强调完全遵从使用一种或者几种风格是不合理的,因为形式主义在这里简单地行不通。更重要的是要明白为什么一种风格要在这种情况下要好于另一种。有了这些一致的认识,基本可以确保代码在可读性方面保持高水平。
- 名字隐藏。如果一个名字不得不暴露在更大的范围内,如全局范围,那么意味着这个名字被多个该范围的模块共享,这些模块之间的关系也因此变得模糊。可以简单地说,全局名字的数目和引用这些名字的模块数目的乘积和软件的实现质量成反比。
- 详细设计。指子模块级接口设计,类设计以及实现。良好的代码级设计极大地依赖于开发人员的能力和素质,这也是影响最终实现质量的最重要的因素,也是最不确定的因素。现实中,往往是开发人员完成从高层设计到基层实现的全部工作,并且底层的实现和设计反决定了高层设计,这是非常错误的。
- 项目组织和配置管理。包括各种源文件(代码文件,第三方源码或者库,编译文件,各种文档手册说明)组织以及编译目标的组织:不同平台,不同版本,不同模块,不同配置(调试或者发布)。配置管理包括版本管理,缺陷管理以及产品的制作管理(编译,打包,自动集成,分发,安装等)。这些不直接影响具体的实现,然而却是实现不可分割的一部分。系统的规模越大,项目组织和配置管理越重要。
通过这些讨论,我们可以大致窥出软件为什么如此复杂。这些因素纠结不清,困住许多熬红眼睛的程序员。就像布鲁克林说的,在焦油坑中苦苦挣扎。我们没有时间,没有精力,没有授权,甚至没有了热情去重构那些烂代码,但是我们至少可以在写新代码的时候能保证它达到工业强度。你敢自豪地宣称这些代码是你写的并签名负责吗?那么你就是真正的工业程序员。
下一节,我们从工业实现的角度,复审一下最常用工业语言C/C++的大部分特性,看看它们是怎么帮助我们克服复杂性的。