多态或条件语句能促进更好的设计吗?

42
我最近在谷歌测试博客上看到了这篇关于编写更易于测试的代码的指南。在作者提出以下观点之前,我对此表示赞同:
偏爱多态性而非条件语句:如果你看到一个switch语句,你应该想到多态性。如果你在类中看到相同的if条件在许多地方重复出现,那么你应该再次考虑多态性。多态性将把你的复杂类分解成几个更简单的类,这些类清晰地定义了哪些代码片段是相关的,并且一起执行。这有助于测试,因为更简单/更小的类更容易测试。
但是,我无法理解这一点。我可以理解使用多态性代替RTTI(或DIY-RTTI,视情况而定),但这似乎是如此广泛的声明,以至于我无法想象它实际上能够有效地用于生产代码。相反,似乎更容易为具有switch语句的方法添加其他测试用例,而不是将代码拆分成几十个单独的类。
此外,我认为多态性可能会导致各种微妙的错误和设计问题,因此我很想知道这里的权衡是否值得。有人能够解释一下这个测试指南到底是什么意思吗?
12个回答

73

实际上,这使得测试和代码编写更加容易。

如果您有一个基于内部字段的 switch 语句,您可能会在多个地方使用相同的 switch 执行略微不同的操作。当您添加新的 case 时,这会导致问题,因为您必须更新所有的 switch 语句(如果您可以找到它们的话)。

通过使用多态性,您可以使用虚函数来获得相同的功能,因为一个新的 case 就是一个新的类,您不必搜索需要检查的代码,每个类都是独立的。

class Animal
{
    public:
       Noise warningNoise();
       Noise pleasureNoise();
    private:
       AnimalType type;
};

Noise Animal::warningNoise()
{
    switch(type)
    {
        case Cat: return Hiss;
        case Dog: return Bark;
    }
}
Noise Animal::pleasureNoise()
{
    switch(type)
    {
        case Cat: return Purr;
        case Dog: return Bark;
    }
}

在这个简单的情况下,每个新动物都需要更新两个switch语句。
你忘了一个?默认值是什么?崩溃了!

使用多态性

class Animal
{
    public:
       virtual Noise warningNoise() = 0;
       virtual Noise pleasureNoise() = 0;
};

class Cat: public Animal
{
   // Compiler forces you to define both method.
   // Otherwise you can't have a Cat object

   // All code local to the cat belongs to the cat.

};

通过使用多态,您可以测试Animal类。
然后单独测试每个派生类。

此外,这使您可以将Animal类(关闭修改)作为二进制库的一部分进行发布。但是,人们仍然可以通过从Animal头文件派生新类来添加新的动物(开放扩展)。如果所有这些功能都被捕获在Animal类中,则需要在发布之前定义所有动物(关闭/关闭)。


9
愉悦噪音确实!我有一句新的说法。 - Kieveli
3
@Calmarius: 我不认为我同意这一点。因为你将所有通用于特定类的代码移到单个类中。这样就将代码模块化了(我认为这样阅读更容易)。而在旧的switch技术中,实体如何工作的特征分散在整个代码库中。这使得很难看出行为变化对实体其他属性的影响。 - Martin York
1
@Calmarius,你曾经质疑多态的智慧:“_问题在于控制在虚函数中消失了_”我指出:多态不是问题,而是设计不良。现在你说你对OO有很少的经验,因为你维护C代码。程序工具中的不良设计和OO工具中的不良设计一样糟糕(也许在没有编译器支持的情况下模拟OO时更加糟糕)。我重申:问题不在于多态,而在于混淆的抽象、单olithic接口、副作用……也许你的怀疑实际上是对未知的恐惧? - Disillusioned
1
@CraigYoung 我并没有说我只有一点点面向对象编程的经验。我不得不同时处理面向对象和过程式代码,否则我就看不出它们之间的区别了。在维护面向对象代码(在以前的公司)之后,我发现维护过程式代码要容易得多。过程式代码通常包含较少的间接引用,但函数更长。我发现这样更容易阅读。 - Calmarius
1
@Calmarius,你不觉得和某个人讨论很棒吗?他首先对某项技术做出了不正确的概括,然后通过指出他主要使用另一种技术(“我大多数情况下是过程式编程”),解释了他的观点,最后否认说自己在面向对象方面经验不足。现在你说你正在将一个公司的面向对象代码与另一个公司的过程式代码进行比较。在我看来,这是非常有限的经验。也许第二份工作会更容易,因为你现在有了更多的经验? - Disillusioned
显示剩余10条评论

