Парадигмы разработки Программирование, ориентированное на данные: DOP, ECS и массивы
0%

Программирование, ориентированное на данные: DOP, ECS и массивы

Программирование, ориентированное на данные: DOP, ECS и массивы

Под словами «ориентированный на данные» в индустрии живут два разных движения, и это регулярно ссорит людей, которые на самом деле согласны друг с другом.

Первое — Data-Oriented Programming (DOP): код отдельно, данные отдельно, данные представлены обобщёнными неизменяемыми структурами, схема описана явно и отдельно от представления. Идея выросла из мира Clojure, была сформулирована Йехонатаном Шарвитом в книге Data-Oriented Programming и почти дословно повторена Брайаном Гётцем в статье Data Oriented Programming in Java.

Второе — Data-Oriented Design (DOD): программа существует, чтобы преобразовывать данные, поэтому проектирование начинается с того, как данные лежат в памяти и как их читает железо. Манифест — доклад Майка Актона Data-Oriented Design and C++ (CppCon 2014) и книга Ричарда Фабиана Data-Oriented Design.

Корень у них общий и он анти-объектный: объект — не единственная и не всегда лучшая единица мышления. В ООП данные прячутся внутрь поведения, потому что главная ценность — инвариант. В данных-ориентированной парадигме поведение выносится наружу, потому что главная ценность — сами данные: их можно логировать, сравнивать, кешировать, отправлять по сети, укладывать в массив и обрабатывать пачками, не спрашивая разрешения у методов.

Сделка: что отнимаем, что получаем

Отнимаем Получаем взамен
Инкапсуляцию: данные больше не спрятаны за методами Универсальные инструменты работают со всеми данными: сериализация, диффы, логирование, кеш, тесты на таблицах
Право иметь «свой» класс на каждую форму данных Обобщённые структуры (map/record/массив), для которых уже написана вся обвязка
Свободу раскладывать поля как удобно писать Предсказуемый доступ к памяти: последовательное чтение вместо прыжков по указателям
Полиморфизм на каждый чих Однородные пачки данных, которые обрабатываются одним циклом и векторизуются
Гарантию «объект всегда валиден» Явная схема и явная точка валидации на границе

Последняя строка — это и есть цена. Отдав инкапсуляцию, вы теряете гарантию, что данные валидны всегда, и обязаны получить её иначе: схемой на входе. Поэтому честная формулировка звучит так: данные-ориентированный подход выигрывает там, где данных много, а инвариантов мало; объектный — там, где инвариантов много, а сущностей мало. Заказ с правилами отмены — объект (или автомат). Миллион точек телеметрии — массив.

Часть I. DOP: код отдельно, данные отдельно

Четыре принципа, в формулировке Шарвита, звучат почти банально — и именно поэтому их так часто нарушают:

  1. Отделить код от данных. Функции живут в модулях, данные — в структурах. Не «метод объекта», а «функция от значения».
  2. Представлять данные обобщёнными структурами. Словарь, список, запись — а не class OrderDTO, class OrderView, class OrderRequest с одинаковыми полями.
  3. Данные неизменяемы. Изменение — новое значение; см. функциональную парадигму.
  4. Отделить схему от представления. Схема — это данные о данных, она валидируется в момент входа и живёт отдельно от кода, который её использует.
# ── Объектный стиль: форма данных зашита в классы ───────────────────────────
class OrderRequest:
    def __init__(self, customer_id, items): ...
    def total(self): ...
class OrderResponse:      # те же поля, другой класс, третий маппер
    ...

# ── DOP: одна форма данных, много функций ──────────────────────────────────
from decimal import Decimal

Order = dict          # обобщённая структура: словарь, а не пять классов-близнецов

def order_total(order: Order) -> Decimal:
    """Чистая функция от данных. Тестируется таблицей примеров, без моков."""
    return sum(Decimal(i["price"]) * i["qty"] for i in order["items"])

def with_discount(order: Order, percent: int) -> Order:
    """Новое значение, старое не тронуто."""
    return {**order, "discount_percent": percent}

def to_public_view(order: Order) -> Order:
    """Проекция — тоже просто функция; отдельный класс не нужен."""
    return {k: order[k] for k in ("id", "status", "items")}

Что реально даёт эта скучная запись, кроме экономии на классах:

  • Универсальные инструменты. Сравнить два заказа — обычный diff словарей. Залогировать — json.dumps. Закешировать — сериализовать. Сохранить в тесте как «золотой» образец — тоже. Ни одна из этих операций не требует кода, специфичного для заказа.
  • Дешёвые проекции. «Тот же заказ, но без персональных данных» — функция, а не иерархия DTO.
  • Данные переживают код. Сообщение в брокере, строка в базе, JSON в S3 живут годами; классы, которые их читали, переписываются трижды. Проектируя данные первыми, вы проектируете самую долгоживущую часть системы.

