hecc - hachem's experimental c compiler
hecc (pronounced "heck") is an experimental c compiler built around a pretty simple idea. c often doesn't give the compiler enough information about what the programmer meant. a pointer assignment could be an alias, an ownership transfer, or just some arbitrary operation that only the programmer understands. c hands the compiler the resulting values, but usually not the intent behind them.
hecc is about whether a few explicit extensions can let the programmer write that intent down, so the compiler can check it and build on it. i'm not trying to turn c into rust, c++, or some other big systems language. it's also a language extension you write in, not an analyzer you point at code you never touch. existing c keeps compiling as is, and wherever you reach for the extensions (ownership transfers, borrows, tagged unions) you get expressiveness and checking that plain c can't give you. the question is how much a small layer on top can add without giving up what makes c worth using.
the closest one-line description is gradual typing, aimed at memory responsibility instead of types. like typescript over javascript, the checks are opt-in, adopted a file at a time, unsound on purpose, and always escapable. they're useful because they check intent the programmer states instead of intent a tool has to guess. none of the individual features are new. people working on c safety have tried all of them before. the bet is on that combination, held to one hard rule. everything erases to ordinary c, nothing is ever required, and the code never gets trapped inside the toolchain. to keep the "small" claim honest, this document has two parts. first there's a deliberately small core, which is what hecc actually claims to be. then, past a divider, there's a set of explorations held to the same rules that aren't part of the core yet.
the project is experimental, and a lot of the semantics here are still being worked out. this isn't meant to be a finished language spec. it's where i keep track of where the project is going and why the features look the way they do, while i'm still figuring them out.
why c?
c has plenty of problems, but size isn't one of them. the language is fairly easy to learn, the standard library is small, and people replace parts of the ecosystem with their own versions all the time. hobby os developers write their own allocators, containers, runtimes, standard libraries, and sometimes whole compilers. there are countless alternative c libraries because the language is small enough that understanding and replacing its pieces is actually realistic.
that changes with the bigger systems languages. c++, rust, and zig all solve problems c doesn't, but they're also much bigger languages with much bigger ecosystems. hecc isn't supposed to slowly rebuild those languages inside c. if every problem gets solved by adding another type, annotation, keyword, library component, or compiler feature, the language eventually turns into the thing it was trying to avoid.
so the question behind hecc isn't how to make c completely safe. it's how much extra information you can give the compiler while the language still looks like c.
how hecc is built
at least at first, hecc is a transpiler. it parses the extended syntax, runs its analysis, and spits out ordinary c for an existing compiler to build. that mostly matters for how the rest of this document reads. most of hecc's extensions are information for the analysis and nothing else. once they're checked they erase, and the c that comes out looks like what a c programmer would have written by hand. when a later section says an extension "generates" some c or "erases at code generation", this is the step it's talking about. the full pipeline, the llvm backend, and what the transpile-to-c design gets a project are covered under transpilation.
additive by design
everything in hecc is meant to be strictly additive. using a feature gets you something, not using it costs you nothing, and existing c compiles and runs unchanged. you never have to give something up before you can start using hecc.
most tools that improve on c take something away before they give anything back. rust asks for a rewrite, static analyzers make you wade through false positives, and sanitizers cost you at runtime. hecc's floor is ordinary c with no penalty, and every feature sits on top of that floor as something you opt into.
the ownership system is the clearest example. = is exactly c's assignment. it makes no claim about ownership and never has to be checked, so all the aliasing and borrowing existing code is full of keeps working. <- and the annotations built on it are the opt-in part. use them where the guarantee is worth the extra typing, and leave = everywhere you don't care. memory safety becomes something you add one line at a time, not a gate you have to get through before the code builds. the rest works the same way. type unions, bounds annotations, and the rulesets are all optional, and none of them change what unannotated code means.
this rule is firm. a feature that made existing c warn, fail, or need edits before you could adopt it would break that, and it doesn't belong in hecc. hecc can reward you for saying more, but it never punishes you for saying nothing.
opt-in sometimes gets read as weaker than a language that's safe by default. but that's a difference in how you compile, not in how much safety you can reach, and opting into strictness is already how people write c. almost nobody ships serious c without -Wall -Wextra -Werror, a sanitizer, or a misra-style ruleset. reaching for extra checking is normal, and hecc's features are the same habit. a hecc codebase is supposed to end up using the safety layer, not sitting on the bare floor forever. writing good hecc means using <-, origins, and the strict and runtime tiers. which of those a build enforces is a policy the project sets, the same way it sets -Werror. strict for the code it wants fully checked, compatibility for the c it's still adopting. so "good hecc" is actually enforced and not just hoped for. how those defaults get picked is under strictness and compatibility. every language has some idea of correct use anyway. you can write rust that leaks (allocate and mem::forget, or build a reference cycle) and it compiles, but it isn't correct rust. = everywhere is legal hecc in the same way. it's allowed and you can adopt from there, but it's not the goal.
ownership
the main thing i'm exploring in hecc right now is an ownership system for pointers. take this ordinary c.
int* a = malloc(...);
int* b = NULL;
b = a;
at runtime this is simple. a and b now hold the same address. the problem is that the compiler doesn't know what the assignment means. did the programmer make another reference to the allocation? is b now supposed to free it? is a still responsible? was it an accident? c can't answer any of that, because the assignment itself doesn't say. hecc tries making ownership transfers explicit.
int* a = malloc(...);
int* b = NULL;
b <- a;
the <- operator means ownership of the allocation moves from a to b. it doesn't need to change what the program does at runtime. once hecc has checked that the move is valid, <- erases and the c that comes out is just this.
int* a = malloc(...);
int* b = NULL;
b = a;
the difference only exists during compilation, and the generated program is still ordinary c. what matters is that <- says something = doesn't. = copies a pointer and says nothing about ownership. <- tells the compiler that responsibility for the allocation is moving.
owners
when memory comes from something the compiler understands, like malloc(), hecc can establish an owner.
int* a = malloc(...);
now a owns the allocation. that ownership can eventually be released
free(a);
or transferred.
int* b = NULL;
b <- a;
after the transfer, b owns the allocation.
the pointer values don't have to change. a and b can both still hold the same address. ownership isn't a property of the address. it's something the compiler tracks about which pointer is currently responsible for the allocation.
before the move it looks like this.
a -> allocation [owner]
and after.
a -> allocation
b -> allocation [owner]
the original pointer doesn't necessarily become an invalid pointer. it just doesn't own the allocation anymore. that distinction matters, because hecc isn't trying to prevent aliasing. c programs use multiple pointers to the same memory all the time, and banning that would make the language a lot less useful for the kind of programming it's for.
receiving ownership
a pointer shouldn't be able to silently overwrite ownership it already has.
int* a = malloc(...);
int* b = malloc(...);
b <- a;
if both pointers own separate allocations, moving ownership into b would leave its old allocation with no owner. the simplest rule is that a pointer receiving ownership can't already own something. so a typical transfer looks like this.
int* a = malloc(...);
int* b = NULL;
b <- a;
b is explicitly initialized and doesn't own anything yet, so it can take ownership from a. this is one of the places where hecc deliberately asks you to be explicit, because ownership shouldn't get overwritten by accident. there might be other valid ways for a pointer to receive ownership later on, but the basic rule is a clear place to start. ownership moves into a pointer that isn't already responsible for another allocation.
ordinary pointer copies
hecc shouldn't make ordinary c pointer assignments illegal. this is still valid.
int* a = malloc(...);
int* b = a;
the programmer copied the address and made an alias. that's ordinary c and it should keep working. it isn't necessarily the right way to express a tracked relationship between two references, though. the compiler now has two pointers to the same allocation.
a ───╮
├──→ allocation
b ───╯
the compiler might know that a owned the allocation first, but nobody has described how the two pointers relate. if a transfers ownership somewhere else, b is still around. if a gets freed, b might be left dangling. if either pointer gets returned from a function, stored somewhere, or passed through several layers of code, the compiler can lose track of which pointers belong together. so ordinary pointer copies stay allowed, and a copy like this is treated as a borrow of whatever a owns. keeping that relationship intact once the reference leaves the function is what borrows and origins are for, which come up shortly.
ownership through functions
functions are another important ownership case. if hecc can see a function's implementation, it can analyse how pointers are used and infer ownership behaviour where possible. here's an example.
int* create_buffer(void)
{
int* buffer = malloc(...);
return buffer;
}
the compiler can see that buffer gets ownership from malloc() and that the allocation is returned from the function. so the caller ends up with ownership.
int* buffer = create_buffer();
external functions are different. if hecc can't see the implementation, it can't automatically know what happens to a pointer passed in. the function might borrow it, modify the memory it points to, store the pointer somewhere, free it, or take ownership of the allocation. when ownership is moving into a function, hecc can use the same <- syntax as a normal transfer.
function(<- pointer);
this tells the compiler that ownership of pointer is moving into function. after the call, the function is responsible for the allocation and the caller isn't. an ordinary call still means ownership isn't transferred.
function(pointer);
the pointer is passed normally, and the caller keeps ownership unless the compiler can tell otherwise from the function's implementation. that keeps the syntax consistent. <- always means an explicit ownership transfer, whether the destination is another pointer
other <- pointer;
a function
function(<- pointer);
or the caller, when a function hands ownership back out through its return.
return <- pointer;
return <- pointer; is the counterpart to function(<- pointer);. one moves ownership explicitly into a function, and the other moves it explicitly back out to the caller. when hecc can see into a function it can usually figure this out by itself, like it did for create_buffer() above. so the explicit form is mostly for functions the compiler can't see into, or for when you want the transfer to be unambiguous. wherever inference works, you shouldn't need any extra syntax.
consuming ownership
passing ownership in at the call site with function(<- pointer) works, but it puts the contract in the wrong place. every caller has to remember to write it, and the function's own signature says nothing. a function whose whole job is to take ownership, like a destructor, a close that owns its handle, or a container that adopts an element, should declare that once, on itself. hecc lets the parameter carry the transfer.
void destroy(struct Thing* <- t);
the <- on the parameter says destroy takes ownership of t. after the call the caller isn't the owner anymore, and destroy is responsible for freeing it or passing it on. since the contract now lives on the signature, every caller is checked against it, and the mistakes that come from using a consumed pointer can be flagged.
struct Thing* t = create();
destroy(t); /* ownership consumed here */
use(t); /* warning: t was consumed by destroy */
free(t); /* warning: t was consumed by destroy (double free) */
the signature already declares the transfer, so the call doesn't need to repeat <-. the compiler knows every call to destroy consumes its argument. you can still write destroy(<- t) where you want the hand-off to be visible at the call site, and for an external function with an unannotated signature, the call-site <- is still the only way to say it.
round-trip ownership
a function can also take ownership and then give it back. if it gives it back through the return value, there's no need for a new operator. it's just a consuming parameter plus a return <-.
struct Node* rebalance(struct Node* <- tree)
{
/* ... */
return <- tree; /* or a different node that now owns the tree */
}
struct Node* tree = build();
tree <- rebalance(tree); /* consumed by the call, re-owned from the return */
tree gets consumed by the call, so it owns nothing at the assignment and can take ownership again from the return without breaking the rule about overwriting ownership it still holds. a separate operator for this would be redundant.
<-> is for the case those pieces can't express, which is when ownership comes back through the parameter instead of the return, so the return value is free to carry something else. that means passing the owning pointer by reference (a pointer to it), which the function writes back to.
bool grow(int** <-> buffer, size_t extra);
<-> says ownership of *buffer goes into grow and comes back out through *buffer. the function can free or reallocate the allocation, and it writes the owning pointer back through buffer. when it returns, the caller owns again, possibly a different allocation. since ownership comes back through the parameter, the return value can mean whatever the function wants. here it says whether the growth worked.
int* buffer = malloc(...);
if (!grow(&buffer, 128))
; /* handle failure; buffer still owns whatever grow left it holding */
use(buffer); /* buffer owns again, no re-assignment from a return needed */
this is the in/out owner parameter, and it's the one shape the other markers can't express. <- alone consumes and never gives ownership back, return <- sends it out through the return, and <-> says it left through the argument and came back through it. realloc is related, but its transfer is conditional (if it fails, the original stays owned), which is why hecc models it specially. that's covered under realloc() below.
conditionals
ownership gets a lot more complicated once control flow is involved. take this.
int* a = malloc(...);
int* b = NULL;
if (condition)
b <- a;
before the if, a owns the allocation. inside it, ownership moves to b. after it, the compiler has two possible ownership states.
condition == true: b owns the allocation
condition == false: a owns the allocation
you can't just call a or b the owner after the if without looking at both paths. more control flow makes it worse.
if (condition_a)
b <- a;
else if (condition_b)
c <- a;
now the owner could be a, b, or c, depending on which way execution went. a flow-sensitive pass can follow these states through the control-flow graph and require the paths to agree where they join, which works as long as the references stay in view. once ownership escapes, through a pointer that gets stored in a structure, returned, or aliased across a translation unit, a plain flow-sensitive pass loses track. reconnecting the reference to what it depends on is the job of borrows and origins, which come next.
borrows and origins
the one problem ownership leaves open is what happens to a reference once it escapes the scope that made it. a pointer can be returned from a function, stored in a structure, or passed through several layers of code, and at that point the flow-sensitive analysis can't see how the reference relates to the allocation it depends on. an earlier version of hecc tried to solve this with symmetric "tracking groups" that marked a set of pointers as related. that was the wrong shape. the relationship isn't symmetric, and "related" isn't something you can check. it's directional. one pointer borrows from another that owns, and the borrowed pointer can't be used after the owner's responsibility ends. this is also the part of hecc that takes the most vocabulary from rust, which makes it the easiest part to mistake for rust. there's more on that at the end of this section.
borrows
hecc already has two kinds of pointer assignment. <- moves ownership, and = copies a pointer without moving it. the pointer you get from = is a borrow, a reference that doesn't own the allocation and depends on some owner that does.
int* a = malloc(...); /* a owns */
int* b = a; /* b borrows from a */
inside one function this needs no annotation. hecc can see that a owns the allocation and that b was copied from it, so it treats b as a borrow of a and can already warn if b gets used after a is freed or moved.
int* a = malloc(...);
int* b = a;
free(a);
*b = 5; /* warning: b borrows from a, which has been freed */
at runtime the borrow is still a plain int*. the borrow relationship is information for the analysis and erases just like ownership does, so the generated c doesn't change.
origins
a borrow only gets hard to track when it crosses a boundary the analysis can't see through, which means a function signature or a struct definition. to carry the relationship across one of those, a borrow is tied to an origin, a name for the owner (or the owning scope) the borrow depends on. the term comes from the "origins" framing in rust's polonius borrow checker. it fits hecc better than the older word "lifetime", and it avoids the confusion that made hecc drop that word earlier.
an origin is written with a $ sigil. it names a relationship, not a variable, and it only shows up where inference can't reach, which is on signatures and struct fields. the rule it carries is the one the old tracking groups were missing.
a borrow must not be used after its origin ends. an origin ends when the owner it names is freed, moved elsewhere with
<-, or goes out of scope.
freeing showed up above. a move ends an origin the same way, because ownership has left the pointer the borrow was tied to.
int* a = malloc(...);
int* b = a; /* b borrows from a; a is its origin */
*b = 1; /* fine: a still owns the allocation */
int* c = NULL;
c <- a; /* ownership moves out of a, so a's origin ends */
*b = 2; /* warning: b used after its origin ended */
*c = 3; /* fine: c owns the allocation now */
going out of scope is the third case. if the owner is a local that leaves scope, a borrow that outlives it is using an origin that's already ended. everything else is just machinery for carrying that rule across boundaries.
borrows across functions
when a function returns a pointer that borrows from one of its arguments, the return value's origin is that argument.
$a char* skip_spaces($a char* s);
the origin $a ties the returned borrow to s. if a caller frees the underlying buffer and then uses the result, it's using the borrow after its origin ended, and hecc can say so.
char* line = malloc(...);
char* rest = skip_spaces(line);
free(line);
puts(rest); /* warning: rest borrows from line, which has been freed */
in the other order, while the origin is still alive, the same call is fine.
char* line = malloc(...);
puts(skip_spaces(line)); /* fine: line still owns here */
free(line);
like in rust, most signatures don't need this spelled out. when a function has a single pointer parameter and returns a borrow, the result borrows from that parameter and the origin is inferred. you only need the annotation when there's more than one candidate and the compiler can't pick.
$a char* longer($a char* x, $a char* y);
here the result borrows from both x and y, so it's only valid while both of them are. its origin is whichever of the two is shorter.
the annotation pays off at the call site, because it lets a caller be checked without the compiler ever seeing the body of longer.
char* a = malloc(...);
char* b = malloc(...);
char* longest = longer(a, b); /* borrows from both a and b */
free(a);
puts(longest); /* warning: longest borrows from a, whose origin has ended */
the signature alone is enough to catch this. freeing b instead of a gets flagged the same way, and freeing neither until after the last use of longest is fine. without the $a on the signature, longer would look like any other function returning a char*. hecc would have no reason to connect the result to the arguments, the call would stay best-effort, and the misuse would slip through. that's what the annotation is for. it turns an opaque boundary into one that can be checked.
calls that pass a pointer with no origin, and external functions the compiler can't see into, stay best-effort like before. if the relationship isn't stated and can't be inferred, hecc doesn't make one up.
borrows in structures
a structure that stores a pointer has to say whether it owns that pointer or borrows it, because the two mean opposite things when the structure is destroyed.
a field the structure owns is freed when the structure's ownership ends, which lets ownership nest.
struct Buffer
{
int* data; /* owned: released when the Buffer is */
size_t len;
};
a field the structure borrows ties the structure to an origin, and an instance can't outlive it.
struct Slice
{
$a char* data; /* borrows from origin a */
size_t len;
};
a struct Slice now carries the origin $a. building one binds $a to the borrowed buffer, and hecc checks that the slice doesn't outlive it.
char* buffer = malloc(...);
struct Slice s =
{
.data = buffer,
.len = n
};
free(buffer);
use(s.data); /* warning: s borrows from buffer, which has been freed */
owned fields push ownership inward, and borrowed fields push origins outward. nested, self-referential, and mixed-ownership structures still need careful rules, but everything else about structures builds on these two kinds of field.
conditionals again
origins also settle the conditional case from before. when a borrow is valid on some paths and not others, it's only valid after the join if it's valid on every path, and its origin after the join is the shortest of the possibilities. it's the same reconciliation the ownership analysis already does, except now there's a defined relationship to reconcile instead of a guess.
not a borrow checker
the words here (ownership, borrows, moves, origins) come from rust, which makes it easy to assume hecc does what rust does. it doesn't. they share a few words and almost nothing else, and it's better to think of them as different tools that happen to use the same nouns.
rust's borrow checker is a proof system. its job is to make unsafe programs impossible to write, so code that can't be shown safe doesn't compile. to get there, one mutable borrow excludes all others, a move invalidates its source, and ownership is the spine the whole language hangs on. Drop, Send, Sync, and the type system are all built on it. you shape your program around what the checker will accept.
hecc's system is a description. its job is to let the programmer state intent the compiler would otherwise have to guess, so it can flag likely mistakes. it proves nothing and forbids almost nothing. the details are the opposite of rust's, point for point.
- purpose. rust enforces and hecc describes. one is a gate, the other is a note in the margin.
- default. rust rejects anything it can't prove safe. hecc accepts everything and only warns about what it can prove wrong.
- moves. a rust move kills the source, and using it again is a compile error. hecc's
<-only moves the responsibility to free. the source is still a usable pointer, because c depends on aliasing. - aliasing. rust's exclusivity rule (one
&mutexcludes every&) holds up everything else. it's what buys soundness, freedom from data races, andnoalias. hecc has no such rule and isn't after any of those things. it allows any number of live borrows, mutable ones included, and tracks exactly one thing, which is whether a borrow outlives its origin. - place in the language. in rust, ownership is the foundation and you can't opt out of it. in hecc it's a layer on top of ordinary c that erases completely (like bounds, and unlike type unions) and can be ignored entirely. the language underneath is still just c.
the cost of not having rust's exclusivity is that hecc can't promise there are no data races, and it can't use borrows to justify aliasing optimizations. what you get in return is that borrowing can be added to existing c without changing how the code shares memory. so the similarities are real but shallow, and reading hecc as "rust with the checking turned down" gets it backwards. rust decides whether a program is allowed to exist. hecc reads a program you already wrote and tells you what looks wrong.
strictness and compatibility
how much hecc enforces is a setting, not something fixed in the language, and it makes the most sense per file or region instead of for the whole program. there are two ends.
- compatibility is plain c.
=and its escapes are best-effort. they're analysed and warned about only where a problem can be proven, and you're never required to resolve them. existing c compiles and runs unchanged, which is what makes adopting hecc free. - strict means every escaping
=borrow has to be resolved. a copy whose borrow leaves the scope has to become a move (<-), get an origin, or be explicitly marked untracked. a silent unresolved escape is an error, not a warning. ownership, origins, and the leak checks are enforced instead of advisory.
strict doesn't mean "no =". lots of = are perfectly fine borrows, and those stay legal. strict only forbids an escape that's silent, one the compiler can't account for and the programmer hasn't resolved. it closes that one real safety hole and nothing else.
the sensible default depends on the code, not on one global switch.
- new code defaults to strict. when you're writing fresh hecc you get the safety without any ceremony, and you write "good hecc" unless you opt out. it's the same reason a new typescript project turns
stricton. - existing code defaults to compatibility. dropping hecc onto a c tree just builds, and you migrate file by file, switching each one to strict once its ownership is written down.
a file declares its own mode, and you can force a whole tree one way from the command line. hecc --compatibility treats everything as legacy c for the first drop-in, and you peel it back as you go. it's the same pattern as -std= and typescript's strict, nothing new.
there are two caveats. strict raises the floor (no silent escapes), but it still isn't a guarantee. an origin can be written down wrong, and the explicit untracked escape is always there, like rust's unsafe. and strict is only as sound as its boundaries. a strict function calling into compatibility code is only checked as far as that code's contracts go, which is why annotated library headers matter.
losing ownership information
some operations will make it impossible for hecc to keep tracking ownership reliably. something like this
int* pointer = malloc(...);
uintptr_t address = (uintptr_t)pointer;
followed later by this
int* other = (int*)address;
can throw away enough information that the compiler can't reliably tell what ownership relationship was intended. the same goes for arbitrary pointer-to-pointer manipulation, indirect memory writes, complicated casts, and weird enough uses of memcpy(). hecc shouldn't forbid any of these. what it needs is a clear line between code the ownership system understands and code where the programmer did something that makes reliable analysis impossible. past that line, hecc should just stop pretending it knows what happened. that's a big part of the project's philosophy. the compiler doesn't have to handle every possible c program. if the programmer gives it enough information, it can do useful analysis. if the programmer deliberately strips that information out with low-level tricks, ordinary c is still there.
bounds analysis
ownership isn't the only place where extra information can make c safer. take this.
int array[5];
int* pointer = array;
pointer[6] = 5;
the compiler knows array has five elements and that pointer came from it, so it can tell the access is outside the known bounds. that doesn't require turning the pointer into some special runtime type. the information is already in the program.
it helps to think of bounds analysis in terms of how much the compiler knows, which usually falls into three levels. a pointer can be completely unknown, where the compiler has no idea what it points at. it can be inferable, where the pointer's origin gives the compiler bounds on its own, like pointer above. or its bounds can be declared explicitly by the programmer, for cases where the compiler can't figure them out but the programmer knows.
when the information can already be inferred, nothing extra is needed. when the programmer wants to state it directly, hecc can accept an explicit form.
int* pointer = (int[5])array;
this tells hecc to treat pointer as pointing at an array of five ints for analysis. it doesn't change how the pointer is represented at runtime, and it doesn't need to show up in the generated c. at runtime the pointer is still a plain int*, and the array information is only there so the compiler can reason about it.
the same form covers cases where the compiler can't infer anything at all.
int* pointer = get_memory();
the compiler has no idea what this pointer refers to. it could be a single integer, an array, or video memory. the compiler shouldn't pretend every pointer has known bounds. where the programmer knows the bounds and the compiler doesn't, they can be declared.
int* pointer = (int[BUFFER_SIZE])get_memory();
the syntax is still experimental, but the idea is to tell the compiler to treat the returned memory as an array of BUFFER_SIZE integers for analysis, while it stays a plain pointer at runtime.
hecc shouldn't get in the way of ordinary low-level c. this is still valid.
volatile uint8_t* vram = (volatile uint8_t*)0xB8000;
the compiler can't meaningfully work out ownership or full bounds for arbitrary hardware addresses, and there's no reason to force them into the ownership system. that isn't hecc's analysis failing. the information just doesn't exist. the ownership system is a tool you use when it helps. it shouldn't become a requirement for every pointer in every program.
array parameters
the same bounds information can go on function parameters, and c already has syntax for it. it just throws the information away. there are three forms worth separating.
void foo(int array[]);
void fizz(int array[5]);
void buzz(int array[n], size_t n);
all three decay to int* at the abi level, exactly like today, so nothing about the generated code or the calling convention changes. what's different is how much hecc knows.
int array[] says nothing. it's int* with a suggestive spelling, and hecc treats it as an unknown pointer. this is the escape hatch, and it stays the default.
int array[5] is a compile-time bound. inside fizz, an access at index five or higher is a provable violation, and a call that passes an array hecc knows is smaller can be flagged. it's the same idea as c's existing int array[static 5], which already means "at least five elements, and not null." hecc builds on that form instead of inventing a parallel one, and takes the reasoning it allows further.
int array[n] is a runtime bound tied to another parameter. hecc carries the fact that array holds n elements, so an index into array should be below n, and the check ties into the loop analysis below. it's a fat pointer without the fat pointer. the length rides along in a value that's already being passed.
the bound can name a parameter that comes later in the list, like in buzz above. standard c doesn't allow this. the name n isn't in scope yet when array[n] gets parsed, so the natural buffer, size order is an error, and the standard forces you into the reversed void buzz(size_t n, int array[n]). c23 added int array[.n] to forward-reference a later parameter, but not many people are going to reach for it.
this ordering rule is the whole reason the feature is adoptable. c code has been writing (buffer, size) for forty years. an existing declaration like this
void f(int* buffer, size_t size);
becomes this
void f(int buffer[size], size_t size);
with one parameter's type changed and everything else left alone. same order, same names, same abi. callers don't move and don't break. it's a pure annotation swap that gets you the length for free. requiring the size first would mean reordering the signature and touching every call site, and that kind of friction is what leaves a feature technically there and practically dead.
when the bound doesn't resolve to anything, like a misspelled or out-of-scope name, hecc doesn't treat it as a hard error the way standard c would. it falls back to [] semantics, an unknown length, and warns. that keeps the feature additive and non-fatal, like the rest of the analysis.
the length only travels as far as the annotations do. assign array to a plain int*, or pass it to a [] parameter, and the fact is gone, the same way ownership gets lost when it escapes into untracked code. also, don't read too much into the syntax. int array[5] as a parameter is still a decayed pointer, so sizeof(array) inside the function is sizeof(int*), just like in ordinary c. the bracket is a contract for the analysis, not a real array passed by value.
detecting leaks
ownership information can also help the compiler find leaks. take this.
int function(void)
{
int* a = malloc(...);
if (!a)
return -1;
int* b = malloc(...);
if (!b)
return -1;
return 0;
}
if the second allocation fails, a leaks. when hecc knows a owns an allocation, it can follow the control-flow paths and see that the function exits without releasing or transferring that ownership. the goal isn't automatic cleanup. the generated program should stay close to ordinary c, so diagnostics are more useful here. the programmer is still responsible for freeing memory, but the compiler can point out when an ownership path seems to end without the allocation being released or handed off.
defer
the leak above happens because the cleanup is far from the allocation and easy to miss on one path. defer puts the cleanup right next to the acquisition and runs it when the block exits.
int* buffer = malloc(...);
defer free(buffer);
/* use buffer, return early, whatever */
whatever happens after that, whether it's an early return, a break, or falling off the end of the block, free(buffer) runs first. you write the cleanup once, next to the thing it releases.
deferred statements run in reverse order, like a stack.
acquire_a();
defer release_a();
acquire_b();
defer release_b();
acquire_c();
defer release_c();
at the end of the block the releases run release_c(), then release_b(), then release_a(). reverse order is what you want when later resources were set up using earlier ones, so each one gets torn down before the thing it depended on.
a defer is scoped to its enclosing block, not the whole function, which is the same choice zig, swift, and the c2y defer proposal make. a defer inside an if or a loop body runs at the end of that block, and in a loop it runs once per iteration.
lowering
defer is a source transformation. the compiler inserts each deferred statement, in reverse order, before every exit from its block, which produces the c a careful programmer would write by hand.
int* buffer = malloc(...);
if (error)
{
free(buffer);
return -1;
}
free(buffer);
return 0;
it adds no runtime machinery and no hidden state, and code that doesn't use it stays the same. the case that needs care is jumping out of a scope with goto or longjmp, where the end of the block is ambiguous. the --simple-control-flow ruleset restricts those and keeps the expansion well-defined.
defer and ownership
defer and the ownership system work together. a defer free(p) next to p's allocation is what the leak analysis looks for, so an owned pointer with a matching deferred free needs no further proof that it gets released.
it also catches a bug. if ownership of p leaves the scope before the block ends, by being returned or moved with <-, the deferred free(p) would free something the caller now owns. hecc knows p doesn't own its allocation anymore at that point, so it flags the deferred free instead of letting it run.
structs and compound types
ownership extends to structures that contain pointers. ordinary assignment is still an ordinary c copy
b = a;
while an explicit transfer moves the ownership information of the structure's members recursively.
b <- a;
whether a pointer field is owned or borrowed is the distinction from borrows in structures. an owned field is released with the structure, and a borrowed field ties it to an origin the instance can't outlive. nested, self-referential, and mixed-ownership structures still need careful rules, but those two kinds of field are the foundation.
free()
freeing memory should stay as permissive as it is in ordinary c. hecc isn't trying to police every call to free(), and normal patterns have to stay legal.
int* a = malloc(...);
int* b = a;
free(b);
this is allowed. the programmer aliased the allocation and freed it through the alias, which is a completely normal thing to do in c. hecc shouldn't reject it just because the allocation wasn't freed through the pointer that originally owned it.
what hecc can do is warn. when memory is freed through a pointer that didn't come straight from a source the compiler understands (like malloc(), a function known to return owned memory, or something else it explicitly understands), hecc can point out that it can't confidently account for what's being freed. the line isn't between allowed and forbidden c. it's between code hecc understands with confidence and code where the programmer chose to give it less information. in the first case the compiler can reason about the free. in the second it still lets the program compile, but it might mention that it can't be sure the operation is correct anymore.
realloc()
realloc() is especially tricky because it can resize an allocation in place, move it, or fail and leave the original allocation valid. ordinary c already needs careful handling.
int* temporary = realloc(pointer, size);
if (temporary)
pointer = temporary;
since realloc() takes part in ownership, hecc can accept the explicit form.
int* temporary = realloc(<- pointer, size);
the <- says the call is involved in an ownership transfer, and the compiler applies what it knows about realloc() in particular. this isn't an ordinary unconditional move, because realloc() is conditional. if it succeeds, it consumes ownership of the original allocation and the returned pointer becomes the owner. if it fails and returns NULL, nothing moved. the original pointer is still valid and still owns its allocation. hecc can encode both outcomes instead of treating the call as one unconditional transfer.
it's a good example of the compiler knowing the special ownership rules of a known function instead of forcing everything through one generic rule. realloc() behaves the way it does because the c library defines it that way, and hecc can model that directly instead of pretending it's a normal assignment.
type unions
c often represents operations with several possible outcomes through conventions instead of the type system. a function might return NULL on failure, return a special integer value, write to an output parameter, set errno, or expect the caller to check some other state. when you want to spell out a value-or-error relationship, the usual c answer is a wrapper struct.
struct DivisionResult
{
int result;
enum DivisionError error;
};
that works, but it gets repetitive across a bigger api. every possible return type needs another wrapper.
struct FileResult
{
struct File file;
enum FileIOError error;
};
struct StringResult
{
char* string;
enum FileIOError error;
};
struct SizeResult
{
size_t size;
enum FileIOError error;
};
these structures also hold two pieces of information at once, even when they're logically exclusive. a successful operation gives a value. a failed one gives an error. the result isn't really an object that holds both.
hecc adds type unions for values that can hold one of several explicitly listed types.
int | enum DivisionError div(int a, int b)
{
if (b == 0)
return DivideByZero;
return a / b;
}
the return value is either an int or an enum DivisionError. the signature says that directly, without needing a separate wrapper type.
type unions can have more than two types.
int | char* | enum Error value;
a type union holds exactly one of its member types at a time. it's not the same as a struct containing every member at once.
type unions are also the first hecc feature that isn't purely an analysis annotation. ownership and bounds erase completely at code generation. they add information for the compiler and cost nothing at runtime. a type union doesn't erase. it has a real runtime representation, with a discriminator, a size, and an abi. it's the one construct in hecc that costs something at runtime instead of just compiler attention, and that cost is intentional.
the mechanism stays small though. type unions don't add a keyword. the only new syntax is the | type combinator, and existing c constructs like typeof() get a tighter definition instead of being replaced. c programmers have wanted tagged unions for a long time, and this gives them tagged unions without a separate ecosystem or a new keyword. the feature has big consequences but a tiny mechanism, and that's the trade i'm going for.
enums
a type union tells its members apart by type. that's enough for a value-or-error result, where the cases already have different types, but it falls apart when two cases share a type. uint32_t | uint32_t is degenerate, so an event that's either a key-down code or a key-up code, both uint32_t, can't be told apart. what separates those cases is their name, not their type.
the general form gives each case a name and an optional payload. it's c's enum with one restriction lifted, so a variant can carry data, and it turns out that one construct covers three things.
enum Shape {
Circle(float radius),
Rect(float w, float h),
Empty,
};
- a variant with no payload is an ordinary enumerator, so a plain
enum Color { Red, Green, Blue }is the degenerate case. it's now a closed, distinct type the compiler checks exhaustively, which is the enum c should have had all along. - a set of variants told apart by type is the anonymous
T | Etype union, a shorthand for when the types alone are enough to tell the cases apart. - variants with names and payloads are the general sum type, which is what rust's enums are.
you build a value by naming the variant, and you use it by matching, which binds the payload to ordinary locals.
enum Shape s = Circle(2.0f);
switch (s)
{
case Circle(r): return 3.14159f * r * r;
case Rect(w, h): return w * h;
case Empty: return 0;
}
this is the type switch again, just matching a variant name instead of a type. switch (typeof(x)) matches the cases of an anonymous union, and switch (x) matches the named variants of an enum. the match has to cover every variant or have a default, the same exhaustiveness rule described below, and it's simpler here because it compares tags instead of resolving types. inside an arm, the bound names (r, w, h) have the variant's declared types. a binding borrows by default and only moves when the arm consumes an owned payload.
because the variants are a closed, named set, this also fixes the rest of what's weak about c enums. the type is distinct. an enum Shape isn't an int and isn't some other enum, and getting to the underlying integer takes an explicit cast. that cast is the escape hatch, and it's where a safe build can trap on an out-of-range value. variant names are scoped to the enum and written Shape.Circle when a bare name would be ambiguous, so two enums no longer clash over a shared name, while the unscoped names still work for existing code. and the tag's underlying integer type can be fixed, which c23 already allows, as in enum Shape : uint8_t.
so type unions and enums are the same construct spelled two ways. T | E is the anonymous form, with its variants named by their types, and the named enum form is the general one. everything the next sections say about representation, discriminators, exhaustiveness, and abi applies to both.
existing c apis
one of the main reasons hecc is an extension of c instead of a brand new systems language is so existing codebases can be improved gradually. a project shouldn't have to be rewritten in rust, zig, or anything else before it can tell the compiler more.
type unions are especially useful here. existing apis can often get more explicit result types without a separate wrapper struct for every function.
FILE* | enum FileIOError open_file(const char* path);
char* | enum FileIOError read_file(FILE* file);
size_t | enum FileIOError write_file(
FILE* file,
const void* buffer,
size_t size
);
the idea isn't to require a whole new hecc-specific standard library. existing c libraries can keep existing, and ordinary c conventions can still be used where needed. type unions are just another way to describe what an operation can result in, for when you want the compiler to understand it.
that lets hecc features come into an existing codebase gradually. a project doesn't need to replace every api or rewrite every library before it starts getting something out of the extra static analysis.
one piece is still unfinished. value-or-error is already solved, since T | enum Error carries the outcome and, on failure, the reason. a plain value-or-nothing works the same way with a dedicated empty member, T | void, told apart by the discriminator. there's no in-band sentinel. returning NULL from a T | void* would squash every failure into a single "nothing", throw away the error reason, and collide with pointer types where null is a legitimate result. that's the in-band sentinel tagged unions are supposed to get rid of. propagation is the open part. once functions return these types, a caller might want a short way to pass an error up instead of type-switching at every level, something like ? or try. whether hecc gets an operator like that, or goes without and leaves callers to check the type explicitly like c and go do, isn't decided yet.
runtime representation
unlike an ordinary c union, a hecc type union has to know which member is active. that's needed for runtime operations like this one.
typeof(value)
an ordinary c union has enough storage for all of its members, but it doesn't remember which one was stored last.
so hecc type unions use a discriminator.
conceptually, a type like
int | enum DivisionError
is represented as
enum DivisionResultType
{
DivisionResultInt,
DivisionResultDivisionError
};
struct DivisionResult
{
enum DivisionResultType type;
union
{
int integer;
enum DivisionError error;
} value;
};
the exact generated names are an implementation detail, but the representation itself is part of the language's abi.
conceptually, every type union has two parts.
- a discriminator that says which type is active.
- storage big enough, and aligned correctly, for the largest member.
the discriminator and payload form a predictable, stable layout, not compiler-private metadata. a type union doesn't need a garbage collector, a runtime type system, or a hidden runtime library. it's basically just a normal tagged union.
a named enum lowers the same way, except the union members are named by variant instead of by type. that's what lets two variants share a payload type without colliding. this
enum Shape {
Circle(float radius),
Rect(float w, float h),
Empty,
};
becomes this.
enum Shape_tag { SHAPE_CIRCLE, SHAPE_RECT, SHAPE_EMPTY };
struct Shape
{
enum Shape_tag tag;
union
{
struct { float radius; } circle;
struct { float w, h; } rect;
/* Empty carries no payload */
} payload;
};
a variant with no payload only contributes a tag, so an enum where none of the variants have payloads has an empty union and lowers to a plain c enum. at runtime that's an int, and the closed-set and exhaustiveness checks happen entirely at compile time. that's why a c-style enum stays zero-cost, while one with payloads pays for its tag just like a type union does.
discriminator values
discriminators are an internal encoding. the programmer never writes a discriminator value directly. source code compares types, and the compiler lowers each type to an integer index for storage and dispatch (see checking the active type). the encoding is described here because it's part of the abi, not because it shows up in normal hecc code.
the order of the types in a type union decides those values. for
int | enum DivisionError
the mapping is conceptually
0 = int
1 = enum DivisionError
and for
FILE* | enum FileIOError | int
the mapping is
0 = FILE*
1 = enum FileIOError
2 = int
the exact discriminator representation should be defined as part of the hecc abi. the implementation should use a predictable representation instead of letting the backend pick one arbitrarily.
that means the order of the types matters for the abi. reordering
int | enum Error
into
enum Error | int
changes the discriminator mapping, and can change the abi of values exposed across compilation boundaries.
checking the active type
you inspect the active member of a type union with typeof().
int | enum DivisionError result = div(5, 0);
switch (typeof(result))
{
case int:
{
auto value = (int)result;
printf("%d\n", value);
break;
}
case enum DivisionError:
{
auto error = (enum DivisionError)result;
printf("error: %d\n", error);
break;
}
}
each arm is its own scope, so it gets braces when it declares a variable, same as an ordinary c switch. here typeof(result) gives the value's active type as a first-class type value, and the switch matches it against types instead of integers. this is a type switch, not c's integer switch, so case int: is a type and not an integer constant. it lowers to a normal integer switch on the hidden discriminator, but in the source, types are compared directly. typeof() applied to a value gives that value's dynamic type. applied to a type it does nothing, so typeof(int) is just int, which is why the labels can be written as bare types.
checking the active type doesn't automatically change the static type of the variable. result is still
int | enum DivisionError
even inside the matching branch.
the programmer pulls out a member explicitly with a cast, and auto can infer the member's type so you don't have to write the name twice.
auto value = (int)result;
or
auto error = (enum DivisionError)result;
that keeps the union type separate from its possible members. a type union isn't automatically treated as one of its members just because the compiler can work out which member is active on some path.
extraction and construction
extraction reuses c's cast syntax, with one rule that removes an ambiguity. for a union whose members can't convert into each other, like FILE* | enum FileIOError, a cast can only mean extraction. for a union whose members can convert into each other, like int | float, a normal reading of (int)value is ambiguous. it could pull out the int member, or it could numerically convert whichever member is active. those give different results.
int | float x = 3.5f; /* float is active */
int y = (int)x; /* extract int, or convert 3.5 to 3? */
hecc settles this with a rule. a cast whose operand is a type union is always extraction, and a cast whose operand is a scalar is an ordinary conversion. to convert instead of extract, extract first and then convert.
int y = (int)((float)x); /* extract the float, then convert it to int */
the inner cast's operand is a union, so it extracts the float. the outer cast's operand is a plain float, so it converts. there's no ambiguity because the two casts work on different kinds of operand.
construction goes the other way. a value goes into a union when the compiler can tell which member the expression matches.
int | enum DivisionError result = 5; /* the int member */
when the member is ambiguous, the assignment is an error and the programmer picks the member with a cast.
int | long n = 5; /* error: int or long? */
int | long n = (int)5; /* the int member */
a value constructs the member whose type matches it, and the ambiguity only comes up when members convert into each other, like int and long. an enum counts as its own type here and not as a plain integer, so int | enum DivisionError has no overlap like that, and the div example above constructs each member from a bare return with no cast. it's the same cast syntax used for extraction, now picking which member a value gets stored as. no new keyword needed.
exhaustiveness
a type switch has to handle every member of the union or provide a default. an incomplete switch is an error.
switch (typeof(result)) /* result : int | enum DivisionError */
{
case int:
...
/* error: enum DivisionError is not handled */
}
this is also why extracting inside a switch is safe without a separate check. inside each arm exactly one member is statically known to be active, so the cast in that arm ((int)result under case int:) is a validated extraction by construction. an if (typeof(result) == int) guard establishes the same thing on its true branch, and the compiler validates the cast there the same way. checking the active type doesn't change the variable's static type by itself. result stays int | enum DivisionError and the programmer still does the cast, so what the check buys you is a proven-valid extraction, not automatic narrowing. the strict, warning, and permissive rulesets described next apply to extraction done outside that kind of proven context, where the active member can't always be established. inside an exhaustive switch or a guarded branch, it always can.
unchecked extraction and compiler leniency
sometimes a programmer will want to pull out a member without checking the active type first.
FILE* | enum FileIOError result = open_file(...);
FILE* file = (FILE*)result;
the cast says the programmer expects result to hold a FILE* right now.
whether that's accepted depends on the compiler's strictness setting or ruleset.
a strict configuration might reject it.
error: cannot extract FILE* from FILE* | enum FileIOError
without validating the active type
a less strict one might allow it with a warning.
warning: unchecked extraction of FILE*
from FILE* | enum FileIOError
a permissive one might accept the cast with no diagnostics at all.
that lets type unions come into existing c codebases gradually. a project can start with permissive rules while it adds type information, then turn on stricter checking as the code gets more explicit.
so the cast works as an explicit unchecked assertion by the programmer. the compiler might reject it or warn about it depending on the ruleset, but the language doesn't have to make unchecked operations impossible everywhere.
this is a force unwrap, and it has two separate parts. at compile time the compiler rejects it, warns about it, or accepts it, which is the strict, warning, and permissive range above. at runtime, an accepted unchecked cast does nothing extra by default. it erases to a plain member access and costs nothing, and reading the wrong member is undefined behaviour, same as in ordinary c. that fits hecc's rule that extensions don't quietly inject runtime behaviour the programmer didn't write.
the T | void case shows the danger clearly. the void member has no payload, so if result currently holds the empty case, a force unwrap like this
int | void result = some_function();
int number = (int)result;
reads storage that was never written. under the default rules this is unwrap_unchecked, not a checked unwrap.
because the discriminator exists at runtime, a checked unwrap is almost free to offer, and it fits in the opt-in rulesets instead of the base language. a rule like --checked-unwrap would turn every force unwrap into a tag comparison followed by a trap if the asserted member isn't active. with a c backend the trap is an abort (abort(), __builtin_trap(), or a handler you supply), not an unwinding panic, because hecc has no unwinding runtime. that gives you a debug and release split for free. build with the rule and a bad force unwrap aborts right where it happens. build without it and the same casts erase to nothing. it shouldn't be the default, because trapping is runtime behaviour the programmer didn't ask for. but it's cheap to offer since the tag is already there, and that's something c can't do at all.
stable abi
the representation of a hecc type union is part of the language abi.
that matters a lot, because hecc is supposed to live alongside existing c code and not create its own isolated ecosystem. a type union exposed across a library boundary should have a representation that can be understood and reproduced without needing another hecc compiler.
for example, a c implementation should be able to build and read a value matching
int | enum DivisionError
by following the specified abi with ordinary c constructs.
enum DivisionResultType
{
DivisionResultInt,
DivisionResultDivisionError
};
struct DivisionResult
{
enum DivisionResultType type;
union
{
int integer;
enum DivisionError error;
} value;
};
so a function written in c could return the equivalent representation.
struct DivisionResult div(int a, int b)
{
if (b == 0)
{
return (struct DivisionResult)
{
.type = DivisionResultDivisionError,
.value.error = DivideByZero
};
}
return (struct DivisionResult)
{
.type = DivisionResultInt,
.value.integer = a / b
};
}
and a hecc program can use values produced by c code that follows the same representation.
that makes type unions usable for all of these.
- c bindings.
- foreign function interfaces.
- shared libraries.
- mixed c and hecc projects.
- assembly interfaces.
- gradually migrating existing codebases.
the exact in-memory layout should be formally specified, including the discriminator representation, payload alignment, padding, and member order. calling conventions don't need to be invented separately. a type union can follow the target platform's existing abi rules for passing and returning the equivalent c struct.
that split is important. hecc defines what the type looks like in memory, and the platform abi decides how an equivalent aggregate gets passed between functions.
this also settles how a type union crosses a header. a hecc consumer can include a header written in hecc syntax and see int | enum DivisionError directly. a plain c consumer uses the lowered form instead. the transpiler can emit a matching c header that declares the generated tagged struct, and the c side reads the result through its type and value members like any other tagged union. both sides agree because they share the specified abi. what i still need to pin down is which header is the canonical one in a mixed project, whether hecc generates the c-facing header from the hecc one or the two are maintained side by side. either way, a library written in hecc can still be called from c through its lowered struct. what c can't do is parse the hecc-syntax header, which is expected.
transpiling type unions
the stable representation also makes type unions easy to support in a c backend.
a hecc function returning int | enum DivisionError
int | enum DivisionError div(int a, int b)
{
if (b == 0)
return DivideByZero;
return a / b;
}
lowers to a function that returns the tagged union shown earlier (the DivisionResult struct with its discriminator and payload), and each return turns into setting the matching member and tag. there's no magic runtime machinery in the generated c. the extended hecc type just becomes ordinary c constructs that do what the language says they do.
a named enum lowers by the same rule, and the only new codegen is the payload binding in a match. construction is a designated initializer, so this
enum Shape s = Circle(2.0f);
turns into this.
struct Shape s = { .tag = SHAPE_CIRCLE, .payload.circle = { .radius = 2.0f } };
a match becomes a switch on the tag, with each bound name declared as a local pulled from the active union member. this
switch (s)
{
case Circle(r): return 3.14159f * r * r;
case Rect(w, h): return w * h;
case Empty: return 0;
}
turns into this.
switch (s.tag)
{
case SHAPE_CIRCLE: { float r = s.payload.circle.radius; return 3.14159f * r * r; }
case SHAPE_RECT: { float w = s.payload.rect.w, h = s.payload.rect.h; return w * h; }
case SHAPE_EMPTY: { return 0; }
}
the binding is all the new work there is, reading the fields of the active variant into locals. everything else is the tagged union lowering already described. a recursive enum, where a variant refers to itself, uses a pointer in that variant just like a recursive c struct does, and ownership of that pointer follows the owned-field rules. that's the one case that needs the same care structures already need.
an llvm backend can use the same logical representation directly while keeping the abi rules hecc defines.
why type unions exist
c can already build tagged unions by hand out of structs, unions, and enums. the problem is the repetition. every new value-and-error combination needs another wrapper with its own discriminator, payload, naming, and extraction code. a type union states the relationship directly and lets the compiler provide that machinery, while the programmer still gets a predictable, clearly defined representation. that fits what hecc is for in general, which is adding information where c currently relies on convention, boilerplate, or unwritten discipline, while staying compatible with ordinary c through a stable abi.
because the discriminator exists at runtime, a type union can also work as a runtime-polymorphic value. a struct Circle | struct Rectangle | struct Triangle inspected with a typeof switch gives you single dispatch over a closed, known set of types, so you get polymorphism without inheritance, vtables, or templates. that's a side effect of the representation and not a goal, and it doesn't replace generics, which work for any type picked at compile time. when a program only has to handle a fixed set of runtime types, listing them is simpler than a generic abstraction. when it needs an arbitrary type, a type union is the wrong tool.
nullability
there's one union that needs no tag at all, and it's the most common one in c, a pointer that may or may not point at something. today T* mixes up "points at a T" and "might be null," and that mix-up is behind a big share of c's crashes. hecc lets the pointer say which one it is.
int *nonnull p; // never null
int *nullable q; // might be null
int * r; // unknown; today's behaviour, and the default
an unannotated pointer keeps exactly the meaning it has now, so existing code doesn't change and the qualifiers are opt-in. all three are bit-for-bit identical to a plain int* at runtime. there's no tag and no extra storage, because a pointer already has its own empty case. the null value is 0, which is already a valid pointer pattern, and nonnull and nullable only say whether that value is allowed. that's why the earlier point about int* | void doesn't apply here. a type union is tagged, which would make the pointer fat and change its abi. nullability just restricts which values the pointer is allowed to have, so it stays a plain int*. it's a union that's free because one of its arms was already a value the pointer could hold.
the qualifier goes after the *, in the same spot const goes when it describes the pointer and not the pointee.
int *const p; // p is a const pointer to int
int *nullable p; // p is a nullable pointer to int
that placement matters when you declare several pointers together. after the *, each qualifier belongs to its own declarator and doesn't leak onto the next one.
int *nullable p, *nonnull q; // p is nullable, q is nonnull
if the qualifier went before the * it would sit with int in the declaration specifiers, which apply to the whole declaration, and it would smear onto every declarator in the list. putting it after the * avoids that, the same way int *const a, *b; leaves b an ordinary pointer.
what the checker does with this is refuse to let you dereference a nullable pointer until it's been tested. the test narrows the type, the same way a type switch narrows a union to its active arm.
void use(int *nullable p)
{
*p = 5; // flagged: p may be null
if (p) {
*p = 5; // fine: p is nonnull in this branch
}
}
a nonnull parameter moves the check to the boundary. the caller has to establish that the pointer isn't null before the call, and inside the function it can be used directly. that puts the one test where the information actually is, instead of repeating a defensive if in every callee.
this is the same state tracking the ownership system does. a value is in one of a few compile-time states, and an operation is only legal in some of them. here it's narrowed down to the two-state case of a pointer. ownership (owned, moved, freed) and nullability (null, non-null) are the same mechanism with different states. the general, user-defined version is typestate, which is in the explorations.
transpilation
the first implementation of hecc will probably be a transpiler. the compiler would parse the extended syntax, do semantic and ownership analysis, and generate ordinary c.
hecc source
|
v
parser and analysis
|
v
generated C
|
v
C compiler
|
v
binary
a lot of the extensions can disappear entirely after analysis. this
b <- a;
becomes
b = a;
and this
int* pointer = (int[5])array;
becomes
int* pointer = array;
the goal is to keep the generated c as close to the original program as possible. hecc should mostly be adding information for analysis, not secretly generating a completely different program.
this also gives a project two separate ways out, which matters when you're asking people to adopt a dialect. a project can transpile once, commit the generated c, and carry on as a normal c project that doesn't need hecc anymore. or it can keep hecc as its compiler and treat the generated c as a fallback it can drop back to whenever it wants. either way the source is never trapped inside the hecc toolchain. if the compiler stops being maintained, the code it already produced keeps working. that's the main structural difference from earlier safe-c dialects, which relied on a runtime and couldn't hand back clean c.
a future llvm backend could share the same frontend.
hecc source
|
v
parser and analysis
/ \
v v
C backend LLVM backend
the c backend is the easiest place to start, because it lets me experiment with the language without having to build a whole backend first.
the same code generation step can sometimes make the emitted c faster and not just safer, by turning facts the analysis proved into hints the backend understands. a non-aliasing fact it established becomes restrict, a proven bound becomes a __builtin_assume that lets the backend drop a redundant check, and a nonnull becomes the platform's non-null attribute. hecc only emits a hint it can back up, never a guess, since a wrong restrict is undefined behaviour and not just a missed warning. it's a small opt-in bonus and not a goal, but it's unusual for a safety layer to give any cycles back at all.
implementation
transpiling to c keeps the compiler small. everything after the emitted c (register allocation, instruction selection, code generation, optimization) belongs to the existing c compiler, so hecc is a frontend, an analysis stage, and a pretty-printer, and not much else. most of what makes a compiler big gets inherited instead of written.
the analysis is where the actual work is, and two decisions already made in the language design make it cheap to build.
the first is that hecc gives diagnostics, not guarantees. since it only warns about what it can prove wrong, instead of proving there are no errors, the analysis is allowed to be approximate. it can cap iteration, give up on cases it can't follow, and accept false negatives, because a missed warning is allowed. a sound checker can't do any of that. it has to reach a precise fixpoint and account for every path, which is a big part of why rust's borrow analysis, and polonius especially, are expensive. the same choice that makes hecc quieter to use makes its analysis much cheaper to compute. precision becomes a dial you turn only as far as the diagnostics stay useful.
the second is that ownership contracts live on signatures. a function is checked against the summaries of the functions it calls (which arguments are consumed, which returns are owned, which are borrows) and never needs to see their bodies. that keeps the analysis intraprocedural, roughly linear in the size of each function, and parallel across functions once the summaries are collected. it never needs whole-program analysis. the annotations aren't just for the programmer. they're also what keeps the analysis from going quadratic.
underneath, it's ordinary compiler engineering. an arena holds the ast and ir and gets released per translation unit, which is faster than allocating per node and also simpler, since there are no individual lifetimes to track. it's the arena pattern hiv describes, used on the compiler itself. identifiers are interned to integers at lex time, so every comparison after that is a single word. the flow-sensitive checks (ownership, leaks, borrows) run as a standard forward dataflow over each function's control-flow graph, with a lattice, transfer functions, and a worklist run to a fixpoint, and bounds done as interval analysis in the same framework. none of it is exotic. it's the textbook approach, kept cheap by the two decisions above.
some of this is worth doing from day one because it's structure, not optimization. the arena, the interner, and the cfg-plus-worklist shape of the analysis are the right shape, and retrofitting them later means a rewrite. the rest can wait until a profiler asks for it. that means parallelism across functions, flat index-based node arrays instead of pointers, and incremental caching that memoizes a function's analysis on a hash of the function and the summaries it depends on. that last one is what would eventually give editor-level responsiveness, so it's worth writing the passes as pure functions of their inputs now, before anything gets cached, to keep that option open.
my guess is that a transpiler this size won't be slow, and the real risk is over-engineering the compiler before the analysis is even correct. the arena and the interner are worth having from the start because they're simpler, not because they're faster. everything else waits for a measurement.
runtime checks
everything above is static. the compiler proves what it can, warns about the rest, and it all erases. some safety can't be established statically though, especially in the plain = code that hecc deliberately leaves unchecked so it can be adopted at all. for that, hecc can insert runtime checks.
runtime checks cost cycles, so they're never on by default. that would penalize adopted code and break the additive rule. they're opt-in build tiers, in the spirit of -fsanitize or zig's safe release modes, so something like hecc --safe, or a more specific selection like --checks=bounds,ownership,tags,overflow. a release build inserts nothing.
this layer works alongside the static one instead of duplicating it. static analysis covers the parts written with <- and origins, for free, at compile time. runtime checks cover what static analysis can't see, most importantly the unchecked = code. together they reach a lot more than either one alone, and a check the compiler already proved statically can be dropped.
these are the checks hecc is in a good position to insert, because it already has the information for them.
- extraction (described above). a force unwrap
(T)unionbecomes a tag comparison and a trap on mismatch. it's nearly free, since the discriminator already exists. - bounds. where bounds analysis knows a pointer's extent, either inferred or declared with
(int[N]), an index or dereference past it traps. this is more targeted than a generic sanitizer's shadow memory. hecc checks against the intended extent it already knows, and inserts nothing where it has no bounds. - ownership. since hecc knows the owner and where
freehappens, a safe build can poison freed allocations, trap on a dereference of a borrow whose owner has been freed, and trap atfreeon a double free or a free through a non-owner. - signed overflow. hecc can define signed overflow as two's-complement wraparound outright (what most hardware already does, close to
-fwrapv) so it stops being undefined, or trap on it in a safe build. you get a defined result or a loud one instead of c's silent ub. this is the one piece of undefined behaviour hecc pins down by default. the approach is case by case, taking a single ub and giving it a predictable result where the cost is acceptable, not trying to fix all of c. - null (optional). trap on a dereference the analysis can't prove is non-null.
on a violation the program traps (abort(), __builtin_trap(), or a handler you supply) instead of doing an unwinding panic, since hecc has no unwinding runtime.
there are two limits to this. first, these checks make a violation fail fast instead of corrupting things silently. they don't make the program correct, only loud. second, hecc's runtime layer targets the kinds of bug it models (bounds, ownership, tags, overflow) and isn't a general memory sanitizer. for arbitrary heap corruption or uninitialized reads, something like addresssanitizer still covers more, and nothing stops you from running both. what hecc has going for it is that its static information lets it insert cheaper, narrower checks than a tool working blind, and skip the ones it already proved.
diagnostics
because hecc tracks how ownership, origins, and bounds flow, it knows why something is wrong and not just that it is, and the diagnostics should show that. a use-after-free isn't just a line number. it's a path. here's where the allocation was owned, here's where ownership moved out, and here's where the borrow got used afterward. hecc has all three points and should print all three, more like rust's or elm's errors than a terse c compiler's.
this is a design goal more than a feature, but it isn't cosmetic. the tools in this space that people actually keep using are the ones whose output tells you what to do next. an analysis that's right but unreadable gets silenced, and a silenced checker checks nothing. hecc's annotations are opt-in, so a diagnostic also has to justify the annotation that produced it. it has to show that writing <- or an origin bought you a real check you can understand, or people won't bother writing them.
hiv
ownership and strict mode are only as good as the contracts on the functions a program calls, and most of those calls end up in the standard library. hecc understands a few standard functions directly (malloc, free, realloc, strdup), but otherwise the platform libc is an opaque boundary the analysis just has to trust.
hiv is hecc's standard library, and it's meant to close that gap. it's a reimplementation of the c standard library, written in hecc itself, so its functions carry ownership decorators natively. they say which arguments are consumed, which returns are owned, and which are borrows and from where. that turns the whole standard library into an annotated boundary strict mode can check across, instead of a wall where the analysis goes blind. and since hecc transpiles to ordinary c, hiv is still a normal c library for callers who don't use hecc.
hiv gives every allocating function two forms. the default keeps c's signature and carries ownership (malloc(size), strdup(s), free(p)), so existing code compiles unchanged and hecc still knows what's owned. the _with form takes an explicit allocator (malloc_with(&a, size), free_with(&a, p)) for when the caller wants to pick where the memory comes from. the _sized variants pass along the buffer size the caller already knows, which feeds hecc's bounds analysis, and they compose with _with.
hecc and hiv back each other up. hecc tracks who owns what and who frees it, and hiv states the ownership of every call and makes the allocator reachable through _with. an hiv guarding allocator (poisoning freed memory, quarantining, guard pages, double-free detection) is the runtime safety net from runtime checks, just provided at the library boundary instead of by codegen. static hecc on top of a guarding hiv allocator covers both the static and the runtime side, end to end.
none of it is required. hecc works against the platform libc, and hiv works as an ordinary c library without hecc. it reimplements the iso c interface instead of inventing its own, so it isn't a new ecosystem you have to buy into. using both together is the maximal setup, not a requirement.
lexical conveniences
a few conveniences live entirely in the lexer. they change how the source is spelled, not what it means, and they're gone before analysis or code generation. numeric literals can use _ as a digit separator, like 1_000_000 or 0xFF_FF. c's grammar already tokenizes that as a single number and only rejects it when converting it to a value, so hecc just relaxes that step without touching the tokenizer. binary literals like 0b1010 (already standard in c23) and an explicit octal 0o755 in place of c's error-prone leading-zero 0755 round out the number formats. block comments nest, so /* ... /* ... */ ... */ closes where you'd expect. each of these strips down to the plainest possible c (1000000, 10, 493, or nothing) and costs nothing.
raw string literals are the one heavier item, and they're worth it. they're spelled the way c++ spells them, R"(...)", with an R"tag(...)tag" form for when the contents contain )". everything between the delimiters is taken verbatim, newlines and backslashes included, with no escape processing. that's what makes an embedded shader, regex, or chunk of sql bearable instead of a wall of \n\". unlike the others this isn't a pass-through. c has no raw strings, so hecc re-escapes the contents into an ordinary c string literal on the way out. it still erases to a plain const char[] and doesn't touch the analysis or the abi, so it's still just lexer sugar. it's worth including because it removes real pain, not because it's free.
the list is meant to stay short. cosmetic syntax only gets in if it either costs nothing or removes real, recurring pain, and only if it matches where c or the c family is already heading. it's not where hecc spends its attention, and the bar for adding to it is high.
keeping it small
the biggest risk to hecc is the one every c successor has run into, which is solving each hard case by adding another keyword, type, or library component until the small language turns into a big one. the defence is a hard line between the core and everything else.
the core is what hecc claims to be, and it's kept small. it's ownership, borrows and origins, leak detection, and defer, then bounds and array parameters, then sum types (enums and the anonymous T | E unions) with nullability. ownership and nullability are the same idea underneath, tracking which of a few compile-time states a value is in and gating operations on that, so they count as one mechanism and not two. these all support each other, they all erase to ordinary c, and none of them is needed to compile existing code. that set is the language.
even inside the core, keywords are kept rare. nonnull and nullable are contextual. each one means something in exactly one position and doesn't reserve the word anywhere else, so existing code that uses those as identifiers keeps working. defer is the only keyword that introduces a statement, and it's worth it. it removes a whole class of leak, it lowers to ordinary c, and it matches where c itself is going with the c2y proposal.
everything after this point is on the other side of the line. these are explorations held to the same rules (additive, erasable, opt-in), but they aren't part of what hecc is yet. some might make it into the core, some might stay experiments or move into a separate tool, and some might get dropped. keeping them clearly separate is what lets the core stay small while i keep thinking about the rest. c's size is part of why people like it, and a big hecc-specific standard library or a pile of always-on features would work against the whole idea. the compiler understands a few standard functions (malloc(), free(), realloc(), strdup()) without asking you to adopt a new standard library first.
beyond the core
the rest of this document is exploration. each piece follows the same rules as the core. it adds information, it erases to ordinary c, and it's opt-in. but none of it is settled, and none of the features above depend on it. it's here because it's worth thinking through, not because it's decided. typestate is the general form of the state tracking that ownership and nullability already do. effects and distinct types are separate ideas that would each stand on their own. they add very little syntax. the effect contracts and distinct are contextual keywords, and typestate adds none, since it reuses distinct, :, |, and ->.
typestate
nullability is a small case of a bigger pattern, and so is ownership. a nullable pointer is a value in one of two states, null or non-null, where a dereference is legal in one and not the other, and a test moves it between them. an owned pointer has the same shape. it's owned, moved-out, or freed, and free is legal on an owner and a bug on a pointer that's already been freed or moved. both are the same mechanism running on fixed, built-in states. a value moves through a set of compile-time states, and an operation is only allowed in the states where it makes sense.
typestate exposes that mechanism directly so a programmer can declare their own states. the states only exist at compile time. the value is still an ordinary FILE* or int at runtime, and the transitions erase like the rest of the analysis. a distinct type can list the states it moves through, and a function can say which state it needs and which one it leaves behind.
typedef distinct FILE File : open | closed;
File* open_file(const char* path) : open; /* result is in state open */
size_t read(File* f : open, void* buf, size_t n); /* requires open, stays open */
void close(File* f : open -> closed); /* open on entry, closed after */
the : state on a parameter is a requirement, and a -> b is a transition. hecc tracks the state through the caller and flags an operation used in the wrong state.
File* f = open_file("data");
read(f, buf, n); /* fine: f is open */
close(f); /* f: open -> closed */
read(f, buf, n); /* warning: read requires open, f is closed */
the interesting case is the one c has never been able to express, which is an api protocol, the order a library has to be called in. "open before read, don't use after close." "lock before touching the field, unlock after." "init before any other call." today these are enforced only by documentation and habit. typestate lets the header carry the rule so the compiler checks every caller against it, the same way ownership and nullability already do for their built-in states.
this is the most speculative feature here and the heaviest conceptually, so it stays strictly opt-in and strictly diagnostic. it proves nothing, forbids nothing, and an = or a cast drops the state the same way it drops ownership. what it gets you is that the two features it generalises stop being special cases hardcoded into the compiler and become visible examples of one mechanism the programmer can use. that's the kind of trade hecc likes, one more mechanism that removes the need for several, instead of several that each handle one case.
effects
c has a couple of function attributes in this area, pure and const, but they're leaf annotations the programmer asserts and the compiler mostly trusts, not a system that checks and propagates anything. hecc can carry a small set of effect contracts and enforce them across the call graph, covering whether a function allocates, whether it can fail, and whether it can block.
void render(struct Scene* s) no_alloc; /* allocates nothing, transitively */
int step(struct Machine* m) no_fail; /* has no failure path */
void poll(struct Device* d) no_block; /* never blocks */
the contract is checked, not assumed. a no_alloc function can't call malloc, or any function that isn't no_alloc itself, and since hecc can see bodies it can follow calls down to the leaves and infer the effect where nobody wrote it down. this gives embedded, kernel, and real-time code something it's always wanted and c never offered, a way to prove a whole call path never allocates or never blocks, checked at compile time instead of found out in the field. it goes hand in hand with the bounded-loops ruleset below, since both exist for worst-case execution reasoning.
no_fail connects to type unions. a function returning T | enum Error has its failure in the type, and no_fail claims the error arm is never taken, so the two describe the same property from opposite ends, one in the return type and one in the contract. like everything else, effects are contextual keywords, opt-in, and erasable. they add no runtime code and disappear from the generated c, leaving only the diagnostics behind. an external function the compiler can't see into needs the contract on its header, same as with ownership.
distinct types
a typedef in c is a transparent alias. typedef int Celsius; gives Celsius a name, but it's still int in every way that matters. you can assign a raw int to it, pass a Fahrenheit where a Celsius is expected, or hand a plain count to a function that wants a user id, and the compiler won't say anything. the name documents an intent the type system doesn't enforce.
hecc adds a marker that makes the alias its own type.
typedef distinct int Celsius;
typedef distinct int Fahrenheit;
typedef distinct int UserId;
Celsius, Fahrenheit, and UserId are now three different types, separate from int and from each other, even though all three are stored as int. mixing them, or going between one of them and a raw int, takes an explicit cast.
Celsius c = 20; // fine, an integer literal used as Celsius
Fahrenheit f = c; // flagged: different types
int temperature = c; // flagged: distinct type into plain int
Celsius c2 = (Celsius)68; // explicit, allowed
this catches the boring but expensive kind of bug where two quantities share a representation but not a meaning, like temperatures in the wrong scale, an order id used as a user id, or a byte count passed as an element count. the cast is the escape hatch, in the same spirit as = for ownership. when you really do mean to cross the boundary you say so, and the crossing shows up in the source.
it stays additive and erasable. a plain typedef works exactly like before, so distinct is opt-in, and a distinct type is just its underlying type at runtime, with the same size, layout, and abi. it only exists for the compiler's checks and disappears in the generated c, where Celsius is just int again. it's only worth adding if the unit and id mix-ups it catches are a real problem in a given codebase, and it's below the line because it has nothing to do with the memory and state safety that hecc is actually about.
rulesets
not every problem in c comes straight from memory management or the type system. a lot of them come from how programs are structured, and c gives programmers a lot of freedom there. control flow can jump around a function arbitrarily, and loops can run forever.
hecc could offer optional, enforceable rules meant to push toward simpler and more predictable code. these rules aren't part of the language, and none of them are on by default. you should always be able to write ordinary c-like code without any of them. projects opt into whichever rules fit the guarantees or style they want, and the rules could just as well live in a separate checking tool instead of the compiler. they're conveniences for writing code that's easier to debug and analyse, not language features.
the general idea is that a lot of problems can be avoided by cutting unnecessary complexity instead of adding more abstractions to the language. simpler control flow is easier to analyse, and bounded loops make execution easier to reason about.
1. simple control flow
unrestricted control flow can make programs a lot harder to reason about. c has goto and longjmp(), which let execution move through a program in ways ordinary structured control flow doesn't represent. an optional rule could ban these and require structured control flow instead.
that's not saying goto and friends are useless. people use them deliberately, especially in low-level c. but arbitrary jumps make control-flow analysis more complicated, and they make ownership analysis, leak detection, and reasoning about program state in general a lot harder. restricting control flow to structured constructs gives both the programmer and the compiler a clearer picture of how execution can move through a function. that's especially useful for hecc, because ownership can move along different control-flow paths, and simpler control flow makes those paths easier to analyse. this could be turned on with a compiler flag, --simple-control-flow, and with it on, things like goto and longjmp() could be rejected or diagnosed by the compiler.
2. bounded loops
for code that needs worst-case execution guarantees, like embedded systems, real-time work, or anything where unbounded iteration is dangerous, it can help to require that every loop has a known maximum number of iterations. that's not a claim that every loop can be bounded, and it's not a default. it's an opt-in rule for projects that want it.
some loops already carry their bound. for a simple for loop the compiler can see it directly.
for (size_t i = 0; i < 100; i++)
...
that loop can't run more than 100 times. other loops don't carry a bound, and under this rule the programmer has to supply one. take a loop that polls a device until it's ready.
while (!device_ready())
...
nothing here limits the number of iterations. where you need worst-case guarantees, you cap it.
for (size_t i = 0; i < MAX_POLLS; i++)
if (device_ready())
break;
the loop can still exit early. what matters is that there's an upper limit on how long execution can stay inside it. the exact syntax for declaring a bound on loops where it can't be inferred is still open. bounded loops make worst-case execution easier to reason about and static analysis more predictable, which is why they're useful in the domains that want them, and why they stay optional everywhere else. this would be turned on with --bounded-loops. the compiler would enforce bounds where it can figure them out and require an explicit bound where it can't.
prior art and answers
hecc isn't the first attempt to add safety information to c, and it's fair to ask why it would do any better than the ones that didn't survive.
cyclone was the closest ancestor, a safe c dialect from at&t and cornell. it didn't last, and part of the reason is that its safety relied on runtime machinery like fat pointers, so what it produced wasn't ordinary c. hecc is built to avoid that. ownership and bounds erase at code generation, and the transpiler emits plain c, so a project never gets trapped inside the toolchain. if hecc stops being maintained, the c it already produced keeps working. that's the one structural advantage it has over the dialects that came before, and it's my answer to "dialects die." the compiler might, but the code won't.
zig and rust solve problems hecc doesn't even try to, and they solve them better, in zig and in rust. that's kind of the point. adopting either one means porting, and neither can retrofit an existing c codebase in place. zig already has tagged unions and error unions that are nicer than hecc's, inside zig. rust already has sound ownership, inside rust, at the cost of a borrow checker and of the unrestricted aliasing c relies on. hecc is aimed at code that is c and will stay c, which those languages can't reach without a rewrite.
the closest match in spirit is gradual typing, not another systems language. typescript, mypy, and sorbet are unsound by design. you can silence them with an escape hatch and adopt them one file at a time, and they check intent the programmer states instead of intent the tool has to guess. they're still hugely useful. hecc makes the same deal for memory responsibility. <- is closer to const than to a rust move. it's an advisory contract the compiler checks where it can, easy to get around when you mean to, and useful anyway. nobody says const is worthless because a cast can strip it.
looking at it this way also makes the limits clear.
- hecc doesn't make programs memory-safe. it gives diagnostics, not guarantees. double-free and use-after-free can still be written.
- ownership analysis is best-effort. borrows and origins reconnect references that escape through returns, struct fields, and annotated signatures, but the checker warns instead of proving anything. external functions and pointer-to-integer casts still make it stop pretending, and nested or self-referential structures need more rules.
- error propagation has no sugar, and might never get any. the value representation is settled, but whether a
?-style operator is worth adding isn't. - the rulesets are advisory, off by default, and could live in a separate tool. they aren't part of the language.
- typestate and effects are the most speculative parts and the least worked out. they generalise mechanisms hecc already needs (nullability and ownership state, allocation and failure contracts), but user-defined states, effect inference across big call graphs, and the exact syntax are all still open.
these limits are real. the claim i'm willing to defend is narrow, and it's the one worth making. hecc is c with tagged unions and best-effort ownership and bounds diagnostics, adoptable gradually, interoperable through a stable abi, with no path that traps a codebase inside the hecc toolchain.
other c dialects and extensions
hecc is far from the first attempt to improve c. the others fall into a few different approaches, and hecc only belongs to one of them.
evolution languages, like c2 and c3. these clean up c itself, with modules instead of the preprocessor, better error handling, slices, and some generics. they're real improvements, but they're separate languages with their own compilers. you write c2 or c3. you don't upgrade your existing .c files in place. hecc is the opposite bet. it isn't a nicer language to switch to. it's a layer on top of the c you already have.
gradual safety extensions, like checked c, clang's -fbounds-safety, and sparse. these are hecc's closest relatives. they're annotations added to real c, checked incrementally, backward compatible, and adoptable file by file. the difference is what they check. checked c and -fbounds-safety add spatial safety, meaning checked pointer and array types that carry their bounds, and the linux kernel's sparse checks address spaces and locking through tags like __user and __rcu. all of them share hecc's additive, tool-checked model, and sparse in particular shows it scales, since the kernel has run on this kind of opt-in annotation for two decades. hecc overlaps with them on bounds, but most of its effort goes into the area they mostly leave alone, which is temporal safety (ownership, use-after-free, double-free, leaks), plus type unions. if a project wants spatial safety, -fbounds-safety is real and shipping today. if it wants ownership, that's where hecc comes in.
restrict-and-verify, like misra c and frama-c. misra restricts c to a safer subset enforced by linters, and hecc's optional rulesets are a lighter version of the same idea. frama-c goes the other direction and adds a contract language for formally proving properties. hecc sits in between. it's more than a lint and much less than a proof, with best-effort diagnostics and local guarantees but no verification.
the cautionary supersets, objective-c and c++. both started out as c supersets. objective-c shows one can succeed if it has a backer and a clear niche. c++ shows the other outcome, where a superset keeps growing until it's a second, much bigger language. hecc's insistence on staying small, and being willing to cut features, is there specifically so it doesn't go the way of c++.
out of all of them, hecc's slot is narrow. it's temporal safety as an additive, erasable extension that stays ordinary c. the evolution languages ask you to switch, the spatial extensions cover a different area, the verifiers want proofs, and the old dialects needed runtimes. hecc is the one aimed at ownership, added in place, with nothing taken away. to be fair, it's also the one that's still on paper while most of the others already ship.
what hecc is trying to do
hecc isn't trying to make c safe. it's trying to help the programmer write safer c, by giving them a way to state intent the compiler otherwise couldn't see. ownership transfers say who's responsible, borrows record what a reference depends on, bounds get checked when the compiler already knows them, and you can provide more information when you need to. none of this has to stop anyone from writing ordinary c. the difference is that hecc gives you the option to say more when saying more helps the compiler understand the program.
there are still big problems to solve. borrows and origins give escaping references a defined relationship to track, but the exact rules for nested and self-referential structures, for origins through function pointers, and for reconciling origins across complicated control flow still need working out. external functions need contracts. unions, realloc(), and indirect pointer manipulation all bring their own problems. that's expected, since hecc is still an experiment. the central question is whether a fairly small number of extensions can give enough information to make static analysis a lot more useful while keeping the experience of writing c. ownership comes first, borrows and origins follow from the problem ownership creates, and everything else should be judged by the same principle. add information where c is ambiguous, but don't add a whole new language.