为什么使用OO方法而不是巨大的“ switch”语句?


59

我在.Net,C#商店工作,并且我有一位同事一直坚持认为,我们应该在代码中使用带有大量“案例”而不是更多面向对象方法的巨型Switch语句。他的论断始终可以回溯到以下事实:Switch语句会编译为“ cpu跳转表”,因此是最快的选择(即使在其他情况下,我们的团队被告知我们不在乎速度)。

老实说,我对此没有争议……因为我不知道他在说什么。
是吗
他只是在说他的屁股吗?
只是想在这里学习。


7
您可以使用.NET Reflector之类的代码查看汇编代码并查找“ cpu跳转表”,以验证他是否正确。
FrustratedWithFormsDesigner

5
“ Switch语句将编译为“ cpu跳转表”。所有纯虚拟函数的最坏情况方法调度也是如此。没有虚拟函数直接直接链接。您是否转储了要比较的代码?
S.Lott

64
应该为人而不是为机器编写代码,否则我们将只进行汇编中的所有事情。
maple_shaft

8
如果他的想法如此之大,请引用库努斯的话:“我们应该忘记效率低下的问题,大约有97%的时间是这样:过早的优化是万恶之源。”
DaveE 2011年

12
可维护性。还有其他一个单词的问题可以帮助您吗?
马特·艾伦

Answers:


48

他可能是个老C黑客,是的,他说的很蠢。.Net不是C ++;.Net编译器不断发展,如果不是今天,那么聪明的黑客会适得其反。最好使用小功能,因为.Net JIT在使用每个功能之前都会对其进行一次操作。因此,如果某些情况在程序的生命周期中从未受到打击,那么在JIT编译这些情况下不会产生任何费用。无论如何,如果速度不是问题,则不应进行优化。首先为程序员编写,然后为编译器编写。您的同事不会轻易被说服,因此我将凭经验证明更好的组织代码实际上更快。我会选择他最糟糕的例子之一,以更好的方式重写它们,然后确保您的代码更快。如果需要,请选择樱桃。然后运行数百万次,剖析并显示给他。

编辑

比尔·瓦格纳写道:

