7.4.23. C++ Integration: Binding shared_ptr via Handle<T>
This tutorial shows how to expose std::shared_ptr<T>-owned C++ objects
to daslang as value-typed handles, without modifying the C++ class
to inherit das::ptr_ref_count. Topics covered:
das::Handle<T>— a 64-bit, generation-checked value handledas::HandleRegistry<T>— per-T singleton that owns the liveshared_ptraddHandleAnnotation<T>— one call registers the type plus==,!=,is_alive, and an optional destructorAutomatic leak-at-shutdown reporting
When to pick
Handle<T>vssmart_ptr<T>
This is the pattern used by modules/dasHV for WebSocketClient,
WebSocketServer, HttpServer and similar types whose ownership
lives inside libhv.
7.4.23.1. Prerequisites
Tutorial 11 — C++ Integration: Context Variables.
Tutorial 12 — C++ Integration: Smart Pointers — read it first to see the intrusive-refcount alternative, then decide which pattern fits your C++ type.
7.4.23.2. When to use Handle<T> vs smart_ptr<T>
Question |
|
|
|---|---|---|
Can I modify the C++ class? |
yes — inherit |
no — use |
Ownership primarily lives in… |
daslang |
C++ engine |
Script-side cost per copy |
refcount bump |
64-bit value copy |
Use-after-free safety |
refcount (lifetime) |
generation check |
Registry thread safety |
n/a |
yes — mutex inside registry |
Requires |
yes |
no — plain |
7.4.23.3. Defining the C++ type
Actor is a plain struct — no base class, no refcount:
struct Actor {
std::string name;
float x = 0.f, y = 0.f;
int32_t health = 100;
explicit Actor(const char * n) : name(n ? n : "unnamed") { }
~Actor() { }
void move(float dx, float dy) { x += dx; y += dy; }
void take_damage(int32_t dmg) { health -= dmg; if (health < 0) health = 0; }
bool is_dead() const { return health <= 0; }
};
This is the whole point of the tutorial: you do not need to touch
Actor to expose it to daslang.
7.4.23.4. Declaring typeName<T>
Handle<T> carries a cast<> / typeFactory<> specialization
for itself, but the leak-dump path reads the inner type name via
typeName<T>::name(). Provide it at file scope:
MAKE_TYPE_FACTORY(Actor, Actor)
(In dasHV the equivalent line is
MAKE_EXTERNAL_TYPE_FACTORY(WebSocketClient, hv::WebSocketClient) in
dasHV.h + IMPLEMENT_EXTERNAL_TYPE_FACTORY(...) in dasHV.cpp
— use the external variant only if the declaration has to cross a
header/translation-unit boundary.)
7.4.23.5. addHandleAnnotation<T>
#include "daScript/misc/handle_registry.h" // Handle, HandleRegistry
#include "daScript/ast/ast_handle.h" // addHandleAnnotation
addHandleAnnotation<Actor>(this, lib, "Actor",
"destroy_actor", // optional — daslang destructor name
"das::Handle<Actor>"); // name AOT emits into generated C++
One call registers:
|
|
|
structural comparison of the 64-bit value |
|
generation-checked validity probe |
|
only if |
leak-dump hook |
wired automatically via
|
7.4.23.6. Factory — acquire
The factory creates a fresh shared_ptr and hands it to the registry:
static Handle<Actor> make_actor(const char * name, float x, float y) {
auto sp = std::make_shared<Actor>(name);
sp->x = x;
sp->y = y;
return HandleRegistry<Actor>::instance().acquire(sp);
}
HandleRegistry<T>::instance() is a per-T singleton; every module
and DLL that references HandleRegistry<Actor> sees the same storage.
7.4.23.7. Method — lookup + null-check
Bound “methods” are free functions that take Handle<Actor> by value
and resolve it through lookup:
static void actor_move(Handle<Actor> h, float dx, float dy) {
if ( auto p = HandleRegistry<Actor>::instance().lookup(h) )
p->move(dx, dy);
}
lookup returns an empty shared_ptr for null, stale, or
slot-reused handles — the null-check is a guaranteed
use-after-free guard.
Warning
Handle<T> is passed by value, so a bound function that
mutates the underlying object cannot use
SideEffects::modifyArgument (which requires a non-const
reference argument — you will get can't add function ... modify
argument requires non-const ref argument at module registration).
Use SideEffects::modifyExternal instead: the handle itself is
not modified, the external state behind it is.
7.4.23.8. Explicit destroy and is_alive
The daslang name passed as destroyFnName becomes a script-callable
destructor that unregisters the handle:
var goblin = make_actor("Goblin", 10.0, 5.0)
destroy_actor(goblin)
assert(!is_alive(goblin))
destroy_actor does not necessarily destroy the Actor — it
releases only the registry’s shared_ptr. If your engine holds
another shared_ptr, the object survives until the engine drops it.
Warning
Handle<T> has no scope-based auto-release — unlike
smart_ptr<T> (Tutorial 12), going out of scope does nothing.
Every handle the script acquired via a factory must be explicitly
destroyed (or handed off to the engine for release), or it will
leak and the runtime will print a Handle<T> idx=... (rc=...)
line at shutdown.
7.4.23.9. Leak dump at shutdown
addHandleAnnotation calls handleRegistry_registerDump<T> once,
registering a per-T dump callback. Module::Shutdown(bool
dumpHandleLeaks = true) calls handleRegistry_dumpAll() directly
and the runtime prints any live handles:
Handle<Actor> idx=3 gen=1 (rc=1)
total 1 leaked handles of type Actor
The tutorial script is designed to end with zero leaked handles — if you see output above, the script is holding a live handle past shutdown.
7.4.23.10. AOT compatibility
Handle<T> is value-typed via a cast<Handle<T>> specialization
in ast_handle.h that maps directly to uint64_t. AOT-generated
C++ passes the handle in a raw register — no boxing, no allocations.
The cppTypeName argument to addHandleAnnotation
("das::Handle<Actor>" above) is the string AOT writes into the
generated stub, so the emitted C++ compiles against the same header
your module uses.
7.4.23.11. Building and running
cmake --build build --config Release --target integration_cpp_23
bin\Release\integration_cpp_23.exe
Expected output:
=== Create actors ===
[C++] Actor('Hero') constructed
[C++] Actor('Goblin') constructed
hero: Hero hp=100 alive=true
goblin: Goblin hp=100 alive=true
=== Value semantics ===
hero == hero_copy : true
hero == goblin : false
=== Combat ===
hero hp = 100
goblin hp after 30 damage = 70
goblin hp after lethal = 0 is_dead=true
=== Explicit destroy ===
is_alive(goblin) before destroy = true
[C++] Actor('Goblin') destroyed
is_alive(goblin) after destroy = false
dead goblin.health = 0
dead goblin.name = ''
=== Engine-owned lifetime ===
[C++] Actor('Shopkeeper') constructed
after destroy: is_alive(npc) = false
[C++] Actor('Hero') destroyed
=== End of test — expect zero leak-dump output below ===
[C++] Actor('Shopkeeper') destroyed
Observe: Goblin and Hero die when destroy_actor drops the
registry’s last shared_ptr. Shopkeeper’s destructor runs after
the “End of test” line — that is the engine dropping its vector in
main after Module::Shutdown, proving that registry release
and engine ownership are independent. The key property is zero
Handle<Actor> idx=… leak lines.
See also
Full source:
23_handle_registry.cpp,
23_handle_registry.das
Previous tutorial: C++ Integration: Namespace Integration
Compare with the intrusive-refcount pattern: C++ Integration: Smart Pointers