Компактные объекты в Python

Питон — объектный язык. Это здорово и удобно, пока не придется создать 10 млн объектов в памяти, которые благополучно ее и съедят. Поговорим о том, как уменьшить аппетит.

Используйте песочницу, чтобы попробовать примеры

Кортеж

Допустим, есть у вас простенький объект «питомец» с атрибутами «имя» (строка) и «стоимость» (целое). Интуитивно кажется, что самое компактное предоставление — в виде кортежа:

("Frank the Pigeon", 50000)

Замерим, сколько займет в памяти один такой красавчик:

import random
from pympler.asizeof import asizeof

def fields():
    name_gen = (random.choice(string.ascii_uppercase) for _ in range(10))
    name = "".join(name_gen)
    price = random.randint(10000, 99999)
    return (name, price)

def measure(name, fn, n=10_000):
    pets = [fn() for _ in range(n)]
    size = round(asizeof(pets) / n)
    print(f"Pet size ({name}) = {size} bytes")
    return size

baseline = measure("tuple", fields)
Pet size (tuple) = 161 bytes

161 байт. Будем использовать как основу для сравнения.

Датакласс против именованного кортежа

С чистыми кортежами, конечно, работать неудобно. Наверняка вы используете датакласс:

from dataclasses import dataclass

@dataclass
class PetData:
    name: str
    price: int

fn = lambda: PetData(*fields())
measure("dataclass", fn)
Pet size (dataclass) = 257 bytes
x1.60 to baseline

Ого, какой толстенький!

Попробуем использовать именованный кортеж:

from typing import NamedTuple

class PetTuple(NamedTuple):
    name: str
    price: int


fn = lambda: PetTuple(*fields())
measure("named tuple", fn)
Pet size (named tuple) = 161 bytes
x1.00 to baseline

Теперь вы понимаете, за что я его так люблю. Удобный интерфейс как у датакласса — а вес как у кортежа. Идеально. Или нет?

Слоты

В Python 3.10 приехали датаклассы со слотами:

@dataclass(slots=True)
class PetData:
    name: str
    price: int


fn = lambda: PetData(*fields())
measure("dataclass w/slots", fn)
Pet size (dataclass w/slots) = 153 bytes
x0.95 to baseline

Ого! Магия слотов создает специальные худощавые объекты, у которых внутри нет словаря, в отличие от обычных питонячих объектов. И такой датакласс ничуть не уступает кортежу.

Что делать, если 3.10 вам еще не завезли? Использовать NamedTuple. Или прописывать слоты вручную:

@dataclass
class PetData:
    __slots__ = ("name", "price")
    name: str
    price: int

У слотовых объектов есть свои недостатки. Но они отлично подходят для простых случаев (без наследования и прочих наворотов).

numpy-массив

Конечно, настоящий победитель — numpy-массив:

import string
import numpy as np

PetNumpy = np.dtype([("name", "S10"), ("price", "i4")])
generator = (fields() for _ in range(n))
pets = np.fromiter(generator, dtype=PetNumpy)
size = round(asizeof(pets) / n)
Pet size (numpy array) = 14 bytes
x0.09 to baseline

Но это не чистая победа. Если строки юникодные (тип U вместо S), выигрыш будет не таким впечатляющим:

PetNumpy = np.dtype([("name", "U10"), ("price", "i4")])
Pet size (numpy U10) = 44 bytes
x0.27 to baseline

А если длина имени не строго 10 символов, а варьируется, скажем, до 50 символов (U50 вместо U10) — преимущества и вовсе сходят на нет:

def fields():
    name_len = random.randint(10, 50)
    name_gen = (random.choice(string.ascii_uppercase) for _ in range(name_len))
    # ...

PetNumpy = np.dtype([("name", "U50"), ("price", "i4")])
Pet size (tuple) = 179 bytes

Pet size (numpy U50) = 204 bytes
x1.14 to baseline

Другие варианты

Для объективности рассмотрим и альтернативы.

Обычный класс по размеру не отличается от датакласса:

class PetClass:
    def __init__(self, name: str, price: int):
        self.name = name
        self.price = price
Pet size (class) = 257 bytes
x1.60 to baseline

И «замороженный» (неизменяемый) датакласс тоже:

@dataclass(frozen=True)
class PetDataFrozen:
    name: str
    price: int
Pet size (frozen dataclass) = 257 bytes
x1.60 to baseline

Словарь еще хуже:

names = ("name", "price")
fn = lambda: dict(zip(names, fields()))
measure("dict", fn)
Pet size (dict) = 355 bytes
x1.98 to baseline

Pydantic-модель ставит антирекорд (неудивительно, она ведь использует наследование):

from pydantic import BaseModel

class PetModel(BaseModel):
    name: str
    price: int
Pet size (pydantic) = 385 bytes
x2.39 to baseline

⌘ ⌘ ⌘

Компактные (и не очень) объекты в Python:

Кортеж
Датакласс
Именованный кортеж
Датакласс со слотами
Ручные слоты
numpy-массив

Подписывайтесь на канал и рассылку, чтобы не пропустить новые заметки 🚀