七、重复的危害
我们觉得,可靠地开发软件、并让我们的开发更易于理解和维护的唯一途径,是遵循我们称之为DRY的原则:
系统中的每一项知识都必须具有单一、无歧义、权威的表示。
DRY-Don't Repeat Yourself.
不要重复你自己。
与此不同的做法是在两个或更多的地方表达同一事物。如果你改变其中一处,你必须记得改变其他各处。或者,就像那些异型计算机,你的程序将因为自相矛盾而被迫屈服。这不是你是否能记住的问题,而是你何时忘记的问题。
重复是怎样发生的
我们所见到的大多数重复都可归入下列范畴:
强加的重复
有时,重复似乎是强加给我们的。项目标准可能要求建立含有重复信息的文档,或是重复代码中的信息的文档。多个目标平台各自需要自己的编程语言、库以及开发环境,这会使我们重复共有的定义和过程。编程语言自身要求某些重复信息的结构。我们都在我们觉得无力避免重复的情形下工作过。然而也有一些方法,可用于把一项知识存放在一处,以遵守DRY原则,同时也让我们的生活变得更容易一点。
信息的多种表示。在编码一级,我们常常需要以不同的形式表示同一信息。我们也许在编写服务器应用,在客户和服务器端使用了不同的语言,并且需要在两端都表示某种共有的结构。我们或许需要一个类,其属性是某个数据库表的schema(模型、方案)的镜像。你也许在描写一本书,其中包括的程序片段,也正是你要编译并测试的程序。
发挥一点聪明才智,你通常能够消除重复的需要。答案常常是编写简单的过滤器或代码生成器。可以在每次构建软件时,使用简单的代码生成器,根据公共的元数据表示构建多种语言下的结构。可以根据在线数据库schema、或是最初用于构建schema的元数据,自动生成类定义。
代码中的文档。程序员被教导说,要给代码加上注释;好代码有许多注释。遗憾的是,没有人教他们,代码为什么需要注释;糟糕的代码才需要许多注释。
DRY法则告诉我们,要把低级的知识放在代码中,它属于那里;把注释留给其他的高级说明。否则,我们就是在重复知识,而每一次改变都意味着既要改变代码,也要改变注释。注释将不可避免地变得过时,而不可信任的注释完全没有注释更糟。
文档与代码。你撰写文档,然后编写代码。有些东西变了,你修订文档、更新代码。文档和代码都含有同一知识的表示。而我们都知道,在最紧张的时候——最后期限在逼近,最重要的客户在喊叫——我们往往会推迟文档的更新。
语言问题。许多语言会在源码中强加可观的重复。如果语言使模块的接口与实现分离,就常常会出现这样的情况。C和C++有头文件,在其中重复了被导出变量、函数和(C++)类的名称和信息。Object Pascel甚至会在同一文件里重复这些信息。如果你使用远地过程调用或CORBA,你将会在接口规范与实现它的代码之间重复接口信息。
无意的重复
有时,重复来自设计中的错误。
让我们看一个来自配送行业的例子。假定我们的分析揭示,一辆卡车有车型、牌照号、司机及其他一些属性。与此类似,发运路线的属性包括路线、卡车和司机。基于这一理解,我们编写了一些类。
但如果Sally打电话请病假、我们必须改换司机,事情又会怎么样呢?Truck和DeliverRoute都包含有司机。我们改变哪一个?显然这样的重复很糟糕。根据底层的商业模型对其进行规范化——卡车底层的属性集真的应包含司机?路线呢?又或许我们需要第三种对象,把司机、卡车及路线结合在一起。不管最终解决方案是什么我们,我们都应避免这种不规范的数据。
当我们拥有多个互相依赖的数据元素时,会出现一种不那么显而易见的不规范数据。让我们看一个表示线段的类:
1 class Line{ 2 public: 3 Point start; 4 Point end; 5 double length; 6 };
第一眼看上去,这个类总是合理的。线段显然有起点和终点,并总是有长度(即使长度为零)。但这里有重复。长度是由起点和终点决定的:改变其中一个,长度就会变化。最好是让长度成为计算字段:
class line{ public: Point start; Point end; double length() {return start.distanceTo(end);} };
在以后的开发过程中,你可以因为性能原因而选择违反DRY原则。这经常发生在你需要缓存数据,以避免重复昂贵的操作时。其诀窍是使影响局部化。对DRY原则的违反没有暴露给外界:只有类中的方法需要注意“保持行为良好“。
class Line { private: bool changed; double length; Point start; Point end; public: void setStart(Point p) {start = p; changed = true;} void setEnd(Point p) {end = p; changed = true;} Point getStart(void) {return start;} Point getEnd(void) {return end;} double getLength() { if (changed) { length = start.distanceTo(end); changed = false; } return length; } };
这个例子还说明了像Java和C++这样的面向对象语言的一个重要问题。在可能的情况下,应该总是用访问器(accessor)函数读写对象的属性。这将使未来增加功能(比如缓存)变得更容易。
无耐性的重复
每个项目都有时间压力——这能够驱使我们中间最优秀的人走捷径的力量。需要你与写过的一个例程相似的例程?你会受到诱惑,去拷贝原来的代码,并做出一些改动。需要一个表示最大点数的值?如果我改动头文件,整个项目就得重新构建。也许我应该在这里使用直接的数字,这里,还有这里,需要一个与Java Runtime中的某个类相似的类?源码在那里(你有使用许可),那么为什么不拷贝它、并作出你所需的改动呢?
如果你觉得受到诱惑,想一想古老的格言:“欲速则不达”。你现在也许可以节省几秒钟,但以后却可能损失几小时。、
无耐性的重复是一种容易检测和处理的重复形式,但那需要你接受训练,并愿意为避免以后的痛苦而预先花一些时间。
开发者之间的重复
另一方面,或许是最难检测和处理的重复发生在项目的不同开发者之间。整个功能集都可能在无意中被重复,而这些重复可能几年里都不会被发现,从而导致各种维护问题。
在高层,可以通过清晰的设计、强有力的技术项目领导,以及在设计中进行得到了充分理解的责任划分,对这个问题加以处理。但是,在模块层,问题更加隐蔽。不能划入某个明显的责任区域的常用功能和数据可能会被实现许多次。
我们觉得,处理这个问题的最佳方式是鼓励开发者相互进行主动的交流。设置论坛,用以讨论常见问题。让某个团队成员担任项目资料管理员,其工作是促进知识的交流。在源码树种指定一个中央区域,用于存放实用例程和脚本。一定要阅读他人的源码与文档,不管是非正式的,还是进行代码复查。你不是在窥探——你是在向他们学习。而且要记住,访问时互惠的——不要因为别人钻研你的代码而苦恼
让复用变得容易
你所要做的是营造一种环境,在其中要找到并复用已有的东西,比自己编写更容易。如果不容易,大家就不会复用。而如果不进行复用,你们就会有重复知识的风险。