Cleaning scoped pointers in C
C gives you the freedom and finer control over memory but also gives you enough chances to shoot yourself in the foot. Both allocations and de-allocations needs to be done manually. This article presents a way to reduce the chances (Of course! you can’t fully get away with it).
Pitfalls
Everyone uses malloc
and free
or some form of it. What happens when a function you’re calling allocates memory and then it does not free it? Leaaaks! Now, this can be due to several reasons, one more common one is that memory pointer is being returned to the caller for further use. Let’s look at the following code.
// A very trivial exampleint* allocator_fn(int size) { int * ptr = malloc(sizeof(int)*size); // do some initialization return ptr;}
int main() { // step 1: allocate the memory int* ptr = allocator_fn(2); // step 2: do something with ptr // step 3: free ptr free(ptr); return 0;}
Now it’s easy to see that the function allocates memory, when the function is literally named allocator_fn
but in a larger code base, these allocator functions might appear multiple times, may return pointers several call stack up and the name might not be an obvious one.
Let’s look at one more example
int maker(int decision) { int* ptr = allocator_fn(2); switch( decision ) { case 1: *ptr = 1; case 2: *(ptr+1) = 1; case 3: *(ptr+1) = 1; case 4: // invalid case for some reason return 0; } // bunch of other processing free(ptr);}
int main() { int result = maker(3); // do something return 0;}
When the value of decision
is 4
, the maker
function returns early and misses the free for the dynamically allocated ptr
.
Now, there are no smart pointers in C, neither do we have defer
mechanism like we have in Zig.
C++ has this concept of RAII1, which essentially means that when an object is created, the necessary memory (or any other resource) is allocated in the constructor and freed at the time of destruction of that object. The resource lifetime is tied to the lifetime of the object.
In the example above, the lifetime of the memory allocated by the allocator_fn
should be tied to the ptr
variable and should be cleaned up when it goes out out scope.
Fixing this in C
There is a way to get this behavior in C as as well i.e. tying the lifetime of a resource to a variable. Let’s have a look at the cleanup
compiler extension.
#include<stdio.h>
void clean(int *value) { printf("Cleanup value: %d\n", *value);}
int main() {printf("Main Begin\n");{ printf("Scope Begin\n"); int value __attribute__((cleanup(clean))) = 3; printf("Value: %d\n", value); printf("Scope End\n");}printf("Main End\n");return 0;}// Output:// Main Begin// Scope Begin// Value: 3// Scope End// Cleanup value: 3// Main End
The cleanup function is called at the time of the end of scope of the variable declared on the stack. This function takes in the pointer to the address of variable declared. We can use this mechanism, to free memory pointed by a pointer when it goes out of scope.
#include<stdio.h>#include<stdlib.h>
void clean(int **value) { printf("Cleanup value: %d\n", **value); free(*value);}
int main() {printf("Main Begin\n");{ printf("Scope Begin\n"); int* value __attribute__((cleanup(clean))) = (int*)malloc(sizeof(int)); printf("Value: %d\n", *value); printf("Scope End\n");}printf("Main End\n");return 0;}
Let’s make it look clean by using a macro.
//...#define WITH_CLEANUP_FN(fn) __attribute__((cleanup(fn)))
int main() {printf("Main Begin\n");{ int* value WITH_CLEANUP_FN(clean) = (int*)malloc(sizeof(int));}printf("Main End\n");return 0;}
Here we’re allocating memory inline using malloc
. Let’s make it look even cleaner with two functions, one that allocates memory and another one that cleans up2.
void* allocator(int n) { return malloc(sizeof(int)*n); }void* dealloc(void **ptr) { free(*ptr); *ptr = NULL; }
#define WITH_CLEANUP_FN(fn) __attribute__((cleanup(fn)))#define WITH_SCOPED_CLEANUP_FN(var, alloc, dealloc) void* var WITH_CLEANUP_FN(dealloc) = alloc
int main() {printf("Main Begin\n");{ printf("Scope Begin\n"); WITH_SCOPED_CLEANUP_FN(value, allocator(3), dealloc); printf("Value: %d\n", *(int*)value); // need to convert the pointer everywhere printf("Scope End\n");}printf("Main End\n");return 0;}
This macro is written generically (using void*
) to be used everywhere. We’d need to convert the pointer every single time. We can copy the pointer to another variable after casting but that’d open another possibility of use after free3. Ideally, we’d want to use the same pointer. Let’s make it more seamless and more readable.
void* allocator(int n) { return malloc(sizeof(int)*n); }void* dealloc(void **ptr) { free(*ptr); *ptr = NULL;}
#define WITH_CLEANUP_FN(fn) __attribute__((cleanup(fn)))#define WITH_SCOPED_CLEANUP_FN(var, alloc, dealloc) alloc; void* dummy WITH_CLEANUP_FN(dealloc) = var
int main() {printf("Main Begin\n");{ printf("Scope Begin\n"); int* value = WITH_SCOPED_CLEANUP_FN(value, allocator(3), dealloc); printf("Value: %d\n", *value); // no need to convert the pointer printf("Scope End\n");}printf("Main End\n");return 0;}
The code is a bit more readable because we can see the new pointer we’re creating explicitly. Also, there is no need to typecast it everywhere. We’re doing this by using a new dummy pointer on stack but this would be hidden away.
This can also be used for scoped resource allocation where resource like file or a socket is closed when the scope of the accessor variable end. Here’s an example
void file_cleanup(int *fd) { close(*fd); printf("File Closed!\n");}
#define WITH_CLEANUP_FN(fn) __attribute__((cleanup(fn)))#define WITH_FILE_AS(f, fname, flags) int f WITH_CLEANUP_FN(file_cleanup) = open(fname, flags);
int main() {printf("Main Begin\n");{ WITH_FILE_AS(f, "filename.txt", O_RDWR | O_CREAT); write(f, "hello", 6);}printf("Main End\n");return 0;}
Caveats
This cleanup
is part of the compiler extension supported by both gcc
and clang
. There is an alternative for MSVC
as well called try-finally
. It’d be good to use
if you’re sticking to a single platform and not writing extremely portable code (looking at you FFmpeg!).
Footnotes
-
Only if we reassign it to a larger scoped variable. If the smaller scoped variable goes out of the scope, the resource would be freed. Accessing the other pointer would be disastrous is this case. ↩