Struct:零开销线上类型
SuperJ 的 struct 是一种固定布局的记录类型,其**内存布局就是它的线上格式** — 紧凑、小端、无对象头、无继承、无 GC。一个 struct 给你的,正是 Java 要用 ByteBuffer 外加一整套访问器类层级才能伪造的东西:字节上的类型化视图,其中 o.field 编译成一个在编译期常量偏移处的直接加载。它为 SEDA 事件消息、mmap journal、网络协议头,以及任何必须读或写另一个进程产生的字节的代码而存在。
本教程从一个简单的原生 struct 走到完整特性集 — 嵌套 struct、定长数组、enum 字段、方法、变长字段、flyweight view()/bindTo() 模式,以及合成的 API。下面每段代码都改编自 SDK 自带的 struct 示例,因此原样即可编译运行。
设计理由(为什么没有继承、为什么没有类引用)和正式规格见本指南其余部分。本页是操作指南。
1. 第一个 struct
struct 用 struct 关键字声明。字段按声明顺序放在连续偏移处,无填充。
public struct EventHeader {
long seq;
long timestamp;
short sourceAppId;
short cmdType;
}
EventHeader.SIZE 是编译器合成的一个 static final int — 这里是 20(8 + 8 + 2 + 2)。用 new 分配一个,直接读写字段:
EventHeader h = new EventHeader();
h.seq = 42L;
h.timestamp = 1700000000L;
h.sourceAppId = (short) 7;
h.cmdType = (short) 100;
System.out.println("seq=" + h.seq);
System.out.println("SIZE=" + EventHeader.SIZE);
这就是原生的全部故事。h.seq = 42L 下放为 putLong(base + 0, 42);h.seq 下放为 getLong(base + 0)。没有访问器对象,运行时没有字段元数据 — 优化器看到的是与手写 buf.getLong(off) 相同的内存访问模式。
赋值与参数是引用,而非拷贝
struct 是一个引用类型,与类相同。P b = a; 以及把 struct 作为方法实参传递,拷贝的都是引用,而非字节 — 与 Java 类引用遵循的规则一致,也是 ==(§8)是同一性比较而非逐字段比较的原因。两个变量可以指向同一份底层字节,通过其中任何一个写入,都能通过另一个看到:
EventHeader a = new EventHeader();
a.seq = 1L;
EventHeader b = a; // b 是 a 的别名 — 不拷贝任何字节
b.seq = 99L;
System.out.println(a.seq); // 99:a 与 b 是同一个 struct
static void bump(EventHeader h) { h.seq = h.seq + 1; }
bump(a);
System.out.println(a.seq); // 100:被调方透过调用方的 struct 写入
这是有意为之,而非疏忽:struct 的全部意义就在于字节之上编译期常量偏移的视图,因此在每次赋值时拷贝,等于在一行看起来像指针移动的代码里藏一次隐式 memmove — 与类引用相同的成本模型。当你真的想要一份独立副本时,请明确说出来:copyFrom(§8)把 totalSize() 字节从一个 struct 拷贝到另一个,而 view()/bindTo()(§7)则是你有意共享或重指一个引用的方式。需要警惕的隐患:修改你收到的 struct — 作为参数,或从集合/池中取出 — 可能对持有同一引用的其他人可见。如果你在修改前需要一份独立副本,先分配一个新 struct 并对它 copyFrom。
2. 嵌套 struct
struct 字段本身可以是 struct。嵌套 struct 被对其声明的偏移处内联 — 没有指针、没有独立分配。通过点号字段名链式访问:
public struct OrderEnvelope {
EventHeader header; // 在偏移 0..20 处内联
long orderId; // 20
long quantity; // 28
int side; // 36
}
OrderEnvelope env = new OrderEnvelope();
env.header.seq = 99L;
env.header.timestamp = 1234567890L;
env.orderId = 555L;
env.side = 1;
System.out.println("nested.seq=" + env.header.seq);
System.out.println("SIZE=" + OrderEnvelope.SIZE); // 40
OrderEnvelope.SIZE 是 40 — EventHeader.SIZE(20)加上外层字段。这就是你不用继承组合线上头的方式(见 §10)。
3. 定长数组与 enum 字段
定长数组
定长数组字段内联 N * sizeof(elem) 字节。语法是 elemType[N] fieldName — 不同于 Java 的动态 byte[](struct 中不允许):
public struct WithUuid {
long id;
byte[16] uuid; // 内联 16 字节
int trailer;
}
WithUuid w = new WithUuid();
w.id = 100L;
w.trailer = 999;
System.out.println("SIZE=" + WithUuid.SIZE); // 28
定长 prim[N] 字段的访问规则(FR#16,#3717/#3740/#3742/#3753/#3770):按元素索引(w.uuid[i])、.length,以及整字段写(w.uuid = src)都是就地且合法的。整字段读只有在作为 = 或声明语句的直接右侧时才合法 — 这是一次显式拷贝,按目的地分别下放(#3753):
dst.uuid = src.uuid; // 字段到字段:一次就地 memmove,无分配
byte[] b = w.uuid; // 全新的 N 元素副本(`local`/不逃逸时在栈上)
existing = w.uuid; // 拷贝进已有数组:同一个对象,无分配
// null -> NPE;existing.length < N -> AIOOBE;
// 可证明过小的 dst 是编译错误
// (E_FIXED_ARRAY_COPY_BOUNDS)。声明时
// 不带初始化器的局部变量会改为重新绑定到
// 一份全新副本(可能尚无数组可填充)
this.cache = w.uuid; // 向字段写入全新副本,重新绑定(如同 new byte[N] + fill)
这份清单背后的规则(doc/honestyPrinciple.md):定长字段是偏移处的内联字节 — 不是数组对象 — 因此从它造出一个 byte[] 是一次转换,而转换只有在源码写明它转换成什么的地方才是诚实的。上面每个合法形式都在为拷贝付出代价的那一行写出了 byte[](或点名一个 byte[] 字段)。把字段直接传给方法、或把它返回,则不是:
Hash.sha256(hdr.parentHash, 0, 32, out, 0); // 错误:这里没有任何地方说“拷贝”
byte[] ph = hdr.parentHash; // 写明它,就是合法的…
Hash.sha256(ph, 0, 32, out, 0); // …而且这段会编译成完全相同的代码
这个临时值不是税:两种写法发出完全相同的拷贝,而且只要被调方可证明不会保留它(一个 SuperJ 方法,或声明了 @NoRetain 的 native — 加密与摘要类 native 就是),逃逸分析会把它放到栈上。临时值买到的是读者能看见这次拷贝。当调用位于热路径时,优先用零拷贝形式:索引与 .length 直接触碰 struct 缓冲区,而 copyTo/copyFrom 移动字节却不物化任何东西。
透过副本的写入永远到不了 struct — 这正是“副本”的含义;上面的就地形式才是你在 struct 之间移动字节的方式。这包括会写自己参数的被调方:writeInto(w.uuid) 填的是副本,struct 原封不动;byte[] b = w.uuid; writeInto(b); 也一样。编译器会对恰好这种情况发出警告(W_FIXED_FIELD_COPY_WRITTEN,#3767)— 它从方法体或 native 上的 @Writes 知道被调方会写哪些参数 — 因此 Hash.sha256(data, 0, n, hdr.digest, 0) 会被报告,而字段作为输入的 Hash.sha256(hdr.parentHash, 0, 32, out, 0) 则不会。当一个方法本意是填充某个字段时,传给它一个缓冲区再用 copyFrom,或让它接收 (buf, off) 并透过视图写入:
byte[] buf = new byte[32];
Hash.sha256(data, 0, n, buf, 0); // 填充一个真实缓冲区
hdr.digest.copyFrom(buf); // 然后把字节移入字段
==/!= 是唯一一个写明目的地也救不回来的场景:对一份全新副本做引用比较,无论怎么写都是 false,所以应改为比较内容。请显式拷贝:
byte[] dst = new byte[16];
w.uuid.copyTo(dst); // 字段 -> 调用方拥有的缓冲区,恰好 N 个元素
w.uuid.copyTo(dst, off); // 写到 dst 的某个偏移处
w.uuid.copyFrom(src); // 缓冲区 -> 字段(也存在偏移形式)
边界行为与 System.arraycopy 相同:null 缓冲区抛 NullPointerException,off < 0 或 off + N > buf.length 抛 ArrayIndexOutOfBoundsException,且缓冲区的元素类型必须与字段的一致。(字段级 copyFrom 不同于下文“写入顺序”一节中 struct 级的 m.copyFrom(other),后者会覆盖整个实例。)
Enum 字段
enum 字段存储 enum 的 ordinal(以声明的整数宽度,默认 int)。赋一个 enum 常量,读回 enum,在 switch 中使用:
enum Color { RED, GREEN, BLUE }
public struct ColorEvent {
long id;
Color c;
}
ColorEvent e = new ColorEvent();
e.id = 123L;
e.c = Color.GREEN;
System.out.println("ord=" + e.c.ordinal());
System.out.println("name=" + e.c.name());
e.c = Color.BLUE;
System.out.println("switch=" + (e.c == Color.BLUE ? "yes" : "no"));
4. 方法、构造器与 static final 常量
struct 可以声明构造器、实例方法、静态方法和 static final 常量 — 就像一个类,只是每个方法都是非虚的(静态分派、完全可内联)。
struct Order {
long orderId;
long quantity;
int side;
static final int SIDE_BUY = 1;
static final int SIDE_SELL = 2;
public Order(long id, long qty, int s) {
this.orderId = id;
this.quantity = qty;
this.side = s;
}
public boolean isBuy() { return side == SIDE_BUY; }
public long notional(long price) { return quantity * price; }
public static int buyCode() { return SIDE_BUY; }
}
Order o = new Order(42L, 100L, Order.SIDE_BUY);
System.out.println(o.notional(5L)); // 500
System.out.println(o.isBuy()); // true
System.out.println(Order.buyCode()); // 1
无参构造器总是被合成(即使你声明了自己的重载),因此 §8 的 flyweight 模式可用 — new Order() 永远有效。
5. 变长字段:bytes 与 string
struct 可以在尾部数据区携带变长数据。变长字段在固定区占一个 8 字节槽(4 字节数据偏移 + 4 字节长度),指向尾部。两种形式:
bytes— 原始字节;读取返回byte[],写入接受byte[]。string— UTF-8 文本;可以直接赋一个String,读回也是String。
public struct LogLine {
long timestamp;
int level;
bytes payload; // 变长原始
}
LogLine line = new LogLine();
line.timestamp = 1700000000L;
line.level = 3;
byte[] data = new byte[5];
data[0] = (byte) 'h'; data[1] = (byte) 'e'; data[2] = (byte) 'l';
data[3] = (byte) 'l'; data[4] = (byte) 'o';
line.payload = data;
System.out.println("SIZE=" + LogLine.SIZE); // 固定区:20
System.out.println("totalSize=" + line.totalSize()); // 20 + 5 = 25
byte[] read = line.payload;
System.out.println("read.length=" + read.length); // 5
SIZE 只是固定区。totalSize() 返回 SIZE 加上所有已写变长数据之和 — 即要成帧/发布的字节数。
写入顺序与 reset()
变长写入严格有序,以避免隐式移位级联:
- 按声明顺序、从第一个开始连续地写变长字段 — 每一个先于你正在写的变长字段都必须已经写好。跳过前面某个同样会抛
IllegalStateException,不只是乱序写入才会:没有为你打算稍后再填的字段预留任何头部槽,因为每个字段的写入偏移就是“上一个变长字段的结尾”。 - 每个变长字段每次构建序列最多写一次。
- 一旦后一个变长字段被写,前一个就被冻结 — 覆盖已冻结字段会抛
IllegalStateException。 reset()清除所有变长状态并开始一个全新的构建序列。
struct Msg { int id; bytes a; bytes b; }
Msg m = new Msg();
m.id = 7;
m.a = new byte[]{1, 2};
m.b = new byte[]{3, 4, 5};
// m.a = new byte[]{9}; // 会抛 IllegalStateException — 'a' 已冻结
m.reset(); // 全新构建序列
m.a = new byte[]{8};
m.b = new byte[]{8, 8};
Msg n = new Msg();
n.id = 7;
// n.b = new byte[]{3, 4, 5}; // 同样会抛 IllegalStateException —
// 'a' 在声明顺序中在前,却从未被写,
// 所以 'b' 无处可去
n.a = new byte[]{1, 2}; // 先写 'a',哪怕只是一个
// 诸如 new byte[0] 的占位符
n.b = new byte[]{3, 4, 5}; // 现在合法了
这契合 SEDA 的“构建一次然后发布”模型。要“修改”一个已存在的消息,调用 reset() 重建 — 你不会就地修补字段。如果某个变长字段有时缺省,改用定容 byte[N]/string[N] 字段(§6)— 它们没有顺序约束。
容量不会替你检查(#4050)
一次变长写入就是向 struct 绑定的缓冲区里做一次原始 memcpy — 没有针对该缓冲区实际容量的越界检查。这是有意为之:struct 是紧凑且无头的,因此写入点没有任何东西知道你绑定给它的缓冲区实际有多大(视图的空间通常比 struct 自己的固定区更大,若按固定区检查会误拒合法代码)。容量是调用方的责任。
特别是 SEDA 发布(CommandHandler.prepare() / Publisher),要在赋值字段之前检查 payload 大小,而不是之后:
CommandHandler<AdminRequest> h = publisher.getHandler(Msgs.ADMIN_REQUEST);
if (payload.length > publisher.maxBodyBytes() - AdminRequest.SIZE) {
throw new IllegalArgumentException("payload too large for this Publisher");
}
AdminRequest req = h.prepare();
req.cmd = payload;
h.publish(req.totalSize());
只在 Publisher.commit() 时才检查就太晚了:那时字段写入已经发生,超大的 payload 已经越过了缓冲区。取决于旁边是什么内存,这可能悄悄破坏无关的状态,并在很久之后以一个令人困惑、看似无关的崩溃形式浮现 — 而不是写入点上一个明显的“payload 过大”错误。
6. 定容字段:string[N] / bytes[N]
定容字段是一个内联的 N 字节槽,带一个长度前缀(N <= 256 时 1 字节,更大 N 时 2 字节),然后是数据。它不同于变长 string/bytes(后者有尾部数据区):
struct Rec {
int id;
string[16] name; // 1 字节前缀,最多 15 数据字节
bytes[8] tag; // 1 字节前缀,最多 7 数据字节
string[300] note; // 2 字节前缀,最多 298 数据字节
}
Rec r = new Rec();
r.id = 5;
r.name = new byte[]{72, 73, 74}; // 3 字节
r.tag = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9}; // 9 -> 截断为 7
r.note = "a longer note here"; // String 赋值 OK
byte[] n = r.name;
System.out.println(n.length); // 3
System.out.println(new String(r.note)); // UTF-8 String 视图
UTF-8 String 访问器
string 或 string[N] 字段在写入时接受 String(以 UTF-8 字节存储)。一个变长 string字段读回时直接是 String:
struct Rec2 {
int id;
string[16] name; // 定容 UTF-8
string note; // 变长 UTF-8
bytes[4] raw; // 原始字节(仅 byte[])
}
Rec2 r = new Rec2();
r.id = 7;
r.name = "Bob";
r.note = "a longer note here";
r.raw = new byte[]{10, 20, 30};
System.out.println(new String(r.name)); // 定容 -> 通过 new String(...) 得 String
System.out.println(r.note); // 变长 -> 直接是 String
byte[] rw = r.raw; // bytes[4] -> byte[]
bytes / bytes[N] 字段保持纯 byte[] — 要把一个字段作为文本读,把它声明为 string。(new String(field) 在 string 字段上也可用,并产生一个独立的副本。)
7. Flyweight 模式:view() 与 bindTo()
热路径的构造模式是零分配:把一个 struct 引用绑定到已有字节 — 一个 byte[] 或一个 mmap 的 MemoryMapped 区域 — 并通过它读写字段。无拷贝、无 arena 分配。这就是 SEDA 遍历事件的方式,也是你就地解析网络帧的方式。
import sj.io.MemoryMapped;
import sj.io.File;
public struct MmFixed {
long id;
long qty;
int side;
}
view() — 绑定一次
MemoryMapped mm = new MemoryMapped(path, 64L);
MmFixed r = MmFixed.view(mm, 0L); // 在偏移 0 处把一个新视图绑定到 mm
r.id = 7L;
r.qty = 100L;
r.side = 1;
mm.close();
bindTo() — 重绑已有引用
MmFixed r = new MmFixed();
r.bindTo(mm, 0L);
r.id = 1L;
r.bindTo(mm, 32L); // 把同一个引用重新指向新偏移
r.id = 2L;
r.bindTo(mm, 0L); // 走回去
// 此时 r.id == 1L
预先一次分配,然后按记录重绑 — 这就是 LMAX/SEDA codec 的形状。
持久化往返
因为布局就是线上格式,把一个 struct 写入 mmap 文件再在新进程中读回是一个 no-op:
// 进程 1:写
MemoryMapped mm = new MemoryMapped(path, 64L);
MmFixed w = MmFixed.view(mm, 0L);
w.id = 12345L; w.qty = 67890L; w.side = 3;
mm.sync(); mm.close();
// 进程 2:读
MemoryMapped mm2 = new MemoryMapped(path, 64L);
MmFixed rd = MmFixed.view(mm2, 0L);
// rd.id == 12345L, rd.qty == 67890L, rd.side == 3
mm2.close();
没有序列化步骤。没有字节序转换。sync() 把 mmap 刷到磁盘;另一个进程映射同一个文件并读取相同字节。
变长 struct:3 参 view/bindTo
带任何 bytes/string 字段的 struct 具有动态总尺寸,因此绑定需要绑定长度(成帧记录大小减去其 4 字节长度前缀)。编译器为变长 struct 合成3 参重载(上面那些 2 参形式对它们不存在):
public struct MmVar {
long id;
bytes payload;
}
MemoryMapped mm = new MemoryMapped(path, 256L);
MmVar v = MmVar.view(mm, 0L); // 变长:也有 3 参形式可用
v.id = 42L;
v.payload = new byte[]{(byte)'a', (byte)'b', (byte)'c'};
byte[] r = v.payload; // {a, b, c}
8. 合成 API 参考
每个 struct 除你声明的内容外,还会得到这些合成成员:
| 成员 | 形状 | 说明 | |
|---|---|---|---|
SIZE | public static final int | 固定区大小(不含变长尾部)。 | |
Foo() | public Foo() | 无参构造器 — 总是被合成,即使你声明了其他构造器。零初始化。 | |
view(buf, off) | public static Foo view(byte[] buf, int off) | Flyweight:把一个新引用绑定到 buf 的 off 处,无分配。变长 struct 合成 3 参形式 view(buf, off, length)。 | |
view(mm, off) | public static Foo view(MemoryMapped mm, long off) | 对 mmap 的相同行为。变长:view(mm, off, length)。 | |
bindTo(buf, off) | public void bindTo(byte[] buf, int off) | 把已有引用重新指向新字节。变长:3 参形式。 | |
bindTo(mm, off) | public void bindTo(MemoryMapped mm, long off) | 对 mmap 的相同行为。变长:3 参形式。 | |
totalSize() | public int totalSize() | SIZE + 已写变长数据之和 — 即要成帧/发布的字节。 | |
reset() | public void reset() | 清除变长状态;把 (offset, length) 槽清零;回卷尾部数据游标。不清零固定区原语。 | |
eqBytes(other) | public boolean eqBytes(Foo other) | 字节范围相等(同类型,totalSize() 字节匹配)。== 仍是引用同一性 — 见 §1 的“赋值与参数是引用,而非拷贝”。 | |
copyFrom(other) | public void copyFrom(Foo other) | 从 other 拷贝 totalSize() 字节到 this(内建 memmove)。返回后 this.eqBytes(other) == true。 | |
toString() | public String toString() | 调试打印:`Foo{f1=v1 | f2=v2}。原语与 enum ordinal 打印值;嵌套 struct/bytes/string 字段打印占位符。用户声明的 toString()` 优先。 |
一个变长 struct 经 flyweight 的写→读往返,每个字段读回为其声明类型:
struct Order {
long id;
int qty;
byte side;
string symbol; // 变长 UTF-8 -> 读回 String
bytes memo; // 变长原始 -> 读回 byte[]
}
byte[] buf = new byte[256];
Order w = Order.view(buf, 0);
w.id = 42; w.qty = 100; w.side = 1;
w.symbol = "GOOG";
w.memo = new byte[]{7, 8, 9};
int n = w.totalSize();
Order r = Order.view(buf, 0);
long id = r.id; // 原语 -> 值
int qty = r.qty;
String sym = r.symbol; // string -> String
byte[] memo = r.memo; // bytes -> byte[]
System.out.println("totalSize=" + n + " SIZE=" + Order.SIZE);
9. 一个真实的消息
把所有东西放一起 — 一个涵盖每种字段类型与一个带方法的嵌套 struct 的 struct:
enum Side { BUY, SELL }
struct Hash16 {
long hi;
long lo;
public void set(long h, long l) { this.hi = h; this.lo = l; }
public long mix() { return hi ^ lo; }
}
struct Transaction {
long txId;
long amount;
Side side; // enum 作为 ordinal
Hash16 hash; // 带方法的嵌套 struct(内联)
string[16] symbol; // 定容 UTF-8
string memo; // 变长 UTF-8
public boolean isBuy() { return side == Side.BUY; }
public long notional(long price) { return amount * price; }
}
Transaction t = new Transaction();
t.txId = 12345L;
t.amount = 50000L;
t.side = Side.SELL;
t.hash.set(12L, 5L); // 在父字节的该区域上运行
t.symbol = "AAPL";
t.memo = "first trade";
System.out.println(t.hash.mix()); // 9
System.out.println(new String(t.symbol)); // AAPL
System.out.println(t.toString()); // Transaction{txId=12345|...}
t.hash 是 base + hash_offset 处的一个视图 — 就地,无拷贝 — 因此 Hash16 上读 this.hi 的方法读到的是父缓冲区在该区域的字节。带方法的嵌套 struct 正是你在 Java 中要用对象和反射来模拟的类型化访问器。
10. struct 不能做什么
| 不能 | 为什么 | 替代 |
|---|---|---|
extends 另一个 struct/类 | 继承 + 冻结的线上布局很别扭;任何分派表都会污染线上格式。 | 通过嵌套 struct 组合(§2、§9)。 |
implements 一个接口 | struct 不属于接口分派系统。 | 基于注册表的分派 — 例如 SEDA 的 ctx.codecs().register(cmdType, flyweight)。 |
类类型字段(String、ArrayList、…) | 线上字节中的指针跨进程/重启毫无意义。 | string/bytes 用于文本/二进制(按值存储);嵌套 struct 用于结构化数据。 |
动态 T[] 数组字段 | 同样的指针问题 + 变长。 | 固定 T[N] 用于定长;bytes/string 用于变长。 |
| 非 final / 可覆盖方法 | 每个 struct 方法实际上都是 final(静态分派)。 | 如果你需要多态,声明一个类。 |
struct 是一个保留关键字 — 不能用作标识符。
11. 编译与链接
struct 代码通常使用 SDK 类型(MemoryMapped、File),并受益于全程序编译,使虚分派表能看到每个子类。在community 或 enterprise 安装上,用 --sdk-path … --enterprise(通过预构建 SDK 做全程序 LTO):
# 单文件(导入 SDK),community/enterprise 安装:
superj compile test_struct_mmap.sj --sdk-path $SJ_HOME/sdk --enterprise --link
./test_struct_mmap
在 VIP 安装上(SDK 源码可用),改用 --sdk-source:
# 单文件(导入 SDK),VIP 安装:
superj compile test_struct_mmap.sj --sdk-source $SJ_HOME/sdk/sj --link
./test_struct_mmap
或作为项目(推荐 — 构建工具会为已安装的变体选择正确的链接路径):
cd my-struct-project
superj run # 构建 + 运行;为你发出针对 OS 的链接行
对于项目,superj build / superj run 自动处理链接行 — 你永远不用手写 clang。完整的 new/build/run/test 工作流见 构建系统。
12. 小结
- 一个 struct 的布局就是它的线上格式 — 紧凑、小端、声明顺序 = 字节顺序。
o.field编译成一个在编译期常量偏移处的直接加载/存储。 - 两种构造模式:
new Foo()(arena、零初始化)与 flyweightFoo.view(buf, off)/bindTo(buf, off)(零分配、按记录重绑)。 - struct 是引用类型:赋值与传参拷贝的是引用,而非字节,与类完全一致。需要显式、独立的拷贝时用
copyFrom。 - 变长
bytes/string字段使用带严格写入顺序的尾部数据区;totalSize()是要成帧的字节;reset()开始一次全新构建。 string字段读回为String;bytes字段读回为byte[]。没有对象被序列化 — 文本/字节按值内联存储。- 无继承、无接口、无类类型字段 — 改用嵌套 struct 与基于注册表的分派。
设计理由与完整规格见本指南其余部分。