26

不要害怕...

我猜你的问题在于熟悉度,而不是技术。熟悉C++面向对象编程。

C++是一种面向对象的语言

它有多种范式,具有面向对象特征,并且能够支持大多数纯面向对象语言的比较。

不要让“C++中的C部分”使你相信C++不能处理其他范式。 C++可以非常优雅地处理许多编程范式。其中,OOP C++是过程化范式(即上述的“C部分”)之后C++范式中最成熟的。

多态性是可用于生产环境的

没有“微妙的错误”或“不适合生产代码”的事情。有些开发者固守自己的方式,而有些开发者会学习如何使用工具并为每个任务使用最佳工具。

switch和多态性几乎相似...

...但多态性消除了大多数错误。

区别在于您必须手动处理开关,而当您熟悉继承方法覆盖时,多态性更加自然。

对于开关,您将不得不将类型变量与不同类型进行比较,并处理差异。对于多态性,变量本身知道如何行为。您只需以逻辑方式组织变量并覆盖正确的方法。

但是最终,如果您忘记在switch中处理情况,则编译器不会告诉您,而如果派生自未覆盖其纯虚拟方法的类,则会通知您。因此,大多数开关错误都得到避免。

总之,这两个功能都是关于做出选择。但多态使您能够做出更复杂,同时更自然,因此更容易的选择。

避免使用RTTI查找对象的类型

RTTI是一个有趣的概念,可以很有用。但是大多数时候(即95%的时间),方法覆盖和继承将足以满足要求,并且您的大部分代码甚至不应该知道所处理的对象的确切类型,而是相信它会做正确的事情。

如果您将RTTI用作显微镜开关,则会错过重点。

(免责声明:我是RTTI概念和dynamic_cast的忠实粉丝。但是,必须使用正确的工具来处理手头的任务,大多数时候RTTI用作显微镜开关,这是错误的)

比较动态和静态多态

如果您的代码在编译时不知道对象的确切类型,则使用动态多态(即经典继承,虚拟方法覆盖等)

如果您的代码在编译时知道类型,则可以使用静态多态,即CRTP模式http://en.wikipedia.org/wiki/Curiously_Recurring_Template_Pattern

CRTP 可以让你的代码具有动态多态性的特点,但每个方法调用都将被静态解析,这对于一些非常关键的代码非常理想。

生产代码示例

类似于下面的代码(从记忆中提取),在生产环境中得到了应用。

更简单的解决方案是围绕着由消息循环调用的过程进行的(在 Win32 中是 WinProc,但为了简单起见,我编写了一个更简单的版本)。总之,它类似于:

void MyProcedure(int p_iCommand, void *p_vParam)
{
   // A LOT OF CODE ???
   // each case has a lot of code, with both similarities
   // and differences, and of course, casting p_vParam
   // into something, depending on hoping no one
   // did a mistake, associating the wrong command with
   // the wrong data type in p_vParam

   switch(p_iCommand)
   {
      case COMMAND_AAA: { /* A LOT OF CODE (see above) */ } break ;
      case COMMAND_BBB: { /* A LOT OF CODE (see above) */ } break ;
      // etc.
      case COMMAND_XXX: { /* A LOT OF CODE (see above) */ } break ;
      case COMMAND_ZZZ: { /* A LOT OF CODE (see above) */ } break ;
      default: { /* call default procedure */} break ;
   }
}

每次添加命令都会增加一个case。
问题在于有些命令相似,部分实现是共享的。
因此,混合案例对进化是一种风险。
我通过使用命令模式解决了这个问题,即创建一个基本的Command对象,具有一个process()方法。
因此,我重新编写了消息过程,将危险代码(即玩弄void*等)最小化,并编写它以确保我永远不需要再次触及它。
void MyProcedure(int p_iCommand, void *p_vParam)
{
   switch(p_iCommand)
   {
      // Only one case. Isn't it cool?
      case COMMAND:
         {
           Command * c = static_cast<Command *>(p_vParam) ;
           c->process() ;
         }
         break ;
      default: { /* call default procedure */} break ;
   }
}

