Антон ЖияновРазработка софта, продуктоводство и здравый смыслhttps://antonz.ru/https://antonz.ru/assets/favicon/favicon.pngАнтон Жияновhttps://antonz.ru/Hugo -- gohugo.ioru-ruTue, 17 Jan 2023 09:20:00 +0000Композиция атомиков в Gohttps://antonz.ru/atomics-composition/Tue, 17 Jan 2023 09:20:00 +0000https://antonz.ru/atomics-composition/Разбираем атомарность и предсказуемость операций в многопоточной среде.Атомарная операция в многозадачной программе — отличная штука. Такая операция превращается в единственную инструкцию процессора, поэтому не требует блокировок. Ее можно безопасно вызывать из разных горутин и получить предсказуемый результат.

Но что будет, если злоупотреблять атомиками? Давайте разбираться.

Атомарность

Посмотрим на функцию, которая увеличивает счетчик:

var counter int32

func increment() {
    counter += 1
    // случайный сон до 10 мс
    sleep(10)
    counter += 1
}

Если вызвать 100 раз в одной горутине:

for i := 0; i < 100; i++ {
    increment()
}

то counter гарантированно будет равен 200.

В многозадачной среде increment() небезопасна из-за гонок на counter += 1. Сейчас я попробую это исправить и предложу несколько вариантов.

В каждом случае ответьте на вопрос: если вызвать increment() из 100 горутин, гарантировано ли итоговое значение counter?

Пример 1

var counter atomic.Int32

func increment() {
    counter.Add(1)
    sleep(10)
    counter.Add(1)
}

Гарантировано ли значение counter?

Ответ

Гарантировано.

Пример 2

var counter atomic.Int32

func increment() {
    if counter.Load()%2 == 0 {
        sleep(10)
        counter.Add(1)
    } else {
        sleep(10)
        counter.Add(2)
    }
}

Гарантировано ли значение counter?

Ответ

Не гарантировано.

Пример 3

var delta atomic.Int32
var counter atomic.Int32

func increment() {
    delta.Add(1)
    sleep(10)
    counter.Add(delta.Load())
}

Гарантировано ли значение counter?

Ответ

Не гарантировано.

Композиция атомарных операций

Люди иногда думают, что композиция атомиков волшебным образом тоже становится атомарной операцией. Но это не так.

Например, второй пример из рассмотренных выше:

var counter atomic.Int32

func increment() {
    if counter.Load()%2 == 0 {
        sleep(10)
        counter.Add(1)
    } else {
        sleep(10)
        counter.Add(2)
    }
}

Вызываем 100 раз из разных горутин:

var wg sync.WaitGroup
wg.Add(100)

for i := 0; i < 100; i++ {
    go func() {
        increment()
        wg.Done()
    }()

}

wg.Wait()
fmt.Println(counter.Load())

Запускаем с флагом -race, гонок нет. Но можно ли гарантировать, какое значение будет у counter в результате? Нет.

% go run atomic-2.go
192
% go run atomic-2.go
191
% go run atomic-2.go
189

Несмотря на отсутствие гонок, increment() в целом — не атомарная.

Проверьте себя, ответив на вопрос: в каком примере increment() — атомарная операция?

Ответ

Ни в одном.

Атомарность и коммутативность

Во всех примерах increment() — не атомарная операция. Композиция атомиков всегда не атомарна.

Но первый пример при этом гарантирует итоговое значение counter в многозадачной среде:

var counter atomic.Int32

func increment() {
    counter.Add(1)
    sleep(10)
    counter.Add(1)
}

Если запустить 100 горутин, counter в итоге будет равен 200 (если в процессе выполнения не было ошибок).

Причина в том, что Add() — коммутативная операция (точнее, sequence-independent, но это на русский нормально не переводится, поэтому пусть будет «коммутативная»). Такие операции можно выполнять в любом порядке, результат при этом не изменится.

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

