-
Notifications
You must be signed in to change notification settings - Fork 0
Container Semantics
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:
-
Parameters. Containers are passed
const&. Mutating a parameter (items.push(x),items[i] = v,tags.set(k, v),tags.remove(k), in-placesort/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++consterror: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; … -
Local aliases.
var b = a; b.push(x);copies in Hatchet —ais 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'scopy()-free spelling of it is idiomatic here); writevar b = a.copy();if you want the copy to be visible in the Haxe semantics too. -
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. -
Returns. Returning a container returns a copy. Mutating a returned container does not affect the one the function read from.
In preference order:
-
Hold the container in a class and pass the object. Classes are reference types (objects are pointers), so a
Rosterclass owning anArray<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.
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.
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 vectors — object_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;getreturnsnull/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 collectionfor). -
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.
Hatchet is licensed under the MIT License — see LICENSE. (c) 2026 Andrew Grant Lind
Getting Started
Language Support
- Declarations
- Value Types & Abstracts
- Members & Access
- Statements & Expressions
- Types & Nullability
- Conditional Compilation
- Memory Ownership
Semantics & Interop
Internals