Схема отдельно от кода

Отказ от классов не означает отказ от типов — он означает, что проверка происходит в одной точке на границе, а не рассеяна по конструкторам. Практически это Pydantic/JSON Schema/Protobuf/Avro: схема — это данные, которые можно версионировать, публиковать в реестре, сравнивать на совместимость и по которым можно сгенерировать клиентов.

from pydantic import BaseModel, Field, TypeAdapter

class Item(BaseModel):
    sku: str = Field(min_length=1)
    qty: int = Field(gt=0)
    price: Decimal = Field(ge=0)

class OrderIn(BaseModel):
    customer_id: str
    items: list[Item] = Field(min_length=1)

# На границе: один раз разобрали, дальше внутри — обычные данные.
adapter = TypeAdapter(OrderIn)
order = adapter.validate_python(raw_json).model_dump()   # -> обычный dict

Здесь стоит честно обозначить спор. Школа типизированного ФП (см. алгебраические типы и моделирование типами) утверждает: не разбирайте в словарь, разбирайте в точный тип — тогда некорректное состояние невыразимо. Школа DOP отвечает: точный тип на каждую форму данных порождает «фабрику классов-близнецов» и делает дорогими самые частые операции — проекции, слияния, частичные обновления. Обе правы в своей области:

Ситуация Что выбрать
Ядро домена, 5–15 сущностей, много правил Точные типы, суммы, инварианты в конструкторе
Границы, интеграции, ETL, конфигурация, аналитика Обобщённые структуры + схема на входе
Данные, форма которых задаётся снаружи и меняется без вас Обобщённые структуры, иначе будете релизиться под каждое чужое поле

Часть II. DOD: как данные лежат в памяти

Второе движение начинается с наблюдения, которое легко проверить измерением: на современном железе стоимость вычисления определяется не количеством операций, а количеством промахов кэша. Чтение из L1 — единицы тактов, из памяти — сотни. Значит, «сколько байт мы прочитали зря» — это и есть главная метрика горячего цикла. Механика кэшей подробно разобрана в статьях Кэш и локальность и Сложность и память; здесь нас интересует только вывод для проектирования.

Классическая иллюстрация — массив структур против структуры массивов.

Массив структур против структуры массивов: что реально читается из памяти

Возьмём частицу в 64 байта: позиция, скорость, цвет, масса, идентификатор. Один объект — ровно одна кэш-линия. Цикл интегрирования трогает только позицию и скорость — это 24 байта из 64.

  • AoS (Particle particles[N]): чтобы прочитать 24 нужных байта, процессор притаскивает всю линию — 64. Полезная нагрузка 37,5 %. На миллионе частиц это 64 МБ трафика вместо 24 МБ.
  • SoA (float xs[N], ys[N], ...): нужные поля лежат подряд, каждая линия используется на 100 %, а компилятор может векторизовать цикл, потому что данные однородны и выровнены.

Разница на таких циклах — обычно 2–4 раза, иногда больше за счёт векторизации. Алгоритмическая сложность при этом не меняется: O(n) и там, и там. Меняется константа — и именно она определяет, укладывается ли кадр в 16 миллисекунд.

# Наглядно на numpy: те же вычисления, разная раскладка.
import numpy as np

n = 1_000_000
# AoS: одна запись — одна строка структурированного массива
aos = np.zeros(n, dtype=[("x", "f4"), ("y", "f4"), ("z", "f4"),
                         ("vx", "f4"), ("vy", "f4"), ("vz", "f4"),
                         ("rgba", "u4"), ("mass", "f4"),
                         ("id", "u8"), ("pad", "u8")])       # 64 байта на запись

# SoA: отдельный плотный массив на каждое поле
xs, ys, zs = (np.zeros(n, "f4") for _ in range(3))
vxs, vys, vzs = (np.zeros(n, "f4") for _ in range(3))

dt = np.float32(0.016)

def step_aos():
    aos["x"] += aos["vx"] * dt      # шаг между соседними x — 64 байта
    aos["y"] += aos["vy"] * dt
    aos["z"] += aos["vz"] * dt

def step_soa():
    xs[:] += vxs * dt               # шаг 4 байта: линия используется целиком
    ys[:] += vys * dt
    zs[:] += vzs * dt

# На типичном x86-ноутбуке step_soa быстрее step_aos в 2-4 раза.
# Проверяйте у себя: timeit + perf stat -e cache-misses.