В общем, несмотря на кажущуюся простоту атомиков, используйте их осторожно. Мьютекс, конечно, подубовее будет, но зато и надежнее.

]]>
Идемпотентный Close в Gohttps://antonz.ru/idempotent-close/Wed, 11 Jan 2023 10:00:00 +0000https://antonz.ru/idempotent-close/Как сделать освобождение ресурсов безопасным.Идемпотентность — это когда повторный вызов операции над объектом не приводит к изменениям или ошибкам. Очень полезная в разработке штука.

Давайте посмотрим, как применить идемпотентность, чтобы безопасно освободить занятые ресурсы.

Идемпотентный Close

Например, есть у нас ворота:

type Gate struct{
    // internal state
    // ...
}

Конструктор NewGate() открывает ворота, занимая какие-то системные ресурсы, и возвращает экземпляр Gate.

g := NewGate()

Понятно, что в итоге занятые ресурсы надо освободить:

func (g *Gate) Close() error {
    // free acquired resources
    // ...
    return nil
}

g := NewGate()
defer g.Close()
// do stuff
// ...

Проблемы начнутся, если в какой-то ветке кода мы захотим явно закрыть ворота:

g := NewGate()
defer g.Close()

err := checkSomething()
if err != nil {
    g.Close()
    // do something else
    // ...
    return
}

// do more stuff
// ...

Первый Close() сработает, а вот второй (через defer) сломается, потому что ресурсы уже освобождены.

Решение — сделать Close() идемпотентным, чтобы повторные вызовы ничего не делали, если ворота уже закрыты:

type Gate struct{
    closed bool
    // internal state
    // ...
}

func (g *Gate) Close() error {
    if g.closed {
        return nil
    }
    // free acquired resources
    // ...
    g.closed = true
    return nil
}

песочница

Теперь Close() можно вызывать сколько угодно без каких-либо проблем. До тех пор, пока мы не попытаемся закрыть ворота из разных горутин — тут-то все и развалится.

Идемпотентность в многозадачной среде

Мы сделали закрытие ворот идемпотентным — безопасным для повторных вызовов:

func (g *Gate) Close() error {
    if g.closed {
        return nil
    }
    // free acquired resources
    // ...
    g.closed = true
    return nil
}

Но если несколько горутин используют общий экземпляр Gate, одновременный вызов Close() приведет к гонкам — конкурентной модификации поля closed. Такого счастья нам не надо.

Придется защитить изменение closed мьютексом:

type Gate struct {
    mu     sync.Mutex
    closed bool
    // internal state
    // ...
}

func (g *Gate) Close() error {
    g.mu.Lock()

    if g.closed {
        g.mu.Unlock()
        return nil
    }
    // free acquired resources
    // ...

    g.closed = true
    g.mu.Unlock()
    return nil
}

песочница

Мьютекс гарантирует, что код между Lock() и Unlock() выполняется только одной горутиной в любой момент времени.

Теперь многократные вызовы Close() корректно работают и в многозадачной среде.

Вот и все!

]]>
Пользовательские функции в SQLitehttps://antonz.ru/sqlean-define/Thu, 08 Sep 2022 15:30:00 +0000https://antonz.ru/sqlean-define/Как писать функции на чистом SQL.Как писать функции на чистом SQL с помощью расширения define.

SQLite не поддерживает пользовательские функции. Да, можно написать кусочек кода на С или Python, и зарегистрировать в SQLite как функцию. Но определить функцию прямо из SQL не получится.

К счастью, SQLite поддерживает расширения. Одно их них — define — как раз позволяет писать функции на чистом SQL.

С define несложно определить собственную функцию:

select define('sumn', ':n * (:n + 1) / 2');

А затем использовать, как будто встроенную:

select sumn(5);
-- 15

Пользовательские функции могут принимать несколько параметров и вызывать другие функции.

Сгенерировать случайное N, такое что a ≤ N ≤ b:

