39

我最近在谷歌测试博客中偶然发现了这篇关于编写更多可测试代码的指南。直到这一点,我都同意作者:

支持多态而不是条件:如果你看到一个 switch 语句,你应该考虑多态。如果您在课堂上的许多地方看到相同的 if 条件重复,您应该再次考虑多态性。多态性会将你的复杂类分解成几个更小的更简单的类,它们清楚地定义了哪些代码片段是相关的并一起执行。这有助于测试,因为更简单/更小的类更容易测试。

我根本无法解决这个问题。我可以理解使用多态性而不是 RTTI(或 DIY-RTTI,视情况而定),但这似乎是一个如此广泛的陈述,我无法想象它实际上被有效地用于生产代码。在我看来,更容易为具有 switch 语句的方法添加额外的测试用例,而不是将代码分解成几十个单独的类。

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

4

12 回答 12

72

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

如果你有一个基于内部字段的 switch 语句,你可能在多个地方有相同的 switch,做的事情略有不同。当您添加新案例时,这会导致问题,因为您必须更新所有 switch 语句(如果您能找到它们)。

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

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 标头派生的新类来添加新的 Animals ( Open for extension )。如果所有这些功能都已在 Animal 类中捕获,那么所有动物都需要在运输之前定义(已关闭/已关闭)。

于 2008-10-24T17:29:38.347 回答
26

不要害怕...

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

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

在其众多范式中,它具有 OOP 特性,并且能够支持与大多数纯 OO 语言的比较。

不要让“C++ 中的 C 部分”让您相信 C++ 无法处理其他范例。C++ 可以非常优雅地处理许多编程范式。其中,OOP C++是继过程范式(即前面提到的“C部分”)之后最成熟的C++范式。

多态性可以用于生产

没有“微妙的错误”或“不适合生产代码”的东西。有些开发人员保持自己的方式,有些开发人员将学习如何使用工具并为每项任务使用最好的工具。

开关和多态性[几乎]相似......

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

不同之处在于您必须手动处理开关,而多态性更自然,一旦您习惯了继承方法覆盖。

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

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

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

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

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

如果你使用 RTTI 作为一个美化的开关,你就错过了重点。

(免责声明:我是 RTTI 概念和 dynamic_casts 的忠实粉丝。但是必须为手头的任务使用正确的工具,而且大多数时候 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 ;
   }
}

每次添加命令都会添加一个案例。

问题是某些命令相似,并且部分共享了它们的实现。

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

我通过使用 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 应该分三个阶段执行其过程:“之前”、“期间”和“之后”。它的代码类似于:

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

我想,开关中的相同代码会很有趣(如果仅根据我在工作应用程序中看到的“历史”代码的数量来判断的话)。

于 2008-10-24T19:48:49.313 回答
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 类。

于 2008-10-24T17:33:11.410 回答
8

我有点怀疑:我相信继承通常增加的复杂性比它消除的复杂性要多。

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

您是否因为处理不同的事情而分成多个班级?还是真的是同一件事,以不同的方式行事

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

我相信默认的解决方案是单类解决方案,程序员有责任提出继承来证明他们的情况。

于 2008-10-24T17:56:33.330 回答
5

不是测试用例含义方面的专家,而是从软件开发的角度来看:

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

  • 不要重复自己——指南的一个重要部分是“相同的if 条件”。这表明您的班级有一些不同的操作模式可以纳入班级。然后,该条件出现在您的代码中的一个位置——当您为该模式实例化对象时。同样,如果出现新的,您只需要更改一段代码。

于 2008-10-24T17:33:40.380 回答
2

多态性是 OO 的基石之一,当然非常有用。通过将关注点划分为多个类,您可以创建独立且可测试的单元。因此,与其做一个 switch...case 来调用几种不同类型或实现的方法,不如创建一个具有多个实现的统一接口。当你需要添加一个实现时,你不需要修改客户端,就像 switch...case 一样。非常重要,因为这有助于避免回归。

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

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

多态是我们公司代码库建立的基础,所以我认为它非常实用。

于 2008-10-24T17:32:45.983 回答
2

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

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

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

在某些情况下,您可能有固定数量的类型,并且可以出现新功能,那么开关会更好。但是添加新类型会使您更新每个开关。

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

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

OOP 具有避免常见陷阱的设计模式。过程式编程也有设计模式(但还没有人写下来,AFAIK,我们需要另一个新的 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综合症”和反OOP主义最终会在这里烧毁我所有的名声。但是,每当我需要或不得不将某些东西破解或固定到程序 C 系统中时,我发现它很容易,缺乏约束、强制封装和较少的抽象层使我“就去做”。但是在一个 C++/C#/Java 系统中,在软件的生命周期中,数十个抽象层堆叠在一起,我需要花费数小时甚至数天时间来找出如何正确解决其他程序员的所有约束和限制内置到他们的系统中,以避免其他人“扰乱他们的班级”。

于 2013-03-13T15:17:07.717 回答
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。对于这个答案来说,完整地描述它太长了,但你可以在这里阅读一个很好的描述

于 2008-10-24T17:39:45.267 回答
0

如果您理解它,它会很好地工作。

还有2种多态性。第一个在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();

可能不是你所期望的。

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

于 2008-10-24T18:23:27.333 回答
0

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

于 2008-12-13T19:26:21.193 回答
0

我必须重申,在成熟的代码库中查找所有 switch 语句可能是一个不平凡的过程。如果你错过了任何一个,那么应用程序可能会因为不匹配的 case 语句而崩溃,除非你有默认设置。

另请查看有关“重构”的“Martin Fowlers”一书
使用开关而不是多态性是代码异味。

于 2009-01-19T16:42:44.853 回答
-1

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

C++ 不是一种 OOP 语言,不要让它成为一种语言。这就像在 C++ 中编程 C 一样糟糕。

于 2008-10-24T17:39:50.353 回答