← Code Compare

Memory Layout: stack, heap & structs

A struct's bytes aren't laid out the way you wrote them: the compiler inserts padding so each field lands on its natural alignment, so sizeof is usually bigger than the sum of the fields. This topic builds the same struct in every language, places one instance on the stack (automatic, freed when its scope ends) and one on the heap (you own it, you free it), and prints sizeof / alignment / field offsets. Watch three things: the padding holes inside the struct, the lifetime difference between the stack copy and the heap copy, and who is responsible for releasing the heap block.

Show: CC++HolyCZigHareOdinForth
C
#include <stdio.h>
#include <stdlib.h>
#include <stddef.h>   /* offsetof */
#include <stdalign.h> /* alignof  */

/* Fields are NOT packed: the compiler pads so each field is naturally aligned.
   On a 64-bit target this lays out as:
     tag   @ 0   (1 byte)  + 3 bytes padding
     id    @ 4   (4 bytes)
     value @ 8   (8 bytes)            => sizeof 16, alignof 8           */
typedef struct {
    char   tag;     /* 1 byte */
    int    id;      /* 4 bytes, wants 4-byte alignment */
    double value;   /* 8 bytes, wants 8-byte alignment */
} Point;

int main(void) {
    /* 1) STACK: automatic storage. Lives until main() returns; freed for us. */
    Point on_stack = { .tag = 'A', .id = 1, .value = 3.14 };

    /* 2) HEAP: we own this block until we free it. */
    Point *on_heap = malloc(sizeof *on_heap);
    if (!on_heap) return 1;
    *on_heap = (Point){ .tag = 'B', .id = 2, .value = 2.72 };

    printf("sizeof(Point) = %zu, alignof = %zu\n",
           sizeof(Point), alignof(Point));
    printf("offsets: tag=%zu id=%zu value=%zu\n",
           offsetof(Point, tag), offsetof(Point, id), offsetof(Point, value));
    printf("stack: %c %d %g | heap: %c %d %g\n",
           on_stack.tag, on_stack.id, on_stack.value,
           on_heap->tag, on_heap->id, on_heap->value);

    free(on_heap);   /* heap block must be returned by hand */
    on_heap = NULL;  /* the stack Point needs no free */
    return 0;
}

C never packs structs by default: offsetof reveals the 3 padding bytes after tag, so sizeof(Point) is 16, not 13. The stack Point is automatic and reclaimed at scope exit, while the heap copy from malloc is yours to free exactly once.

C++
#include <cstddef>   // offsetof
#include <memory>    // unique_ptr
#include <print>     // std::println (C++23); use <iostream> pre-23

struct Point {
    char   tag;     // 1 byte
    int    id;      // 4 bytes (4-byte aligned)
    double value;   // 8 bytes (8-byte aligned)
};                  // padded: sizeof 16, alignof 8 on a 64-bit target

int main() {
    // 1) STACK: an automatic object, destroyed at end of scope (RAII).
    Point on_stack{'A', 1, 3.14};

    // 2) HEAP: unique_ptr owns the block; ~unique_ptr deletes it for us.
    auto on_heap = std::make_unique<Point>(Point{'B', 2, 2.72});

    std::println("sizeof(Point) = {}, alignof = {}",
                 sizeof(Point), alignof(Point));
    std::println("offsets: tag={} id={} value={}",
                 offsetof(Point, tag), offsetof(Point, id), offsetof(Point, value));
    std::println("stack: {} {} {} | heap: {} {} {}",
                 on_stack.tag, on_stack.id, on_stack.value,
                 on_heap->tag, on_heap->id, on_heap->value);

    return 0;  // on_heap deletes its Point; on_stack is destroyed too. No leaks.
}

Layout and padding match C (sizeof 16, alignof 8), inspectable with offsetof/alignof. The difference is ownership: the stack Point and the unique_ptr's heap Point are both released automatically via RAII at scope exit, so there is no manual delete.

HolyC
// HolyC aligns struct members naturally (like C), so padding appears too.
class Point {
  U8  tag;     // 1 byte
  I64 id;      // 8 bytes (8-byte aligned -> 7 pad bytes after tag)
  F64 value;   // 8 bytes
};               // sizeof(Point) == 24, members at 0, 8, 16

// 1) STACK: a local lives on the task's stack until the block ends.
Point on_stack;
on_stack.tag   = 'A';
on_stack.id    = 1;
on_stack.value = 3.14;

// 2) HEAP: MAlloc grabs from this task's data heap; we Free it ourselves.
Point *on_heap = MAlloc(sizeof(Point));
on_heap->tag   = 'B';
on_heap->id    = 2;
on_heap->value = 2.72;

Print("sizeof(Point) = %d\n", sizeof(Point));
Print("stack: %c %d %f | heap: %c %d %f\n",
      on_stack.tag, on_stack.id, on_stack.value,
      on_heap->tag, on_heap->id, on_heap->value);

Free(on_heap);   // return the heap block; Free(NULL) is a safe no-op

HolyC class is C's struct, and members are naturally aligned, so I64 id after a U8 tag forces 7 padding bytes and sizeof(Point) is 24. The stack instance dies with its block; the MAlloc block lives on the per-task heap until you Free it (and is reclaimed wholesale if the task exits) -- runs only on TempleOS.

Zig
const std = @import("std");