然后,对于每个可能的命令,我没有在过程中添加代码,也没有混合(更糟糕的是复制/粘贴)来自类似命令的代码,而是创建了一个新命令,并从Command对象或其派生对象之一派生它:

这导致了层次结构(表示为树):

[+] Command
 |
 +--[+] CommandServer
 |   |
 |   +--[+] CommandServerInitialize
 |   |
 |   +--[+] CommandServerInsert
 |   |
 |   +--[+] CommandServerUpdate
 |   |
 |   +--[+] CommandServerDelete
 |
 +--[+] CommandAction
 |   |
 |   +--[+] CommandActionStart
 |   |
 |   +--[+] CommandActionPause
 |   |
 |   +--[+] CommandActionEnd
 |
 +--[+] CommandMessage

现在,我所需要做的就是为每个对象覆盖进程。
简单易行,易于扩展。
例如,假设CommandAction应该在三个阶段(“before”,“while”和“after”)中执行其过程。它的代码将类似于:
class CommandAction : public Command
{
   // etc.
   virtual void process() // overriding Command::process pure virtual method
   {
      this->processBefore() ;
      this->processWhile() ;
      this->processAfter() ;
   }

   virtual void processBefore() = 0 ; // To be overriden
   
   virtual void processWhile()
   {
      // Do something common for all CommandAction objects
   }
   
   virtual void processAfter()  = 0 ; // To be overriden

} ;

例如,CommandActionStart 可以编码为:

class CommandActionStart : public CommandAction
{
   // etc.
   virtual void processBefore()
   {
      // Do something common for all CommandActionStart objects
   }

   virtual void processAfter()
   {
      // Do something common for all CommandActionStart objects
   }
} ;

正如我所说:如果注释得当,易于理解,并且非常易于扩展。

开关语句被简化到了最少的程度(即类似于if的语法),因为我们仍然需要将Windows命令委托给Windows默认过程,而不需要RTTI(或更糟糕的是,内部RTTI)。

在开关语句中使用相同的代码可能会很有趣,我想(仅从我在工作中看到的“历史”代码数量来判断)。


1
非常详尽的写作。只是有一个小问题,C ++中的多态性可以引入微妙的错误 - 特别是忘记声明方法为虚拟的情况(1)和对象切片,当您将对基类的引用视为值对象时会发生。(2) 尽管在我看来优点大于缺点。 - j_random_hacker
@j_random_hacker:你的观点很正确(顺便加1分),但错误不在编译器的实现上,而是程序员对C++不熟悉。 - paercebal
@Gustavo - Gtoknu:C++不是C的子集:是的,我相信我知道这个... :-P ...实际上,我写了不要让C++成为C的子集,我承认这并不是很清楚(看看“AAA的子集”和“AAA子集”的区别?)。我会澄清文本,以确保没有人误解它。 - paercebal
在C++11中,override修复了这些问题。 - paulm

10

对OO程序进行单元测试意味着将每个类作为一个单元进行测试。你需要学习的原则是“开放-封闭”原则。我从《Head First设计模式》中学到了这一点。但它基本上是说,你希望在不修改现有测试过的代码的情况下轻松扩展你的代码。

多态性通过消除条件语句来实现这一点。考虑下面的例子:

假设你有一个携带武器的角色对象。你可以编写像这样的攻击方法:

If (weapon is a rifle) then //Code to attack with rifle else
If (weapon is a plasma gun) //Then code to attack with plasma gun

通过多态性,角色不必“知道”武器的类型,只需

weapon.attack()

如果发明了一种新武器,那么没有使用多态性,就必须修改条件语句。而使用多态性,只需添加一个新类并保留被测试的Character类即可。


1
《Head First 设计模式》是一本非常好的书。它也在 Jeff Atwood 的书架上!请参考 http://www.codinghorror.com/blog/archives/001108.html。 - pestophagous

