Why I Always End Up Going Back to C

14 min read Original article ↗

Preface: Loud Opinions and Quiet Work

Scroll twitter long enough and you’ll start to think software is some sort of combat sport or MMA promotion. Every post is a proclamation. Rust or you’re stupid. Zig killed C. C++ was a mistake. Lisp or bust. JavaScript is king. OCaml till the end. If you’re not a diehard fan of the newest trendy thing, you’re a fossil clinging to a dead language who's opinions should be discarded.

It’s exhausting, but not because disagreement is bad. Quite the opposite - passion for the best of the best is great. However, such authoritative slander without context is largely useless. Programming languages are tools rather than religions, and their utility should be decided by pragmatic trade-offs rather than by dogma and tribalism.

I don’t write code to win Twitter arguments, even if I get baited into a handful of them... I write code to make things that work well, and last.


The Shape of My Code

Most of my time has been spent in what people loosely call "C/C++", or "C+", or "C-like C++"... Mostly that means something close to "C with a compiler that understands operator overloading if I really need it". However, the point is not the name. The point is restraint.

C++ is a massive language which contains multitudes. Some of those multitudes are useful, whereas many have really sharp edges. A good number of them are seductive in the same way power tools are much more seductive than gloves or safety goggles are. Modern C++ culture often assumes you should use everything because it’s there. I bought that, as a professional programmer doing things the enterprise way, until consuming content like Handmade Hero. That is where I learnt that simple, readable code produces performant, scalable, and maintainable results.

As a result of this, I found myself shedding features instead of accumulating them. Fewer abstractions, fewer clever tricks, and fewer moments where the debugger feels like it’s punishing me for past sins. I went from a consistent reviewer of the newest C++ features to someone who can barely remember the difference between a weak_ptr and a shared_ptr. Eventually, I crossed a line that surprised me: straight C. Not even "C with extensions", not "C but actually C++ compiled as C". Plain ANSI C. C89. Sometimes C99 if I didn't wanna declare all my variables at the top of the function. C89 is the language I chose to write my GUI library in, and is probably the language I'll write most of my general purpose libraries in.


What I Gained by Giving Things Up

C doesn’t give you much... That’s kinda the point.

The language’s limited feature set forces decisions into the open. There’s no template metaprogramming ready to ruin your innocence, no smart pointer choreography to imply correctness. I get to have data structures and I get to have algorithms. That's it. If something is complicated, it’s because I couldn't figure out how to simplify it.

That constraint comes with consequences.

Code gets simpler because it has to, and architecture becomes explicit. Code becomes documentation. State management can’t be hand-waved away with a type trick. The debugger becomes a magnifying glass rather than a pickaxe that has me spelunking through seemingly infinite layers of abstraction.

Build times drop off a cliff. There are no template instantiations, there's less bloated header horror, and there's no nervous compiler spending precious seconds being examined on type deduction. Fast builds changed the subconscious mental frustration that worsened how I work. Iteration tightened up and prototyping became playful. The joy of exploration in programming became so much more apparent, because I no longer had to dread a 15 minute compilation at the end of every potential experiment. Production-size codebases now compiled in the blink of an eye.

Portability improves in a way that’s boring until it saves you. C89 runs everywhere, without arguing about ABIs and without reinterpreting your object layout depending on which compiler you decided to use. Foreign function interfaces become predictable. Your code can sit at the centre of the universe and talk to Zig, Rust, Go, Python - whatever system you dictate - without ceremony.

The ABI stays still. That matters more than I would have admitted before getting C-radicalized. A stable ABI lets you do interesting things safely: dynamic loading, plugin systems, long-lived binaries that don’t fall apart when you sneeze on them. C++ complicates this by design. V-tables, name mangling, object layout variability... All in service of abstractions that are often unnecessary at the boundary.

Then there’s performance. Not even "microbenchmark code golf" performance, but observability performance. In C, you can see what the machine is doing. Allocations don’t hide behind constructors, and destructors don’t quietly run during stack unwinding. You can profile at the machine-code level without feeling like you’re peeling an onion, appropriately shedding tears the whole time.

There’s less noise at runtime. No RTTI, no exceptions, no sprawling standard library hauling half a runtime behind it. Binaries are smaller, debug info is cleaner, and behaviour is consistent.

None of this inherently makes C "better", but rather makes it narrower. When I cook, I don't use a swiss army knife to cut my vegetables - I use a clean, simple knife designed for that one purpose only.


The Cost of Transparency

