Skip to content

Container Semantics

Andrew Lind edited this page Sep 22, 2026 · 5 revisions

Array and Map are value types

This is Hatchet's largest deliberate divergence from Haxe. In Haxe, Array and Map are objects — every binding is a reference to one shared container, so a mutation made through any of them is visible through all of them. Hatchet lowers them to std::vector / std::map by value: assignment and parameter passing copy the container. There is no shared-container runtime to lean on (that is the point of targeting bare C++98), so the divergence shows up in four places:

  1. Parameters. Containers are passed const&. Mutating a parameter (items.push(x), items[i] = v, tags.set(k, v), tags.remove(k), in-place sort/reverse/…) is the classic Haxe idiom for filling a caller's list — and it is exactly what the value lowering cannot express. Hatchet lints every such mutation at the Haxe line, ahead of the C++ const error:

    warning: World.hx:31: fill: `push` mutates `items`, an Array parameter — Haxe containers
    are shared by reference (the caller would see this change), but Hatchet passes containers
    by value (`const&`), so the mutation is lost and the generated C++ will not compile; …
    
  2. Local aliases. var b = a; b.push(x); copies in Hatchet — a is unchanged, where Haxe would mutate the one shared array. This is not linted (a local working copy is usually the intent in retro-target code, and Hatchet's copy()-free spelling of it is idiomatic here); write var b = a.copy(); if you want the copy to be visible in the Haxe semantics too.

  3. Fields. this.items = items; stores a copy into the field, where Haxe would store a reference to the caller's container. Later mutations of the field are the class's own; later mutations of the original do not reach the field.

  4. Returns. Returning a container returns a copy. Mutating a returned container does not affect the one the function read from.

Idiomatic patterns

In preference order:

  • Hold the container in a class and pass the object. Classes are reference types (objects are pointers), so a Roster class owning an Array<Unit> gives you Haxe-style shared mutation through the object — squad.add(u) works from anywhere, one container, no copies:

    class Roster {
        public var units(default, null):Array<Unit>;
        public function new() { units = []; }
        public function add(u:Unit):Void { units.push(u); }
    }
  • Return the result instead of mutating an argument: function doubled(xs:Array<Int>):Array<Int>.

  • Mutate a local copy when a working copy is genuinely what you want.

Conversely, when value semantics are what you want — small objects copied freely, composed by value, no heap — an abstract Name(U) (see Value Types & Abstracts) is the deliberate tool: it carries methods like any class but behaves like a struct, matching the value-object idiom of hand-written C++.

A future evolution may pass mutated container parameters by non-const reference (restoring Haxe semantics at the parameter boundary); until then, the lint is the contract.

Pushed values are bound to a local first

push, insert and unshift bind their value to a named local of the element type before the call, unless it already is one:

for (q in 0...quads) { var base:Int = q * 4; indices.push(base + 1); }
uint16_t _elem2 = (uint16_t)(base + 1);
indices.push_back(_elem2);

std::vector<T>::push_back takes const T&, so an argument that is not already a T lvalue materialises a temporary and binds the reference to that. Naming it costs nothing at runtime and is the shape hand-written C++ would have — and on a compiler as old as VC6, the fewer unnamed temporaries bound to references in a hot loop, the better. (The narrowing cast is a separate rule; see Types & Nullability.)

Nothing is inserted where there is no temporary to name: a literal, a plain local or this field already of the element type — looking through alias typedefs, so a Tileset local pushes straight into an Array<Tile> — a struct, array or map literal (already expanded into an element-typed temp), and a pointer element type (nothing converts, so list.push(new Thing()) is unchanged). Note that v + 1 where v is a cpp.UInt16 does get a local: C++ promotes it to int, so it converts back on the way in.

@orderedMap — an insertion-ordered map

A Map<K,V> lowers to std::map, which is key-sorted — it reorders keys — and is fragile on VC6. When you need a map that preserves insertion order (a JSON object, an ordered registry), tag the field @orderedMap:

@orderedMap public var object:Map<String, JValue>;

Hatchet stores it as two parallel vectorsobject_keys and object_vals — that only ever grow at the end, so first-seen order is preserved. There is no std::map (and no incomplete recursive container: a reference-typed V is a pointer, forward-declarable), which is what makes it VC6-safe. Every operation lowers to a scan over the vectors:

  • get(k) / exists(k) — linear find; get returns null/default when absent.
  • set(k, v) — replace in place if the key is present (keeping its position), else append to both.
  • remove(k) — paired erase from both vectors.
  • keys() — the keys vector (iterable via the ordinary collection for).
  • for (k => v in m) / for (v in m) / [for (k => v in m) …] — a paired index loop.
  • init: new Map() / [] clears both; a map literal clears then appends each pair in order.

Lookups are O(n) linear scans rather than std::map's O(log n) — faster for the small maps this targets (no node allocation, cache-friendly), but not for large ones. Because the field has no single map object, using it as a whole value — returning it, passing it, assigning a map into it — is a hard error naming the supported operations. The hxcpp build still sees an ordinary Map<K,V>, so the source stays valid, type-checkable Haxe; the parallel-vector representation and its insertion-order guarantee are Hatchet's, and (as everywhere) the emitted C++98 is the authoritative runtime.

Clone this wiki locally