select define('randint', ':a + abs(random()) % (:b - :a + 1)');
select randint(10, 99);
-- 42
select randint(10, 99);
-- 17
select randint(10, 99);
-- 29

Показать все пользовательские функции:

select * from sqlean_define;

Удалить функцию:

select undefine('sumn');

Можно даже задать функцию, которая возвращает несколько значений!

Подробности в документации

]]>
JSON Lineshttps://antonz.ru/json-lines/Thu, 04 Aug 2022 18:30:00 +0000https://antonz.ru/json-lines/CSV на стероидах.На днях оценил формат JSON Lines. Это такой CSV на стероидах:

  • каждая запись идет отдельной строкой, как в CSV;
  • но при этом представляет собой полноценный JSON.

Например:

{ "id":11, "name":"Diane", "city":"London", "department":"hr", "salary":70 }
{ "id":12, "name":"Bob", "city":"London", "department":"hr", "salary":78 }
{ "id":21, "name":"Emma", "city":"London", "department":"it", "salary":84 }
{ "id":22, "name":"Grace", "city":"Berlin", "department":"it", "salary":90}
{ "id":23, "name":"Henry", "city":"London", "department":"it", "salary":104}

Классная штука:

  • Подходит для объектов сложной структуры (в отличие от csv);
  • Легко потоково читать, не загружая файл целиком в память (в отличие от json);
  • Легко дописывать новые записи к существующему файлу (в отличие от json).

JSON, в принципе, тоже можно читать потоково. Но посмотрите, насколько это проще с JSON Lines:

import json
from typing import Iterator


def jl_reader(fname: str) -> Iterator[dict]:
    with open(fname) as file:
        for line in file:
            obj = json.loads(line.strip())
            yield obj


if __name__ == "__main__":
    reader = jl_reader("employees.jl")
    for employee in reader:
        id = employee["id"]
        name = employee["name"]
        dept = employee["department"]
        print(f"#{id} - {name} ({dept})")
#11 - Diane (hr)
#12 - Bob (hr)
#21 - Emma (it)
#22 - Grace (it)
#23 - Henry (it)

песочница

Идеально для логов и конвейеров обработки данных.

]]>
Многозначительное многоточие в Pythonhttps://antonz.ru/ellipsis/Fri, 03 Jun 2022 10:50:00 +0000https://antonz.ru/ellipsis/Что такое Ellipsis и как его используют.Не самая известная штука в Python — многоточие:

class Flyer:
    def fly(self):
        ...

Это рабочий код. В питоне ... (он же Ellipsis) — реальный объект, который можно использовать в коде.

Ellipsis — единственный экземпляр типа EllipsisType (аналогично тому, как None — единственный экземпляр типа NoneType):

>>> ... is Ellipsis
>>> True
>>> Ellipsis is ...
>>> True

Авторы Python в основном используют ..., чтобы показать, что у типа, метода или функции отсутствует реализация — как в примере с fly().

И в тайп-хинтах:

It is possible to declare the return type of a callable without specifying the call signature by substituting a literal ellipsis for the list of arguments in the type hint: Callable[..., ReturnType]

To specify a variable-length tuple of homogeneous type, use literal ellipsis, e.g. Tuple[int, ...]. A plain Tuple is equivalent to Tuple[Any, ...], and in turn to tuple.

# numbers  - кортеж целых чисел произвольной длины
# summator - функция, которая принимает любые аргументы,
#            а возвращает целое число
def print_sum(numbers: tuple[int, ...], summator: Callable[..., int]):
    total = summator(numbers)
    print(total)

print_sum((1, 2, 3), sum)
# 6

Ну, а обычные разработчики… Кто во что горазд ツ

]]>
Летающая свинья, или протоколы в Pythonhttps://antonz.ru/protocol/Tue, 31 May 2022 12:30:00 +0000https://antonz.ru/protocol/Структурная типизация с помощью протоколов.Допустим, вы написали утилиту, которая отправляет что угодно в полет:

def launch(thing):
    thing.fly()

Ну, то есть не прям все что угодно. Любую штуку с методом fly(). Очень удобно — одной функцией запускаем и голубя Френка, и самолет, и даже Супермена:

class Frank:
    def fly(self):
        print("💩")

class Plane:
    def fly(self):
        print("Рейс задержан")

class Superman:
    def fly(self):
        print("ε===(っ≧ω≦)っ")
f = Frank()
launch(f)
# 💩

p = Plane()
launch(p)
# Рейс задержан

s = Superman()
launch(s)
# ε===(っ≧ω≦)っ

Не то чтобы наши герои особо успешно справлялись с задачей, но запуск на них работает.

Работает, и ладно. Но иногда (особенно если программа разрастается) разработчику хочется добавить немного строгости. Дать понять, что параметр thing в launch() — это не любой объект, а обязательно летающая хреновина с методом fly(). Как лучше это сделать?

Описательно

Если вы привыкли избегать типов, то обойдетесь именем переменной или комментарием к функции:

def launch(flyer):
    """Launces a flyer (an object with a `fly()` method)"""
    flyer.fly()

Почему бы и нет. Беда в том, что чем сложнее код, тем чаще сбоит «описательный» подход.

Через базовый класс

Если расчехлить навыки программирования на джаве из 1990-х годов, получится небольшая иерархия:

class Flyer:
    def fly():
        ...

class Frank(Flyer):
    # ...

class Plane(Flyer):
    # ...

class Superman(Flyer):
    # ...
def launch(thing: Flyer):
    thing.fly()

Подход рабочий:

$ mypy flyer.py
Success: no issues found in 1 source file

Но, как говорят авторы Python, ужасно «непитоничный»:

The problem is that a class has to be explicitly marked, which is unpythonic and unlike what one would normally do in idiomatic dynamically typed Python code.

Действительно. Мало того, что нам пришлось модифицировать три класса вместо одной функции. Мало того, что у нас в коде завелась иерархия наследования. Так еще и Френк, самолет и Супермен теперь объединены общим знанием о том, что они Летающие Объекты. Им прекрасно жилось и без этого, знаете ли.

Через протокол

Цитата выше взята из PEP 544 (Python Enhancement Proposal, предложение о доработке), который был реализован в Python 3.8. Начиная с этой версии в питоне появились протоколы.

Протокол описывает поведение. Вот наш Летающий Объект:

from typing import Protocol

class Flyer(Protocol):
    def fly(self):
        ...

Используем протокол, чтобы указать, что объект должен обладать конкретным поведением. Функция launch() умеет запускать только Летающие Объекты:

def launch(thing: Flyer):
    thing.fly()

Причем самим объектам не надо знать о протоколе. Достаточно обладать нужным поведением:

class Frank:
    def fly(self):
        # ...

class Plane:
    def fly(self):
        # ...

class Superman:
    def fly(self):
        # ...

Протокол — это статическая утиная типизация:

  • интерфейс явно описан в протоколе: летающий объект обладает методом fly();
  • но реализуется он неявно, по «утиному» принципу: у Супермена есть метод fly() — значит, он летающий объект.

Проверим:

$ mypy flyer.py
Success: no issues found in 1 source file

Идеально!

Итого

Если ваш код должен единообразно работать с разными объектами — выделите у них общее поведение и вынесите в протокол. Используйте тип-протокол для статической проверки кода с помощью mypy.

По возможности избегайте голубей, самолетов и супергероев. От них одни проблемы.

]]>
Случайные числа и последовательности в Pythonhttps://antonz.ru/random/Mon, 23 May 2022 15:30:00 +0000https://antonz.ru/random/Там намного больше, чем просто randint()Все знают про random.randint(a, b), который возвращает случайное число в указанном диапазоне:

