Программирование, ориентированное на данные: 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: код отдельно, данные отдельно
Четыре принципа, в формулировке Шарвита, звучат почти банально — и именно поэтому их так часто нарушают:
- Отделить код от данных. Функции живут в модулях, данные — в структурах. Не «метод объекта», а «функция от значения».
- Представлять данные обобщёнными структурами. Словарь, список, запись — а не
class OrderDTO,class OrderView,class OrderRequestс одинаковыми полями. - Данные неизменяемы. Изменение — новое значение; см. функциональную парадигму.
- Отделить схему от представления. Схема — это данные о данных, она валидируется в момент входа и живёт отдельно от кода, который её использует.
# ── Объектный стиль: форма данных зашита в классы ───────────────────────────
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, конфигурация, аналитика | Обобщённые структуры + схема на входе |
| Данные, форма которых задаётся снаружи и меняется без вас | Обобщённые структуры, иначе будете релизиться под каждое чужое поле |
данные + методы"] --> O2["OrderService"] O2 --> O3["OrderDTO"] O3 --> O4["OrderMapper"] end subgraph DATA["Данные-ориентированная нарезка"] direction TB D1["Схема
данные о данных"] --> D2["Значение
обобщённая структура"] D2 --> D3["Функции: total, discount, view"] D2 --> D4["Инструменты: diff, лог, кеш,
сериализация — общие для всех данных"] end OBJ -. "инварианты дешевле" .-> USE1["Домен с правилами"] DATA -. "обработка дешевле" .-> USE2["Границы, потоки, аналитика"]
Часть 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.
Три правила, чтобы не превратить это в карго-культ:
- SoA нужна только на горячих однородных проходах. Если вы обрабатываете десять сущностей за запрос, раскладка не имеет значения вообще: разница в наносекундах, а читаемость падает заметно.
- Раскладка — не архитектура. Держите SoA внутри модуля обработки, а наружу отдавайте нормальные значения. Иначе структура памяти протечёт в API и застынет.
- Сначала измерьте.
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 — в дата-стеке.
Когда какая раскладка окупается
Практический порядок действий, чтобы не переусердствовать:
- Начните с обычных значений и обобщённых структур на границах — это дёшево и почти всегда правильно.
- Инварианты, которые нельзя нарушать, заприте в объект или в автомат — их обычно немного.
- Профилируйте. Если горячий цикл упирается в память — переложите только его данные в SoA или колонки.
- Не давайте раскладке протечь наружу: внутри — массивы, на границе — значения.
Типичные ошибки
- Анемичная модель под флагом DOP. Вынесли всё поведение в сервисы, но не завели ни схемы, ни валидации — получили худшее из обоих миров. Классический разбор — AnemicDomainModel Фаулера.
- Словари без схемы. «Обобщённые структуры» без явной валидации на входе — это не парадигма, а отсутствие типов. Схема обязательна, она и есть замена конструктору.
- SoA без профиля. Раскладка усложняет код и окупается только на горячих проходах. Без измерений это ухудшение читаемости за нулевой выигрыш.
- ECS в бизнес-приложении. Если у вас нет ни цикла кадров, ни комбинаторики компонентов, вы получите распределённое по десяти таблицам состояние и никакой выгоды.
- Материализация промежуточных массивов. Шесть операций — шесть копий гигабайтов; используйте ленивые API или чанки.
- Мутация «обобщённых» данных на месте. Если словарь передаётся в пять функций и каждая его правит, вы вернулись к глобальному состоянию — только без имени.
- Данные как публичный контракт по недосмотру. Внутреннее представление, попавшее в ответ API, превращается в обязательство на годы. Проекции пишутся явно.
Как это соотносится с остальным треком
| Парадигма | Отношение |
|---|---|
| ООП | Прямая противоположность по вопросу «где живёт поведение»; сосуществуют по слоям: объекты в домене, данные на границах и в обработке |
| Функциональная | Ближайший родственник: неизменяемость и чистые функции — общие принципы; расходятся в вопросе «обобщённые структуры или точные типы» |
| Декларативная | Массивные и колоночные движки отдают порядок исполнения планировщику — тот же обмен контроля на оптимизацию |
| Обобщённое программирование | Кодогенерация по схеме — стандартный способ получить типобезопасный доступ к данным без ручных классов |
| Автоматы | Автомат описывает правила изменения состояния, DOP — форму данных, которые через это состояние проходят |
Мини-итог
- Данные-ориентированная парадигма меняет инкапсуляцию на универсальность обработки: данные, не спрятанные за методами, доступны любым общим инструментам.
- DOP — про организацию кода: код отдельно, данные обобщённые и неизменяемые, схема отдельно. DOD — про раскладку: память читается линиями, платите только за нужные байты.
- Схема на границе заменяет конструктор-валидатор: она версионируется, публикуется и порождает клиентов.
- AoS против SoA не меняет
O-сложность, но меняет константу в 2–4 раза на горячих однородных проходах — и только там это стоит делать. - ECS — это реляционная модель в памяти: сущность-ключ, компоненты-таблицы, системы-запросы. Побеждает иерархию по гибкости и по кэшу, проигрывает по «цельности» сущности.
- Массивное программирование убирает цикл из кода и отдаёт порядок движку; главная ловушка — материализация промежуточных данных.
- Правило распределения: инварианты — в объекты, объёмы — в массивы, границы — в обобщённые структуры со схемой.
Источники
- Yehonatan Sharvit. Data-Oriented Programming — четыре принципа и подробные примеры.
- Brian Goetz. Data Oriented Programming in Java — те же идеи через записи и запечатанные типы.
- Mike Acton. Data-Oriented Design and C++, CppCon 2014 — доклад, с которого началась мода на DOD.
- Richard Fabian. Data-Oriented Design — книга целиком онлайн.
- Kenneth Iverson. Notation as a Tool of Thought — Тьюринговская лекция о массивном программировании.
- Bevy ECS Cheatbook и EnTT wiki — устройство современных ECS.
- Apache Arrow — спецификация колоночного формата в памяти.
- Rich Hickey. The Value of Values — почему значение долговечнее объекта.
Что дальше
Мы разобрались, как организовать состояние и как организовать данные. Осталась третья ось — как нарезать саму программу на части, которые можно понимать и заменять по отдельности: Модульное и компонентное программирование.