This is the third article in DOM-handling series.
If you haven't read the prior parts:
- Which Language Handles DOM-Like Models Best.
- Building a DOM in JavaScript: Ownership, X-Refs, and Copy Semantics you might want to start there for context.
C++ offers no garbage collector or borrow checker, but its smart pointers can model ownership hierarchies - if you treat them like the sharp tools they are. The challenge: building a DOM-ish structure (Document → Card → CardItem) that supports cross-references, shared immutable resources, and topological deep copies without leaks or undefined behavior.
Result: A working C++ implementation of the CardDOM in ~150 LOC that survives Valgrind, enforces immutability at compile time, and auto-expires weak references - though it demands constant vigilance against pointer misuse.
Design Decisions
Ownership and Reference Tracking
-
shared_ptrs
holding all nodes, with runtime checks preventing multiparenting or cycles. -
weak_ptrs
for cross-links (connectors, buttons) to ensure automatic expiry on deletion: - No manual unlinking required; expired
weak_ptrs
are safely handled with.lock()
. - Parent pointers validated at runtime during add/remove to block self-nesting or duplicate parents.
Handling / Avoidance of Shared Mutable State
- Const
Style
types enforce immutability at compile time, blocking direct mutation of shared resources. - Copy-on-write via explicit
clone()
: Mutations require creating a mutable copy first. - No shared mutable state in the graph; reassigned clones become
const
again post-mutation.
Trade-Offs
- No
unique_ptr
for hierarchy roots: since all nodes of DOM-like structure can be targets of cross-references (weak-ptrs) this forceshared_ptr
everywhere, complicating single ownership. - Two-phase deep copy: Custom traversal needed to resolve topology and rewire weak_ptrs correctly.
- Discipline required: Smart pointers prevent use-after-free but not leaks from cycles.
Safety Guarantees
From the Language/runtime
- Smart pointers automate deallocation and prevent use-after-free via reference counting.
- Const-correctness rejects mutable access to shared data at compile time. But it does not prevent having const and non-const pointers to the same objects. In should be enforced manually.
-
Weak_ptr
auto-expiry prevents dangling cross-references without explicit cleanup.
From the Design
- Runtime exceptions on multiparenting/cycles maintain graph integrity during modifications.
- Stack reference protection: All params should be passed by value/reference to smart_ptrs; no raw pointer exposure.
Code Size & Cognitive Overhead
- Size: ~150 LOC, focused on pointer management, assertions, and two-phase copy logic.
- Cognitive load: Every new DOM node must reimplement copy/add/drop-child logic.
Usage
//// Creation
auto doc = make_shared<Document>();
{
auto style = make_shared<const Style>("Times", 16.5, 600);
auto card = make_shared<Card>();
auto hello = make_shared<TextItem>("Hello", style);
auto button = make_shared<ButtonItem>("Click me", card);
auto conn = make_shared<ConnectorItem>(hello, button);
card->add_item(move(hello));
card->add_item(move(button));
card->add_item(move(conn));
card->add_item(make_shared<GroupItem>());
doc->add_item(move(card));
}
// Unshare-on-modification
auto hello_text = dynamic_pointer_cast<TextItem>(doc->items[0]->items[0]);
// Compile-time prevention of direct mutation:
hello_text->style->size++; // ERROR
// Explicit clone to mutate:
auto new_style = hello_text->style->clone();
new_style->size++;
hello_text->style = new_style;
// Stack references prevent objects from being deleted
{
auto hello_text = dynamic_pointer_cast<TextItem>(doc->items[0]->items[0]);
doc->items[0]->remove_item(hello_text);
// hello_text is still alive here
assert(!dynamic_pointer_cast<ConnectorItem>(
doc->items[0]->items[1])->from.expired());
} // Actual deletion occurs here
// Topologically correct copy
auto new_doc = deep_copy(doc);
assert(new_doc->items[0]->items[0] ==
dynamic_pointer_cast<ConnectorItem>(
new_doc->items[0]->items[1])->to.lock());
assert(new_doc->items[0] ==
dynamic_pointer_cast<ButtonItem>(
new_doc->items[0]->items[0])->target_card.lock());
// Runtime multiparenting prevention
try {
doc->add_item(new_doc->items[0]);
} catch (std::runtime_error&) {
std::cout << "multiparented!\n";
}
// Runtime cycle detection
try {
auto group = make_shared<GroupItem>();
auto subgroup = make_shared<GroupItem>();
group->add_item(subgroup);
subgroup->add_item(group);
} catch (std::runtime_error&) {
std::cout << "loop\n";
}
Evaluation Table
Criterion | Description | Verdict |
---|---|---|
Memory safety | Avoids unsafe access patterns | ⚠️ Smart pointers block UAF; const prevents shared mutations, but raw pointer slips possible |
Leak prevention | Avoids memory leaks | ⚠️ Reference counting + RAII handles most cases; cycles or forgotten drops need Valgrind |
Ownership clarity | Are ownership relations clear and enforced? | ⚠️ Shared_ptr enables cross-refs but blurs single ownership; runtime checks enforce rules |
Copy semantics | Are copy/clone operations predictable and correct? | ⚠️ Manual two-phase deep_copy preserves topology and rewires weak_ptrs
|
Weaks handling | Survives partial deletions and dangling refs? | ✔️ Weak_ptrs expire automatically; no manual cleanup, stack refs extend lifetime safely |
Runtime resilience | Can DOM ops crash app? | ⚠️ Exceptions on violations (multiparent, cycles); survives partial deletes via RAII |
Code expressiveness | Concise and maintainable? | ⚠️ Verbose pointer boilerplate offsets readable API; manual copy/loop logic adds overhead |
Ergonomic trade-offs | Difficulty of enforcing invariants? | ❌ High; relies on developer discipline - no compile-time ownership beyond const |
Verdict
C++ can model complex DOM-like graphs if:
- You standardize on
shared_ptr
for cross-referenced nodes, - Leverage const for compile-time immutability,
- Implement two-phase
deep_copy
for topology-aware cloning, - And cultivate pointer discipline with runtime guards and Valgrind.
It's powerful but unforgiving - demanding vigilance.
At 150 LOC, it proves modern C++ handles shared mutable structures robustly, though leaks and UB lurk for the unwary. Ref-counting languages like Rust offer similar foundations with panic-based safety nets, but neither category natively groks DOM topologies.
Rust: Honorable Mention
Rust's borrow checker shines for stack values but falls back to ref-counting for heap graphs, mirroring C++'s smart pointers:
-
Box<t>
=unique_ptr<t>
-
Rc<t>
=shared_ptr<const t>
-
Rc<RefCell<t>>
≈shared_ptr<t>
-
Weak<RefCell<t>>
≈weak_ptr<t>
Differences between languages are centered around idea of how to handle object disposal while it's still referenced from stack:
- C++ risks UB (and almost always damages memory)
- Rust handles this situation in
RefCell.drop()
and ifRc
is deleted while its innerRefCell
is in a borrowed state, it panics.
So for DOMs, Rust adds safety via panics but sacrifices resilience and clarity.
The bigger revelation:
- Almost every modern app depends on DOM-like structures.
- Yet no GC-languages (JS) nor ref-counted languages (C++) is truly equipped to handle them.
Next:
Top comments (0)