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.
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
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.
- 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};
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.
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. | |
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). - 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.