C语言跟汇编语言比,是一门高级语言,因为提供了更多抽象的工具供程序员使用。其中一个重要的抽象就是用函数来表示计算。

从函数到方法

函数这个名字应该是从数学中借用来的,原本是从某定义域到某取值范围的映射。借用到C语言中,用来表示从一个输入值到输出值的映射。当然,不是所有的函数都有输入值,或者输出值。这种情况下,可以认为它们的输入或输出是一种特殊的值:空值。

函数对程序员来说是一个有力的工具,可以帮助代码的组织。就是说一个大型的程序可以拆成各个不同大小,且互相调用的函数。这些调用可以是交互嵌套的,也就是函数1调用了函数2,然后函数2又调用了函数1。

为了方便这些相互调用,函数的状态要局部化,以避免某个函数中的状态影响另一个函数。所以存在局部变量,还有局部跳转(即goto)这些汇编语言中不存在的概念。要知道,汇编中的跳转可是能够跳转到任意地址的,而C语言函数中的跳转不能跳出函数体。如果C语言中需要进行跨越函数的长跳转,需要用到longjmp这些个库函数。

但函数状态的局部化是一个双刃剑,减少函数之间的干扰的同时,也降低了函数之间共享状态的自由度。除了通过调用获取其他函数的返回值这种状态同步方式之外,两个函数之间的状态同步需要靠其他机制,比如采用全局变量,甚至是采用操作系统提供的底层通信机制。这就显得比较麻烦了,至少使用全局变量来在不同函数之间共享状态不是一个良好的编程实践,但很多时候又不得不用。

为了减少使用全局变量所带来的痛苦,C++走上了面向对象的道路。通过面向对象机制,将相关状态归置到一个对象中,然后有一些称为对象方法的函数,能够以全局的方式访问到对象的状态。那些不是对象方法的函数,无法访问对象的状态,或者访问要受到限制。所以,有了面向对象这种封装机制,一组函数可以在有限范围内共享全局状态,却不影响范围外的函数。

二等公民的函数

虽然C语言的代码在组织上是围绕着函数进行的,但是函数却不是C语言中的一等公民。跟函数式语言中的函数不同,无法将C语言中的函数作为值传递。为此,C语言提供了一个变通的办法,可以将函数的调用入口地址作为一个函数指针类型的值传递。可是其他方面的限制依然存在,比如无法在函数内部定义一个子函数。这大概是因为C语言不支持闭包(closure)的缘故,不过C++提供了lambda的抽象,从某种程度上缓解了这个问题。

函数调用的默认对应着压栈和退栈的操作。这意味着在编译的时候,就要将函数的栈帧大小给计算出来,才能判断压栈的时候需要放入多少信息,另外退栈的时候要回退多少空间。在同一个线程中的函数调用与函数调用之间不能穿插,必须依次压栈入栈。打个比方说,如果把函数比作一包薯片,你只能打开一袋吃一袋,不能同时打开不同袋的薯片换着吃,除非这些薯片袋子是在不同线程上打开的。函数执行完默认是要退栈的。虽然主流编译器支持尾递归调用,在当前的栈帧上执行一个新的函数调用。

这种开一袋吃一袋的操作其实不是很完美。联想到现实生活中的一个场景,你打开一袋薯片,吃了一些,然后剩下的把袋子扎起来留着以后吃。这种场景其实很常见,但是不被C语言支持,因为C语言默认无法让函数执行到一半停下来,挂起之后留待以后执行。一个变通的办法是使用某些系统调用,来设置一个专门的栈来存放被挂起的函数的栈帧。C++ 20中提供了不基于栈帧的Coroutine,可以用于函数执行的挂起和恢复。

函数挂起往往是因为需要等待某些外部条件。挂起的时候会保存运行时的状态,比如局部变量的值。等到外部条件满足的时候可以恢复到原运行状态执行。这种在函数内部保持状态的办法,是对之前只在函数外部(比如类变量、全局变量)保持状态的一种有益补充。应用得当的话可以减少所需协调的外部状态。在大型系统中,这是一个降低系统复杂度的有效办法。

(完)