第11项:了解小功能的吸引力(有效的C#第二版)请记住,将C#代码转换为机器可执行代码是一个两步过程。C#编译器生成IL,这些IL将以程序集形式交付。JIT编译器根据需要为每个方法(或涉及内联的方法组)生成机器代码。小功能使JIT编译器更容易摊销该成本。小型函数也更有可能成为内联的候选对象。这不仅是小问题,更简单的控制流程也同样重要。函数内较少的控制分支使JIT编译器更易于注册变量。编写更清晰的代码不仅是一种好习惯;这就是您在运行时创建更高效​​代码的方式。

编辑2:

所以...显然,switch语句比一堆if / else语句更快,更好,因为一个比较是对数的,另一个是线性的。 http://sequence-points.blogspot.com/2007/10/why-is-switch-statement-faster-than-if.html

好吧,我最喜欢的替换巨大的switch语句的方法是使用字典(如果我打开枚举或小整数,有时甚至是数组),该字典将值映射到响应它们而调用的函数。这样做会迫使人们删除很多讨厌的共享意大利面状态,但这是一件好事。较大的开关声明通常是维护的噩梦。因此...使用数组和字典,查找将花费固定时间,并且几乎不会浪费额外的内存。

我仍然不相信switch语句会更好。


47
不用担心会更快地证明它。这是过早的优化。与您忘记将索引添加到数据库(该索引花费200毫秒)相比,您可以节省的毫秒数不算什么。您正在打错仗。
Rein Henrichs

27
@Job如果他实际上是正确的怎么办?重点不是他错了,而是重点是他是对的,没关系
Rein Henrichs

2
即使他是正确的,在大约100%的案件中,他仍然在浪费我们的时间。
杰里米(Jeremy)

6
我想仔细阅读一下您链接的页面。
AttackingHobo

3
C ++仇恨是什么?C ++编译器也变得越来越好,并且由于完全相同的原因,大型开关在C ++中与在C#中一样糟糕。如果您被前C ++程序员所包围,这会让您感到悲伤,那不是因为他们是C ++程序员,而是因为他们是不好的程序员。
塞巴斯蒂安·雷德尔2014年

39

除非您的同事能够提供证明,证明这种更改可以在整个应用程序的范围内提供实际的可衡量的好处,否则它不如您的方法(即多态性)好,后者确实提供了这样的好处:可维护性。

只有确定瓶颈,才能进行微优化。过早的优化是万恶之源

速度是可量化的。“方法A比方法B更快”中几乎没有有用的信息。问题是“ 快多少? ”。


2
绝对真实。永远不要声称事物更快,总是要衡量。并且仅在应用程序的那部分是性能瓶颈时才进行测量。
Kilian Foth

6
-1表示“过早的优化是万恶之源”。请显示整个报价,而不是仅显示一部分歪曲Knuth观点的内容。
替代

2
@mathepic:我故意没有以报价的形式出现。这句话是我个人的看法,尽管当然不是我的创造。尽管可能会注意到,c2的家伙似乎只是将这部分视为核心智慧。
back2dos

8
@alternative完整的Knuth语录:“毫无疑问,效率的高低会导致滥用。程序员浪费大量时间来思考或担心程序的非关键部分的速度,而这些提高效率的尝试实际上具有在考虑调试和维护时会产生严重的负面影响。我们应该忽略效率低下的问题,例如大约97%的效率:过早的优化是万恶之源。” 完美地描述了OP的同事。恕我直言,back2dos用“过早的优化是万恶之源”对报价进行了很好的总结
MarkJ

2
@MarkJ 97%的时间
替代

27

谁在乎它是否更快?

除非您正在编写实时软件,否则以完全疯狂的方式进行操作可能会获得微不足道的提速对客户产生很大的影响。我什至不打算在速度方面与这个问题作斗争,这个人显然不会听取任何关于这个问题的争论。

但是,可维护性是游戏的目标,巨大的switch声明甚至无法维护,您如何向新手解释代码的不同途径?文档必须与代码本身一样长!

另外,您还完全无法有效地进行单元测试(太多的可能路径,更不用说可能缺少接口等),这使您的代码更难以维护。

[有趣的是:JITter在较小的方法上表现更好,因此,巨大的switch语句(及其固有的大型方法)将损害IIRC在大型程序集中的速度。]


1
+一个过早优化的巨大例子。
ShaneC 2011年

绝对是这个。
DeadMG

为“巨大的转换声明甚至无法维持”而+1
Korey Hinton 2013年

2
新手更容易理解一个巨大的switch语句:所有可能的行为都收集在一个整洁的列表中。间接调用极其困难,在最坏的情况下(函数指针),您需要在整个代码库中搜索正确签名的功能,而虚拟调用则要好一些(搜索正确名称和签名的功能,通过继承关联)。但是可维护性不是只读的。
Ben Voigt 2014年

14

远离switch语句...

这种类型的switch语句应像瘟疫一样避免,因为它违反了Open Closed Principle。当需要添加新功能时,它迫使团队对现有代码进行更改,而不是仅添加新代码。


11
需要注意的是。有操作(功能/方法)和类型。添加新操作时,只需要在一个地方更改switch语句的代码(使用switch语句添加一个新功能),但是在OO情况下,您必须将该方法添加到所有类中(否则将打开) /封闭原则)。如果添加新类型,则必须触摸每个switch语句,但是在OO情况下,只需再添加一个类。因此,要做出明智的决定,您必须知道是要向现有类型添加更多操作还是添加更多类型。
Scott Whitlock,

3
如果您需要在OO范式中向现有类型添加更多操作而又不违反OCP,那么我相信这就是访客模式的目的。
斯科特·惠特洛克

3
@Martin-可以的话叫名字,但这是一个众所周知的折衷方案。我推荐您使用RC Martin的Clean Code。他重新审视了有关OCP的文章,解释了我上面概述的内容。您不能同时为将来的所有需求进行设计。您必须在是添加更多操作还是添加更多类型之间做出选择。OO支持添加类型。如果将操作建模为类,则可以使用OO添加更多操作,但这似乎已进入访客模式,这有其自身的问题(特别是开销)。
Scott Whitlock,

8
@马丁:你曾经写过一个解析器吗?在超前缓冲区中打开下一个令牌时,通常会有大型的切换案例。您可以将这些开关替换为对下一个令牌的虚拟函数调用,但这将成为维护的噩梦。这种情况很少见,但有时切换案例实际上是更好的选择,因为它使应该将应被读取/修改的代码紧密地保持在一起。
尼基,

