软件腐化
软件腐化
敏捷软件开发——原则、模式与实践 Robert C. Martin
软件腐化是说,在项目开始时,系统的图像看起来非常清晰,随着时间的增加和需求变得逐渐复杂,我们的设计没有跟上这些变化,腐化蔓延、增长,丑陋腐烂的痛处在代码中累积,使程序变得越来越难以维护。最后,即使仅仅进行最简单的更改,也需要花费巨大的力气。
书里第七章讲到软件腐化过程的一个小故事,和我写第一个蒙特卡洛模拟程序时的历程差不多,边学边写过了一年,模拟程序也腐化到基本上不能再更改了,暑假给商飞写的东西也基本上是这样。虽然理解整个敏捷设计需要长期实践的体会,但是从这个小故事中至少可以学习到:程序要依赖于抽象接口,不要依赖于具体实现(依赖反转原则,Dependency Inversion Principle);增添新的功能时,可以对现有的代码进行扩展,而不能修改现有的代码(开闭原则,Open-Closed Principle)。这两个设计原则可以组合出一个解决方案,就是构建一个抽象类提供抽象接口,增添不同的新功能时,具体实现不同的类。
所以面向对象编程看起来本质上只是面向接口编程的一种实现方式。划分一个类,并不是因为这些东西看起来很像,而是在我们的场景中他们可以抽象出统一的接口;发生继承,也是因为子类满足了父类定义的所有接口。因此接口的隔离和划分是做程序设计时主要要考虑的事情,这继而要求我们满足接口隔离原则(Interface-Segregation Principle):使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖那些它不需要的接口。
"Copy"程序的小例子
最开始,我们需要编写一个从键盘读入字符并输出到打印机的程序。程序中有3个模块,Copy
模块调用另外两个模块。Copy
程序从Read Keyboard
模块中获取字符,并把字符传递给Write Printer
模块。我们写出了Copy
模块的代码,
1 | void Copy() |
接下来,客户希望Copy
程序还能从纸带读入机中读入信息。于是我们开始修改计划方案,我们想在Copy
函数中添加一个boolean
变量。如果变量值为true
,那么就从纸带读入机中读取信息;如果变量值为false
,就像以前一样从键盘读取信息。然而由于现在已有许多其他程序正在使用Copy
程序,我们不能改变Copy
程序的接口,于是使用了一个全局变量,编写了修改后的代码,
1 | bool ptFlag = false; |
想让Copy
从纸带读入机读入信息的调用者必须把ptFlag
设置为true
。接下来,客户还希望Copy
程序可以输出到纸带穿孔机上。于是我们使用了另一个全局变量和更多的判断来实现这个功能,
1 | bool ptFlag = false; |
当再次变更输入设备和输出设备的时候,我们需要再次重新组织while循环的条件判断和更多的全局变量。
而在一个合理的开发和设计过程中,起初因为需求很简单,我们编写的程序完全一样。而当需求增加时,我们应该去利用机会改进设计,以便设计对将来的同类变化具有弹性,而不是设法去给原有的设计打补丁。很多事情都是一样的,但是Anyway,大部分情况下只能眼睁睁看着它一步一步走到大家早就知道的结局。
1 | class Reader |
在这里,Reader
是一个抽象基类,它只起到通过纯虚函数read()
定义接口的作用。这个类不能被实例化,必须由具体的子类提供符合接口的实现。KeyboardReader
是 Reader
的一个具体子类,它提供了 read()
方法的实现。当通过 Reader
类的指针或引用调用 read()
时,会根据实际的对象类型(这里是 KeyboardReader
)来调用相应的方法,实现了多态。这种设计使得 Copy
函数可以从任何其他实现了 Reader
接口的来源读取。如果想要添加新的读取来源,只需创建一个新的 Reader
子类并提供 read()
方法的实现即可,无需修改 Copy
函数或其他部分的代码,这符合了开闭原则。
所以设计原则起到的作用就是,当我们发现问题的时候,我们可以用设计原则去诊断问题:之所以会出现这样的问题,就是因为没有符合设计原则。然后应用适当的设计模式去解决这个问题,致力于保持设计的适当和干净。
坏的蒙特卡洛程序
我最初写的程序和最上面逐步增加功能的过程基本是一致的,比如一个典型的例子,刚开始模拟的时候,一般是从薄膜的导热写起,这个简单的结构里只有黑体边界,于是会定义一个对应的collide
方法,
1 | class Surface(object): |
之后我们遇到了更加复杂的体系,遇到了漫反射边界、镜面反射边界,于是我们的程序很可能变成了这样,
1 | class Surface(object): |
这违反了开闭原则,每次定义新的类,我们就需要修改现有的代码。和上面一样,我们可以用抽象基类只定义接口,在子类中满足具体的需求。当然Python是没必要的,因为一切皆对象,直接定义符合接口的函数做策略模式就可以了。
1 | class CollisionStrategy(ABC): |
又比如我们定义了Simulation
类去执行模拟,用一个run
函数去执行模拟逻辑。但是当我们想要实现不同逻辑的模拟时,比如瞬态和稳态,这时候不要把各种新的逻辑和判断都丢进run
函数里,而是用接口和策略模式去开发新的逻辑。所以很多时候当我们从简单的情况开始写起的时候,一般都是完全从实现上去考虑的,因为不知道需不需要考虑地那么复杂。而随着功能的开发,一旦发现自己的类只定义了实现,想要再增添新的功能时候,就可以去接口层面想一想怎么更改和扩展程序设计了。