Memory Management
This guide explains how fluent-asserts handles memory allocation internally. Understanding these concepts is essential if you’re extending the library or debugging memory-related issues.
Why Manual Memory Management?
Fluent-asserts uses manual memory management internally to minimize GC pressure during test execution. While the library itself cannot be called from @nogc code (due to exception handling requirements), the internal data structures are designed to avoid triggering garbage collection in hot paths. This is achieved through:
- Fixed-size arrays (
FixedArray,FixedAppender) for bounded data - Heap-allocated arrays (
HeapData,HeapString) for unbounded data with reference counting
HeapData and HeapString
HeapData!T is a dynamic array using malloc/free instead of the GC, with two key optimizations:
- Small Buffer Optimization (SBO): Short data is stored inline without heap allocation
- Combined Allocation: Reference count is stored with the data in a single allocation
/// Heap-allocated dynamic array with ref-counting and small buffer optimization.struct HeapData(T) { private union Payload { T[SBO_SIZE] small; // Inline storage for small data HeapPayload* heap; // Pointer to heap allocation } private Payload _payload; private size_t _length; private ubyte _flags; // Bit 0: isHeap flag}
alias HeapString = HeapData!char;Small Buffer Optimization
HeapData stores small amounts of data directly in the struct without any heap allocation:
- x86-64: Up to ~47 characters stored inline (64-byte cache line)
- ARM64 (Apple M1/M2): Up to ~111 characters stored inline (128-byte cache line)
This dramatically reduces allocations for short strings and improves cache locality.
auto hs = toHeapString("hello"); // Stored inline, no malloc!assert(hs.refCount() == 0); // SBO doesn't use ref counting
auto long_str = toHeapString("a]".repeat(100).join); // Heap allocatedassert(long_str.refCount() == 1); // Heap data has ref countCreating HeapStrings
// From a string literalauto hs = toHeapString("hello world");
// Using create() for manual controlauto hs2 = HeapString.create(100); // Initial capacity of 100hs2.put("data");
// create() with small capacity uses SBOauto hs3 = HeapString.create(); // Uses small bufferReference Counting
HeapData uses reference counting for heap-allocated data only. Small buffer data is copied independently:
// Small buffer - copies are independentauto a = toHeapString("hi"); // SBO, refCount = 0auto b = a; // Independent copyb.put("!"); // Only b is modifiedassert(a[] == "hi"); // a unchangedassert(b[] == "hi!");
// Heap allocation - copies share dataauto x = toHeapString("x".repeat(200).join); // Forces heapauto y = x; // Shares reference, refCount = 2assert(x.refCount() == 2);Combined Allocation
The reference count is stored at the start of the heap allocation, followed by the data:
private struct HeapPayload { size_t refCount; size_t capacity; // Data follows immediately after...
T* dataPtr() { return cast(T*)(cast(void*)&this + HeapPayload.sizeof); }}This means heap allocation requires only one malloc call instead of two (compared to the old implementation).
The Postblit Solution
fluent-asserts uses D’s postblit constructor (this(this)) to handle blit operations automatically. Postblit is called after D performs a blit, allowing us to fix up reference counts:
struct HeapData(T) { /// Postblit - called after D blits this struct. /// For heap data: increments ref count. /// For small buffer: nothing to do (data already copied). this(this) @trusted @nogc nothrow { if (isHeap() && _payload.heap) { _payload.heap.refCount++; } }}How Postblit Works
- D performs blit (memcpy) of the struct
- D calls
this(this)on the new copy - For heap data: postblit increments the reference count
- For SBO data: nothing needed (blit already copied the inline data)
This happens automatically - you don’t need to call any special methods when returning HeapString or structs containing HeapString from functions.
Nested Structs
When a struct contains members with postblit constructors, D automatically calls postblit on each member - no explicit postblit is needed in the containing struct:
struct ValueEvaluation { HeapString strValue; // Has postblit HeapString niceValue; // Has postblit HeapString fileName; // Has postblit HeapString prependText; // Has postblit
// No explicit postblit needed! // D automatically calls the postblit for each HeapString field // when ValueEvaluation is blitted}String Concatenation
HeapData supports concatenation operators for convenient string building:
auto hs = toHeapString("hello");
// Create new HeapData with combined contentauto result = hs ~ " world";assert(result[] == "hello world");assert(hs[] == "hello"); // Original unchanged
// Append in placehs ~= " world";assert(hs[] == "hello world");
// Concatenate two HeapData instancesauto a = toHeapString("foo");auto b = toHeapString("bar");auto c = a ~ b;assert(c[] == "foobar");Legacy: incrementRefCount()
For edge cases, the incrementRefCount() method is still available:
/// Manually increment ref count (for edge cases)/// Note: Only affects heap-allocated data, not SBOvoid incrementRefCount() @trusted @nogc nothrow { if (isHeap() && _payload.heap) { _payload.heap.refCount++; }}In most cases, you should not need to call this method - the postblit constructor handles everything automatically.
Memory Initialization
HeapData zero-initializes allocated memory using memset. This prevents garbage values in uninitialized struct fields from causing issues:
static HeapData create(size_t initialCapacity = 0) @trusted @nogc nothrow { HeapData h; h._flags = 0; // Ensure flags are initialized h._length = 0;
if (initialCapacity > SBO_SIZE) { h._payload.heap = HeapPayload.create(cap); h.setHeap(true); } // Otherwise uses SBO - no allocation needed
return h;}Assignment Operator
HeapData provides a single assignment operator that handles both lvalue and rvalue assignments efficiently:
void opAssign(HeapData rhs) @trusted @nogc nothrow { // Decrement old ref count and free if needed if (isHeap() && _payload.heap) { if (--_payload.heap.refCount == 0) { free(_payload.heap); } }
// Take ownership from rhs (rhs was copied via postblit) _length = rhs._length; _flags = rhs._flags;
if (rhs.isHeap()) { _payload.heap = rhs._payload.heap; rhs._payload.heap = null; // Prevent rhs destructor from freeing rhs.setHeap(false); } else { _payload.small = rhs._payload.small; // Copy SBO data }}When called with an lvalue, D invokes the postblit constructor on rhs first, incrementing the ref count. The assignment then takes ownership of the copied reference. This unified approach keeps the code simpler while handling all cases correctly.
Accessing HeapString Content
Use the slice operator [] to get a const(char)[] from a HeapString:
HeapString hs = toHeapString("hello");
// Get slice for use with string functionsconst(char)[] slice = hs[];
// Pass to functions expecting const(char)[]writeln(hs[]);Comparing HeapStrings
HeapData provides opEquals for convenient comparisons without needing the slice operator:
HeapString hs = toHeapString("hello");
// Direct comparison with string literalif (hs == "hello") { /* ... */ }
// Comparison with another HeapStringHeapString hs2 = toHeapString("hello");if (hs == hs2) { /* ... */ }
// Negation works tooif (hs != "world") { /* ... */ }Best Practices
- Use slice operator
[]to access HeapString content for functions expectingconst(char)[] - Prefer FixedArray when the maximum size is known at compile time
- Let SBO work for you - short strings are automatically optimized
- Use
isValid()in debug assertions to catch memory corruption early
// Access content with slice operatorHeapString hs = toHeapString("hello");writeln(hs[]); // Use [] to get const(char)[]
// Compare directly (opEquals implemented)if (hs == "hello") { /* ... */ }
// Use concatenation for building stringsauto result = toHeapString("Error: ") ~ message ~ " at line " ~ lineNum;
// Debug validationassert(hs.isValid(), "HeapString memory corruption detected");What You Don’t Need to Do
With the current implementation, you don’t need to:
- Call
incrementRefCount()before returning structs (postblit handles this) - Write explicit postblit or copy constructors for structs containing HeapString (D calls nested postblits automatically)
- Write explicit assignment operators for structs containing HeapString (D handles this)
- Worry about heap allocation for short strings (SBO handles this)
- Make two allocations for ref count and data (combined allocation)
- Manually track reference counts when passing structs through containers
Debugging Memory Issues
If you encounter heap corruption or use-after-free:
- Enable debug mode: Compile with
-version=DebugHeapDatafor extra validation - Use
isValid()checks: Add assertions to catch corruption early - Check struct literals: Replace with field-by-field assignment
- Verify initialization: Ensure HeapData is properly initialized before use
- Check
refCount(): Use the debug method to inspect reference counts (returns 0 for SBO)
Debug Mode Features
When compiled with -version=DebugHeapData, HeapData includes:
- Double-free detection (asserts if ref count already zero)
- Corruption detection (asserts if ref count impossibly high)
- Creation tracking for debugging lifecycle issues
// Enable debug checksHeapString hs = toHeapString("test");assert(hs.isValid(), "HeapString is corrupted");
// Check if using heap (refCount > 0) or SBO (refCount == 0)if (hs.refCount() > 0) { // Heap allocated} else { // Using small buffer optimization}Common Symptoms
- Crashes in
malloc/free - Invalid pointer values like
0x6,0xa,0xc - Double-free errors
- Use-after-free (reading garbage data)
- Assertion failures in debug mode
Architecture-Specific Details
HeapData adapts to different CPU architectures for optimal cache performance:
| Architecture | Cache Line | SBO Size (chars) | Min Heap Capacity |
|---|---|---|---|
| x86-64 | 64 bytes | ~47 | 64 |
| x86 (32-bit) | 64 bytes | ~47 | 64 |
| ARM64 | 128 bytes | ~111 | 128 |
| ARM (32-bit) | 32 bytes | ~15 | 32 |
Next Steps
- Review the Core Concepts for understanding the evaluation pipeline
- See Extending for adding custom operations