1
@Martin:您使用了“从不”,“从不”和“ Poppycock”之类的词,因此我假设您是在谈论所有情况,没有例外,而不仅仅是最常见的情况。(顺便说一句:人们仍然是手动编写解析器。例如,
CIRC

8

我度过了由大量switch语句操纵的大规模有限状态机的噩梦,幸免于难。更糟糕的是,在我的情况下,FSM跨越了三个C ++ DLL,这很简单,代码是由精通C的人编写的。

您需要关注的指标是:

  • 改变的速度
  • 发生问题时发现问题的速度

我承担了向该组DLL添加新功能的任务,并且能够说服管理层,将3个DLL重写为一个面向对象的DLL所花费的时间与我的猴子补丁一样多。陪审团将解决方案装配到已经存在的解决方案中。重写是巨大的成功,因为它不仅支持新功能,而且扩展起来也容易得多。实际上,一项通常需要一周的时间来确保您没有损坏任何东西的任务最终将花费几个小时。

那么执行时间呢?没有速度增加或减少。公平地说,我们的性能受到系统驱动程序的限制,因此,如果面向对象的解决方案实际上速度较慢,我们将不会知道。

面向对象语言的大量switch语句有什么问题?

  • 程序控制流从其所属的对象中移出并放置在对象外部
  • 外部控制的许多方面都转化为您需要检查的许多地方
  • 目前尚不清楚状态存储在何处,特别是如果开关在循环内
  • 最快的比较是完全没有比较(使用良好的面向对象设计,可以避免进行多次比较)
  • 遍历对象并始终在所有对象上调用相同的方法要比基于对象类型或编码该类型的枚举更改代码的效率更高。

8

我不赞成表现论点。这全都与代码的可维护性有关。

但是:有时候,巨大的switch语句比一堆覆盖抽象基类的虚函数的小类更易于维护(更少的代码)。例如,如果要实现CPU仿真器,则不会在单独的类中实现每条指令的功能-您只需将其塞入操作码中的一个巨大的swtich中,可能会调用辅助函数来获取更复杂的指令。

经验法则:如果在TYPE上以某种方式执行了切换,则可能应该使用继承和虚函数。如果在固定类型的VALUE上执行切换(例如,上述操作码指令),则可以保持原样。


5

您不能说服我:

void action1()
{}

void action2()
{}

void action3()
{}

void action4()
{}

void doAction(int action)
{
    switch(action)
    {
        case 1: action1();break;
        case 2: action2();break;
        case 3: action3();break;
        case 4: action4();break;
    }
}

明显快于:

struct IAction
{
    virtual ~IAction() {}
    virtual void action() = 0;
}

struct Action1: public IAction
{
    virtual void action()    { }
}

struct Action2: public IAction
{
    virtual void action()    { }
}

struct Action3: public IAction
{
    virtual void action()    { }
}

struct Action4: public IAction
{
    virtual void action()    { }
}

void doAction(IAction& actionObject)
{
    actionObject.action();
}

另外,OO版本更易于维护。


8
对于某些事情和较少的动作,OO版本要愚蠢得多。它必须具有某种工厂才能将某些价值转换为IAction的创建。在许多情况下,仅打开该值更具可读性。
Zan Lynx

@Zan Lynx:您的论点太笼统了。创建IAction对象就像检索动作整数一样困难,不容易,不容易。因此,我们可以进行真正的对话,而不必泛泛泛滥。考虑一个计算器。这里的复杂性有何不同?答案是零。由于所有动作都是预先创建的。您从用户那里获得了输入,它已经是一个动作。
马丁·约克

3
@马丁:你假设一个GUI计算器应用程序。让我们取而代之的是在嵌入式系统上为C ++编写的键盘计算器应用程序。现在您有了来自硬件寄存器的扫描码整数。现在,什么更简单?
Zan Lynx

2
@Martin:您看不到整数->查找表->创建新对象->调用虚拟函数比整数->开关->函数如何复杂吗?你怎么看不到?
Zan Lynx

2
@马丁:也许我会。同时,请说明如何获取IAction对象以从不具有查找表的整数调用action()。
Zan Lynx

4

他是正确的,所产生的机器代码可能会更高效。编译器必须将switch语句转换为一组测试和分支,这将是相对较少的指令。由更多抽象方法生成的代码很有可能需要更多指令。

但是:几乎可以肯定,您的特定应用程序不必担心这种微优化,或者您一开始就不会使用.net。对于缺少非常受限的嵌入式应用程序或CPU密集型工作的任何事情,都应始终让编译器进行优化。专注于编写干净,可维护的代码。与执行时间的十分之几十分之一相比,这几乎总是具有巨大的价值。


3

使用类而不是switch语句的一个主要原因是switch语句倾向于导致一个具有很多逻辑的大文件。这既是维护的噩梦,也是源代码管理的问题,因为您必须检出并编辑该大文件,而不是其他较小的类文件


3

OOP代码中的switch语句强烈表明缺少类

两种方式都可以尝试并运行一些简单的速度测试;机会是相差不大。如果是,并且代码是时间关键的,则保留switch语句


3

通常,我讨厌“过早的优化”这个词,但是这很讨厌。值得注意的是,Knuth在推动使用goto语句的上下文中使用了这个著名的引号,以加快关键区域中的代码的速度。这就是关键:关键路径。

他建议使用它goto来加快代码的速度,但警告那些希望基于直觉和迷信来执行这些类型的事情的程序员,这些想法甚至都不是至关重要的。

在整个代码库中switch尽可能统一地支持语句(无论是否处理任何重负载)是Knuth所说的“精打细算”的程序员的经典示例,该程序员整日都在努力保持其“优化” ”代码,因为试图节省几分钱而变成了调试恶梦。这样的代码很少可维护,更不用说效率高了。

是吗

从最基本的效率角度来看,他是正确的。据我所知,没有哪个编译器能比switch语句更好地优化涉及对象和动态调度的多态代码。您永远都不会以LUT或跳转表到达多态代码中的内联代码,因为这样的代码往往会成为编译器的优化器障碍(在动态分配之前,它不知道要调用哪个函数)发生)。

