python 列表的實現探析

知其然也要知其因此然,python中的容器對象真的很少,日常咱們會很問心無愧的根據需求來使用對應的容器,不定長數據用list,想去重用set,想快速進行匹配用dict,字符處理用str,可爲什麼能實現這個效果呢?好比咱們用list的時候,知道這玩意能夠隨意存儲各類格式,存整型、浮點、字符串、甚至還能夠嵌套list等其餘容器,這底層的原理究竟是用數組實現的,仍是用鏈表?好比咱們的字典,底層是用數組仍是其餘?若是是其餘如哈希表,那又怎麼實現輸入數據的順序排列?此次不妨一層層剖析,推演一番。貪多嚼不爛,本次就先對list進行分析html

簡述

這個名字很容易和其它語言(C++、Java等)標準庫中的鏈表混淆,不過事實上在CPython的列表根本不是列表(這話有點繞,可能換成英文理解起來容易些:python中的list不是咱們所學習的list),在CPython中,列表被實現爲長度可變的數組python

從細節上看,Python中的列表是由對其它對象的引用組成的連續數組,指向這個數組的指針及其長度被保存在一個列表頭結構中。這意味着,每次添加或刪除一個元素時,由引用組成的數組須要該標大小(從新分配)。在實現過程當中,Python在建立這些數組時採用了指數分配的方式,其結果致使每次操做不都須要改變數組的大小,可是也由於這個緣由添加或取出元素的平均複雜度較低。編程

這個方式帶來的後果是在普通鏈表上「代價很小」的其它一些操做在Python中計算複雜度相對太高。數組

  • 利用list.insert(i,item) 方法在任意位置插入一個元素——複雜度O(N)
  • 利用list.pop(i)list.remove(value) 刪除一個元素——複雜度O(N)

源碼解析

讓咱們先看下list實現的源碼,源汁源味,細細品評。咱們先發現list多重繼承自MutableSequenceGeneric。以後咱們能夠讀到,list的相關內嵌函數的實現,如append、pop、extend、insert等其實都是經過繼承來實現的,那麼咱們就不得不去找一下MutableSequenceGeneric這兩個類的實現底層,也只有解答了這兩個類以後,咱們才能回答爲什麼list能夠實現動態添加數據,並且刪除和插入的複雜度還不是那麼優秀。緩存

class list(MutableSequence[_T], Generic[_T]):
    @overload
    def __init__(self) -> None: ...
    @overload
    def __init__(self, iterable: Iterable[_T]) -> None: ...
    if sys.version_info >= (3,):
        def clear(self) -> None: ...
        def copy(self) -> List[_T]: ...
    def append(self, object: _T) -> None: ...
    def extend(self, iterable: Iterable[_T]) -> None: ...
    def pop(self, index: int = ...) -> _T: ...
    def index(self, object: _T, start: int = ..., stop: int = ...) -> int: ...
    def count(self, object: _T) -> int: ...
    def insert(self, index: int, object: _T) -> None: ...
    def remove(self, object: _T) -> None: ...
    def reverse(self) -> None: ...
    if sys.version_info >= (3,):
        def sort(self, *, key: Optional[Callable[[_T], Any]] = ..., reverse: bool = ...) -> None: ...
    else:
        def sort(self, cmp: Callable[[_T, _T], Any] = ..., key: Callable[[_T], Any] = ..., reverse: bool = ...) -> None: ...

    def __len__(self) -> int: ...
    def __iter__(self) -> Iterator[_T]: ...
    def __str__(self) -> str: ...
    __hash__: None  # type: ignore
    @overload
    def __getitem__(self, i: int) -> _T: ...
    @overload
    def __getitem__(self, s: slice) -> List[_T]: ...
    @overload
    def __setitem__(self, i: int, o: _T) -> None: ...
    @overload
    def __setitem__(self, s: slice, o: Iterable[_T]) -> None: ...
    def __delitem__(self, i: Union[int, slice]) -> None: ...
    if sys.version_info < (3,):
        def __getslice__(self, start: int, stop: int) -> List[_T]: ...
        def __setslice__(self, start: int, stop: int, o: Sequence[_T]) -> None: ...
        def __delslice__(self, start: int, stop: int) -> None: ...
    def __add__(self, x: List[_T]) -> List[_T]: ...
    def __iadd__(self: _S, x: Iterable[_T]) -> _S: ...
    def __mul__(self, n: int) -> List[_T]: ...
    def __rmul__(self, n: int) -> List[_T]: ...
    if sys.version_info >= (3,):
        def __imul__(self: _S, n: int) -> _S: ...
    def __contains__(self, o: object) -> bool: ...
    def __reversed__(self) -> Iterator[_T]: ...
    def __gt__(self, x: List[_T]) -> bool: ...
    def __ge__(self, x: List[_T]) -> bool: ...
    def __lt__(self, x: List[_T]) -> bool: ...
    def __le__(self, x: List[_T]) -> bool: ...