8

我有点怀疑:我认为继承经常会增加比减少更多的复杂性。

我认为你提出了一个好问题,其中一件事情我要考虑到:

你是将其分成多个类,因为你正在处理不同的 东西?还是它真的是同一个东西,以不同的 方式 行动?

如果它真的是一个新的 类型,那么请创建一个新的类。但如果只是一个选项,我通常会将其保留在同一个类中。

我认为默认解决方案是单一类方案,提出继承的程序员需要证明他们的情况。


1
如果是同一件事以不同的方式行动,我会使行动具有多态性而不是物品。 - Martin York
5
如果动作块足够不同、足够大并且足够多,那么我会支持为它们创建新的类。但是,我宁愿有一个文件,其中有一个方法,带有一个具有75个单行案例的开关,而不是有75个仅有1行功能的类。 - rice
Rice,我非常同意。现在我更倾向于编写单元测试(测试功能),然后编写最简单的代码使测试通过。如果我遵循20年前写的书并不会真正困扰我。别误解我的意思。GoF确实很棒,但要正确使用。 - Oliver Shaw

5

虽然我不是测试用例方面的专家,但从软件开发的角度来看:

  • 开闭原则 -- 类应该对修改关闭,对扩展开放。如果你通过条件语句来管理条件操作,那么如果添加了一个新条件,你的类就需要改变。如果使用多态性,基类就不需要改变。

  • 不要重复自己 -- 指导方针的一个重要部分是“相同的if条件”。这表明您的类具有一些可以分解为类的独特操作模式。然后,当您实例化该模式的对象时,该条件在代码中出现一次。如果有新的模式出现,您只需要更改一处代码。


2
开关和多态做的事情是一样的。
在多态中(以及通常的基于类的编程中),你按类型对函数进行分组。使用开关时,你按函数将类型分组。决定哪种视图适合你。
如果你的接口固定且只添加新类型,则多态是你的好朋友。但是,如果你向接口添加新函数,则需要更新所有实现。
在某些情况下,你可能有固定数量的类型,可以添加新功能,然后开关更好。但是添加新类型会使你更新每个开关。
使用开关时,你正在复制子类型列表。使用多态时,你正在复制操作列表。你交换了一个问题以获得另一个问题。这就是所谓的表达式问题,我所知道的任何编程范例都没有解决这个问题。问题的根源是用于表示代码的文字的单维性质。
由于支持多态的观点在这里已经讨论得很好了,所以让我提供一个支持开关的观点。
面向对象编程有设计模式来避免常见陷阱。过程化编程也有设计模式(但据我所知还没有人把它写下来,我们需要另一个新的“四人帮”来制作这些最畅销书……)。其中一种设计模式可以是始终包括一个默认情况。
开关语句可以做得很好:
switch (type)
{
    case T_FOO: doFoo(); break;
    case T_BAR: doBar(); break;
    default:
        fprintf(stderr, "You, who are reading this, add a new case for %d to the FooBar function ASAP!\n", type);
        assert(0);
}

这段代码将会指向你忘记处理某个情况的位置,从而帮助你调试。编译器可以强制要求你实现接口,但是这个方法会“强制”你彻底测试代码(至少能够发现新情况)。当然,如果一个特定的开关在多个地方使用,它会被剪切到一个函数中(不要重复自己)。
如果你想扩展这些开关,只需要在 Linux 上运行“grep 'case[ ]*T_BAR' rn .”,它会输出值得注意的位置。由于你需要查看代码,因此你将会看到一些上下文信息,这有助于你正确添加新情况。当你使用多态时,调用站点会隐藏在系统内部,如果存在的话,你就依赖于文档的正确性。
扩展开关也不会破坏 OCP,因为你不会修改现有情况,只是添加了一个新情况。
开关还有助于下一个尝试适应和理解代码的人:
可能的情况就在你眼前。这在阅读代码时是一件好事(少跳来跳去)。
但是虚方法调用和普通方法调用一样。人们永远无法知道一个调用是虚拟的还是普通的(不查找类)。这是不好的。
但是如果调用是虚拟的,可能的情况并不明显(没有找到所有派生类)。这也是不好的。
当您为第三方提供接口以便他们向系统添加行为和用户数据时,则情况有所不同。(他们可以设置回调和指针以及用户数据,并且您会给他们句柄)
更多讨论请参见:http://c2.com/cgi/wiki?SwitchStatementsSmell “我担心我的‘C语言程序员综合症’和反面向对象编程主义最终会损害我的声誉。但每当我需要或不得不将某些东西插入到过程化的C系统中时,我发现这相当容易,缺乏约束、强制封装和较少的抽象层使我可以‘随便搞搞’。但在一个C++ / C#/ Java系统中,有成堆的抽象层在软件生命周期中堆叠在一起,我需要花费很多小时甚至几天的时间来找出如何正确地解决所有其他程序员为避免其他人‘搞乱他们的类’而构建的约束和限制。”