不要以跳转表的角度考虑此成本,而要以优化障碍的角度考虑,这会更有用。对于多态性,调用Base.method()不允许编译器知道哪个函数实际上method是虚拟的,未密封的且可以被覆盖的最终被调用。由于它不知道实际上要提前调用哪个函数,因此它无法优化该函数调用,而无法利用更多信息进行优化决策,因为它实际上并不知道该调用哪个函数。代码被编译的时间。

当优化程序可以窥探函数调用并进行优化以完全使调用者和被调用者扁平化,或者至少优化调用者以最有效地与被调用者一起工作时,它们就处于最佳状态。如果他们不知道实际上要提前调用哪个函数,他们将无法执行此操作。

他只是在说他的屁股吗?

使用通常等于几分钱的费用来证明将其变成统一应用的编码标准通常是非常愚蠢的,尤其是对于具有可扩展性需求的地方。这是您使用真正的过早优化器要提防的主要问题:他们希望将较小的性能问题转变为在整个代码库中统一应用的编码标准,而不考虑可维护性。

不过,我对接受的答案中使用的“老C黑客”语录有点冒犯,因为我就是其中之一。并非所有从非常有限的硬件开始进行编码已有数十年历史的人都已经成为过早的优化器。但是我也曾经遇到过并与之合作。但是这些类型从未度量过分支错误预测或高速缓存未命中之类的事情,他们认为自己了解得更多,并且将效率低下的概念建立在基于迷信的复杂生产代码库中,这些迷信今天不成立,有时甚至不成立。真正在性能至关重要的领域工作过的人通常会理解有效的优化是有效的优先级划分,而试图将可维护性降低的编码标准推广出去以节省便士则是非常无效的优先级划分。

如果您的廉价函数无法执行那么多的工作(在一个非常紧迫且对性能至关重要的循环中被称为十亿次),便士便非常重要。在这种情况下,我们最终节省了1000万美元。当您拥有一个被称为两次功能的功能,仅身体一个功能就需要花费数千美元。在汽车购买过程中花时间讨价还价是不明智的。如果您要从制造商那里购买一百万罐苏打水,那很值得讨价还价。有效优化的关键是在适当的情况下了解这些成本。有人尝试在每次购买时节省几分钱,并建议其他人不管购买什么都试图讨价还价不是熟练的优化器。


2

听起来您的同事非常在意绩效。在某些情况下,大型案例/交换机结构可能会执行得更快,但是希望你们可以通过对OO版本和交换机/案例版本进行时序测试来进行实验。我猜OO版本的代码更少,并且更易于遵循,理解和维护。我将首先考虑OO版本(因为维护/可读性起初应该更为重要),并且仅当OO版本存在严重的性能问题并且可以证明开关/案例将使OO /版本出现问题时,才考虑使用switch / case版本。很显着的提高。


