Intentionally or not, this post demonstrates one of the things that makes safer abstractions in C less desirable: the shared pointer implementation uses a POSIX mutex, which means it’s (1) not cross platform, and (2) pays the mutex overhead even in provably single-threaded contexts. In other words, it’s not a zero-cost abstraction.
C++’s shared pointer has the same problem; Rust avoids it by having two types (Rc and Arc) that the developer can select from (and which the compiler will prevent you from using unsafely).
> the shared pointer implementation uses a POSIX mutex [...] C++’s shared pointer has the same problem
It doesn't. C++'s shared pointers use atomics, just like Rust's Arc does. There's no good reason (unless you have some very exotic requirements, into which I won't get into here) to implement shared pointers with mutexes. The implementation in the blog post here is just suboptimal.
(But it's true that C++ doesn't have Rust's equivalent of Rc, which means that if you just need a reference counted pointer then using std::shared_ptr is not a zero cost abstraction.)
The primary exotic thing I can imagine is an architecture lacking the ability to do atomic operations. But even in that case, C11 has atomic operations [1] built in. So worst case, the C library for the target architecture would likely boil down to mutex operations.
Unfortunately, for C++, thats not true. At least with glibc and libstdc++, if you do not link with pthreads, then shared pointers are not thread-safe. At runtime it will do a symbol lookup for a pthreads symbol, and based off the result, the shared pointer code will either take the atomic or non-atomic path.
I'd much rather it didnt try to be zero-cost and it always used atomics...
True, but that's a fault of the implementation, which assumes POSIX is the only thing in town & makes questionable optimization choices, rather that of the language itself
The number of times I might want to write something in C and have it less likely to crash absolutely dwarfs the number of times I care about that code being cross-platform.
Sure, cross-platform is desirable, if there's no cost involved, and mandatory if you actually need it, but it's a "nice to have" most of the time, not a "needs this".
As for mutex overheads, yep, that's annoying, but really, how annoying ? Modern CPUs are fast. Very very fast. Personally I'm far more likely to use an os_unfair_lock_t than a pthread_mutex_t (see the previous point) which minimizes the locking to a memory barrier, but even if locking were slow, I think I'd prefer safe.
Rust is, I'm sure, great. It's not something I'm personally interested in getting involved with, but it's not necessary for C (or even this extra header) to do everything that Rust can do, for it to be an improvement on what is available.
There's simply too much out there written in C to say "just use Rust, or Swift, or ..." - too many libraries, too many resources, too many tutorials, etc. You pays your money and takes your choice.
> As for mutex overheads, yep, that's annoying, but really, how annoying ?
For this use-case, you might not notice. ISTR, when examing the pthreads source code for some platform, that mutexes only do a context switch as a fallback, if the lock cannot be acquired.
So, for most use-cases of this header, you should not see any performance impact. You'll see some bloat, to be sure.
> There's simply too much out there written in C to say "just use Rust, or Swift, or ..." - too many libraries, too many resources, too many tutorials, etc.
There really isn't. Speaking as someone who works in JVM-land, you really can avoid C all the time if you're willing to actually try.
> Intentionally or not, this post demonstrates one of the things that makes safer abstractions in C less desirable: the shared pointer implementation uses a POSIX mutex, which means it’s (1) not cross platform, and (2) pays the mutex overhead even in provably single-threaded contexts. In other words, it’s not a zero-cost abstraction.
It's an implementation detail. They could have used atomic load/store (since c11) to implement the increment/decrement.
TBH I'm not sure what a mutex buys you in this situation (reference counting)
I'd think a POSIX mutex--a standard API that I not only could implement anywhere, but which has already been implemented all over the place--is way more "cross platform" than use of atomics.
To lift things up a level: I think a language’s abstractions have failed if we even need to have a conversation around what “cross platform” really means :-)
If you're targeting a vaguely modern C standard, atomics win by being part of the language. C11 has atomics and it's straightforward to use them to implement thread-safe reference counting.
Rust pays the cumbersome lifetime syntax tax even in provably single threaded contexts. When will Rust develop ergonomics with better defaults and less boilerplate in such contexts?
Tecnhically the mutex refcounting example is shown as an example of the before the header the author is talking about. We don't know what they've chosen to implement shared_ptr with.
A recent superpower was added by Fil aka the pizlonator who made C more Fil-C with FUGC, a garbage collector with minimal adjustments to existing code, turning it into a memory safe implementation of the C and C++ programming languages you already know and love.
Because about 99% of the time the garbage collect is a negligible portion of your runtime at the benefit of a huge dollop of safety.
People really need to stop acting like a garbage collector is some sort of cosmic horror that automatically takes you back to 1980s performance or something. The cases where they are unsuitable are a minority, and a rather small one at that. If you happen to live in that minority, great, but it'd be helpful if those of you in that minority would speak as if you are in the small minority and not propagate the crazy idea that garbage collection comes with massive "performance penalties" unconditionally. They come with conditions, and rather tight conditions nowadays.
IDK about Fil-C, but in Java garbage collector actually speeds up memory management compared to C++ if you measure the throughput. The cost of this is increased worst-case latency.
A CLI tool (which most POSIX tools are) would pick throughput over latency any time.
This feels like a misrepresentation of features that actually matter for memory safety. Automatically freeing locals and bounds checking is unquestionably good, but it's only the very beginning.
The real problems start when you need to manage memory lifetimes across the whole program, not locally. Can you return `UniquePtr` from a function? Can you store a copy of `SharedPtr` somewhere without accidentally forgetting to increment the refcount? Who is responsible for managing the lifetimes of elements in intrusive linked lists? How do you know whether a method consumes a pointer argument or stores a copy to it somewhere?
I appreciate trying to write safer software, but we've always told people `#define xfree(p) do { free(p); p = NULL; } while (0)` is a bad pattern, and this post really feels like more of the same thing.
It is surprisingly hard to find information about it, do you have any ? From what I can guess it's a new syntax but it's the feature itself is still an extension ?
The `[[attribute]]` syntax is new, the builtin ones in C23 are `[[deprecated]]`, `[[fallthrough]]`, `[[maybe_unused]]`, `[[nodiscard]]`, `[[noreturn]]`, `[[reproducible]]`, and `[[unsequenced]]`.
C++: "look at what others must do to mimic a fraction of my power"
This is cute, but also I'm baffled as to why you would want to use macros to emulate c++. Nothing is stopping you from writing c-like c++ if that's what you like style wise.
It's interesting to me to see how easily you can reach a much safer C without adding _everything_ from C++ as a toy project. I really enjoyed the read!
Though yes, you should probably just write C-like C++ at that point, and the result sum types used made me chuckle in that regard because they were added with C++17. This person REALLY wants modern CPP features..
> I'm baffled as to why you would want to use macros to emulate c++.
I like the power of destructors (auto cleanup) and templates (generic containers). But I also want a language that I can parse. Like, at all.
C is pretty easy to parse. Quite a few annoying corner cases, some context sensitive stuff, but still pretty workable. C++ on the other hand? It’s mostly pick a frontend or the highway.
No name mangling by default, far simpler toolchain, no dependence on libstdc++, compiles faster, usable with TCC/chibicc (i.e. much more amenable to custom tooling, be it at the level of a lexer, parser, or full compiler).
C’s simplicity can be frustrating, but it’s an extremely hackable language thanks to that simplicity. Once you opt in to C++, even nominally, you lose that.
I highly doubt (and some quick checks seem to verify that) any of the tiny CC implementations will support the cleanup extension that most of this post's magic hinges upon.
Yup. And I like the implication that Rust is 'cross platform', when it's 'tier 1' support consists of 2 architectures (x86 & arm64). I guess we're converging on a world where those 2 + riscv are all that matter to most people, but it's not yet a world where they are all that matter to all people.
In my experience most chips released in the past 10+ years ship with C++ compilers.
Quite frankly I'm not sure why you wouldn't given that most are using GCC on common architectures. The chip vendor doesn't have to do any work unless they are working on an obscure architecture.
How can anyone be this interested in maintaining an annex k implementation when it's widely regarded as a design failure, specially the global constraint handler. There's a reason why most C toolchains don't support it.
FWIW, it's heavily used inside Microsoft and is actually pretty nice when combined with all the static analysis tools that are mandatory parts of the dev cycle.
The problem with macro-laden C is that your code becomes foreign and opaque to others. You're building a new mini-language layer on top of the base language that only your codebase uses. This has been my experience with many large C projects: I see tons of macros used all over the place and I have no idea what they do unless I hunt down and understand each one of them.
Macros are simply a fact of life in any decent-sized C codebase. The Linux kernel has some good guidance to try to keep it from getting out of hand but it is just something you have to learn to deal with.
I think this can be fine if the header provides a clean abstraction with well-defined behaviour in C, effectively an EDSL. For an extreme example, it starts looking like a high-level language:
Nim is a language that compiles to C. So it is similar in principle to the "safe_c.h". We get power and speed of C, but in a safe and convenient language.
> It's finally, but for C
Nim has `finally` and `defer` statement that runs code at the end of scope, even if you raise.
> memory that automatically cleans itself up
Nim has ARC[1]:
"ARC is fully deterministic - the compiler automatically injects destructors when it deems that some variable is no longer needed. In this sense, it’s similar to C++ with its destructors (RAII)"
> automated reference counting
See above
> a type-safe, auto-growing vector.
Nim has sequences that are dynamically sized, type and bounds safe
> zero-cost, non-owning views
Nim has openarray, that is also "just a pointer and a length", unfortunately it's usage is limited to parameters.
But there is also an experimental view types feature[2]
> explicit, type-safe result
Nim has `Option[T]`[3] in standard library
> self-documenting contracts (requires and ensures)
Nim's assert returns message on raise: `assert(foo > 0, "Foo must be positive")`
> safe, bounds-checked operations
Nim has bounds-checking enabled by default (can be disabled)
> The UNLIKELY() macro tells the compiler which branches are cold, adding zero overhead in hot paths.
This won't play along with setjmp/longjmp exception handling schemes, unfortunately, without a lot of extra effort.
You can do cleanup handling that integrates with your exception handling library by using pairs of macros inspired by the POSIX pthread_cleanup_push stuff.
#define cleanup_push(fn, type, ptr, init) { \
cleanup_node_t node; \
type ptr = init; do cleanup_push_api(&node, fn, ptr) while (0)
#define cleanup_pop(ptr) cleanup_pop_api(ptr) \
}
cleanup_push_api places a cleanup node into the exception stack. This is allocated on the stack: the node object. If an exception goes off, that node will be seen by the exception handling which will call fn(ptr).
The cleanup_pop_api call removes the top node, doing a sanity check that the ptr in the node is the same as ptr. It calls fn(ptr).
The fact that cleanup_push leaves an open curly brace closed by cleanup_pop catches some balancing errors at compile time.
The POSIX pthread_cleanup_pop has an extra boolean parameter indicating whether to do the cleanup call or not. That's sometimes useful when the cleanup is only something done in the case of an abort. E.g. suppose tha the "cleanup" routine is rollback_database_transaction. We don't want that in the happy case; in the happy case we call commit_database_transaction.
Nice, but if the intention is portability my experience has unfortunately been that you pretty much have to stick to C99. MSVC’s C compiler is rough, but pretty much necessary for actual cross platform. I have my own such header which has many, many things like the OP’s. As much as I would find it constantly useful, I don’t have a cleanup utility because of this.
But if you can stay out of MSVC world, awesome! You can do so much with a few preprocessor blocks in a header
That's the nice thing about macros, you can also have the macro generate C++ code using destructors instead of using the cleanup attribute. As long as your other C code is also valid C++ code, it should work.
This is a great example of how ADTs can be implemented in C by emulating classes, despite the loss in brevity.
For the first item on reference counting, batched memory management is a possible alternative that still fits the C style. The use of something like an arena allocator approximates a memory lifetime, which can be a powerful safety tool. When you free the allocator, all pages are freed at once. Not only is this less error prone, but it can decrease performance. There’s no need to allocate and free each reference counted pointer, nor store reference counts, when one can free the entire allocator after argument parsing is done.
This also decreases fallible error handling: The callee doesn’t need to free anything because the allocator is owned by the caller.
Of course, the use of allocators does not make sense in every setting, but for common lifetimes such as: once per frame, the length of a specific algorithm, or even application scope, it’s an awesome tool!
> This is a great example of how ADTs can be implemented in C by emulating classes, despite the loss in brevity.
I don't see it that way, mostly because ADTs don't require automatic destructors or GC, etc, but also because I never considered a unique/shared pointer type to be an abstract data type
> When you free the allocator, all pages are freed at once. Not only is this less error prone, but it can decrease performance.
How does it decrease performance? My experience with arenas is that they increase performance at the cost of a little extra memory usage.
Given how C was seen in the past, before there was a change of heart to add C11/C17, minus atomics and aligned memory allocators (still between experimental or not going to happen),
Does the StringView/Span implementation here seem lacking? If the underlying data goes away, wouldn't the pointer now point to an invalid freed location, because it doesn't track the underlying data in any way?
That's what std::string_view and std::span are, though. They're views / borrows over the underlying data, minus the kind of protections something like Rust has about lifetimes of the underlying data vs the borrow.
The benefit of these types is that they're a pair of pointer+size, instead of just a bare pointer without a known size.
True, I guess I was expecting if you were reimplementing these with additional protections, why not add the protection using the custom pointer reimplementations to ensure no use after frees. Seems like a missed opportunity.
I think because it's called `safe_c.h` and is "designed to give C some safety and convenience features from ... Rust", and says "The Memory Management Beast: Slain", you expected to see some level of memory safety, Rust's headline selling point. I did too when I opened the article. But it doesn't at all. Those phrases are all clickbait and slop.
In fact I don't see anything here from Rust that isn't also in C++. They talk about Result and say "Inspired by Rust, Result forces you to handle errors explicitly by returning a type that is either a success value or an error value", but actually, unlike Rust, nothing enforces that you don't just incorrectly use value without checking status first.
It's just some macros the author likes. And strangely presented—why are the LIKELY/UNLIKELY macros thrown in with the CLEANUP one in that first code snippet? That non sequitur is part of what gives me an LLM-written vibe.
I feel like there might be some value in this header file for some projects, but this is exactly the wrong use case.
> In cgrep, parsing command-line options the old way is a breeding ground for CVEs and its bestiary. You have to remember to free the memory on every single exit path, difficult for the undisciplined.
No, no, no. Command line options that will exist the entire lifetime of the program are the quintessential case for not ever calling free() on them because it's a waste of time. There is absolutely no reason to spend processor cycles to carefully call free() on a bunch of individual resources when the program is about to exit and the OS will reclaim the entire process memory in one go much faster than your program can. You're creating complexity and making your program slower and there is literally no upside: this isn't a tradeoff, it's a bad practice.
Sure. And the rare programmer reading this who is still using that OS knows they're the exception to the rule. To everyone else that fact is irrelevant.
I think that a blanket should/shouldn't recommendation for arenas isn't right. Arenas are a tradeoff:
Pros: preallocating one arena is likely faster than many smaller allocations.
Cons: preallocation is most effective if you can accurately predict usage for the arena; if you can't, then you either overshoot and allocate more memory than you need, or undershoot and have to reallocate which might be less performant than just allocating as-needed.
In short, if you're preallocating, I think decisions need to be made based on performance testing and the requirements of your program (is memory usage more important than speed?). If you aren't preallocating and just using arenas for to free in a group, then I'm going to say using an arena for stuff that is going to be freed by the OS at program exit is adding complexity for no benefit--it depends on your arena implementation (arenas aren't in the C standard to my knowledge).
In general, I'd be erring on the side of simplicity here and not using arenas for this by default--I'd only consider adding arenas if performance testing shows the program spending a lot of time in individual allocations. So I don't think a blanket recommendation for arenas is a good idea here.
EDIT: In case it's not obvious: note that I'm assuming that the reason you want to use an arena is to preallocate. If you're thinking that you're going to call free on the arena on program exit, that's just as pointless as calling free on a bunch of individual allocations on program exit. It MIGHT be faster, but doing pointless things faster is still not as good as not doing pointless things.
Fil-C essentially lifts C onto a managed, garbage-collected runtime. This is a small header that adds some C++ features to C.
(These features are still essentially unsafe: the unique pointer implementation still permits UAF, for example, because nothing prevents another thread from holding the pointer and failing to observe that it has been freed.)
Just use C++. Every single one of these is a horror-show worse reinvention of a C++ feature.
Like, if I was stuck in a C codebase today, [[cleanup]] is great -- I've used this in the past in a C-only shop. But vectors, unique_ptrs, and (awful) shared_ptrs? Just use C++.
I am not convinced. I much prefer using plain C without superpowers and write extensive test suite and analyse the code multiple times. There is always a chance to miss something, but code without "magic" is much easier to reason about.
// The Old Way (don't do this)
char* include_pattern = NULL;
if (optarg) {
include_pattern = strdup(optarg);
}
// ...200 lines later...
if (some_error) {
if (include_pattern) free(include_pattern); // Did I free it? Did I??
return 1;
Nope!
// ...200 lines later...
// common return block
out:
free(include_pattern); // free(NULL) allowed since before 1989 ANSI C
return result;
Nice toy. It works until it stops working. An experienced C developer would quickly find a bunch of corner cases where this just doesn't work.
Given how simple examples in this blog post are, I ask myself, why don't we already have something like that as a part of the standard instead of a bunch of one-off personal, bug-ridden implementations?
It's just, I'd rather play with my own toys instead of using someone else's toy. Especially since I don't think it would ever grow up to be something more than a toy.
For serious work, I'd use some widely used, well-maintained, and battle-tested library instead of my or someone else's toy.
Yeah, kids like to waste time to make C more safe or bring C++ features.
If you need them, use C++ or different language. Those examples make code look ugly and you are right, the corner cases.
If you need to cleanup stuff on early return paths, use goto.. Its nothing wrong with it, jump to end when you do all the cleanup and return.
Temporary buffers? if they arent big, dont be afraid to use static char buf[64];
No need to waste time for malloc() and free. They are big? preallocate early and reallocate or work on chunk sizes. Simple and effective.
My thoughts as well. The only thing I would be willing to use is the macro definition for __attribute__, but that is trivial. I use C, because I want manual memory handling, if I wouldn't want that I would use another language. And now I don't make copies when I want to have read access to some things, that is simply not at a problem. You simply pass non-owning pointers around.
In a function? That makes the function not-threadsafe and the function itself stateful. There are places, where you want this, but I would refrain from doing that in the general case.
God forbid we should make it easier to maintain the existing enormous C code base we’re saddled with, or give devs new optional ways to avoid specific footguns.
I consider code written in Frama-C as a verifiable C dialect, like SPARK is to Ada, rather than C proper. I find it funny how standard C is an undefined-behaviour minefield with few redeeming qualities, but it gets some of the best formal verification tools around.
Intentionally or not, this post demonstrates one of the things that makes safer abstractions in C less desirable: the shared pointer implementation uses a POSIX mutex, which means it’s (1) not cross platform, and (2) pays the mutex overhead even in provably single-threaded contexts. In other words, it’s not a zero-cost abstraction.
C++’s shared pointer has the same problem; Rust avoids it by having two types (Rc and Arc) that the developer can select from (and which the compiler will prevent you from using unsafely).
> the shared pointer implementation uses a POSIX mutex [...] C++’s shared pointer has the same problem
It doesn't. C++'s shared pointers use atomics, just like Rust's Arc does. There's no good reason (unless you have some very exotic requirements, into which I won't get into here) to implement shared pointers with mutexes. The implementation in the blog post here is just suboptimal.
(But it's true that C++ doesn't have Rust's equivalent of Rc, which means that if you just need a reference counted pointer then using std::shared_ptr is not a zero cost abstraction.)
To be clear, the “same problem” is that it’s not a zero-cost abstraction, not that it uses the same specific suboptimal approach as this blog post.
12 replies →
> very exotic requirements
I'd be interested to know what you are thinking.
The primary exotic thing I can imagine is an architecture lacking the ability to do atomic operations. But even in that case, C11 has atomic operations [1] built in. So worst case, the C library for the target architecture would likely boil down to mutex operations.
[1] https://en.cppreference.com/w/c/atomic.html
13 replies →
Unfortunately, for C++, thats not true. At least with glibc and libstdc++, if you do not link with pthreads, then shared pointers are not thread-safe. At runtime it will do a symbol lookup for a pthreads symbol, and based off the result, the shared pointer code will either take the atomic or non-atomic path.
I'd much rather it didnt try to be zero-cost and it always used atomics...
True, but that's a fault of the implementation, which assumes POSIX is the only thing in town & makes questionable optimization choices, rather that of the language itself
(for reference, the person above is referring to what's described here: https://snf.github.io/2019/02/13/shared-ptr-optimization/)
2 replies →
This is, impressively, significantly worse than I realized!
Why use atomics if you don't need them? There really should just be two different shared pointer types.
1 reply →
The number of times I might want to write something in C and have it less likely to crash absolutely dwarfs the number of times I care about that code being cross-platform.
Sure, cross-platform is desirable, if there's no cost involved, and mandatory if you actually need it, but it's a "nice to have" most of the time, not a "needs this".
As for mutex overheads, yep, that's annoying, but really, how annoying ? Modern CPUs are fast. Very very fast. Personally I'm far more likely to use an os_unfair_lock_t than a pthread_mutex_t (see the previous point) which minimizes the locking to a memory barrier, but even if locking were slow, I think I'd prefer safe.
Rust is, I'm sure, great. It's not something I'm personally interested in getting involved with, but it's not necessary for C (or even this extra header) to do everything that Rust can do, for it to be an improvement on what is available.
There's simply too much out there written in C to say "just use Rust, or Swift, or ..." - too many libraries, too many resources, too many tutorials, etc. You pays your money and takes your choice.
That’s all reasonable, but here’s one of the primary motivations from the post:
> We love its raw speed, its direct connection to the metal
If this is a strong motivating factor (versus, say, refactoring risk), then C’s lack of safe zero-cost abstractions is a valid concern.
> As for mutex overheads, yep, that's annoying, but really, how annoying ?
For this use-case, you might not notice. ISTR, when examing the pthreads source code for some platform, that mutexes only do a context switch as a fallback, if the lock cannot be acquired.
So, for most use-cases of this header, you should not see any performance impact. You'll see some bloat, to be sure.
> There's simply too much out there written in C to say "just use Rust, or Swift, or ..." - too many libraries, too many resources, too many tutorials, etc.
There really isn't. Speaking as someone who works in JVM-land, you really can avoid C all the time if you're willing to actually try.
1 reply →
> Intentionally or not, this post demonstrates one of the things that makes safer abstractions in C less desirable: the shared pointer implementation uses a POSIX mutex, which means it’s (1) not cross platform, and (2) pays the mutex overhead even in provably single-threaded contexts. In other words, it’s not a zero-cost abstraction.
It's an implementation detail. They could have used atomic load/store (since c11) to implement the increment/decrement.
TBH I'm not sure what a mutex buys you in this situation (reference counting)
I'd think a POSIX mutex--a standard API that I not only could implement anywhere, but which has already been implemented all over the place--is way more "cross platform" than use of atomics.
To lift things up a level: I think a language’s abstractions have failed if we even need to have a conversation around what “cross platform” really means :-)
4 replies →
If you're targeting a vaguely modern C standard, atomics win by being part of the language. C11 has atomics and it's straightforward to use them to implement thread-safe reference counting.
> the shared pointer implementation uses a POSIX mutex
Do you have a source for this? I couldn't find the implementation in TFA nor a link to safe_c.h
The shared-pointer implementation isn’t actually shown (i.e. shared_ptr_copy), and the SharedPtr type doesn’t use a pthread_mutex_t.
C11 has a mutex API (threads.h), so why would it rely on POSIX? Are you sure it's not an runtime detail on one platform? https://devblogs.microsoft.com/cppblog/c11-threads-in-visual...
The article has an excerpt using posix mutexes specifically. But you're right that C11 code can just portably use standard mutexes.
ISO C has had mutexes since C11 I think.
In any case, you could use the provided primitives to wrap the C11 mutex, or any other mutex.
With some clever #ifdef, you can probably have a single or multithreaded build switch at compile time which makes all the mutex stuff do nothing.
Rust pays the cumbersome lifetime syntax tax even in provably single threaded contexts. When will Rust develop ergonomics with better defaults and less boilerplate in such contexts?
Tecnhically the mutex refcounting example is shown as an example of the before the header the author is talking about. We don't know what they've chosen to implement shared_ptr with.
it is quite obvious which one is easier: type bunch of ifdefs vs learn a new language.
BTW don’t fight C for portability, it is unlikely you will win.
Meh, it could easily use atomics instead, no lock needed.
A recent superpower was added by Fil aka the pizlonator who made C more Fil-C with FUGC, a garbage collector with minimal adjustments to existing code, turning it into a memory safe implementation of the C and C++ programming languages you already know and love.
https://fil-c.org/
Thank you so much for sharing this. I missed the HN post.
This is beautiful!
Why would I want to run a garbage collector and deal with it's performance penalties?
Because about 99% of the time the garbage collect is a negligible portion of your runtime at the benefit of a huge dollop of safety.
People really need to stop acting like a garbage collector is some sort of cosmic horror that automatically takes you back to 1980s performance or something. The cases where they are unsuitable are a minority, and a rather small one at that. If you happen to live in that minority, great, but it'd be helpful if those of you in that minority would speak as if you are in the small minority and not propagate the crazy idea that garbage collection comes with massive "performance penalties" unconditionally. They come with conditions, and rather tight conditions nowadays.
44 replies →
IDK about Fil-C, but in Java garbage collector actually speeds up memory management compared to C++ if you measure the throughput. The cost of this is increased worst-case latency.
A CLI tool (which most POSIX tools are) would pick throughput over latency any time.
8 replies →
Because C is very unsafe, but there are still many billions of lines of C in use, so making C safer is a great idea.
Easy: because in your specific use-case, it's worth trading some performance for the added safety.
2 replies →
This feels like a misrepresentation of features that actually matter for memory safety. Automatically freeing locals and bounds checking is unquestionably good, but it's only the very beginning.
The real problems start when you need to manage memory lifetimes across the whole program, not locally. Can you return `UniquePtr` from a function? Can you store a copy of `SharedPtr` somewhere without accidentally forgetting to increment the refcount? Who is responsible for managing the lifetimes of elements in intrusive linked lists? How do you know whether a method consumes a pointer argument or stores a copy to it somewhere?
I appreciate trying to write safer software, but we've always told people `#define xfree(p) do { free(p); p = NULL; } while (0)` is a bad pattern, and this post really feels like more of the same thing.
> Can you return `UniquePtr` from a function?
Yes: you can return structures by value in C (and also pass them by value).
> Can you store a copy of `SharedPtr` somewhere without accidentally forgetting to increment the refcount?
No, this you can't do.
> we've always told people `#define xfree(p) do { free(p); p = NULL; } while (0)` is a bad pattern
Have we? Why?
> C23 gave us [[cleanup]] attributes
C23 didn't introduce it, it's still a GCC extension that needs to be spelled as [[gnu::cleanup()]] https://godbolt.org/z/Gsz9hs7TE
It is surprisingly hard to find information about it, do you have any ? From what I can guess it's a new syntax but it's the feature itself is still an extension ?
[[ ]] attributes were added in C++11 and later C23. There are 7 standard(C32) attributes but GCC has hundreds of them.
https://en.cppreference.com/w/c/language/attributes.html
https://en.cppreference.com/w/cpp/language/attributes.html
https://gcc.gnu.org/onlinedocs/gcc/Attributes.html
https://gcc.gnu.org/onlinedocs/gcc/Common-Variable-Attribute...
1 reply →
The `[[attribute]]` syntax is new, the builtin ones in C23 are `[[deprecated]]`, `[[fallthrough]]`, `[[maybe_unused]]`, `[[nodiscard]]`, `[[noreturn]]`, `[[reproducible]]`, and `[[unsequenced]]`.
The feature itself is probably still __attribute__((cleanup(f))). That’s documented at https://gcc.gnu.org/onlinedocs/gcc/Common-Variable-Attribute...
C standard _may_ eventually have a feature semantically equivalent, but very different syntactically:
https://thephd.dev/_vendor/future_cxx/technical%20specificat...
Discussion:
https://thephd.dev/_vendor/future_cxx/papers/C%20-%20Improve...
C++: "look at what others must do to mimic a fraction of my power"
This is cute, but also I'm baffled as to why you would want to use macros to emulate c++. Nothing is stopping you from writing c-like c++ if that's what you like style wise.
It's interesting to me to see how easily you can reach a much safer C without adding _everything_ from C++ as a toy project. I really enjoyed the read!
Though yes, you should probably just write C-like C++ at that point, and the result sum types used made me chuckle in that regard because they were added with C++17. This person REALLY wants modern CPP features..
> I'm baffled as to why you would want to use macros to emulate c++.
I like the power of destructors (auto cleanup) and templates (generic containers). But I also want a language that I can parse. Like, at all.
C is pretty easy to parse. Quite a few annoying corner cases, some context sensitive stuff, but still pretty workable. C++ on the other hand? It’s mostly pick a frontend or the highway.
There was a language called clay that was C compatible but had move semantics, destructors, templates and operator overloading.
No name mangling by default, far simpler toolchain, no dependence on libstdc++, compiles faster, usable with TCC/chibicc (i.e. much more amenable to custom tooling, be it at the level of a lexer, parser, or full compiler).
C’s simplicity can be frustrating, but it’s an extremely hackable language thanks to that simplicity. Once you opt in to C++, even nominally, you lose that.
I highly doubt (and some quick checks seem to verify that) any of the tiny CC implementations will support the cleanup extension that most of this post's magic hinges upon.
(Agree on your other points for what it's worth.)
1 reply →
Perhaps but a project using this stops you from writing any old C++ in your C. Writing C++ in a C style has no such protection.
It's choosing which features are allowed in.
Embedded CPU vendors not shipping C++ compilers is what usually stops people.
Yup. And I like the implication that Rust is 'cross platform', when it's 'tier 1' support consists of 2 architectures (x86 & arm64). I guess we're converging on a world where those 2 + riscv are all that matter to most people, but it's not yet a world where they are all that matter to all people.
[1] https://doc.rust-lang.org/beta/rustc/platform-support.html
3 replies →
In my experience most chips released in the past 10+ years ship with C++ compilers.
Quite frankly I'm not sure why you wouldn't given that most are using GCC on common architectures. The chip vendor doesn't have to do any work unless they are working on an obscure architecture.
>Nothing is stopping you from writing c-like c++ if that's what you like style wise.
You'll just have to get used to the C++ community screaming at you that it's the wrong way to write C++ and that you should just use Go or Zig instead
If you have a pile of C, switching to using C++ is not easy.
Just don't mix that up with the real safec.h header from safeclib:
https://github.com/rurban/safeclib/tree/master/include
How can anyone be this interested in maintaining an annex k implementation when it's widely regarded as a design failure, specially the global constraint handler. There's a reason why most C toolchains don't support it.
https://www.open-std.org/jtc1/sc22/wg14/www/docs/n1967.htm
It's only regarded as design failure by the linux folks. Maybe because it came from Microsoft, NIH syndrome.
A global constraint handler is still by far better than dynamic env handlers, and most of the existing libc/POSIX design failures.
You can disable this global constraint handler btw.
5 replies →
FWIW, it's heavily used inside Microsoft and is actually pretty nice when combined with all the static analysis tools that are mandatory parts of the dev cycle.
2 replies →
The problem with macro-laden C is that your code becomes foreign and opaque to others. You're building a new mini-language layer on top of the base language that only your codebase uses. This has been my experience with many large C projects: I see tons of macros used all over the place and I have no idea what they do unless I hunt down and understand each one of them.
Like the Linux kernel?
Macros are simply a fact of life in any decent-sized C codebase. The Linux kernel has some good guidance to try to keep it from getting out of hand but it is just something you have to learn to deal with.
I think this can be fine if the header provides a clean abstraction with well-defined behaviour in C, effectively an EDSL. For an extreme example, it starts looking like a high-level language:
https://www.libcello.org/
Obligatory link to Bourne Shell source code. https://news.ycombinator.com/item?id=22191790
You can get all of that and more with Nim[0].
Nim is a language that compiles to C. So it is similar in principle to the "safe_c.h". We get power and speed of C, but in a safe and convenient language.
> It's finally, but for C
Nim has `finally` and `defer` statement that runs code at the end of scope, even if you raise.
> memory that automatically cleans itself up
Nim has ARC[1]:
"ARC is fully deterministic - the compiler automatically injects destructors when it deems that some variable is no longer needed. In this sense, it’s similar to C++ with its destructors (RAII)"
> automated reference counting
See above
> a type-safe, auto-growing vector.
Nim has sequences that are dynamically sized, type and bounds safe
> zero-cost, non-owning views
Nim has openarray, that is also "just a pointer and a length", unfortunately it's usage is limited to parameters. But there is also an experimental view types feature[2]
> explicit, type-safe result
Nim has `Option[T]`[3] in standard library
> self-documenting contracts (requires and ensures)
Nim's assert returns message on raise: `assert(foo > 0, "Foo must be positive")`
> safe, bounds-checked operations
Nim has bounds-checking enabled by default (can be disabled)
> The UNLIKELY() macro tells the compiler which branches are cold, adding zero overhead in hot paths.
Nim has likely / unlikely template[4]
------------------------------------------------------------
[0] https://nim-lang.org
[1] https://nim-lang.org/blog/2020/10/15/introduction-to-arc-orc...
[2] https://nim-lang.org/docs/manual_experimental.html#view-type...
[3] https://nim-lang.org/docs/options.htm
[4] https://nim-lang.org/docs/system.html#likely.t%2Cbool
This won't play along with setjmp/longjmp exception handling schemes, unfortunately, without a lot of extra effort.
You can do cleanup handling that integrates with your exception handling library by using pairs of macros inspired by the POSIX pthread_cleanup_push stuff.
cleanup_push_api places a cleanup node into the exception stack. This is allocated on the stack: the node object. If an exception goes off, that node will be seen by the exception handling which will call fn(ptr).
The cleanup_pop_api call removes the top node, doing a sanity check that the ptr in the node is the same as ptr. It calls fn(ptr).
The fact that cleanup_push leaves an open curly brace closed by cleanup_pop catches some balancing errors at compile time.
The POSIX pthread_cleanup_pop has an extra boolean parameter indicating whether to do the cleanup call or not. That's sometimes useful when the cleanup is only something done in the case of an abort. E.g. suppose tha the "cleanup" routine is rollback_database_transaction. We don't want that in the happy case; in the happy case we call commit_database_transaction.
Nice, but if the intention is portability my experience has unfortunately been that you pretty much have to stick to C99. MSVC’s C compiler is rough, but pretty much necessary for actual cross platform. I have my own such header which has many, many things like the OP’s. As much as I would find it constantly useful, I don’t have a cleanup utility because of this.
But if you can stay out of MSVC world, awesome! You can do so much with a few preprocessor blocks in a header
That's the nice thing about macros, you can also have the macro generate C++ code using destructors instead of using the cleanup attribute. As long as your other C code is also valid C++ code, it should work.
You can use GCC on MS Windows just fine. Installing MSYS2 will also give you a package manager.
Yes, of course. Unfortunately, sometimes you need to link to Windows binaries and therefore need to compile against the Windows ABI.
2 replies →
MSVC now supports C17.
Does it support C99 with VLAs yet?
1 reply →
MSVC is also made out of a dozen javascript processes which makes typing text need a beefy computer.
4 replies →
The post mentions cgrep several times, but I don't see a link to the code. Is it available somewhere?
Github has several repositories named cgrep, but the first results are written in other languages than C (Haskell, Python, Typescript, Java, etc).
yes, it is frustrating. I also am not quite sure what he is referencing and would be interested in trying out cgrep for myself.
This is a great example of how ADTs can be implemented in C by emulating classes, despite the loss in brevity.
For the first item on reference counting, batched memory management is a possible alternative that still fits the C style. The use of something like an arena allocator approximates a memory lifetime, which can be a powerful safety tool. When you free the allocator, all pages are freed at once. Not only is this less error prone, but it can decrease performance. There’s no need to allocate and free each reference counted pointer, nor store reference counts, when one can free the entire allocator after argument parsing is done.
This also decreases fallible error handling: The callee doesn’t need to free anything because the allocator is owned by the caller.
Of course, the use of allocators does not make sense in every setting, but for common lifetimes such as: once per frame, the length of a specific algorithm, or even application scope, it’s an awesome tool!
> This is a great example of how ADTs can be implemented in C by emulating classes, despite the loss in brevity.
I don't see it that way, mostly because ADTs don't require automatic destructors or GC, etc, but also because I never considered a unique/shared pointer type to be an abstract data type
> When you free the allocator, all pages are freed at once. Not only is this less error prone, but it can decrease performance.
How does it decrease performance? My experience with arenas is that they increase performance at the cost of a little extra memory usage.
> My experience with arenas is that they increase performance at the cost of a little extra memory usage.
ack, thanks! You’re exactly right, I got my words mixed up.
Any hopes that MSVC will add C23 support before 2040?
Will windows be relevant by 2040? I personally don’t think so.
Depends on how much Valve manages to get rid of Proton.
[dead]
If you interpret 23 as hex, that's only five years after the year, which isn't too bad!
I suppose it makes sense that every version that came after "c++0x" should be interpreted in hex.
Given how C was seen in the past, before there was a change of heart to add C11/C17, minus atomics and aligned memory allocators (still between experimental or not going to happen),
https://herbsutter.com/2012/05/03/reader-qa-what-about-vc-an...
https://devblogs.microsoft.com/cppblog/c11-atomics-in-visual...
https://learn.microsoft.com/en-us/cpp/c-runtime-library/comp...
And the new guidelines regarding the use of unsafe languages at Microsoft, I wouldn't bet waiting that it will ever happen, even after 2040.
https://azure.microsoft.com/en-us/blog/microsoft-azure-secur...
https://blogs.windows.com/windowsexperience/2024/11/19/windo...
Well, with the death of Xbox and release of raddebugger, maybe supporting VS/MSVC just isn't that important anymore. Good riddance.
2 replies →
Bit of a random question on an article about C.
The article clearly states that the code only works on GCC and Clang, which leaves MSVC. Not sure how the question was random.
1 reply →
Does the StringView/Span implementation here seem lacking? If the underlying data goes away, wouldn't the pointer now point to an invalid freed location, because it doesn't track the underlying data in any way?
That's what std::string_view and std::span are, though. They're views / borrows over the underlying data, minus the kind of protections something like Rust has about lifetimes of the underlying data vs the borrow.
The benefit of these types is that they're a pair of pointer+size, instead of just a bare pointer without a known size.
True, I guess I was expecting if you were reimplementing these with additional protections, why not add the protection using the custom pointer reimplementations to ensure no use after frees. Seems like a missed opportunity.
1 reply →
I think because it's called `safe_c.h` and is "designed to give C some safety and convenience features from ... Rust", and says "The Memory Management Beast: Slain", you expected to see some level of memory safety, Rust's headline selling point. I did too when I opened the article. But it doesn't at all. Those phrases are all clickbait and slop.
In fact I don't see anything here from Rust that isn't also in C++. They talk about Result and say "Inspired by Rust, Result forces you to handle errors explicitly by returning a type that is either a success value or an error value", but actually, unlike Rust, nothing enforces that you don't just incorrectly use value without checking status first.
It's just some macros the author likes. And strangely presented—why are the LIKELY/UNLIKELY macros thrown in with the CLEANUP one in that first code snippet? That non sequitur is part of what gives me an LLM-written vibe.
I feel like there might be some value in this header file for some projects, but this is exactly the wrong use case.
> In cgrep, parsing command-line options the old way is a breeding ground for CVEs and its bestiary. You have to remember to free the memory on every single exit path, difficult for the undisciplined.
No, no, no. Command line options that will exist the entire lifetime of the program are the quintessential case for not ever calling free() on them because it's a waste of time. There is absolutely no reason to spend processor cycles to carefully call free() on a bunch of individual resources when the program is about to exit and the OS will reclaim the entire process memory in one go much faster than your program can. You're creating complexity and making your program slower and there is literally no upside: this isn't a tradeoff, it's a bad practice.
There are (older) OSes where this is NOT the case. Leaving things un-freed would leak memory after the application has terminated.
Sure. And the rare programmer reading this who is still using that OS knows they're the exception to the rule. To everyone else that fact is irrelevant.
2 replies →
Sure, like prior to the invention of virtual memory. But those days are in the distant past.
shouldn't be individually allocated either, arenas for the win
I think that a blanket should/shouldn't recommendation for arenas isn't right. Arenas are a tradeoff:
Pros: preallocating one arena is likely faster than many smaller allocations.
Cons: preallocation is most effective if you can accurately predict usage for the arena; if you can't, then you either overshoot and allocate more memory than you need, or undershoot and have to reallocate which might be less performant than just allocating as-needed.
In short, if you're preallocating, I think decisions need to be made based on performance testing and the requirements of your program (is memory usage more important than speed?). If you aren't preallocating and just using arenas for to free in a group, then I'm going to say using an arena for stuff that is going to be freed by the OS at program exit is adding complexity for no benefit--it depends on your arena implementation (arenas aren't in the C standard to my knowledge).
In general, I'd be erring on the side of simplicity here and not using arenas for this by default--I'd only consider adding arenas if performance testing shows the program spending a lot of time in individual allocations. So I don't think a blanket recommendation for arenas is a good idea here.
EDIT: In case it's not obvious: note that I'm assuming that the reason you want to use an arena is to preallocate. If you're thinking that you're going to call free on the arena on program exit, that's just as pointless as calling free on a bunch of individual allocations on program exit. It MIGHT be faster, but doing pointless things faster is still not as good as not doing pointless things.
2 replies →
[dead]
This reminds me that Scott Meyers (IMO rightfully) considered the destructor as the single most important fearure in C++.
I too enjoy space leaks on recursion
I checked Fil-C out and it looks great. How is this different from Fil-C? Apart from the obvious LLVM stuff
Fil-C essentially lifts C onto a managed, garbage-collected runtime. This is a small header that adds some C++ features to C.
(These features are still essentially unsafe: the unique pointer implementation still permits UAF, for example, because nothing prevents another thread from holding the pointer and failing to observe that it has been freed.)
Just use C++. Every single one of these is a horror-show worse reinvention of a C++ feature.
Like, if I was stuck in a C codebase today, [[cleanup]] is great -- I've used this in the past in a C-only shop. But vectors, unique_ptrs, and (awful) shared_ptrs? Just use C++.
I am not convinced. I much prefer using plain C without superpowers and write extensive test suite and analyse the code multiple times. There is always a chance to miss something, but code without "magic" is much easier to reason about.
Seems like someone should invent C+, which would be C but with the reasonable safety guardrails that C++ implemented in the last 10/20 years.
Nope!
This feels AI-generated.
Guarding builtin_expect with __GNUC__ instead of has_builtin might be what you find in elderly codebases that were fed into an llm
No, that's pretty normal. It's the English writing that feels AI.
Anybody know his github?
Please don't do It.
Nice toy. It works until it stops working. An experienced C developer would quickly find a bunch of corner cases where this just doesn't work.
Given how simple examples in this blog post are, I ask myself, why don't we already have something like that as a part of the standard instead of a bunch of one-off personal, bug-ridden implementations?
It would be a lot more constructive if you reported a bunch of corner cases where this doesn't work rather than just dismissing this as a toy.
No, I don't dismiss anything.
It's just, I'd rather play with my own toys instead of using someone else's toy. Especially since I don't think it would ever grow up to be something more than a toy.
For serious work, I'd use some widely used, well-maintained, and battle-tested library instead of my or someone else's toy.
Yeah, kids like to waste time to make C more safe or bring C++ features. If you need them, use C++ or different language. Those examples make code look ugly and you are right, the corner cases.
If you need to cleanup stuff on early return paths, use goto.. Its nothing wrong with it, jump to end when you do all the cleanup and return. Temporary buffers? if they arent big, dont be afraid to use static char buf[64]; No need to waste time for malloc() and free. They are big? preallocate early and reallocate or work on chunk sizes. Simple and effective.
Can you share such a corner case?
1 reply →
> use goto
My thoughts as well. The only thing I would be willing to use is the macro definition for __attribute__, but that is trivial. I use C, because I want manual memory handling, if I wouldn't want that I would use another language. And now I don't make copies when I want to have read access to some things, that is simply not at a problem. You simply pass non-owning pointers around.
> static char buf[64];
In a function? That makes the function not-threadsafe and the function itself stateful. There are places, where you want this, but I would refrain from doing that in the general case.
7 replies →
God forbid we should make it easier to maintain the existing enormous C code base we’re saddled with, or give devs new optional ways to avoid specific footguns.
17 replies →
I don't understand this passion for turning C into what it's not...
Just don't use C for sending astronauts in space. Simple.
C wasn't designed to be safe, it was designed so you don't have to write in assembly.
Just a quick look through this and it just shows one thing: someone else's walled garden of hell.
> Just don't use C for sending astronauts in space
But do use C to control nuclear reactors https://list.cea.fr/en/page/frama-c/
It's a lot easier to catch errors of omission in C than it is to catch unintended implicit behavior in C++.
I consider code written in Frama-C as a verifiable C dialect, like SPARK is to Ada, rather than C proper. I find it funny how standard C is an undefined-behaviour minefield with few redeeming qualities, but it gets some of the best formal verification tools around.
5 replies →
Actually C performs quite good in exactly that area.
https://ntrs.nasa.gov/citations/19950022400
And
https://hackaday.com/2024/02/10/the-usage-of-embedded-linux-...
> Just don't use C for sending astronauts in space. Simple.
Last time I checked, even SpaceX uses C to send astronauts to space...
Some C devs will make all kinds of crazy efforts only not to use C++.
C++ is edge case hell even for simple looking code
3 replies →
I agree, if people just had refrained from building things in c/c++ that operated on data from across a security boundary we wouldn't be in this mess.