name
hecc - hachem's experimental c compiler
synopsis
hecc [--why-c] [--how-hecc-is-built] [--additive-by-design] [--ownership] [--owners] [--receiving-ownership] [--ordinary-pointer-copies] [--ownership-through-functions] [--conditionals] [--borrows-and-origins] [--strictness-and-compatibility] [--losing-ownership-information] [--bounds-analysis] [--detecting-leaks] [--defer] [--structs-and-compound-types] [--free] [--realloc] [--type-unions] [--enums] [--existing-c-apis] [--runtime-representation] [--discriminator-values] [--checking-the-active-type] [--extraction-and-construction] [--exhaustiveness] [--unchecked-extraction-and-compiler-leniency] [--stable-abi] [--transpiling-type-unions] [--why-type-unions-exist] [--nullability] [--transpilation] [--implementation] [--runtime-checks] [--diagnostics] [--hiv] [--lexical-conveniences] [--keeping-it-small] [--beyond-the-core] [--typestate] [--effects] [--distinct-types] [--rulesets] [--prior-art-and-answers] [--other-c-dialects-and-extensions] [--what-hecc-is-trying-to-do]
description
hecc (pronounced "heck") is an experimental c compiler. c often doesn't tell the compiler what you meant. a pointer assignment could be an alias, an ownership transfer, or something only you understand. the compiler sees the values but usually not the intent.
hecc tests whether a few explicit extensions let you write that intent down for the compiler to check and build on. it doesn't turn c into rust, c++ or another big systems language, and it isn't an analyzer for code you never touch. you write in it, and where you use the extensions (ownership transfers, borrows, tagged unions), you get expressiveness and checks plain c can't give.
think of it as gradual typing for 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 check intent you state rather than guess it.
none of the features are new. c safety work has tried them all, and the bet is the combination under one hard rule. everything erases to ordinary c, nothing is required, and code never gets trapped in the toolchain. to keep "small" honest, the document has a small core, which is what hecc claims to be, and explorations past a divider that follow the same rules but aren't core yet.
a lot of the semantics are still open. this is my working record of where the project is going and why the features look the way they do, not a finished spec.
why c?
c has plenty of problems, but size isn't one. it's easy to learn, has a small standard library, and is small enough to understand and replace piece by piece. hobby os developers write their own allocators, containers, runtimes, standard libraries and even compilers, and alternative c libraries are countless.
c++, rust and zig solve problems c doesn't, but they're far bigger languages with far bigger ecosystems. hecc shouldn't rebuild them inside c. solving every problem with another type, annotation, keyword, library piece or compiler feature would turn c into what it tried to avoid.
so hecc doesn't aim for a completely safe c. it asks how much more the compiler can be told while the language still looks like c and keeps what makes it worth using.
how hecc is built
at least at first, hecc is a transpiler. it parses the extended syntax, runs its analysis, and outputs ordinary c for an existing compiler. most extensions are only information for the analysis, so once checked they erase and the output looks hand-written. that's what later sections mean by "generates" some c or "erases at code generation". the full pipeline, the llvm backend, and what this design gives a project are under transpilation.
additive by design
everything in hecc is meant to be strictly additive. using a feature gets you something, skipping it costs nothing, and existing c compiles and runs unchanged.
most tools that improve c take something away first. rust wants a rewrite, static analyzers bring false positives, and sanitizers cost runtime. hecc's floor is ordinary c with no penalty, and every feature is opt-in above it.
ownership shows this best. = is plain c assignment, claims nothing about ownership and is never checked, so existing aliasing and borrowing keeps working. <- and its annotations are opt-in, for where the guarantee is worth the typing. memory safety gets added a line at a time instead of gating the build. type unions, bounds annotations and the rulesets are optional too, and none change what unannotated code means.
this rule is firm. hecc never punishes you for saying nothing, so a feature that made existing c warn, fail or need edits before adoption doesn't belong in it.
opt-in can sound weaker than safe by default, but it changes how you compile, not how much safety you can reach. c programmers already opt in, since almost nobody ships serious c without -Wall -Wextra -Werror, a sanitizer, or a misra-style ruleset, and hecc's features are the same habit.
a hecc codebase should still end up using <-, origins, and the strict and runtime tiers. which of them a build enforces is project policy, like -Werror, with strict for code it wants fully checked and compatibility for c it's still adopting. so "good hecc" is enforced, not just hoped for. how those defaults get picked is under strictness and compatibility.
every language has a notion of correct use. leaky rust (allocate and mem::forget, or build a reference cycle) compiles but isn't correct rust. = everywhere is legal hecc in the same way, a place to start rather than the goal.
ownership
the main thing i'm exploring is pointer ownership. take this ordinary c.
int* a = malloc(...);
int* b = NULL;
b = a;
at runtime a and b just share an address. the compiler can't tell if that's a second reference, a hand-off of who frees the memory, a copy where a stays responsible, or a mistake. hecc tries making ownership transfers explicit.
int* a = malloc(...);
int* b = NULL;
b <- a;
<- moves ownership of the allocation from a to b without changing runtime behaviour. once checked, it erases to just this.
int* a = malloc(...);
int* b = NULL;
b = a;
= copies a pointer and says nothing about ownership, while <- says 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, though both pointers may still hold the same address. ownership lives in the compiler's tracking of which pointer is responsible, not in the address.
before the move it looks like this.
a -> allocation [owner]
and after.
a -> allocation
b -> allocation [owner]
the old pointer isn't necessarily invalid. it just no longer owns the allocation. hecc doesn't try to prevent aliasing, since c programs rely on it constantly and banning it would make c far less useful.
receiving ownership
a pointer shouldn't silently overwrite ownership it already has.
int* a = malloc(...);
int* b = malloc(...);
b <- a;
if b already owned an allocation, moving into it would leave that one with no owner. so the simplest rule is that a pointer receiving ownership can't already own something. a typical transfer looks like this.
int* a = malloc(...);
int* b = NULL;
b <- a;
b is explicitly initialized and owns nothing yet, so it can take ownership from a. hecc wants this spelled out so ownership never gets overwritten by accident. other ways to receive ownership may come later.
ordinary pointer copies
hecc shouldn't make ordinary c pointer assignments illegal. this is still valid.
int* a = malloc(...);
int* b = a;
that's an ordinary alias and should keep working, though it isn't necessarily a good way to express a tracked relationship. the compiler now has two pointers to one allocation.
a ───╮
├──→ allocation
b ───╯
even if the compiler knows a owned it first, nothing says how the two relate. if a hands ownership elsewhere or gets freed, b may be left dangling. once either is returned, stored or passed through layers of code, the compiler can lose track of which pointers belong together. so copies stay allowed and count as a borrow of whatever a owns. borrows and origins, covered shortly, keep that link once the reference leaves the function.
ownership through functions
if hecc can see a function's body, it can analyse how pointers are used and infer ownership where possible. here's an example.
int* create_buffer(void)
{
int* buffer = malloc(...);
return buffer;
}
buffer gets ownership from malloc() and is returned, so the caller ends up owning it.
int* buffer = create_buffer();
without the body of an external function, hecc can't know if it borrows the pointer, changes its memory, stores it, frees it, or takes ownership. when ownership moves into a function, hecc uses the same <- as any other transfer.
function(<- pointer);
now ownership of pointer moves into function and away from the caller. an ordinary call means no transfer.
function(pointer);
the pointer is passed normally, and the caller keeps ownership unless the body shows otherwise. so <- always marks an explicit 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);, moving ownership out instead of in. hecc can usually infer this from the body, as with create_buffer() above, and then no extra syntax is needed. the explicit form is mostly for functions it can't see into, or for making a transfer unambiguous.
consuming ownership
writing function(<- pointer) at each call site works, but every caller has to remember it and the signature says nothing. a function whose 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 no longer owns it, and destroy must free it or pass it on. every caller is checked against the signature, so 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 call doesn't repeat <-, since the signature declares it. you can still write destroy(<- t) to make the hand-off visible. 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 take ownership and give it back through its return with no new operator. it's 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 */
the call consumes tree, so it owns nothing at the assignment and can take ownership back without breaking the overwrite rule.
<-> is for ownership that comes back through the parameter, which leaves the return value free for something else. you pass the owning pointer by reference (a pointer to it), and the function writes back to it.
bool grow(int** <-> buffer, size_t extra);
ownership of *buffer goes into grow and comes back out through *buffer. the function may free or reallocate it, then writes the owning pointer back through buffer, so the caller owns again, possibly a different allocation. here the return value reports 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 */
no other marker can express this in/out owner parameter. <- alone consumes for good, and return <- hands ownership out through the return. realloc is related, but its transfer is conditional (if it fails, the original stays owned), so hecc models it specially (see realloc() below).
conditionals
control flow makes ownership much harder. 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, there are two possible states.
condition == true: b owns the allocation
condition == false: a owns the allocation
you can't name an owner without looking at both paths, and 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. a flow-sensitive pass can track these states and require paths to agree where they join. once ownership escapes into a structure, a return value, or an alias across a translation unit, that pass loses track. borrows and origins, next, reconnect the reference to what it depends on.
borrows and origins
ownership leaves one problem open, which is a reference that escapes the scope that made it.
an earlier version used symmetric "tracking groups" to mark pointers as related. that was the wrong shape, since "related" can't be checked and the real relationship is directional. one pointer borrows from an owner, and the borrow can't be used after the owner's responsibility ends.
borrows
the pointer you get from = is a borrow, a reference that doesn't own the allocation and depends on an owner that does.
int* a = malloc(...); /* a owns */
int* b = a; /* b borrows from a */
inside one function this needs no annotation. hecc sees that b was copied from the owner a, treats it as a borrow, and can warn if b is 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*. like ownership, the relationship erases and the generated c doesn't change.
origins
borrows only get hard to track across function signatures and struct definitions, which the analysis can't see through. there a borrow is tied to an origin, a name for the owner (or owning scope) it depends on. the term comes from the "origins" framing in rust's polonius borrow checker. it fits better than "lifetime", which hecc dropped over the confusion it caused.
an origin is written with a $ sigil and names a relationship, not a variable. it only appears where inference can't reach, on signatures and struct fields. it carries the rule the 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 came up above. a move ends an origin the same way, since 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 */
scope is the third case. a borrow that outlives a local owner is using an ended origin. everything else is machinery for carrying this rule across boundaries.
borrows across functions
when a function returns a pointer borrowed from one of its arguments, that argument is the return value's origin.
$a char* skip_spaces($a char* s);
$a ties the result to s. using it after freeing the buffer means the borrow outlived its origin, 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 call is fine.
char* line = malloc(...);
puts(skip_spaces(line)); /* fine: line still owns here */
free(line);
as in rust, most signatures don't need this. when a function has a single pointer parameter, a returned borrow's origin is inferred from it. you only annotate when there are several candidates 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 valid only while both are. its origin is the shorter of the two.
the annotation lets callers 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 catches this. freeing b instead is flagged too, and freeing neither until after the last use of longest is fine. without $a, longer would look like any function returning a char*, and the misuse would slip through as best-effort.
calls that pass a pointer with no origin, and external functions hecc can't see into, stay best-effort. if a relationship isn't stated and can't be inferred, hecc doesn't invent one.
borrows in structures
a structure that stores a pointer has to say whether it owns or borrows it, since the two mean opposite things when it's destroyed.
a field the structure owns is freed when the structure's ownership ends, so ownership can 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 it can't outlive.
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 earlier conditional case. after a join, a borrow is valid only if it was valid on every path, and its origin is the shortest of the possibilities. it's the same reconciliation ownership does, now over a defined relationship instead of a guess.
not a borrow checker
the words here (ownership, borrows, moves, origins) come from rust, which makes this the part of hecc easiest to mistake for rust. the two share a few nouns and almost nothing else.
rust's borrow checker is a proof system. it makes unsafe programs impossible to write, so code that can't be shown safe doesn't compile. ownership is the spine that Drop, Send, Sync and the type system rest on, and you shape your program around what the checker accepts.
hecc's system is a description. you state intent the compiler would otherwise guess, and it flags likely mistakes. it proves nothing and forbids almost nothing. point for point, it's the opposite of rust.
- purpose. rust enforces and hecc describes. one is a gate, the other a note in the margin.
- default. rust rejects anything it can't prove safe. hecc accepts everything and warns only about what it can prove wrong.
- moves. a rust move kills the source, and using it again won't compile. hecc's
<-only moves the responsibility to free. the source stays usable, because c depends on aliasing. - aliasing. rust's exclusivity rule (one
&mutexcludes every&) holds everything else up and buys soundness, freedom from data races, andnoalias. hecc has no such rule and doesn't aim for those. it allows any number of live borrows, mutable ones included, and only tracks whether a borrow outlives its origin. - place in the language. rust is built on ownership and you can't opt out. in hecc it's a layer over ordinary c that erases completely (like bounds, unlike type unions) and can be ignored entirely.
so hecc can't promise freedom from data races or use borrows to justify aliasing optimizations. in return, borrowing fits into existing c without changing how it shares memory. the similarities are real but shallow, and reading hecc as "rust with the checking turned down" gets it backwards. rust decides whether a program may exist, and hecc reads one you already wrote and points out what looks wrong.
strictness and compatibility
how much hecc enforces is a setting, not fixed in the language, and it's best chosen per file or region. there are two ends.
- compatibility is plain c.
=and its escapes are best-effort. hecc warns only where it can prove a problem, and you never have to resolve them. existing c builds unchanged, so adoption is free. - strict requires every escaping
=borrow to be resolved. a copy whose borrow leaves the scope must become a move (<-), get an origin, or be marked untracked explicitly. a silent unresolved escape is an error, and ownership, origins and the leak checks are enforced instead of advisory.
strict doesn't mean "no =". plenty of = are fine borrows and stay legal. strict only forbids the silent escape, one the compiler can't account for and the programmer hasn't resolved. that closes one real safety hole and nothing else.
the default depends on the code, not on a global switch.
- new code defaults to strict. you get the safety without ceremony and write "good hecc" unless you opt out, the same reason a new typescript project turns on
strict. - existing code defaults to compatibility. hecc on a c tree just builds, and files move to strict one at a time as their ownership gets written down.
each file declares its mode, and the command line can force a whole tree into one mode. hecc --compatibility treats everything as legacy c for the first drop-in, and you peel that back as you go, like -std= and typescript's strict.
two caveats. strict raises the floor (no silent escapes) but isn't a guarantee. an origin can be written wrong, and the explicit untracked escape, like rust's unsafe, is always there. strict is also only as sound as its boundaries. a strict function calling compatibility code is checked only as far as that code's contracts go, which is why annotated library headers matter.
losing ownership information
some operations make reliable ownership tracking impossible. something like this
int* pointer = malloc(...);
uintptr_t address = (uintptr_t)pointer;
followed later by this
int* other = (int*)address;
can lose enough information that the compiler can't tell what ownership was meant. so can arbitrary pointer-to-pointer manipulation, indirect memory writes, complicated casts and odd uses of memcpy(). hecc shouldn't forbid them, but it needs a clear line between code the ownership system understands and code that defeats analysis. past that line it should stop pretending it knows what happened.
that's central to the project. the compiler doesn't have to handle every c program. given enough information it can do useful analysis, and if you deliberately strip that 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 pointer came from it, so it can tell the access is out of bounds. no special runtime pointer type is needed, since the information is already in the program.
a pointer's bounds fall into three levels. they can be unknown, inferable from where the pointer came from (like pointer above), or declared by a programmer who knows them when the compiler doesn't.
inferable bounds need nothing extra. to state them directly, hecc can accept an explicit form.
int* pointer = (int[5])array;
this tells hecc to treat pointer as an array of five ints for analysis. it doesn't change the runtime representation or appear in the generated c, where it stays a plain int*.
the same form covers pointers the compiler can't infer anything about.
int* pointer = get_memory();
the compiler has no idea what this points to. it could be one integer, an array, or video memory, and the compiler shouldn't pretend every pointer has known bounds. if the programmer knows them, they can be declared.
int* pointer = (int[BUFFER_SIZE])get_memory();
the syntax is still experimental. the idea is 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;
arbitrary hardware addresses have no ownership or full bounds to work out, and no reason to be forced into the ownership system. that isn't the analysis failing, since the information doesn't exist. ownership is a tool for when it helps, not a rule for every pointer.
array parameters
bounds can also go on function parameters. c already has syntax for this but 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, as today, so the generated code and calling convention don't change. only what hecc knows changes.
int array[] says nothing. it's int* with a suggestive spelling, so hecc treats it as unknown. it's the escape hatch and stays the default.
int array[5] is a compile-time bound. inside fizz, index five or above is a provable violation, and passing an array hecc knows is smaller can be flagged. c's int array[static 5] already means "at least five elements, and not null." hecc builds on that form instead of inventing a parallel one, and takes the reasoning further.
int array[n] is a runtime bound tied to another parameter. hecc knows 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, since the length rides in a value already being passed.
the bound can name a later parameter, as in buzz above. standard c can't, because n isn't in scope yet when array[n] is parsed. so the natural buffer, size order is an error, forcing the reversed void buzz(size_t n, int array[n]). c23 added int array[.n] for forward references, but few people will use it.
this ordering rule is what makes the feature adoptable. c code has written (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. order, names, abi and callers stay the same, so a pure annotation swap gets you the length for free. requiring the size first would mean reordering the signature and touching every call site, the kind of friction that leaves a feature technically there but practically dead.
if the bound doesn't resolve, like a misspelled or out-of-scope name, hecc falls back to [] semantics (unknown length) and warns, instead of a hard error as in standard c. 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 it's gone, the same way ownership is lost in untracked code. also, an int array[5] parameter is still a decayed pointer, so sizeof(array) inside is sizeof(int*), as in ordinary c. the bracket is a contract for the analysis, and no array is passed by value.
detecting leaks
ownership information also helps 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. hecc knows a owns an allocation, so it can see a path where the function exits without releasing it or handing it off.
hecc reports that instead of cleaning up for you, since the output should stay close to ordinary c. freeing memory is still your job.
defer
the cleanup above is far from its allocation and easy to miss. defer puts it next to the allocation and runs it when the block exits.
int* buffer = malloc(...);
defer free(buffer);
/* use buffer, return early, whatever */
however the block ends, even by an early return or break, free(buffer) runs first.
deferred statements run in reverse order, like a stack.
acquire_a();
defer release_a();
acquire_b();
defer release_b();
acquire_c();
defer release_c();
so release_c() runs first, then release_b(), then release_a(). each resource is torn down before the earlier ones it was built from.
a defer is scoped to its block, not the function, as in zig, swift and the c2y defer proposal. in an if or loop body it runs when that block ends, so once per loop iteration.
lowering
defer is a source transformation. the compiler inserts each deferred statement, in reverse order, before every exit from its block, as a careful programmer would by hand.
int* buffer = malloc(...);
if (error)
{
free(buffer);
return -1;
}
free(buffer);
return 0;
it adds no runtime machinery or hidden state, and code without defer is unchanged. goto and longjmp make the end of a block unclear, so the --simple-control-flow ruleset restricts them to keep the expansion well-defined.
defer and ownership
the leak analysis treats a defer free(p) next to p's allocation as proof that an owned p gets released.
it also catches a bug. if p is returned or moved with <- before the block ends, the deferred free(p) would free memory the caller now owns. hecc flags it instead of letting it run.
structs and compound types
ownership extends to structs with pointer members. ordinary assignment is still an ordinary c copy
b = a;
while an explicit transfer moves the members' ownership information recursively.
b <- a;
as in borrows in structures, an owned pointer field is released with the struct, and a borrowed one ties the struct to an origin it can't outlive. nested, self-referential and mixed-ownership structs need more rules on top of these two.
free()
freeing memory should stay as permissive as in ordinary c. hecc won't police every free(), and normal patterns stay legal.
int* a = malloc(...);
int* b = a;
free(b);
this is allowed. freeing through an alias is normal c, even if the original owner didn't do it.
if memory is freed through a pointer that didn't come straight from a source hecc understands, like malloc() or a function known to return owned memory, hecc can warn that it can't account for it.
the code compiles either way. hecc can reason about the free when it understands the code, and otherwise may say it can't be sure the free is correct.
realloc()
realloc() can resize in place, move the allocation, or fail and leave the original valid, so plain c already needs care.
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 <- marks an ownership transfer, and hecc applies what it knows about realloc(). on success, the returned pointer takes over the original allocation. on failure it returns NULL and nothing moves, so the original pointer still owns it. hecc can encode both outcomes, not one unconditional move.
hecc models the special rules the c library defines for this function, instead of forcing a generic rule or treating the call as a normal assignment.
type unions
c usually expresses several possible outcomes through conventions. a function might return NULL or a special integer, write to an output parameter, set errno, or leave the caller to check some other state. for value-or-error, c uses a wrapper struct.
struct DivisionResult
{
int result;
enum DivisionError error;
};
that works, but it gets repetitive, since every 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 structs also hold a value and an error at once, though a result only ever has one.
hecc adds type unions, values that hold one of several listed types.
int | enum DivisionError div(int a, int b)
{
if (b == 0)
return DivideByZero;
return a / b;
}
the signature says the result is an int or an enum DivisionError, with no wrapper.
type unions can have more than two types.
int | char* | enum Error value;
a type union holds only one member at a time, unlike a struct.
ownership and bounds annotations erase completely at code generation, but a type union has a real runtime representation with a discriminator, a size and an abi. it's the one hecc construct that costs something at runtime, and that's on purpose.
the consequences are big but the mechanism stays small, which is the trade i want. the only new syntax is the | type combinator, with no new keyword. existing constructs like typeof() get a tighter definition instead of being replaced. c programmers have long wanted tagged unions, and this gives them that without a separate ecosystem.
enums
a type union tells members apart by type, which fails when two cases share one. uint32_t | uint32_t is degenerate, so a key-down and a key-up code, both uint32_t, can't be told apart by type, only by name.
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 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, the enum c should have had all along. - variants told apart by type are the anonymous
T | Etype union, a shorthand for when types alone are enough. - variants with names and payloads are the general sum type, like rust's enums.
you build a value by naming the variant, and use it by matching, which binds the payload to 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, but on variant names. switch (typeof(x)) matches an anonymous union and switch (x) matches an enum. per the exhaustiveness rule below, it must cover every variant or have a default. it's simpler here, since it compares tags instead of resolving types.
in 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.
the closed set also fixes c's other enum problems. an enum Shape is its own type, not an int or another enum. the escape hatch is an explicit cast to its integer, where a safe build can trap on an out-of-range value.
variant names are scoped to the enum, written Shape.Circle when a bare name is ambiguous, so enums don't clash. unscoped names still work for existing code. the tag's integer type can be fixed, as in enum Shape : uint8_t, which c23 already allows.
so type unions and enums are one construct. T | E is the anonymous form, with variants named by their types, and the named enum is the general one. the next sections apply to both.
existing c apis
hecc extends c, instead of being a new language, mainly so existing codebases can improve gradually, without a rewrite in rust, zig or anything else.
type unions help here. existing apis can get explicit result types without a wrapper struct per 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
);
existing c libraries and conventions still work, and there's no new hecc standard library. type unions just tell the compiler what an operation can return, so a project gets the extra analysis before replacing any api or library.
one piece is unfinished. value-or-error is solved, since T | enum Error carries the outcome and, on failure, the reason. value-or-nothing uses an empty member, T | void, told apart by the discriminator.
it doesn't use an in-band sentinel, which is what tagged unions exist to remove. returning NULL from a T | void* would collapse every failure into one "nothing" and lose the reason. it would also clash with pointer types where null is valid.
propagation is still open. callers might want a short way to pass an error up, like ? or try, instead of type-switching at every level. i haven't decided whether hecc gets that, or leaves callers to check explicitly like c and go do.
runtime representation
unlike a c union, a hecc type union must know which member is active, for operations like this.
typeof(value)
a c union has room for every member but doesn't track which one was stored last, so hecc adds 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 generated names are an implementation detail, but the representation is part of the 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.
it's a plain tagged union with a stable, predictable layout that isn't private to the compiler. it needs no garbage collector, runtime type system or hidden runtime library.
a named enum lowers the same way, but names the union members by variant, so two variants can share a payload type. 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 without a payload only adds a tag. an enum with no payloads has an empty union and lowers to a plain c enum, an int at runtime, with its closed-set and exhaustiveness checks done at compile time. so a c-style enum stays zero-cost, while one with payloads pays for its tag.
discriminator values
discriminators are an internal encoding, and you never write one. source code compares types, and the compiler lowers each one to an integer index for storage and dispatch (see checking the active type). the encoding matters here only as part of the abi.
the order of the types decides the 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 hecc abi should define the exact representation, so the backend doesn't pick one arbitrarily.
so type order is part of the abi. reordering
int | enum Error
into
enum Error | int
changes the mapping, and can change the abi of values exposed across compilation boundaries.
checking the active type
you inspect a type union's active member 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;
}
}
as in c, each arm is its own scope and needs braces if it declares a variable. typeof(result) gives the active type as a first-class value, and the switch matches types, so case int: is a type, not an integer constant. this type switch lowers to an integer switch on the hidden discriminator.
typeof() gives a value's dynamic type. on a type it does nothing, so typeof(int) is int, which lets labels be bare types.
checking doesn't automatically change the variable's static type. result is still
int | enum DivisionError
even inside the matching branch.
you extract a member explicitly with a cast, and auto can infer its type so you don't repeat it.
auto value = (int)result;
or
auto error = (enum DivisionError)result;
this keeps the union type separate from its members, even where the compiler knows which one is active.
extraction and construction
extraction reuses c's cast syntax, plus one rule. if the members can't convert into each other, like FILE* | enum FileIOError, a cast can only extract. if they can, like int | float, (int)value could extract the int or numerically convert the active member, with different results.
int | float x = 3.5f; /* float is active */
int y = (int)x; /* extract int, or convert 3.5 to 3? */
the rule is that a cast on a type union always extracts, and a cast on a scalar is an ordinary conversion. to convert, extract first.
int y = (int)((float)x); /* extract the float, then convert it to int */
the inner cast extracts the float from the union, and the outer cast converts that plain float.
construction goes the other way. a value goes into a union when the compiler can tell which member it matches.
int | enum DivisionError result = 5; /* the int member */
if that's ambiguous, the assignment is an error and you pick the member with a cast.
int | long n = 5; /* error: int or long? */
int | long n = (int)5; /* the int member */
ambiguity only comes up when members convert into each other, like int and long. an enum counts as its own type, not an integer, so int | enum DivisionError has no overlap. the div example above builds each member from a bare return with no cast. picking a member reuses the cast syntax, with no new keyword.
exhaustiveness
a type switch must handle every member or have a default. an incomplete switch is an error.
switch (typeof(result)) /* result : int | enum DivisionError */
{
case int:
...
/* error: enum DivisionError is not handled */
}
so extracting inside a switch needs no extra check. in each arm the compiler statically knows which one member is active, so its cast ((int)result under case int:) is validated by construction. an if (typeof(result) == int) guard does the same on its true branch.
result stays int | enum DivisionError, so you still write the cast. the check proves it valid without narrowing the type.
the strict, warning and permissive rulesets below only apply outside such proven contexts.
unchecked extraction and compiler leniency
sometimes you'll want to extract without checking first.
FILE* | enum FileIOError result = open_file(...);
FILE* file = (FILE*)result;
the cast asserts that result holds 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 it silently.
so an existing c codebase can start permissive while it adds type information, then tighten the rules. the language doesn't have to make unchecked operations impossible everywhere.
this is a force unwrap. at compile time the ruleset rejects it, warns or accepts it. at runtime it erases to a plain member access by default, so reading the wrong member is undefined behaviour, as in c. hecc's extensions don't quietly add runtime behaviour the programmer didn't write.
T | void shows the danger. the void member has no payload, so if result holds the empty case, a force unwrap like this
int | void result = some_function();
int number = (int)result;
reads storage that was never written. by default this is unwrap_unchecked, not a checked unwrap.
since the discriminator exists at runtime, an opt-in ruleset, not the base language, can offer a checked unwrap almost for free. a rule like --checked-unwrap would add a tag check to each force unwrap and trap if the asserted member isn't active. with a c backend that's an abort (abort(), __builtin_trap(), or a handler you supply), not an unwinding panic, since hecc has no unwinding runtime.
that's a free debug and release split. with the rule, a bad force unwrap aborts where it happens, and without it the casts erase to nothing. c can't offer this at all, and in hecc it's cheap because the tag is already there. it still shouldn't be the default, since the programmer didn't ask for a trap.
stable abi
a hecc type union's representation is part of the language abi. hecc lives alongside c, not in its own ecosystem, so a type union crossing a library boundary should be reproducible without a hecc compiler.
for example, c code 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 from 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 discriminator representation, payload alignment, padding and member order. calling conventions come from the target platform's rules for the equivalent c struct. hecc defines the layout, and the platform decides how it's passed and returned.
this also settles headers. hecc code can include a hecc-syntax header and see int | enum DivisionError. c code includes a c header the transpiler emits for the generated tagged struct, and reads its type and value members. both agree because they share the abi.
i haven't settled which header is canonical in a mixed project, or whether the c one is generated or kept side by side. either way c can call a hecc library through its lowered struct, though it can't parse the hecc-syntax header, as 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 one returning the tagged union shown earlier (the DivisionResult struct with its discriminator and payload). each return sets the matching member and tag. it's just ordinary c, with no magic runtime machinery.
a named enum lowers by the same rule. 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; }
}
that payload binding is the only new codegen. a recursive enum, where a variant refers to itself, uses a pointer there like a recursive c struct, and that pointer follows the owned-field rules.
an llvm backend can use the same logical representation directly while keeping hecc's abi rules.
why type unions exist
c can already build tagged unions by hand, but each value-and-error combination needs its own wrapper, discriminator, payload, naming and extraction code. a type union lets the compiler generate that, with a predictable, defined representation.
that's hecc's general aim. it adds information where c relies on convention, boilerplate or unwritten discipline, and stays compatible through a stable abi.
since the discriminator exists at runtime, a type union also works as a runtime-polymorphic value. a typeof switch over struct Circle | struct Rectangle | struct Triangle gives single dispatch over a closed set of types, without inheritance, vtables or templates.
that's a side effect, not a goal, and it doesn't replace generics, which handle any type picked at compile time. for a fixed set of runtime types, listing them is simpler than a generic abstraction. for an arbitrary type, a type union is the wrong tool.
nullability
the most common union in c needs no tag. it's a pointer that may or may not point at something. T* mixes up "points at a T" and "might be null," a big source of c's crashes. hecc lets the pointer say which 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 its meaning, so existing code is unchanged and the qualifiers are opt-in. at runtime all three are bit-for-bit a plain int*. a pointer already has an empty case, null, which is 0, so it needs no tag or extra storage. nonnull and nullable only say whether null is allowed.
that's why the earlier point about int* | void doesn't apply. a tagged type union would make the pointer fat and change its abi. nullability only restricts the allowed values, so the pointer stays a plain int* and the union is free.
the qualifier goes after the *, where 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 matters when you declare several pointers at once. after the *, each qualifier stays with its own declarator.
int *nullable p, *nonnull q; // p is nullable, q is nonnull
before the *, it would join int in the declaration specifiers, which apply to every declarator. after the * it doesn't, the same way int *const a, *b; leaves b an ordinary pointer.
the checker won't let you dereference a nullable pointer until it's tested. the test narrows the type, like 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 must show the pointer isn't null before the call, and the function uses it directly. the one test sits where the information is, instead of a defensive if in every callee.
this is the same state tracking ownership uses. a value is in one of a few compile-time states, and some operations are only legal in some of them. ownership (owned, moved, freed) and nullability (null, non-null) are one mechanism with different states. the general, user-defined version is typestate, which is in the explorations.
transpilation
the first hecc implementation will probably be a transpiler. it would parse the extended syntax, run semantic and ownership analysis, and generate ordinary c.
hecc source
|
v
parser and analysis
|
v
generated C
|
v
C compiler
|
v
binary
many extensions disappear entirely after analysis. this
b <- a;
becomes
b = a;
and this
int* pointer = (int[5])array;
becomes
int* pointer = array;
the generated c should stay close to the original program. hecc should mostly add information for analysis, not secretly produce a different program.
that gives a project two ways out, which matters for adoption. it can transpile once, commit the c and carry on without hecc, or keep hecc and treat the generated c as a fallback. either way the source isn't trapped, and if the compiler is abandoned, its output 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 start, since i can experiment with the language without building a whole backend.
codegen can sometimes make the c faster, not just safer, by turning proven facts into hints. non-aliasing becomes restrict, a proven bound becomes a __builtin_assume that drops a redundant check, and nonnull becomes the platform's non-null attribute. hecc only emits hints it can back up, since a wrong restrict is undefined behaviour. it's a small opt-in bonus, not a goal, but it's unusual for a safety layer to give cycles back.
implementation
transpiling to c keeps the compiler small. the c compiler does register allocation, instruction selection, code generation and optimization. hecc is just a frontend, an analysis stage and a pretty-printer.
the analysis is the real work, and two design choices keep it cheap.
first, hecc gives diagnostics, not guarantees. it only warns about what it can prove wrong, so it can cap iteration, give up on hard cases, and accept false negatives. a sound checker must reach a precise fixpoint over every path, which is much of why rust's borrow analysis, and polonius especially, are expensive. being quieter makes hecc cheaper, since it only needs enough precision for useful diagnostics.
second, ownership contracts live on signatures. each function is checked against its callees' summaries (which arguments are consumed, which returns are owned, which are borrows), not their bodies. so the annotations keep the analysis local, roughly linear, parallel once summaries exist, and never quadratic.
the rest is textbook. an arena holds the ast and ir and is freed per translation unit. it's faster than per-node allocation, with no lifetimes to track. identifiers are interned to integers when lexing, so comparisons are one word. ownership, leaks and borrows are a forward dataflow over each function's control-flow graph, using a lattice, transfer functions and a worklist run to a fixpoint. bounds are interval analysis in the same framework.
the arena, interner and cfg-plus-worklist shape come first, since retrofitting them means a rewrite. parallelism, flat index-based node arrays instead of pointers, and incremental caching wait for a profiler. caching on a hash of a function and its summaries would give editor-level speed, so passes should be pure functions now.
i doubt a transpiler this size will be slow, and the bigger risk is over-engineering before the analysis is correct. the arena and interner are there for simplicity, not speed. the rest waits for a measurement.
runtime checks
everything above is static and erases. some safety can't be checked that way, especially in the plain = code hecc leaves unchecked so it can be adopted. there, hecc can insert runtime checks.
they cost cycles, so they're opt-in build tiers. being on by default would penalize adopted code and break the additive rule. think -fsanitize or zig's safe release modes, with hecc --safe or a narrower --checks=bounds,ownership,tags,overflow. release builds insert nothing.
this complements the static layer, which covers <- and origins for free. together they reach much more than either alone, and checks already proved statically get dropped.
hecc already has the information for these checks.
- extraction (described above). a force unwrap
(T)unionbecomes a tag comparison that traps on mismatch. it's nearly free, since the discriminator already exists. - bounds. an index or dereference past a known extent, inferred or declared with
(int[N]), traps. that's more targeted than a generic sanitizer's shadow memory, and adds nothing where hecc has no bounds. - ownership. a safe build can poison freed allocations and trap on a dereference of a borrow whose owner was freed. it can also trap at
freeon a double free or a free through a non-owner. - signed overflow. hecc can define it as two's-complement wraparound (what most hardware does, close to
-fwrapv), or trap on it in a safe build. either way it's defined or loud, unlike c's silent ub. it's the one undefined behaviour hecc pins down by default. the approach is case by case, giving one ub a predictable result where the cost is acceptable, not fixing all of c. - null (optional). trap on a dereference the analysis can't prove is non-null.
a violation traps (abort(), __builtin_trap(), or your own handler) instead of panicking, since hecc has no unwinding runtime.
there are two limits. the checks make a violation fail fast instead of silently corrupting things, so the program is loud but not correct. and they only target the bugs hecc models (bounds, ownership, tags, overflow), so they aren't a general memory sanitizer. addresssanitizer covers more for arbitrary heap corruption or uninitialized reads, and you can run both. hecc's edge is that its static information lets it add cheaper, narrower checks than a blind tool, and skip proven ones.
diagnostics
since hecc tracks how ownership, origins and bounds flow, it knows why something is wrong. a use-after-free should show three points: where the allocation was owned, where ownership moved out, and where the borrow was used after. that's closer to rust's or elm's errors than a terse c compiler's.
it's a design goal more than a feature, but not a cosmetic one. people keep using checkers whose output tells them what to do next. a correct but unreadable one gets silenced, and then it checks nothing. since annotations are opt-in, each diagnostic also has to show that writing <- or an origin bought a real check you can understand. otherwise people won't bother.
hiv
ownership and strict mode are only as good as the contracts on called functions, and most are in the standard library. hecc knows a few directly (malloc, free, realloc, strdup). the rest of the platform libc is an opaque boundary it has to trust.
hiv, hecc's standard library, closes that gap. it reimplements the c standard library in hecc, so its functions carry ownership decorators natively. they say which arguments are consumed, which returns are owned, and which are borrows and from where. strict mode can then check across it instead of going blind. since hecc emits ordinary c, hiv also works as a normal c library without hecc.
each allocating function has two forms. the default keeps c's signature and carries ownership (malloc(size), strdup(s), free(p)), so existing code compiles unchanged. the _with form takes an explicit allocator (malloc_with(&a, size), free_with(&a, p)) so the caller picks where memory comes from. _sized variants pass a known buffer size to hecc's bounds analysis, and compose with _with.
the two back each other up. a guarding hiv allocator (poisoning freed memory, quarantining, guard pages, double-free detection) is the safety net from runtime checks, at the library boundary instead of in codegen. static hecc on top of it covers both sides.
none of it is required. hecc works with the platform libc, and hiv works without hecc. hiv implements the iso c interface, so there's no new ecosystem to buy into.
lexical conveniences
a few conveniences live only in the lexer. they change spelling, not meaning, and are gone before analysis.
numbers can use _ as a digit separator, like 1_000_000 or 0xFF_FF. c's tokenizer already reads that as one number and only rejects it at value conversion, so hecc just relaxes that step. there are binary literals like 0b1010 (already in c23), and an explicit octal 0o755 instead of c's error-prone leading-zero 0755. block comments nest, so /* ... /* ... */ ... */ closes where you'd expect. all of these reduce to plain c (1000000, 10, 493, or nothing) for free.
raw strings are the heavier item. they use c++'s spelling, R"(...)", with an R"tag(...)tag" form for contents that contain )". everything inside is taken as is, newlines and backslashes included, so an embedded shader, regex or bit of sql isn't a wall of \n\". c has no raw strings, so hecc re-escapes the contents into a normal string literal. it still erases to a plain const char[] and leaves the analysis and abi alone, so it's still lexer sugar. it isn't free, but it removes real pain.
the list stays short, and the bar is high. cosmetic syntax only gets in if it's free or removes real, recurring pain, and if it matches where c or the c family is heading.
keeping it small
the biggest risk to hecc is the one every c successor has hit: another keyword, type or library part for each hard case, until the small language is a big one. the defence is a hard line between the core and everything else.
the core is what hecc claims to be: 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 count as one mechanism, since both track which compile-time state a value is in and gate operations on it. the pieces support each other, erase to ordinary c, and aren't needed to compile existing code. that set is the language.
keywords are rare even here. nonnull and nullable are contextual, meaning something in one position and reserving nothing elsewhere, so code using them as identifiers still works. defer is the only keyword that starts a statement. it earns that by removing a whole class of leak, lowering to plain c, and matching c's own c2y proposal.
everything after this is past the line. some may join the core, some may stay experiments or move to a separate tool, and some may be dropped. keeping it apart keeps the core small while i think about the rest.
c's small size is part of its appeal, and a big hecc-specific standard library or a pile of always-on features would work against that. the compiler knows a few standard functions (malloc(), free(), realloc(), strdup()) without making you adopt a new standard library.
beyond the core
the rest is exploration that follows the core's rules. it adds information, erases to ordinary c, and is opt-in. none of it is settled, and nothing above depends on it.
typestate generalises the state tracking ownership and nullability already do. effects and distinct types are separate ideas that stand alone. the effect contracts and distinct are contextual keywords, and typestate adds no keywords, reusing distinct, :, |, and ->.
typestate
nullability and ownership are both cases of a bigger pattern. a nullable pointer is null or non-null, a dereference is only legal in one, and a test moves it between them. an owned pointer is owned, moved-out or freed, and free is legal on an owner but a bug after a free or move. in both, a value moves through fixed compile-time states that decide which operations are allowed.
typestate lets you declare your own states. they exist only at compile time, so the value stays a plain FILE* or int at runtime and the transitions erase. a distinct type can list its states, and a function can say which state it needs and which 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 an api protocol, the order a library must be called in, which c has never been able to express. "open before read, don't use after close." "lock before touching the field, unlock after." "init before any other call." today only docs and habit enforce these. typestate puts the rule in the header, and the compiler checks every caller, as it already does for ownership and nullability.
it's the most speculative and conceptually heaviest feature here, so it stays strictly opt-in and diagnostic. it proves and forbids nothing, and an = or a cast drops the state like it drops ownership. in return, ownership and nullability stop being hardcoded special cases and become examples of one mechanism you can use. hecc prefers that to several single-case mechanisms.
effects
c's pure and const attributes are leaf annotations the programmer asserts and the compiler mostly trusts, with nothing checked or propagated. hecc can carry a small set of effect contracts, for whether a function allocates, can fail, or can block, and enforce them across the call graph.
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 contracts are checked, not assumed. a no_alloc function can't call malloc or any function that isn't no_alloc. since hecc sees bodies, it can follow calls to the leaves and infer effects nobody wrote down. embedded, kernel and real-time code have always wanted to prove at compile time that a call path never allocates or blocks, instead of finding out in the field. c never offered that. this pairs with the bounded-loops ruleset below, since both serve worst-case execution reasoning.
no_fail connects to type unions. a function returning T | enum Error has failure in its type, and no_fail claims the error arm is never taken, so the two describe one property from opposite ends.
effects add no runtime code and vanish from the generated c. an external function the compiler can't see into needs the contract on its header, as with ownership.
distinct types
a c typedef is a transparent alias. typedef int Celsius; names Celsius, but it's still int. you can assign a raw int to it, pass a Fahrenheit where a Celsius is expected, or pass a plain count as a user id, and the compiler says nothing. the name documents 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 types, separate from int and each other, though all are stored as int. mixing them, or converting to or from a raw int, needs 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 a boring but expensive bug, two quantities with the same representation but different meanings. think 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, like = for ownership, so crossing the boundary shows in the source.
a plain typedef still works, so distinct is opt-in. at runtime a distinct type is its underlying type, with the same size, layout and abi, and in the generated c Celsius is int again. it's only worth it where unit and id mix-ups are a real problem. it's below the line because it isn't about memory or state safety, which is hecc's focus.
rulesets
some c problems come from program structure, not memory or types, and c allows a lot there. control flow can jump anywhere in a function, and loops can run forever.
hecc could offer optional, enforceable rules that push toward simpler, more predictable code. they're off by default and not part of the language, so ordinary c-like code always works. projects pick the rules that fit their guarantees or style, and the rules could live in a separate tool. they're conveniences for code that's easier to debug and analyse.
the idea is to avoid problems by cutting complexity instead of adding abstractions.
1. simple control flow
c's goto and longjmp() move execution in ways structured control flow can't represent, which makes programs harder to reason about. an optional rule could ban them and require structured control flow.
they aren't useless, and people use them on purpose, especially in low-level c. but arbitrary jumps complicate control-flow analysis, ownership analysis, leak detection and reasoning about program state. that matters for hecc, since ownership moves along control-flow paths, and structured flow gives the programmer and the compiler a clearer picture of them.
a flag, --simple-control-flow, would turn it on, and the compiler would reject or diagnose things like goto and longjmp().
2. bounded loops
some code needs worst-case execution guarantees, like embedded or real-time work, or anything where unbounded iteration is dangerous. there, every loop can be required to have a known maximum number of iterations, which also makes static analysis more predictable. it's opt-in, and doesn't claim every loop can be bounded.
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. under this rule, other loops need the programmer to supply a bound, like this one 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, but there's an upper limit on how long execution stays inside. the syntax for a bound that can't be inferred is still open.
--bounded-loops would turn this on. the compiler would enforce bounds it can work out and require explicit ones where it can't.
prior art and answers
hecc isn't the first attempt to add safety information to c, so why would it do better than the ones that died?
the closest ancestor is cyclone, a safe c dialect from at&t and cornell. it died partly because its safety needed runtime machinery like fat pointers, so its output wasn't ordinary c. hecc's ownership and bounds erase at code generation, and it emits plain c. if hecc is abandoned, the c it produced keeps working, so no project gets trapped in its toolchain. that's its one structural advantage over earlier dialects, and my answer to "dialects die."
zig and rust solve problems hecc doesn't attempt, and solve them better, inside their own languages. zig's tagged unions and error unions are nicer than hecc's. rust has sound ownership, at the cost of a borrow checker and the unrestricted aliasing c relies on. but adopting either means porting, and neither can retrofit a c codebase in place. hecc is for code that is c and will stay c.
in spirit, hecc is closer to gradual typing than to another systems language. typescript, mypy and sorbet are unsound by design, have escape hatches, and can be adopted one file at a time. they check intent the programmer states instead of guessing it, and they're still hugely useful. hecc makes the same deal for memory responsibility. <- is closer to const than to a rust move, an advisory contract that's checked where possible, easy to bypass on purpose, and useful anyway. nobody calls const worthless because a cast can strip it.
this framing also makes the limits clear.
- hecc doesn't make programs memory-safe. it gives diagnostics, not guarantees, and 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 only warns. 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 it 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 and 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 syntax are still open.
the claim i'll defend is narrow, but it's the one worth making. hecc is c with tagged unions and best-effort ownership and bounds diagnostics. you can adopt it gradually, it interoperates through a stable abi, and nothing traps a codebase in its toolchain.
other c dialects and extensions
other attempts to improve c take a few approaches, and hecc fits only one.
evolution languages, like c2 and c3. these clean up c 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, so you switch instead of upgrading your .c files in place. hecc is the opposite bet, a layer on the c you already have.
gradual safety extensions, like checked c, clang's -fbounds-safety, and sparse. these are hecc's closest relatives: annotations on real c, checked incrementally, backward compatible and adoptable file by file. checked c and -fbounds-safety add spatial safety, with checked pointer and array types that carry their bounds. 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 shows it scales, since the kernel has run on it for two decades. hecc overlaps with them on bounds, but mostly targets temporal safety (ownership, use-after-free, double-free, leaks), which they mostly leave alone, plus type unions. for spatial safety, -fbounds-safety is real and shipping today. for ownership, there's hecc.
restrict-and-verify, like misra c and frama-c. misra restricts c to a safer subset enforced by linters, and hecc's rulesets are a lighter version of that. frama-c adds a contract language for formally proving properties. hecc sits between them, 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 as c supersets. objective-c shows one can succeed with a backer and a clear niche. c++ shows one growing into a second, much bigger language. hecc stays small, and is willing to cut features, to avoid that.
so hecc's slot is narrow: 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. to be fair, hecc is still on paper while most of the others ship.
what hecc is trying to do
hecc's aim is narrower than making c safe. it's meant to help programmers write safer c by letting them state intent the compiler can't otherwise see. ownership transfers say who's responsible, borrows record what a reference depends on, and known bounds get checked. you can say more when it helps the compiler understand the program, and none of it stops you writing ordinary c.
big problems remain. borrows and origins give escaping references a relationship to track, but the exact rules still need work. that covers nested and self-referential structures, origins through function pointers, and origins across complicated control flow. external functions need contracts. unions, realloc() and indirect pointer manipulation bring their own problems. that's expected for an experiment.
the central question is whether a fairly small set of extensions can make static analysis much more useful while c still feels like c. ownership comes first, and borrows and origins follow from the problem it creates. everything else is judged by the same principle. add information where c is ambiguous, but don't add a whole new language.
see also
hiv(7), iridis(7), style-guide(7), blabbing(7), hachem(1)
author
hachem <im@hachem.wtf>