Три правила, чтобы не превратить это в карго-культ:

  1. SoA нужна только на горячих однородных проходах. Если вы обрабатываете десять сущностей за запрос, раскладка не имеет значения вообще: разница в наносекундах, а читаемость падает заметно.
  2. Раскладка — не архитектура. Держите SoA внутри модуля обработки, а наружу отдавайте нормальные значения. Иначе структура памяти протечёт в API и застынет.
  3. Сначала измерьте. perf stat, cachegrind, профиль по промахам. Разбор методики — в практической оптимизации.

Часть III. ECS: данные-ориентированность как архитектура

Игровые движки довели идею до архитектурного стиля — Entity-Component-System:

  • Entity — просто идентификатор, число. Никаких полей и методов.
  • Component — чистые данные, привязанные к идентификатору: Position, Velocity, Health, Sprite.
  • System — функция, которая раз в кадр проходит по всем сущностям с нужным набором компонентов и преобразует их.

Это буквально реляционная модель в оперативной памяти: сущность — ключ, компонент — таблица, система — запрос с соединением по ключу. Отсюда и терминология «архетипов» (наборов компонентов) и «запросов» в современных движках вроде Bevy, EnTT и Unity DOTS.

# Минимальный ECS: плотные массивы + карта «сущность -> индекс».
class Store:
    """Одна таблица компонентов. Плотное хранение, удаление за O(1)."""
    def __init__(self):
        self.dense: list = []          # сами компоненты, подряд
        self.owners: list[int] = []    # какой сущности принадлежит dense[i]
        self.index: dict[int, int] = {}

    def add(self, entity: int, value) -> None:
        self.index[entity] = len(self.dense)
        self.dense.append(value)
        self.owners.append(entity)

    def remove(self, entity: int) -> None:
        i = self.index.pop(entity)
        last = len(self.dense) - 1
        if i != last:                              # swap-remove: дыр не остаётся
            self.dense[i], self.owners[i] = self.dense[last], self.owners[last]
            self.index[self.owners[i]] = i
        self.dense.pop(); self.owners.pop()

positions, velocities = Store(), Store()

def movement_system(dt: float) -> None:
    """Система — обычная функция над массивами. Ни классов, ни виртуальных вызовов."""
    for i, entity in enumerate(velocities.owners):
        j = positions.index.get(entity)
        if j is None:
            continue
        vx, vy = velocities.dense[i]
        x, y = positions.dense[j]
        positions.dense[j] = (x + vx * dt, y + vy * dt)

Почему ECS вытеснил наследование там, где раньше строили GameObject → Character → Enemy → FlyingEnemy:

Проблема иерархии Ответ ECS
«Летающий взрывающийся сундук» не влезает ни в одну ветку Композиция: добавили компоненты Flying и Explosive
Виртуальный вызов на каждую сущность каждый кадр Один цикл на систему, вызовов нет
Данные разбросаны по объектам в куче Компоненты лежат плотными массивами
Добавить поле — тронуть базовый класс Добавить компонент — не трогать ничего

И честная цена: сущность перестаёт существовать как единое целое. «Показать всё про этого моба» — это запрос к десяти таблицам; отладка сложнее, ссылки между сущностями надо валидировать вручную, порядок систем становится частью семантики. Для CRUD-приложения с сотней запросов в секунду ECS — чистый оверинжиниринг: там нет ни горячего цикла, ни комбинаторики компонентов.

Часть IV. Массивное программирование: цикл как деталь реализации

Третья ветвь той же парадигмы — array programming: операции определены сразу над целыми массивами, а не над элементами. Её родоначальник — APL Кена Айверсона (Тьюринговская лекция Notation as a Tool of Thought, 1979); прямые наследники — NumPy, pandas, Polars, R, MATLAB, kdb+/q, Julia.

import numpy as np

# Поэлементно: цикл виден, интерпретатор платит за каждый шаг.
def normalize_loop(xs: list[float]) -> list[float]:
    m = sum(xs) / len(xs)
    var = sum((x - m) ** 2 for x in xs) / len(xs)
    s = var ** 0.5
    return [(x - m) / s for x in xs]

# Массивно: цикла нет, есть операции над массивом целиком.
def normalize_vec(a: np.ndarray) -> np.ndarray:
    return (a - a.mean()) / a.std()

# Сложность одинаковая — O(n) времени, O(n) памяти.
# Разница в константе: на массиве в миллион float64 векторная версия
# обычно быстрее в десятки раз (нет боксинга, есть SIMD и один проход по памяти).