MutableSequence

這個類實際上是來自於collections.abc.MutableSequence,其實也就是所謂的抽象基礎類裏面的可變序列的方法。數據結構

Python的序列有兩種,可變序列和不可變序列併爲其提供了兩個基類SequenceMutableSequence,這兩個基類存在於內置模塊collections.abc中,與其餘常見的類如intlist等不一樣,這兩個基類都是抽象基類。這裏涉及到一個新的概念抽象基類,什麼是抽象基類呢?app

對於抽象基類,目前能夠不用關注太多,只需知道抽象基類是指不能實例化產生實例對象的類,後面有機會咱們再專門來討論抽象基類。less

SequenceMutableSequence是兩個抽象基類,所以這兩個類都是不能實例化產生實例對象,那要SequenceMutableSequence兩個抽象基類還有什麼做用呢?ide

其實抽象基類的做用並非實例化產生實例對象的,它的做用更多的像是定義一種規則,或者官方的說法叫作協議,這樣之後咱們但願建立這種類型的對象時,要求遵循這種規則或者協議。如今咱們須要瞭解序列類型都有哪些協議,這須要學習abc模塊中的SequenceMutableSequence兩個類。函數

Sequence和MutableSequence兩個類的繼承關係以下:
python 列表的實現探析

圖中粗體表示抽象基類,斜體表示抽象方法,不妨理解爲並未作具體實現的方法,剩下的爲抽象基類中已經實現的方法。

能夠看到,這裏面的繼承關係並不複雜,可是信息量很大,應該牢記這個圖,由於這對理解序列類型很是重要。咱們看到,可變序列MutableSequence類繼承自不可變序列Sequence類,Sequence類又繼承了兩個類ReversibleCollectionCollection又繼承自ContainerIterableSized三個抽象基類。經過這個繼承圖,咱們至少應該可以知道,對於標準不可變序列類型Sequence,應該至少實現如下幾種方法(遵循這些協議):

__contains__,__iter__,__len__,__reversed__,__getitem__,index,count

這幾個方法到底意味着什麼呢?在前面的list的實現源碼裏面咱們能夠窺探一二:

  • 實現了__contains__方法,就意味着list能夠進行成員運算,即便用innot in的效果
  • 實現了__iter__方法,意味着list是一個可迭代對象,能夠進行for循環、拆包、生成器表達式等多種運算
  • 實現了__len__方法,意味着可使用內置函數len()。同時,當判斷一個list的布爾值時,若是list沒有實現__bool__方法,也會嘗試調用__len__方法
  • 實現了__reversed__方法,意味着能夠實現反轉操做
  • 實現了__getitem__方法,意味着能夠進行索引和切片操做
  • 實現了indexcount方法,則表示能夠按條件取索引和統計頻數。

標準的Sequence類型聲明瞭上述方法,這意味着繼承自Sequence的子類,其實例化產生的對象將是一個可迭代對象、可使用for循環、拆包、生成器表達式、in、not in、索引、切片、翻轉等等不少操做。這同時也代表,若是咱們說一個對象是不可變序列時,暗示這個對象是一個可迭代對象、可使用for循環、......。

而對於標準可變序列MutableSequence,咱們發現,除了要實現不可變序列中幾種方法以外,至少還須要實現以下幾個方法(遵循這些協議):

__setitem__,__delitem__,insert,append,extend,pop,remove,__iadd__

這幾個方法又意味着什麼呢?一樣以Python的內置類型list爲例進行說明:

  • 實現了__setitem__方法,就能夠對列表中的元素進行修改,如a = [1,2],代碼a[0]=2就是在調用這個方法
  • 實現了__delitem__,pop,remove方法,就能夠對列表中的元素進行刪除,如a = [1,2],代碼del a[0]就是在調用__delitem__方法
  • 實現了insert,append,extend方法,就能夠在序列中插入元素
  • 實現了__iadd__方法,列表就能夠進行增量賦值

這就是說,對於標準可變序列類型,除了執行不可變類型的查詢操做以外,其子類的實例對象均可以執行增刪改的操做。