C is not exactly friendly. Anyone who tells you otherwise is lying or selling their Udemy course. The language is decades old - whereas the architecture my CPU runs on came out last year. It is bound to have flaws.

It’s verbose. Simple ideas can take a lot of code. You’ll write countless helpers that feel obvious in hindsight and irritating in the moment. Sometimes you’ll need hacks, and sometimes the compiler will feel like throwing you some riddles you have to solve to get your prized exe.

The learning curve is harsh and doesn’t end. Comfort in C correlates strongly with comfort in systems programming and hardware as a whole: memory models, hardware realities, undefined behaviour, and the thousand small rules that lie in wait to bite you, much like a snake in the grass.

There is a lot of room to do things badly. C will not stop you, nudge you, or save you from yourself. Compared to languages like Rust or Zig, which actively fence you in, C tosses you the footgun and watches in silence.

Experience helps. Discipline helps. Taste helps. None of them make you invincible. Seasoned programmers still step on the same landmines. That’s the price of unapologetic honesty and transparency. The language shows you the machine, a machine which is not forgiving to mistakes.


Why It’s Still Worth It

Despite all of that (or because of it) there’s a particular reward in becoming genuinely competent in C.

I reached a point where the language stopped feeling hostile and started feeling transparent. I know where the edges are. I know what costs what. I know when I'm taking a shortcut and what I'm paying for it.

More importantly, I feel I can bend the language to my will.

The real goal isn’t to write C once for a one-off project. It’s to write it for decades. To build up a personal ecosystem of practices, libraries, conventions, and tooling that compound over time. Each project gets easier not because I've memorized more tricks, but because you’ve invested in myself and my tools.

I could make an apt comparison of how I see C to Factorio, or Satisfactory. You build system upon system, which in-turn, make your future systems better. You optimize for yourself: your speed, your safety, your ability to reason under pressure. You deal in the language of your constraints.

That entire universe is yours to build, and if you do it well, it carries you a very long way.


On Choosing Constraints

This isn’t a manifesto against modern languages such as Rust, Zig, or C++. they all exist for good reasons, and they solve problems C never will. This is about choosing constraints deliberately instead of absorbing them by cultural osmosis.

C is flawed, openly and loudly. But it’s also stable, understandable, and deeply interoperable. It rewards patience, discipline, and curiosity. It makes the machine legible.

In a world obsessed with novelty, there’s value in tools that don’t change much. Not because progress is bad, but because mastery requires stillness. It's hard to practice 1 kick 10,000 times if you get a different leg transplanted onto your body every couple of months.

That said, I have ways to make this whole C thing easier on myself.


Foundation: Memory and Core Systems

When living in C, memory is not a detail so much as the foundation. Every other abstraction I build is only as good as my ability to decide where bytes come from, how long they live, and who’s allowed to touch them.

The first mental shift I had to get to terms with is abandoning the idea that “the heap” is a place. The heap is a service, and malloc is a general-purpose convenience API optimized for workloads that generally don’t look like mine. Games, tools, engines, and long-running systems all have structure. Lifetimes tend to fit into nice little groups. Allocations come in waves, so freeing one thing at a time is often the wrong move.

Arena allocators fix this by making memory lifetimes explicit. You allocate a large region once, then carve it up linearly. No bookkeeping per allocation, and no code fragmentation. When you’re done, you reset the arena in one line of code and move on. Temporary arenas, or scratch spaces, let me allocate recklessly inside a scope without paying any long-term consequences. Permanent arenas give my core systems a stable home. Pools handle fixed-size objects without ever touching the general heap.

Thinking this way made my code change shape. Functions stop secretly allocating, and APIs become honest about ownership. Memory locality became a design goal. The use of malloc, or new and free became a thing of the past, for the most part.

To do this well, I had to understand memory as layout, which was in hindsight much simpler than trying to understand esoteric descriptions that come with literature heavy on malloc and free use. Alignment matters practically, not because the compiler begs for it, but because cache lines and SIMD instructions demand it. Fragmentation isn’t a theoretical problem, it becomes something that clearly tanks performance as per the profiler.

Understanding this was the price of entry in making C exponentially easier to write. I paid it once, and everything I built from then on got cheaper.


My Own Standard Library

The C standard library is not necessarily bad, considering age. It just wasn't written for me.

malloc doesn’t know my allocation patterns. strtok doesn’t care about the multithreading paradigm I'm throwing it at. string.h is optimized for correctness and generality, not for expressiveness or cohesion. None of it shares my naming conventions or error-handling philosophy.

So I replace it, gradually and deliberately.

