Python 容器使用的 5 個技巧和 2 個誤區
每天 11:40 準時為你分享
編程派公眾號授權發布
「容器」這兩個字很少被 Python 技術文章提起。一看到「容器」,大家想到的多是那頭藍色小鯨魚:Docker,但這篇文章和它沒有任何關係。本文里的容器,是 Python 中的一個抽象概念,是對專門用來裝其他對象的數據類型的統稱。
在 Python 中,有四類最常見的內建容器類型: 、 、 、 。通過單獨或是組合使用它們,可以高效的完成很多事情。
Python 語言自身的內部實現細節也與這些容器類型息息相關。比如 Python 的類實例屬性、全局變數 等就都是通過字典類型來存儲的。
在這篇文章里,我首先會從容器類型的定義出發,嘗試總結出一些日常編碼的最佳實踐。之後再圍繞各個容器類型提供的特殊機能,分享一些編程的小技巧。
當我們談論容器時,我們在談些什麼?
我在前面給了「容器」一個簡單的定義:專門用來裝其他對象的就是容器。但這個定義太寬泛了,無法對我們的日常編程產生什麼指導價值。要真正掌握 Python 里的容器,需要分別從兩個層面入手:
底層實現:內置容器類型使用了什麼數據結構?某項操作如何工作?
高層抽象:什麼決定了某個對象是不是容器?哪些行為定義了容器?
下面,讓我們一起站在這兩個不同的層面上,重新認識容器。
底層看容器
Python 是一門高級編程語言,它所提供的內置容器類型,都是經過高度封裝和抽象後的結果。和「鏈表」、「紅黑樹」、「哈希表」這些名字相比,所有 Python 內建類型的名字,都只描述了這個類型的功能特點,其他人完全沒法只通過這些名字了解它們的哪怕一丁點內部細節。
這是 Python 編程語言的優勢之一。相比 C 語言這類更接近計算機底層的編程語言,Python 重新設計並實現了對編程者更友好的內置容器類型,屏蔽掉了內存管理等額外工作。為我們提供了更好的開發體驗。
但如果這是 Python 語言的優勢的話,為什麼我們還要費勁去了解容器類型的實現細節呢?答案是:關注細節可以幫助我們編寫出更快的代碼。
寫更快的代碼
1. 避免頻繁擴充列表/創建新列表
所有的內建容器類型都不限制容量。如果你願意,你可以把遞增的數字不斷塞進一個空列表,最終撐爆整台機器的內存。
在 Python 語言的實現細節里,列表的內存是按需分配的[注1],當某個列表當前擁有的內存不夠時,便會觸發內存擴容邏輯。而分配內存是一項昂貴的操作。雖然大部分情況下,它不會對你的程序性能產生什麼嚴重的影響。但是當你處理的數據量特別大時,很容易因為內存分配拖累整個程序的性能。
還好,Python 早就意識到了這個問題,並提供了官方的問題解決指引,那就是:「變懶」。
如何解釋「變懶」? 函數的進化是一個非常好的例子。
在 Python 2 中,如果你調用 ,需要等待好幾秒才能拿到結果,因為它需要返回一個巨大的列表,花費了非常多的時間在內存分配與計算上。但在 Python 3 中,同樣的調用馬上就能拿到結果。因為函數返回的不再是列表,而是一個類型為 的懶惰對象,只有在你迭代它、或是對它進行切片時,它才會返回真正的數字給你。
所以說,為了提高性能,內建函數 「變懶」了。而為了避免過於頻繁的內存分配,在日常編碼中,我們的函數同樣也需要變懶,這包括:
更多的使用 關鍵字,返回生成器對象
盡量使用生成器表達式替代列表推導表達式
生成器表達式:
列表推導表達式:
盡量使用模塊提供的懶惰對象:
使用 替代
直接使用可迭代的文件對象: ,而不是
2. 在列表頭部操作多的場景使用 deque 模塊
列表是基於數組結構(Array)實現的,當你在列表的頭部插入新成員( )時,它後面的所有其他成員都需要被移動,操作的時間複雜度是 。這導致在列表的頭部插入成員遠比在尾部追加( 時間複雜度為 )要慢。
如果你的代碼需要執行很多次這類操作,請考慮使用 collections.deque 類型來替代列表。因為 deque 是基於雙端隊列實現的,無論是在頭部還是尾部追加元素,時間複雜度都是 。
3. 使用集合/字典來判斷成員是否存在
當你需要判斷成員是否存在於某個容器時,用集合比列表更合適。因為 操作的時間複雜度是 ,而 的時間複雜度是 。這是因為字典與集合都是基於哈希表(Hash Table)數據結構實現的。
Hint: 強烈建議閱讀 TimeComplexity - Python Wiki,了解更多關於常見容器類型的時間複雜度相關內容。
如果你對字典的實現細節感興趣,也強烈建議觀看 Raymond Hettinger 的演講 Modern Dictionaries(YouTube)
高層看容器
Python 是一門「鴨子類型」語言:「當看到一隻鳥走起來像鴨子、游泳起來像鴨子、叫起來也像鴨子,那麼這隻鳥就可以被稱為鴨子。」所以,當我們說某個對象是什麼類型時,在根本上其實指的是:這個對象滿足了該類型的特定介面規範,可以被當成這個類型來使用。而對於所有內置容器類型來說,同樣如此。
打開位於 collections 模塊下的 abc(「抽象類 Abstract Base Classes」的首字母縮寫)子模塊,可以找到所有與容器相關的介面(抽象類)[注2]定義。讓我們分別看看那些內建容器類型都滿足了什麼介面:
列表(list):滿足 、 、 等介面
元組(tuple):滿足 、
字典(dict):滿足 、 、 [注3]
集合(set):滿足 、 、 [注4]
每個內置容器類型,其實就是滿足了多個介面定義的組合實體。比如所有的容器類型都滿足 ) 這個介面,這意味著它們都是「可被迭代」的。但是反過來,不是所有「可被迭代」的對象都是容器。就像字元串雖然可以被迭代,但我們通常不會把它當做「容器」來看待。
了解這個事實後,我們將在 Python 里重新認識面向對象編程中最重要的原則之一:面向介面而非具體實現來編程。
讓我們通過一個例子,看看如何理解 Python 里的「面向介面編程」。
寫擴展性更好的代碼
某日,我們接到一個需求:有一個列表,裡面裝著很多用戶評論,為了在頁面正常展示,需要將所有超過一定長度的評論用省略號替代。
這個需求很好做,很快我們就寫出了第一個版本的代碼:
上面的代碼里, 函數接收一個列表作為參數,然後遍歷它,替換掉需要修改的成員。這一切看上去很合理,因為我們接到的最原始需求就是:「有一個列表,裡面...」。但如果有一天,我們拿到的評論不再是被繼續裝在列表裡,而是在不可變的元組裡呢?
那樣的話,現有的函數設計就會逼迫我們寫出 這種即慢又難看的代碼了。
面向容器介面編程
我們需要改進函數來避免這個問題。因為 函數強依賴了列表類型,所以當參數類型變為元組時,現在的函數就不再適用了(原因:給 賦值的地方會拋出 異常)。如何改善這部分的設計?秘訣就是:讓函數依賴「可迭代對象」這個抽象概念,而非實體列表類型。
使用生成器特性,函數可以被改成這樣:
在新函數里,我們將依賴的參數類型從列表改成了可迭代的抽象類。這樣做有很多好處,一個最明顯的就是:無論評論是來自列表、元組或是某個文件,新函數都可以輕鬆滿足:
將依賴由某個具體的容器類型改為抽象介面後,函數的適用面變得更廣了。除此之外,新函數在執行效率等方面也都更有優勢。現在讓我們再回到之前的問題。從高層來看,什麼定義了容器?
答案是:各個容器類型實現的介面協議定義了容器。不同的容器類型在我們的眼裡,應該是 、 、 等各種特性的組合。我們需要在編寫相關代碼時,更多的關注容器的抽象屬性,而非容器類型本身,這樣可以幫助我們寫出更優雅、擴展性更好的代碼。
TAG:編程派 |