In the last few weeks I started a 3D game project working with C and SDL3, with the main objective being to learn as many concepts as I can while making some tools for myself to develop games in the future, since I’d like to use Unity less and Godot didn’t really click with me.
So one of the things I tried to implement in this project is the concept of smart pointers that are present in C++. Of course I didn’t created everything in my head, since this post from Snaipe was really helpful.
Smart pointers (at least as far as I understand them) are a way to manage memory in an automatic fashion, by checking if the block must be deallocated at the end of the current scope. Unique pointers point to memory that is always deallocated in the end of the scope, while shared and weak pointers point to blocks with more flexible lifetime.
The basic idea is to allocate a metadata struct together with the actual data you want to allocate, so when you destroy the data, you can use a generic function for data cleanup. This along side with the cleanup attribute from GCC allows for a behavior where the function is called right after the end of the scope where the variable was created:
#define ATTR_INLINE __attribute__((always_inline)) inline
#define ATTR_PURE __attribute__((pure))
#define ATTR_CLEANUP(x) __attribute__((__cleanup__(x)))
#define ATTR_MALLOC __attribute__((malloc))
#define UNIQUE_PTR(Type) ATTR_CLEANUP(Mem_FreeUnique) Type*
typedef void (*Mem_Destroy_f) (void *);
typedef struct
{
Mem_Destroy_f destructor;
} Mem_UniqueMeta_s;
ATTR_INLINE static Mem_UniqueMeta_s *Mem_GetUniqueMeta(void *ptr)
{
return (Mem_UniqueMeta_s *)((uint8_t *)ptr - sizeof(Mem_UniqueMeta_s));
}
ATTR_MALLOC void *Mem_MakeUniquePointer(size_t size, Mem_Destroy_f destructor)
{
Mem_UniqueMeta_s *meta = SDL_malloc(sizeof(Mem_UniqueMeta_s) + size);
meta->destructor = destructor;
return meta + 1;
}
void Mem_FreeUnique(void *ptr)
{
void **p = (void **)p;
if (*p == NULL) return;
Mem_UniqueMeta_s *meta = Mem_GetUniqueMeta(*p);
if (meta->destructor != NULL) meta->destructor(*p);
SDL_free(meta);
}
With this, as you can see, we already have something similar to the C++ unique_ptr behavior. However, to implement shared_ptr and weak_ptr, we’ll have to use atomic reference counting to control if we should or shouldn’t destroy the data.
If the shared reference count reaches zero, then we can use the destruction function on the actual data, but we can only free the metadata’s memory to the operating system if both the shared and weak reference counters are equal to zero.
To increase the reference counters, we’ll have one sharing function for each pointer type, one for sharing strong references, and other for sharing weak references. To decrease, we’ll do something similar to the Mem_FreeUnique function we just created, but for each pointer type. And thus, we have the following code:
#define SHARED_PTR(Type) ATTR_CLEANUP(Mem_FreeShared) Type*
#define WEAK_PTR(Type) ATTR_CLEANUP(Mem_CloseWeakReference) Type*
typedef struct
{
SDL_AtomicInt refCount;
SDL_AtomicInt weakRefCount;
Mem_Destroy_f destructor;
} Mem_SharedMeta_s;
ATTR_INLINE static Mem_SharedMeta_s *Mem_GetSharedMeta(void *ptr)
{
return (Mem_SharedMeta_s *)((uint8_t *)ptr - sizeof(Mem_SharedMeta_s));
}
ATTR_MALLOC void *Mem_MakeSharedPointer(size_t size, Mem_Destroy_f destructor)
{
Mem_SharedMeta_s *meta = SDL_malloc(sizeof(Mem_SharedMeta_s) + size);
meta->destructor = destructor;
SDL_SetAtomicInt(&(meta->refCount), 1);
SDL_SetAtomicInt(&(meta->weakRefCount), 0);
void *ptr = (void *)(meta + 1);
return ptr;
}
void *Mem_SharedReference(void *sharedPtr)
{
Mem_SharedMeta_s *meta = Mem_GetSharedMeta(sharedPtr);
SDL_AtomicIncRef(&(meta->refCount));
return sharedPtr;
}
void *Mem_WeakReference(void *sharedPtr)
{
Mem_SharedMeta_s *meta = Mem_GetSharedMeta(sharedPtr);
SDL_AtomicIncRef(&(meta->weakRefCount));
return sharedPtr;
}
void *Mem_SharedReferenceFromWeak(void *weakPtr)
{
Mem_SharedMeta_s *meta = Mem_GetSharedMeta(weakPtr);
if (SDL_GetAtomicInt(&(meta->refCount)) != 0)
{
SDL_AtomicIncRef(&(meta->refCount));
return weakPtr;
}
return NULL;
}
void Mem_FreeShared(void *ptr)
{
void **p = (void **)ptr;
if (*p == NULL) return;
Mem_SharedMeta_s *meta = Mem_GetSharedMeta(*p);
SDL_AtomicDecRef(&(meta->refCount));
if (SDL_GetAtomicInt(&(meta->refCount)) == 0)
{
if (meta->destructor != NULL) meta->destructor(*p);
if (SDL_GetAtomicInt(&(meta->weakRefCount)) == 0)
{
SDL_free(meta);
}
}
}
void Mem_CloseWeakReference(void *ptr)
{
void **p = (void **)ptr;
if (*p == NULL) return;
Mem_SharedMeta_s *meta = Mem_GetSharedMeta(*p);
SDL_AtomicDecRef(&(meta->weakRefCount));
if (SDL_GetAtomicInt(&(meta->weakRefCount)) == 0 && SDL_GetAtomicInt(&(meta->refCount)) == 0)
{
SDL_free(meta);
}
}
Note that we’re using the SDL_AtomicInt structure as the reference counters. This is because when we use this on multithreaded code, we can make sure that we avoid race conditions while accessing from different pointers. In my current project, I intend to use these smart pointers to control the lifetime of manager structures, or to give references to data that needs to be accessed by multiple systems, but more about this in future posts.
Leave a Reply