软件腐化

软件腐化

敏捷软件开发——原则、模式与实践 Robert C. Martin

软件腐化是说,在项目开始时,系统的图像看起来非常清晰,随着时间的增加和需求变得逐渐复杂,我们的设计没有跟上这些变化,腐化蔓延、增长,丑陋腐烂的痛处在代码中累积,使程序变得越来越难以维护。最后,即使仅仅进行最简单的更改,也需要花费巨大的力气。

书里第七章讲到软件腐化过程的一个小故事,和我写第一个蒙特卡洛模拟程序时的历程差不多,边学边写过了一年,模拟程序也腐化到基本上不能再更改了,暑假给商飞写的东西也基本上是这样。虽然理解整个敏捷设计需要长期实践的体会,但是从这个小故事中至少可以学习到:程序要依赖于抽象接口,不要依赖于具体实现(依赖反转原则,Dependency Inversion Principle);增添新的功能时,可以对现有的代码进行扩展,而不能修改现有的代码(开闭原则,Open-Closed Principle)。这两个设计原则可以组合出一个解决方案,就是构建一个抽象类提供抽象接口,增添不同的新功能时,具体实现不同的类。

所以面向对象编程看起来本质上只是面向接口编程的一种实现方式。划分一个类,并不是因为这些东西看起来很像,而是在我们的场景中他们可以抽象出统一的接口;发生继承,也是因为子类满足了父类定义的所有接口。因此接口的隔离和划分是做程序设计时主要要考虑的事情,这继而要求我们满足接口隔离原则(Interface-Segregation Principle):使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖那些它不需要的接口。

"Copy"程序的小例子

image-20231007150335328

最开始,我们需要编写一个从键盘读入字符并输出到打印机的程序。程序中有3个模块,Copy模块调用另外两个模块。Copy程序从Read Keyboard模块中获取字符,并把字符传递给Write Printer模块。我们写出了Copy模块的代码,

1
2
3
4
5
6
void Copy()
{
int c;
while ((c=RdKbd()) !=
WrtPrt(c);
}

接下来,客户希望Copy程序还能从纸带读入机中读入信息。于是我们开始修改计划方案,我们想在Copy函数中添加一个boolean变量。如果变量值为true,那么就从纸带读入机中读取信息;如果变量值为false,就像以前一样从键盘读取信息。然而由于现在已有许多其他程序正在使用Copy程序,我们不能改变Copy程序的接口,于是使用了一个全局变量,编写了修改后的代码,

1
2
3
4
5
6
7
8
bool ptFlag = false; 
// remember to reset this flag
void Copy()
{
int c;
while ((c = (ptFlag ? Rdpt() : RdKbd())) != EOF)
WrtPrt(c);
}

想让Copy从纸带读入机读入信息的调用者必须把ptFlag设置为true。接下来,客户还希望Copy程序可以输出到纸带穿孔机上。于是我们使用了另一个全局变量和更多的判断来实现这个功能,

1
2
3
4
5
6
7
8
9
bool ptFlag = false; 
bool punchFlag = false;
// remember to reset this flag
void Copy()
{
int c;
while ((c = (ptFlag ? Rdpt() : RdKbd())) != EOF)
punchFlag ? WrtPunch(c) : WrtPrt(c);
}

当再次变更输入设备和输出设备的时候,我们需要再次重新组织while循环的条件判断和更多的全局变量。

而在一个合理的开发和设计过程中,起初因为需求很简单,我们编写的程序完全一样。而当需求增加时,我们应该去利用机会改进设计,以便设计对将来的同类变化具有弹性,而不是设法去给原有的设计打补丁。很多事情都是一样的,但是Anyway,大部分情况下只能眼睁睁看着它一步一步走到大家早就知道的结局。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Reader
{
public:
virtual int read() = 0;
};

class KeyboardReader : public
{
public:
virtual int read() { return RdKbd();}
}

KeyboardReader GdefaultReader;

void Copy(reader& reader = GdefaultReader)
{
int c;
while ((c=reader.read()) != EOF)
WrtPrt(c);
}

在这里,Reader是一个抽象基类,它只起到通过纯虚函数read()定义接口的作用。这个类不能被实例化,必须由具体的子类提供符合接口的实现。KeyboardReaderReader 的一个具体子类,它提供了 read() 方法的实现。当通过 Reader 类的指针或引用调用 read() 时,会根据实际的对象类型(这里是 KeyboardReader)来调用相应的方法,实现了多态。这种设计使得 Copy 函数可以从任何其他实现了 Reader 接口的来源读取。如果想要添加新的读取来源,只需创建一个新的 Reader 子类并提供 read() 方法的实现即可,无需修改 Copy 函数或其他部分的代码,这符合了开闭原则。

所以设计原则起到的作用就是,当我们发现问题的时候,我们可以用设计原则去诊断问题:之所以会出现这样的问题,就是因为没有符合设计原则。然后应用适当的设计模式去解决这个问题,致力于保持设计的适当和干净。

坏的蒙特卡洛程序

我最初写的程序和最上面逐步增加功能的过程基本是一致的,比如一个典型的例子,刚开始模拟的时候,一般是从薄膜的导热写起,这个简单的结构里只有黑体边界,于是会定义一个对应的collide方法,

1
2
3
class Surface(object):
def collide(self):
# black boundary...

之后我们遇到了更加复杂的体系,遇到了漫反射边界、镜面反射边界,于是我们的程序很可能变成了这样,

1
2
3
4
5
6
7
8
9
10
11
class Surface(object):
def __init__(self, boundary_type=0):
self.boundary_type = boundary_type

def collide(self
if self.boundary_type == 0:
# black boundary...
elif self.boundary_type == 1:
# reflective boundary...
elif self.boundary_type == 2:
# diffusive boundary...

这违反了开闭原则,每次定义新的类,我们就需要修改现有的代码。和上面一样,我们可以用抽象基类只定义接口,在子类中满足具体的需求。当然Python是没必要的,因为一切皆对象,直接定义符合接口的函数做策略模式就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class CollisionStrategy(ABC):
@abstractmethod
def collide(self):
pass

class BlackBoundaryCollision(CollisionStrategy
def collide(self):
# black boundary...

class ReflectiveBoundaryCollision(
def collide(self):
# reflective boundary...

class DiffusiveBoundaryCollision(CollisionStrategy):
def collide(self):
# diffusive boundary...

class Surface(object):
def __init__(self, collision_strategy: CollisionStrategy):
self.collision_strategy = collision_strategy

def collide(self):
self.collision_strategy.collide()

又比如我们定义了Simulation类去执行模拟,用一个run函数去执行模拟逻辑。但是当我们想要实现不同逻辑的模拟时,比如瞬态和稳态,这时候不要把各种新的逻辑和判断都丢进run函数里,而是用接口和策略模式去开发新的逻辑。所以很多时候当我们从简单的情况开始写起的时候,一般都是完全从实现上去考虑的,因为不知道需不需要考虑地那么复杂。而随着功能的开发,一旦发现自己的类只定义了实现,想要再增添新的功能时候,就可以去接口层面想一想怎么更改和扩展程序设计了。