Representation contract

How much space a value takes, how an array of it strides, and what lands on disk or on a wire.

This page is the specification, not a report. There is no formal standard for most of what is below: Delphi made choices, FPC followed them, and PXX takes FPC's where they are settled. So when you need to know whether you may rely on a size, there is nothing else to appeal to — which means this page owes you a distinction that a "how it works today" page does not:

Every number here was measured, not derived, with the compiler named at the bottom of the page.

How to read a row

Three separate questions, and the second and third are the ones that bite:

size what SizeOf answers
stride how far apart consecutive elements of an array of T sit
on disk what one record of file of T occupies

For every type on this page all three are the same number. That is itself part of the contract: PXX does not pad array elements beyond their own size, and a typed file writes exactly SizeOf per record. Where a type has no file of support at all, the row says so.

Scalars

Guaranteed, and identical on x86-64, i386, arm32, aarch64 and riscv32:

Type Size
Boolean, Byte, ShortInt, Char 1
WideChar, Word, SmallInt 2
Integer, LongInt, Cardinal, Single 4
Int64, QWord, Double 8

Integer is 4 bytes on every target, including the 64-bit ones. If you want a width that follows the machine, name NativeInt or PtrInt.

Two rows follow the target's pointer width instead — guaranteed to be the pointer width, not guaranteed to be any particular number:

Type 32-bit targets 64-bit targets
Pointer, any typed pointer, string 4 8
NativeInt, PtrInt, PtrUInt 4 8

Real is the one scalar whose width is a target decision rather than a pointer-width one — 8 on the desktop targets and 4 on the ESP class. It has its own section under Types, including why, and it is the reason not to name Real in anything shared with another program.

Strings

string is a handle, not the characters

SizeOf(string) is the pointer width — 8 on a 64-bit target, 4 on a 32-bit one — because the variable holds a reference to a managed, reference-counted, heap-allocated buffer. It is not the length of the text and it is not a bound on it. A record containing a string field is likewise 8 bytes wider per field, not "wide enough for the text", so a record with a string in it cannot be blitted to a file: writing the handle writes a pointer into a heap the reader does not have.

This is worth stating plainly because FPC's answer depends on its mode. Under {$mode delphi} or {$H+} FPC agrees with us and SizeOf(string) is the pointer width; in FPC's own default mode a plain string is a 256-byte shortstring instead. PXX has one string and it is always the managed one.

string[N] is fixed-width and blittable

A capacity-bounded string is a length prefix followed by the characters, laid out inline. This is the type to use when a width has to be known — a record written to a flat file, a fixed field in a wire format.

Declaration Size, all targets Layout
string[1] 2 1-byte length + 1 char
string[10] 11 1-byte length + 10 chars
string[255] 256 1-byte length + 255 chars

Guaranteed for N from 1 to 255: SizeOf(string[N]) is N+1, on every target, and it is byte-for-byte what FPC produces. So a file of fixed strings written by PXX is readable by an FPC program and the reverse, and a 32-bit and a 64-bit build of the same PXX program agree.

N above 255 is a PXX extension, and it does not have the same guarantee. FPC refuses the declaration outright — "string length must be a value from 1 to 255" — so there is nothing to be compatible with, and PXX switches to an 8-byte length word to hold the larger count:

Declaration 32-bit targets 64-bit targets
string[256] 264 264
string[300] 308 312
string[1000] 1008 1008

The size is N+8 rounded up to the target's pointer alignment, which is why string[300] differs between word sizes while string[256] does not. Do not put a string[N>255] in a record you write to a file that a differently-sized build will read. For a portable fixed field, stay at 255 or below, or use an array[1..N] of Char and carry the length yourself.

The trailing padding is incidental: the rule (8-byte count, then align) is the contract; that string[300] happens to land on 312 rather than 316 is not.

Sets

A set is 32 bytes on every target, always, whatever its declared bounds. Guaranteed.

type
  TSmall = set of 0..7;      { SizeOf = 32 }
  TBytes = set of Byte;      { SizeOf = 32 }
  TChars = set of Char;      { SizeOf = 32 }
  TFlags = set of (fA, fB);  { SizeOf = 32 }

The width does not follow the declared bounds. That is a deliberate choice and not a pending optimisation: one width means a set never changes size when its element type gains a member, and the RTL needs no per-set-size code paths.

The layout is a plain bitset — ordinal n is bit n mod 8 of byte n div 8, so ordinals 0..255 all fit and nothing beyond 255 can be a set element.

