Coding Guidelines
Three style rules that are currently learned by getting them wrong. They are not about taste — each one is the difference between code that ships and code that quietly costs you. These guidelines are for both human and agent authors.
Contents
1. GC-free coding
SuperJ has no garbage collector. Every new is permanent — the object lives as long as the arena that owns it, and the default arena is the global one, which lives until process exit. There is no such thing as a short-lived object unless you make it one. A new in a hot path is not "a bit of pressure the collector will absorb" — it is a leak with a rate.
This is the single biggest difference from Java and the source of every memory bug downstream projects have hit. The full guide to flat memory — the allocation ladder, arenas, local stack allocation, the scratch-buffer cookbook, and the memory-event log — is in Memory Management Best Practices. This section is the rules-of-thumb summary; read that page for the why and the recipes.
The allocation ladder — try these in order
- Don't allocate. Most allocations are avoidable. Return a primitive instead of a wrapper; fill a caller-owned array instead of returning a new one; precompute strings once instead of concatenating per call.
local— stack-allocate small, provably non-escaping temporaries. Needs an initializer; not allowed on primitives (a plainintis already a stack slot). Only works when the size is a compile-time constant — with a runtime size it is silently ignored and you get a global-arena allocation instead. The compiler does not escape-checklocal— alocalreference that outlives its scope is undefined behaviour.- Reuse a long-lived static scratch buffer. For anything sized by the data, this is the answer. Allocate once, reuse across every call, partition, query and iteration. Grow-on-demand, never shrink:
``java static double[] scratch = null; static double[] ensureScratch(int n) { if (scratch == null || scratch.length < n) scratch = new double[n]; return scratch; } ``
Single-threaded → one buffer per process is safe. No locks, no thread-locals, no defensive copies. The ordering contract replaces the lock: the caller must consume the returned data before the next call overwrites it. Write that contract in a comment at the definition.
arenablock for a batch of scoped intermediates you want dropped together, in O(1). The block is dropped on every exit path — fall-through,return,break/continue. One gap: an in-flight exception unwinds past the frame without dropping the arena, sothrowout of anarenablock still leaks. Escape is a placement decision, not a compile error — a value that would outlive its block is placed in a region that does outlive it.- Global arena — the default — only for things that genuinely live for the process: precomputed path tables, the scratch buffers themselves, long-lived scanners and indexes.
Hard-won rules
- No
newinside a loop. A sum over 100M rows allocates nothing inside the loop. Accumulators, masks and index lists are allocated before it. Build with--warn-alloc-in-loopto catch this at compile time. - No String
+inside a loop. Each+produces a new String in the global arena. Precompute path strings once and index them. Build with--warn-string-concat-in-loop. - A class that is reused gets a
reset(), not anew. If younewa class per query, it leaks forever. Addreset(), hold a static instance, callreset()before each query. Build with--warn-no-reset-in-loop. - Anything handed a possibly-oversized buffer takes an explicit count and validates it. A scratch buffer is only guaranteed to be
>= n; usingbuf.lengthas the row count reads stale bytes past the valid data. Use@Scratchannotations and build with--warn-scratch-length. - Prefer primitive arrays (
int[],long[],double[]) over collections — no autoboxing, no per-element objects. - A library method allocates in the global arena. Arena scoping is lexical, so a
newinside a callee belongs to the global arena no matter what the caller wrapped it in. A reusable class therefore cannot allocate per call and be scoped by its caller: it must take caller-owned buffers or pool its own.
The two gates
- Compile-time gate. Build with all four warning flags before any perf claim:
``bash superj build --warn-alloc-in-loop --warn-string-concat-in-loop \ --warn-no-reset-in-loop --warn-scratch-length ``
A clean build means the five known leak patterns are absent. It does not catch everything — the flags are pattern-specific.
- Runtime gate. Compare peak RSS across a low and high iteration count (
--iter 3vs--iter 200). If peak RSS grows with iterations, something in the loop still allocates. Flat is the pass condition, not "small". Use the memory-event log to attribute the growth.
Reviewing for it
Reading a diff, the questions that catch nearly everything:
- Is there a
newinside a loop, or in a function called per row, per partition, per query? Why is it not hoisted orlocal? - Does a function return a freshly allocated array? Could the caller own it instead?
- Is a
Stringbuilt anywhere that runs more than once? - Does anything use
buf.lengthwhere it means "row count"? - Is
t.col()called inside a loop when the result depends only on the column index, not on the loop variable? - Does a class that is reused per query have a
reset()?
2. Prefer switch over if/else if chains
SuperJ compiles switch to a jump table when the cases are dense integers, and to a binary search when they are sparse. An if/else if chain is always a linear scan.
When to use switch
- 3+ cases on the same expression. Two cases is a toss-up; three or more,
switchwins on both speed and clarity. - Dispatching on an
intopcode orenum. This is the canonical case — the compiler emits a jump table and the branch predictor handles it well.
Rules
- No implicit fall-through. Unlike C/Java, each case ends with
breakorreturn. The compiler treats fall-through as an error, not a warning. - Always provide a
defaultfor switches on values that could be out of range (intopcodes from external input, type codes from files). A missingdefaulton an unexpected value is silent wrong behaviour. - Keep cases short. If a case body is more than a few lines, extract it into a method. A 20-case
switchwhere each case is one line is readable; a 5-caseswitchwhere each case is 30 lines is not. - Case labels must be integer literals (or
char/enumconstants), notstatic final intnamed constants. Useif/else ifwhen the values are named constants.
Example
// Good — jump table, one line per case (enum constants are allowed)
switch (agg.op) {
case SUM: return c.sumDoubleRange(start, end);
case COUNT: return (double)(end - start);
case MIN: return c.minDoubleRange(start, end);
case MAX: return c.maxDoubleRange(start, end);
default: return 0.0;
}
// Bad — linear scan, harder to read
if (agg.op == AggOp.SUM) {
return c.sumDoubleRange(start, end);
} else if (agg.op == AggOp.COUNT) {
return (double)(end - start);
} else if (agg.op == AggOp.MIN) {
return c.minDoubleRange(start, end);
} else if (agg.op == AggOp.MAX) {
return c.maxDoubleRange(start, end);
} else {
return 0.0;
}
3. Prefer early return to reduce nesting
Deep nesting makes code harder to read and harder to get right. A guard clause at the top of a method is clearer and shorter than wrapping the entire body in an if.
Rules
- Guard clauses first. Check preconditions and impossible cases at the top, return early, keep the happy path at the top indentation level.
- Early return from
arenablocks is safe. The block is dropped on return — no leak. (Butthrowstill leaks — see GC-free coding §1.) - Early return costs nothing. There is no stack unwinding cost in a no-GC language — it is just a branch. Don't avoid early return for "performance".
Example
// Good — guard clauses, happy path at indent 1
static double eval(Table t, int symId, PIndex idx) {
if (idx == null) return 0.0;
if (symId < 0 || symId >= idx.groupCount()) return 0.0;
long[] bounds = idx.runBoundaries();
double sum = 0.0;
for (int g = 0; g < idx.groupCount(); g++) {
int start = (int) bounds[g * 2];
int end = (int) bounds[g * 2 + 1];
sum += Kernels.sumDoubleRange(t.col(0).asDoubles(), start, end);
}
return sum;
}
// Bad — deep nesting, happy path at indent 3
static double eval(Table t, int symId, PIndex idx) {
if (idx != null) {
if (symId >= 0 && symId < idx.groupCount()) {
long[] bounds = idx.runBoundaries();
double sum = 0.0;
for (int g = 0; g < idx.groupCount(); g++) {
int start = (int) bounds[g * 2];
int end = (int) bounds[g * 2 + 1];
sum += Kernels.sumDoubleRange(t.col(0).asDoubles(), start, end);
}
return sum;
}
}
return 0.0;
}
The two forms produce identical code, but the first is easier to read, easier to extend, and easier to review for allocation discipline — the guard clauses separate the "can't happen" cases from the hot path, so the reviewer's attention stays on the loop.