Deferred Promises: Simplifying Resource Management in C

Have you ever forgotten to free resources you used in your C program and experienced the excruciating pain of debugging memory leaks? You’re not alone. Manual memory management in C is a demanding task that often results in headaches for developers.

Imagine being in the middle of complex C code, manipulating a handful of resources, and then realizing you’ve forgotten to free them. Memory leaks are always nasty and often difficult to track down. Hence, it's essential to have an efficient way to handle the resources and free them after their work is done. But how can we make this memory management process simpler and pain-free?

If you wish there was an easier way out, you might find the concept of deferred promises a godsend solution. Unlike the typical usage of the term ‘deferred promises’ found in JavaScript and other related languages, I am using it here to explain a unique methodology for managing resources in your C programs.

In this post, we would explore an approach that uses macros to handle resource allocation and deallocation in a C program, reducing the chances of memory leaks.

Essentially, we define four macro functions that assist in resource management: scope, defer, defer_exit & defer_error.

How it Works

Let’s dive headfirst into the core of it.

1. scope { … }: This macro function creates four variables for tracking destructors, error statuses, and resource deallocation. It initiates a scope where resources can be properly managed.

2. defer(variable, { value }, destructor): The defer macro is where the magic happens. It allows you to allocate resources and define how to dispose of them when the scope is exited. The variable name, resource value, and destructor code block are passed as arguments.

3. defer_exit { … }: As an aptly named macro, defer_exit handles the execution of all the declared destructors within the scope when it is exited.

4. defer_error { … }: This macro function is a safeguard. It declares a label that the program jumps to in case of errors within the defined scope.

Let’s look at an usage example:

#include <stdio.h>
#include <stdlib.h>
#include <cdefer.h>

struct bignum {
  int *data;
};

int main(int argc, char *argv[]) {
  scope {
    struct bignum defer(googol, {.data = malloc(sizeof(int) * 10)},
                        printf("free googol number %d\n", *googol.data); free(googol.data));
    struct bignum defer(pi, {.data = malloc(sizeof(int) * 10)},
                        printf("free pi number %d\n", *pi.data); free(pi.data););
    googol.data[0] = 10;
    pi.data[0] = 3;
  }
  defer_exit { return 0; }
  defer_error { return 1; }
}

In this section of code, defer macro is used to declare and assign two variables googol and pi of type bignum. Each of them is allocated a chunk of memory for an array of integers. Corresponding destructors are defined that print the first element of that array and free up the allocated memory.

When the scope is exited at the end of the main function, defer_exit macro handles the responsibility of calling the destructors for both googol and pi. If something goes wrong within this scope, the program reacts by returning an error code of 1, as defined with defer_error macro.

A Closer Look at the Implementation

If you’re like me, you already know that explaining a concept without a concrete example is like trying to describe a rainbow to someone who has never seen colors.

Let’s take a closer look at how scope and defer macros are implemented in this deferred promises technique.

Scoped Struct Variables

#define scope                                                                  \
  void *destructors[MAX_STACK_SIZE];                                           \
  unsigned int destructor_index = 0;                                           \
  unsigned int defer_error_no = 0;                                             \
  unsigned int defer_runtime = 0;

When you initiate a scope, four variables are defined. We have an array of void pointers destructors with the size of MAX_STACK_SIZE, think of this as a memory stack containing the destructors’ addresses. Then we have destructor_index, a counter to store the index of the last destructor added to the stack. We also have defer_error_no to handle error detection and defer_runtime for runtime checks.

The defer Macro

#define defer(name, value, destructor)                                         \
  name = value;                                                                \
  if (defer_runtime) {                                                         \
    destructor_##name : destructor;                                            \
    goto defer_entry_freed;                                                    \
  }                                                                            \
  destructors[destructor_index] = &&destructor_##name;                         \
  destructor_index += 1

The defer macro takes three parameters: name, value, and destructor. The name and value parameters are used to declare a variable with the given value.

Next, the defer_runtime flag is checked. If it is set, the destructor is called, and the program jumps to defer_entry_freed. This step is crucial for ensuring that destructors are called when the scope is exited.

Otherwise, if defer_runtime is not set, the address of the destructor is added to the destructors array at the current destructor_index, and the index is then incremented.

The defer_runtime flag probably raised your eyebrow, didn’t it? You may be wondering why it exists, considering the fact that we initialize it to zero and never alter its value in the macros. Well, its existence can be justified in a broader context where runtime checks are needed before destructors are run. However, in the current simplistic use-case, it doesn’t play a significant part.

The inclusion of goto defer_entry_freed might seem odd at first but it plays an important role in cleanup during early exits.

The defer_exit Macro

#define defer_exit                                                             \
  while (destructor_index > 0) {                                               \
    goto *destructors[destructor_index - 1];                                   \
  defer_entry_freed:                                                           \
    destructor_index -= 1;                                                     \
  }                                                                            \
  if (defer_error_no)                                                          \
    goto defer_error_code;

The defer_exit macro calls all registered destructors when the scope is to be exited. It achieves that by walking through the destructors array in a stack-like manner, going in reverse order from the last registered destructor.

We first check if the destructor_index is greater than 0. If yes, we use goto *destructors[destructor_index — 1]to jump to the location (destructor function) pointed to by the void pointer. This is where the magic happens — it’s an elegant use of computed goto, a GNU extension, to dynamically jump to destructor functions.

We then hit a label defer_entry_freed which serves as an entry point to reduce the destructor_index by 1. And the process repeats until no destructor is left to run.

And finally, if the defer_error_no flag is set indicating an error within the scope, we jump to the defer_error_codelabel, which should ideally take us to an error handling section of the code.

The defer_error Macro

#define defer_error                                                            \
  defer_error_code:                                                            \
  if (defer_error_no)

The defer_error macro is pretty simple. It merely provides a defer_error_code label that serves as an entry point for handling errors. If defer_error_no flag is set, it should ideally trigger the error handling code that follows this macro in actual use.

The complete setup ensures that all allocated resources are cleaned up brilliantly — irrespective of normal execution, forced exits (like returns or breaks), or even error scenarios.

Overall Impression

Certainly, ensuring perfect memory management has ever been the challenge for all C programmers, but here, with just four macros, we have been given a flexible and powerful tool that shelters us from the pain of forgetting to free up our resources.

The beauty of this method lies in the synergy between all four macros. Performed inside the ‘scope’, defer helps manage resources within the program and handle cleanup when exiting the scope using defer_exit. If an error occurs, defer_error is right there to step in.

This kind of deferred cleanup and error jumping makes your code safer, less error-prone and much easier to understand and debug. It certainly takes a step forward in making C a bit more user-friendly when it comes to manual memory management.

Try It Out!

Feel intrigued by this approach? You’ll find examples and tests for this approach in the C Deferred Promises repository, offering you a playground to explore this technique to its fullest potential.

Remember, the best way to understand a new concept is to implement it yourself. Who knows, by trying this out, you may stumble upon opportunities to enhance it, making the system even more robust.