Modules & Compilation
Real programs span many files, and how they split is where each language's compilation model shows: C/HolyC paste headers in textually, C++20 has true named modules, while Zig (@import a file), Hare (a directory of .ha files), and Odin (a directory = a package) treat files and folders as first-class units; Forth just INCLUDEs another file into one global dictionary. The task is identical in all seven - define a Circle type plus a factory and an area function in one unit, then use it from a second file - and the memory lesson is the same: when a factory allocates on the heap, the alloc/free pairing crosses the module boundary, so watch who owns the object and who must release it.
/* ---- shape.h : the interface (declarations only) ---- */
#ifndef SHAPE_H
#define SHAPE_H
typedef struct Circle Circle; /* opaque type: callers can't see fields */
Circle *circle_new(double r); /* allocates -> caller must free it */
double circle_area(const Circle *c);
void circle_free(Circle *c); /* the matching deallocator */
#endif
/* ---- shape.c : the implementation (one translation unit) ---- */
#include "shape.h"
#include <stdlib.h>
struct Circle { double r; }; /* real layout, private to this .c */
Circle *circle_new(double r) {
Circle *c = malloc(sizeof *c); /* heap alloc lives behind the API */
if (c) c->r = r;
return c; /* ownership transfers to the caller */
}
double circle_area(const Circle *c) { return 3.14159265 * c->r * c->r; }
void circle_free(Circle *c) { free(c); } /* free(NULL) is safe */
/* ---- main.c : the user, compiled separately then linked ---- */
#include <stdio.h>
#include "shape.h" /* sees declarations, not the layout */
int main(void) {
Circle *c = circle_new(2.0); /* I allocated -> I must free */
if (!c) return 1;
printf("area=%.2f\n", circle_area(c)); /* area=12.57 */
circle_free(c); /* one new, one free */
return 0;
}
/* build: cc -c shape.c && cc -c main.c && cc shape.o main.o -o app */C has no module system: a .h header is the interface (function prototypes plus an opaque typedef struct Circle Circle;) and a .c is the implementation. Each .c is compiled to an object file independently and the linker stitches them together. Hiding struct Circle's fields in shape.c forces all allocation through circle_new/circle_free, so the malloc/free pair lives behind the API and callers never touch the layout. Include guards (#ifndef SHAPE_H) stop the header being pasted in twice.
// ---- shape.ixx : a C++20 named module (interface unit) ----
export module shape; // declares the module
import <memory>;
export class Circle { // 'export' makes it visible to importers
double r_;
public:
explicit Circle(double r) : r_(r) {} // RAII: no manual free
double area() const { return 3.14159265 * r_ * r_; }
};
// A factory returning an owning smart pointer.
export std::unique_ptr<Circle> make_circle(double r) {
return std::make_unique<Circle>(r); // heap, freed automatically
}
// ---- main.cpp : the user ----
import shape; // no header text-pasting, no include guards
import <iostream>;
import <format>;
int main() {
auto c = make_circle(2.0); // unique_ptr<Circle>
std::cout << std::format("area={:.2f}\n", c->area()); // area=12.57
// ~unique_ptr deletes the Circle here -- no delete to write.
}
// build (clang): clang++ -std=c++20 --precompile shape.ixx -o shape.pcm
// clang++ -std=c++20 shape.pcm main.cpp -o app
Modern C++20 replaces text-pasted headers with true named modules: export module shape; defines a module and export marks what importers see, so import shape; brings in a precompiled interface with no include guards and no re-parsing. The class owns its data by value (RAII), and make_circle hands back a std::unique_ptr<Circle> whose destructor frees the heap object exactly once - there is no new/delete to balance across the module boundary.
// ---- Shape.HC : the "module" is just a file you #include ----
class Circle { // HolyC 'class' == C struct, no methods
F64 r;
};
Circle *CircleNew(F64 r) {
Circle *c = MAlloc(sizeof(Circle)); // from THIS task's heap
c->r = r;
return c; // caller owns it -> must Free
}
F64 CircleArea(Circle *c) { return 3.14159265 * c->r * c->r; }
// ---- Main.HC : the user ----
#include "Shape" // pastes Shape.HC in; TempleOS resolves names live
Circle *c = CircleNew(2.0); // top-level code runs, no main()
Print("area=%5.2f\n", CircleArea(c)); // area=12.57
Free(c); // one MAlloc, one Free
TempleOS has no separate compilation: a "module" is simply another .HC file you #include, and HolyC JIT-compiles each file into the single global namespace as it loads - every function and type stays visible everywhere, ring-0, with no headers or linker. MAlloc draws from the per-task data heap and Free returns it; because there is no opaque-type trick and no memory protection, any caller can poke c->r directly and a stray write can corrupt the running OS.
// ---- shape.zig : a module is literally a file's top-level struct ----
const std = @import("std");
pub const Circle = struct { // 'pub' exports it across the @import boundary
r: f64,
pub fn area(self: Circle) f64 {
return 3.14159265 * self.r * self.r;
}
};
// Factory that takes the allocator EXPLICITLY -- no hidden heap use.
pub fn create(allocator: std.mem.Allocator, r: f64) !*Circle {
const c = try allocator.create(Circle); // may fail -> error union
c.* = .{ .r = r };
return c; // caller owns + frees it
}
// ---- main.zig : the user ----
const std = @import("std");
const shape = @import("shape.zig"); // bind the other file to a const
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit(); // reports leaks at exit
const a = gpa.allocator();
const c = try shape.create(a, 2.0);
defer a.destroy(c); // paired free, guaranteed to run
std.debug.print("area={d:.2}\n", .{shape.Circle.area(c.*)}); // 12.57
}In Zig a file is a module: @import("shape.zig") returns the file's implicit top-level struct, and only pub declarations are reachable from it - there are no headers, just const shape = @import(...). Crucially the create factory takes a std.mem.Allocator as a parameter, so the heap is never hidden behind the module boundary; the caller supplies the allocator and pairs shape.create with defer a.destroy(c), while the GeneralPurposeAllocator flags any leak.
// ---- shape/circle.ha : files in a directory form a module ----
// (the directory name 'shape' IS the module name)
export type circle = struct { // 'export' makes it visible to importers
r: f64,
};
export fn new(r: f64) *circle = {
let c: *circle = alloc(circle { r = r }); // heap copy; caller owns it
return c; // ...and must free it
};
export fn area(c: *circle) f64 = 3.14159265 * c.r * c.r;
// ---- main.ha : the user ----
use fmt;
use shape; // import the module by directory name
export fn main() void = {
let c = shape::new(2.0); // *shape::circle
defer free(c); // matching free at scope exit
fmt::printfln("area={:.2}", shape::area(c))!; // area=12.57
};
// build: hare run main.ha (the build driver finds the shape/ dir)A Hare module is a directory: every .ha file in shape/ contributes to module shape, and only exported names are reachable via use shape; and the shape:: namespace. There is no GC, so the new factory's single alloc(circle{...}) (which copies the struct onto the heap and yields *circle) is balanced by the caller's defer free(c) - the alloc/free pairing crosses the module boundary just like C's, but with namespaced, explicitly exported symbols.
// ---- shape/circle.odin : every .odin file in a dir = one package ----
package shape // all files in this folder share 'package shape'
Circle :: struct { r: f64 } // capitalized export rules don't apply; pkg-scoped
new_circle :: proc(r: f64) -> ^Circle {
c := new(Circle) // uses the implicit context.allocator
c.r = r
return c // caller owns it -> must free it
}
area :: proc(c: ^Circle) -> f64 { return 3.14159265 * c.r * c.r }
// ---- main.odin : the user (in its own package) ----
package main
import "core:fmt"
import "shape" // import the sibling package by path
main :: proc() {
c := shape.new_circle(2.0) // ^shape.Circle
defer free(c) // freed via context.allocator
fmt.printf("area=%.2f\n", shape.area(c)) // area=12.57
}
// build: odin run . (collection/path resolves the shape package)Odin organizes code into packages = directories: every .odin file in shape/ declares package shape, and other code pulls it in with import "shape", qualifying symbols as shape.new_circle. The new(Circle) factory allocates through the implicit context.allocator and returns ^Circle; the caller's defer free(c) returns it to that same allocator, so swapping in an arena via context would let callers drop the per-object free entirely without touching the package's API.
\ Forth has no modules: a "file" of word definitions IS the unit, and
\ you load another file with INCLUDE (or S" name" INCLUDED). Words go
\ into the global dictionary -- there is no namespacing in classic Forth.
\ ---- shape.fs : the definitions ----
: CIRCLE ( r addr -- ) \ "constructor": store radius cell at addr
! ; \ (here a Circle is just one cell holding r)
: AREA ( addr -- area ) \ read r, compute pi*r*r (integer demo)
@ DUP * 314 * 100 / ; \ pi ~ 3.14 as fixed-point hundredths
\ ---- main.fs : the user ----
S" shape.fs" INCLUDED \ load the other file's words into the dict
CREATE C 1 CELLS ALLOT \ reserve one cell for a Circle (static)
2 C CIRCLE \ C.r = 2
." area=" C AREA . CR \ -> area=12 (3.14 * 4, integer-rounded)
\ Heap variant: C ALLOCATE / FREE would malloc CELLS bytes the same way.Classic Forth has no module or namespace system - the closest idiom is a file of : word definitions loaded with INCLUDE/INCLUDED, after which every word lives in the one shared dictionary (name clashes just redefine). So shape.fs exports CIRCLE/AREA simply by defining them. Memory is equally bare: CREATE C 1 CELLS ALLOT carves a static cell from the dictionary (never freed); for heap-managed objects you'd use the optional ALLOCATE/FREE word set, which mallocs CELLS bytes exactly as C would.