
Это вторая часть статьи Путеводитель по чужим STL, возможно будет и третья про разные трюки-хаки с контейнерами, как наберется материал.
Стандартная библиотека плюсов оказалась почти непригодной для игровой разработки и поняли это практически сразу, как попытались её использовать. Крупнейший на тот момент издатель Electronic Arts стал самым известным примером реализации стандартной библиотеки для разработчиков игр (но конечно же были и другие, менее именитые), и силами команд нескольких студий под руководством Paul Pedriana это было претворено в жизнь.
Корни EASTL уходят в Maxis к 1998-му году, когда Пол работая над SimCity 3000, выступал на GDC с докладом «High Performance Game Programming in C++» про кастомные контейнеры, стоимость вызовов и замеры производительности. Единой EASTL тогда ещё не было, но подход, из которого она потом выросла, виден уже там.
До консолидации, даже внутри одной студии, могли существовать несколько параллельных STL-реализаций, разработанных под разные игры, платформы и инструменты. Рискну предположить, что самой полной была реализация от Maxis, как одной из ведущих студий, а в других командах были поменьше, каждая со своими допущениями.
Он же в статьях и докладах упоминал, что EASTL была синтезом практик из этих внутренних вариантов, а не работой одного человека с нуля, и в основе большой EASTL лежит коллективный опыт нескольких инженерных команд, даже если формальным автором финальной публичной версии и статьи остался он один. Конкретные имена соавторов отдельных модулей (аллокаторов, фиксированных контейнеров и т.д.) указаны в репо, но слава досталась Полу.
Свой плюсовый стандарт
Стандартная библиотека C++ проектировалась как универсальная, стабильная и корректная. Это её сила и одновременно её проблема, потому что универсальное решение почти никогда не бывает оптимальным для конкретного случая. А разработка игры всегда была и останется этим конкретным случаем с принципиально другими ограничениями, добавьте сюда что в EA делали большую часть своих игр сначала под консоли и потом под уже пк, то и решения ориентированы в основном под память и особенности консолей.
Игровая консоль сильно отличается от обычного пк (сейчас уже поменьше, но особенности все равно есть), потому-то что все дальнейшие решения EASTL растут именно отсюда:
Память фиксирована и её мало... ну как мало... памяти всегда мало, на консоли нет виртуальной и нет свопа и если кончилась RAM, то игра просто крашится. Игры EA середины нулевых работали в физическом объеме 32Мб с несколькими свободными килобайтами, а некоторые вообще с нулём, ставя out-of-memory callback, который в момент запроса шёл освобождать память где-то ещё. Иногда успешно, иногда нет.
Фрагментация убивает. Она убивает даже сейчас, и на моем недавнем проекте на ХBox после часа игры объем фрагментированной памяти подбирается к 200Мб, т.е. это не какие-то жалкие килобайты. Раз нет виртуальной памяти, то дырки в куче не «размазываются» железом и аллокатор, который оставляет мусор между блоками, рано или поздно не найдёт непрерывный кусок нужного размера, и всё, дальше смотри пункт про фиксированную память.
Кэши меньше и алгоритмы выборки слабее, чем на PC. Промахи по кэшу стоят дороже (меньше блоки предвыборки), ветвления стоят дороже (меньше BPU), виртуальные вызовы стоят дороже (меньше объем таблиц переходов). Не-настольные платформы (портативках и мобилках) и вовсе проседают на мелких выборках в память при долгой работе (читай косвенным переходам/частым обращениям по разным адресам) и это одна из причин, почему EASTL избегал и избегает лишних слоёв абстракции, ведь даже промах кэша на консоли или телефоне обходится дороже, чем на PC с его агрессивным алгоритмом выборки данных и бездонным кешем.
Всё, что тянет лишние данные в кэш-линию, стоит дорого. EASTL избегает лишних вызовов (даже инлайнящихся), потому что это создаёт лишние обращения к памяти и разрастание кода функций, что сильно бьет уже по кешу инструкций, т.е. больше инструкций в алгоритме физически медленнее.
Debug-сборка тоже обязана быть быстрой. Игру тестируют люди, руками, итеративно и если debug-билд еле шевелится, то тестировать её становится физически сложно или невозможно.
Из этого списка появляется главная идея самой EASTL - проблема не в алгоритмах (они отличные) и даже не в интерфейсах контейнеров (они удобные), но проблема лежит в самой модели памяти, которую использует стандарт. И раз уж корень в модели памяти, чинить нужно в первую очередь её, и всё что с ней связано, т.е. локальность данных, временную и пространственную, связность данных и сами аллокаторы, эти данные порождающие.

