前面我们提到,函数参数的传递,本质上就是调用函数和被调用函数发生的信息交换。参数传递机制主要有两种:传值(pass-by-value)和传引用(pass-by-reference)。

通常来说,在传值过程中,被调用函数的形式参数(简称形参)作为被调用函数的局部变量,即在堆栈中重新开辟一块内存空间,用来存放由主调用函数放进来的实际参数(简称实参)值,从而成为实参的一个副本。

传值的特点是,由于形参可视为函数本身的“自留地”(即局部变量),因此,函数内部对形参的任何修改,都是函数的“内政”,不会对主调用函数的实参有任何影响。说得“学术”点,形参和实参存在于不同的地址空间,它们是不同的对象,除了参数赋值那一刻短暂的“邂逅”,之后它们独来独往,互不干扰。

而传引用则不同。在引用传递的过程中,被调用函数的形参就是实参变量的地址。这里的“引用”实际上就是指内存地址。换句话说,在传引用机制下,形参和实参指向同一块内存地址,却有两个不同的“皮囊”——形参名称和实参名称,因此有的文献也将“传引用”称为“传别名”。

正因为如此,在被调用函数中,对形参做的任何操作,实质上都影响了主调函数中的实参变量。这就好比“张三”的别名叫“狗剩”,你打了“狗剩”一拳(修改形参的值),实际上也是打了“张三”一拳(影响到了实参变量)。

那么,Python 中的函数参数传递,又是怎样一番情景呢?

简单来说,Python 中所有的函数参数传递,统统都是基于传递对象的引用进行的。这是因为,在 Python 中,一切皆对象。而传对象,实质上传的是对象的内存地址,而地址即引用。

看起来,Python 的参数传递方式是整齐划一的,但具体情况还得具体分析。在 Python 中,对象大致分为两类,即可变对象和不可变对象。可变对象包括字典、列表及集合等。不可变对象包括数值、字符串、不变集合等。

如果参数传递的是可变对象,传递的就是地址,形参的地址就是实参的地址,如同“两套班子,一套人马”一样,修改了函数中的形参,就等同于修改了实参。

如果参数传递的是不可变对象,为了维护它的“不可变”属性,函数内部不得不“重构”一个实参的副本。此时,实参的副本(即形参)和主调用函数提供的实参在内存中分处于不同的位置,因此对函数形参的修改,并不会对实参造成任何影响,在结果上看起来和传值一样。

在了解了上面介绍的函数参数传递机制之后,下面我们来观察一下例 1,这样就可以对运行结果理解得比较透彻了。

【例 1 】Python 的参数传递(paras_pass.py)
def numFunc(x):
print('在函数中,形参x的地址为:' , id (x) )
print('在函数中,形参x的值为:', x)
x = x + 1
print('在函数中,x的值更新为:', x)
print('在函数中,x的地址更新为:' , id (x))
a = 3
print('在函数外,实参a的地址为:', id (a))
numFunc(a)
print('在调用函数之后,实参a的值为:' , a)
程序执行结果为:
在函数外,实参a的地址为:1748092656
在函数中,形参x的地址为:1748092656
在函数中,形参x的值为:3
在函数中,x的值更新为:4
在函数中,x的地址更新为:1748092688
在调用函数之后,实参a的值为:3

从前面的描述中可知,在 Python 中,一切皆为对象,数值型对象也不例外,而且它还是不可变对象。因此,数值型的实参 a(第 08 行定义)和形参 x(第 01 行定义)实际上都是对象,通过函数 numFunc( ) 调用,参数的传递方式自然是传引用。

全局函数 id( ) 的功能是返回对象在内存中的地址。对比一下第 09 行和第 02 行的地址输出,可明显看出实参和形参的地址相同(1748092656)。而且,在函数中,形参 x 的输出为 3(第 03 行),和实参 a 的值是一致的(第 08 行)。所处的位置相同,且内部的值也相同,这也验证了 x 和 a 实际上就是一个对象。

下面,关键之处来了。在第 04 行,我们试图在函数中将 x 的值 +1,从运行结果看(输出为 4),操作的确是成功了。但从第 06 行输出的地址可以看出,x 的地址发生了变化。也就是说,实际上,系统重新分配了一块内存空间来存放加和的结果,然后再让标识 x 重新指向这个新单元。此时新的 x 和旧的 x 就是完全不同的两个对象。而对用户来说,好像是一样的,这就好比“狸猫换太子”的把戏。

最后,我们再次输出实参 a(第 11 行),此时发现 a 依然是 3,这维护了数值型对象不可改变的“形象”。从整体上来看,参数传递的效果类似于传值,但内部的机制却“大相径庭”,示意图如图 1 所示。

图 1:数值型参数传递示意图

如前所述,字符串(str)也属于不可变对象。下面我们来看看字符串作为参数时的传递情况,请看如下代码:

In [1]: b = * hhhh1
In [2]: def strFun(s):
...: print (n修改之前字符串为s
...: s = * xxxx *
...: print (n修改之后字符串为s
In [3]: strFun(b)
修改之前字符串为s = hhhh
修改之后字符串为s = xxxx
In [4]: print(b)
hhhh

类似地,实参字符串 b 原本的内容是 'hhhh',通过调用函数 def strFun(s),在函数内部,形参 s 的值被修改了,但实参 b 的值依然是 'hhhh'(体现在 In [4] 处的输出上)。

然后我们再看一下元组作为参数时的传递情况,可参考如下代码。

In [5]: tuple1 = (111,222,333)
In [6]: def foo(a):
...: a = a + (333, 444) #元组元素不可变,此处对元组进行连接
...: return a
...:
In [7]: print(foo(tuplel))
(111, 222, 333, 333, 444)
In [8]: tuplel = (111,222,333)

从运行结果可以看到,对元组的操作得到了和数值型、字符串型类似的结果。元组在传递到函数内部时,看似是可以改变的,但改变的结果并不影响实参。

再次需要强调的是,这里说的“改变”并非真正的改变,而是重新生成一个新的元组,然后再冠以相同的名称(比如 a ),造成了一个元组可以在函数中被修改的假象。但此 a(函数内部)已非彼 a(实参)。

最后我们再来说一下传递可变参数的情况,以列表为例,参考如下代码。

In [9]: def foo(a):
...: a. append ("可变对象”)
...: return a
In [10]: list1= [111,222,333]
In [11]: print(foo(listl))
[111, 222, 333, '可变对象']
In [12]: list1
Out[12]: [111, 222, 333, '可变对象']

从运行结果中可以看出,在给形参 a 赋值后,实参 list1 和形参 a 事实上指向了同一块内存空间(传对象的地址,即传引用)。这样一来,在函数内部修改了对象 a 的值,事实上也就修改了实际参数传来的引用值指向的对象 list1。

通过前面的描述可知,对于可变数据类型(如列表和字典),参数传递是纯粹的传引用,即修改形参的值等同于修改实参的值。如此一来,In [9] 处的 return 语句实际上是多余的,即使没有那个 return 语句,In [12] 处的输出也是更新后的结果。