← Code Compare

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.

Show: CC++HolyCZigHareOdinForth
C
/* ---- 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.

C++
// ---- 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.

HolyC
// ---- 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.

Zig
// ---- 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.

Hare
// ---- 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.

Odin
// ---- 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
\ 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.