清晰性和可测试性的权衡邓 辉
如果你和我的爱好相同,那么想必你也一定会花费大量的时间思考这样一个问题:“究竟是什么东西使得一个设计可以被称之为好呢?”大多数软件开发者都会在他们职业生涯中的某个时刻关注过这个问题,这个时刻往往是在亲眼目睹了一个糟糕的设计所带来的负面效果之后。就在那个时刻,我们开始反思。在这个反思中,我们都会经历这样一个阶段,我们似乎感觉到我们知道什么是好的设计,但是就是无法清楚地把它定义出来。我们学习了各种各样的设计原则和经验法则,通过这些原则和法则我们可以比较容易地判断出一个好的设计的构成元素。但是当这些原则和法则之间发生冲突时,我们就不得不作出权衡,并决定出针对每种不同情形的最佳选择。
在近几年的软件开发中,通过自己的实践、体会并参考他人的大量经验,我也给自己定义了一个经验法则,以帮助自己在权衡时能够作出有效地判断。这个法则就是:保持设计尽可能的清晰。我非常地确信,无论我们需要做何种权衡,清晰性都是最重要的。如果我们在系统开发时能够采用一种简单明了的编码风格,其中每个方法和类都具有贴切的名字,并且足够的简短从而非常易于理解,系统中也不存在纠结在一起的混乱、晦涩的代码。那么这就为下一步的任何工作提供了一个良好的基础。你可以泰然地对系统进行更改而无需担心会带来什么破坏,你可以为系统编写测试,做一些调整,增加特性等等。而这一切都会由于系统本身的清晰性而变得相对容易。
因此,“清晰的设计就是好的设计”看起来似乎是一个非常合理的经验法则,毕竟导致代码不可维护的原因最终几乎都归结为清晰性的缺乏。如果一个系统能够被很好的理解,那么我们就何以非常有效地对它进行更改。否则,就会变得非常困难。这个论断听起来非常的简单明了,不是吗?不过它确实可能有些太简单了。在我最近对系统进行的重构中,我发现我总是不时地牺牲清晰性,并把它的优先级别排在另外一个标准之下。
更改清晰的代码我们来看一个例子。下面的C++代码是我们目前项目中PSTN协议类中的一个方法,看上去是一段相当清晰的代码。
void Pstn::OnHookOff_An1(INT8U*, INT32S)
{
SetState(AN2);
SendEstablish(SteadySignalTypeCode::HOOKOFF);
t1_.Start();
}
这个方法描述的是Pstn协议状态机在AN1状态下,接收到HookOff消息时的处理逻辑。任何看过PSTN协议文本的人都应该会非常迅速地理解这段代码所表达的含义,因为它简单明了地描述了协议文本中所规定的逻辑。它把状态迁移到AN2,并发送带有稳态信号HOOKOFF的Establish消息,并启动定时器t1。其中t1是一个定时器对象,它是TimerWrapper类的实例,作为Pstn类的成员变量存在。
那么,当我们想要更改定时器的实现逻辑时,应该做些什么呢?答案似乎相当的简单。我们直接找到定时器类的实现,并更改其逻辑即可。但是,我们怎样知道我们所做的更改是正确的呢?
保证我们所做的更改是正确的最为直接的方法就是针对我们要更改的代码编写一些测试。这样,我们就能够了解到它当前的工作状况。此后,当我们做改动时,我们就可以再编写更多的测试来检查所做的改动是否具有期望的行为。
因此,我们要做的第一步工作就是要在测试中创建一个Pstn实例,并调用其OnHookOff_An1方法,接下来检查一下t1定时器是否启动。在面的代码片断展示了使用CppUnitLite(http://cppunit.sourceforge.net)测试框架编写的测试代码。
TEST(Pstn, HookOff_An1)
{
L3ADDR addr = 0;
Pstn pstn(addr);
pstn.SetState(AN1);
pstn.OnHookOff_An1(0,0);
CHECK(pstn.GetT1().IsRuning() );
}
看上去很简单,但是当我们运行测试时遇到了麻烦。我们的定时器类的Start方法和IsRunning方法中使用了TimerService类(这是一个定时器实现类),并且这个类还和特定的定时硬件和操作系统平台相关。我们要想在Windows平台中使用CppUnitLite测试这个类的逻辑,就需要把相关的类都引进来,并且还要做一些讨厌的修改。我们究竟应该怎么做才能够不用引进系统中的其他部分就能够对我们这一小段代码进行测试呢?
对代码进行重构使之具有可测试性这听上去像是重构应该完成的工作。重构是这样一个过程:更改代码的结构但是不改变代码的行为,使之变得更加易于维护(更多信息,可以参考Martin Fowler的Refactoring: Improving the Design of Existing Code一书)。针对本例来说,我们希望对Pstn类进行重构,使之易于测试,从而也使之更加易于维护。我们要记住,当我们进行重构时,必须要有针对要重构代码的测试存在,这样就可以确保我们不会引入错误。
为了解决这个问题,我们需要使用一些“依赖消除”技术来使该类变得易于测试。依赖消除技术都是重构技术,但是它们非常的保守。一般来讲,无需运行测试,我们就可以安全地实施它们。(进一步的信息,请参考Michael Feathers的Working Effectively with Legacy Code一书。)
那么我们如何把代码变得更加易于测试呢?下面是我们所采用的技术。我们首先找到TimerWrapper类中的Start和IsRunning方法,把它们申明为virtual方法(请参见下面的代码片断)。这样,我们就可以定义TimerWrapper的子类,并override其中的Start和IsRunning方法,以提供我们测试所需要的逻辑。并且,只要我们对Pstn类稍做更改,给它增加一个SetT1方法。我们就可以在Pstn中使用该子类的实例,从而消除了这两个方法中对硬件和操作系统平台的依赖。这样做了以后,我们还可以方便地编写Pstn类中其他使用定时器的方法测试。
class TimerWrapper
{
...
public:
// change these two methods to be virtual
virtual void Start();
virtual bool IsRunning();
...
};
class FakeTimer1 : public TimerWrapper
{
public:
FakeTimer1()
{
started = false;
}
public:
void Start()
{
started = true;
}
bool IsRunning()
{
return started;
}
public:
bool started;
};
void Pstn::OnHookOff_An1(INT8U*, INT32S)
{
SetState(AN2);
SendEstablish(SteadySignalTypeCode::HOOKOFF);
t1_->Start();
}
void Pstn::OnHookOff_An1(INT8U*, INT32S)
{
SetState(AN2);
SendEstablish(SteadySignalTypeCode::HOOKOFF);
t1_->Start();
}
void Pstn::OnHookOff_An1(INT8U*, INT32S)
{
SetState(AN2);
SendEstablish(SteadySignalTypeCode::HOOKOFF);
t1_->Start();
}
TEST(Pstn, HookOff_An1)
{
L3ADDR addr = 0;
Pstn pstn(addr);
FakeTimer1 t1;
pstn.SetState(AN1);
pstn.SetT1(&t1);
pstn.OnHookOff_An1(0,0);
CHECK(t1.IsRuning() );
}
我们上面所采用的技术是Introduce Instance Delegator技术的一个变种。我们使用该技术克服了我们所面临的困难后,就可以为HookOff_An1方法编写测试了。这个更改后的结构比更改前要复杂一些,并且我们还不得不找到所有使用TimerWrapper的地方,把其更改为使用Set方式设置的形式,以便于我们可以比较容易地进行测试。
可以看出,这并不是最为简洁的解决方案。我们得继承TimerWrapper并且还得把那些原来可以直接以成员变量方式TimerWrapper的类更改为更为间接的形式。那么,我们应不应该由于代码变得复杂而具有某些不太好的感觉呢?我们确实会有这种感觉,但是我们同样也在使得代码更加易于维护的道路上前进了一步。事实就是这样:要么我们就得这么做,要么我们就必须得使用一些同样(甚至更加)复杂得手段来对代码进行测试。原来的代码确实更加简单清晰,但是却不易测试。对我来说,正是这一点使它成为一个糟糕的设计。
我非常喜欢整洁、清晰的代码。我认为清晰性是我们在设计时必须要保证的最为重要的原则之一。但是,当我开始在那些看上去很清晰的代码上试图去进行一些测试时,我就会考虑宁愿丧失掉一些清晰性(甚至是全局上的)来达成需要的可测试性。当代码变得可测试时,我们就获得了另外一种类型的清晰性。我们可以通过编写测试使得代码的功能更加清楚,并且我们还可以在这些测试代码的保护下,对代码进行重构,使之逐渐变得具有传统意义上的清晰性。
是的,我们可以继续对Pstn类进行重构,使之逐渐接近我们在传统意义上的清晰性,并且我们在这个过程中所编写的测试可以确保我们在这个进程中没有造成任何其他的破坏。同样,我们可以针对大多数的设计采用类似的方法,使它们首先变得更加易于测试。一旦我们做到了这一点,我们就可以在接下来的开发中使之变得更加清晰。