random.randint(10, 99)
# 59

Но модуль random предоставляет намного больше возможностей. Так много, что одной заметкой и не охватишь.

Например, можно выбрать из диапазона с шагом:

random.randrange(10, 99, 3)
# 91

Или случайный элемент последовательности:

numbers = [7, 9, 13, 42, 64, 99]
random.choice(numbers)
# 42

А то и несколько элементов:

numbers = range(99, 10, -1)
random.choices(numbers, k=3)
# [32, 62, 76]

Можно еще и веса элементам назначить — чтобы одни выбирались чаще других:

numbers = [7, 9, 13, 42, 64, 99]
weights = [10, 1, 1, 1, 1, 1]

random.choices(numbers, weights, k=3)
# [42, 13, 7]

random.choices(numbers, weights, k=3)
# [7, 7, 7]

random.choices(numbers, weights, k=3)
# [13, 7, 7]

Хотите выборку без повторений? Нет проблем:

numbers = [7, 9, 13, 42, 64, 99]
random.sample(numbers, k=3)
# [42, 99, 7]

Или можно всю последовательность перемешать:

numbers = [1, 2, 3, 4, 5]
random.shuffle(numbers)
# [3, 2, 1, 5, 4]

Еще есть вещественные распределения вроде uniform(), gauss(), expovariate(), paretovariate() и их многочисленных друзей. Не буду сейчас вдаваться в подробности — убедитесь сами, если вы поклонник матстата.

И напоследок. Если используете случайные числа в тестах, всегда инициализируйте генератор константой, чтобы он давал воспроизводимые результаты:

random.seed(42)

А в продакшене, наоборот, вызывайте seed() без параметров — так питон использует генератор шума операционной системы (или текущее время, если его нет).

]]>
Временные таблицы в SQLitehttps://antonz.ru/temp-tables/Wed, 18 May 2022 21:30:00 +0000https://antonz.ru/temp-tables/Для быстрого исследовательского анализа данных.Бывает, хочется собрать данные из нескольких таблиц в одну, и дальше с ними поработать. Например, выбрать вакансии вместе с работодателями и регионами:

Сводный датасет

select v.*, e.name, a.name
from vacancy as v
  join employer as e on e.id = v.employer_id
  join area as a on a.id = v.area_id

Вопрос, как дальше запускать запросы по сводным данным. Есть три способа это сделать:

  1. Табличное выражение (CTE)
  2. Представление (view)
  3. Временная таблица (temporary table)

CTE

Табличное выражение — это подзапрос с именем:

with combined_cte as (
  select v.*, e.name, a.name
  from vacancy as v
    join employer as e on e.id = v.employer_id
    join area as a on a.id = v.area_id
)
select ...
from combined_cte
where ...
group by ...
order by ...

Я подробно описывал CTE в отдельной заметке, так что не буду повторяться. Важно, что CTE каждый раз вычисляется заново, так что если наш сводный селект не особо быстрый — запросы по нему будут тормозить.

Представление

Представление работает как CTE, но к нему можно обращаться как к обычной таблице и не повторять каждый раз подзапрос. Как и CTE, представление тоже вычисляется на лету.

-- 1) create once
create view combined_view as
select v.*, e.name, a.name
from vacancy as v
  join employer as e on e.id = v.employer_id
  join area as a on a.id = v.area_id;

-- 2) use everywhere
select ...
from combined_view
where ...
group by ...
order by ...

В PostgreSQL и других СУБД есть материализованные представления, которые сохраняют данные на диск. Но не в SQLite.

Временная таблица

Временная таблица — такая же, как настоящая: хранит данные на диске, можно построить индексы. Но существует она, только пока открыто соединение с базой данных.

-- 1) create once
create temp table combined_temp as
select v.*, e.name, a.name
from vacancy as v
  join employer as e on e.id = v.employer_id
  join area as a on a.id = v.area_id;

