Control Flow
SafeC supports all standard C control flow constructs and extends them with pattern matching, deferred execution, and compile-time branching.
If / Else
Standard conditional branching:
if (x > 0) {
printf("positive\n");
} else if (x == 0) {
printf("zero\n");
} else {
printf("negative\n");
}While Loop
int i = 0;
while (i < 10) {
printf("%d\n", i);
i = i + 1;
}For Loop
C-style for loops with init, condition, and increment:
for (int i = 0; i < 10; i = i + 1) {
printf("%d\n", i);
}Do-While Loop
Executes the body at least once:
int attempts = 0;
do {
attempts = attempts + 1;
} while (!try_connect() && attempts < 3);Match Statement
The match statement provides pattern matching without fall-through (unlike C switch). Each case is independent -- no break required.
match (status_code) {
case 200: printf("OK\n");
case 404: printf("Not Found\n");
case 400..499: printf("Client Error\n");
case 500..599: printf("Server Error\n");
default: printf("Unknown\n");
}Features
Range patterns match inclusive ranges of integers or chars, with either .. or ...:
match (status_code) {
case 400..499: printf("client error\n");
case 500..599: printf("server error\n");
default: printf("other\n");
}
match (c) {
case 'a'...'z': printf("lowercase\n");
case 'A'..'Z': printf("uppercase\n");
case '0'...'9': printf("digit\n");
default: printf("other\n");
}Alternation patterns match multiple values — use comma, not |:
match (day) {
case 1, 7: printf("weekend\n");
case 2, 3, 4, 5, 6: printf("weekday\n");
}Alternation uses ,, not |
case 1 | 7: does not parse — | is not valid inside a pattern list. Use a comma to separate alternatives, as shown above.
Wildcard matches everything:
match (value) {
case 0: handle_zero();
default: handle_other();
}A match statement without a wildcard/default arm only warns if non-exhaustive (match statement may not be exhaustive) — it doesn't error, since falling through with no value produced is harmless in statement position.
Matching Nullable References and Optionals
match also destructures pointers (T*), nullable references (?®ion T), and optionals (?T) — this is the primary sanctioned way to read one of these, alongside is_null()/is_none()/.default(fallback) (see Types); direct dereference/member-access/force-unwrap (!) require unsafe.
struct Node { int value; };
void describe(?&stack Node next) {
match (next) {
case null: printf("empty\n");
case some(n): printf("value=%d\n", n.value); // n bound as the payload type directly
}
}
?int maybe = compute();
match (maybe) {
case none: printf("nothing\n");
case some(x): printf("got %d\n", x);
}Pattern names are null/some(x) for pointers and nullable references, none/some(x) for optionals — plain identifiers, not dot-prefixed (unlike tagged-union variant patterns).
Match as an Expression
match can also be used as an expression, producing a value from whichever arm ran. Unlike the statement form, a match expression must be provably exhaustive — every tagged-union variant covered (or a default/wildcard arm), or, for nullable/optional subjects, both null/none and some(x) covered — otherwise it's a compile error, since it has to produce a value on every path:
int result = match (status_code) {
case 200: 1,
case 400..499: -1,
default: 0,
};
int describe_len(?&stack Node next) {
return match (next) {
case null: -1,
case some(n): n.value,
};
}Switch Statement
Real C fall-through dispatch, distinct from match above (which never falls through, and whose break passes through to an enclosing loop, not the match itself):
switch (n) {
case 1:
case 2:
do_low();
break;
case 3:
do_three();
// falls through — no break
case 4:
do_three_or_four();
break;
default:
do_other();
}Case values are literal (optionally negated) integer or char constants — not general constant expressions, matching C's "case labels must be integer constant expressions" rule closely enough to compile to a real switch/jump-table instruction rather than a chain of comparisons. Unlabeled break exits the switch; continue is not consumed by a switch — it keeps referring to whatever loop (if any) already encloses it, same as real C.
Defer
defer schedules a statement to execute when the enclosing scope exits, in LIFO (last-in, first-out) order. This is ideal for resource cleanup.
void process_file(const char *path) {
FILE *f = fopen(path, "r");
defer fclose(f);
char *buf = (char*)malloc(4096);
defer free(buf);
// ... use f and buf ...
} // free(buf) runs first, then fclose(f)Deferred statements execute regardless of how the scope is exited -- whether by normal flow, return, or early exit.
Multiple Defers
Defers execute in reverse order of declaration:
void example() {
defer printf("3\n");
defer printf("2\n");
defer printf("1\n");
}
// Output: 1, 2, 3Try Operator
The try operator unwraps an optional value, propagating the empty case (null) to the caller immediately if the value is absent — equivalent to if (x.is_none()) return null; T value = ...; written inline:
?int parse_config(const char *path) {
?int fd = open_file(path);
int file = try fd; // if fd is empty, return null immediately
?int value = read_int(file);
return try value;
}A bare T returned from a ?T-returning function implicitly wraps to "present" (return file; above needs no explicit wrap), and return null; produces the empty case — there's no separate some(x) constructor to call in ordinary code (some/none only appear as match patterns, see above).
Errdefer
errdefer is written like defer, but is meant to run only on a try-propagated failure exit rather than every exit:
?int open_and_process(const char *path) {
?int fd = open_file(path);
int f = try fd; // propagates null on failure
errdefer close_fd(f); // intended to run only if a later 'try' below fails
?int size = read_size(f);
int n = try size; // on failure: close_fd(f) runs, then null propagates
// ... process ...
return n;
}A normal (non-try-triggered) exit — a plain explicit return, including a successful one, or falling off the end of the function — runs every registered defer, but skips errdefer, as the names suggest. A try-propagated failure exit runs both: general cleanup (defer) still owes its work, on top of whatever error-specific cleanup (errdefer) was registered.
Labeled Break and Continue
Labels allow breaking out of or continuing nested loops:
outer: for (int i = 0; i < 10; i = i + 1) {
for (int j = 0; j < 10; j = j + 1) {
if (i + j > 15) break outer;
if (j % 2 == 0) continue;
printf("%d %d\n", i, j);
}
}Goto and Labels
For C compatibility and low-level control flow:
void state_machine(int input) {
goto start;
start:
if (input == 0) goto done;
input = input - 1;
goto start;
done:
printf("finished\n");
}WARNING
Prefer structured control flow (match, loops, break/continue) over goto. The compiler does not verify that goto respects region lifetimes.
If Const (Compile-Time Branching)
if const evaluates a condition at compile time and selects a branch. The dead branch is eliminated entirely and is not type-checked.
const int PLATFORM = 1;
void init() {
if const (PLATFORM == 1) {
init_linux();
} else if const (PLATFORM == 2) {
init_macos();
} else {
init_generic();
}
}This is the SafeC replacement for #ifdef-based conditional compilation. Unlike preprocessor conditionals, if const respects scoping and can reference const and consteval values.