So here's what we're going to do. The state of our application, called the 'document', will consist of a collection of 'objects'. Each Object has a unique uint64_t ID, a type, and an array of properties.

typedef struct Object {
	int type;
	Property *properties;
} Object;

Each Property has a type, a key, and a value. We're going to use strings for the property keys, but you could equivalently use integers.

typedef struct Property {
	uint8_t type;
#define PROPERTY_KEY_MAX_SIZE (14)
	char key[PROPERTY_KEY_MAX_SIZE + 1];

	union {
		// TODO.
	};
} Property;

The property types you should implement depend on what you need for your application. You might need strings, integers, floats, doubles, etc. You might more complicated things like the ability to store audio data in a property, or have an array of object IDs.

This simple setup is pretty powerful. You could think of it a bit like a 'flat JSON', where instead of nesting data structures, you have a single array of objects and objects can refer to each other using IDs. We allocate IDs sequentially starting at 1, never reusing values. With a uint64_t IDs type, with don't have to worry about overflow. This means that once an object is deleted, all the references to that object become permanently invalid, which is quite convenient. The data structure being flat is useful, too. When deserializing untrusted data we don't have to worry about stack overflows and such. And again, it allows the ID referencing system. You never have to traverse more than the 2 levels of objects and properties.

Before I settled on this design, I experimented with a similar setup, except instead of an object being an array of properties, instead an object would be a C structure associated with an array containing a list of the fields in the structure, and what type each field was. This worked, but it required the use of metaprogramming tools and made debugging harder. Adding or removing fields from structures became a precarious task. And having non-uniform field sizes complicated various tasks. Ultimately, I chose the model with an array of uniform properties because it massively simplies the code that deals with objects, and I found that having C structures for storing document state was actually not that helpful. Indeed, having to extract the properties you want from an object instead of being able to directly access them from a structure just really isn't that big of a deal. Especially when you need to internally convert from the arrangement of document data to something that can be used for rendering and the like regardless.

For our simple test application, we're only going to have one object of the type #define OBJECT_COUNTER (1) which will contain one property "count" of the type #define PROPERTY_U32 (1).

Since we're going to need a hash map to store the map of objects (based on their ID) and dynamic arrays of properties, I'm going to use the stb_ds.h single-header library from @nothings. You could easily write your own implementation of these (and might want to -- I don't think stb_ds.h allows you to handle out of memory errors), but that's out of scope for this tutorial, so we'll go with something pre-rolled.

#define STB_DS_IMPLEMENTATION
#include "stb_ds.h"

We can now create the global map of objects.

struct { uint64_t key; Object value; } *objects;
uint64_t objectIDAllocator;

Although it doesn't make much sense for this application, we'll also define a global variable for the currently selected object, since it is applicable for many applications. You could turn this into an array of object IDs to support selecting multiple objects.

uint64_t selectedObjectID;

At the start of our main function, we can construct the initial document.

// Create the counter object.
Object objectCounter = { 0 };
objectCounter.type = OBJECT_COUNTER;

// Add a U32 property "count" with the value 10.
Property propertyCount = { 0 };
propertyCount.type = PROPERTY_U32;
strcpy(propertyCount.key, "count");
propertyCount.u32 = 10; // Add `uint32_t u32;` to the union in `Property` if you haven't already!
arrput(objectCounter.properties, propertyCount);

// Put the counter object into the objects map, and select it.
objectIDAllocator++;
hmput(objects, objectIDAllocator, objectCounter);
selectedObjectID = objectIDAllocator;

We're going to need to be able to deallocate properties, object and the whole document. Since we don't know what property types or object types we might add as we develop our application, it's good to get these separated out into separate functions early on, or else we might create a memory leak somewhere.

void PropertyFree(Property property) {
	// Nothing to do yet! None of our property types need freeing.
}

void ObjectFree(Object object) {
	// Free each property.
	for (int i = 0; i < arrlen(object.properties); i++) {
		PropertyFree(object.properties[i]);
	}

	// And free the dynamic array used to store the properties.
	arrfree(object.properties);
}

void DocumentFree() {
	// Free each object.
	for (int i = 0; i < hmlen(objects); i++) {
		ObjectFree(objects[i].value);
	}

	// And free the hash map used to store the objects.
	hmfree(objects);
}

We're now going to make some helpers functions to help us work with objects and their properties. We start with a function to find a property in an object and return it.

Property ObjectReadAny(Object *object, const char *key) {
	// Look through each property in the object's array.
	for (int i = 0; i < arrlen(object->properties); i++) {
		// Check if the key matches.
		if (0 == strcmp(object->properties[i].key, key)) {
			// If so, return the property.
			return object->properties[i];
		}
	}

	// The property was not found.
	// Return an empty property.
	// Since this has type = 0, the caller will know it is an invalid property.
	Property empty = { 0 };
	return empty;
}

For each property type, I recommend creating a helper function to read a property of that specific type only, and otherwise return a default value. This way you won't be accidentally trying to interpret the wrong field of the union!

uint32_t ObjectReadU32(Object *object, const char *key, uint32_t defaultValue) {
	Property property = ObjectReadAny(object, key);
	return property.type == PROPERTY_U32 ? property.u32 : defaultValue;
}

Our last helper function simply returns a pointer to the selected object. Be careful with this pointer though! It is not stable if the hash map is modified, i.e. when objects are created or removed.

Object *SelectedObject() {
	return &hmgetp(objects, selectedObjectID)->value;
}

In the next article we'll start using this document model in our test application. But before we get to that, let's just call DocumentFree at the end of our main function to be tidy.

int result = MessageLoop();
DocumentFree();
return result;

Of course, you don't need to do this, but it is helpful to have it for checking for memory leaks while developing.

part17.c

Part 16: Preparing to rewrite.

Part 18: Reading state to create the UI.