A Guide to Modern C
This page is an introduction to a few C features that I want to see people use more often.
Nothing on this page will be new to you unless you are new to C. That said, a lot of people blindly copy geriatric practices without thinking about it, so my hope is that this convinces a few people to make use of modern conveniences.
If anything here is useful, you can learn way more by checking out cppreference.com or some of the other resources.
Defer keyword
I am beginning with the big and controversial one. There is a technical specification (TS 25755) for a defer keyword in C. Although it is not currently part of the standard itself, you can use it in Clang.
If you’re not sure what a defer statement does, it just causes code to execute when the current code block finishes. For example, lot of C projects use the goto statement for cleanup.
int foo() {
int ret = 0;
struct foo_s foo;
if (foo_init(&foo) != 0)
return 1;
if (foo_bar(&foo) != 0) {
ret = 1;
goto end;
}
if (foo_baz(&foo) != 0) {
ret = 1;
goto end;
}
end:
foo_finish(&foo);
return ret;
}
With defer, you can do something like this instead.
#include <stddefer.h>
int foo() {
int ret = 0;
struct foo_s foo;
if (foo_init(&foo) != 0)
return 1;
defer foo_finish(&foo);
if (foo_bar(&foo) != 0)
return 1;
if (foo_baz(&foo) != 0)
return 1;
return 0;
}
At least for me, this is much easier to stay on top of, especially for longer functions.
Compiler support isn’t that widespread yet, but it is usable with a compiler flag (-fdefer-ts) as of Clang 22, so you can use it in Fedora 44, Gentoo, and (probably) your favourite rolling-release distribution. There is a patch for GCC as well, but I don’t know how it’s going along. There are also a few macro tricks that can be used for compilers that do not support the technical specification.
Cleanup attributes
It’s not part of ISO C, but all compilers worth their salt support the cleanup attribute that basically lets you run a callback when a variable goes out of scope. However, a lot of people don’t use it for whatever reason, so it’s made the list. I’ll include a full example so you can try this in your compiler of choice.
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
void free_cb(uint8_t **ptr) {
free(*ptr);
}
int main() {
__attribute__((cleanup(free_cb))) uint8_t *data = calloc(1, 4);
data[0] = 'a';
data[1] = 'b';
data[2] = 'c';
fprintf(stdout, "Result: %s\n", data);
}
If you run it with valgrind, you’ll notice that there are no memory leaks. You might notice that this mostly handles the same use case as defer, and you would be correct. However, unlike defer, compilers have supported cleanup attributes as a non-standard feature for decades, so they are pretty common.
In addition, note that there’s no “return 0;” at the end of the function either. That is not a mistake. Among other special properties, the main function automatically returns 0 at the end.
Fixed-width types
For misguided historical reasons, the number of bytes in the C data types is implementation-dependent, so sizeof(int) isn’t guaranteed to be 4 bytes, nor is sizeof(char) guaranteed to be 1 byte. The actual sizes are defined by the platform instead.
This is rarely a problem in practice, but being able to explicitly specify the size of a data type is nice. This is what fixed-width types (stdint.h) are for. They’ve been around since C99 and let you explicitly specify how many bits are in a number. Pretty much all modern codebases either use these types or define their own equivalent types, but I continue to see people using legacy data types.
__FILE__ and __LINE__ macros
This is far from a “modern feature”, but I am including it anyway. The C preprocessor has __FILE__ and __LINE__ macros that, as described on the tin, expand to the file and line names. This is very useful for producing verbose error messages. For example, I often find myself doing something like below.
#define verbose_printf(...) \
{ \
fprintf(stderr, "%s:%d: ", __FILE__, __LINE__); \
fprintf(stderr, __VA_ARGS__); \
}
This is useful for obvious reasons. I’ve seen people hardcode numbers in their print statements so they can identify where their programs failed, and it’s a huge waste of time. Please do this instead.
Virtual functions
These aren’t modern either, but when writing C, virtual functions let you abstract away implementation details with a generalized interface. This can be used to replace various cases where you’d otherwise need to either keep track of multiple types or abuse switch statements.
struct buffer_mmap {
int fd; /* An internal variable */
};
struct buffer {
uint8_t *data;
uint32_t size;
void (*finish)(struct buffer *);
union {
struct buffer_mmap mmap;
}
};
void buffer_finish(struct buffer *target) {
free(target->data);
}
bool buffer_init(struct buffer *out, uint32_t size) {
char *data = calloc(1, size);
if (data == NULL)
return false;
struct buffer buffer = {
.data = data,
.size = size,
.finish = &buffer_finish
*out = buffer;
return true;
}
void buffer_mmap_finish(struct buffer *target) {
munmap(target->data, target->size);
close(target->mmap.fd);
}
bool buffer_mmap_init(struct buffer *out, char *file_path) {
int fd = 0;
/* Open a file, set up an mmap, etc. */
struct buffer buffer_mmap = {
.data = data,
.size = size,
.finish = &buffer_mmap_finish,
.mmap {
.fd = fd
}};
*out = buffer_mmap;
return true;
}
bool main() {
struct buffer buf1;
if (!buffer_init(&buf1, 4))
return false;
defer buf1.finish(&buf1);
buf1.data[0] = 'H';
buf1.data[1] = 'i';
buf1.data[2] = '!';
fprintf(stdout, "\"%.*s\"", buf1.size, buf1.data);
struct buffer buf2;
if (!buffer_mmap_init(&buf2, "foo.txt"))
return false;
defer file_buffer.finish(&buf2);
fprintf(stdout, "\"%.*s\"", buf2.size, buf2.data);
/* No extra cleanup needed. */
}
I did not test any of the code above, but it should be pretty much correct. As you can see, even though the file buffer and generic buffer involve different cleanup, using a virtual function (function pointer) makes it possible to handle them in the exact same way. This is useful if you want to pass an array of buffers with varying types to a function (or something of the sort). Virtual functions, when used in the right places, can save a ton of pain writing unnecessary code to perform identical operations on related data types.
Takeaway
I completely understand why people use C. You can just hammer out 1000 lines of code without needing to think about the language, and the result will run on nearly any device and continue to work more-or-less indefinitely.
That said, a significant chunk of C programmers are averse to certain patterns because they don’t want to admit that their code is getting old. Still, the largest major projects (Linux included) make a point of using modern practices in their C codebases, so I hope that people new to the language adopt these features. I don’t expect that they will though, and I’m mostly writing this page to vaguely complain about codebases that seemingly try to target C89 for no reason.
Other resources
- cppreference.com: An excellent reference for C.
- The Pasture: The blog of JeanHeyd Meneide (the Project Editor for the C standard working group).
- 21st Century C: A book by Ben Klemens that explains modern C way better than I can.