For example, I wrote a string type where the string know their length. Dynamic arrays that grow predictably. Hash maps that are really understandable and "non-pessimized". Linked lists that are only useful when I actually need pointer-stable nodes. Things are named super clearly, with verbosity if it helps. No abbreviations that save two characters at the expense of readability. Every function advertises its ownership rules in its signature.

IO becomes something I own. File loading that returns buffers from my arenas. Writing helpers that don’t silently allocate. Memory-mapped IO when it makes sense. Async only if I can justify the complexity.

This isn’t NIH syndrome as much as it is coherence. A standard library is a philosophy, purpose-built, and If I don’t write my own, I'm just inheriting someone else’s by force.


Platform Abstraction Without Amnesia

Portability doesn’t mean pretending platforms are the same. IIRC, Mike Acton mentioned never referring to "platform independent" when discussing his work. I agree, and to me, portability means isolating where platforms are different.

The platform layer is a thin membrane between what the application should actually do, and the reality of the environment it operates in. On one side: Win32, POSIX, Linux syscalls, graphics APIs, threading primitives, socket weirdness. On the other; clean, boring interfaces that the rest of the codebase can rely on.

I can wrap system calls without hiding them, I can expose the cost, I can know when a function can block. I can know which calls cross into kernel space. I can respect calling conventions and structure padding because the ABI is not a suggestion.

Graphics abstractions don’t chase unification fantasies. I'll define what I need - buffers, shaders, command submission - and map that to OpenGL, Vulkan, D3D, or a software renderer. Input and windowing follow the same rule: abstract behaviour, not APIs.

The goal is to write code that knows where it is at all times, so that it doesn't violate the laws of its environment.


Language Ergonomics

C is small, but that doesn’t mean it has to be painful.

You can generate headers instead of maintaining them by hand, you can build better enums with string tables and bitflags that don’t rot, You can encode invariants in macros that expand to boring, readable C instead of clever tricks.

The part that's hard to adhere to is restraint. Macros are power tools. Used well, they remove repetition and enforce consistency. Used poorly, they turn the codebase into a bloody crime scene.

It is important to learn macro hygiene. To know when an inline function is better. It is important to keep expansions simple enough that stepping through them in a debugger doesn’t feel like swimming in the river Styx.


Error Handling

Exceptions hide control flow and set up ugly surprises. Error codes rot. C forces you to confront error handling in a direct manner (unless you prefer to explode into a ball of flames).

So I try to make error handling explicit and consistent. Functions return results that encode success or failure clearly, or booleans, when that’s enough. Structured error values when it isn’t. If I make a mess, I clean it up, rather than hoping some caller in a far away land can deal with it for me.

The goal doesn't always need to be elegance, rather making failure paths obvious, debuggable, and hard to ignore.


Building the Ecosystem

Over time, this turns into a toolkit - not a framework, so much as a mega-monolith-lib-thing.

Resizable arrays I trust, hash maps I understand, sorting and search algorithms tuned for cache behaviour and the data I throw at it, not general-purpose academic excellence. Math libraries that respect alignment and SIMD constraints. File utilities that hot-reload without corrupting state.

I can also reuse larger systems. Immediate-mode GUIs for tools and debugging. Audio pipelines that respect real-time constraints. Renderers with modular backends. Asset pipelines that funnel data into a usable state with ease. Serialization formats I can version and reason about.

I've built simple profilers because guessing is a waste of time. Loggers that tell me exactly what I decide matters. Memory visualizers that expose allocation patterns in the exact way that make sense for my custom arena implementation. Readable build scripts that run instantly and fail loudly. Every piece exists because, at some point, I needed it, and I'll probably need it again. This becomes my ecosystem.


The Ecosystem Mindset

This shift was more of a philosophical one. I'd previously reach for a library if, say, I wanted to read some JSON. However, the handmade mindset got its hooks in me. I'd now much rather build a very specific, purpose built JSON parser myself, that's extremely good at reading a super specific format of data that I can always dictate.

I like to use my own tools, which means every project feeds the next one. Nothing stagnates. My workflow and requirement fulfilment becomes muscle memory. I get to have direct continuity over projects.

C becomes less about the language and more about the environment I've constructed around it. An environment that rewards understanding and compounds, paying dividends in the currency that is my time.

This is how I make C liveable, and even enjoyable, long-term. Not by pretending it’s a modern tool that always works, and not by apologizing for it. But by shaping it to fit the way I think, to mould it to the way my mind works by default. I get to choose the constraints that work for me.

That’s the quiet part nobody tweets about.