Некоторым танцорам мешают...
...ноги, как известно. Как выглядит требование стандарта к аллокатору (упрощённо, typedef-ы я выкинул):
template <typename T> class allocator { public: template <class U> struct rebind { typedef allocator<U> other; }; T* allocate(size_type n, const void* hint = 0); void deallocate(T* p, size_type n); void construct(T* p, const T& val); // аллокатор ещё и конструирует объекты void destroy(T* p); size_type max_size() const; };
Выглядит хорошо, но этот дизайн в разработке стал источником болей. Они разные, каждая болит по своему и каждая тянет за собой реальные последствия.
Аллокатор привязан к типу, а не к экземпляру. Стандартный аллокатор является классом, а не объектом и вся информация о том, откуда брать память, живёт в типе, а не в конкретном инстансе контейнера. Поэтому когда вам нужен именно инстанс‑based аллокатор (например, «этот вектор берёт память вот из этого конкретного пула, а тот из другого»), то вам приходится делать два разных класса, чтобы это сделать.
// Стандартный allocator, где вся информация о пуле "вшита" в тип. // Если хотим взять память из двух разных пулов? Придётся сделать два разных класса. template <typename T> class PoolAAllocator { public: T* allocate(std::size_t n) { return static_cast<T*>(g_poolA.alloc(n * sizeof(T))); } void deallocate(T* p, std::size_t n) { g_poolA.free(p); } // ... construct, destroy, rebind, и весь остальной обязательный набор }; template <typename T> class PoolBAllocator { public: T* allocate(std::size_t n) { return static_cast<T*>(g_poolB.alloc(n * sizeof(T))); } void deallocate(T* p, std::size_t n) { g_poolB.free(p); } // ... тот же самый код, только другой пул }; // Получаем два вектора РАЗНЫХ типов, // хотя логически это два одинаковых вектора Foo с разным источником памяти std::vector<Foo, PoolAAllocator<Foo>> vecFromPoolA; std::vector<Foo, PoolBAllocator<Foo>> vecFromPoolB; // Они не взаимозаменяемы на уровне типа. Нельзя написать функцию, // которая одинаково работает с обоими типы контейнеров разные. // Для vecFromPoolB нужна ОТДЕЛЬНАЯ перегрузка или шаблон void ProcessVector(std::vector<Foo, PoolAAllocator<Foo>>& v);
Технически проблема глубже, чем просто "дизайн неудобный" и по стандарту C++98/03 предполагалось (хоть и не всегда буквально требовалось), что все инстансы allocator<T> для одного T эквивалентны и взаимозаменяемы (могут освобождать память друг друга). Это следствие того, что аллокатор по сути является статeless-типом, а не объектом с состоянием.
Если нужно, чтобы vector<Foo> брал память из пула А, а другой vector<Foo> из пула Б, стандартный дизайн вынуждает создавать два разных типа аллокатора (PoolAAllocator<Foo> и PoolBAllocator<Foo>), а не просто передать два разных объекта одного типа с разными указателями на пул.
В EASTL это решается наоборот и её аллокатор является обычным объектом с состоянием (хранит указатель/имя), который просто передаётся контейнеру как значение при создании, и его можно менять через set_allocator, то есть аллокатор стал инстанс-ориентированным по дизайну, а не только "местами эмулирующим" это через хаки.
// Один и тот же тип аллокатора, разные инстансы с разным состоянием eastl::allocator poolAAlloc("PoolA", &g_poolA); eastl::allocator poolBAlloc("PoolB", &g_poolB); // Один и тот же тип вектора, но разные объекты-аллокаторы переданы в конструктор eastl::vector<Foo> vecFromPoolA(poolAAlloc); eastl::vector<Foo> vecFromPoolB(poolBAlloc); // Можно писать функции, не завязанные на конкретный пул: void ProcessVector(eastl::vector<Foo>& v); // работает с обоими // Можно даже поменять пул на лету: vecFromPoolA.set_allocator(poolBAlloc);
С++11 частично решил эту проблему, введя allocator_traits, а также разрешив стейтфул-аллокаторы, что убрало жёсткое допущение "все аллокаторы одного типа обязаны быть взаимозаменяемы". Но фундаментальная проблема осталась и если у PoolAAllocator<T> и PoolBAllocator<T> всё ещё два разных типа, то vector<Foo, PoolAAllocator<Foo>> и vector<Foo, PoolBAllocator<Foo>> будут разными типам контейнеров, т.е. реально поменялось очень мало.
В C++17 появились std::pmr::polymorphic_allocator и std::pmr::memory_resource которые разрешили разное поведение аллокации в зависимости от memory_resource, из которого он сконструирован, и поскольку memory_resource использует полиморфизм на уровне рантайма, то появилась возможность переключать алгоритм аллокации на лету.
Это практически то же самое решение, что и в EASTL и аллокатор становится настоящим объектом с состоянием внутри контейнера (обёртка над указателем на memory_resource), а не разными типами, причём автор пропозала (Pablo Halpern, N3916) явно формулировал проблему в тех же терминах, что и EASTL в 2007.
Пример с poolAAlloc/poolBAlloc можно переписать почти слово в слово на std::pmr, но теперь у нас появилось отдельное пространство имен внутри std, и код везде тоже придется переписывать.
std::pmr::monotonic_buffer_resource poolA(&bufferA, sizeA); std::pmr::monotonic_buffer_resource poolB(&bufferB, sizeB); std::pmr::vector<Foo> vecFromPoolA(&poolA); std::pmr::vector<Foo> vecFromPoolB(&poolB); void ProcessVector(std::pmr::vector<Foo>& v); // один тип, работает с обоими
Как вы помните... за все приходится платить, и здесь ценой будут виртуальные вызовы внутри memory_resource (do_allocate/do_deallocate виртуальные), то есть решена проблема "инстанс vs тип", но заплачено virtual dispatch, что все еще плохо для маленьких кэшей и памяти консолей.
Поэтому в геймдеве, особенно консольном, pmr приняли довольно вяло, и EASTL-подобный подход (объект-аллокатор без virtual) для многих студий так и остаётся предпочтительным, потому что не тянет за собой оверхеда на виртуальные вызовы с каждой аллокаций.

Allocator-rebind
rebind (ребайнд) это механизм в стандартной модели аллокаторов C++, который позволяет контейнеру взять аллокатор, параметризованный одним типом, и получить из него аллокатор для другого типа. Если вы написали что-то вида:
std::list<int, MyAllocator<int>> myList;
То вы сделали аллокатор для int, но std::list внутри себя хранит вовсе не голые int, а узлы связного списка, каждый из которых содержит int плюс два указателя (next/prev) и реальный тип, который нужно выделять, будет что-то вроде ListNode<int>, а вовсе не int. И контейнеру нужен способ превратить переданный ему MyAllocator<int> в MyAllocator<ListNode<int>>, именно это делает rebind.
template <typename T> class MyAllocator { public: template <typename U> struct rebind { typedef MyAllocator<U> other; }; // ... }; // Дай мне мой же аллокатор, но перенастроенный на тип узла typedef typename MyAllocator<int>::rebind<ListNode<int>>::other NodeAllocator; NodeAllocator nodeAlloc; // теперь аллоцирует ListNode<int>, а не int
По сути rebind будет "фабрикой типов", и если дать аллокатор для T, то rebind<U>::other выдаёт тот же аллокатор, но для U. В C++11 это формально упростили и rebind стал необязательным для аллокатора, и теперь std::allocator_traits умеет вывести его автоматически, но сам механизм никуда не делся и просто уехал под капот трейтов и библиотеки. Т.е. убрали ручной boilerplate для автора аллокатора или контейнера, но на саму кодогенерацию это не повлияло и компилятор по-прежнему инстанцирует ребайнднутые типы, поэтому рост числа шаблонов никуда не делся и все также порождает взрыв шаблонов, превращаясь в лишний код и лишние вызовы.

Иммутабельность аллокатора
Аллокатор в контейнере нельзя поменять после конструирования и до него нельзя дотянуться, потому что контейнер даёт вам только копию своего аллокатора через get_allocator(), а установить свой можно только в конструкторе. Для игр, где часто бывает нужно создать контейнер, и только после создания можно будет сказать ему откуда брать память, это очень неудобно. А еще есть отложенные и параллельные задачи, которые берут аллокатор того потока, который будет их выполнять, тут уже время между созданием контейнера и реальной установкой аллокатора в него может быть несколько миллисекунд.
EASTL меняет сам подход к тому что мы понимаем под аллокатором, теперь он больше похож на пару malloc/free, чем на new/delete и просто выделяет сырые байты.
class allocator { public: explicit allocator(const char* name = "EASTL"); // у аллокатора есть имя // Обычное выделение и выделение с выравниванием и смещением: void* allocate(size_t n, int flags = 0); void* allocate(size_t n, size_t alignment, size_t offset, int flags = 0); void deallocate(void* p, size_t n); const char* get_name() const; void set_name(const char* name); };
Смотрите, что тут поменялось по сравнению со стандартом, и почему именно такие изменения были сделаны:
Теперь аллокатор не обязан быть шаблоном, нет
rebind, нет взрыва инстанцирований, нет member-темплейтов.Добавился
flags, например как подсказка аллокатору, что это временная память, или постоянная, или для видеокарты, или для файлового чтения. Мелочь, но на ней держится целая техника борьбы с фрагментацией (о ней ниже).Теперь у аллокатора есть имя, и каждое выделение можно протегать, и в отчёте о памяти будет видно куда ушли эти 4 мегабайта, или 400 или 4 гигабайта.
Про flags стоит сказать отдельно, потому что это очень красивая идея, которая позволяет строить иерархии областей памяти (опять же прочитать, что это такое можно в статье про аллокаторы или книге). Игровые кучи EA делят память на постоянную и временную: очень упрощенно будет (иерархии бывают разные), что постоянная (выделенная на старте уровня и живущая до его конца) растёт с одного конца кучи, временная (выделяемая и освобождаемая хаотично), как вы уже догадались с другого.
В результате постоянные аллокации плотно упакованы наверху и не оставляют «мёртвых зон» среди временных внизу. Флаг в allocate будет самым простым способом сказать куче, с какого конца блока памяти её откусывать. Стандартный аллокатор такого сделать не может в принципе, а для некоторых игр EA без этой оптимизации просто не хватало памяти на запуск. Вот такая организация памяти позволяет экономить до 10% реально используемого объема, просто за счет грамотного разведения по типам данных.
[временная --> (свободное место) <-- постоянная] (младшие адреса) (старшие адреса)

Соглашения контейнеров
EASTL не переписывает интерфейсы ради переписывания. Наоборот, он гарантирует, что существующий код на std::vector будет вести себя так же если превратится в eastl::vector, а изменения приходят только через новые методы, дополнительные шаблонные параметры или вообще новые контейнеры.
Из главных особенностей EASTL часто выделяют бережное отношение к пустым объектам и пустой контейнер почти никогда не выделяет память. А некоторые реализации std::list и std::map при конструировании сразу создают узел-сентинел, но мы хотим чтобы пустой контейнер стоил ноль байт кучи.
Например пустой std::deque в большинстве реализаций делает две аллокации.
Проблема пустых контейнеров
template <typename T> struct SpyAllocator { using value_type = T; SpyAllocator() = default; template <typename U> SpyAllocator(const SpyAllocator<U>&) {} T* allocate(std::size_t n) { std::cout << " allocate: " << n << " x " << sizeof(T) << " bytes (" << n * sizeof(T) << " total)\n"; return static_cast<T*>(::operator new(n * sizeof(T))); } void deallocate(T* p, std::size_t) noexcept { ::operator delete(p); } }; template <typename T, typename U> bool operator==(const SpyAllocator<T>&, const SpyAllocator<U>&) { return true; } template <typename T, typename U> bool operator!=(const SpyAllocator<T>&, const SpyAllocator<U>&) { return false; } int main() { std::cout << "vector (empty):\n"; std::vector<int, SpyAllocator<int>> v; // ожидаем тишина std::cout << "list (empty):\n"; std::list<int, SpyAllocator<int>> l; // здесь возможен sentinel-узел std::cout << "map (empty):\n"; std::map<int, int, std::less<int>, SpyAllocator<std::pair<const int, int>>> m; // тоже возможен std::cout << "deque (empty):\n"; std::deque<int, SpyAllocator<int>> d; // а вот здесь, скорее всего, будет allocate std::cout << "\n--- добавим по одному элементу ---\n"; std::cout << "vector.push_back:\n"; v.push_back(1); std::cout << "list.push_back:\n"; l.push_back(1); std::cout << "map.insert:\n"; m.insert({1, 1}); }
GCC/CLANG vector (empty): list (empty): map (empty): deque (empty): allocate: 8 x 8 bytes (64 total) allocate: 128 x 4 bytes (512 total) --- добавим по одному элементу --- vector.push_back: allocate: 1 x 4 bytes (4 total) list.push_back: allocate: 1 x 24 bytes (24 total) map.insert: allocate: 1 x 40 bytes (40 total) ========================================================== MSVC vector (empty): list (empty): allocate: 1 x 24 bytes (24 total) map (empty): allocate: 1 x 40 bytes (40 total) deque (empty): allocate: 1 x 16 bytes (16 total) --- добавим по одному элементу --- vector.push_back: allocate: 1 x 4 bytes (4 total) list.push_back: allocate: 1 x 24 bytes (24 total) map.insert: allocate: 1 x 40 bytes (40 total)
Еще есть reset(), и это, пожалуй, лучшее «игровое» расширение, которое потом перекочевало в очень много движков и игр, потому что reset() в одну операцию сбрасывает контейнер в пустое состояние, не освобождая память объектов.
Типичный сценарий, вы построили контейнер в куске scratch-памяти (например, во временном буфере кадра), поработали с ним, а в конце просто «обнулили» без обхода и разрушения всех узлов и вызовов dealloc/free. Если интересно, то можете почитать про разные виды аллокаторов в одной из моих прошлых статей и еще больше примемов и теории по аллокаторам в книге Game++.
// Классический паттерн: временная таблица в буфере кадра eastl::hash_map<int, Enemy*> visible(frame_allocator); // память из frame-аллокатора build_visibility(visible); render(visible); visible.reset(); // не clear()! Просто забыли про всё разом, O(1)
Тонкость reset() , что он безопасен только для типов с тривиальным деструктором, иначе вы получите утечки или что похуже, потому что после выполнения reset у контейнера нет аллоцированной памяти вообще. То есть reset не "оставляет память объектов, просто помечая контейнер пустым", а заставляет контейнер забыть про всю память, которой тот владел.
Смысл в том, что этой памятью владеет не контейнер, а внешний scratch-аллокатор (буфер кадра), и она будет освобождена оптом, когда сбросывается весь буфер целиком. reset именно поэтому и безопасен в таком паттерне, что контейнер отпускает указатели, ничего не вызывая, а реальное освобождение делает аллокатор где-то в другом месте.
В стандартной модели такое сделать «нельзя», потому что контейнер обязан при разрушении (или clear) вызвать деструктор каждого элемента и вернуть память через deallocate. Это часть контракта и контейнер владеет своими элементами, отвечая за их корректное уничтожение. Функции «забудь про всю память, ничего не вызывая» в стандартном интерфейсе просто нет и никогда не будет, потому что для универсального контейнера это дыра в безопасности и для любого типа с нетривиальным деструктором будет гарантированной утечкой ресурсов.
Но консольная память часто фиксирована и расчерчена на регионы под жёсткий бюджет (вот эти мегабайты под кадр, вторые под уровень, а третьи постоянные), и такая арена-аллокатор становится единственным владельцем для десятков разных контейреров. А раз владелец только один, то контейнеру достаточно просто "отпустить" память, не возвращая её. std спроектирован без каких-либо предположений о такой карте, и для него куча это просто куча, безликий глобальный ресурс, из которого берут и в который возвращают.
Arena frameArena(64 * 1024); // 64 КБ scratch-памяти "на кадр" for (int frame = 0; frame < 3; ++frame) { // Временный контейнер строится в памяти арены eastl::vector<EnemyVis> visible{ frameArena }; for (int i = 0; i < 100; ++i) visible.push_back(EnemyVis{ i, float(i), float(i) }); std::cout << " построено врагов: " << visible.size() << "\n"; // ... здесь был бы render(visible) ... visible.reset(); // Конец кадра: НЕ обходим 100 элементов, НЕ зовём deallocate. // Просто сбрасываем всю арену одним вызовом. frameArena.reset(); }

Fixed-контейнеры
Вот мы и добрались до fixed_string и всей его родни, ради которого EASTL часто тащут в свой проект. Это, на мой взгляд, самая ценная часть EASTL, и многие в EA пользовались этими контейнерами чаще обычных, а некоторые игры компании так вообще исключительно ими.
Идея простая: fixed-контейнер хранит свои данные прямо внутри себя, в буфере фиксированного размера, встроенном в объект. Вуаля... никаких обращений к куче нет, от слова совсем. У этого подхода даже есть отдельное название - "Zero frame allocations". Конечно, совсем чистого от аллокаций фрейма добиться сложно, но снизить их число до не скольких сотен или десятков крупных, вместо тысяч и десятков тысяч мелких вполне возможно, и этим обычно занимается performance-инженер.
template <typename T, size_t nodeCount, bool enableOverflow = true, typename OverflowAllocator = EASTLAllocator> class fixed_vector { /* ... буфер на nodeCount элементов лежит прямо тут ... */ };
Вы объявляете fixed_vector<Entity, 64> и внутри объекта живёт место под 64 сущности и пока вы не превысили лимит, аллокатор не трогается. enableOverflow просто страховка, когда и если вы всё-таки переполнили буфер, там контейнер уйдёт за добавкой в запасной аллокатор вместо падения. Все fixed-контейнеры могут писать в лог отметки максимального использования, чтобы потом подобрать нужные размеры. А знать правильные размеры надо, если вспомнить про кэш и фрагментацию:
// std::vector данные ГДЕ-ТО в куче, объек вектора хранит указатель туда std::vector<Vec3> path; // sizeof ~ 24 байта (3 указателя) path.push_back({1, 2, 3}); // -> поход в кучу, возможный cache miss // eastl::fixed_vector данные прямо под ногами, в стеке eastl::fixed_vector<Vec3, 32> path; // sizeof ~ 32*12 + служебка, всё в объекте path.push_back({1, 2, 3}); // -> запись в уже горячую память, ноль аллокаций
Когда такой fixed_vector лежит на стеке или внутри другого объекта, его данные физически рядом с другими служебными полями и, скорее всего, уже в кэше. Для маленьких коллекций (список видимых объектов, буфер частиц эффекта, временный путь A*) это очень существенная разница от х2 до х100 по времени работы.
fixed_string та же идея для строк, просто короткое имя файла или тег живёт прямо в объекте, без обращения к аллокатору, fixed_substring просто view на кусок чужой строки без копирования, задолго до std::string_view и span из C++17.
std::inplace_vector принят в C++26. P0843R14 был принят в рабочий документ на июньском заседании 2024 года в Сент-Луисе, это как раз fixed_vector из EASTL 2007 года, только под другим именем. Динамически изменяемый массив с фиксированной на этапе компиляции ёмкостью и встроенным хранением, в самом пропозал EASTL прямо назван как prior art для ориентирования на механизм работы.
Почему так поздно? Просто планка "войти в стандарт" несравнимо выше, чем "сделать для своих игр" и своей STL, какой бы крутой она ни была. EASTL все равно остается внутренней библиотекой, пусть и крупнейшего издателя, и ему достаточно было, чтобы оно работало на консолях и в проектах компании.
Комитету нужно специфицировать поведение на все возможные случаи всего возможного железа с семантикой исключений, инвалидацией итераторов, constexpr-поведением и много чего еще.

Intrusive-контейнеры
Вторым столпом всей библиотеки EASTL стали интрузивные контейнеры, когда не контейнер выделяет узлы под ваши объекты, а вы сами встраиваете «ссылочные поля» в свой объект, и контейнер просто сшивает их в список.
// Обычный std::list<Widget> хранит указатель на Widget внутри своего узла: // node { prev, next, Widget* } -> Widget где-то ещё в куче // Intrusive-список требует, чтобы Widget сам был узлом: struct Widget : public eastl::intrusive_list_node // prev/next живут в самом Widget { int hp; }; eastl::intrusive_list<Widget> active; Widget w; active.push_back(w); // ноль аллокаций: узел это сам w
Что это даёт, помимо очевидного нуля аллокаций? Объект можно вынуть из списка, не имея ссылки на сам список, потому что поля prev/next живут в объекте, важно, когда вы раздаёте клиентам указатели на элементы, а те возвращают их обратно.
eastl::intrusive_list<Widget> active; void spawn(Widget& w) { active.push_back(w); // выдали клиенту указатель на w } void kill(Widget& w) { // У нас на руках только сам объект, ссылки на 'active' нет. // Обычному списку понадобился бы итератор ИЛИ сам контейнер. // Интрузивному хватает объекта: prev/next живут в нём самом. eastl::intrusive_list<Widget>::remove(w); // O(1), статический метод }
Теперь элемент не обязан быть копируемым. Обычные контейнеры при вставке копируют, а интрузивный просто перецепляет указатели. Один объект может лежать в нескольких несвязанных списках сразу, если встроить несколько наборов ссылочных полей.
// Два независимых набора prev/next через разные базовые типы-теги struct ByHealthTag : public eastl::intrusive_list_node {}; struct ByDistanceTag: public eastl::intrusive_list_node {}; struct Enemy : public ByHealthTag, public ByDistanceTag { int hp; float distance; }; eastl::intrusive_list<ByHealthTag> byHealth; eastl::intrusive_list<ByDistanceTag> byDistance; Enemy e{ /* ... */ }; byHealth.push_back(e); // цепляется через поля ByHealthTag byDistance.push_back(e); // цепляется через поля ByDistanceTag // один и тот же объект e одновременно в двух несвязанных списках, // и его удаление из одного не трогает другой
Платить приходится за все, и теперь объект «знает», что он элемент контейнера, то есть абстракция протекает в реализацию, но в геймдеве это часто приемлемая цена за отсутствие аллокаций в хотпасе.
struct Widget : public eastl::intrusive_list_node { // <- уже протечка int hp; // тип знает про список };
Почему такие контейнеры никогда не завезут в std? Тут я поостерегусь говорить "никогда", потому что fixed-контейнеры тоже считали "невозможными для std", а в C++26 приняли. Может и с интрузивными в один июльский вечерок будет также, ибо попытки уже были и одна даже дошла до комитета, но заглохла.
Есть Proposal P0406 "Intrusive Containers" Хэла Финкеля (https://github.com/hfinkel/intrusive-containers-proposal, https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0406r1.html), который предлагает затащить подмножество Boost.Intrusive, хорошо известные в народе. Он дошел до LEWG этапа (Library Evolution Working Group), т.е. подачи на рассмотрение в комитет, но дальше R1/R2 (2016) дело фактически не двинулось, и на сегодня стандартная библиотека не предлагает никаких реализаций интрузивных контейнеров.
Почему не могут затащить? Интрузивный контейнер тоже ломает модель владения, на которой стоит вся STL и если inplace_vector при своей необычности всё еще владеет элементами, просто храня их в себе, то интрузивный контейнер не владеет ничем, и это выбивает фундамент из-под стандартных гарантий. Кто вызывает деструкторы? Когда? Что происходит при разрушении контейнера, если объекты живут дольше? Стандарт построен на "контейнер владеет и отвечает за lifetime"; интрузивный это ломает, и специфицировать такое как универсальную компоненту сейчас практически невозможно, в С+17 могли бы попробовать, но сейчас уже поздно.
Sorted vectors
std::map и std::set это почти всегда красно-чёрные деревья, где каждый элемент лежит в отдельном узле кучи, узлы связаны указателями и разбросаны по памяти, и при обходе узлов мы получим парад cache miss'ов. EASTL добавляет vector_map, vector_set , которые являются реализацией тех самых «отсортированных векторов» из «Effective STL» Мейерса.
std::map<int, Enemy> // дерево, каждый узел отдельно в куче, обход прыгает по памяти eastl::vector_map<int, Enemy> // один непрерывный массив, отсортированный по ключу
Красно-чёрное дерево платит за свой поиск O(log n) россыпью узлов по куче, где каждый элемент будет отдельной аллокацией, да и сам узел это не только ваши key/value, но ещё два-три указателя и флаг цвета. Соотвественно при поиске вы прыгаете по этим указателям, нагружая кеш, потому что соседние по значению ключи (на 99%) лежат в памяти где попало.
Отсортированный вектор устроен так, что все его элементы лежат в одном непрерывном блоке памяти, и поиск будет идти по сплошному блоку, который кеш как раз любит, и там нет никаких указателей, и данные плотно упакованы. Для коротких ключей до 8байт, vector_map обгоняет std::map даже там, где по интуиции должен проигрывать.
Как вы знаете... платить приходится за всё, и здесь ценой будут вставка и удаление, которые становятся O(n), потому что держать вектор отсортированным можно только сдвигая хвост при вставке, а любая реаллокация инвалидирует итераторы и указатели. То есть vector_map это не «map, только быстрее», а контейнер под конкретный паттерн, когда набил один раз и много читаешь.
При чем здесь игродев? Огромная доля игровых «словарей» будут как раз такими таблицами, собранными на загрузке уровня и дальше только опрашиваемые (id→ассет, имя→хендл, всевозможные реестры и конфиги). Запись в них идёт редко и пачкой, а пачку тоже выгоднее сделать массивов и отсортировать перед вставкой, чем вставлять по одному. А на маленьких N (до 50-100 объектов, в зависимости от размера кеша, а их в игре большинство) бинарный поиск по вектору обгоняет дерево в разы, и нередко даже линейный проход оказывается быстрее, просто потому что у вас всё уже лежит в кэше.
Почему такого долго не было в std? Считалось, что отсортированный вектор является идиомой, а не реальным типом, который имеет широкое практическое применение и у Мейерса это просто один из советов (Item 23, предпочитайте отсортированные vector'ы ассоциативным контейнерам).
В C++23 приняли std::flat_map/std::flat_set (P0429 и родня), т.е. народная практика → внутренняя библиотека EA → стандарт полтора десятка лет спустя. Здесь есть небольшая тонкость, по которой std::flat_map немного проигрывает в некоторых сценариях использования eastl::vector_map .
eastl::vector_map хранит pair<Key,Value> в одном векторе (массив структур), а std::flat_map сделан адаптером над двумя параллельными массивами: отдельно ключи, отдельно значения (структура массивов). И при чистом поиске, вида "есть этот элемент в контейнере" вы обходите только вектор ключей, не таща в кэш-линии ненужные значения, так что на lookup-тяжёлых нагрузках плоский стандартный map выходит ещё кэш-дружелюбнее оригинала.
Но если надо найти и достать значения, то будет выигрывать уже eastl::vector_map, потому что значение уже будет лежать в кеше и его можно обработать, в стандартном flat_map придетсся сходить еше раз в память. И эти нюансы, как оказалось, тоже имеют свои последствия.

Что из этого доехало до стандарта
Самое интересное, что спустя годы стандарт C++ пришёл ко многим из этих идей, иногда почти дословно:
- emplace_back / emplace (C++11) тот самый push_back(void) / insert(key) с конструированием на месте без копии.
- std::string_view (C++17) идейный наследник fixed_substring
- std::pmr (C++17), полиморфные аллокаторы с memory_resource шаг в сторону инстанс-based аллокаторов, о которых просят разрботчики игр.
- Move-семантика (C++11) закрыла бОльшую часть проблем с копированием, но Move-семантика ≠ релокация из EASTL. Move-конструктор + деструктор как раз то, чего memcpy-релокация избегает и большая часть EASTL контейнеров умеет релоцировать тривиальные типы побайтово через свои has_trivial_relocate, а не через move. trivial relocatability дошла-таки до стандарта и за P2786 проголосовали в C++26. То есть ещё одна EASTL-идея приехала в стандарт через ~18 лет.
А вот аллокаторы так и остались у стандарта неудобным, отчасти std::pmr помог, но привязка типа и виртуальность никуда не делись, поэтому EASTL (уже опенсорсный) до сих пор живёт и используется в реальных движках. Фундаментальная задача-то не изменилась с эпохи Atari, о которой я писал в прошлый раз: данные должны оказаться в правильном месте, в правильный момент и в правильной форме, иначе о производительности можно забыть. Стандартная библиотека решает эту задачу «в среднем по больнице». А там, где больница одна и очень конкретная, среднего решения не хватает, и приходится писать свою стандартную библиотеку.
За последние годы развитие сместилось от внедрения новых типов контейнеров к «полировке» существующего кода под требования современных компиляторов и стандартов C++. Большой фокус был сделан на обеспечении бесшовной работы с C++20/23, включая полноценную поддержку новых типов и constexpr-семантики для многих алгоритмов и контейнеров, фиксов статических анализаторов. Библиотека последние несколько лет считается индустриальным стандартом, для использования внутри игровых движков самых разных размеров, где std слишком медленен или неудобен, и делает это с учетом всех современных требований к безопасности кода и кроссплатформенности.
З.Ы. EASTL сейчас лежит на GitHub под BSD-лицензией (https://github.com/electronicarts/EASTL)
