编码规范
三条风格规则,目前都是吃过亏才学到的。它们无关品味 — 每一条都是能发布的代码与悄悄让你付出代价的代码之间的差别。这些规范既适用于人类作者,也适用于 agent 作者。
目录
1. 无 GC 编码
SuperJ 没有垃圾回收器。每个 new 都是永久的 — 对象与其拥有的 arena 同寿,默认 arena 是全局的,存活到进程退出。除非你自己动手,否则不存在短生命周期对象。热路径上的一个 new 不是"回收器会吸收的一点压力" — 它是一个带速率的泄漏。
这是与 Java 最大的不同,也是每个下游项目踩到的内存 bug 的源头。平坦内存的完整指南 — 分配阶梯、arena、local 栈分配、scratch 缓冲区手册、内存事件日志 — 见 内存管理最佳实践。本节是经验法则摘要;原因与配方读那一页。
分配阶梯 — 按顺序尝试
- 不分配。 大多数分配可以避免。返回原语而非包装器;填充调用者拥有的数组而非返回新的;字符串预计算一次而非每次拼接。
local— 栈分配小的、可证明不逃逸的临时量。需要初始化器;不允许用在原语上(普通int已经是栈槽)。仅当 size 是编译期常量时才有效 — 带运行时 size 它被静默忽略,你得到一个全局 arena 分配。编译器不对local做逃逸检查 — 超出其作用域的local引用是未定义行为。- 复用一个长寿 static scratch 缓冲区。 对任何由数据定尺寸的东西,这是答案。分配一次,跨每次调用、分区、查询与迭代复用。按需增长、永不收缩:
``java static double[] scratch = null; static double[] ensureScratch(int n) { if (scratch == null || scratch.length < n) scratch = new double[n]; return scratch; } ``
单线程 → 每个进程一个缓冲区是安全的。无锁、无 thread-local、无防御性拷贝。顺序契约取代锁:调用者必须在下次调用覆盖前消费返回的数据。 在定义处用注释写下那个契约。
arena块用于一批你想一起、以 O(1) 丢弃的作用域中间量。块在每条退出路径上被 drop — 落空、return、break/continue。一个缺口:进行中的异常越过帧展开而不 drop arena,因此从arena块中throw仍泄漏。逃逸是放置决定,而非编译错误 — 一个会超越其块的值被放置到一个确实超越它的区域。- 全局 arena — 默认 — 仅用于真正与进程同寿的东西:预计算路径表、scratch 缓冲区自身、长寿扫描器与索引。
来之不易的规则
- 循环内不要
new。 对 1 亿行的求和循环内不分配。累加器、掩码与索引列表在循环前分配。用--warn-alloc-in-loop在编译时捕获它。 - 循环内不要 String
+。 每个+在全局 arena 产生一个新 String。预计算路径字符串一次并索引。用--warn-string-concat-in-loop。 - 被复用的类用
reset(),不用new。 如果你按查询new一个类,它永远泄漏。加reset()、持有一个 static 实例、每次查询前调用reset()。用--warn-no-reset-in-loop。 - 任何接收可能过大缓冲区的东西都取一个显式计数并校验它。 一个 scratch 缓冲区只保证
>= n;用buf.length作为行数会读到有效数据之后的过时字节。用@Scratch注解并用--warn-scratch-length构建。 - 优先用原语数组(
int[]、long[]、double[])而非集合 — 无装箱、无逐元素对象。 - 库方法在全局 arena 分配。 arena 作用域是词法的,因此被调用者内的
new属于全局 arena,无论调用者把它包在什么里。一个可复用的类因此不能每次调用都分配并指望调用者作用域化它:它必须接收调用者拥有的缓冲区或内部池化。
两道门
- 编译期门。 在任何性能声明前用全部四个告警标志构建:
``bash superj build --warn-alloc-in-loop --warn-string-concat-in-loop \ --warn-no-reset-in-loop --warn-scratch-length ``
干净的构建意味着五种已知泄漏模式不存在。它不捕获一切 — 标志是模式特定的。
- 运行时门。 比较低与高迭代次数的峰值 RSS(
--iter 3对--iter 200)。如果峰值 RSS 随迭代增长,循环中仍有东西在分配。平坦是通过条件,不是"小"。 用 内存事件日志 归因增长。
为此审查
读一个 diff 时,能抓住几乎一切的问题:
- 循环内或按行、按分区、按查询调用的函数里有
new吗?为什么没有提升或local? - 一个函数返回一个新分配的数组吗?调用者能拥有它吗?
- 任何运行超过一次的地方构建了
String吗? - 有谁在意思为"行数"的地方用了
buf.length吗? - 循环内调用了
t.col()而其结果只依赖列索引、不依赖循环变量吗? - 按查询复用的类有
reset()吗?
2. 优先 switch 而非 if/else if 链
SuperJ 在 case 是密集整数时把 switch 编译为跳转表,稀疏时编译为二分搜索。一个 if/else if 链总是线性扫描。
何时用 switch
- 同一表达式上 3+ 个 case。 两个 case 难分;三个或更多,
switch在速度与清晰度上都赢。 - 分派一个
intopcode 或enum。 这是经典场景 — 编译器发跳转表,分支预测器处理得好。
规则
- 无隐式落空。 不同于 C/Java,每个 case 以
break或return结束。编译器把落空视为错误,而非告警。 - 总是提供
default,对可能越界的值(来自外部输入的intopcode、来自文件的类型码)的 switch。在意外值上缺失default是静默的错误行为。 - 保持 case 短。 如果一个 case body 超过几行,提取成方法。一个 20-case 的
switch,每 case 一行,可读;一个 5-case 的switch,每 case 30 行,不可读。 - case 标签必须是整数字面量(或
char/enum常量),而非static final int命名常量。值是命名常量时用if/else if。
示例
// 好 — 跳转表,每 case 一行(允许 enum 常量)
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;
}
// 差 — 线性扫描,更难读
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. 优先提前返回以减少嵌套
深嵌套让代码更难读也更难写对。方法顶部的守卫子句比把整个 body 包进 if 更清晰、更短。
规则
- 守卫子句优先。 在顶部检查前置条件与不可能情况、提前返回,把正常路径留在顶部缩进级别。
- 从
arena块提前返回是安全的。 块在返回时被 drop — 无泄漏。(但throw仍泄漏 — 见 无 GC 编码 §1。) - 提前返回零成本。 在无 GC 语言中没有栈展开代价 — 它就是一个分支。不要为"性能"而避免提前返回。
示例
// 好 — 守卫子句,正常路径在缩进 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;
}
// 差 — 深嵌套,正常路径在缩进 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;
}
两种形式产生相同代码,但第一种更易读、更易扩展、更易审查分配纪律 — 守卫子句把"不可能"情况与热路径分开,因此审查者的注意力留在循环上。