// `extern struct` keeps C ABI layout (declared order + C padding), so the
// offsets are predictable. NOTE: a plain `struct` lets Zig REORDER fields
// to shrink padding -- use extern/packed when layout must be fixed.
const Point = extern struct {
    tag: u8, // 1 byte (+3 pad)
    id: i32, // 4 bytes @ 4
    value: f64, // 8 bytes @ 8   => @sizeOf 16, @alignOf 8
};

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit(); // reports leaks at shutdown
    const allocator = gpa.allocator();

    // 1) STACK: a local; its storage is reclaimed when main() returns.
    const on_stack = Point{ .tag = 'A', .id = 1, .value = 3.14 };

    // 2) HEAP: create returns *Point (an error union); we own it.
    const on_heap = try allocator.create(Point);
    defer allocator.destroy(on_heap); // matched free, runs at scope exit
    on_heap.* = Point{ .tag = 'B', .id = 2, .value = 2.72 };

    std.debug.print("@sizeOf = {d}, @alignOf = {d}\n", .{ @sizeOf(Point), @alignOf(Point) });
    std.debug.print("offsets: tag={d} id={d} value={d}\n", .{ @offsetOf(Point, "tag"), @offsetOf(Point, "id"), @offsetOf(Point, "value") });
    std.debug.print("stack: {c} {d} {d} | heap: {c} {d} {d}\n", .{ on_stack.tag, on_stack.id, on_stack.value, on_heap.tag, on_heap.id, on_heap.value });
}

Key gotcha: a default Zig struct may reorder fields to minimize size, so layout is only guaranteed with extern struct (C ABI) or packed struct (bit-exact). @sizeOf/@alignOf/@offsetOf report the result at comptime; the stack Point auto-frees, while the explicit allocator's heap Point is paired with defer destroy.

Hare
use fmt;

// Hare lays struct fields out in order with natural alignment (padding added).
type point = struct {
	tag: u8,    // 1 byte (+ padding)
	id: i32,    // 4 bytes, 4-aligned
	value: f64, // 8 bytes, 8-aligned  => size 16, align 8
};

export fn main() void = {
	// 1) STACK: a local, freed automatically when main returns.
	let on_stack = point { tag = 'A': u8, id = 1, value = 3.14 };

	// 2) HEAP: alloc returns *point; aborts if out of memory.
	let on_heap: *point = alloc(point {
		tag = 'B': u8, id = 2, value = 2.72,
	});
	defer free(on_heap); // matched free at scope exit

	fmt::printfln("size = {}, align = {}", size(point), align(point))!;
	fmt::printfln("stack: {} {} {} | heap: {} {} {}",
		on_stack.tag, on_stack.id, on_stack.value,
		on_heap.tag, on_heap.id, on_heap.value)!;
};

Hare's builtin size(point) and align(point) report the padded layout (16 / 8), and fields keep declared order. The stack point is automatic; the alloc'd *point is manual memory, so defer free(on_heap) guarantees the single alloc is matched by one free -- there is no GC.

Odin
package main

import "core:fmt"

// Odin keeps declared field order with natural alignment (padding inserted).
// Add `struct #packed { ... }` to remove all padding.
Point :: struct {
	tag:   u8,  // 1 byte (+3 pad)
	id:    i32, // 4 bytes @ 4
	value: f64, // 8 bytes @ 8   => size_of 16, align_of 8
}

main :: proc() {
	// 1) STACK: a local value, gone when the proc returns.
	on_stack := Point{tag = 'A', id = 1, value = 3.14}

	// 2) HEAP: new uses the implicit context.allocator; returns ^Point.
	on_heap := new(Point)
	defer free(on_heap) // returned to the same allocator at scope end
	on_heap^ = Point{tag = 'B', id = 2, value = 2.72}

	fmt.println("size_of =", size_of(Point), "align_of =", align_of(Point))
	fmt.println("offsets: tag =", offset_of(Point, tag),
		"id =", offset_of(Point, id), "value =", offset_of(Point, value))
	fmt.println("stack:", on_stack, "| heap:", on_heap^)
}

Odin keeps fields in declared order, so offset_of exposes the padding (size_of 16, align_of 8) -- struct #packed would collapse it to 13. The stack Point is automatic; new(Point) allocates via the implicit context.allocator and defer free returns it to that same allocator, so swapping in an arena would change cleanup without touching this code.

Forth
\ Classic Forth has no struct type: you compute field OFFSETS by hand and
\ address fields as base+offset. Below, a 24-byte record (8-byte slots so the
\ I64/F64-style fields stay cell-aligned), mirroring { tag id value }.

0          CONSTANT >TAG     \ offset of tag   (1 byte, but slot-aligned)
1 CELLS    CONSTANT >ID      \ offset of id    @ 8
2 CELLS    CONSTANT >VALUE   \ offset of value @ 16
3 CELLS    CONSTANT /POINT   \ size of one record = 24 bytes

\ 1) "STACK"/static: CREATE ... ALLOT carves a fixed record in data space.
\    (Forth has no automatic locals; dictionary space is the closest idiom
\     and is never individually freed -- it lives for the program's life.)
CREATE ON-STACK  /POINT ALLOT
65 ON-STACK >TAG   + C!     \ 'A'  (store one byte with C!)
 1 ON-STACK >ID    + !

\ 2) HEAP: the optional ALLOCATE / FREE word set is Forth's malloc/free.
/POINT ALLOCATE THROW       ( -- addr )   \ one record on the heap; we own it
DUP >TAG   + 66 SWAP C!     \ 'B'
DUP >ID    +  2 SWAP !

DUP >ID + @ .               \ print heap id = 2
/POINT .                    \ print record size = 24
FREE THROW                  \ return the heap block: one ALLOCATE, one FREE

Forth has no struct or sizeof, so a record is just a base address plus hand-computed field offsets (CONSTANTs built from CELLS); /POINT plays the role of sizeof. CREATE ... ALLOT reserves a fixed record in dictionary space (the closest thing to a stack/static instance, never freed), while the optional ALLOCATE/FREE words are the real heap -- each ALLOCATE paired with one FREE by hand.