當我們的計畫變得越來越大時,高效管理計算資源是一個不可避免的要求。 不幸的是,與低階語言如C或C++相比,Python在記憶體效率方面似乎不夠。 那麽,現在應該更改程式語言嗎?
當然不是。 事實上,有許多方法可以顯著最佳化Python程式的記憶體使用,從優秀的模組和工具到先進的數據結構和演算法。 本文將聚焦於Python的內建機制,並介紹7個原始但有效的記憶體最佳化技巧。 掌握這些技巧將顯著提高我們的Python編程技能。
1. 在類別定義中使用__slots__
Python作為一種動態型別語言,在物件導向編程方面更加靈活。一個很好的例子是在執行時向Python類中添加額外的內容和方法的能力。 例如,下面的程式碼定義了一個名為Author的類。 最初它有兩個內容name和age。 但是我們可以很容易地在後來添加一個額外的內容:
classAuthor:
def__init__(self, name, age):
self.name = name
self.age = age
me = Author('Yang Zhou', 30)
me.job = 'Software Engineer'
print(me.job)
然而,每個硬幣都有兩面。這種靈活性在底層浪費了更多的記憶體。 因為Python 類的每個例項都維護一個特殊的字典(__dict__)來儲存例項變量。 這個字典由於其基於哈希表的實作方式而固有地記憶體效率低下,占用大量記憶體。
在大多數情況下,我們不需要在執行時更改例項的變量或方法,而且在類別定義之後__dict__將不會改變。因此,如果我們能避免維護__dict__字典,那就更好了。 Python為此提供了一個神奇的內容: __slots__。 它透過指定類的所有有效內容的名稱來充當白名單:
classAuthor:
__slots__ = ('name', 'age')
def__init__(self, name, age):
self.name = name
self.age = age
me = Author('Yang Zhou', 30)
me.job = 'Software Engineer'
print(me.job)
#AttributeError: 'Author' object has no attribute 'job'
如上所示,我們不能再在執行時添加job內容。因為__slots__白名單只定義了兩個有效內容name和age。 從理論上講,由於內容現在是固定的,Python不需要為其維護一個字典。 它只需為__slots__中定義的內容分配必要的記憶體空間。
讓我們編寫一個簡單的比較程式,看看它是否確實起作用:
import sys
classAuthor:
def__init__(self, name, age):
self.name = name
self.age = age
classAuthorWithSlots:
__slots__ = ['name', 'age']
def__init__(self, name, age):
self.name = name
self.age = age
# Creating instances
me = Author('Yang', 30)
me_with_slots = AuthorWithSlots('Yang', 30)
# Comparing memory usage
memory_without_slots = sys.getsizeof(me) + sys.getsizeof(me.__dict__)
memory_with_slots = sys.getsizeof(me_with_slots) # __slots__ classes don't have __dict__
print(memory_without_slots, memory_with_slots)
# 152 48
print(me.__dict__)
# {'name': 'Yang', 'age': 30}
print(me_with_slots.__dict__)
# AttributeError: 'AuthorWithSlots' object has no attribute '__dict__'
正如上面的程式碼所演示的,由於使用了__slots__,me_with_slots例項不具有__dict__字典。與必須保留額外字典的me例項相比,這有效地節省了記憶體資源。
2. 使用生成器
生成器是Python中的惰性求值版本的列表。 它們就像元素生成工廠: 僅在呼叫next()方法時生成一個計畫,而不是一次計算所有計畫。 因此,當處理大型數據集時,它們非常記憶體高效。
defnumber_generator():
for i in range(100):
yield i
numbers = number_generator()
print(numbers)
print(next(numbers))
#0
print(next(numbers))
#1
上面的程式碼展示了編寫和使用生成器的基本範例。關鍵字yield是生成器定義的核心。套用它意味著只有在呼叫next()方法時才會產生計畫i。 現在,讓我們比較一下生成器和列表,看看哪個更記憶體高效:
import sys
numbers = []
for i in range(100):
numbers.append(i)
defnumber_generator():
for i in range(100):
yield i
numbers_generator = number_generator()
print(sys.getsizeof(numbers_generator))
#112
print(sys.getsizeof(numbers))
#920
上述程式的結果證明了使用生成器可以顯著節省記憶體使用。 順便說一下,如果我們將列表推導式的方括弧改成括弧,它將變成生成器運算式。 這是在Python中定義生成器的更簡便的方法:
import sys
numbers = [i for i in range(100)]
numbers_generator = (i for i in range(100))
print(sys.getsizeof(numbers_generator))
#112
print(sys.getsizeof(numbers))
#920
3. 利用記憶體對映檔支持大檔處理
記憶體對映檔I/O,簡稱「mmap」,是一種作業系統級別的最佳化。
它實作了需求分頁,因為檔內容並不立即從磁盤讀取,並且最初根本不使用物理RAM。實際從磁盤讀取是在特定位置被存取時以懶惰的方式執行的。—— 維基百科
簡單來說,當使 用mmap技術記憶體對映檔時,它在當前行程的虛擬記憶體空間中直接建立檔的對映,而不是將整個檔載入到記憶體中。 對映而不是載入整個檔可以節省大量記憶體。
聽起來很復雜? 幸運的是,Python已經提供了一個用於使用這種技術的內建模組,因此我們可以輕松利用它,而不必考慮作業系統級別的實作。 例如,這是在Python中使用mmap進行檔處理的方法:
import mmap
with open('test.txt', "r+b") as f:
# memory-map the file, size 0 means whole file
with mmap.mmap(f.fileno(), 0) as mm:
# read content via standard file methods
print(mm.read())
# read content via slice notation
snippet = mm[0:10]
print(snippet.decode('utf-8'))
如上所演示的,Python使得記憶體對映檔I/O技術的使用變得方便。我們所需要做的就是簡單地套用`mmap.mmap()`方法,然後使用標準檔方法或甚至切片表示法處理開啟的物件。
4. 減少全域變量的使用
全域變量在程式執行期間始終駐留在記憶體中,因為它們具有全域範圍。 因此,如果一個全域變量保存一個大型數據結構,它將在整個程式生命周期中占用記憶體,可能導致記憶體使用效率低下。 我們應該在Python程式碼中盡量減少全域變量的使用。
5. 利用邏輯運算子的短路求值
這個技巧似乎微妙,但巧妙地使用它將極大地節省程式的記憶體使用。 例如,下面是一個簡單的程式碼片段,根據兩個函式返回的布爾值得到最終結果:
result_a = expensive_function_a()
result_b = expensive_function_b()
result = result_a if result_a else result_b
上面的程式碼能夠工作,但實際上執行了兩個記憶體效率低下的函式。 獲取相同結果的更聰明的方法如下:
result = expensive_function1() orexpensive_function2()
由於邏輯運算子遵循短路求值規則,上述程式碼中的`expensive_function2()`將不會在`expensive_function1()`為True時執行。這將節省不必要的記憶體使用。
6. 謹慎選擇數據型別
一位經驗豐富的Python開發者會仔細而準確地選擇數據型別。 因為在某些場景中,使用一個數據型別比另一個更節省記憶體。
元組比列表更節省記憶體
由於元組是不可變的(在建立後不能更改),它允許Python在記憶體分配方面進行最佳化。 然而,列表是可變的,因此需要額外的空間來容納潛在的修改。
import sys
my_tuple = (1, 2, 3, 4, 5)
my_list = [1, 2, 3, 4, 5]
print(sys.getsizeof(my_tuple))
#80
print(sys.getsizeof(my_list))
#120
如上面的片段所示,即使它們包含相同的元素,元組`my_tuple`使用的記憶體比列表更少。 因此,如果在建立後不需要更改數據,我們應該更喜歡使用元組而不是列表。
陣列比列表更節省記憶體
Python中的陣列要求元素是相同的數據型別(例如,全部整數或全部浮點數),但列表可以儲存不同型別的物件,這必然需要更多的記憶體。 因此,如果列表的元素都是相同型別,使用陣列會更節省記憶體:
import sys
import array
my_list = [i for i in range(1000)]
my_array = array.array('i', [i for i in range(1000)])
print(sys.getsizeof(my_list))
#8856
print(sys.getsizeof(my_array))
#4064
優秀的數據科學模組比內建數據型別更高效
Python是數據科學的主導語言。有許多強大的第三方模組和工具提供了更多的數據型別,例如NumPy和Pandas。 如果我們只需要一個簡單的一維數位陣列,並且不需要NumPy提供的廣泛功能,那麽Python內建的陣列可能是一個不錯的選擇。
但是,當涉及到復雜的矩陣操作時,對於所有數據科學家來說,使用NumPy提供的陣列是第一選擇,可能是最好的選擇。
7. 對相同的字串套用字串駐留技術
下面的程式碼可能會使許多開發者感到困惑:
>>> a = 'Y'*4096
>>> b = 'Y'*4096
>>> a is b
True
>>> c = 'Y'*4097
>>> d = 'Y'*4097
>>> c is d
False
正如我們所知,`is`運算子用於檢查兩個變量是否參照記憶體中的同一物件。它與`==`運算子不同,後者用於比較兩個物件是否具有相同的值。 那麽為什麽`a is b`返回True,而`c is d`返回False呢?
這裏有Python中的一個隱秘技巧 —— 字串駐留技術。 如果有幾個值相同的小型字串,它們將由Python隱式地進行駐留,並參照記憶體中的同一物件。 定義小型字串的神奇數位是4096。 由於`c`和`d`的長度都是4097,它們是記憶體中的兩個物件而不是一個。 不再有隱式的字串駐留。 因此,在執行`c is d`時得到False。
字串駐留是一種最佳化記憶體使用的強大技術。如果我們想要顯式地進行駐留,`sys.intern()`方法就派上用場了:
>>> import sys
>>> c = sys.intern('Y'*4097)
>>> d = sys.intern('Y'*4097)
>>> c is d
True
順便說一下,除了字串駐留,Python還對小整數套用駐留技巧。我們也可以利用它進行記憶體最佳化。
來源:小白玩轉Python
作者:二旺
Crossin的新書【 碼上行動:用ChatGPT學會Python編程 】已經上市了。 本書以ChatGPT為輔助,系統全面地講解了如何掌握Python編程,適合Python零基礎入門的讀者學習。
購買後可加入讀者交流群,Crossin為你開啟陪讀模式,解答你在閱讀本書時的一切疑問。
Crossin的其他書籍:
添加微信 crossin123 ,加入編程教室共同學習 ~
感謝 轉發 和 點贊 的各位~