-- 2) use everywhere
select ...
from combined_temp
where ...
group by ...
order by ...

Технически, SQLite хранит временные таблицы в специальной temp-базе. Она лежит в отдельном файле на диске и видна только тому соединению БД, в котором была создана. Как только соединение закрывается, SQLite автоматически удаляет временную базу.

Где находится временная база

На юниксо-подобных системах временная база лежит в одном из следующих каталогов:

  1. Каталог, заданный настройкой PRAGMA temp_store_directory (объявлена устаревшей)
  2. Переменная окружения SQLITE_TMPDIR
  3. Переменная окружения TMPDIR
  4. /var/tmp
  5. /usr/tmp
  6. /tmp
  7. Текущий рабочий каталог (.)

SQLite выбирает первый вариант из списка, для которого есть разрешения на запись (write) и исполнение (execute).

Чтобы хранить временную базу в памяти, задайте настройку PRAGMA temp_store = MEMORY.

документация

Временные таблицы отлично подходят для экспериментов, когда вы только знакомитесь с данными. Можно позволить себе любые безумства — ведь после отключения от базы все будет забыто ツ

]]>
JSON и виртуальные столбцы в SQLitehttps://antonz.ru/json-virtual-columns/Sun, 15 May 2022 11:25:00 +0000https://antonz.ru/json-virtual-columns/Строим NoSQL-базу на минималках.У вычисляемых столбцов есть еще одно чрезвычайно полезное применение.

JSON-данные

Допустим, вы решили вести журнал событий, которые происходят в системе. События бывают разных типов, у каждого свой набор полей. Например, вход в систему:

{
    "timestamp": "2022-05-15T09:31:00Z",
    "object": "user",
    "object_id": 11,
    "action": "login",
    "details": {
        "ip": "192.168.0.1"
    }
}

Или пополнение счета:

{
    "timestamp": "2022-05-15T09:32:00Z",
    "object": "account",
    "object_id": 12,
    "action": "deposit",
    "details": {
        "amount": "1000",
        "currency": "USD"
    }
}

JSON-функции

Вы решаете не заниматься нормализацией по таблицам, а хранить прямо в JSON. Заводите таблицу events с единственным полем value:

select value from events;
{"timestamp":"2022-05-15T09:31:00Z","object":"user","object_id":11,"action":"login","details":{"ip":"192.168.0.1"}}
{"timestamp":"2022-05-15T09:32:00Z","object":"account","object_id":12,"action":"deposit","details":{"amount":"1000","currency":"USD"}}
{"timestamp":"2022-05-15T09:33:00Z","object":"company","object_id":13,"action":"edit","details":{"fields":["address","phone"]}}

И выбираете события по конкретному объекту:

select
  json_extract(value, '$.object') as object,
  json_extract(value, '$.action') as action
from events
where json_extract(value, '$.object_id') = 11;
┌────────┬────────┐
│ object │ action │
├────────┼────────┤
│ user   │ login  │
└────────┴────────┘

Все здорово, но json_extract() при вызове каждый раз парсит текст, так что на сотне тысяч записей запрос будет работать медленно. Что делать?

JSON-столбцы

Создать виртуальные столбцы:

alter table events
add column object_id integer
as (json_extract(value, '$.object_id'));

alter table events
add column object text
as (json_extract(value, '$.object'));

alter table events
add column action text
as (json_extract(value, '$.action'));

Построить индекс:

create index events_object_id on events(object_id);

Теперь запрос работает моментально:

select object, action
from events
where object_id = 11;

Благодаря виртуальным столбцам получилась практически NoSQL база данных ツ

песочница

]]>
Компактные объекты в Pythonhttps://antonz.ru/compact-objects/Fri, 13 May 2022 20:25:00 +0000https://antonz.ru/compact-objects/Кортеж против датакласса, пока не вмешается numpyПитон — объектный язык. Это здорово и удобно, пока не придется создать 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-массив
]]>