1
除时序测试外,代码转储还可以帮助说明C ++(和C#)方法的调度方式。
S.Lott

2

没有人提到的多态性的可维护性优势是,如果您始终切换相同的案例列表,则可以使用继承更好地构造代码,但是有时某些案例以相同的方式处理,有时不是

例如。如果你之间切换DogCat并且Elephant,有时DogCat有相同的情况下,你可以让他们无论是从一个抽象类继承DomesticAnimal,并把这些功能的抽象类。

另外,令我惊讶的是,有几个人使用解析器作为不使用多态的示例。对于类似树的解析器,这绝对是错误的方法,但是如果您有某种类似于汇编的东西,其中每一行在某种程度上都是独立的,并且从一个指示如何解释其余行的操作码开始,我将完全使用多态和工厂。每个类可以实现类似ExtractConstants或的功能ExtractSymbols。我已将这种方法用于玩具BASIC解释器。


开关也可以通过默认情况继承行为。“ ...扩展BaseOperationVisitor”变为“默认值:BaseOperation(节点)”
Samuel Danielson

0

“我们应该忘记效率低下的问题,大约有97%的时间是这样:过早的优化是万恶之源”

唐纳德·克努斯


0

即使这对可维护性也不错,但我不认为这对性能会更好。虚函数调用只是一个额外的间接调用(与switch语句的最佳情况相同),因此即使在C ++中,性能也应大致相等。在所有函数调用都是虚拟的C#中,由于两个版本中的虚拟函数调用开销相同,所以switch语句应该更糟。


1
缺少“不”吗?在C#中,并非所有函数调用都是虚拟的。C#不是Java。
Ben Voigt 2014年

0

就有关跳转表的评论而言,您的同事并没有退缩。但是,用它来证明编写不良代码是正确的。

C#编译器仅将少数情况下的switch语句转换为一系列的if / else,因此这并不比使用if / else快。编译器将较大的switch语句转换为Dictionary(您的同事引用的跳转表)。有关更多详细信息,请参见有关该主题的“堆栈溢出”问题的此答案

大型switch语句很难阅读和维护。“案例”和函数的字典更易于阅读。由于这就是转换的结果,因此建议您和您的同事直接使用词典。


0

他并不一定在说话。至少在C和C ++ switch语句中,可以优化语句以跳转表,而我从未见过在只能访问基本指针的函数中通过动态分派发生这种情况。至少,后者需要一个更聪明的优化器,该优化器查看更多周围的代码以准确确定通过基指针/引用从虚拟函数调用中使用的子类型。

最重要的是,动态调度通常充当“优化障碍”,这意味着编译器通常将无法内联代码并优化分配寄存器以最大程度地减少堆栈溢出和所有这些花哨的东西,因为它无法弄清楚什么虚拟函数将通过基本指针被调用以内联它并执行其所有优化魔术。我不确定您甚至不希望优化器如此聪明并尝试优化间接函数调用,因为这可能导致必须在给定的调用堆栈中分别生成许多代码分支(调用foo->f()将具有生成与调用完全不同的机器代码bar->f() 通过基本指针,然后调用该函数的函数将必须生成两个或多个版本的代码,依此类推-生成的机器代码数量将是爆炸性的-对于跟踪JIT来说可能不是那么糟糕通过热执行路径进行跟踪时,可以即时生成代码。

但是,正如许多答案所反映的那样,这是支持大量switch语句的不好理由,即使它的下手速度快了一些。此外,谈到微效率,与诸如内存访问模式之类的东西相比,诸如分支和内联之类的东西通常没有那么高的优先级。

就是说,我跳进了一个不寻常的答案。我想说明一种情况,即switch只有当您肯定知道只有一个地方需要执行时,语句才具有多态解决方案的可维护性switch

一个主要的例子是中央事件处理程序。在那种情况下,通常没有很多地方可以处理事件,只有一个(为什么是“中央”)。在这些情况下,您将无法从多态解决方案提供的可扩展性中受益。当有很多地方可以进行类比switch陈述时,多态解决方案将是有益的。如果您确定只有一个,那么switch有15种情况的语句要比设计由15个具有重写功能的子类型继承的基类和工厂实例化它们简单得多,然后将其实例化,然后在一个函数中使用在整个系统中。在这些情况下,添加新的子类型比向case一个函数添加语句要乏味得多。如果有的话,我主张的是可维护性,而不是性能,switch 在这种特殊情况下,您不会从任何可扩展性中受益。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.