← Code Compare

C Interop / FFI

The reason all seven languages live in the same family is the C ABI: a stable, OS-level contract for how functions pass arguments, return values, and lay out structs. Because every one of them can speak it, they can all call straight into libc (and any C library) with no marshalling, no runtime bridge, and no copies. The task is identical everywhere - call C's puts, plus a libc function that allocates (strdup) - and the deep lesson is memory ownership across the boundary: a pointer that crosses the FFI line carries no lifetime with it, so when C mallocs the result of strdup, you must call C's free on it, never your own language's allocator.

Show: CC++HolyCZigHareOdinForth
C
#include <stdio.h>     /* puts            */
#include <string.h>    /* strdup (POSIX)  */
#include <stdlib.h>    /* free            */

int main(void) {
    /* Native: no "FFI" at all -- this IS C, calling libc directly. */
    puts("Hello from libc");           /* writes the string + a newline */

    /* strdup malloc()s a copy and returns ownership to the caller. */
    char *dup = strdup("owned by C");   /* heap copy via C's malloc      */
    if (dup == NULL) return 1;          /* allocation can fail           */

    puts(dup);                          /* use it... then release it     */
    free(dup);                          /* the SAME malloc/free heap      */
    return 0;
}
/* build: cc main.c -o app */

For C there is no boundary to cross - puts and strdup are the C ABI. The point worth stressing is the ownership rule the other six must obey: strdup returns a char* that it malloced, so the matching free must come from the same C heap. Every language below ultimately reproduces this exact strdup/free pairing.

C++
#include <cstdio>     // std::puts
#include <cstring>    // ::strdup (POSIX)
#include <cstdlib>    // std::free
#include <memory>     // std::unique_ptr

// extern "C" turns OFF C++ name-mangling so the linker finds the C symbol.
extern "C" char *strdup(const char *);

int main() {
    std::puts("Hello from libc");        // C function, C linkage

    // Wrap the C-owned pointer in a unique_ptr whose deleter is C's free,
    // so RAII releases it on the *C heap* -- never with delete.
    std::unique_ptr<char, decltype(&std::free)> dup{
        ::strdup("owned by C"), std::free};
    if (!dup) return 1;

    std::puts(dup.get());                // pass the raw char* back to C
    return 0;                            // ~unique_ptr calls std::free(dup)
}
// build: c++ -std=c++20 main.cpp -o app

C++ already shares C's ABI, but its compiler mangles names; extern "C" declares C linkage so the C symbol resolves. The memory lesson is sharp here: you must not delete a C malloced pointer, so the idiom is a std::unique_ptr<char, decltype(&std::free)> whose custom deleter is std::free - RAII then returns the block to C's heap automatically and exactly once.

HolyC
// HolyC IS C semantically, but TempleOS has NO libc and NO linker --
// there is no external `puts`/`strdup` to call. The closest idiom is
// HolyC's own ABI-compatible builtins on the per-task heap.

U8 *StrNew2(U8 *s) {           // hand-rolled strdup using TempleOS heap
  I64 n = StrLen(s) + 1;
  U8 *dup = MAlloc(n);         // from THIS task's data heap
  MemCpy(dup, s, n);           // copy bytes incl. the NUL terminator
  return dup;                  // caller owns it -> must Free
}

Print("Hello from libc\n");    // "libc" here is the TempleOS runtime
U8 *dup = StrNew2("owned by C");
Print("%s\n", dup);
Free(dup);                     // one MAlloc, one Free, same task heap

HolyC compiles to the same machine model as C, but TempleOS is a single-binary, ring-0 OS with no C library and no dynamic linking, so there is nothing external to FFI into - calls resolve against the JIT'd global namespace instead. The faithful equivalent is to reimplement strdup over MAlloc/Free on the per-task heap; the alloc/free pairing is identical to C's, just without a separate libc heap.

Zig
const std = @import("std");
// @cImport runs the C preprocessor at COMPILE TIME and exposes the
// declarations as a normal Zig namespace -- no bindings to hand-write.
const c = @cImport({
    @cInclude("stdio.h");   // puts
    @cInclude("string.h");  // strdup
    @cInclude("stdlib.h");  // free
});

pub fn main() void {
    _ = c.puts("Hello from libc"); // C function; returns c_int we ignore

    // strdup returns [*c]u8 backed by C's malloc -> free with C's free,
    // NOT a Zig allocator. The optional handles the null-on-failure case.
    const dup: [*c]u8 = c.strdup("owned by C") orelse return;
    defer c.free(dup);             // pair the C free right beside the call

    _ = c.puts(dup);              // pass the C pointer straight back
}
// build: zig build-exe main.zig -lc   (-lc links libc)

