-
-
Notifications
You must be signed in to change notification settings - Fork 30
Containers
ZEngine provides a small set of arena-backed containers. None of them call malloc/new — all memory comes from an explicit ArenaAllocator* passed at construction time. There are no default constructors that allocate and no implicit copies.
See also: Memory Management · Engine Architecture
- Overview
- Array
- UnorderedHashMap
- HashMap (ordered)
- UnorderedHashSet / HashSet
- String / StringView
- Common Pitfalls
| Container | Header | Purpose |
|---|---|---|
Array<T> |
Core/Containers/Array.h |
Contiguous growable buffer; move-only |
UnorderedHashMap<K, V> |
Core/Containers/UnorderedHashMap.h |
Open-addressing hash map; no ordering guarantee |
HashMap<K, V> |
Core/Containers/HashMap.h |
Open-addressing hash map; insertion order preserved |
UnorderedHashSet<K> |
Core/Containers/UnorderedHashSet.h |
Set backed by UnorderedHashMap<K, bool>; no ordering guarantee |
HashSet<K> |
Core/Containers/HashSet.h |
Set backed by HashMap<K, bool>; insertion order preserved |
String |
Core/Containers/Strings.h |
Arena-backed null-terminated string |
StringView |
Core/Containers/Strings.h |
Non-owning view over a String or C string |
All containers share the same init pattern — call init(arena, ...) before any other method. Calling methods on an uninitialised container is caught by ZENGINE_VALIDATE_ASSERT in debug builds.
Array<T> is the foundation for all other containers internally. It is move-only: operator=(const Array&) is deleted to prevent accidental shallow copies that would share a data pointer.
Array<int> arr;
arr.init(&arena, 64); // allocate 64 slots, m_size = 0
arr.push(42); // append by value
arr.push(std::move(val)); // append by move
auto& slot = arr.push_use({}); // emplace-default and return reference
slot.field = 123;
arr[0]; // bounds-checked (debug assert)
arr.size(); // occupied element count
arr.capacity(); // allocated slot count
arr.data(); // raw pointerThree-argument init — init(arena, initial_capacity, initial_size) sets m_size = initial_size
without constructing elements. The caller is responsible for writing valid data (e.g. via memcpy
or placement-new) before accessing any slot. Used internally by the hash maps.
h.LocalTransforms.init(&arena, count, count);
secure_memcpy(h.LocalTransforms.data(), count * sizeof(Mat4f), src, count * sizeof(Mat4f));Array can grow via push if m_size == m_capacity — it calls ZResize (arena realloc). On a
linear arena, if the block was the last allocation it extends in-place; otherwise it allocates a new
block and abandons the old one. Prefer pre-sizing to avoid wasted arena space.
Open-addressing hash map with linear probing and power-of-2 capacity.
init(arena, slot_capacity) rounds the requested capacity up to the next power of 2 (minimum 16)
and allocates the full block in one shot via Array::init(arena, cap, cap). All entry state bytes
are zeroed immediately with secure_memset so every slot starts as EntryState::Empty.
Rule: pass at least 2× the number of entries you intend to insert. This keeps the initial load at or below 50% and means a rehash is never needed in normal use.
// node_count = 906 entries expected
UnorderedHashMap<uint32_t, uint32_t> map;
map.init(&arena, node_count * 2); // capacity = next_pow2(1812) = 2048
map.insert(key, value); // copy
map.insert(key, std::move(value)); // move
map[key] = value; // upsert (creates with V{} if absent)
if (auto* v = map.find(key)) // returns V* or nullptr
use(*v);
map.contains(key); // bool
map.remove(key); // marks slot Deleted (tombstone)
map.clear(); // resets all slots to Empty, size = 0reserve(n) grows the map to next_pow2(n) if that exceeds the current capacity. It allocates a
new Array block and rehashes all occupied entries into it. The old block is abandoned in the arena —
call reserve sparingly and prefer up-front sizing via init.
- Primitive types and POD structs: hashed with
rapidhash(&key, sizeof(K)), compared with==. -
const char*: hashed withrapidhash(key, strlen(key)), compared withsecure_strcmp. Two different pointers holding the same string content will match.
A guard_load() check fires ZENGINE_VALIDATE_ASSERT if inserting would push occupancy above 75%.
If this assertion triggers, increase the init capacity or call reserve() before inserting.
for (auto [key, value] : map) { /* unordered */ }
for (const auto [key, value] : map) { /* const */ }Same open-addressing design as UnorderedHashMap, plus insertion order is preserved via a
doubly-linked list threaded through the slot array (prev / next indices; size_type(-1) = sentinel).
HashMap<int, int> map;
map.init(&arena, 64);
map.insert(30, 300);
map.insert(10, 100);
map.insert(20, 200);
for (auto [k, v] : map) // 30, 10, 20 — insertion order
use(k, v);remove(key) marks the slot Deleted and unlinks the entry from the ordered list. If the same key
is inserted again later it is appended at the tail — it does not return to its original position.
map.insert(1, 10); map.insert(2, 20); map.insert(3, 30);
map.remove(1); // order: 2, 3
map.insert(1, 100); // order: 2, 3, 1 (appended, not spliced back)Relinks the insertion-order list so iteration yields keys in ascending order. The hash table itself
is unchanged — lookup is O(1) before and after. sort_keys() is idempotent.
map.sort_keys();
for (auto [k, v] : map) // 10, 20, 30 — sorted
use(k, v);
map.insert(25, 250); // appended at tail — order: 10, 20, 30, 25HashMap uses a per-slot default construction loop rather than memset(0) because the
linked-list sentinels prev and next must be size_type(-1), not zero. This means HashMap::init
is slightly slower than UnorderedHashMap::init for large capacities.
Both set types are thin wrappers over their corresponding map types, storing bool as the value.
All capacity rules, the power-of-2 rounding, the load guard, and the key-type handling are
identical to the underlying map — refer to the sections above.
UnorderedHashSet<int> uset;
uset.init(&arena, count * 2); // same 2× sizing rule as the map
uset.insert(42);
uset.contains(42); // true
uset.remove(42);
for (const int& k : uset) { /* unordered */ }HashSet<int> oset;
oset.init(&arena, count * 2);
oset.insert(30); oset.insert(10); oset.insert(20);
for (const int& k : oset) { /* 30, 10, 20 — insertion order */ }
oset.sort_keys();
for (const int& k : oset) { /* 10, 20, 30 */ }Because capacity() delegates to the underlying map, it returns the power-of-2 rounded value —
not the raw number passed to init. Use EXPECT_GE(set.capacity(), n) in tests rather than
equality.
String is a thin arena-backed wrapper around a null-terminated char array.
String s;
s.init(&arena, "hello"); // allocates strlen("hello")+1 bytes
s.init(&arena, other.c_str()); // copy from another string or literal
s.c_str(); // const char*
s.empty(); // bool
s == other; // content comparison (secure_strcmp)StringView is a non-owning view — it holds a pointer and length with no arena interaction.
Under-sized init triggers the load guard:
// BAD — 10 slots for 10 entries = 100% load
map.init(&arena, 10);
for (int i = 0; i < 10; ++i)
map.insert(i, i); // assertion at 8th insert (75% threshold)
// GOOD — 2× margin keeps load at 50%
map.init(&arena, 20);Rehash wastes arena space:
Every reserve() call allocates a new block and abandons the old one — arena allocators cannot
free individual allocations. Pre-size with init so reserve is never needed.
Wrong sizeof in memcpy:
When copying element arrays, use sizeof of the actual element type, not the container struct.
A sizeof(ContainerType) instead of sizeof(ElementType) will silently overflow into adjacent
arena allocations. Always write count * sizeof(ElementType) explicitly.
Scratch arena lifetime:
Do not store a container in a long-lived struct while pointing its arena at a scratch sub-arena
obtained via ZGetScratch. The container's memory will be released when ZReleaseScratch is
called. Use the subsystem's permanent arena for any container that outlives a single function.