Почему __dict__ экземпляров в Python 3 намного меньше по размеру?

В Python словари, созданные для экземпляров класса, крошечные по сравнению со словарями, созданными с теми же атрибутами этого класса:

import sys

class Foo(object):
    def __init__(self, a, b):
        self.a = a
        self.b = b

f = Foo(20, 30)

При использовании Python 3.5.2 следующие вызовы getsizeof производят:

>>> sys.getsizeof(vars(f))  # vars gets obj.__dict__
96 
>>> sys.getsizeof(dict(vars(f))
288

Сохранено 288 - 96 = 192 байт!

С другой стороны, при использовании Python 2.7.12 возвращаются те же вызовы:

>>> sys.getsizeof(vars(f))
280
>>> sys.getsizeof(dict(vars(f)))
280

Сохранено 0 байт.

В обоих случаях словари, очевидно, имеют абсолютно одинаковое содержание:

>>> vars(f) == dict(vars(f))
True

так что это не фактор. Кроме того, это относится только к Python 3.

Итак, что здесь происходит? Почему размер экземпляра __dict__ в Python 3 такой маленький?


person Dimitris Fasarakis Hilliard    schedule 23.02.2017    source источник


Ответы (1)


Короче:

Экземпляры __dict__ реализованы иначе, чем «обычные» словари, созданные с помощью dict или {}. Словари экземпляра разделяют ключи и хэши и хранят отдельный массив для различающихся частей: значений. sys.getsizeof учитывает только эти значения при расчете размера экземпляра dict.

Ещё немного:

Словари в CPython, начиная с Python 3.3, реализованы в одной из двух форм:

  • Комбинированный словарь: все значения словаря хранятся вместе с ключом и хешем для каждой записи. (me_value член структуры PyDictKeyEntry). Насколько я знаю, эта форма используется для словарей, созданных с помощью dict, {} и пространства имен модуля.
  • Разделить таблицу: значения хранятся отдельно в массиве, а ключи и хэши являются общими (Значения, хранящиеся в ma_values из PyDictObject)

Экземплярные словари всегда реализуются в форме разделенной таблицы (словарь совместного использования ключей), что позволяет экземплярам данного класса совместно использовать ключи (и хэши) для своих __dict__ и различаться только соответствующими значениями. .

Все это описано в PEP 412 -- Словарь совместного использования ключей. Реализация разделенного словаря появилась в Python 3.3, поэтому предыдущие версии семейства 3, а также Python 2.x не имеют этой реализации.

Реализация __sizeof__ для словарей учитывает этот факт. и учитывает только размер, соответствующий массиву значений, при расчете размера для разделенного словаря.

Это, к счастью, говорит само за себя:

Py_ssize_t size, res;

size = DK_SIZE(mp->ma_keys);
res = _PyObject_SIZE(Py_TYPE(mp));
if (mp->ma_values)                    /*Add the values to the result*/
    res += size * sizeof(PyObject*);
/* If the dictionary is split, the keys portion is accounted-for
   in the type object. */
if (mp->ma_keys->dk_refcnt == 1)     /* Add keys/hashes size to res */
    res += sizeof(PyDictKeysObject) + (size-1) * sizeof(PyDictKeyEntry);
return res;

Насколько мне известно, словари с разделенными таблицами создаются только для пространства имен экземпляров, использование dict() или {} (как также описано в PEP) всегда приводит к комбинированному словарь, который не имеет этих преимуществ.


Кроме того, поскольку это весело, мы всегда можем сломать эту оптимизацию. В настоящее время я нашел два способа: глупый способ или более разумный сценарий:

  1. Быть глупым:

    >>> f = Foo(20, 30)
    >>> getsizeof(vars(f))
    96
    >>> vars(f).update({1:1})  # add a non-string key
    >>> getsizeof(vars(f))
    288
    

    Разделенные таблицы поддерживают только строковые ключи, добавление нестрокового ключа (что на самом деле не имеет смысла ноль) нарушает это правило, и CPython превращает разделенную таблицу в комбинированную, теряя весь прирост памяти.

  2. Сценарий, который может произойти:

    >>> f1, f2 = Foo(20, 30), Foo(30, 40)
    >>> for i, j in enumerate([f1, f2]):
    ...    setattr(j, 'i'+str(i), i)
    ...    print(getsizeof(vars(j)))
    96
    288
    

    Различные ключи, вставленные в экземпляры класса, в конечном итоге приведут к объединению разделенной таблицы. Это относится не только к уже созданным экземплярам; все последовательные экземпляры, созданные из класса, будут иметь комбинированный словарь вместо разделенного.

    # after running previous snippet
    >>> getsizeof(vars(Foo(100, 200)))
    288
    

конечно, нет никаких веских причин, кроме забавы, делать это намеренно.


Если кому-то интересно, реализация словаря Python 3.6 не меняет этого факта. Две вышеупомянутые формы словарей, хотя и все еще доступны, просто еще больше сжаты (реализация dict.__sizeof__ также изменилась, поэтому должны появиться некоторые различия в значениях, возвращаемых из getsizeof.)

person Dimitris Fasarakis Hilliard    schedule 23.02.2017