目录
为什么Python没有指针?
Python中的对象
不可变对象和可变对象
了解变量
C的变量
Python的名称
关于Python的预实现对象的注释
在Python中模拟指针
使用可变类型作为指针
使用Python对象
使用ctypes模块实现的真正指针
总结
如果您曾经使用过像C或者C++这样的低级语言,那么您可能听说过指针。指针允许您在部分代码上取得更高的效率。但它们也会给初学者带来困惑,而且还可能导致各种内存管理错误,即使对于专家来说也会如此。那么它们在Python的哪里?您该如何在Python中模拟指针?
指针在C和C++中广泛应用。从本质上来说,它们是保存另一个变量内存地址的变量。有关指针的复习,您可以考虑看一下这篇关于C语言指针的概述。
通过本文,您会更好地理解Python的对象模型,同时明白为什么Python中不存在真正的指针。对于需要模仿指针行为的情况,您将学习到如何在没有内存管理噩梦的情况下在Python中模拟指针。
在本文中,您将
· 了解为什么Python中的指针不存在
· 探索C变量和Python名称之间的区别
· 在Python中模拟指针
· 使用ctypes实现真正的指针
注意:在本文中,“Python”会涉及C中Python的参考实现,也称作CPython。当文章在讨论该语言的一些内部结构时,这些注释适用于CPython 3.7,但在将来或过去的语言迭代中可能不适用。
为什么Python没有指针?
事实上,我并不知道答案。Python中的指针本身可以存在吗?可能,但指针似乎违背了Python的禅宗。指针鼓励隐含的变化而不是明确的变化。通常,它们很复杂而不是简单,特别是对于初学者。更糟糕的是,他们会导致你自作自受,或者做一些非常危险的事情,比如从您不被允许的一段内存中读取数据。
Python倾向于尝试从用户那里抽象出内存地址等实现细节。Python通常关注可用性而不是速度。因此,Python中的指针并没有多大意义。但不要害怕,默认情况下,Python会为您提供使用指针的一些好处。
理解Python中的指针需要简要介绍Python的实现细节。具体来说,您需要了解:
1.不可变对象和可变对象
2.Python变量/名称
保留你的内存地址,让我们开始吧。
Python中的对象
在Python中,一切都是对象。为了证明,你可以打开一个REPL并尝试使用isinstance():
此代码向您显示Python中的所有内容确实是一个对象。每个对象至少包含三个数据:
•引用计数
•类型
•值
引用计数用于内存管理。要深入了解Python内存管理的内核,您可以阅读Python中的内存管理。
类型在CPython层使用,用于确保运行时的类型安全性。最后,值,即与对象关联的实际值。
但并非所有对象都是相同的。您还需要了解另一个重要的区别:不可变对象和可变对象。理解对象类型之间的差异确实有助于阐明Python中的指针的第一层。
不可变对象和可变对象
在Python中,有两种类型的对象;
1. 不可变对象无法更改
2. 可变对象可以更改
理解这种差异是认识Python指针的第一个关键。以下是常见类型的细分以及它们是否可变或不可变:
如您所见,许多常用的基元类型是不可变的。您可以通过编写一些Python来证明这一点。您需要Python标准库中的一些工具:
1.id() 返回对象的内存地址。
2.is 当且仅当两个对象具有相同的内存地址时才返回True。
再次,您可以在REPL环境中使用运行以下代码:
在上面的代码中,您已经将5赋给x。如果您尝试使用add修改此值,那么您将获得一个新对象:
上面的代码似乎修改了x的值,但您得到了一个新对象作为响应。
str类型也是不变的:
同样,经过“+=”操作后s最终会有不同的内存地址。
福利:“+=”操作会转换成不同的方法调用。
对于某些对象,如list对象,+=将转换为__iadd__()(就地添加)。这将修改self并返回相同的ID。但是,str和int对象没有这些方法,这就导致它们调用的是__add__()而不是__iadd__()。
有关更多详细信息,请查看Python 数据模型文档。
试图直接改变字符串s会导致错误:
上面的代码失败了,这表明str不支持这种突变,这与str类型是不可变的定义一致。
与可变对象作对比,例如list类型:
此代码显示了两种类型对象的主要区别。”my_list“最初有一个id。即使在4被附加到列表后,”my_list“也具有相同的 ID。这是因为list类型是可变的。
证明列表可变的另一种方法是赋值:
在此代码中,您可以改变“my_list”,将其第一个元素设置为0。但是,即使在赋值之后,它仍保持原有的ID。随着可变和不可变对象的出现,Python启蒙之旅的下一步是理解Python的变量生态系统。
了解变量
Python变量在根本上与C或C ++中的变量不同。事实上,Python甚至没有变量。Python有名称,而不是变量。
这可能看起来很迂腐,而且在大多数情况下就是迂腐。大多数时候,将Python名称视为变量是完全可以接受的,但理解差异很重要。当您在Python中探寻棘手的指针主题时尤为重要。
为了帮助理解差异,您可以了解变量如何在C中工作,它们代表什么,然后将其与名称在Python中的工作方式进行对比。
C中的变量
假设您用以下代码来定义变量x:
这一行代码在执行时有几个不同的步骤:
1. 为整数分配足够的内存
2. 将值分配2337给该内存位置
3. 指示x指向该值
以简化的内存视图显示,它可能如下所示:
在这里,您可以看到该变量x具有伪内存位置0x7f1和值2337。如果在程序中稍后要更改其x的值,则可以执行以下操作:
上面的代码给变量x分配了一个新的值2338,从而覆盖了以前的值。这意味着变量x是可变的。更新的内存布局显示新值:
请注意,x的位置没有改变,只是改变了值。这是一个重要的观点。这意味着x 是内存位置,而不仅仅是名称。
另一种思考这个概念的方法是在所有权方面。从某种意义上说,x拥有内存位置。首先,x恰好是一个可以存储整数的空盒子,可以用来存储整数值。
当您给x赋值时,您将向x拥有的盒子中放入一个值。如果你想引入一个新的变量(y),你可以添加这行代码:
此代码创建一个名为y的盒子,并将x的值复制到y盒子中。现在内存布局将如下所示:
注意新位置0x7f5的y。即使将x的值复制到y,但是变量y在内存中拥有新地址。因此,您可以覆盖y的值而不影响x的值:
现在内存布局将如下所示:
同样,你修改的是y的值,而不是它的位置。此外,您始终没有影响原始的x变量。这与Python名称的工作方式形成鲜明对比。
Python中的名称
Python没有变量。它有名字。是的,这是一个迂腐点,你当然可以随意使用术语变量。重要的是要知道变量和名称之间存在差异。
让我们根据上面的C示例获取等效代码并将其写在Python中:
与C类似,上面的代码在执行过程中分解为几个不同的步骤:
1.创建一个 PyObject
2.将PyObject的typecode设置为整数 PyObject
3.将PyObject的值设置为2337
4.创建一个名称 x
5.将x指向新的PyObject
6.将PyObject引用计数增加1
注意:这里的PyObject与Python的对象不一样。它于CPython特有的并表示所有Python对象的基本结构。
PyObject被定义为C结构,所以,如果你想知道为什么你不能调用typecode或refcount,这是因为你没有权限直接进入结构。方法调用如sys.getrefcount()可以帮助您获得一些内部情况。
在内存中,它可能看起来像这样:
您可以看到内存布局与之前的C布局截然不同。在这里,新创建的Python对象拥有值2337所在的内存,而不是x拥有值2337所在的内存。Python名称x不直接拥有任何内存地址,不像C变量在内存中拥有静态插槽。
如果您尝试为x赋新的值,可以尝试以下操作:
这里发生的事情与C的同样操作不同,但与Python中的原始绑定没有太大区别。
这行代码:
· 创建一个新的 PyObject
· 将PyObject的typecode设置为整数
· 将PyObject的值设置为2338
· 将x指向新的PyObject
· 将新的PyObject引用计数增加1
· 将旧的PyObject引用计数减少1
现在在内存中,它看起来像这样:
此图有助于说明x指向对象的引用,并不像以前那样拥有内存空间。它还表明命令“x = 2338”不是赋值,而是将名称x绑定到一个引用。
此外,前一个对象(拥有值2337)现在位于内存中,引用计数为0,并将被垃圾收集器清理。
您可以引入一个新名称y,就如C的示例一样:
在内存中,您将拥有一个新名称,但不一定是新对象:
现在,你可以看到并没有创建一个新的Python对象,只是创建指向同一个对象的新名称。此外,对象的引用参数增加了1。您可以检查对象标识来确认它们是否相同:
上面的代码表明x和y是相同的对象。没错:y仍然是不可改变的。
例如,您可以对有y执行以下操作:
添加调用后,将返回一个新的Python对象。现在,内存看起来像这样:
一个新对象被创建,y现在指向新对象。有趣的是,如果你已经将2339绑定到y,结束状态也是如此:
上述语句导致与添加相同的结束内存状态。回顾一下,在Python中,您不需要分配变量。而是将名称绑定到引用。
关于Python中的预实现对象的注释
现在您已经了解了如何创建Python对象并将名称绑定到这些对象,现在是时候在机器中抛出一把扳手了。该扳手叫做预实现对象。
假设您有以下Python代码:
如上所述,x和y这两个名字都指向同一个Python对象。但是保存1000的Python对象并不能保证总是具有相同的内存地址。例如,如果将两个数字相加以获得1000,则最终会得到一个不同的内存地址:
这一次,"x is y"返回False。如果这令人困惑,别担心。以下是执行此代码时发生的步骤:
1.创建Python对象(1000)
2.将名称分配x给该对象
3.创建Python对象(499)
4.创建Python对象(501)
5.将这两个对象一起添加
6.创建一个新的Python对象(1000)
7.将名称分配y给该对象
技术说明:只有在REPL中执行此代码时,才会执行上述步骤。如果您采用上面的示例,将其粘贴到一个文件中,然后运行该文件,那么您会发现"x is y"将返回True。
这是因为编译器很聪明。CPython编译器尝试进行称为窥孔优化的优化,这有助于尽可能地保存执行步骤。有关详细信息,您可以查看CPython的窥孔优化器源代码。
这不是浪费吗?嗯,是的,这是你为Python所有巨大好处付出的代价。您永远不必担心如何清理这些中间对象,甚至都不需要知道它们存在!令人高兴的是,这些操作相对较快,并且直到现在你都不需要去理解这些细节。
Python核心开发人员也睿智地注意到了这种浪费,并决定进行一些优化。这些优化产生了令新手感到惊讶的行为:
在此示例中,您看到的代码几乎与以前相同,除了这次结果是True。这是预实现对象的结果。Python在内存中预先创建了某个对象子集,并将它们保存在全局命名空间中以供日常使用。
哪些对象依赖于Python的预实现。CPython 3.7预实现对象如下:
1.-5到256之间的整数
2.仅包含ASCII字母,数字或下划线的字符串
这背后的原因是这些变量很可能在许多程序中使用。通过预先实现些对象,Python可以防止对一致使用的对象进行内存分配调用。
预先实现小于20个字符且包含ASCII字母,数字或下划线的字符串。背后的原因是假设这些字符串是某种身份:
在这里您可以看到s1和s2都指向相同的内存地址。如果您要引入非ASCII字母,数字或下划线组成的字符串,那么您将得到不同的结果:
因为此示例中包含感叹号“!”,所以这些字符串不会被预先实现,并且s1和s2是内存中的不同对象。
福利:如果您真的希望这些对象引用相同的内部对象,那么您可能需要查看sys.intern()。文档中概述了此功能的一个用例:
预先实现的字符串对于在字典查找中获得一点性能很有用 - 如果字典中的键被预先实现,并且查找键被预先实现,则键比较(完成在散列之后)就可以通过指针来比较而不是用字符串来比较。(来源)
预实现对象通常是混乱的来源。请记住,如果您有任何疑问,可以随时使用id()和is确定对象是否相同。
在Python中模拟指针
仅仅因为Python中的指针本身不存在并不意味着你无法获得使用指针的好处。实际上,可以有多种方法在Python中模拟指针。您将在本节中学习到两种:
1.使用可变类型作为指针
2.使用自定义Python对象
好的,让我们进入正题。
使用可变类型作为指针
您已经了解过可变类型。因为这些对象是可变的,所以您可以将它们视作指针,以此来模拟指针行为。假设您复制了以下c代码:
此代码将一个指针指向一个整数(*x),然后将其值增加1。这有一个运行代码的主函数:
在上面的代码中,将值2337赋给y,打印出当前值,将值增加1,然后打印出修改后的值。执行此代码的输出如下:
在Python中模仿此类行为的一种方法是使用可变类型。考虑使用列表并修改第一个元素:
在这里,add_one(x)访问第一个元素并将其值增加1。通过使用列表,最终似乎已修改了该值。那么Python中的指针确实存在吗?好吧,不。唯一的可能是:因为列表是一种可变类型。如果您尝试使用一个元组,则会收到错误消息:
上面的代码演示了元组是不可变的。因此,它不支持项目赋值。列表不是唯一可变的类型。在Python中模仿指针的另一种常见方法是创建字典。
假设您有一个应用程序,您希望每次发生有趣事件时都要跟踪。实现此目的的另一种方法是创建一个字典 并使用其中的一项作为计数器:
在此示例中,counters字典用于跟踪函数调用的数量。调用foo()函数后,计数器按预期增加到2。这都是因为字典是可变类型。
请记住,这只是模拟指针行为,并不直接映射到C或C ++中的真指针。也就是说,这些操作在Python中会比在C或C ++中付出更多代价。
使用Python对象
使用字典是在Python中模拟指针的一种好方法,但有时您需要记住使用的密钥名称,这会很繁琐。如果您在应用程序的各个部分都使用字典,则尤其如此。这就是自定义Python类可以真正起到作用的地方。
构建最后一个示例,假设您要跟踪应用程序中的指标。创建一个类是解决那些讨厌的抽象细节的好方法:
此代码定义了一个Metrics类。该类仍然使用字典来保存实际数据,该数据位于_metrics成员变量中。这将为您提供所需的可变性。现在您只需要能够访问这些值。一个很好的方法是使用属性:
这段代码利用了@property。如果您不熟悉装饰器,可以查看Python装饰器入门。@property装饰器允许您访问func_calls,cat_pictures_served,它们就像属性一样:
您可以把名称当作属性访问这一事实,意味着您已抽象了一个事实:这些值在字典中。您还可以更明确地指出属性的名称是什么。当然,您应该能够增加这些值:
您已了解了两种新方法:
1.inc_func_calls()
2.inc_cat_pics()
这些方法能够修改类中字典的值。您现在有一个类可以修改,就像您正在修改指针一样:
这样,您就可以在应用程序中的各个位置访问func_calls和调用inc_func_calls(),并在Python中模拟指针。当您需要在应用程序的各个部分中频繁使用和更新指针时,这非常有用。
注意:特别是在这个类中,使用inc_func_calls()和inc_cat_pics()更为清楚明白,而不是使用@property.setter,这能阻止用户将这些值设置为任意的整型或无效的值,如字典。
这是Metrics类的完整代码:
使用ctypes模块实现真实指针
好吧,也许Python中有指针,特别是CPython。使用内置ctypes模块,您可以在Python中创建真正的C风格指针。如果您不熟悉ctypes,那么您可以查看使用C库扩展Python和“ctypes”模块。
你要使用它的真正原因是你需要对C库创建一个需要指针的函数调用。让我们回到之前的C函数add_one():
同样,这段代码将x的值增加1。要使用它,首先将其编译为共享对象。假设上述代码存储在add.c文件中,您可以通过gcc来完成以下操作:
第一个命令将C源文件编译为一个名为add.o的对象。第二个命令获取该未链接的目标文件并生成一个名为libadd1.so的共享对象。
libadd1.so应该在您当前的目录中。您可以使用ctypes的命令将其加载到Python:
代码ctypes.CDLL返回一个代表libadd1的共享对象。因为您add_one()在此共享对象中定义,所以您可以像访问其他任何Python对象一样访问它。在调用该函数之前,您应该指定函数签名。这有助于Python确保将正确的类型传递给函数。
在这种情况下,函数签名是指向整数的指针。ctypes允许您使用以下代码来指定:
在此代码中,您设置函数签名以匹配C所期望的内容。现在,如果您尝试使用错误的类型调用此代码,那么您将得到一个很好的警告而不是未定义的行为:
Python抛出一个错误,解释说add_one()想要一个指针而不是一个整数。幸运的是,ctypes有一种方法可以将指针传递给这些函数。首先,声明一个C风格的整数:
上面的代码创建了一个C风格的整数x,其值为0。ctypes提供方便的byref()方法,它允许通过引用来传递变量。
注意:传递变量时,术语"通过引用"与"通过值"相反。
通过引用传递时,您将引用传递给原始变量,因此修改将反映在原始变量中。按值传递会生成原始变量的副本,并且修改不会反映在原始变量中。
你可以用下面的代码来调用add_one():
太好了!你的整数加1。恭喜,您已成功使用Python的真实指针。
总结
您现在对Python对象和指针之间的关系有了更好的理解。尽管名称和变量之间的某些区别似乎很迂腐,但从根本上理解这些关键术语可以扩展您对Python如何处理变量的理解。
您还学习了一些在Python中模拟指针的好方法:
· 利用可变对象作为低开销指针
· 创建自定义Python对象以便于使用
· 使用ctypes模块的解锁真实指针
这些方法允许您在Python中模拟指针,而且不会牺牲Python提供的内存安全性。
感谢您的阅读。如果您仍有疑问,请随时在评论部分或Twitter上与我联系。
英文原文:https://realpython.com/pointers-in-python
译者:ZH
领取专属 10元无门槛券
私享最新 技术干货