Low-level SWE interview prep — primitive sizes, struct padding, std::string SSO, and where a class object actually lives
sizeof feels like it should be a simple lookup table, but it's actually the output of three separate decisions stacking on top of each other: the data model of the platform (32-bit vs. 64-bit, LP64 vs. LLP64), the alignment rules the compiler must obey, and — for anything with a constructor or virtual function — the ABI (Itanium C++ ABI on macOS/Linux, MSVC ABI on Windows) that decides where hidden machinery like vtable pointers go. This page works bottom-up: plain types first, then padding, then strings, then full class objects.
These are typical sizes under the LP64 data model, which macOS and Linux both use. Nothing here is guaranteed by the C/C++ standard — only minimum ranges are guaranteed — but these are what every mainstream 64-bit compiler actually produces.
| Type | Size | Alignment | Notes |
|---|---|---|---|
| char | 1 byte | 1 | Only type the standard guarantees is exactly 1 byte. |
| bool | 1 byte | 1 | Size not mandated, but universally 1 byte in practice. |
| short | 2 bytes | 2 | |
| int | 4 bytes | 4 | Standard only guarantees ≥ short; 4 bytes is just universal convention now. |
| long | 8 bytes (macOS/Linux) | 8 | The classic gotcha — see callout below. |
| long long | 8 bytes | 8 | Guaranteed ≥ 64 bits by the standard. |
| float | 4 bytes | 4 | IEEE-754 single precision. |
| double | 8 bytes | 8 | IEEE-754 double precision. |
| long double | 16 bytes (GCC/Clang, x86-64) | 16 | 80-bit extended precision, padded to 16; MSVC just makes it identical to double (8 bytes). |
| T* (any pointer) | 8 bytes | 8 | 4 bytes on any 32-bit build. |
| size_t | 8 bytes | 8 | Unsigned, same width as a pointer on that platform. |
| nullptr_t | 8 bytes | 8 | Same width as a pointer. |
Every type has an alignment requirement — the address of any instance of that type must be a multiple of its alignment. For a struct, two rules follow from this: (1) each member must start at an offset that's a multiple of its own alignment, and the compiler inserts padding bytes to make that true, and (2) the struct's overall size must be a multiple of its own alignment (the largest alignment among its members), so that it packs correctly inside an array.
struct Bad {
char c; // 1 byte
int i; // 4 bytes
char c2; // 1 byte
double d; // 8 bytes
};
sizeof(Bad) == 24
struct Good {
double d; // 8 bytes
int i; // 4 bytes
char c; // 1 byte
char c2; // 1 byte
};
sizeof(Good) == 16
A hand-rolled "naive" string — int len; char* str; — would be 16 bytes on a 64-bit machine (4 bytes length + 4 padding + 8-byte pointer) and would always heap-allocate, even for a 1-character string. Real std::string implementations are bigger and smarter: they carry a capacity field for amortized growth, and they avoid the heap entirely for short strings by storing the characters directly inside the object.
| Implementation | Used by | sizeof(std::string) | Max SSO length |
|---|---|---|---|
| libc++ | Clang / macOS (default) | 24 bytes | 22 chars |
| libstdc++ | GCC / most Linux distros | 32 bytes | 15 chars |
| MSVC STL | Windows | 32 bytes | 15 chars |
Take a polymorphic class with one int, one std::string, and a virtual function:
class ZooAnimal {
public:
ZooAnimal(const char* n) : name(n) {}
virtual ~ZooAnimal() {}
virtual void rotate() {}
int loc = 0;
std::string name;
};
Under the Itanium C++ ABI (GCC/Clang, i.e. macOS and Linux), a class that introduces virtual functions gets its hidden vtable pointer placed at offset 0 — the very front of the object, not the end. (Older compilers, like the cfront-era ones some textbooks still diagram, put it at the end — that's a real but now largely obsolete convention.)
| Piece | Segment | Space allocated | Contents filled in |
|---|---|---|---|
| Compiled code for rotate(), ctor, dtor | .text | Compile time | Compile time — it's just instructions, nothing to "fill in" later. |
| The vtable (one per class, shared by all instances) | .rodata / .data.rel.ro | Compile/link time | Static linker (non-PIE binaries) resolves addresses at link time; for PIE executables / shared libraries, the dynamic loader patches relocations while mapping the image — before main() runs, but it's the loader doing address patching, not your code executing. |
| A global object like za | .bss / .data | Compile time (fixed address + size) | If the constructor does real work (heap alloc, copies), the compiler emits a startup call registered in .init_array, run before main() — this is genuine code execution ("dynamic initialization"). |
| A local object (stack) | Stack | Function entry (part of the stack frame) | Constructor runs the instant control reaches the declaration. |
| A heap object (new T(...)) | Heap | operator new call, at runtime | Constructor runs immediately after, on the freshly allocated bytes. |
| A member subobject (e.g. name inside za) | Inline inside the enclosing object | Whenever the enclosing object's storage is allocated | Runs as part of the enclosing object's constructor, in member-declaration order, before the constructor body executes. |
| Segment | Full form |
|---|---|
| .bss | Block Started by Symbol |
| .rodata | Read-Only Data |
| .data.rel.ro | Data, Relocated then Read-Only — writable just long enough for the loader to patch relocations, then remapped read-only |
| Type / object | Typical size (64-bit) | Lives in | Constructed |
|---|---|---|---|
| int | 4 bytes | wherever it's declared (stack/heap/global/inline) | N/A — trivial type, no ctor |
| T* | 8 bytes | wherever declared | N/A |
| Naive {int len; char* str;} | 16 bytes | wherever declared; chars always on heap | Runs whenever the object is constructed |
| std::string (libc++) | 24 bytes | object itself wherever declared; heap only if > 22 chars | Ctor runs at construction; may or may not touch the heap |
| std::string (libstdc++) | 32 bytes | same, SSO threshold 15 chars | Same |
| std::vector<int> | 24 bytes (3 pointers: begin/end/cap) | object inline; elements always on heap | Ctor runs at construction; heap alloc only once you add elements |
| std::unique_ptr<T> | 8 bytes (no custom deleter) | wherever declared | No runtime overhead beyond wrapping the raw pointer |
| std::shared_ptr<T> | 16 bytes (object ptr + control-block ptr) | object inline; control block always on heap | Control block heap-allocated at construction (or reused via make_shared) |
| Polymorphic class w/ vtable (ZooAnimal) | 40 bytes (libc++) / 48 (libstdc++) | wherever declared; vptr points into .rodata | Ctor runs at construction; vtable itself was already fixed at compile/link time |
| The vtable itself | ~1 pointer per virtual function + RTTI/offset slots | .rodata / .data.rel.ro — one copy, shared by every instance | Filled in at link time or by the loader before main() — never per-object |