
在编程中,规定函数如何交换数据的规则被称为参数传递机制。这一通信过程虽然常被视为一个纯粹的技术细节而被忽略,但它却是基础性的,决定着一个程序的效率、安全性,乃至其核心意义。本文深入探讨了计算机科学的这一关键方面,揭示了每个函数调用背后优雅而复杂的机制。通过展示参数传递的深远影响,本文旨在纠正那种认为这是一个简单、已成定论的话题的普遍误解。
旅程始于 “原理与机制” 一章,我们将在其中探索通信的基础模型。我们将对比通过传值调用创建副本的安全性与通过传引用调用共享笔记本的高效和风险,并考察惰性求值等高级策略。随后,“应用与跨学科联系” 一章将拓宽我们的视野,展示这些机制不仅是实现细节,更是塑造编译器优化、操作系统设计乃至我们数字世界安全的基石。通过理解这些交互规则,我们可以领会到简单的函数调用行为背后所蕴含的深邃复杂性。
想象两位杰出的科学家 Alice 和 Bob 正在合作解决一个难题。Alice 取得了突破,需要将她的发现分享给 Bob,以便他能继续工作。她应该怎么做?这个简单的问题,实质上就是参数传递的核心挑战。在编程世界里,我们的“科学家”是函数,它们分享的“发现”是数据。支配这种交换的规则被称为参数传递机制。这些规则不仅仅是技术上的注脚,它们是函数如何沟通的核心,定义了我们程序的安全性、效率,甚至是其意义。这是一个关于这一基础性对话的美丽而时而危险的图景的故事。
Alice 分享其发现最直接的方式,是制作一份她笔记的完美复印件并交给 Bob。Bob 可以在他的副本上书写、划线,甚至将咖啡洒在上面,而 Alice 的原始笔记则保持原样。这就是传值调用的精髓。
在这种机制中,调用函数(调用者)对每个参数进行求值,并将结果值的副本传递给被调用函数(被调用者)。被调用者使用其私有副本进行工作。它所做的任何修改都只针对这些副本,调用者的原始数据完全不受影响。这创造了一面强大的单向镜:被调用者可以看到调用者的初始值,但调用者看不到被调用者如何处理它们。
这种方法的优点在于其安全性和简洁性。它使得对程序进行推理变得容易得多。一个只对其局部副本进行操作且不触及任何全局状态的函数被称为纯函数。这类函数具有极好的可预测性:给予相同的输入,它们总会产生相同的输出,不会有任何意料之外的副作用。例如,如果一个函数的唯一工作是执行计算,那么让它通过传值方式接受参数,就能有力地保证它不会意外修改调用它的那部分程序中的变量。这种隔离原则是稳健软件设计的基石。
当然,天下没有免费的午餐。如果 Alice 的“笔记”不是一页纸,而是一整部百科全书呢?复印它将极其耗时和浪费。类似地,如果一个函数被调用时传入一个像巨型数组这样的大型数据结构,为传值调用创建一个完整的副本可能会成为一个显著的性能瓶颈。这种成本促使我们寻求更高效的协作方式。
如果 Alice 不制作副本,而是直接将她的原始笔记本交给 Bob 呢?这就是传引用调用的核心思想。被调用者得到的不是一个值,而是一个引用——本质上是调用者原始数据的内存地址。当被调用者读取参数时,它会跟随引用读取原始数据。当它写入时,它会直接写入调用者的变量中。
这种方式的威力在于其巨大的效率。传递一个庞大的数据结构现在只需要传递一个单一的、很小的地址。它还促成了一种常见而强大的编程模式:允许一个函数通过修改调用者传递给它的多个变量来产生多个结果。
然而,这种威力伴随着一个深远的风险:别名(aliasing)。当两个或多个不同的名称指向同一个内存位置时,就产生了别名。这时,我们共享笔记本的比喻就变得异常真实。想象一个简单的函数 ,它首先修改 ,然后使用 的新值来修改 。如果一个持有变量 的调用者执行了调用 ,会发生什么?
在传引用调用下,形式参数 和 都成为了单一变量 的别名。它们是同一个东西的两个不同名称。
u := 3 时,调用者的变量 被设置为 。v := u + 4 时,它实际上是在计算 a := a + 4。因为 是 ,所以 变成了 。这是一个简单的例子,但在大型复杂程序中,这种无意的别名可能导致极难追踪的错误。问题在递归中可能变得更糟。一个函数可能将一个变量通过引用传递给自己,创建跨越程序栈上多个活动调用的别名。为了防范这种情况,一个复杂的运行时系统可能需要主动监管这些“借用”,例如通过维护一个当前所有被引用内存位置的集合,并在函数试图对一个已被借用的位置创建第二个冲突的引用时引发错误。
为了驾驭这片雷区,语言设计者发明了混合机制。复制传入/复制传出(也称为值结果传递)试图将局部副本的安全性与返回结果的能力结合起来。值在开始时被复制传入,函数在其私有副本上工作,最终结果在结束时被复制传出。但即使是这样也有微妙之处。如果你调用 ,哪个值最后被复制出去并决定 的最终状态?答案取决于一个任意的规则:是第一个参数的复制传出发生在第二个参数之前还是之后。
到目前为止,我们一直在谈论原理。但计算机实际上是如何实现这些“对话”的呢?答案在于一套被称为应用程序二进制接口(Application Binary Interface, ABI)或调用约定的严格规则。这是编译器遵守的契约,精确规定了如何传递参数、返回值和管理栈。
传递参数的首选地是 CPU 的寄存器。寄存器是直接内置于处理器中的小型、速度极快的存储位置。访问寄存器的速度比访问主内存快几个数量级。性能影响不容小觑。在现代乱序处理器中,从内存(栈)中获取参数会创建一个“加载”操作,这会消耗宝贵的资源,堵塞流水线,并增加内部簿记的开销。而在寄存器中传递相同的参数则完全消除了加载操作,从而释放处理器去做更多有用的工作。
因此,大多数现代 ABI,如在 Linux 和 macOS 上使用的 System V ABI,都规定函数的前几个参数通过指定的寄存器传递。对于一个 64 位系统,前六个整数或指针参数通过 %rdi、%rsi、%rdx 等寄存器传递。只有当寄存器用完时,对于有很多参数的函数,我们才诉诸于将剩余参数放在栈上——一个速度较慢但空间更大的内存位置 [@problem_li:3680365]。
这种“寄存器优先”的策略还需要另一层仔细的规则。一个寄存器是 64 位宽。如果我们传递的是一个较小的 8 位字符怎么办?ABI 必须规定如何处理另外的 56 位。这个责任落在调用者身上。为了保持效率,调用者必须准备好数据,使其对被调用者来说“随时可用”。如果这个 8 位值是带符号的,调用者必须对其进行符号扩展,即将其符号位复制到所有高位。如果它是无符号的,调用者必须对其进行零扩展。这确保了寄存器中的 64 位值在数值上等同于原始的 8 位值,使得被调用者可以直接对其进行 64 位算术运算,而无需任何额外的转换步骤。
使函数调用快速这一挑战是如此基础,以至于一些处理器设计直接在硬件中解决了它。SPARC 架构引入了一种名为寄存器窗口的绝妙机制。想象一下,CPU 的寄存器排列在一个大型的圆形转盘上。函数调用并不复制数据;save 指令只是旋转这个转盘。调用者的“出”寄存器在物理上变成了被调用者的“入”寄存器。这是以光速——或者至少是以改变一个指针的速度——进行的参数传递。当函数返回时,restore 指令将转盘转回原位。
但问题在于,这个转盘的窗口数量是有限的(通常是 8 或 16 个)。如果你的函数调用链很深,超过了这个数量,就会发生窗口溢出。硬件会触发一个陷阱,操作系统必须介入,小心地将“最旧”窗口的内容保存到内存栈中,以便为新的调用腾出空间。这是一个优美的权衡:为常见情况提供闪电般快速的调用,并为异常情况提供较慢的软件处理作为后备。
到目前为止,我们所看到的机制都是及早(eager)的:它们在函数调用开始前就准备好参数的值。但还有一种截然不同的方法:如果我们推迟这项工作会怎样?
这就是传名调用背后的思想。调用者传递的不是一个值,而是一个“thunk”——一个知道如何计算参数值的小型、打包好的代码片段。被调用者接收这个 thunk,并且每当它访问该参数时,它都会执行 thunk 来从头重新计算该值。
这可能很强大,但如果参数表达式有副作用,它就是一个雷区。考虑像 f(log("hello")) 这样的调用,其中 log 会在屏幕上打印一条消息。如果 f 使用其参数五次,那么 "hello" 将被打印五次,这可能不是程序员的本意!。
这个想法一个更实用、更精炼的版本是按需调用,也称为惰性求值。它是传名调用的“智能”版本。调用者仍然传递一个 thunk。然而,在被调用者首次访问该参数时,它会执行 thunk,然后缓存或记忆化(memoizes)结果。在所有后续的访问中,它只使用缓存的值。这兼具了两者的优点:如果参数从未使用,它就永远不会被计算;如果被使用,它也只被计算一次。这种优雅的机制是像 Haskell 这样的惰性函数式编程语言的支柱。
没有一种“最佳”的参数传递方式。每种机制都是在充满权衡的广阔图景中的一个点。选择取决于语言、硬件以及手头的问题。
考虑将一个大矩阵的一个切片传递给一个将执行更新的并行函数。
理解参数传递就是理解计算的物理学。它关乎信息如何流动,结构如何被保持或改变,以及抽象思想如何被转化为寄存器、内存和缓存的具体现实。从副本的简单安全性到引用的共享状态风险,从硬件的蛮力速度到惰性的优雅延迟,这些机制揭示了函数间每一次对话背后深邃而优美的复杂性。
在我们深入探讨了参数传递的原理和机制之后,人们可能会倾向于将这个话题归档为计算机科学的一个已成定论的琐事。我们在第一堂编程课上就学到,我们调用一个函数 f(x),然后不知怎的,值 x 就 出现 在 f 内部了。这看起来很简单,近乎微不足道。但如果止步于此,就好比学会了字母表却从未读过一本书。参数传递的真正魅力不在于其基本定义,而在于这种看似简单的通信行为如何塑造了计算世界的结构。
在本章中,我们将踏上一段旅程,去看看这些机制如何不仅仅是实现细节,而是一个具有深远影响的基础性原则。我们将看到它们如何决定我们程序的真正含义、我们处理器的速度、我们操作系统的架构,以及我们最敏感数据的安全性。参数传递的故事,就是关于一个计算宇宙的不同部分——从微小的函数到庞大的分布式系统——如何相互对话的故事。而与任何形式的沟通一样,交互的规则决定了一切。
在最直接的层面上,参数传递机制的选择定义了我们的代码实际上做什么。考虑一个接受另一个函数——即所谓的“高阶函数”——作为参数的函数。当我们传递这个函数时,我们传递的是它当前状态的副本,还是一个连接回其原始环境的实时线路?这个问题的答案,作为参数传递策略的直接结果,可能导致截然不同的结果。如果我们按值传递一个闭包,被调用者会得到一个在调用瞬间该闭包捕获变量的“快照”。其内部工作是完全隔离的。但如果我们按引用传递它,被调用者会收到一个到原始状态的直接链接。它所做的任何更改都会被原始调用者感知到,从而在多次调用之间创建了一个持久的共享状态。两者都不能说“错”,但它们代表了两种根本不同的交互模型:一种是隔离计算,另一种是状态化协作。
这种对约定的敏感性甚至出现在更令人惊讶的地方。许多语言,如 Python,提供了默认参数的便利。还有什么比这更简单的呢?如果你不提供值,就使用默认值。但这种便利隐藏了一个关键的参数传递细节。这个默认值是在何时创建并“传递”到函数作用域的?在像 C++ 这样的语言中,每次调用都会创建一个新的默认对象。但在 Python 中,默认对象在函数首次定义时只创建一次,并且这个单一的、持久的对象会被每次省略该参数的后续调用重用。这导致了一个著名的陷阱:一个带有默认列表参数的函数,def my_func(items=[]),似乎会“记住”之前调用的项。这不是一个 bug;这是该语言设计的一个直接后果,即默认列表实际上是从一个单一的、持久的位置通过对象共享来传递的。理解这一点不是为了记住一条规则,而是为了看到即使是“隐式”参数也有其传递机制,并且该机制具有其意义。
如果我们再剥开一层,会发现编译器,这位将我们抽象的代码转化为残酷的机器指令的工匠大师。对编译器来说,参数传递是一个效率之谜。我们如何能以最少的工作量将数据从调用者移动到被调用者?
考虑从一个函数返回一个大型对象,比如一个复杂的数据结构。对“按值返回”的朴素解释意味着将整个对象逐字节地从被调用者的工作区复制回调用者的工作区。对于大型对象,这将是灾难性的缓慢。那么,实际发生了什么?编译器和应用程序二进制接口(ABI)进行了一场巧妙的合谋。调用者不是等待对象返回,而是首先为结果分配空间。然后它向被调用者传递一个秘密的、“隐藏”的第一个参数——一个指向这块空闲空间的指针。知晓这个秘密的被调用者,随后直接在调用者预分配的内存中构造返回对象。返回时不需要进行大规模复制。这种被称为返回值优化(RVO)的优化,是为服务于性能之神而变通参数传递规则的一个绝佳范例。
这种与硬件的亲密舞蹈延伸到了最先进的处理器特性。现代 CPU 采用 SIMD(单指令,多数据)技术来对多份数据同时执行相同的操作。一个关键特性是“谓词执行”,即操作只应用于由位掩码确定的“活动”数据通道。函数应该如何接收这个掩码?一种方法是传递一个布尔标志数组,每个通道一个。但这既笨拙又缓慢。被调用者将不得不从内存中加载这些标志,并费力地将它们转换成 CPU 理解的特殊位掩码格式。优雅的解决方案是让 ABI 定义一个约定,直接在一个专用的“掩码寄存器”中传递掩码。调用者准备好掩码,将其放入正确的寄存器,被调用者就可以立即使用它。这就是高性能计算的精髓:让参数传递约定说出芯片的原生方言。
再把视野拉远,我们会看到参数传递约定是整个系统赖以构建的基石。它们是管理沟通的协议,不仅是函数之间,也是庞大、独立的组件之间。
以操作系统为例。当用户程序需要内核的服务时——比如读取文件——它会进行一次系统调用。这不是一个普通的函数调用;这是一个跨越特权边界的、受到严格控制的转换。参数必须跨越这个鸿沟。ABI 规定了严格的协议:少数参数可以通过 CPU 寄存器走“快车道”,但更多的参数必须放在用户空间栈上。更重要的是,如果一个参数是指向用户内存中缓冲区的指针,内核不能简单地信任它。它必须在能够安全地使用这些数据之前,一丝不苟地将所有数据从不受信任的用户空间复制到自己受保护的内核内存中。这种复制带来了性能成本,这是为用户-内核边界提供的安全性和稳定性所付出的“税”。因此,系统调用接口的设计是一个权衡过程,需要在参数的数量和类型与安全传递它们不可避免的开销之间取得平衡。
当我们将用不同语言或为不同平台编写的软件连接起来时,这种通信的契约性概念变得更加关键。一个用 Go 编写的程序如何调用一个用 C 编写的库?它们之所以能够通信,是因为双方都同意遵守该平台相同的 ABI。但如果平台不同呢?一个有趣的例子是返回一个包含两个 64 位整数的简单结构。在类 Unix 系统上,System V ABI 规定这个 16 字节的对象通过两个 CPU 寄存器 RAX 和 RDX 高效返回。然而,在 Windows 上,Microsoft x64 ABI 对任何大于 8 字节的结构都强制采用完全不同的方法:调用者必须为结果分配内存,并向被调用者传递一个隐藏的指针。这是用两种方言说同一件事。如果没有一个可以充当翻译的编译器或包装器,通信就会失败。ABI 及其参数传递规则,是使得异构软件世界成为可能的通用语。
有时,沟通的挑战会激发全新的架构范式。在传统的“宏内核”中,解决 TOCTOU(检查时-使用时)竞争条件和其他与指针相关的风险的方法是一个由锁和仔细验证组成的复杂网络。但如果我们改变通信模型本身呢?在“微内核”架构中,服务作为隔离的用户空间进程运行。客户端不向服务传递指针;它将其请求序列化为一个自包含的消息——一份所有必要数据的完整副本。然后通过进程间通信(IPC)发送此消息。这种从指针传递到消息传递的范式转换为我们带来了深远的好处。服务器操作的是数据的一致快照,完全消除了 TOCTOU 竞争。此外,消息可以被显式地版本化,允许客户端和服务器独立演进。这是为分布式、互不信任的组件世界重新构想的参数传递。
最后,也许是最关键的,参数传递不仅仅关乎语义或性能;它是计算机安全的基石。我们如何将数据交给另一段代码,通常是第一道也是最重要的一道防线。
想象一个需要加密密钥的函数。如果我们“按引用”传递这个密钥,我们就是把一个指向我们原始秘密密钥的实时句柄交给了被调用者。一个有 bug 或恶意的被调用者可能会修改它,破坏我们的安全上下文,或者私藏这个引用以备后用。安全的方法是“按值”传递密钥。这创建了一个防御性副本。被调用者获得了执行其任务所需的数据,但它操作的是一个一次性的克隆。它可以用它的副本做任何它想做的事——甚至为了良好的卫生习惯将其从自己的内存中清除——而我们的原始密钥在调用者的作用域内保持原始和隔离。
参数传递的安全影响在同步中也至关重要。两个各自生活在自己独立的虚拟地址空间中的进程,如何在一个共享的同步变量(如 futex)上会合?如果一个进程将其 futex 变量的虚拟地址传递给内核,该地址对任何其他进程都毫无意义。内核用一个绝妙的抽象解决了这个问题。当它在共享内存中接收到一个 futex 的 uaddr 参数时,它不直接使用该地址值。相反,它会检查该内存区域的*元数据*,并从底层的共享文件对象(其 inode)和在该文件内的偏移量派生出一个唯一的密钥。由于这个密钥是基于共享文件而不是进程特定的虚拟地址,任何共享该文件的进程都会为同一个 futex 生成相同的密钥,从而使它们能够在同一个点上会合。uaddr 参数是一个密钥,不是指向一个位置,而是指向一个身份。
在一个动态链接和基于组件的软件世界里,我们甚至可能在编译时都不知道我们正在调用的确切函数是什么。我们可能通过一个可能指向任何东西的指针来调用一个函数。这可能是一场灾难的根源。用错误的数量、类型或顺序的参数调用函数可能导致崩溃和安全漏洞。一个稳健的解决方案是采用“信任但验证”的模型。我们可以为每个函数指针附加元数据,一种描述其预期签名的数字护照。在进行调用之前,一个运行时检查器可以比较调用者预期的签名与被调用者的护照。如果它们不匹配,调用可以被中止。如果它们基本匹配但调用顺序不同,一个“编组器”可以动态地重新排序参数。这种运行时验证增加了开销,但它为我们在动态代码执行的狂野和不可预测的世界中换来了安全。
从闭包的微妙语义到操作系统的宏伟架构,传递参数这个简单的行为是一条贯穿所有计算领域的线索。它是一场在便利性、性能和安全性之间的持续协商。下次你写下 f(x) 时,花点时间欣赏一下在表面之下默默工作的庞大而优雅的机器,正是它让那简单的沟通成为可能。