Python: 淺析列表的變長變短

前言

Python 的列表(list)是一個很是靈活的數組,能夠隨意調整長度。正是由於這種便利,使得咱們會不由自主地去修改數組以知足咱們的需求,其中相比於insert, pop 等等而言, append 用法更常見。segmentfault

有像這樣使用:數組

>>> test = []
>>> test.append(1)
>>> test.append({2})
>>> test.append([3])
>>> print test

# 輸出 
[1, set([2]), [3]]

也有像這樣使用的:緩存

test = []

for i in range(4):
    test.append(i)
print test

# 輸出 
[0, 1, 2, 3]

這樣用很開心,也很知足。app

但其實只要遇到可以動態修改數據長度場景,咱們都應該立刻反應過來一點,那就是內存管理的問題。函數

若是運行效率和便捷性同時知足的話,那簡直就是大大的福音呀。spa

然而,上帝爲你開啓一扇窗的同時確定也已經關上了一扇門了!指針

吝嗇的初始化

深受預分配知識的薰陶,咱們也是以爲 list 在初始化是有分配必定的長度的,要否則每次都申請內存那得多 」low「 啊。code

而後實際上 list 真的就是這麼 」low「:orm

import sys

test = []
test_1 = [1]
print sys.getsizeof(test)
print sys.getsizeof(test_1) - sys.getsizeof(test)

# 輸出 
72     # 空列表內存大小,也是 list 對象的總大小
8       # 表明增長一個成員,list 增長的大小 ( 此大小爲對象指針的長度 )

咱們的猜想是,list 在定義以後,會預先分配好一個必定大小的池用來塞數據,以免動不動就申請內存。對象

可是在上面的實驗看出,一個成員的列表,比一個空列表,長度僅僅只是大了 8 字節(對象指針的大小),若是真的存在這樣一個預分配的池,那麼在預分配個數以內添加成員,二者的內存大小應該是保持不變纔對。

因此能夠猜想這塊 list 應該是沒有這樣的一個預分配內存池。這裏須要來個實錘

PyObject *
PyList_New(Py_ssize_t size)
{
    PyListObject *op;
    size_t nbytes;

    if (size < 0) {
        PyErr_BadInternalCall();
        return NULL;
    }
    /* Check for overflow without an actual overflow,
     *  which can cause compiler to optimise out */
    if ((size_t)size > PY_SIZE_MAX / sizeof(PyObject *))
        return PyErr_NoMemory();
        
    // list對象指針的緩存
    if (numfree) {
        numfree--;
        op = free_list[numfree];
        _Py_NewReference((PyObject *)op);
    } else {
        op = PyObject_GC_New(PyListObject, &PyList_Type);
        if (op == NULL)
            return NULL;
    }
    
    // list 成員的內存申請
    nbytes = size * sizeof(PyObject *);
    if (size <= 0)
        op->ob_item = NULL;
    else {
        op->ob_item = (PyObject **) PyMem_MALLOC(nbytes);
        if (op->ob_item == NULL) {
            Py_DECREF(op);
            return PyErr_NoMemory();
        }
        memset(op->ob_item, 0, nbytes);
    }
    Py_SIZE(op) = size;
    op->allocated = size;
    _PyObject_GC_TRACK(op);
    return (PyObject *) op;
}

當咱們在執行 test = [1] 時,實際上只作了兩件事:

  1. 根據成員的數目,構建相應長度的空列表;(上述代碼)
  2. 一個個將這些成員塞進去;

可能有童鞋會以爲,在塞成員的那一步,說不定會觸發什麼機制使它變大?

很惋惜,由於初始化用的方法是 PyList_SET_ITEM, 因此這裏是木有的觸發什麼機制,只是簡單的數組成員賦值而已:

#define PyList_SET_ITEM(op, i, v) (((PyListObject *)(op))->ob_item[i] = (v))

因此整個 list 的初始化,還真的就是木有預分配的內存池,直接按需申請,一個蘿蔔一個坑,實在得狠;

可變長的關鍵

初始化過程是這樣還能夠理解,若是運行中還這樣的話,那就有點說不過去了。

試想下,在文章開頭用 append 的例子中,若是每 append 一個元素就申請一次內存,那麼list 可能要被吐槽到懷疑人生了, 因此很明顯,在對於內存的申請,它仍是有本身的套路的。

list 裏面,不論是 insertpop 仍是 append,都會遇到 list_resize,故名思義,這個函數就是用來調整 list 對象的內存佔用的。

static int
list_resize(PyListObject *self, Py_ssize_t newsize)
{
    PyObject **items;
    size_t new_allocated;
    Py_ssize_t allocated = self->allocated;

    /* Bypass realloc() when a previous overallocation is large enough
       to accommodate the newsize.  If the newsize falls lower than half
       the allocated size, then proceed with the realloc() to shrink the list.
    */
    if (allocated >= newsize && newsize >= (allocated >> 1)) {
        assert(self->ob_item != NULL || newsize == 0);
        Py_SIZE(self) = newsize;
        return 0;
    }

    /* This over-allocates proportional to the list size, making room
     * for additional growth.  The over-allocation is mild, but is
     * enough to give linear-time amortized behavior over a long
     * sequence of appends() in the presence of a poorly-performing
     * system realloc().
     * The growth pattern is:  0, 4, 8, 16, 25, 35, 46, 58, 72, 88, ...
     */
    # 肯定新擴展以後的佔坑數
    new_allocated = (newsize >> 3) + (newsize < 9 ? 3 : 6);

    /* check for integer overflow */
    if (new_allocated > PY_SIZE_MAX - newsize) {
        PyErr_NoMemory();
        return -1;
    } else {
        new_allocated += newsize;
    }

    if (newsize == 0)
        new_allocated = 0;

    # 申請內存
    items = self->ob_item;
    if (new_allocated <= (PY_SIZE_MAX / sizeof(PyObject *)))
        PyMem_RESIZE(items, PyObject *, new_allocated);
    else
        items = NULL;
    if (items == NULL) {
        PyErr_NoMemory();
        return -1;
    }
    self->ob_item = items;
    Py_SIZE(self) = newsize;
    self->allocated = new_allocated;
    return 0;
}

