指针解引用开销与分支/条件语句

12

在重循环中,例如游戏应用程序中发现的循环,可能有许多因素决定执行循环体的哪一部分(例如,角色对象将根据其当前状态以不同的方式进行更新),因此不要这样做:

void my_loop_function(int dt) {
  if (conditionX && conditionY) 
    doFoo();
  else
    doBar();

  ...
}

我习惯于使用函数指针,该指针指向与字符当前状态相对应的逻辑函数,例如:

void (*updater)(int);

void something_happens() {
  updater = &doFoo;
}
void something_else_happens() {
  updater = &doBar;
}
void my_loop_function(int dt) {
  (*updater)(dt);

  ...
}

在我不想执行任何操作的情况下,我定义了一个虚拟函数,并在需要时指向它:

void do_nothing(int dt) { }

我现在真正想知道的是:我是否过分纠结于这个问题了?当然,上面给出的例子很简单;有时候我需要检查许多变量才能确定我需要执行哪些代码片段,因此我想到使用这些“状态”函数指针确实更加优化,对我来说也更自然,但是一些我正在处理的人强烈反对。

那么,使用(虚)函数指针是否值得,而不是用条件语句填充循环以流程控制逻辑呢?

编辑:为了澄清指针是如何设置的,它是通过每个对象的事件处理来完成的。当事件发生并且,比如,该字符带有自定义逻辑时,它会在该事件处理程序中设置更新程序指针,直到再次发生另一个事件,这将再次改变流程。

谢谢


你有遇到性能问题吗?我感觉你在考虑过早优化的问题。你应该以可维护的方式编写逻辑。如果以后出现性能问题,仍然有时间对代码进行分析和优化。 - ereOn
3
编写清晰的代码。在优化之前先进行测量。 - Cheers and hth. - Alf
我们这个行业的美妙之处在于,你不必相信一个解决方案,也没有主观性的空间。你实际上可以测量性能。要么编写一个实际的基准程序(我现在太懒了),要么在应用程序中进行测试。 - bitmask
@Alf:编写清晰的代码 <-- !?! --> 在优化之前进行测量 b4↯ <lol type="nervous fit"/> - sehe
在这种情况下,我会小心地跳上“过早优化”的车轮:在我看来,使用函数指针的代码比使用条件语句的代码更清晰,而且最重要的是更加灵活。这些优点比速度更重要。 - stijn
5个回答

6
函数指针方法可以让过渡变得异步。不仅将dt传递给updater,还要传递对象。现在,更新程序本身可以负责状态转换。这样,状态转换逻辑就局部化了,而不是将其全局化在一个丑陋的if ... else if ... else if ...函数中。
至于间接寻址的成本,您在意吗?如果您的更新程序非常小,那么间接寻址加上函数调用的成本可能会压倒执行更新程序代码的成本。如果更新程序具有任何复杂性,则该复杂性将超过此灵活性的成本。

如果我没有误解你的回答,在我的代码中,这一切都是针对每个对象处理的,并且状态转换是通过事件完成的。当某个对象对事件感兴趣并且发生时,它会相应地修改更新器。我以上述C风格编写代码只是为了清晰明了,没有其他意图。 - amireh
你在某种程度上误解了我的回答。我建议将事件处理放置在更新器中,至少就更新器本身的行为/哪些更新器而言。这不一定是最好的解决方案。将事件与更新分离是一个非常好的想法。当然,你需要某种事件引擎来实现这一点。多态性使得这成为一个非常可行的任务。 - David Hammen

4
我认为我会同意这里的非信徒。在这种情况下,钱的问题是指针值将如何设置?
如果您可以以某种方式索引到一个映射并生成一个指针,那么这种方法可能通过减少代码复杂性来证明自己。然而,您在这里所拥有的更像是分布在几个函数中的状态机。
考虑到实际上“something_else_happens”必须在设置指针到另一个值之前检查指针的先前值。对于“something_different_happens”等也是如此。实际上,您已经将状态机的逻辑散布在各个地方,并使其难以跟踪。

在我的情况下,这是我的错,因为我没有澄清,所有这些都是在对象上完成的,这些对象具有事件处理程序所需的所有状态,以便找出接下来该做什么(这就是指针设置的地方)。我会相应地更新我的问题。 - amireh
虽然我同意在各个地方分散状态机的逻辑会让它难以跟进,但是拥有一个包含状态机的5000行if/else语句会让维护成为噩梦。显然,问题不在于性能,而在于可读性和可维护性。 - Adrien Plisson