抽象基類SequenceMutableSequence聲明瞭對於一個序列類型應該實現那些方法,很顯然,若是一個類直接繼承自Sequence類,內部也重載了Sequence中的七個方法,那麼顯然這個類必定是序列類型了,MutableSequence的子類也是同樣。確實如此,可是當咱們查看列表list、字符序列str、元組tuple的繼承鏈時,發如今其mro列表中並無Sequence和MutableSequence類,也就是說,這些內置類型並無直接繼承自這兩個抽象基類,那麼爲何咱們在文章的開頭還要說他們都是序列類型呢?

>>> list.__mro__
(<class 'list'>, <class 'object'>)
>>> tuple.__mro__
(<class 'tuple'>, <class 'object'>)
>>> str.__mro__
(<class 'str'>, <class 'object'>)
>>> dict.__mro__
(<class 'dict'>, <class 'object'>)

其實,Python中有一種被稱爲鴨子類型的編程風格。在這種風格下,咱們並不太關注一個對象的類型是什麼,它繼承自那個類型,而是關注他能實現那些功能,定義了那些方法。正所謂若是一個東西看起來像鴨子,走起來像鴨子,叫起來像鴨子,那他就是鴨子。

在這種思想之下,若是一個類並非直接繼承自Sequence,可是內部卻實現了__contains____iter____len____reversed____getitem__indexcount幾個方法,咱們就能夠稱之爲不可變序列。甚至都沒必要這麼嚴格,可能只須要實現__len____getitem__兩個方法就能夠稱做是不可變序列類型。對於可變序列也一樣如此。

鴨子類型的思想貫穿了Python面向對象編程的始終。

Generic

這個類其實就是泛型的實現,從註釋中能夠發現,這個其實也是抽象基類,本質上用來實現多類型參數輸入。好比在list中咱們能夠既存入int,又能夠是str,還能夠是list,也能夠是dict等等多個不一樣類型的元素,這個本質上就是依賴於這個類的繼承。

class Generic:
    """Abstract base class for generic types.

    A generic type is typically declared by inheriting from
    this class parameterized with one or more type variables.
    For example, a generic mapping type might be defined as::

      class Mapping(Generic[KT, VT]):
          def __getitem__(self, key: KT) -> VT:
              ...
          # Etc.

    This class can then be used as follows::

      def lookup_name(mapping: Mapping[KT, VT], key: KT, default: VT) -> VT:
          try:
              return mapping[key]
          except KeyError:
              return default
    """
    __slots__ = ()
    _is_protocol = False

    @_tp_cache
    def __class_getitem__(cls, params):
        if not isinstance(params, tuple):
            params = (params,)
        if not params and cls is not Tuple:
            raise TypeError(
                f"Parameter list to {cls.__qualname__}[...] cannot be empty")
        msg = "Parameters to generic types must be types."
        params = tuple(_type_check(p, msg) for p in params)
        if cls in (Generic, Protocol):
            # Generic and Protocol can only be subscripted with unique type variables.
            if not all(isinstance(p, TypeVar) for p in params):
                raise TypeError(
                    f"Parameters to {cls.__name__}[...] must all be type variables")
            if len(set(params)) != len(params):
                raise TypeError(
                    f"Parameters to {cls.__name__}[...] must all be unique")
        else:
            # Subscripting a regular Generic subclass.
            _check_generic(cls, params, len(cls.__parameters__))
        return _GenericAlias(cls, params)

    def __init_subclass__(cls, *args, **kwargs):
        super().__init_subclass__(*args, **kwargs)
        tvars = []
        if '__orig_bases__' in cls.__dict__:
            error = Generic in cls.__orig_bases__
        else:
            error = Generic in cls.__bases__ and cls.__name__ != 'Protocol'
        if error:
            raise TypeError("Cannot inherit from plain Generic")
        if '__orig_bases__' in cls.__dict__:
            tvars = _collect_type_vars(cls.__orig_bases__)
            # Look for Generic[T1, ..., Tn].
            # If found, tvars must be a subset of it.
            # If not found, tvars is it.
            # Also check for and reject plain Generic,
            # and reject multiple Generic[...].
            gvars = None
            for base in cls.__orig_bases__:
                if (isinstance(base, _GenericAlias) and
                        base.__origin__ is Generic):
                    if gvars is not None:
                        raise TypeError(
                            "Cannot inherit from Generic[...] multiple types.")
                    gvars = base.__parameters__
            if gvars is not None:
                tvarset = set(tvars)
                gvarset = set(gvars)
                if not tvarset <= gvarset:
                    s_vars = ', '.join(str(t) for t in tvars if t not in gvarset)
                    s_args = ', '.join(str(g) for g in gvars)
                    raise TypeError(f"Some type variables ({s_vars}) are"
                                    f" not listed in Generic[{s_args}]")
                tvars = gvars
        cls.__parameters__ = tuple(tvars)