在上面的代碼中,頻繁看到兩個名詞:newsizenew_allocated, 這裏須要解釋下,newsize 並非 增長/減小 的個數,而是 增長/減小 以後的成員總數目。比方說:

a = [1, 2, 3]
a.append(1)

上面的 append 觸發list_resize 時, newsize 是 3 + 1, 而不是 1;這邊比較重要,由於在 pop 這類減小列表成員時候,就是傳入縮減後的總數目。

在 list 的結構定義中,關於長度的定義有兩個,分別是 ob_size(實際的成員數)allocated(總成員數)

它們之間的關係就是:

0 <= ob_size <= allocated
 len(list) == ob_size

因此 new_allocated 就很好理解了,這個就是新的總坑數。

當名詞含義理解得差很少時,咱們就能順藤摸瓜知道一個列表在list_resize 以後,大小會變成怎樣?

方法其實從上面註釋和代碼都說得很明白了,這裏再簡單整理下:

  1. 先肯定一個基數:new_allocated = (newsize >> 3) + (newsize < 9 ? 3 : 6);
  2. 判斷下 new_allocated + newsize 有沒有超過 PY_SIZE_MAX, 若是超過了,直接報錯;
  3. 最終肯定新的總坑數是:new_allocated + newsize, 若是 newsize 是 0, 那麼總坑數直接爲 0 ;

下面演示下:

#coding: utf8
import sys

test = []
raw_size = sys.getsizeof(test)

test.append(1)
print "1 次 append 減去空列表的內存大小:%s " % (sys.getsizeof(test) - raw_size)

test.append(1)
print "2 次 append 減去空列表的內存大小:%s " % (sys.getsizeof(test) - raw_size)

test.append(1)
print "3 次 append 減去空列表的內存大小:%s " % (sys.getsizeof(test) - raw_size)

test.append(1)
print "4 次 append 減去空列表的內存大小:%s " % (sys.getsizeof(test) - raw_size)

test.append(1)
print "5 次 append 減去空列表的內存大小:%s " % (sys.getsizeof(test) - raw_size)

test.append(1)
print "6 次 append 減去空列表的內存大小:%s " % (sys.getsizeof(test) - raw_size)
# 輸出結果
1 次 append 減去空列表的內存大小:32
2 次 append 減去空列表的內存大小:32
3 次 append 減去空列表的內存大小:32
4 次 append 減去空列表的內存大小:32
5 次 append 減去空列表的內存大小:64
6 次 append 減去空列表的內存大小:64

開始簡單的代入法一步步算:

其中:

  • new_allocated = (newsize >> 3) + (newsize < 9 ? 3 : 6) + newsize (由於下面的 newsize > 0)
  • 當原allocated >= newsize 而且 newsize >= 原allocated / 2 時,不改變 allocated 不申請內存直接返回
第 n 次 append 列表原長度 新增成員數 原 allocated newsize new_allocated
1 0 1 0 0 + 1 = 1 3 + 1 = 4
2 1 1 4 1 + 1 = 2 無需改變
3 2 1 4 2 + 1 = 3 無需改變
4 3 1 4 3 + 1 = 4 無需改變
5 4 1 4 4 + 1 = 5 3 + 5 = 8
6 5 1 8 5 + 1 = 6 無需改變

經過上面的表格,應該比較清楚看到何時會觸發改變 allocated,而且當觸發時它們是如何計算的。爲何咱們須要這樣關注 allocated?理由很簡單,由於這個值決定了整個 list 的動態內存的佔用大小;

擴容是這樣,縮容也是照貓畫虎。反正都是算出新的 allocated, 而後由 PyMem_RESIZE 來處理。

#define PyMem_REALLOC(p, n)    ((size_t)(n) > (size_t)PY_SSIZE_T_MAX  ? NULL \
                : realloc((p), (n) ? (n) : 1))

#define PyMem_RESIZE(p, type, n) \
  ( (p) = ((size_t)(n) > PY_SSIZE_T_MAX / sizeof(type)) ? NULL :    \
    (type *) PyMem_REALLOC((p), (n) * sizeof(type))

基本上,就是判斷是否超過最大數,不然的話就是和C realloc函數近似了,如下摘抄了一段關於C realloc函數的描述:

clipboard.png

多說幾句

綜上所述,在一些明確列表成員或者簡單處理再塞入列表的狀況下,咱們不該該再用下面的方式:

test = []

for i in xrange(4):
    test.append(i)
print test

而是應該用列表推導式:test = [i for i in xrange(4)]

爲何推薦列表推導呢?顯而易見的效果就有:

  1. 簡練、清晰;
  2. 用多少就申請多少,不會由於 append 觸發 PyMem_RESIZE 申請過多內存;容易形成內存浪費;
  3. 相比 for i in xxx,列表推導方式直接增長元素,少了一些函數調用,如:SETUP_LOOPCALL_FUNCTION 等;

可是上面的推薦確定也是在某些前提條件下才合適咯:

  1. 真的只是爲了獲得一個列表;
  2. 循環體內邏輯簡單,沒有太複雜的處理、判斷、調用等等;

PS: 切記勿爲了使用列表推導而使用,合理使用纔是科學之道;

歡迎各位大神指點交流, QQ討論羣: 258498217
轉載請註明來源: https://segmentfault.com/a/11...

相關文章
相關標籤/搜索