如果您曾經使用過像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