Что здесь именно парадигмальное, а не «библиотечное»:

  • Единица мышления — массив, поэтому исчезает целый класс ошибок: выход за границы, порядок обхода, забытый break.
  • Порядок исполнения принадлежит движку, как в декларативной парадигме: NumPy решает, как обойти память, Polars — в каком порядке выполнять план, kdb+ — как разложить колонки.
  • Композиция операций — оптимизируемая структура, а не цепочка вызовов. Ленивые API (Polars LazyFrame, Dask, Spark) строят план и сливают операции, устраняя промежуточные массивы; ровно та же идея слияния конвейеров разбиралась в ФП.

Типичная ловушка массивного стиля — материализация промежуточных данных. Цепочка из шести операций над массивом в 8 ГБ порождает шесть массивов по 8 ГБ, если движок не ленивый. Лечится либо ленивым API, либо обработкой чанками, либо выражением всей цепочки одной операцией.

Та же идея на уровне хранилищ называется колоночным форматом: ClickHouse, Parquet, Arrow хранят колонку подряд ровно по тем же причинам, что SoA в памяти — аналитический запрос читает три колонки из пятидесяти и не платит за остальные. Разбор — в статьях ClickHouse и OLAP и Форматы хранения. Практика векторных вычислений в Python — в дата-стеке.

Когда какая раскладка окупается

Практический порядок действий, чтобы не переусердствовать:

  1. Начните с обычных значений и обобщённых структур на границах — это дёшево и почти всегда правильно.
  2. Инварианты, которые нельзя нарушать, заприте в объект или в автомат — их обычно немного.
  3. Профилируйте. Если горячий цикл упирается в память — переложите только его данные в SoA или колонки.
  4. Не давайте раскладке протечь наружу: внутри — массивы, на границе — значения.

Типичные ошибки

  1. Анемичная модель под флагом DOP. Вынесли всё поведение в сервисы, но не завели ни схемы, ни валидации — получили худшее из обоих миров. Классический разбор — AnemicDomainModel Фаулера.
  2. Словари без схемы. «Обобщённые структуры» без явной валидации на входе — это не парадигма, а отсутствие типов. Схема обязательна, она и есть замена конструктору.
  3. SoA без профиля. Раскладка усложняет код и окупается только на горячих проходах. Без измерений это ухудшение читаемости за нулевой выигрыш.
  4. ECS в бизнес-приложении. Если у вас нет ни цикла кадров, ни комбинаторики компонентов, вы получите распределённое по десяти таблицам состояние и никакой выгоды.
  5. Материализация промежуточных массивов. Шесть операций — шесть копий гигабайтов; используйте ленивые API или чанки.
  6. Мутация «обобщённых» данных на месте. Если словарь передаётся в пять функций и каждая его правит, вы вернулись к глобальному состоянию — только без имени.
  7. Данные как публичный контракт по недосмотру. Внутреннее представление, попавшее в ответ API, превращается в обязательство на годы. Проекции пишутся явно.

Как это соотносится с остальным треком

Парадигма Отношение
ООП Прямая противоположность по вопросу «где живёт поведение»; сосуществуют по слоям: объекты в домене, данные на границах и в обработке
Функциональная Ближайший родственник: неизменяемость и чистые функции — общие принципы; расходятся в вопросе «обобщённые структуры или точные типы»
Декларативная Массивные и колоночные движки отдают порядок исполнения планировщику — тот же обмен контроля на оптимизацию
Обобщённое программирование Кодогенерация по схеме — стандартный способ получить типобезопасный доступ к данным без ручных классов
Автоматы Автомат описывает правила изменения состояния, DOP — форму данных, которые через это состояние проходят

Мини-итог

  1. Данные-ориентированная парадигма меняет инкапсуляцию на универсальность обработки: данные, не спрятанные за методами, доступны любым общим инструментам.
  2. DOP — про организацию кода: код отдельно, данные обобщённые и неизменяемые, схема отдельно. DOD — про раскладку: память читается линиями, платите только за нужные байты.
  3. Схема на границе заменяет конструктор-валидатор: она версионируется, публикуется и порождает клиентов.
  4. AoS против SoA не меняет O-сложность, но меняет константу в 2–4 раза на горячих однородных проходах — и только там это стоит делать.
  5. ECS — это реляционная модель в памяти: сущность-ключ, компоненты-таблицы, системы-запросы. Побеждает иерархию по гибкости и по кэшу, проигрывает по «цельности» сущности.
  6. Массивное программирование убирает цикл из кода и отдаёт порядок движку; главная ловушка — материализация промежуточных данных.
  7. Правило распределения: инварианты — в объекты, объёмы — в массивы, границы — в обобщённые структуры со схемой.

Источники

Что дальше

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

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов