Generic Containers in C: Vec (uecker.codeberg.page)

47 points by uecker 3 days ago

lor_louis 43 minutes ago

I do something similar, but I don't implement the logic in a macro, instead I have a Vec struct which looks like

    struct Vec {
        void *data;
        size_t len;
        size_t cap;
        size_t sizeof_ty;
    }
I then use a macro to define a new type

    IntVec {
        struct Vec inner;
        int ty[0];
    }
Using the zero sized filed I can do typeof(*ty) to get some type safety back.

All of the methods are implemented on the base Vec type and have a small wrapper which casts/assets the type of the things you are trying to push.

cyber1 7 hours ago

Many C programmers need proper generic programming mechanisms (perhaps something like Zig's comptime) in C, but macros are the worst possible approach, and they don't want to switch to a different language like C++. As a result, they struggle with these issues. This is what I think the standardization committee should focus on, but instead, they introduced _Generic.

sparkie 2 hours ago

The biggest issue is the ABI for C - it's the lingua-franca of language interoperability and can't really be changed - so whatever approach is taken it needs to be fully compatible with the existing ABI. `_Generic` is certainly flawed but doesn't cause any breaking ABI changes.

That's also a major reason why you'd use C rather than C++. The C++ ABI is terrible for language interoperability. It's common for C++ libraries to wrap their API in C so that it can be used from other language's FFIs.

Aside from that another reason we prefer C to C++ is because we don't want vtables. I think there's room for a `C+` language, by which I mean C+templates and not C+classes - perhaps with an ABI which is a subset of the C++ ABI but superset of the C ABI.

signa11 an hour ago

> I think there's room for a `C+` language, by which I mean C+templates and not C+classes - perhaps with an ABI which is a subset of the C++ ABI but superset of the C ABI.

indeed, i have spoken to a lot of my colleagues about just that. if overloading is not allowed, perhaps there is still some hope for a backwards compatible abi ?

sparkie an hour ago

cherryteastain 31 minutes ago

cyber1 an hour ago

This is true. I agree with this statement. It's the holy cow of C. However, the problem with generic programming and metaprogramming isn't going away, and many people continue to struggle with it. Introducing something like compile-time reflection might be a solution...

sirwhinesalot 7 hours ago

The most insulting thing about _Generic is the name. Really? _Generic? For a type-based switch with horrific syntax? What were they thinking...

That said, generic programming in C isn't that bad, just very annoying.

To me the best approach is to write the code for a concrete type (like Vec_int), make sure everything is working, and then do the following:

A macro Vec(T) sets up the struct. It can then be wrapped in a typedef like typedef Vec(int) Vec_i;

For each function, like vec_append(...), copy the body into a macro VEC_APPEND(...).

Then for each relevant type T: copy paste all the function declarations, then do a manual find/replace to give them some suffix and fill in the body with a call to the macro (to avoid any issues with expressions being executed multiple times in a macro body).

Is it annoying? Definitely. Is it unmanageable? Not really. Some people don't even bother with this last bit and just use the macros to inline the code everywhere.

Some macros can delegate to void*-based helpers to minimize the bloating.

EDIT: I almost dread to suggest this but CMake's configure_file command works great to implement generic files...

uecker 5 hours ago

There are less annoying ways to implement this in C. There are at least two different common approaches which avoid having macro code for the generic functions:

The first is to put this into an include file

  #define type_argument int
  #include <vector.h>
Then inside vector.h the code looks like regular C code, except where you insert the argument.

  foo_ ## type_argument ( ... )
The other is to write generic code using void pointers or container_of as regular functions, and only have one-line macros as type safe wrappers around it. The optimizer will be able to specialize it, and it avoids compile-time explosion of code during monomorphization,

I do not think that templates are less annoying in practice. My experience with templates is rather poor.

sparkie 3 hours ago

sirwhinesalot 5 hours ago

cyber1 6 hours ago

Hey, I understand you and know this stuff well, having worked with it for many years as a C dev. To be honest, this isn't how things should generally be done. Macros were invented for very simple problems. Yes, we can abuse them as much as possible (for example, in C++, we discovered SFINAE, which is an ugly, unreadable technique that wasn't part of the programming language designer's intent but rather like a joke that people started abusing), but is it worth it?

ioasuncvinvaer 6 hours ago

username checks out

uecker 6 hours ago

I don't struggle, I switch from C++ to C and find this much nicer.

cyber1 6 hours ago

I'm currently at a crossroads: C++ or Zig. One is very popular with a large community, amazing projects, but has lots of ugly design decisions and myriad rules you must know (this is a big pain, it seems like even Stroustrup can't handle all of them). The other is very close to what I want from C, but it's not stable and not popular.

uecker 6 hours ago

itay2805 an hour ago

By far the best implementation for type-safe containers in C I found is stb_ds, it provides both a vector and a hashmap (including a string one).

https://nothings.org/stb_ds

petters 10 hours ago

> Many vector types include a capacity field, so that resizing on every push can be avoided. I do not include one, because simplicity is more important to me and realloc often does this already internally. In most scenarios, the performance is already good enough.

I think this is the wrong decision (for a generic array library).

ethan_smith 7 hours ago

Without a capacity field, each push operation potentially triggers a realloc, causing O(n) copying and possible memory fragmentation - especially problematic for large vectors or performance-critical code.

tialaramex 9 hours ago

> realloc often does this already internally

Is Martin claiming that realloc is "often" maintaining a O(1) growable array for us?

That's what the analogous types in C++ or Rust, or indeed Java, Go, C# etc. provide.

uecker 8 hours ago

No, I claim that the performance of realloc is good enough for most use cases because it also does not move the memory in case there is already enough space left.

I then mention that for other use cases, you can maintain a capacity field only in the part of the code where you need this.

Whether this is the right design for everybody, I do not know, but so far it is what I prefer for myself.

tialaramex 7 hours ago

im3w1l 6 hours ago

cv5005 10 hours ago

And if I want a vec(int *)? These token pasting 'generic' macros never work for non-trivial types.

teo_zero 10 hours ago

Correct, complex types must be typedef'd. At least, until c2y integrates _Record as per N3332: https://thephd.dev/_vendor/future_cxx/papers/C%20-%20_Record...

hyperbolablabla 10 hours ago

I think the overwhelmingly better approach for C is codegen here. Better ergonomics, tab completion, less error prone, etc. As long as your codegen is solid!

uecker 9 hours ago

Why? I do not find the ergonomics bad.

It is also not clear how you get tap completion with code generation. But you could also get tab completion here, somebody just has to add this to the tab completion logic.

mashpoe 6 hours ago

I made a pretty convenient C vector library a while back that lets you use the [] operator directly on the vector pointers: https://github.com/Mashpoe/c-vector

A neat project that was posted here a while back uses it: https://x.com/kotsoft/status/1792295331582869891

jll29 7 hours ago

The authoritative treatise of this topic is "C - Interfaces and Implementations" (known as CII) by David R. Hanson.

His code is here: https://github.com/drh/cii

camel-cdr 10 hours ago

Here is my version of this concept, I tried to keep it as simple as possible: https://github.com/camel-cdr/cauldron/blob/main/cauldron/str...

gsliepen 8 hours ago

It's amazing how many people try to write generic containers for C, when there is already a perfect solution for that, called C++. It's impossible to write generic type-safe code in C, and this version resorts to using GCC extensions to the language (note the ({…}) expressions).

For those afraid of C++: you don't have to use all of it at once, and compilers have been great for the last few decades. You can easily port C code to C++ (often you don't have to do anything at all). Just try it out and reassess the objections you have.

serbuvlad 7 hours ago

My problem with C++, and maybe this is just me, is RAII.

Now, Resource Aquisition Is Initialization is correct, but the corollary is not generally true, which is to say, my variable going out of scope does not generally mean I want to de-aquire that resource.

So, sooner or later, everything gets wrapped in a reference counting smart pointer. And reference counting always seemed to me to be a primitive or last-resort memory managment strategy.

gpderetta 6 hours ago

Your problem is not with RAII, but with reference counting, which you correctly identified should be the last resort, not the default; at least for the applications typically written in C++.

Levitating 2 hours ago

spacechild1 5 hours ago

> my variable going out of scope does not generally mean I want to de-aquire that resource.

But it does! When an object goes out of scope, nobody can/shall use it anymore, so of course it should release its (remaining) resources. If you want to hold on the object, you need to revisit its lifetime and ownership, but that's independent from RAII.

secondcoming 7 hours ago

If you want to take back manual control, use the release() function

uecker 6 hours ago

Except that I find C++ far from being perfect. In fact, I switched from C++ to C (a while ago) to avoid its issues and I am being much happier I also find my vec(int) much nicer.

In fact, we are at the moment ripping out some template code in a C code base which has some C++ for cuda in it, and this one file with C++ templates almost doubles the compilation time of the complete project (with ~700 source files). IMHO it is grotesque how bad it is.

eps 9 hours ago

The post is more of a quick-n-dirty (and rather trivial) proof of concept as the code includes only sporadical checks for allocation errors and then adds a hand-wavy disclaimer to improve it as needed.

E.g. in production code this

  if (!vec_ptr) // memory out
    abort();

  for (int i = 0; i < 10; i++)
    vec_push(int, &vec_ptr, i);
should really be

  if (!vec_ptr) // memory out
    abort();

  for (int i = 0; i < 10; i++)
    if (! vec_push(int, &vec_ptr, i))
      abort();
but it doesn't really roll of the tongue.

johnisgood 3 hours ago

If this is in a library code, then I tend to disagree. As an user of a library, I would rather be able to handle errors the way I want, I do not want the library to decide this for me, so just return an error value, like "VEC_ERR_NOMEM", or whatever.

uecker 8 hours ago

If all you do is call abort anyway, you do not need an interface that makes you test for errors.

rwmj 11 hours ago

Here's a generic vector used in real world code: https://gitlab.com/nbdkit/nbdkit/-/blob/master/common/utils/...

teo_zero 10 hours ago

Why do you need to pass the type to vec_push? Can't T be replaced by typeof(v->data[0]) ?

uecker 8 hours ago

It could. I like things being spelled out explicitly. Otherwise I would probably not use C.

uecker 3 days ago

Towards safe containers in C.

senderista 11 hours ago

I doubt I'd want to use a dynamic array in C without a custom allocator.

teo_zero 10 hours ago

You don't need an explicit allocator: you can create an empty object and vec_push() does the magic of (re)allocating the memory when needed.

Instead, what is missing is an automatic deallocator, one that's automatically called when the variable goes out of scope. For example:

  {
    vec(int) v={}; /* no extra room allocated */
    vec_push(v,1); /* space allocated */
    ... use v ...
  } /* here the space is dellocated, then v is released */
This example doesn't use the same definition of vec as TFA, but something more similar to 'span' by the same author.

uecker 9 hours ago

And you could easily add a version with a custom allocator if you need it.