Skip to content

Rationale · essay 10

FFI

rationale.md · 77 lines · 3 min read

The redefinition: @cimport reads the C header directly, and all of the foreign lives under c.. The dot shouts “this is C” at every contact.

Talking to C is mandatory for a systems language: half a century of ecosystem is in C. The traditional form is to write bindings by hand (redeclare each prototype, each struct, keep that in sync) or to generate bindings with an external tool. Either way, you reconstruct what C already exposes.

@cimport("header.h") runs at comptime, invokes an embedded C compiler, translates the entire header and returns a module under the root-namespace c. You redeclare nothing: @cimport("stdio.h") gives you c.stdio.printf directly. The fundamental types (compiler/platform-dependent) come from an imported runtime: c.gcc.size_t is size_t as GCC defines it.

Everything from C stays under c., and this is on purpose: the dot shouts “here is a C contract, not Makoto” at every point of contact, and your namespace stays clean (whoever does no FFI never sees any C). Calling C is unsafe (each call is a black box that can violate any invariant), tamed with assert/assume or grouped in a block; and the return validates at the edge (a pointer that may be NULL becomes Optional, bytes that become a string go through a Result).

At the boundary, memory stays colorless: @mm(c) (the manager delegates to C’s free), @promote(gc) (repatriates to the GC), @pin/@unpin (pins a GC object while C holds it). And the lower sibling: inline assembly via @asm.

The bet is “the C ecosystem for free, if you accept the safety mark”. If C is inherently unsafe, rewriting bindings by hand buys no safety; it only adds bureaucracy and a copy that goes out of sync. Better to read the header directly (the source of truth already exists) and spend the effort where it matters: at the boundary, marking the danger (unsafe, validation at the edge) and the memory crossing (@mm(c)/@promote/@pin).

The c. namespace is non-poisoning applied to FFI (ch. 01): the foreign does not pollute the global, and the mark is the name path itself. It is the same reason unsafe is a mark and not a silent mode: danger should be visible, and c.malloc in the middle of your code is visible.

  • Hand-written bindings. They reconstruct what C already exposes, go out of sync when the header changes, and do not buy safety (C stays unsafe). @cimport reads the source of truth.
  • FFI without a dedicated namespace (extern "C" in scope, à la Zig’s mixed @import("c")). Names collide (is open yours or C’s?) and the boundary stays invisible. The c. disambiguates and marks.
  • Automatic conversion C int Makoto i32. It would hide the cost: on some targets int is 32 bits, on others 64, and the bug stays invisible. C’s types come explicit (c.gcc.size_t); the crossing is written.
  • Async/memory coloring crossing the boundary automatically. C’s memory arrives as @mm(c) and you decide what to do (@promote, @pin), explicitly, because crossing worlds is a decision, not a coercion.

The project that keeps a 2000-line bindings file in manual sync with a header that changes every release. The Zig where you pass an i32 to a C int parameter and it works on x86 but breaks on another target, with no clue in the code. The NULL that comes back from a C function and becomes a segfault three calls later because nobody handled it. Each one is the poorly-marked C boundary charging the price.

Point at the .h, and C works. All of the foreign lives under c. (which shouts “this is C”); the danger is unsafe and memory crosses the worlds explicitly.

@cimport requires an embedded C compiler in the toolchain (real weight, but you pay once). Each call to C carries the obligation of unsafe + validation at the edge, more ceremony than a simple call, on purpose, because the danger is real. And the c. in every name is verbose when you use C heavily, but it is the mark that keeps the boundary honest.

Next: 11 · Ecosystem and tiers