七個Python內(nèi)存優(yōu)化技巧,你用過幾個?
當我們的項目變得越來越大時,高效管理計算資源是一個不可避免的要求。不幸的是,與低級語言如C或C++相比,Python在內(nèi)存效率方面似乎不夠。那么,現(xiàn)在應該更改編程語言嗎?
當然不是。事實上,有許多方法可以顯著優(yōu)化Python程序的內(nèi)存使用,從優(yōu)秀的模塊和工具到先進的數(shù)據(jù)結(jié)構(gòu)和算法。本文將聚焦于Python的內(nèi)置機制,并介紹7個原始但有效的內(nèi)存優(yōu)化技巧。掌握這些技巧將顯著提高我們的Python編程技能。
1. 在類定義中使用__slots__
Python作為一種動態(tài)類型語言,在面向?qū)ο缶幊谭矫娓屿`活。一個很好的例子是在運行時向Python類中添加額外的屬性和方法的能力。例如,下面的代碼定義了一個名為Author的類。最初它有兩個屬性name和age。但是我們可以很容易地在后來添加一個額外的屬性:
class Author:
def __init__(self, name, age):
self.name = name
self.age = age
me = Author('Yang Zhou', 30)
me.job = 'Software Engineer'
print(me.job)
然而,每個硬幣都有兩面。這種靈活性在底層浪費了更多的內(nèi)存。因為Python類的每個實例都維護一個特殊的字典(__dict__)來存儲實例變量。這個字典由于其基于哈希表的實現(xiàn)方式而固有地內(nèi)存效率低下,占用大量內(nèi)存。
在大多數(shù)情況下,我們不需要在運行時更改實例的變量或方法,而且在類定義之后__dict__將不會改變。因此,如果我們能避免維護__dict__字典,那就更好了。Python為此提供了一個神奇的屬性:slots。它通過指定類的所有有效屬性的名稱來充當白名單:
class Author:
__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。從理論上講,由于屬性現(xiàn)在是固定的,Python不需要為其維護一個字典。它只需為__slots__中定義的屬性分配必要的內(nèi)存空間。
讓我們編寫一個簡單的比較程序,看看它是否確實起作用:
import sys
class Author:
def __init__(self, name, age):
self.name = name
self.age = age
class AuthorWithSlots:
__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實例相比,這有效地節(jié)省了內(nèi)存資源。
2. 使用生成器
生成器是Python中的惰性求值版本的列表。它們就像元素生成工廠:僅在調(diào)用next()方法時生成一個項目,而不是一次計算所有項目。因此,當處理大型數(shù)據(jù)集時,它們非常內(nèi)存高效。
def number_generator():
for i in range(100):
yield i
numbers = number_generator()
print(numbers)
print(next(numbers))
#0
print(next(numbers))
#1
上面的代碼展示了編寫和使用生成器的基本示例。關(guān)鍵字yield是生成器定義的核心。應用它意味著只有在調(diào)用next()方法時才會產(chǎn)生項目i。現(xiàn)在,讓我們比較一下生成器和列表,看看哪個更內(nèi)存高效:
import sys
numbers = []
for i in range(100):
numbers.append(i)
def number_generator():
for i in range(100):
yield i
numbers_generator = number_generator()
print(sys.getsizeof(numbers_generator))
#112
print(sys.getsizeof(numbers))
#920
上述程序的結(jié)果證明了使用生成器可以顯著節(jié)省內(nèi)存使用。順便說一下,如果我們將列表推導式的方括號改成括號,它將變成生成器表達式。這是在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. 利用內(nèi)存映射文件支持大文件處理
內(nèi)存映射文件I/O,簡稱“mmap”,是一種操作系統(tǒng)級別的優(yōu)化。
它實現(xiàn)了需求分頁,因為文件內(nèi)容并不立即從磁盤讀取,并且最初根本不使用物理RAM。實際從磁盤讀取是在特定位置被訪問時以懶惰的方式執(zhí)行的。
—— 維基百科
簡單來說,當使用mmap技術(shù)內(nèi)存映射文件時,它在當前進程的虛擬內(nèi)存空間中直接創(chuàng)建文件的映射,而不是將整個文件加載到內(nèi)存中。映射而不是加載整個文件可以節(jié)省大量內(nèi)存。
聽起來很復雜?幸運的是,Python已經(jīng)提供了一個用于使用這種技術(shù)的內(nèi)置模塊,因此我們可以輕松利用它,而不必考慮操作系統(tǒng)級別的實現(xiàn)。例如,這是在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使得內(nèi)存映射文件I/O技術(shù)的使用變得方便。我們所需要做的就是簡單地應用`mmap.mmap()`方法,然后使用標準文件方法或甚至切片表示法處理打開的對象。
4. 減少全局變量的使用
全局變量在程序運行期間始終駐留在內(nèi)存中,因為它們具有全局范圍。因此,如果一個全局變量保存一個大型數(shù)據(jù)結(jié)構(gòu),它將在整個程序生命周期中占用內(nèi)存,可能導致內(nèi)存使用效率低下。我們應該在Python代碼中盡量減少全局變量的使用。
5. 利用邏輯運算符的短路求值
這個技巧似乎微妙,但巧妙地使用它將極大地節(jié)省程序的內(nèi)存使用。例如,下面是一個簡單的代碼片段,根據(jù)兩個函數(shù)返回的布爾值得到最終結(jié)果:
result_a = expensive_function_a()
result_b = expensive_function_b()
result = result_a if result_a else result_b
上面的代碼能夠工作,但實際上執(zhí)行了兩個內(nèi)存效率低下的函數(shù)。獲取相同結(jié)果的更聰明的方法如下:
result = expensive_function1() or expensive_function2()
由于邏輯運算符遵循短路求值規(guī)則,上述代碼中的`expensive_function2()`將不會在`expensive_function1()`為True時執(zhí)行。這將節(jié)省不必要的內(nèi)存使用。
6. 謹慎選擇數(shù)據(jù)類型
一位經(jīng)驗豐富的Python開發(fā)者會仔細而準確地選擇數(shù)據(jù)類型。因為在某些場景中,使用一個數(shù)據(jù)類型比另一個更節(jié)省內(nèi)存。
元組比列表更節(jié)省內(nèi)存
由于元組是不可變的(在創(chuàng)建后不能更改),它允許Python在內(nèi)存分配方面進行優(yōu)化。然而,列表是可變的,因此需要額外的空間來容納潛在的修改。
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`使用的內(nèi)存比列表更少。因此,如果在創(chuàng)建后不需要更改數(shù)據(jù),我們應該更喜歡使用元組而不是列表。
(1) 數(shù)組比列表更節(jié)省內(nèi)存
Python中的數(shù)組要求元素是相同的數(shù)據(jù)類型(例如,全部整數(shù)或全部浮點數(shù)),但列表可以存儲不同類型的對象,這必然需要更多的內(nèi)存。因此,如果列表的元素都是相同類型,使用數(shù)組會更節(jié)省內(nèi)存:
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
(2) 優(yōu)秀的數(shù)據(jù)科學模塊比內(nèi)置數(shù)據(jù)類型更高效
Python是數(shù)據(jù)科學的主導語言。有許多強大的第三方模塊和工具提供了更多的數(shù)據(jù)類型,例如NumPy和Pandas。如果我們只需要一個簡單的一維數(shù)字數(shù)組,并且不需要NumPy提供的廣泛功能,那么Python內(nèi)置的數(shù)組可能是一個不錯的選擇。
但是,當涉及到復雜的矩陣操作時,對于所有數(shù)據(jù)科學家來說,使用NumPy提供的數(shù)組是第一選擇,可能是最好的選擇。
7. 對相同的字符串應用字符串駐留技術(shù)
下面的代碼可能會使許多開發(fā)者感到困惑:
>>> a = 'Y'*4096
>>> b = 'Y'*4096
>>> a is b
True
>>> c = 'Y'*4097
>>> d = 'Y'*4097
>>> c is d
False
正如我們所知,`is`運算符用于檢查兩個變量是否引用內(nèi)存中的同一對象。它與`==`運算符不同,后者用于比較兩個對象是否具有相同的值。那么為什么`a is b`返回True,而`c is d`返回False呢?
這里有Python中的一個隱秘技巧 —— 字符串駐留技術(shù)。如果有幾個值相同的小型字符串,它們將由Python隱式地進行駐留,并引用內(nèi)存中的同一對象。定義小型字符串的神奇數(shù)字是4096。由于`c`和`d`的長度都是4097,它們是內(nèi)存中的兩個對象而不是一個。不再有隱式的字符串駐留。因此,在執(zhí)行`c is d`時得到False。
字符串駐留是一種優(yōu)化內(nèi)存使用的強大技術(shù)。如果我們想要顯式地進行駐留,sys.intern()
方法就派上用場了:
>>> import sys
>>> c = sys.intern('Y'*4097)
>>> d = sys.intern('Y'*4097)
>>> c is d
True
順便說一下,除了字符串駐留,Python還對小整數(shù)應用駐留技巧。我們也可以利用它進行內(nèi)存優(yōu)化。