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

时间:2008-10-24 17:19:47

标签: c++ oop tdd polymorphism

我最近偶然发现this entry in the google testing blog关于编写更多可测试代码的指南。在此之前,我与作者达成了一致意见:

  

优先于条件的多态:如果你看到一个switch语句,你应该考虑多态性。如果您在班级的许多地方看到相同的if条件,您应该再次考虑多态性。多态性会将您的复杂类分解为几个较小的简单类,这些类可以清楚地定义代码的哪些部分相关并一起执行。这有助于测试,因为更简单/更小的类更容易测试。

我根本无法绕过那个。我可以理解使用多态而不是RTTI(或DIY-RTTI,视情况而定),但这看起来像是一个广泛的陈述,我无法想象它实际上在生产代码中被有效使用。在我看来,更容易为具有switch语句的方法添加其他测试用例,而不是将代码分解为几十个单独的类。

另外,我的印象是多态可能导致各种其他微妙的错误和设计问题,所以我很想知道这里的权衡是否值得。有人可以向我解释这个测试指南的确切含义吗?

12 个答案:

答案 0 :(得分:71)

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

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

通过使用多态,您可以使用虚函数来获得相同的功能,并且因为新案例是一个新类,您不必在代码中搜索需要检查的内容,它们对于每个类都是隔离的。 / p>

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语句 你忘了一个?什么是默认值? BANG !!

使用多态

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类中捕获了所有这些功能,那么在发货之前需要定义所有动物(关闭/关闭)。

答案 1 :(得分:25)

不要害怕...

我猜你的问题在于熟悉,而不是技术。熟悉C ++ OOP。

C ++是一种OOP语言

在其多种范例中,它具有OOP功能,并且能够支持与大多数纯OO语言的比较。

不要让C ++中的“C部分”让你相信C ++不能处理其他范例。 C ++可以非常慷慨地处理很多编程范例。其中,OOP C ++是程序范式之后最成熟的C ++范例(即前面提到的“C部分”)。

多态性可用于生产

没有“微妙的错误”或“不适合生产代码”的事情。有些开发人员会按照自己的方式进行设置,开发人员将学习如何使用工具并为每项任务使用最佳工具。

switch和polymorphism [几乎]相似......

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

区别在于您必须手动处理这些开关,而一旦您使用继承方法覆盖,多态性就更自然了。

使用开关,您必须将类型变量与不同类型进行比较,并处理差异。使用多态,变量本身就知道如何表现。您只需要以逻辑方式组织变量,并覆盖正确的方法。

但是最后,如果你忘记在switch中处理一个case,编译器就不会告诉你,而你会被告知你是否从一个类派生而不是覆盖它的纯虚方法。因此避免了大多数开关错误。

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

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

RTTI是一个有趣的概念,可能很有用。但是大多数时候(即95%的时间),方法重写和继承都会绰绰有余,而且大多数代码甚至不知道所处理对象的确切类型,而是相信它能做正确的事情。

如果您使用RTTI作为美化开关,那么您就错过了这一点。

(免责声明:我是RTTI概念和dynamic_casts的忠实粉丝。但是必须使用正确的工具来完成手头的任务,并且大多数时候RTTI被用作美化开关,这是错误的)< / p>

比较动态多态与静态多态

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

如果您的代码在编译时知道类型,那么也许您可以使用静态多态,即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 ;
   }
}

每增加一个命令就增加了一个案例。

问题在于某些命令类似,并且部分共享它们的实现。

因此混合病例是进化的风险。

我通过使用Command模式解决了这个问题,即使用一个process()方法创建一个基本Command对象。

所以我重新编写了消息程序,将危险代码(即使用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-like,因为我们仍然需要将Windows命令委托给Windows默认程序),并且不需要RTTI(或者更糟糕的是,内部RTTI)。

交换机内部的相同代码非常有趣,我想(如果仅根据我在工作中的应用程序中看到的“历史”代码的数量来判断)。