驀然回首

做爲一個經常使用數據結構,在不少場景中被用來當作數組使用,可能不少時候都以爲list無非就是一個動態數組,就像C++中的vector或者Go中的slice同樣。從源碼的實現中,咱們也能夠發現list繼承MutableSequence而且擁有泛型的效果,但這樣就能夠斷言說list就是一個動態數組嗎?

咱們來思考一個簡單的問題,Python中的list容許咱們存儲不一樣類型的數據,既然類型不一樣,那內存佔用空間就就不一樣,不一樣大小的數據對象又是如何"存入"數組中呢?

咱們能夠分別在數組中存儲了一個字符串,一個整形,以及一個字典對象,假如是數組實現,則須要將數據存儲在相鄰的內存空間中,而索引訪問就變成一個至關困難的事情了,畢竟咱們沒法猜想每一個元素的大小,從而沒法定位想要的元素位置。

python 列表的實現探析

是不是經過鏈表結構實現的呢? 畢竟鏈表支持動態的調整,藉助於指針能夠引用不一樣類型的數據,好比下面的圖示中的鏈表結構。可是這樣的話使用下標索引數據的時候,須要依賴於遍歷的方式查找,O(n)的時間複雜度訪問效率實在是過低。

不過對於鏈表的使用,系統開銷也較大,畢竟每一個數據項除了維護本地數據指針外,還要維護一個next指針,所以還要額外分配8字節數據,同時鏈表分散性使其沒法像數組同樣利用CPU的緩存來高效的執行數據讀寫。

python 列表的實現探析

咱們這個時候再來推敲一下list這個結構的內部實現,筆者接下來的推演都是基於CPython來的,不一樣語言的實現語法應該是不一樣的,不過思路大同小異。

Python中的list數據結構實現要更比想象的更簡單且純粹一些,保留了數組內存連續性訪問的方式,只是每一個節點存儲的不是實際數據,而是對應數據的指針,以一個指針數組的形式來進行存儲和訪問數據項,對應的結構以下面圖示:

python 列表的實現探析

實現的細節能夠從其Python的源碼中找到, 定義以下:

typedef struct {
    PyObject_VAR_HEAD
    PyObject **ob_item;
    Py_ssize_t allocated;
} PyListObject;

內部list的實現的是一個C結構體,該結構體中的ob_item是一個指針數組,存儲了全部對象的指針數據,allocated是已分配內存的數量, PyObject_VAR_HEAD是一個宏擴展包含了更多擴展屬性用於管理數組,好比引用計數以及數組大小等內容。

動態數組

既然是一個動態數組,則必然會面臨一個問題,即如何進行容量的管理,大部分的程序語言對於此類結構使用動態調整策略,也就是當存儲容量達到必定閾值的時候,擴展容量,當存儲容量低於必定的閾值的時候,縮減容量。道理很簡單,不過實施起來可沒那麼容易,何時擴容,擴多少,何時執行回收,每次又要回收多少空閒容量,這些都是在實現過程當中須要明確的問題。

對於Python中list的動態調整規則程序中定義以下:當追加數據容量已滿的時候,經過下面的方式計算再次分配的空間大小,建立新的數組,並將全部數據複製到新的數組中。這是一種相對數據增速較慢的策略,回收的時候則當容量空閒一半的時候執行策略,獲取新的縮減後容量大小。其實這個方式就很像TCP的滑動窗口的機制

new_allocated = (newsize >> 3) + (newsize < 9 ? 3 : 6);

new_allocated += newsize
//  0, 4, 8, 16, 25, 35, 46, 58, 72, 88, …

假如咱們使用一種最簡單的策略:超出容量加倍,低於一半容量減倍。這種策略會有什麼問題呢?設想一下當咱們在容量已滿的時候進行一次插入,隨即刪除該元素,交替執行屢次,那數組數據豈不是會不斷的被總體複製和回收,已經無性能可言了。

append

接下來,咱們來看下list數據結構的幾個常見操做。首先是在list上執行append的操做, 該函數將元素添加到list的尾部。注意這裏是指針數據被追加到尾部,而不是實際元素

test = list()
test.append("hello yerik")

向列表添加字符串:test.append("hello yerik") 時發生了什麼?其實是調用了底層的 C 函數 app1()。

arguments: list object, new element
returns: 0 if OK, -1 if not
app1:
    n = size of list
    call list_resize() to resize the list to size n+1 = 0 + 1 = 1
    list[n] = list[0] = new element
    return 0

