如果可以内联嵌套函数调用,为什么程序要使用调用堆栈?


32

为什么不让编译器采用这样的程序:

function a(b) { return b^2 };
function c(b) { return a(b) + 5 };

并将其转换为这样的程序:

function c(b) { return b^2 + 5 };

从而消除了计算机记住c(b)的回信地址的需要?

我认为存储程序并支持其编译所需的硬盘空间和RAM分别增加,这就是我们使用调用堆栈的原因。那是对的吗?


30
看看如果在任何有意义大小的程序上执行此操作,会发生什么情况。特别是,从多个位置调用函数。
user253751 2015年

10
另外,有时编译器不知道调用哪个函数!愚蠢的例子:window[prompt("Enter function name","")]()
user253751 2015年

26
function(a)b { if(b>0) return a(b-1); }没有堆栈如何实现?
pjc50 2015年

8
与功能编程的关系在哪里?
mastov

14
@ pjc50:它是尾递归的,因此编译器将其转换为可变的循环b。但是要注意的是,并非所有递归函数都可以消除递归,即使原则上该函数可以,编译器也可能不够聪明。
史蒂夫·杰索普

Answers:


75

这称为“内联”,许多编译器在有意义的情况下将其作为优化策略。

在您的特定示例中,此优化将节省空间和执行时间。但是,如果在程序中的多个位置调用该函数(这并不罕见!),则会增加代码大小,因此该策略变得更加不确定。(当然,如果函数直接或间接调用自身,则不可能内联,因为那样代码的大小将变得无限大。)

显然,只有“私人”功能才有可能。不能优化外部调用者公开的功能,至少不能优化具有动态链接的语言。


7
@Blrfl:现代编译器实际上不再需要标头中的定义;他们可以跨翻译单位内联。不过,这确实需要一个不错的链接器。头文件中的定义是哑链接器的解决方法。
MSalters 2015年

3
“无法优化暴露给外部调用者的功能”-该功能必须存在,但是可以内联到该调用点的任何给定调用站点(在您自己的代码中,或者如果有源,则是内部调用者)。
Random832

14
哇,有28人赞成一个答案,而该答案甚至都没有提到无法内联所有内容的原因:递归。
mastov

3
@R ..:LTO是LINK时间优化,而不是LOAD时间优化。
MSalters

2
@immibis:但是,如果编译器引入了该显式堆栈,则该堆栈就是调用堆栈。
user2357112支持Monica 2015年

51

您的问题有两个部分:为什么要有多个功能(而不是用其定义替换函数调用),为什么要用调用栈实现这些功能,而不是在其他地方静态分配其数据?

第一个原因是递归。不仅是“哦,让我们为该列表中的每个项目都调用一个新的函数”类型,还有一种谦虚的类型,即您可能同时激活一个函数的两个调用,并且它们之间还有许多其他函数。您需要将局部变量放在堆栈上以支持此操作,并且通常不能内联递归函数。

然后是库的问题:您不知道从何处以及多久调用一次函数,因此永远无法真正编译“库”,而只能以某种方便的高级格式将其交付给所有客户端,内联到应用程序中。除了与此相关的其他问题之外,您还完全丧失了动态链接及其所有优点。

此外,有很多原因即使没有可能也不能内联函数:

  1. 不一定更快。设置堆栈框架并拆除堆栈框架可能是十几条单循环指令,对于许多大型或循环函数而言,它们的执行时间甚至不到其执行时间的0.1%。
  2. 可能会慢一些。代码重复具有成本,例如,它将对指令高速缓存施加更大的压力。
  3. 一些函数非常大,可以在许多地方调用,将它们内联到任何地方都会增加二进制代码,远远超出了合理范围。
  4. 编译器通常很难完成非常庞大的功能。在其他所有条件都相同的情况下,大小为2 * N的函数所花费的时间超过2 * T,而大小为N的函数所花费的时间为T时间。

1
我对第4点感到惊讶。这是什么原因?
JacquesB 2015年