2
现在我真正想知道的是:我是否不必要地过分关注这个问题?
如果你还没有实际运行代码,并发现它的运行速度实际上太慢,那么是的,我认为你可能过早地担心了性能。
Herb Sutter和Andrei Alexandrescu在《C++编程规范:101条规则、指南和最佳实践》中专门介绍了第8章“不要过早优化”,他们对此进行了总结:
不要刺激一匹愿意奔跑的马(拉丁谚语):过早的优化既令人上瘾又无益。优化的第一条规则是:不要优化。优化的第二条规则(仅适用于专家)是:不要立即优化。多测量几次,然后再优化一次。
阅读第9章“不要过早降低效率”也是值得的。

谢谢你提供的书籍链接,我还没有读过那本书,但我会去看的!关于过早优化,这种风格对我来说更清晰:根据事件进行状态转换,而不是简单地检查标志。因此,我的思考既有性能方面的考虑,也有设计方面的考虑。 - amireh
好的。我理解你提到的“heavy loops”是指你对速度有所顾虑。是的,代码的清晰度非常重要。 - Clare Macrae

0

测试一个条件是:

  • 获取一个值
  • 比较(相减)
  • 如果为零(或非零)则跳转

执行间接寻址是:

  • 获取一个地址
  • 跳转。

这样甚至可能更高效!

实际上,您在另一个地方先进行“比较”,以决定要调用什么。结果将是相同的。 您所做的只是一个分派系统,与编译器在调用虚函数时所做的相同。 事实证明,避免使用虚函数通过开关实现分派不会提高现代编译器的性能。

在大多数情况下,“不要使用间接寻址/不要使用虚拟/不要使用函数指针/不要动态转换等”只是基于早期编译器和硬件架构的历史限制的神话。


1
这个逻辑假设使用非流水线CPU,即上个世纪的CPU。在今天的流水线CPU中,当跳转位置依赖于前一个获取时,会出现令人讨厌的停顿。在第一种情况下,跳转位置是固定的,在第二种情况下,它取决于获取的值。 - MSalters
@MSalters 它主要取决于跳转位置的所在位置。现代 CPU 可以解决你提到的大多数“正常”情况下的问题。如果所有与跳转相关的内容都适合于同一执行模块(例如未分散在多个 DLL 中)并且由同一单线程访问,则不需要特别处理。 - Emilio Garavaglia
1
@Emilio:CPU没有DLL的概念。所有跳转都在单个线程内进行,这是定义。跳转实际上表示:当前线程继续执行_那个_指令。至于“解决方法”,请更具体说明。在第一种情况下,跳转地址仅取决于指令流;在第二种情况下,它需要物理获取。后者基本上更复杂(绝对需要内存访问)。当然,快速的CPU会尽力将成本最小化,但他们会为所有常见操作做到这一点,而基本复杂操作仍将保持最慢。 - MSalters
1
你忘了间接引用会阻止内联,所以它将是:获取地址,保存当前位置,跳转,为函数设置堆栈帧,执行任务,恢复堆栈。 - adrianm
@MSalters:关于DLL等问题。问题在于“跳跃”的远近程度。无论如何,都需要获取跳转地址。有时它会立即出现在操作码后面,而有时则是存储在变量处(可能是寄存器或内部缓存中映射的另一页)。由于所有地址集合永远不会改变(一旦程序编译完成,所有这些跳转的可能值都是有限的),它们很可能被缓存并且永远不会重新加载。缓存值的访问时间与寄存器的访问时间并没有太大的区别。 - Emilio Garavaglia
显示剩余2条评论

0

性能差异将取决于硬件和编译器优化器。间接调用在某些机器上可能非常昂贵,而在其他机器上可能非常便宜。而真正好的编译器可能能够基于分析器输出来优化甚至间接调用。在你实际的目标硬件上并且使用你最终发布代码中所使用的编译器和编译器选项来比较这两个变量直到进行了基准测试之前是不可能说的。

如果间接调用最终变得太昂贵,您仍然可以通过在循环外部设置枚举并在循环内使用switch或通过为每个设置组合实现循环并在开始时进行选择来提升测试。如果您指向的函数实现完整的循环,即使间接调用很昂贵,这几乎肯定比每次通过循环测试条件更快。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接