Our bytes are a zero-extension of FPC's. FPC sizes a small set down to 1, 2 or 4 bytes (and how far depends on its mode: set of 0..7 is 4 bytes under {$mode objfpc} and 1 byte under {$mode delphi}). Where FPC's set is narrower, its bytes are exactly our leading bytes. Measured: [1, 3, 7] is 138 in byte 0 with every remaining byte zero under PXX, 138 0 0 0 under FPC objfpc, and 138 under FPC delphi. So the low bytes of a PXX set can be read directly by FPC code that knows the narrower width — but a PXX set written whole is 32 bytes and an FPC reader must expect that.

Records

Field offsets and padding match FPC exactly. Guaranteed, and measured identical on all five runnable targets:

type
  TRec  = record B: Byte; L: LongInt; end;          { SizeOf 8, L at offset 4 }
  TPack = packed record B: Byte; L: LongInt; end;   { SizeOf 5, L at offset 1 }

An unpacked record aligns each field to its own size and pads the whole record to its widest member. packed removes all of it. There is no PXX-specific alignment rule to learn: if you know FPC's layout, you know ours.

The two caveats are about what is inside the record, not the record: a field of a managed type (string, a dynamic array, an interface) is a pointer, and a string[N>255] field carries the word-size dependence described above. A record of scalars, fixed strings up to 255, and other such records is blittable.

file of T and BlockWrite

A typed file writes exactly SizeOf(T) per record and the cursor advances by the same, so element k is at byte offset k * SizeOf(T). Measured, writing two records each:

Declaration Bytes per record
file of Char 1
file of LongInt 4
file of packed record B: Byte; L: LongInt; end 5
file of record B: Byte; L: LongInt; end 8

BlockWrite on an untyped file writes exactly the count you pass, and SizeOf(v) is the right count for a blittable v — verified for a string[10] (11 bytes) and a string[300] (312 on a 64-bit target).

One current limitation: file of string[N] does not compile. Write to one is refused with a width that disagrees with SizeOf, even though the element size the file records is correct. FPC compiles the same program. Until it is fixed, write fixed strings through BlockWrite on an untyped file, or wrap the string in a record. This is tracked in the project backlog; it is a defect in one argument-size path and not a property of the layout, which is why the string[N] rows above are still the contract.

What PXX deliberately does differently from FPC

Everything else on this page is FPC's layout. These three are chosen:

PXX FPC Why
SizeOf(set) always 32 1–32, follows the bounds and the mode one width, no per-size RTL paths; our bytes zero-extend theirs
SizeOf(string) always the pointer width pointer width in Delphi/{$H+} mode, 256 in FPC's default mode PXX has one string and it is managed
string[N], N > 255 accepted, 8-byte count rejected outright a capability FPC does not offer; the cost is the word-size dependence noted above

None of these is a gap waiting to be closed.

Check it yourself

The whole contract fits in one program. Compile and run it on any target you care about rather than trusting the tables above:

program contract;
type
  S10   = string[10];
  S255  = string[255];
  TSet  = set of Byte;
  TRec  = record B: Byte; L: LongInt; end;
  TPack = packed record B: Byte; L: LongInt; end;
var
  r: TRec;
  a: array[0..1] of S10;
begin
  writeln('Pointer      ', SizeOf(Pointer));
  writeln('string       ', SizeOf(string));
  writeln('string[10]   ', SizeOf(S10), '  stride ', SizeOf(a) div 2);
  writeln('string[255]  ', SizeOf(S255));
  writeln('set of Byte  ', SizeOf(TSet));
  writeln('record       ', SizeOf(TRec), '  L at ', PtrUInt(@r.L) - PtrUInt(@r));
  writeln('packed       ', SizeOf(TPack));
end.

On a 64-bit target, and on a 32-bit one for comparison:

                 x86-64   i386
Pointer            8        4
string             8        4
string[10]        11       11    stride 11 on both
string[255]      256      256
set of Byte       32       32
record             8        8    L at 4 on both
packed             5        5

Two rows move and five do not. That is the shape of the whole contract: the pointer-width rows follow the machine and everything else is fixed. Cross-compile it with --target=i386 (or arm32, riscv32, aarch64) and run it under qemu to reproduce the right-hand column.

What these numbers were measured with

Every size, stride, offset and file width on this page was produced by running a probe program, not by reading the compiler source.

The string[N > 255] word-size dependence is the reason the target list matters: on x86-64 alone, string[300] measures 312 and looks like a constant.

Next