我在.Net,C#商店工作,并且我有一位同事一直坚持认为,我们应该在代码中使用带有大量“案例”而不是更多面向对象方法的巨型Switch语句。他的论断始终可以回溯到以下事实:Switch语句会编译为“ cpu跳转表”,因此是最快的选择(即使在其他情况下,我们的团队被告知我们不在乎速度)。
老实说,我对此没有争议……因为我不知道他在说什么。
是吗
他只是在说他的屁股吗?
只是想在这里学习。
我在.Net,C#商店工作,并且我有一位同事一直坚持认为,我们应该在代码中使用带有大量“案例”而不是更多面向对象方法的巨型Switch语句。他的论断始终可以回溯到以下事实:Switch语句会编译为“ cpu跳转表”,因此是最快的选择(即使在其他情况下,我们的团队被告知我们不在乎速度)。
老实说,我对此没有争议……因为我不知道他在说什么。
是吗
他只是在说他的屁股吗?
只是想在这里学习。
Answers:
他可能是个老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语句会更好。
除非您的同事能够提供证明,证明这种更改可以在整个应用程序的范围内提供实际的可衡量的好处,否则它不如您的方法(即多态性)好,后者确实提供了这样的好处:可维护性。
只有确定瓶颈后,才能进行微优化。过早的优化是万恶之源。
速度是可量化的。“方法A比方法B更快”中几乎没有有用的信息。问题是“ 快多少? ”。
谁在乎它是否更快?
除非您正在编写实时软件,否则以完全疯狂的方式进行操作可能会获得微不足道的提速对客户产生很大的影响。我什至不打算在速度方面与这个问题作斗争,这个人显然不会听取任何关于这个问题的争论。
但是,可维护性是游戏的目标,巨大的switch声明甚至无法维护,您如何向新手解释代码的不同途径?文档必须与代码本身一样长!
另外,您还完全无法有效地进行单元测试(太多的可能路径,更不用说可能缺少接口等),这使您的代码更难以维护。
[有趣的是:JITter在较小的方法上表现更好,因此,巨大的switch语句(及其固有的大型方法)将损害IIRC在大型程序集中的速度。]
远离switch语句...
这种类型的switch语句应像瘟疫一样避免,因为它违反了Open Closed Principle。当需要添加新功能时,它迫使团队对现有代码进行更改,而不是仅添加新代码。
我度过了由大量switch语句操纵的大规模有限状态机的噩梦,幸免于难。更糟糕的是,在我的情况下,FSM跨越了三个C ++ DLL,这很简单,代码是由精通C的人编写的。
您需要关注的指标是:
我承担了向该组DLL添加新功能的任务,并且能够说服管理层,将3个DLL重写为一个面向对象的DLL所花费的时间与我的猴子补丁一样多。陪审团将解决方案装配到已经存在的解决方案中。重写是巨大的成功,因为它不仅支持新功能,而且扩展起来也容易得多。实际上,一项通常需要一周的时间来确保您没有损坏任何东西的任务最终将花费几个小时。
那么执行时间呢?没有速度增加或减少。公平地说,我们的性能受到系统驱动程序的限制,因此,如果面向对象的解决方案实际上速度较慢,我们将不会知道。
面向对象语言的大量switch语句有什么问题?
您不能说服我:
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版本更易于维护。
通常,我讨厌“过早的优化”这个词,但是这很讨厌。值得注意的是,Knuth在推动使用goto
语句的上下文中使用了这个著名的引号,以加快关键区域中的代码的速度。这就是关键:关键路径。
他建议使用它goto
来加快代码的速度,但警告那些希望基于直觉和迷信来执行这些类型的事情的程序员,这些想法甚至都不是至关重要的。
在整个代码库中switch
尽可能统一地支持语句(无论是否处理任何重负载)是Knuth所说的“精打细算”的程序员的经典示例,该程序员整日都在努力保持其“优化” ”代码,因为试图节省几分钱而变成了调试恶梦。这样的代码很少可维护,更不用说效率高了。
是吗
从最基本的效率角度来看,他是正确的。据我所知,没有哪个编译器能比switch语句更好地优化涉及对象和动态调度的多态代码。您永远都不会以LUT或跳转表到达多态代码中的内联代码,因为这样的代码往往会成为编译器的优化器障碍(在动态分配之前,它不知道要调用哪个函数)发生)。
不要以跳转表的角度考虑此成本,而要以优化障碍的角度考虑,这会更有用。对于多态性,调用Base.method()
不允许编译器知道哪个函数实际上method
是虚拟的,未密封的且可以被覆盖的最终被调用。由于它不知道实际上要提前调用哪个函数,因此它无法优化该函数调用,而无法利用更多信息进行优化决策,因为它实际上并不知道该调用哪个函数。代码被编译的时间。
当优化程序可以窥探函数调用并进行优化以完全使调用者和被调用者扁平化,或者至少优化调用者以最有效地与被调用者一起工作时,它们就处于最佳状态。如果他们不知道实际上要提前调用哪个函数,他们将无法执行此操作。
他只是在说他的屁股吗?
使用通常等于几分钱的费用来证明将其变成统一应用的编码标准通常是非常愚蠢的,尤其是对于具有可扩展性需求的地方。这是您使用真正的过早优化器要提防的主要问题:他们希望将较小的性能问题转变为在整个代码库中统一应用的编码标准,而不考虑可维护性。
不过,我对接受的答案中使用的“老C黑客”语录有点冒犯,因为我就是其中之一。并非所有从非常有限的硬件开始进行编码已有数十年历史的人都已经成为过早的优化器。但是我也曾经遇到过并与之合作。但是这些类型从未度量过分支错误预测或高速缓存未命中之类的事情,他们认为自己了解得更多,并且将效率低下的概念建立在基于迷信的复杂生产代码库中,这些迷信今天不成立,有时甚至不成立。真正在性能至关重要的领域工作过的人通常会理解有效的优化是有效的优先级划分,而试图将可维护性降低的编码标准推广出去以节省便士则是非常无效的优先级划分。
如果您的廉价函数无法执行那么多的工作(在一个非常紧迫且对性能至关重要的循环中被称为十亿次),便士便非常重要。在这种情况下,我们最终节省了1000万美元。当您拥有一个被称为两次功能的功能,仅身体一个功能就需要花费数千美元。在汽车购买过程中花时间讨价还价是不明智的。如果您要从制造商那里购买一百万罐苏打水,那很值得讨价还价。有效优化的关键是在适当的情况下了解这些成本。有人尝试在每次购买时节省几分钱,并建议其他人不管购买什么都试图讨价还价不是熟练的优化器。
听起来您的同事非常在意绩效。在某些情况下,大型案例/交换机结构可能会执行得更快,但是希望你们可以通过对OO版本和交换机/案例版本进行时序测试来进行实验。我猜OO版本的代码更少,并且更易于遵循,理解和维护。我将首先考虑OO版本(因为维护/可读性起初应该更为重要),并且仅当OO版本存在严重的性能问题并且可以证明开关/案例将使OO /版本出现问题时,才考虑使用switch / case版本。很显着的提高。
没有人提到的多态性的可维护性优势是,如果您始终切换相同的案例列表,则可以使用继承更好地构造代码,但是有时某些案例以相同的方式处理,有时不是
例如。如果你之间切换Dog
,Cat
并且Elephant
,有时Dog
并Cat
有相同的情况下,你可以让他们无论是从一个抽象类继承DomesticAnimal
,并把这些功能的抽象类。
另外,令我惊讶的是,有几个人使用解析器作为不使用多态的示例。对于类似树的解析器,这绝对是错误的方法,但是如果您有某种类似于汇编的东西,其中每一行在某种程度上都是独立的,并且从一个指示如何解释其余行的操作码开始,我将完全使用多态和工厂。每个类可以实现类似ExtractConstants
或的功能ExtractSymbols
。我已将这种方法用于玩具BASIC解释器。
即使这对可维护性也不错,但我不认为这对性能会更好。虚函数调用只是一个额外的间接调用(与switch语句的最佳情况相同),因此即使在C ++中,性能也应大致相等。在所有函数调用都是虚拟的C#中,由于两个版本中的虚拟函数调用开销相同,所以switch语句应该更糟。
就有关跳转表的评论而言,您的同事并没有退缩。但是,用它来证明编写不良代码是正确的。
C#编译器仅将少数情况下的switch语句转换为一系列的if / else,因此这并不比使用if / else快。编译器将较大的switch语句转换为Dictionary(您的同事引用的跳转表)。有关更多详细信息,请参见有关该主题的“堆栈溢出”问题的此答案。
大型switch语句很难阅读和维护。“案例”和函数的字典更易于阅读。由于这就是转换的结果,因此建议您和您的同事直接使用词典。
他并不一定在说话。至少在C和C ++ switch
语句中,可以优化语句以跳转表,而我从未见过在只能访问基本指针的函数中通过动态分派发生这种情况。至少,后者需要一个更聪明的优化器,该优化器查看更多周围的代码以准确确定通过基指针/引用从虚拟函数调用中使用的子类型。
最重要的是,动态调度通常充当“优化障碍”,这意味着编译器通常将无法内联代码并优化分配寄存器以最大程度地减少堆栈溢出和所有这些花哨的东西,因为它无法弄清楚什么虚拟函数将通过基本指针被调用以内联它并执行其所有优化魔术。我不确定您甚至不希望优化器如此聪明并尝试优化间接函数调用,因为这可能导致必须在给定的调用堆栈中分别生成许多代码分支(调用foo->f()
将具有生成与调用完全不同的机器代码bar->f()
通过基本指针,然后调用该函数的函数将必须生成两个或多个版本的代码,依此类推-生成的机器代码数量将是爆炸性的-对于跟踪JIT来说可能不是那么糟糕通过热执行路径进行跟踪时,可以即时生成代码。
但是,正如许多答案所反映的那样,这是支持大量switch
语句的不好理由,即使它的下手速度快了一些。此外,谈到微效率,与诸如内存访问模式之类的东西相比,诸如分支和内联之类的东西通常没有那么高的优先级。
就是说,我跳进了一个不寻常的答案。我想说明一种情况,即switch
只有当您肯定知道只有一个地方需要执行时,语句才具有多态解决方案的可维护性switch
。
一个主要的例子是中央事件处理程序。在那种情况下,通常没有很多地方可以处理事件,只有一个(为什么是“中央”)。在这些情况下,您将无法从多态解决方案提供的可扩展性中受益。当有很多地方可以进行类比switch
陈述时,多态解决方案将是有益的。如果您确定只有一个,那么switch
有15种情况的语句要比设计由15个具有重写功能的子类型继承的基类和工厂实例化它们简单得多,然后将其实例化,然后在一个函数中使用在整个系统中。在这些情况下,添加新的子类型比向case
一个函数添加语句要乏味得多。如果有的话,我主张的是可维护性,而不是性能,switch
在这种特殊情况下,您不会从任何可扩展性中受益。