题
我最近偶然发现 谷歌测试博客中的此条目 关于编写更多可测试代码的指南。到目前为止,我同意作者的观点:
优先考虑多态而不是条件:如果您看到 switch 语句,您应该想到多态性。如果您在班级的许多地方看到相同的 if 条件重复,您应该再次考虑多态性。多态性会将复杂的类分解为几个更小的、更简单的类,这些类清楚地定义了哪些代码片段是相关的并一起执行。这有助于测试,因为更简单/更小的类更容易测试。
我根本无法理解这一点。我可以理解使用多态性而不是 RTTI(或 DIY-RTTI,视情况而定),但这似乎是一个如此广泛的声明,我无法想象它实际上在生产代码中有效使用。相反,在我看来,为具有 switch 语句的方法添加额外的测试用例会更容易,而不是将代码分解为数十个单独的类。
另外,我的印象是多态性可能会导致各种其他微妙的错误和设计问题,所以我很想知道这里的权衡是否值得。有人可以向我解释一下这个测试指南的含义吗?
解决方案
实际上,这使得测试和代码更容易编写。
如果您有一个基于内部字段的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类中捕获了所有这些功能,那么在发货之前需要定义所有动物(关闭/关闭)。
其他提示
不要害怕...
我猜你的问题在于熟悉,而不是技术。熟悉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被用作美化开关,这是错误的)
比较动态多态与静态多态
如果你的代码在编译时不知道对象的确切类型,那么使用动态多态(即经典继承,虚方法覆盖等)。
如果您的代码在编译时知道类型,那么也许您可以使用静态多态,即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应该分三个阶段完成它的过程:<!> quot;在<!> quot;之前,<!> quot; while <!> quot;并且<!>在; <!>之后;它的代码类似于:
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)。
交换机内部的相同代码非常有趣,我猜(如果只是根据我在工作中的应用程序中看到的<!> quot;历史<!>代码的数量来判断)。
单元测试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类。
我有点怀疑:我相信继承通常会增加复杂性而不是删除。
我认为你提出了一个很好的问题,我考虑的一件事是:
您是否因为处理不同的事物而分成多个类?或者它是否真的是一样的,以不同的方式行事?
如果它真的是一个新的类型,那么继续创建一个新类。但如果它只是一个选项,我通常将它保持在同一个类中。
我认为默认解决方案是单类解决方案,并且程序员提出继承以证明其案例的责任。
不是测试用例影响方面的专家,但从软件开发的角度来看:
开闭原则 -- 类应该禁止更改,但对扩展开放。如果您通过条件构造管理条件操作,那么如果添加新条件,您的类需要更改。如果使用多态性,则不需要更改基类。
不要重复自己 ——《指导意见》的一个重要内容是“相同的 if 条件。”这表明您的类具有一些可以分解为类的不同操作模式。然后,当您实例化该模式的对象时,该条件就会出现在代码中的一个位置。再说一次,如果有新的出现,您只需要更改一段代码。
多态性是OO的基石之一,当然非常有用。 通过划分多个类的关注点,可以创建隔离的和可测试的单元。 因此,不要进行切换...在几种不同类型或实现上调用方法的情况下,您可以创建一个统一的接口,具有多个实现。 当您需要添加实现时,您不需要修改客户端,就像switch ... case一样。非常重要,因为这有助于避免回归。
您还可以通过处理一种类型来简化客户端算法:接口。
对我来说非常重要的是,多态性最好与纯接口/实现模式一起使用(如古老的Shape <!> lt; - Circle等...)。 您还可以使用模板方法(也称为钩子)在具体类中具有多态性,但随着复杂性的增加,其有效性会降低。
多态性是我们公司代码库构建的基础,因此我认为它非常实用。
开关和多态性做同样的事情。
在多态性(以及一般基于类的编程)中,您可以按函数的类型对函数进行分组。使用开关时,您可以按功能对类型进行分组。确定哪种视图适合您。
因此,如果您的接口是固定的并且您只添加新类型,那么多态性就是您的朋友。但是,如果您向界面添加新功能,您将需要更新所有实现。
在某些情况下,你可能有固定数量的类型,并且可能会出现新功能,那么切换会更好。但是添加新类型会让您更新每个开关。
使用开关,您可以复制子类型列表。通过多态性,您可以复制操作列表。你用一个问题换来了另一个问题。这就是所谓的 表达问题, ,我所知道的任何编程范例都无法解决这个问题。问题的根源在于用于表示代码的文本的一维性质。
由于支持多态性的观点在这里得到了很好的讨论,所以让我提供一个支持转换的观点。
OOP 具有避免常见陷阱的设计模式。过程式编程也有设计模式(但还没有人把它写下来,据我所知,我们需要另一本新的 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 系统中,在软件的生命周期中,数十个抽象层相互堆叠,我需要花费大量时间(有时甚至数天)来找出如何正确解决其他程序员所面临的所有约束和限制。内置到他们的系统中以避免其他人“扰乱他们的班级”。
这主要与知识的封装有关。让我们从一个非常明显的例子开始 - 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。完全描述它的答案太长了,但你可以在这里阅读一个好的描述。
如果你在任何地方使用switch语句,你可能会在升级时错过一个需要更新的地方。
如果您理解,非常好。。
还有两种多态性。第一个在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();
可能不是您所期望的。
只要敏锐地意识到你做了什么,不知道你是否正在使用运行时多态性。不要过于自信,如果你不确定在运行时会做什么,那就测试一下。
我必须重新发现这一发现,所有的交换机状态都可能是成熟代码库中的一个非常重要的过程。如果你错过任何一个,那么除非你有默认设置,否则应用程序很可能因为一个无法匹配的case语句而崩溃。
同时查看<!>“Martin Fowlers <!>”;预订<!>“重构<!>”;
使用开关而不是多态是代码气味。
这取决于你的编程风格。虽然这在Java或C#中可能是正确的,但我不同意自动决定使用多态是正确的。例如,您可以将代码拆分为许多小函数,并使用函数指针(在编译时初始化)执行数组查找。在C ++中,多态性和类经常被过度使用 - 可能是人们从强大的OOP语言到C ++的最大设计错误就是一切都进入了一个类 - 这不是真的。一个类应该只包含使它作为一个整体工作的最小的东西。如果一个子类或朋友是必要的,那么它也是如此,但它们不应该是常态。该类上的任何其他操作应该是同一命名空间中的自由函数; ADL将允许在不查找的情况下使用这些功能。
C ++不是一种OOP语言,不是一种语言。它与在C ++中编程C一样糟糕。