1. 参数传递机制¶
1.1. 按值调用与按引用调用¶
作者注:本模块中所有参数传递方法的可视化均由威斯康星大学奥什科什分校计算机科学专业学生 Cory Sanin 开发。他在这些可视化上的工作极大地改进了本模块的原始版本。
参数传递技术可以分解如下:
及早求值(应用序)技术。这些方法的共同点是:为函数形参传入的实参在函数被调用之前就已求值。
按值调用
按引用调用
按副本-恢复调用(也称为值-结果调用或复制入-复制出调用)
惰性求值(正规序)技术。这些方法的共同点是:为函数形参传入的实参的求值被推迟到该实参在函数执行中真正被使用时才进行。
宏展开
按名调用
按需调用
按值调用与按引用调用之间的区别,可以用我们在 SLang 1 和 SLang 2 解释器中表示值(denoted value)的差异来说明。也就是说,在按值调用中,函数形参对应的实参是该实参值的一份副本;而在按引用调用中,函数得到的是该实参的地址。有了地址,函数就有能力修改该实参。
要了解按值调用如何工作,请使用下面的幻灯片生成器逐步执行几个示例程序。一旦你确信自己理解了每一步,就用随后的熟练度练习来测试自己。
通过完成下面的熟练度练习来测试你对按值调用的掌握。
与按值调用相比,按引用调用由下面的幻灯片生成器来演示。同样,请逐步浏览几个生成的幻灯片,直到你准备好做随后的熟练度练习。
通过完成下面的熟练度练习来测试你对按引用调用的掌握。
现在你已经看到了按值调用与按引用调用之间的区别,我们将用一个问题来结束本节,帮助你复习语言 C++ 中 按值调用 与 按引用调用 的区别——在 C++ 中,形参名前面的 & 符号用于表示按引用调用语义。要获得该题的分数,你必须连续三次正确完成这个随机生成的问题。
1.2. 副本-恢复¶
在副本-恢复参数传递中,函数仍然像按引用调用那样得到实参的地址。然而,这种技术的协议要求函数在执行函数体之前先制作实参的一份副本。函数体随后使用这份副本进行操作。当函数体执行完毕后,副本-恢复的协议要求利用实参的地址把实参的副本“恢复”回原实参中,因此可能会修改该实参。注意,尽管原实参被修改了,但修改发生的时机与按引用调用语义下略有不同。在 Ada 编程语言中,程序员可以通过把形参指定为 in-out 形参来选择使用副本-恢复语义。虽然 C++ 不提供副本-恢复作为参数传递技术,但我们可以在下面的 C++ 代码中模拟它。
#include <iostream>
using namespace std;
void by_value(int a, int b) {
a = b;
b = 6;
}
void by_reference(int &a, int &b) {
a = b;
b = 6;
}
void by_copy_restore(int &a, int &b) {
int copya, copyb;
copya = a; // copy-in phase
copyb = b;
copya = copyb; // function proper
copyb = 6;
a = copya; // copy-out phase
b = copyb;
}
int main() {
int x,y;
x = 4; y = 5;
by_value(x, y);
cout << "Call-by-value semantics: " << x << " " << y << endl;
x = 4; y = 5;
by_reference(x, y);
cout << "Call-by-reference semantics: " << x << " " << y << endl;
x = 4; y = 5;
by_copy_restore(x, y);
cout << "Call-by-copy-restore semantics: " << x << " " << y << endl;
}
正如你对按值和按引用所做的那样,使用下面的幻灯片生成器逐步执行几个副本-恢复方法的示例,然后通过完成随后的熟练度练习来测试自己。
作者注:在上面的幻灯片中,从 r 和 s 指回函数调用实参的指针是存在的,并且应当在函数被调用时以及整个函数调用执行期间显示出来。
所以,从上面的 C++ 代码可以看出,在按副本-恢复调用中,一个函数形参对应两个值:一个指向相应实参的指针,以及一份实参值的副本。首先,制作实参值的副本。然后,函数体在执行期间只使用这份副本。最后,在函数返回之前的恢复阶段,把实参的局部副本(即函数执行完毕后的最终值)复制回实参中。
注意,当存在多个形参时,恢复阶段会按照函数签名中从左到右的顺序对每个形参依次进行。这一顺序是该参数传递机制规范所要求的。
你能想到哪些场景,其中恢复阶段的从左到右顺序会产生影响?
现在,用一道副本-恢复熟练度练习来测试自己。
到目前为止,我们已经介绍了三种使用函数实参及早求值的参数传递方法。
在继续之前,请确保你理解为什么这三种方法确实使用及早求值。
现在,为了比较和对比这三种方法,请弄清楚下一道练习题中的程序在 按值调用 、 按引用调用 和 按副本-恢复调用 下的输出分别是什么。这样做将澄清这三种方法之间的细微差别。要获得下面这道题的分数,你必须对它所生成的随机程序连续三次正确完成。
1.3. 宏展开¶
按值调用、按引用调用和按副本-恢复调用都使用 及早 求值:函数调用的实参立即被求值,也就是说,甚至在函数体执行之前就被求值。
其余三种参数传递机制使用 惰性 求值:函数调用的实参未经求值就被传递给函数。然后,在函数体执行期间,只有在需要时、并且需要多少次就求值多少次,形参才会被求值。
我们要讨论的第一种惰性求值技术是宏展开。
宏展开涉及的步骤如下:
不求值:宏调用中每个实参的字面文本被替换到宏体中所有对应的形参出现处。
不求值:由第 1 步得到的宏代码体,以文本方式替换调用者程序中的宏调用。
求值:宏体 在调用者的环境中 执行。也就是说,由于宏代码被以文本方式替换进调用者程序,所涉及变量的作用域是根据宏被调用的位置、而不是宏定义在程序中出现的位置来确定的。你将在下面幻灯片的第二步中看到这一点,其中第 1 步和第 2 步产生的代码与原始代码并排显示。
一旦你浏览了足够多的示例幻灯片,完全理解了宏式参数传递中每一步的细节,就用下面的熟练度练习来测试自己。
我们通过考察宏在 C++ 中的使用来结束本节关于宏展开参数传递的讨论。在 C++ 中,像下面例子中的 a 或 b 这样的形参,在宏体中真正被使用时必须用括号括起来。请尝试确定每个例子中主程序的输出。
#include <iostream>
using namespace std;
#define by_macro( a, b ) { (a) = (b); (b) = 6; } // Note parens around use of parameter
int main()
{
int x,y;
x = 4; y = 5;
by_macro(x, y);
cout << "Call-by-macro semantics: " << x << " " << y << endl;
}
#include <iostream>
using namespace std;
#define by_macro( a, b ) \
{ (a) = (a) + (b); (b) = (a) - (b); (a) = (a) - (b); } // Again parens wrap use of param
int main()
{
int x,y;
cout << "\nNo aliasing" << endl << endl;
x = 4; y = 5;
by_macro(x, y);
cout << "Call-by-macro semantics: " << x << " " << y << endl;
int z;
cout << endl << endl << "With aliasing" << endl << endl;
z = 4;
by_macro(z, z);
cout << "Call-by-macro semantics: " << z << endl;
}
在 C++ 中实现宏展开
前面描述的三步过程所建议的宏展开实现方式是执行双重文本替换。例如,C++ 预处理器执行这种双重替换,然后编译器处理得到的代码,从不看到宏调用。当然,运行时也不会执行任何函数调用。
由于宏体在实参被(未经求值地)替换为形参之后被拼接进调用者的代码中,宏的整个体都在调用者的环境中执行。这使我们能够使用宏展开来模拟动态作用域,如下面的代码所示。
#include <iostream>
using namespace std;
int n = 6;
#define dynamic_scoping { cout << n << endl; }
void static_scoping() { cout << n << endl; }
void test_dynamic() {
int n = 5;
cout << "Using dynamic scoping --> ";
dynamic_scoping;
}
void test_static() {
int n = 5;
cout << "Using static scoping --> ";
static_scoping();
}
int main() {
test_dynamic();
test_static();
}
下面这道题将帮助你复习 按引用调用 、 按副本-恢复调用 和 按宏调用 之间的区别。要获得分数,你必须连续三次正确完成这个随机生成的问题。
1.4. 按名调用¶
在宏展开中,宏体在实参被(未经求值地)替换为形参之后被拼接进调用者的代码中。因此,宏的整个体都在调用者的上下文中(即调用者的环境中)执行。
在按名调用中,不会把任何代码拼接进调用者的代码。相反,函数体在它自己的上下文中执行,但那些替换形参的实参 确实 是在调用者的上下文中求值的。
按名调用与宏展开的不同之处在于:只有形参在调用者的上下文中求值,而不是函数的整个体。请逐步浏览几个按名调用示例的幻灯片,研究这一变化带来的影响。当你确信自己理解了其中的微妙之处后,试试随后的熟练度练习。
作者注:在上面的幻灯片中,从形参指向实参的箭头并不是真正的指针,而是一种用来表示以下事实的方式:每个形参都有某种办法(我们将在下一节中以“thunk”这个名字来描述它)指回调用者环境中的实参。
现在轮到你来做一个熟练度练习,看看你对按名调用的理解程度如何。做这个熟练度练习时,每条赋值语句都需要两个步骤。在与某条赋值语句对应的第一步中,你必须计算右端的值,然后点击该值将被存储的位置。在第二步中,你必须点击一个可能因第一步中计算与赋值而改变的新箭头目标。
下面这道题将帮助你复习 按副本-恢复调用 、 按宏调用 和 按名调用 之间的区别。要获得它的分数,你必须连续三次正确完成这个随机生成的问题。
1.5. 对目前为止所研究的五种方法的综合复习¶
在下一节中,我们将在名为 惰性表 的具体示例中考察按名调用与按需调用。然而,在继续之前,请测试你对目前为止所研究的全部五种技术的综合理解: 按值调用 、 按引用调用 、 按副本-恢复调用 、 按宏调用 和 按名调用 。要获得它的分数,你必须连续三次正确完成这个随机生成的问题。