答案 2 :(得分:10)

单元测试OO程序意味着将每个类作为一个单元进行测试。您想要学习的原则是“开放扩展,关闭修改”。我是从Head First Design Patterns那里得到的。但它基本上表示您希望能够在不修改现有测试代码的情况下轻松扩展代码。

多态性通过消除那些条件语句使这成为可能。考虑这个例子:

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

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类。

答案 3 :(得分:8)

我有点怀疑:我相信继承通常会增加复杂性而不是删除。

我认为你提出了一个很好的问题,我考虑的一件事是:

您是否因为处理不同的事物而分成多个类?或者它是否真的是一样的,以不同的方式行事

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

我认为默认解决方案是单类解决方案,并且程序员提出继承以证明其案例的责任。

答案 4 :(得分:5)

不是测试用例影响的专家,而是从软件开发的角度来看:

  • 开放式原则 - 类应该关闭以进行更改,但可以扩展。如果您通过条件构造管理条件操作,那么如果添加了新条件,则您的类需要更改。如果使用多态,则基类不需要更改。

  • 不要重演 - 指南的一个重要部分是“相同的条件”。这表明您的类具有一些可以在类中考虑的不同操作模式。然后,该条件出现在代码中的一个位置 - 当您为该模式实例化对象时。而且,如果出现一个新的,你只需要改变一段代码。

答案 5 :(得分:2)

多态性是OO的基石之一,当然非常有用。 通过划分多个类的关注点,可以创建隔离的和可测试的单元。 因此,不要进行切换...在几种不同类型或实现上调用方法的情况下,您可以创建一个统一的接口,具有多个实现。 当您需要添加实现时,您不需要修改客户端,就像switch ... case一样。非常重要,因为这有助于避免回归。

您还可以通过处理一种类型来简化客户端算法:接口。

对我来说非常重要的是,多态性最好与纯接口/实现模式一起使用(如古老的Shape&lt; - Circle等...)。 您还可以使用模板方法(也称为钩子)在具体类中具有多态性,但随着复杂性的增加,其有效性会降低。

多态性是我们公司代码库构建的基础,因此我认为它非常实用。

答案 6 :(得分:2)

开关和多态性做同样的事情。

在多态(以及一般的基于类的编程)中,您可以按类型对函数进行分组。使用开关时,您可以按功能对类型进行分组。确定哪种观点对你有好处。

因此,如果你的界面是固定的,你只添加新类型,那么多态就是你的朋友。 但是,如果向界面添加新功能,则需要更新所有实现。

在某些情况下,您可能拥有固定数量的类型,并且可以使用新功能,然后切换更好。但添加新类型会让您更新每个开关。

使用开关,您可以复制子类型列表。使用多态性,您正在复制操作列表。你交换了一个问题来获得另一个问题。这就是所谓的expression problem,我所知道的任何编程范例都没有解决这个问题。问题的根源是用于表示代码的文本的一维特性。

由于亲多态性点在这里得到了很好的讨论,让我提供一个支持切换点。

OOP有设计模式以避免常见的陷阱。程序编程也有设计模式(但是没有人把它写下来但AFAIK,我们需要另一个新的Gang of N来制作这本书的畅销书......)。一种设计模式可能始终包含默认情况

开关可以正确完成:

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);
}

此代码会将您喜欢的调试器指向您忘记处理案例的位置。编译器可以强制您实现您的界面,但是强制您彻底测试您的代码(至少看到新案例被注意到)。

当然,如果一个特定的开关会被多个地方使用,那么它就被切成一个函数(不要重复)。

如果你想扩展这些开关,只需执行一个grep 'case[ ]*T_BAR' rn .(在Linux上),它会吐出值得关注的位置。由于您需要查看代码,您将看到一些帮助您正确添加新案例的上下文。当您使用多态时,调用站点将隐藏在系统内部,并且您依赖于文档的正确性(如果存在的话)。

