Structs: Zero-Overhead Wire Types
SuperJ's struct is a fixed-layout record type whose **in-memory layout is its wire format** — packed, little-endian, no object header, no inheritance, no GC. A struct gives you the thing Java needs a ByteBuffer plus an accessor class hierarchy to fake: a typed view over bytes where o.field compiles to a direct load at a compile-time-constant offset. It exists for SEDA event messages, mmap journals, network protocol headers, and any code that has to read or write bytes another process produced.
This tutorial walks from a trivial primitive struct through the full feature set — nested structs, fixed arrays, enum fields, methods, var-length fields, the flyweight view()/bindTo() pattern, and the synthesized API. Every snippet below is adapted from the struct examples shipped with the SDK, so it compiles and runs as written.
See the rest of this guide for the design rationale (why no inheritance, why no class references) and the formal spec. This page is the how-to.
1. A first struct
A struct is declared with the struct keyword. Fields are placed at sequential offsets in declaration order, with no padding.
public struct EventHeader {
long seq;
long timestamp;
short sourceAppId;
short cmdType;
}
EventHeader.SIZE is a static final int the compiler synthesizes — here 20 (8 + 8 + 2 + 2). Allocate one with new, read and write fields directly:
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);
That's the whole primitive story. h.seq = 42L lowers to putLong(base + 0, 42); h.seq lowers to getLong(base + 0). No accessor object, no field metadata at runtime — the optimizer sees the same memory access pattern a hand-coded buf.getLong(off) would.
Assignment and parameters are references, not copies
A struct is a reference type, the same as a class. P b = a; and passing a struct as a method argument copy the reference, not the bytes — the same rule Java class references follow, and the reason == (§8) is identity, not a per-field comparison. Two variables can point at the same underlying bytes, and a write through either is visible through both:
EventHeader a = new EventHeader();
a.seq = 1L;
EventHeader b = a; // b aliases a — no bytes copied
b.seq = 99L;
System.out.println(a.seq); // 99: a and b are the SAME struct
static void bump(EventHeader h) { h.seq = h.seq + 1; }
bump(a);
System.out.println(a.seq); // 100: the callee wrote through the caller's struct
This is deliberate, not an oversight: a struct's whole point is a compile-time-constant-offset view over bytes, so copying on every assignment would mean a hidden memmove on a line that looks like a pointer move — the same cost model a class reference has. When you actually want an independent copy, say so explicitly: copyFrom (§8) copies totalSize() bytes from one struct into another, and view()/bindTo() (§7) are how you deliberately share or re-point a reference. The hazard to watch for: mutating a struct you received — as a parameter, or pulled from a collection/pool — can be visible to whoever else holds the same reference. If you need an independent copy before mutating, allocate a new struct and copyFrom it first.
2. Nested structs
A struct field can itself be a struct. The nested struct is inlined at its declared offset — no pointer, no separate allocation. Access chains through with dotted field names:
public struct OrderEnvelope {
EventHeader header; // inlined at offset 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 is 40 — EventHeader.SIZE (20) plus the outer fields. This is how you compose wire headers without inheritance (see §10).
3. Fixed-length arrays and enum fields
Fixed-length arrays
A fixed-length array field inlines N * sizeof(elem) bytes. The syntax is elemType[N] fieldName — distinct from Java's dynamic byte[] (which is not allowed in a struct):
public struct WithUuid {
long id;
byte[16] uuid; // 16 bytes inlined
int trailer;
}
WithUuid w = new WithUuid();
w.id = 100L;
w.trailer = 999;
System.out.println("SIZE=" + WithUuid.SIZE); // 28
Access rules for a fixed prim[N] field (FR#16, #3717/#3740/#3742/#3753/#3770): element indexing (w.uuid[i]), .length, and the whole-field write (w.uuid = src) are in-place and legal. The whole-field read is legal as the direct right-hand side of = or of a declaration — an explicit copy, lowered per destination (#3753):
dst.uuid = src.uuid; // field-to-field: ONE in-place memmove, no allocation
byte[] b = w.uuid; // fresh N-element copy (stack when `local`/non-escaping)
existing = w.uuid; // copy INTO the existing array: same object, no allocation
// null -> NPE; existing.length < N -> AIOOBE; a
// provably-too-small dst is a compile error
// (E_FIXED_ARRAY_COPY_BOUNDS). A local declared
// WITHOUT an initializer re-binds to a fresh copy
// instead (there may be no array to fill yet)
this.cache = w.uuid; // fresh copy into a field, re-binds (like new byte[N] + fill)
The rule behind that list (doc/honestyPrinciple.md): a fixed field is inline bytes at an offset — not an array object — so making a byte[] from it is a conversion, and a conversion is honest exactly where the source says what it converts to. Every legal form above writes byte[] (or names a byte[] field) on the line that pays for the copy. Passing the field straight to a method, or returning it, does not:
Hash.sha256(hdr.parentHash, 0, 32, out, 0); // ERROR: nothing here says "copy"
byte[] ph = hdr.parentHash; // say it, and it is legal…
Hash.sha256(ph, 0, 32, out, 0); // …and this compiles to the SAME code
The temporary is not a tax: both spellings emit the identical copy, and escape analysis puts it on the stack whenever the callee provably does not retain it (a SuperJ method, or a native declared @NoRetain — the crypto and digest natives are). What the temporary buys is that a reader can see the copy. When the call is hot, prefer the zero-copy forms: indexing and .length touch the struct buffer directly, and copyTo/copyFrom move bytes without materialising anything.
Writes through a copy never reach the struct — that is what a copy means; the in-place forms above are how you move bytes between structs. This includes a callee that writes its parameter: writeInto(w.uuid) fills the copy and leaves the struct untouched, and so does byte[] b = w.uuid; writeInto(b);. The compiler warns about exactly this (W_FIXED_FIELD_COPY_WRITTEN, #3767) — it knows which parameters a callee writes, from its body or from @Writes on a native — so Hash.sha256(data, 0, n, hdr.digest, 0) is reported while Hash.sha256(hdr.parentHash, 0, 32, out, 0), where the field is the input, is not. When a method is meant to fill a field, pass it a buffer and use copyFrom, or have it take (buf, off) and write through the view:
byte[] buf = new byte[32];
Hash.sha256(data, 0, n, buf, 0); // fill a real buffer
hdr.digest.copyFrom(buf); // then move the bytes into the field
==/!= is the one place where writing the destination down does not rescue it: a reference comparison against a fresh copy is always false however it is spelled, so compare the contents instead. Copy explicitly:
byte[] dst = new byte[16];
w.uuid.copyTo(dst); // field -> caller-owned buffer, exactly N elements
w.uuid.copyTo(dst, off); // at an offset into dst
w.uuid.copyFrom(src); // buffer -> field (the offset form exists too)
Bounds mirror System.arraycopy: a null buffer throws NullPointerException, off < 0 or off + N > buf.length throws ArrayIndexOutOfBoundsException, and the buffer's element type must equal the field's. (Field-level copyFrom is distinct from the struct-level m.copyFrom(other) in "Write ordering" below, which overwrites a whole instance.)
Enum fields
An enum field stores the enum's ordinal (as the declared integer width, default int). Assign an enum constant, read it back as the enum, use it in 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. Methods, constructors, and static final constants
A struct can declare constructors, instance methods, static methods, and static final constants — just like a class, except every method is non-virtual (statically dispatched, fully inlinable).
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
A no-arg constructor is always synthesized (even when you declare your own overloads) so the flyweight pattern in §8 works — new Order() is always valid.
5. Variable-length fields: bytes and string
A struct can carry variable-length data in a trailing data area. A var-length field occupies an 8-byte slot in the fixed area (4-byte data offset + 4-byte length) pointing into the tail. There are two flavors:
bytes— raw bytes; reads return abyte[], writes accept abyte[].string— UTF-8 text; aStringmay be assigned directly and reads back as aString.
public struct LogLine {
long timestamp;
int level;
bytes payload; // var-length raw
}
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); // fixed area: 20
System.out.println("totalSize=" + line.totalSize()); // 20 + 5 = 25
byte[] read = line.payload;
System.out.println("read.length=" + read.length); // 5
SIZE is the fixed area only. totalSize() returns SIZE plus the sum of all written var-length data — the number of bytes to frame/publish.
Write ordering and reset()
Var-length writes are strictly ordered to avoid an implicit shift cascade:
- Write var-length fields in declaration order, contiguously starting from the first — every var-length field earlier than the one you're writing must already be written. Skipping an earlier one throws
IllegalStateExceptiontoo, not just writing out of order: there's no header slot reserved for a field you plan to fill in later, since each field's write offset is simply "the previous var-length field's end". - Each var-length field is written at most once per build sequence.
- Once a later var-length field is written, earlier ones are frozen — overwriting a frozen field throws
IllegalStateException. reset()clears all var-length state and starts a fresh build sequence.
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}; // would throw IllegalStateException — 'a' is frozen
m.reset(); // fresh build sequence
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}; // would ALSO throw IllegalStateException —
// 'a' comes first in declaration order and
// was never written, so 'b' has nowhere to go
n.a = new byte[]{1, 2}; // write 'a' first, even if it's just a
// placeholder such as new byte[0]
n.b = new byte[]{3, 4, 5}; // now legal
This fits SEDA's build-once-then-publish model. To "mutate" an existing message, call reset() and rebuild — you don't patch fields in place. If a var-length field is sometimes absent, prefer a fixed byte[N]/string[N] field (§6) instead — those have no ordering constraint.
Capacity is not checked for you (#4050)
A var-length write is a raw memcpy into the struct's bound buffer — there is no bounds check against that buffer's actual capacity. This is deliberate: a struct is packed and header-free, so nothing at the write site knows how big the buffer you bound it to actually is (a view routinely has more room than the struct's own fixed area, so checking against that would reject legal code). Capacity is the caller's responsibility.
For SEDA publishing specifically (CommandHandler.prepare() / Publisher), check the payload size before assigning the field, not after:
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());
Checking only at Publisher.commit() time is too late: by then the field write has already happened, so an oversized payload has already overrun the buffer. Depending on what memory sits next, that can silently corrupt unrelated state and surface much later as a confusing, seemingly-unrelated crash — not an obvious "payload too big" error at the write site.
6. Fixed-capacity fields: string[N] / bytes[N]
A fixed-capacity field is an inline N-byte slot with a length prefix (1 byte for N <= 256, 2 bytes for larger N), then the data. It is distinct from the var-length string/bytes (which has a trailing data area):
struct Rec {
int id;
string[16] name; // 1-byte prefix, up to 15 data bytes
bytes[8] tag; // 1-byte prefix, up to 7 data bytes
string[300] note; // 2-byte prefix, up to 298 data bytes
}
Rec r = new Rec();
r.id = 5;
r.name = new byte[]{72, 73, 74}; // 3 bytes
r.tag = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9}; // 9 -> truncated to 7
r.note = "a longer note here"; // String assign OK
byte[] n = r.name;
System.out.println(n.length); // 3
System.out.println(new String(r.note)); // UTF-8 String view
The UTF-8 String accessor
A string or string[N] field accepts a String on write (stored as its UTF-8 bytes). A var-length string field reads back directly as a String:
struct Rec2 {
int id;
string[16] name; // fixed-capacity UTF-8
string note; // var-length UTF-8
bytes[4] raw; // raw bytes (byte[] only)
}
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)); // fixed -> String via new String(...)
System.out.println(r.note); // var-length -> String directly
byte[] rw = r.raw; // bytes[4] -> byte[]
A bytes / bytes[N] field stays byte[]-only — to read a field as text, declare it string. (new String(field) also works on a string field and yields a distinct copy.)
7. The flyweight pattern: view() and bindTo()
The hot-path construction mode is zero-allocation: bind a struct reference to existing bytes — a byte[] or an mmap'd MemoryMapped region — and read/write fields through it. No copy, no arena allocation. This is how SEDA walks events and how you parse a network frame in place.
import sj.io.MemoryMapped;
import sj.io.File;
public struct MmFixed {
long id;
long qty;
int side;
}
view() — bind once
MemoryMapped mm = new MemoryMapped(path, 64L);
MmFixed r = MmFixed.view(mm, 0L); // bind a fresh view to mm at offset 0
r.id = 7L;
r.qty = 100L;
r.side = 1;
mm.close();
bindTo() — rebind an existing reference
MmFixed r = new MmFixed();
r.bindTo(mm, 0L);
r.id = 1L;
r.bindTo(mm, 32L); // re-point the SAME reference at a new offset
r.id = 2L;
r.bindTo(mm, 0L); // walk back
// r.id == 1L here
One allocation up front, then rebind per record — that's the LMAX/SEDA codec shape.
Persistence round-trip
Because the layout is the wire format, writing a struct to an mmap file and reading it back in a new process is a no-op:
// process 1: write
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();
// process 2: read
MemoryMapped mm2 = new MemoryMapped(path, 64L);
MmFixed rd = MmFixed.view(mm2, 0L);
// rd.id == 12345L, rd.qty == 67890L, rd.side == 3
mm2.close();
No serialization step. No byte-order conversion. sync() flushes the mmap to disk; the other process maps the same file and reads the same bytes.
Var-length structs: 3-arg view/bindTo
A struct with any bytes/string field has a dynamic total size, so the binding needs the bound length (the framed record size minus its 4-byte length prefix). The compiler synthesizes 3-arg overloads for var-length structs (the 2-arg forms above do not exist for them):
public struct MmVar {
long id;
bytes payload;
}
MemoryMapped mm = new MemoryMapped(path, 256L);
MmVar v = MmVar.view(mm, 0L); // var-length: 3-arg form also available
v.id = 42L;
v.payload = new byte[]{(byte)'a', (byte)'b', (byte)'c'};
byte[] r = v.payload; // {a, b, c}
8. Synthesized API reference
Every struct gets these synthesized members in addition to what you declare:
| Member | Shape | Notes | |
|---|---|---|---|
SIZE | public static final int | Fixed-area size (excludes var-length tail). | |
Foo() | public Foo() | No-arg constructor — always synthesized even if you declare other constructors. Zero-init. | |
view(buf, off) | public static Foo view(byte[] buf, int off) | Flyweight: bind a fresh reference to buf at off, no allocation. For var-length structs the 3-arg form view(buf, off, length) is synthesized instead. | |
view(mm, off) | public static Foo view(MemoryMapped mm, long off) | Same over mmap. Var-length: view(mm, off, length). | |
bindTo(buf, off) | public void bindTo(byte[] buf, int off) | Re-point an existing reference at new bytes. Var-length: 3-arg form. | |
bindTo(mm, off) | public void bindTo(MemoryMapped mm, long off) | Same over mmap. Var-length: 3-arg form. | |
totalSize() | public int totalSize() | SIZE + sum of written var-length data — the bytes to frame/publish. | |
reset() | public void reset() | Clear var-length state; zero the (offset, length) slots; rewind the trailing-data cursor. Does not zero fixed-area primitives. | |
eqBytes(other) | public boolean eqBytes(Foo other) | Byte-range equality (same type, totalSize() bytes match). == is still reference identity — see "Assignment and parameters are references, not copies" in §1. | |
copyFrom(other) | public void copyFrom(Foo other) | Copy totalSize() bytes from other into this (intrinsic memmove). After return this.eqBytes(other) == true. | |
toString() | public String toString() | Debug print: `Foo{f1=v1 | f2=v2}. Primitives and enum ordinals print values; nested-struct/bytes/string fields print a placeholder. A user-declared toString()` wins. |
A var-length struct write→read round-trip through the flyweight, each field read back as its declared type:
struct Order {
long id;
int qty;
byte side;
string symbol; // var-length UTF-8 -> reads back as String
bytes memo; // var-length raw -> reads back as 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; // primitive -> value
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. A realistic message
Putting it all together — a struct exercising every field type and a nested struct with methods:
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 as ordinal
Hash16 hash; // nested struct with methods (inlined)
string[16] symbol; // fixed-capacity UTF-8
string memo; // var-length 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); // runs over THIS region of the parent's bytes
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 is a view at base + hash_offset — in-place, no copy — so a method on Hash16 reading this.hi reads the parent's buffer at that region. A nested struct with methods is the typed accessor you'd emulate with objects and reflection in Java.
10. What structs cannot do
| Cannot | Why | Use instead |
|---|---|---|
extends another struct/class | Inheritance + frozen wire layout is awkward; any dispatch table contaminates the wire format. | Composition via nested structs (§2, §9). |
implements an interface | Structs are not part of the interface-dispatch system. | Registry-based dispatch — e.g. SEDA's ctx.codecs().register(cmdType, flyweight). |
A class-typed field (String, ArrayList, …) | A pointer in wire bytes is meaningless across processes/restarts. | string/bytes for text/blob (stored by value); a nested struct for structured data. |
A dynamic T[] array field | Same pointer problem + variable length. | Fixed T[N] for fixed-size; bytes/string for variable-length. |
| Non-final / overridable methods | Every struct method is effectively final (static dispatch). | Declare a class if you need polymorphism. |
struct is a reserved keyword — it cannot be used as an identifier.
11. Compiling and linking
Struct code typically uses SDK types (MemoryMapped, File) and benefits from whole-program compilation so virtual-dispatch tables see every subclass. On a community or enterprise install, use --sdk-path … --enterprise (whole-program LTO via the prebuilt SDK):
# one-off file (imports SDK), community/enterprise install:
superj compile test_struct_mmap.sj --sdk-path $SJ_HOME/sdk --enterprise --link
./test_struct_mmap
On a VIP install (SDK source available), use --sdk-source instead:
# one-off file (imports SDK), VIP install:
superj compile test_struct_mmap.sj --sdk-source $SJ_HOME/sdk/sj --link
./test_struct_mmap
Or as a project (preferred — the build tool picks the right link path for the installed variant):
cd my-struct-project
superj run # build + run; emits the OS-specific link line for you
For a project, superj build / superj run handles the link line automatically — you never write clang by hand. See The Build System for the full new/build/run/test workflow.
12. Summary
- A struct's layout is its wire format — packed, little-endian, declaration order = byte order.
o.fieldcompiles to a direct load/store at a compile-time-constant offset. - Two construction modes:
new Foo()(arena, zero-init) and the flyweightFoo.view(buf, off)/bindTo(buf, off)(zero-alloc, rebind per record). - A struct is a reference type: assignment and parameter passing copy the reference, not the bytes, exactly like a class. Use
copyFromfor an explicit, independent copy. - Var-length
bytes/stringfields use a trailing data area with strict write-ordering;totalSize()is the bytes to frame;reset()starts a fresh build. - A
stringfield reads back as aString; abytesfield reads back as abyte[]. No object is serialized — the text/bytes are stored inline by value. - No inheritance, no interfaces, no class-typed fields — composition via nested structs and registry-based dispatch instead.
For the design rationale and the full spec, see the rest of this guide.