2
多态是面向对象编程的基石之一,它非常有用。通过将关注点分散到多个类中,您可以创建隔离和可测试的单元。因此,不需要使用 switch...case,在其中调用多个不同类型或实现的方法,而是创建一个统一的接口,具有多个实现。当您需要添加一个实现时,无需修改客户端,这非常重要,因为这有助于避免回归。
您还可以通过处理一个类型(即接口)来简化客户端算法。
对我来说非常重要的是,多态最好与纯接口/实现模式(如备受推崇的 Shape <- Circle 等)一起使用。您还可以在具体类中使用模板方法(也称为钩子)进行多态,但随着复杂度的增加,其效果会降低。
多态是我们公司代码库建立的基础,因此我认为它非常实用。

1
这主要涉及到知识的封装。让我们从一个非常明显的例子开始 - toString()。这是Java,但很容易转移到C++。假设您想为调试目的打印对象的人类友好版本。您可以执行以下操作:
switch(obj.type): {
case 1: cout << "Type 1" << obj.foo <<...; break;   
case 2: cout << "Type 2" << ...

然而,这显然是愚蠢的。为什么一个方法要知道如何打印所有内容。对于对象本身来说,它自己知道如何打印通常会更好,例如:

cout << object.toString();

这样,toString() 方法就可以访问成员字段而无需进行强制转换。它们可以独立测试。它们可以轻松更改。

然而,你可以争论对象如何打印不应该与对象相关联,而应该与打印方法相关联。在这种情况下,另一种设计模式非常有用,即访问者模式,用于模拟双重分派。完整描述过长,但你可以在此处阅读一个好的描述


0

如果你理解了它,它会非常有效。

多态性也有两种形式。第一种在Java风格中非常容易理解:

interface A{

   int foo();

}

final class B implements A{

   int foo(){ print("B"); }

}

final class C implements A{

   int foo(){ print("C"); }

}

B和C共享一个公共接口。在这种情况下,B和C不能被扩展,因此您始终可以确定调用哪个foo()。对于C++也是如此,只需将A::foo设置为纯虚函数即可。

其次,更棘手的是运行时多态性。在伪代码中看起来并不太糟糕。

class A{

   int foo(){print("A");}

}

class B extends A{

   int foo(){print("B");}

}

class C extends B{

  int foo(){print("C");}

}

...

class Z extends Y{

   int foo(){print("Z");

}

main(){

   F* f = new Z();
   A* a = f;
   a->foo();
   f->foo();

}

但这是非常棘手的。特别是如果你在C++中工作,其中一些foo声明可能是虚拟的,而一些继承可能是虚拟的。此外,对于这个问题的答案:

A* a  = new Z;
A  a2 = *a;
a->foo();
a2.foo();

可能不是您所期望的。

如果您正在使用运行时多态性,请保持清醒,了解自己知道和不知道的内容。不要过于自信,如果您不确定某个东西在运行时会做什么,请进行测试。


0
我必须再次强调,在成熟的代码库中查找所有switch语句可能是一个非常棘手的过程。如果您错过了任何一个,那么应用程序很可能会因为未匹配的case语句而崩溃,除非您设置了默认值。
此外,请查看“Martin Fowler”关于“重构”的书籍。 使用switch而不是多态性是一种代码异味。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接