12
@JacquesB许多优化算法都是二次的,三次的,甚至在技术上都是NP完全的。典型的例子是寄存器分配,类似于图着色,它是NP完全的。(通常,编译器不会尝试精确的解决方案,而只能在线性时间内运行几个非常差的启发式方法。)许多简单的单遍优化都需要首先进行超线性分析,例如,所有依赖于控制流优势的事物(通常是用n个基本块记录n个时间。

2
“您这里确实有两个问题”不,我没有。只是一个-为什么不将函数调用仅视为占位符,例如,编译器可能会将其替换为被调用函数的代码?
moonman239

4
@ moonman239然后您的措辞使我失望。不过,您的问题可以像我在回答中一样分解,我认为这是一个有用的观点。

16

堆栈使我们可以优雅地绕过有限数量的寄存器所施加的限制。

想象一下,恰好有26个全局“寄存器a”(甚至只有8080芯片的7个字节大小的寄存器),并且您在此应用中编写的每个函数都共享此平面列表。

一个天真的开始就是将前几个寄存器分配给第一个函数,并且知道只花了3个,就为第二个函数以“ d”开头……您很快就用光了。

相反,如果您有一个比喻性的磁带,例如图灵机,则可以让每个函数通过保存正在使用的所有变量并将其转发到磁带上来启动“调用另一个函数” ,然后被调用方函数可以与许多函数混淆根据需要注册。当被调用方完成操作后,它将控制权返回给父函数,该父函数知道需要在哪里锁定被调用方的输出,然后向后播放磁带以恢复其状态。

您的基本调用框架就是这样,它由标准化的机器代码序列创建和删除,编译器围绕从一个功能到另一个功能的转换进行放置。(自从我不得不记住我的C堆栈框架以来已经有很长时间了,但是您可以通过各种方式来了解X86_calling_conventions中谁丢掉什么的职责。)

(递归很棒,但是如果您不得不在没有堆栈的情况下处理寄存器,那么您真的喜欢堆栈。)


我认为存储程序并支持其编译所需的硬盘空间和RAM分别增加,这就是我们使用调用堆栈的原因。那是对的吗?

尽管这些天我们可以内联更多(“更快的速度”总是很好;“更少的kb的汇编”在视频流的世界中意味着很少),主要限制在于编译器能够在某些类型的代码模式中展平。

例如,多态对象-如果您不知道将要处理的唯一一种对象,则无法展平;您必须查看对象的功能表并通过该指针进行调用……在运行时很简单,在编译时无法内联。

当现代的工具链已平整了足够多的调用者以确切知道obj的风格是什么时,它可以愉快地内联一个多态定义的函数:

class Base {
    public: void act() = 0;
};
class Child1: public Base {
    public: void act() {};
};
void ActOn(Base* something) {
    something->act();
}
void InlineMe() {
    Child1 thingamabob;
    ActOn(&thingamabob);
}

在上面的代码中,编译器可以选择从InlineMe到act()内部的任何内容,保持静态内联,也不需要在运行时触摸任何vtable。

但是在对象的什么味道的任何不确定性将会把它作为一个离散函数的调用,即使相同功能的其他一些调用内联。


11

这种方法无法处理的情况:

function fib(a) { if(a>2) return fib(a-1)+fib(a-2); else return 1; }

function many(a) { for(i = 1 to a) { b(i); };}

还有语言和平台有限或没有调用堆栈。PIC微处理器的硬件堆栈限制为2到32个条目。这产生了设计约束。

COBOL禁止递归:https : //stackoverflow.com/questions/27806812/in-cobol-is-it-possible-to-recursively-call-a-paragraph

禁止递归确实意味着您可以将程序的整个调用图静态表示为DAG。然后,您的编译器可以针对每个调用该函数的位置发出一个函数副本,并以固定的跳转而不是返回的形式进行调用。不需要堆栈,只需要更多的程序空间,对于复杂的系统可能会很多。但是对于小型嵌入式系统,这意味着您可以保证在运行时不会出现堆栈溢出,这对于您的核反应堆/喷气涡轮/汽车节气门控制等而言是个坏消息。


12
您的第一个示例是基本递归,在这里您是正确的。但是您的第二个示例似乎是一个for循环,它调用了另一个函数。内联函数不同于展开循环。该函数可以内联而无需展开循环。还是我错过了一些细微的细节?
jpmc26 2015年

1
如果第一个示例旨在定义斐波那契数列,那是错误的。(未fib接听电话。)
PaŭloEbermann

1
尽管禁止递归确实意味着可以将整个调用图表示为DAG,但这并不意味着可以在合理的空间内列出嵌套调用序列的完整列表。在我的一个具有128KB代码空间的微控制器的我的项目中,我犯了一个错误,即要求一个调用图,该调用图包括可能影响最大参数RAM需求的所有函数,并且该调用图超过了gig。完整的调用图甚至会更长,那是针对适合128K代码空间的程序的。
2015年

8

您需要函数内联,并且大多数(优化)编译器都在这样做。

请注意,内联要求被调用函数是已知的(并且仅在被调用函数不太大时才有效),因为从概念上讲,它是通过重写被调用函数来代替调用。因此,您通常无法内联未知函数(例如,函数指针-包括动态链接的 共享库中的函数 -可能在某些vtable中作为虚拟方法可见;但是某些编译器有时可能会通过非虚拟化技术进行优化)。当然,内联递归函数并非总是可行的(某些聪明的编译器可能使用部分求值,并且在某些情况下能够内联递归函数)。

还请注意,即使很容易实现,内联也不总是有效:您(实际上是您的编译器)可能会增加太多的代码大小,以至于CPU缓存(或branch预报器)的工作效率降低,从而使程序运行慢点。

我有点侧重于函数式编程风格,因为您对qestion进行了标记。

请注意,您不需要任何调用栈(至少从机器意义上来说是“调用栈”表达式)。您只能使用堆。

因此,请看一下延续,并详细了解延续传递样式(CPS)和CPS转换(直观上讲,您可以将延续闭包用作分配在堆中的经过验证的“调用框架”,它们类似于调用栈;那么您需要一个高效的垃圾收集器)。

安德鲁•阿佩尔(Andrew Appel)写了一本书“ 用连续进行编译 ”(Build with Continuations),旧的纸垃圾收集比堆栈分配要快。另请参阅A.Kennedy的论文(ICFP2007)续编,续

我还建议阅读Queinnec的《Lisp In Small Pieces》一书,该书中有几章与延续和编译有关。

还要注意,某些语言(例如Brainfuck)或抽象机(例如OISCRAM)没有任何调用功能,但仍然是图灵完备的,因此(理论上)您甚至不需要任何函数调用机制,即使这非常方便。顺便说一句,一些旧的指令集体系结构(例如IBM / 370)甚至没有硬件调用栈,也没有推送呼叫机器指令(IBM / 370只有分支和链接机器指令)

最后,如果您的整个程序(包括所有需要的库)没有任何递归,则可以将每个函数的返回地址(以及实际上已经变为静态的“本地”变量)存储在静态位置。早期的Fortran77编译器在1980年代初就这样做了(因此,当时编译的程序没有使用任何调用栈)。


2
CPS没有“调用堆栈”,这值得商de。它不在堆栈上,而是普通RAM的神秘区域,该区域具有一点硬件支持%esp等,但它仍将等效簿记保留在另一个RAM区域中的一个适当命名的意大利面条堆栈上。特别地,返回地址本质上在延续中被编码。当然,延续并不会比通过内联根本不进行调用要快(在我看来,这就是OP正在实现的目标)。

Appel的旧论文声称(并通过基准测试证明)CPS可以和拥有调用堆栈一样快。
Basile Starynkevitch 2015年

我对此表示怀疑,但是不管那不是我所声称的。

1
实际上,这是在1980年代后期的MIPS工作站上。当前PC上的缓存层次结构可能会使性能略有不同。有几篇论文分析了Appel的主张(实际上,在当前的机器上,堆栈分配可能比精心制作的垃圾收集快-降低了几个百分点)
Basile Starynkevitch 2015年

1
@Gilles:许多更新的ARM内核,例如Cortex M0和M3(可能还有M4等其他内核)都具有对中断处理之类的硬件堆栈支持。此外,Thumb指令集包括STRM / STRM指令的有限子集,其中包括STRMDB R13和R0-R7的任意组合(带/不带LR)和LDRMIA R13,R0-R7的任意组合带/不带PC,有效地处理了R13作为堆栈指针。
2015年

8

内联(用等效功能替换函数调用)可以很好地用作小型简单函数的优化策略。可以有效地权衡函数调用的开销,而只需付出很小的代价就可以增加程序大小(或者在某些情况下完全没有代价)。

但是,如果内联所有内容,则依次调用其他函数的大型函数可能会导致程序规模的剧增。

可调用函数的全部要点不仅是程序员,而且是机器本身,都是为了促进有效的重用,并且包括诸如合理的内存或磁盘占用空间之类的属性。

物有所值:您可以拥有无​​需调用堆栈的可调用函数。例如:IBM System / 360。当在该硬件上使用诸如FORTRAN之类的语言进行编程时,程序计数器(返回地址)将保存在函数入口点之前保留的一小部分内存中。它允许可重用​​的函数,但不允许递归或多线程代码(尝试递归或重入调用将导致先前保存的返回地址被覆盖)。

正如其他答案所解释的那样,堆栈是件好事。它们有助于递归和多线程调用。尽管可以编码任何使用递归的算法而无需依赖递归,但是结果可能更复杂,更难维护且效率可能更低。我不确定无堆栈架构是否可以完全支持多线程。

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.