Zig's @cImport invokes the C compiler at comptime and translates the headers into a real Zig namespace, so c.puts/c.strdup are called directly over the C ABI with -lc. Memory discipline is explicit: strdup's result is C-heap memory, so it is released with c.free (not a std.mem.Allocator), and defer c.free(dup) keeps that C free next to the C alloc - Zig never sneaks a hidden allocation into the boundary.

Hare
use types::c;   // C-compatible types & null-terminated string helpers

// Declare (import) the external C symbols: a bodyless fn with @symbol,
// and NO `export` -- `export` would instead DEFINE the symbol here.
@symbol("puts") fn c_puts(s: *const c::char) int;
@symbol("strdup") fn c_strdup(s: *const c::char) *c::char;
@symbol("free") fn c_free(p: nullable *opaque) void;

export fn main() void = {
	// c::nulstr ABORTS unless the literal is NUL-terminated, so the \0
	// is required -- a Hare string literal has no implicit trailing NUL.
	const hello = c::nulstr("Hello from libc\0");  // borrow a C string
	c_puts(hello);

	// strdup uses C's malloc, so it must be released with C's free --
	// Hare's own free() manages a DIFFERENT heap and would corrupt it.
	const dup = c_strdup(c::nulstr("owned by C\0"));
	defer c_free(dup);                            // matching C free
	c_puts(dup);
};
// build: hare build -lc main.ha   (link libc)

Hare imports a C function by declaring a bodyless fn tagged with @symbol("...") so the linker binds to the exact C name - note there is no export (that keyword would make Hare define the symbol instead of borrowing it). types::c supplies ABI-correct types plus c::nulstr, which converts a Hare string to a C string but aborts unless the input is NUL-terminated, so each literal carries an explicit \0 (Hare string literals have no implicit trailing NUL). The critical memory rule: a pointer from strdup lives on C's malloc heap, so it is freed with the C free you declared - calling Hare's built-in free on it would hand foreign memory to the wrong allocator.

Odin
package main

import "core:c/libc"   // Odin ships ISO-C bindings (puts, malloc, free)

// strdup is POSIX, not ISO C, so it is NOT in core:c/libc -- bind it
// ourselves via foreign import against the system C library.
foreign import libc_sys "system:c"
@(default_calling_convention="c")
foreign libc_sys {
	strdup :: proc(s: cstring) -> [^]u8 ---
}

main :: proc() {
	libc.puts("Hello from libc")   // cstring literal, C ABI, no wrapper

	// strdup returns a [^]u8 from C's malloc; release it with libc.free,
	// NOT Odin's context.allocator free() -- different heaps entirely.
	dup := strdup("owned by C")
	defer libc.free(dup)           // pair the C free with the C alloc
	libc.puts(cstring(dup))        // hand the C pointer back to puts
}
// build: odin run .   (Odin links libc by default)

Odin speaks the C ABI natively: the cstring type is a NUL-terminated C pointer, and core:c/libc ships prebuilt bindings for ISO C (puts, malloc, free). strdup is POSIX rather than ISO C, so it is not in core:c/libc; you declare it yourself with foreign import "system:c", which is exactly how Odin binds any C library. The memory point is the same trap as everywhere - strdup allocates on C's heap, so it must be freed with libc.free, never with Odin's free/delete (which target context.allocator); defer libc.free(dup) keeps the pairing honest.

Forth
\ Classic Forth has no standardized FFI; the closest portable idiom is
\ a system-specific library word set. Below uses gforth's c-library:

c-library libc
  \c #include <string.h>
  \c #include <stdio.h>
  \c #include <stdlib.h>
  c-function c-puts   puts   a -- n    \ ( c-addr -- int )
  c-function c-strdup strdup a -- a    \ ( c-addr -- c-addr )
  c-function c-free   free   a --      \ ( c-addr -- )
end-c-library

S\" Hello from libc\0" drop c-puts drop  \ S\" processes \0 -> real NUL

\ strdup mallocs via C: keep the addr, print it, then C-free it.
S\" owned by C\0" drop c-strdup          ( -- c-addr )
DUP c-puts drop                          ( c-addr -- c-addr )
c-free                                    \ release on C's heap, not Forth's

Standard Forth defines no FFI, so this shows the closest real idiom - gforth's c-library/c-function word set, which compiles a tiny C stub to call puts, strdup, and free over the C ABI (each c-function line gives the C name and a stack-effect type signature). Note the S\" (s-backslash-quote) word, not plain S": only S\" interprets the \0 escape, so the buffer actually ends in a NUL byte - puts needs a NUL-terminated C string, and plain S" would store a literal backslash-zero instead. The memory rule survives even here: strdup's address comes from C's malloc, so it is handed to the C c-free word, never to Forth's own ALLOCATE/FREE pool.