扩展开关也不会破坏OCP,因为您不会改变现有案例,只需添加新案例。

开关还可以帮助下一个尝试习惯并理解代码的人:

  • 可能的情况在你眼前。阅读代码时这是一件好事(少跳过)。
  • 但虚拟方法调用就像普通方法调用一样。人们永远无法知道呼叫是虚拟的还是正常的(没有查找课程)。那太糟糕了。
  • 但如果调用是虚拟的,可能的情况并不明显(没有找到所有派生类)。那也不错。

当您向第三方提供接口时,他们可以向系统添加行为和用户数据,那么这是另一回事。 (他们可以设置回调和指向用户数据的指针,并为他们提供句柄)

可在此处找到进一步的辩论:http://c2.com/cgi/wiki?SwitchStatementsSmell

我担心我的“C-hacker综合症”和反OOPism最终会在这里烧掉我所有的声誉。但是每当我需要或不得不将某些内容破解或插入过程C系统时,我发现它很容易,缺少约束,强制封装和较少的抽象层使我“只是去做”。但是在C ++ / C#/ Java系统中,在软件的生命周期中,数十个抽象层堆叠在一起,我需要花费很多时间才能找到如何正确解决其他程序员的所有约束和限制内置于他们的系统中以避免其他人“搞乱他们的班级”。

答案 7 :(得分:1)

这主要与知识的封装有关。让我们从一个非常明显的例子开始 - toString()。这是Java,但很容易转移到C ++。假设您要打印对象的人类友好版本以进行调试。你可以这样做:

switch(obj.type): {
case 1: cout << "Type 1" << obj.foo <<...; break;   
case 2: cout << "Type 2" << ...
然而,这显然是愚蠢的。为什么某个方法应该知道如何打印所有内容。对象本身通常更好地知道如何打印自己,例如:

cout << object.toString();

这样,toString()可以访问成员字段而无需强制转换。它们可以独立测试。它们可以很容易地改变。

然而,你可以争辩说,对象打印的方式不应该与对象相关联,它应该与print方法相关联。在这种情况下,另一种设计模式很有用,这就是访客模式,用于伪造Double Dispatch。完全描述它对于这个答案来说太长了,但你可以read a good description here

答案 8 :(得分:0)

如果您理解非常好。

还有两种多态性。第一个在java-esque中很容易理解:

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();

可能不是您所期望的。

只要敏锐地意识到你做了什么,不知道你是否正在使用运行时多态性。不要过于自信,如果你不确定在运行时会做什么,那就测试一下。

答案 9 :(得分:0)

如果你在任何地方使用switch语句,你可能会在升级时错过一个需要更新的地方。

答案 10 :(得分:0)

我必须重新发现这一发现,所有的交换机状态都可能是成熟代码库中的一个非常重要的过程。如果你错过任何一个,那么除非你有默认设置,否则应用程序很可能因为一个无法匹配的case语句而崩溃。

另见“马丁福勒斯”关于“重构”的书 使用开关而不是多态是代码气味。

答案 11 :(得分:-2)

这取决于你的编程风格。虽然这在Java或C#中可能是正确的,但我不同意自动决定使用多态是正确的。例如,您可以将代码拆分为许多小函数,并使用函数指针(在编译时初始化)执行数组查找。在C ++中,多态性和类经常被过度使用 - 可能是人们从强大的OOP语言到C ++的最大设计错误就是一切都进入了一个类 - 这不是真的。一个类应该只包含使它作为一个整体工作的最小的东西。如果一个子类或朋友是必要的,那么它也是如此,但它们不应该是常态。该类上的任何其他操作应该是同一命名空间中的自由函数; ADL将允许在不查找的情况下使用这些功能。

C ++不是一种OOP语言,不是一种语言。它与在C ++中编程C一样糟糕。