值传递,引用传递,赋值传递,一文理解python的参数传递和赋值
当你在Python中调用一个函数并给该函数传递一些参数时......这些参数是按值传递(passed by value?)的吗?不对!引用传递(passed by reference)?还不对的!他们实际上是通过了赋值传递( passed by assignment)。
Introduction 介绍
许多传统编程语言在向函数传递参数时采用以下两种模型之一:
一些语言使用传值模型;
其他大多数使用引用传递模型。
话虽如此,了解 Python 使用的模型很重要,因为这会影响代码的行为方式。
在这个 Pydon 中,你将学习到如下的知识点:
看到Python不使用按值传递或按引用传递模型;
了解 Python 使用传递赋值的模型;
了解内置函数 id ;
更好地理解 Python 对象模型;
认识到每个对象都有 3 个非常重要的属性;
了解可变对象和不可变对象之间的区别;
了解浅拷贝和深拷贝之间的区别;以及
了解如何使用模块 copy 进行这两种类型的对象复制。
Python 是按值传递吗?
在按值传递模型中,当您使用一组参数调用函数时,数据将被复制到该函数中。这意味着您可以在函数内部随意修改参数,并且无法更改函数外部的程序状态。这不是Python所做的,Python不使用传值模型。
查看下面的代码片段,Python 可能看起来使用了值传递:
def foo(x):
x = 4
a = 3
foo(a)
print(a)
# 3
这看起来像传值模型,因为我们给函数传递 3,函数将其更改为 4,并且更改没有反映在外部( a 仍然是 3)。所以看起来好象是函数内部复制了一份传递的参数,所以行为和按值传递一样了。
但事实上,Python 并没有将数据复制到函数中。
为了证明这一点,我将向您展示一个不同的函数:
def clearly_not_pass_by_value(my_list):
my_list[0] = 42
l = [1, 2, 3]
clearly_not_pass_by_value(l)
print(l)
# [42, 2, 3]
正如我们所看到的,在函数外部定义的列表 l 在调用函数 clearly_not_pass_by_value 后发生了变化。因此,Python 不使用传值模型。
Python 是按引用传递吗?
在真正的引用传递模型中,被调用函数可以访问被调用者的变量!有时,Python 看起来就是这么做的,但 Python 并不使用引用传递模型。
我将尽力解释为什么 Python 不这样做:
def not_pass_by_reference(my_list):
my_list = [42, 73, 0]
l = [1, 2, 3]
not_pass_by_reference(l)
print(l)
# [1, 2, 3]
如果 Python 使用引用传递模型,该函数将设法完全更改函数外部 l 的值,但正如我们所看到的,事实并非如此。
让我向您展示一个实际的引用传递情况。
program callByReference;
var
x: integer;
procedure foo(var a: integer);
{ create a procedure called `foo` }
begin
a := 6 { assign 6 to `a` }
end;
begin
x := 2; { assign 2 to `x` }
writeln(x); { print `x` }
foo(x); { call `foo` with `x` }
writeln(x); { print `x` }
end.
查看该代码的最后几行:
我们用 x := 2 将 2 分配给 x ;
我们打印 x ;
我们以 x 作为参数调用 foo ;和
我们再次打印 x 。
这个程序的输出是什么?
我想你们中的大多数人都没有 Pascal 解释器,所以您可以直接访问 tio.run 并在线运行此代码
如果你运行这个,你会看到输出是
2
6
如果您的大部分编程经验都是使用 Python 进行的,这可能会令人相当惊讶!
过程 foo 有效地接收了变量 x 并更改了它包含的值。 foo 完成后,变量 x (位于 foo 外部)具有不同的值。你不能在 Python 中做这样的事情。
Python对象模型
对象(object)的三个特征
在Python中,一切都是对象,每个对象都具有以下三个特征:
它的身份(唯一标识该对象的整数,就像识别人的社会安全号码一样);
类型(标识对象支持的操作运算);以及
对象的内容
以下是一个对象及其三个特征:
>>> id(obj)
2698212637504 # the identity of `obj`
>>> type(obj)
<class 'list'> # the type of `obj`
>>> obj
[1, 2, 3] # the contents of `obj`
正如我们在上面看到的, id 是用于查询对象身份的内置函数, type 是用于查询某个对象的类型的内置函数。
(不)可变性
Python对象的(不)可变性取决于其类型。换句话说,(不)可变性是类型的特征,而不是特定对象的特征!
但是对象可变到底意味着什么呢?或者一个对象是不可变的,又意味着什么?
回想一下,对象的特征是其身份、类型和内容。如果您可以更改其对象的内容而不更改其标识和类型,则类型是可变的。
列表是可变数据类型的一个很好的例子。为什么?因为列表是容器:您可以将内容放入列表中,也可以从这一个列表中删除内容。
下面,您可以看到当我们进行方法调用时列表 obj 的内容如何变化,但列表的标识保持不变:
>>> obj = []
>>> id(obj)
2287844221184
>>> obj.append(0); obj.extend([1, 2, 3]); obj
[42, 0, 1, 2, 3]
>>> id(obj)
2287844221184
>>> obj.pop(0); obj.pop(0); obj.pop(); obj
42
0
3
[1, 2]
>>> id(obj)
2287844221184
然而,当处理不可变对象时,情况就完全不同了。如果我们查一下英语词典,这就是“不可变”的定义:
adjective: immutable – unchanging over time or unable to be changed.(随着时间的推移不变或无法改变。)
不可变对象的内容永远不会改变。以字符串为例:
>>> obj = "Hello, world!"
字符串是本次讨论的一个很好的例子,因为有时它们看起来好像是可变的,但其实是不可变的!
一个对象不可变的一个很好的指标是它的所有方法都返回一些东西。例如,这与列表的 .append 方法不同!如果您在列表上使用 .append ,则不会获得任何返回值。另一方面,无论您对字符串使用什么方法,结果都会返回给您:
>>> [].append(0) # No return.
>>> obj.upper() # A string is returned.,并且obj还是原来的值
'HELLO, WORLD!"
请注意 obj 没有自动更新为 "HELLO, WORLD!" 。相反,新字符串已创建并返回给您。
关于字符串不可变这一事实的另一个重要提示是您不能通过索引更改其值:
>>> obj[0]
'H'
>>> obj[0] = "h"
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment
这表明,当创建一个字符串时,它将一直保持不变。它可以用来构建其他字符串,但始终是字符串本身。留下来。不变。
作为一个参考, int 、 float 、 bool 、 str 、 tuple 和 complex 是常见不可变对象类型, list,set 和 dict 是最常见的可变对象类型。
作为标签的变量名称
另一件需要理解的重要事情是变量名与对象本身关系不大。(动态类型语言是这样的,静态类型语言另当别论)
事实上,名称 obj 只是我决定附加到标识为 2698212637504、具有列表类型和内容 1、2、3 的对象的标签。
就像我将标签 obj 附加到该对象一样,我可以为其附加更多名称:
>>> foo = bar = baz = obj
再次强调,这些名称只是标签。我决定贴在同一个物体上的标签。我们怎么知道这是同一个对象?好吧,他们所有的“社会安全号码”(id)都匹配,所以它们一定是同一个对象:
>>> id(foo)
2698212637504
>>> id(bar)
2698212637504
>>> id(baz)
2698212637504
>>> id(obj)
2698212637504
因此,我们得出结论, foo 、 bar 、 baz 和 obj 都是引用同一对象的变量名称。
运算符 is
运算符 is 所做的就是:检查两个对象是否相同。
要使两个对象相同,它们必须具有相同的标识:
>>> foo is obj
True
>>> bar is foo
True
>>> obj is foo
True
仅仅具有相同的类型和内容是不够的!我们可以创建一个包含内容 [1, 2, 3] 的新列表,该列表与 obj 不是同一对象:
>>> obj is [1, 2, 3]
False
把它想象成完美双胞胎。当两个兄弟姐妹是完美双胞胎时,他们看起来一模一样。然而,他们是不同的人!
is not 运算符
作为旁注,但很重要的是,您应该了解运算符 is not 。
一般来说,当你想要否定一个条件时,你可以在它前面加上一个 not :
n = 5
if not isinstance(n, str):
print("n is not a string.")
# n is not a string.
因此,如果您想检查两个变量是否指向不同的对象,您可能会想写
if not a is b:
print("`a` and `b` are different objets.")
然而,Python 有运算符 is not ,它更类似于正确的英语句子,我认为这真的很酷!
因此,上面的例子实际上应该写成
if a is not b:
print("`a` and `b` are different objects.")
Python 对 in 运算符做了类似的事情,还提供了 not in 运算符......这有多酷?!
Assignment as nicknaming(把赋值作为昵称)
如果我们继续推进这个比喻,赋值变量就像给某人起一个新的昵称。
我的中学朋友叫我“Rojer”。我大学里的朋友叫我“Girão”。我不熟悉的人都直呼我的名字——“罗德里戈”。不过,不管他们怎么称呼我,我还是我,不是吗?
如果有一天我决定改变发型,每个人都会看到新发型,无论他们叫我什么!
以类似的方式,如果我修改对象的内容,我可以使用我喜欢的任何昵称来对对象做修改。例如,我们可以更改我们一直在使用的列表的中间元素:
>>> foo[1] = 42
>>> bar
[1, 42, 3]
>>> baz
[1, 42, 3]
>>> obj
[1, 42, 3]
我们使用昵称 foo 来修改中间元素,但这种更改也可以从所有其他昵称中看到。
为什么?
因为它们都指向同一个列表对象。
Python是按赋值传递的
介绍完所有这些之后,我们现在准备好了解 Python 如何将参数传递给函数了。
当我们调用函数时,函数的每个参数都会分配给它们传入的对象。本质上,函数中的每个参数现在都成为所给对象的新昵称。(相当于函数参数名称和传入的对象绑定了)
不可变的参数
如果我们传入不可变的参数,那么我们就无法修改传入的这个参数本身。毕竟,这就是不可变的含义:“不会改变”。
这就是为什么 Python 看起来像是使用传值模型的原因。因为我们可以让函数参数名称保存其他东西的唯一方法是将其分配给完全不同的对象。当我们这样做时,我们为不同的对象重复使用相同的昵称:
def foo(bar):
bar = 3
return bar
foo(5)
在上面的示例中,当我们使用参数 5 调用 foo 时,就好像我们在函数开头执行 bar = 5 一样。
紧接着,我们就有了 bar = 3 。这意味着“取昵称“bar”并将其指向整数 3 ”。 Python 并不关心 bar ,因为昵称(作为变量名)已经被使用了。它现在指向 3 !(原来的5还是原来的5)
可变参数
另一方面,可变参数是可以改变的。我们可以修改它们的内部内容。可变对象的一个主要例子是列表:它的元素可以改变(它的长度也可以改变)。
这就是为什么 Python 看起来使用引用传递模型。然而,当我们改变一个对象的内容时,我们并没有改变该对象本身的身份。同样,当您改变发型或衣服时,您的社会安全号码不会改变:
>>> l = [42, 73, 0]
>>> id(l)
3098903876352
>>> l[0] = -1
>>> l.append(37)
>>> id(l)
3098903876352
你明白我想说的吗?如果没有,请在下面发表评论,我会尽力提供帮助。
调用函数时要小心
这表明您在定义函数时应该小心。如果您的函数需要可变参数,您应该执行以下两项操作之一:
不要以任何方式改变参数;或者
明确记录参数可能会发生变化。
就我个人而言,我更喜欢采用第一种方法:不改变参数;但在某些时候和地点也可以采用第二种方法。
有时,您确实需要将参数作为某种转换的基础,这意味着您想要改变参数。在这些情况下,您可能会考虑制作参数的副本(在下一节中讨论),但制作该副本可能会占用大量资源。在这些情况下,改变参数可能是唯一明智的选择。
浅拷贝与深拷贝
“复制对象”意味着创建具有不同标识(因此是不同对象)但内容相同的第二个对象。一般来说,我们复制一个对象,以便我们可以使用它并改变它,同时保留源对象。
复制对象时,有一些细微差别需要讨论。
复制不可变对象
首先需要说的是,对于不可变对象,谈论副本是没有意义的。(拷贝一份不可变对象是没有意义的,因为系统中只有一份该不可变对象,和有100份该对象的拷贝都是一样的,既然如此,那为什么要有那么多的拷贝)
“副本”仅对可变对象有意义。如果你的对象是不可变的,并且你想保留对它的引用,你可以进行第二次赋值并对其进行处理:
string = "Hello, world!"
string_ = string
# Do stuff with `string_` now...
或者,有时,您可以直接在原始数据上调用方法和其他函数,因为原始数据不会被改变:
string = "Hello, world!"
print(string.lower())
# After calling `.lower`, `string` is still "Hello, world!"
因此,我们只需要担心可变对象。
浅拷贝
许多可变对象本身可以包含可变对象。因此,存在两种类型的副本:
浅拷贝
深拷贝
不同之处在于可变对象内部的可变对象会发生什么。
列表和字典有一个方法 .copy ,它返回相应对象的浅表副本。
>>> sublist = []
>>> outer_list = [42, 73, sublist]
>>> copy_list = outer_list.copy()
首先,我们在列表内创建一个列表,然后复制外部列表。现在,因为它是一个副本,所以复制的列表与原始外部列表不是同一对象:
>>> copy_list is outer_list
False
但如果它们不是同一个对象,那么我们可以修改其中一个列表的内容,而另一个列表不会反映更改:
>>> copy_list[0] = 0
>>> outer_list
[42, 73, []]
这就是我们所看到的:我们更改了 copy_list 的第一个元素,而 outer_list 保持不变。
现在,我们尝试修改 sublist 的内容,这就是乐趣开始的时候!
>>> sublist.append(999)
>>> copy_list
[0, 73, [999]]
>>> outer_list
[42, 73, [999]]
当我们修改 sublist 的内容时, outer_list 和 copy_list 都会反映这些更改......
但副本不是应该给我第二个列表,我可以在不影响第一个列表的情况下更改它吗?是的!这就是发生的事情!
事实上,修改 sublist 的内容并没有真正修改 copy_list 和 outer_list 的内容:毕竟,两者的第三个元素都指向一个列表对象,现在仍然是!这是我们所指向的对象的(内部)内容发生了变化。
有时,我们不希望这种情况发生:有时,我们不希望可变对象共享内部可变对象。
常见的浅拷贝技术
>>> outer_list = [42, 73, []]
>>> shallow_copy = outer_list[::]
>>> outer_list[2].append(999)
>>> shallow_copy
[42, 73, [999]]
在对象本身上使用相应类型的内置函数也会构建浅副本。这适用于列表和字典,并且可能适用于其他可变类型。
这是一个列表内有列表的示例:
>>> outer_list = [42, 73, []]
>>> shallow_copy = list(outer_list)
>>> shallow_copy[2].append(999)
>>> outer_list
[42, 73, [999]]
这是一个字典中包含列表的示例:
>>> outer_dict = {42: 73, "list": []}
>>> shallow_copy = dict(outer_dict)
>>> outer_dict["list"].append(999)
>>> shallow_copy
{42: 73, 'list': [999]}
深拷贝
当您想要“彻底”复制一个对象,并且不希望副本共享对内部对象的引用时,您需要对对象进行“深层复制”。您可以将深复制视为递归算法。
您复制第一层的元素,每当您在第一层找到可变元素时,就向下递归并复制这些元素的内容。
为了展示这个想法,下面是包含其他列表的列表的深度复制的简单递归实现:
def list_deepcopy(l):
return [
elem if not isinstance(elem, list) else list_deepcopy(elem)
for elem in l
]
我们可以使用这个函数复制之前的 outer_list ,看看会发生什么:
>>> sublist = []
>>> outer_list = [42, 73, sublist]
>>> copy_list = list_deepcopy(outer_list)
>>> sublist.append(73)
>>> copy_list
[42, 73, []]
>>> outer_list
[42, 73, [73]]
在这里可以看到,修改 sublist 的内容只是间接影响 outer_list ;它没有影响 copy_list 。
遗憾的是,我实现的 list_deepcopy 方法不是很健壮,也不是通用的,但是 Python 标准库已经满足了我们的要求!
模块 copy 和方法 deepcopy
模块 copy 正是我们所需要的。该模块提供了两个有用的功能:
copy.copy 用于浅拷贝;和
copy.deepcopy 用于深层复制。
就是这样!而且,更重要的是,方法 copy.deepcopy 足够智能,可以处理例如循环定义可能出现的问题!也就是说,当一个对象包含另一个包含第一个对象的对象时:深度复制算法的简单递归实现将进入无限循环!
如果您编写自己的自定义对象,并且想要指定应制作这些对象的浅副本和深副本,则只需分别实现 __copy__ 和 __deepcopy__ !
在我看来,这是一个很棒的模块。
代码中的示例
现在我们已经深入了解了这个理论(双关语),现在是时候向您展示一些使用这些概念的实际代码了。
可变的默认参数
In [35]: def my_append(elem, l=[]):
...: l.append(elem)
...: return l
...:
In [36]:
In [36]: my_append(1)
Out[36]: [1]
In [37]: my_append(1)
Out[37]: [1, 1]
In [38]: my_append(1)
Out[38]: [1, 1, 1]
显然,使用可变对象作为默认参数是一个坏主意。下面的片段向您展示了原因:
def my_append(elem, l=[]):
l.append(elem)
return l
上面的函数将一个元素追加到列表中,如果没有给出列表,则默认将其追加到空列表中。
太好了,让我们充分利用这个功能:
>>> my_append(1)
[1]
>>> my_append(1, [42, 73])
[42, 73, 1]
>>> my_append(3)
[1, 3]
我们将它与 1 一起使用一次,然后我们得到一个包含 1 的列表。然后,我们使用它将 1 附加到我们拥有的另一个列表中。最后,我们用它来将 3 附加到一个空列表中......但事实并非如此!
事实证明,当我们定义一个函数时,会创建默认参数并将其存储在一个特殊的位置:
>>> my_append.__defaults__
([1, 3],)
这意味着默认参数始终是同一个对象。因此,因为它是一个可变对象,所以它的内容可以随着时间的推移而改变。这就是为什么在上面的代码中, __defaults__ 显示了一个已经包含两个项目的列表。
如果我们重新定义该函数,那么它的 __defaults__ 显示一个空列表:
>>> def my_append(elem, l=[]):
... l.append(elem)
... return l
...
>>> my_append.__defaults__
([],)
这就是为什么通常不应将可变对象用作默认参数的原因。
在这些情况下,标准做法是使用 None ,然后使用布尔短路来分配默认值:
def my_append(elem, l=None):
lst = l or []
lst.append(elem)
return lst
通过此实现,该函数现在可以按预期工作:
>>> my_append(1)
[1]
>>> my_append(3)
[3]
>>> my_append(3, [42, 73])
[42, 73, 3]
is not None
搜索 Python 标准库显示 is not 运算符的使用次数超过 5,000 次。好多啊。
而且,总的来说,该运算符后面几乎总是跟着 None 。事实上, is not None 在标准库中出现了 3169 次!
x is not None 的作用与它所写的完全一样:它检查 x 是否为 None 。
下面是一个简单的示例用法,从 argparse 模块创建命令行界面:
# From Lib/argparse.py from Python 3.9
class HelpFormatter(object):
# ...
class _Section(object):
# ...
def format_help(self):
# format the indented section
if self.parent is not None:
self.formatter._indent()
# ...
即使没有大量上下文,我们也可以看到发生了什么:当显示给定部分的命令帮助时,我们可能希望缩进(或不缩进)以显示层次依赖性。
如果一个段的 parent 是 None ,那么该部分没有父级,并且不需要缩进。换句话说,如果一个段的父 is not None ,那么我们想要缩进它。请注意我的英语与代码是如何完全匹配的!
系统环境深拷贝
方法 copy.deepcopy 在标准库中使用了几次,这里我想展示一个复制字典的示例用法。
模块 os 提供属性 environ ,类似于字典,其中包含定义的环境变量。
以下是我的 (Windows) 机器上的几个示例:
>>> os.environ["lang"]
'en_US.UTF-8'
>>> os.environ["appdata"]
'C:\\Users\\rodri\\AppData\\Roaming'
>>> os.environ["systemdrive"]
'C:'
# Use list(os.environ.keys()) for a list of your environment variables.
模块 http.server 为基本的HTTP服务器提供了一些类。
其中一个类 CGIHTTPRequestHandler 实现了一个也可以运行 CGI 脚本的 HTTP 服务器,并且在其 run_cgi 方法中,它需要设置一堆环境变量。
设置这些环境变量是为了为将要运行的 CGI 脚本提供必要的上下文。然而,我们并不想真正修改当前的环境!
所以,我们所做的就是创建环境的深层副本,然后将其修改为我们想要的内容!完成后,我们告诉 Python 执行 CGI 脚本,并提供更改后的环境作为参数。
完成此操作的确切方式可能并不容易理解。一方面,我认为我无法向你解释这一点。但这并不意味着我们不能推断其中的一部分:
这是代码:
# From Lib/http/server.py in Python 3.9
class CGIHTTPRequestHandler(SimpleHTTPRequestHandler):
# ...
def run_cgi(self):
# ...
env = copy.deepcopy(os.environ)
env['SERVER_SOFTWARE'] = self.version_string()
env['SERVER_NAME'] = self.server.server_name
env['GATEWAY_INTERFACE'] = 'CGI/1.1'
# and many more `env` assignments!
# ...
else:
# Non-Unix -- use subprocess
# ...
p = subprocess.Popen(cmdline,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env = env
)
正如我们所看到的,我们复制了环境并定义了一些变量。最后,我们创建了一个新的子进程来获取修改后的环境。
Conclusion 结论
本文的主要内容,为您提供:
“Python 使用传递赋值模型,理解它需要你认识到所有对象都由身份号、类型和内容来表征。”
这个 Pydon 没有向你展示:
Python 不使用传值模型,也不使用传引用模型;
Python 使用传递赋值模型(你可以简单认为使用“昵称”);
每个对象的特点是:其ID,类型,内容
id 函数用于查询对象的标识符;
type 函数用于查询对象的类型;
对象的类型决定了它是可变的还是不可变的;
浅拷贝复制嵌套可变对象的引用;
深拷贝执行的复制允许更改一个对象及其内部元素,而不会影响其他对象;
copy.copy 和 copy.deepcopy 可用于执行浅/深拷贝;和
如果您希望自己的对象可复制,则可以实现 __copy__ 和 __deepcopy__ 。
See also
如果您喜欢视频内容,可以查看此 YouTube 视频,该视频的灵感来自本文。
如果您喜欢这个 Pydon,请务必在下面留下评论,并与您的朋友和其他 Python 爱好者分享。另外,请订阅时事通讯,这样您就不会错过任何一个 Pydon!
评论
发表评论