對於一個空的list,此時數組的大小爲0,爲了可以插入元素,咱們須要對數組進行擴容,按照上面的計算公式進行調整大小。好比這時候只有一個元素,那麼newsize = 1, 計算的new_allocated = 3 + 1 = 4 , 成功插入元素後,直到插入第五元素以前咱們都不須要從新分配新的空間,從而避免頻繁調用 list_resize() 函數,提高程序性能。

python 列表的實現探析

咱們嘗試繼續添加更多的元素到列表中,當咱們插入元素"abc"的時候,其內部數組大小不足以容納該元素,執行新一輪動態擴容,此時newsize = 5 , new_allocated = 3 + 5 = 8

>>> test.append(520)
>>> test.append(dict())
>>> test.append(list())
>>> test.append("abc")
>>> test
['hello yerik', 520, {}, [], 'abc']

執行插入後的數據存儲空間分佈以下圖所示:

python 列表的實現探析

insert

在列表偏移量 2 的位置插入新元素,整數 5:test.insert(1,2.33333333),內部調用ins1() 函數。

arguments: list object, where, new element
returns: 0 if OK, -1 if not
ins1:
    resize list to size n+1 = 5 -> 4 more slots will be allocated
    starting at the last element up to the offset where, right shift each element 
    set new element at offset where
    return 0

python實現的insert函數接收兩個參數,第一個是指定插入的位置,第二個爲元素對象。中間插入會致使該位置後面的元素進行移位操做,因爲是存儲的指針所以實際的元素不須要進行位移,只須要位移其指針便可。

>>> test.insert(2,2.33333333)
>>> test
['hello yerik', 520, 2.33333333, {}, [], 'abc']

python 列表的實現探析

插入元素爲一個字符串對象,建立該字符串並得到其指針(ptr5), 將其存入索引爲2的數組位置中,並將其他後續元素分別移動一個位置便可,insert函數調用完成。正是因爲須要進行「檢查擴容」的緣由,從而致使了該操做的複雜度達到了O(n),而不是鏈表所存在的O(1)

pop

取出列表最後一個元素 即l.pop(),調用了 listpop() 函數。在 listpop() 函數中會調用 list_resize 函數,若是此時元素的使用率低於一半,則進行空閒容量的回收。

arguments: list object
returns: element popped
listpop:
    if list empty:
        return null
    resize list with size 5 - 1 = 4. 4 is not less than 8/2 so no shrinkage
    set list object size to 4
    return last element

在鏈表中pop 操做的平均複雜度爲 O(1)。不過因爲可能須要進行存儲空間大小的修改,所以致使複雜度上升

>>> test.pop()
'abc'
>>> test.pop()
[]
>>> test.pop()
{}
>>> test
['hello yerik', 520, 2.33333333]

末尾位置的元素被回收,指針清空,這時候長度爲5,容量爲8,所以不須要執行任何的回收策略。當咱們繼續執行三次pop使其長度變爲3後,此時使用量低於了一半的容量,須要執行回收策略。回收的方式一樣是利用上面的公式進行處理,好比這裏新的大小爲3,則返回容量大小爲3+3 = 6 ,並不是回收所有的空閒空間。

python 列表的實現探析

pop的操做也是須要進行檢查縮小,所以也是致使複雜度爲O(n)

Remove

remove函數會指定刪除的元素,而該元素能夠在列表中的任意位置。所以每次執行remove都必須先依次遍歷數據項,進行匹配,直到找到對應的元素位置。執行刪除可能會致使部分元素的遷移。Remove操做的總體時間複雜度爲O(n)。

python 列表的實現探析

>>> test
['hello yerik', 520, 2.33333333]
>>> test.remove(520)
>>> test
['hello yerik', 2.33333333]

其實對於Python列表這種數據結構的動態調整,在其餘語言中也都存在,只是你們可能在平常使用中並無意識到,瞭解了動態調整規則,咱們能夠經過好比手動分配足夠的空間,來減小其動態分配帶來的遷移成本,使得程序運行的更高效。

另外若是事先知道存儲在列表中的數據類型都相同,好比都是整形或者字符等類型,能夠考慮使用arrays庫,或者numpy庫,二者都提供更直接的數組內存存儲模型,而不是上面的指針引用模型,所以在訪問和存儲效率上面會更高效一些。

參考資料

  1. https://zhuanlan.zhihu.com/p/341443253
  2. https://docs.python.org/3/library/collections.abc.html
  3. https://zhuanlan.zhihu.com/p/359079737
  4. https://mypy.readthedocs.io/en/stable/generics.html
  5. https://blog.csdn.net/u014029783/article/details/107992840
  6. https://www.jianshu.com/p/cd75475168ae
相關文章
相關標籤/搜索