Skip to content

07 · Interfacing with Other Languages

C is the lingua franca of the software world. Python, Ruby, Rust, Go, Java, C#, Lua and JavaScript can all call C, and almost none of them can call each other directly. The reason is the C ABI — a stable, documented agreement about how arguments go into registers, how the stack is laid out, and what a symbol name looks like. C has no name mangling, no runtime, no garbage collector, and no exceptions, so there is very little to disagree about.

That is why "write the fast part in C" works. This module builds a C library and calls it from Python with ctypes, which needs no compilation on the Python side and shows the boundary issues plainly.

A library designed to be called

The API shape matters more than the algorithms. Every function here is chosen to demonstrate one boundary problem:

// stats.c -- a C library designed to be called from another language
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>

typedef struct { double mean, stddev; int count; } Summary;

/* Plain scalars: the easy case. */
int add(int a, int b) { return a + b; }

/* Array in, struct out by pointer -- caller owns the struct. */
void summarize(const double *data, int n, Summary *out) {
    double sum = 0.0;
    for (int i = 0; i < n; i++) sum += data[i];
    out->count = n;
    out->mean  = n ? sum / n : 0.0;

    double sq = 0.0;
    for (int i = 0; i < n; i++) { double d = data[i] - out->mean; sq += d * d; }
    out->stddev = n ? sqrt(sq / n) : 0.0;
}

/* Array in, array out IN PLACE -- no ownership question at all. */
void scale(double *data, int n, double factor) {
    for (int i = 0; i < n; i++) data[i] *= factor;
}

/* C allocates a string. The caller MUST call free_string. */
char *describe(const Summary *s) {
    char *buf = malloc(128);
    if (!buf) return NULL;
    snprintf(buf, 128, "n=%d mean=%.3f sd=%.3f", s->count, s->mean, s->stddev);
    return buf;
}

void free_string(char *p) { free(p); }

/* A callback: C calls back into the host language. */
typedef int (*Filter)(double);

int count_matching(const double *data, int n, Filter pred) {
    int c = 0;
    for (int i = 0; i < n; i++) if (pred(data[i])) c++;
    return c;
}

Build it as a shared library — the same -fPIC -shared from Level 3 module 06:

clang -Wall -Wextra -std=c11 -fPIC -shared -o libstats.dylib stats.c
nm -gU libstats.dylib
0000000000000408 T _add
00000000000006a8 T _count_matching
00000000000005f0 T _describe
0000000000000684 T _free_string
0000000000000590 T _scale
0000000000000428 T _summarize

Six plain, unmangled names. That listing is the ABI — the other language looks up exactly these strings. (C++ would show _Z3addii-style mangled names, which is why C++ libraries meant for FFI wrap their entry points in extern "C".)

Calling it from Python

# use_stats.py -- calling libstats from Python with ctypes
import ctypes

lib = ctypes.CDLL("./libstats.dylib")   # .so on Linux, .dll on Windows

class Summary(ctypes.Structure):
    _fields_ = [("mean",   ctypes.c_double),
                ("stddev", ctypes.c_double),
                ("count",  ctypes.c_int)]

# Declaring signatures is NOT optional -- see the next section.
lib.add.argtypes = [ctypes.c_int, ctypes.c_int]
lib.add.restype  = ctypes.c_int

lib.summarize.argtypes = [ctypes.POINTER(ctypes.c_double), ctypes.c_int,
                          ctypes.POINTER(Summary)]
lib.summarize.restype  = None

lib.describe.argtypes    = [ctypes.POINTER(Summary)]
lib.describe.restype     = ctypes.POINTER(ctypes.c_char)   # NOT c_char_p
lib.free_string.argtypes = [ctypes.POINTER(ctypes.c_char)]

FILTER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_double)
lib.count_matching.argtypes = [ctypes.POINTER(ctypes.c_double), ctypes.c_int, FILTER]
lib.count_matching.restype  = ctypes.c_int

values = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]
buf = (ctypes.c_double * len(values))(*values)      # a real C double[8]

s = Summary()
lib.summarize(buf, len(values), ctypes.byref(s))

lib.scale(buf, len(buf), 10.0)

ptr = lib.describe(ctypes.byref(s))
print("describe ->", ctypes.cast(ptr, ctypes.c_char_p).value.decode())
lib.free_string(ptr)          # C allocated it, C must free it

@FILTER
def is_big(x):
    return 1 if x > 4.5 else 0

print("count_matching ->", lib.count_matching(buf, len(buf), is_big))
python3 use_stats.py
add(2, 40)        = 42
summarize         -> count=8 mean=5.0 stddev=2.0
scale(x10)        -> [20.0, 40.0, 40.0, 40.0] ...
describe          -> n=8 mean=5.000 sd=2.000
count_matching    -> 8 of 8 (after scaling, all are > 4.5)

Everything crossed the boundary: scalars, an array modified in place, a struct filled by pointer, a heap string, and a Python function called from C once per element.

(ctypes.c_double * len(values))(*values) is the important line. It builds an actual contiguous C double[8] — a Python list would not do, because a list of floats is an array of pointers to boxed objects with no contiguous doubles anywhere in it.

Why declaring signatures is mandatory

ctypes will happily call a function you have not described. It assumes every argument is passed as-is and every return value is a C int. Here is what that costs:

# trap2.py -- the same function, declared and undeclared
lib.describe.restype = ctypes.POINTER(ctypes.c_char)
good = lib.describe(ctypes.byref(s))
true_addr = ctypes.cast(good, ctypes.c_void_p).value

# Now the default: ctypes assumes every function returns C int.
raw = lib2.describe(ctypes.byref(s))

buf = (ctypes.c_double * 3)(1.0, 2.0, 3.0)
lib2.scale(buf, 3, 10)          # 10 is a Python int, not 10.0
true pointer          : 0x0000000151f127e0
default restype (int) : 0x0000000051f127e0  <- truncated to 32 bits
dereferencing that address would read unmapped memory.
scale(buf, 3, 10) with no argtypes -> [0.0, 0.0, 0.0]
same call WITH argtypes            ->  [10.0, 20.0, 30.0]

Two catastrophes on one screen. The 64-bit pointer came back truncated to its low 32 bits — 0x151f127e0 became 0x51f127e0, an address that is not mapped and not even the same object. And scale with an integer 10 where a double was expected produced zeros: on this ABI, floating-point arguments travel in floating-point registers, so the integer went into a general-purpose register and scale read whatever garbage was in d0.

Neither raised an exception. Neither printed a warning. Both are silent corruption of exactly the kind module 05 is about, except now the bug is in the glue code rather than the C.

Declare argtypes and restype for every function you call. Without them you are not calling C, you are guessing at an ABI.

Ownership across the boundary

The hardest part of FFI is not types, it is lifetimes. Neither side's memory model applies to the other: Python's garbage collector does not know about malloc, and free does not know about reference counts.

The rule that makes this tractable: whoever allocates, frees, and the API must expose a way to do it. That is why describe has a matching free_string, and why the Python code declares restype as POINTER(c_char) rather than the convenient c_char_p:

lib.describe.restype = ctypes.c_char_p        # the tempting shortcut
p = lib.describe(ctypes.byref(s))             # p is a Python bytes COPY
describe as c_char_p -> b'n=4 mean=2.500 sd=1.118'
...but we now hold a Python bytes copy and have LOST the C pointer,
   so free_string() can never be called on it: a guaranteed leak.

c_char_p silently copies the string into a Python bytes object and discards the original pointer. The data is correct and the 128-byte allocation is unreachable forever. Every call leaks.

The four patterns, in order of preference:

Pattern Ownership Example
Caller provides the buffer Caller's, always summarize(data, n, &out)
Modify in place Caller's, always scale(data, n, f)
C allocates + explicit free C's, released by the caller describe / free_string
C returns a pointer to static/internal data C's, never freed Must document invalidation rules

Prefer the first two. A function that fills a caller-supplied buffer has no ownership question to get wrong, which is why so many C APIs take a (buf, size) pair and return the length they needed.

Callbacks have their own lifetime trap. The @FILTER-decorated Python function must be kept alive by a Python reference for as long as C might call it — assigning it to a local that goes out of scope while C still holds the pointer is a use-after-free with a Python object on the other end. Callbacks must also never let an exception escape into C: C has no unwinding mechanism, so the stack is corrupted rather than unwound. Catch everything inside the callback and signal failure with a return value.

Cheat sheet

Concern Rule
Symbol names C only. C++ needs extern "C" to avoid mangling
Build -fPIC -shared; .so (Linux), .dylib (macOS), .dll (Windows)
Types at the boundary Only <stdint.h> fixed-width types, double, and pointers
int / long Avoid — width varies by platform (module 04)
Structs Layout must match exactly; padding is implementation-defined
Strings C: null-terminated bytes. Encode/decode explicitly, never assume UTF-8
Errors Return codes or an out-parameter. Never exceptions, never longjmp
Ownership Whoever allocates, frees. Export a free_* for anything you return
Callbacks Keep the host-side object alive; never let exceptions escape
Threads Document whether your functions are thread-safe; the host may call from many
Binding tool Notes
Python ctypes Stdlib, no build step, runtime-only checking — used here
Python cffi Parses real C declarations; safer, needs installing
Python C API Fastest, most work, ties you to CPython versions
Rust extern "C" + #[repr(C)]; bindgen generates declarations from headers
Go cgo; note the real cost per call and the pointer-passing rules
Java JNI (verbose) or the newer FFM API (java.lang.foreign)
Node.js N-API

How It Actually Works

The pointer-truncation bug is a direct consequence of the System V x86-64 calling convention and ctypes's default assumption. Without a declared restype, ctypes treats the function's return value as a C int, which on this ABI means it reads only the low 32 bits of the rax register — the CPU register a function's return value comes back in — and sign- or zero-extends that into a Python integer, discarding the upper 32 bits entirely. The real pointer, a full 64-bit value, was sitting whole in rax the entire time; the bug is purely in how the caller (the ctypes runtime) chose to interpret bits that were always there. This is the same class of silent truncation as (char)300 in the portability module — correct data, wrong-width read.

The scale(buf, 3, 10) case is subtler and comes from the ABI's separate register classes for integer and floating-point arguments. System V x86-64 passes the first several integer/pointer arguments in rdi, rsi, rdx, rcx, r8, r9, but the first several floating-point arguments in a completely different bank, xmm0 through xmm7. A C function declared to take a double reads its value out of the corresponding xmm register — that's part of the function's compiled prologue, fixed at compile time from the declared parameter type. When ctypes has no argtypes telling it "this parameter is a double," it packs the Python int 10 as a plain machine integer and places it in the next general-purpose register in sequence, because that's ctypes's undeclared-argument default. The C function still reads its expected double out of xmm2 (say) — a register ctypes never touched — so it gets whatever value was left there from a previous operation, not the 10 you passed. Declaring argtypes as [POINTER(c_double), c_int, c_double] tells ctypes to marshal the third argument as a double specifically, which routes it through the SSE register bank the compiled function actually reads.

The ownership rule ("whoever allocates, frees") is a direct consequence of each side using a completely different, closed memory manager: CPython's allocator (pymalloc, layered over the system allocator for small objects) tracks memory via reference counts embedded in every PyObject header — when a bytes object's refcount hits zero, its destructor calls back into pymalloc to release it. Neither side's memory manager has any visibility into the other's bookkeeping structures, so a C-allocated block handed to Python without a compatible wrapper can never be released by Python's GC — it isn't a PyObject and has no header for the refcount machinery to find — and a PyObject passed the other way is equally opaque to free(). The c_char_p shortcut leaks specifically because ctypes, on seeing that declared return type, immediately copies the C string's bytes into a new Python bytes object and returns that — the original char * value returned by describe is never stored anywhere in Python, so there is no longer any handle in either language capable of passing it back to free_string.

Exercise

Wrap the key-value store from Level 3's project as a shared library and drive it from Python. Export kv_create, kv_set, kv_get, kv_delete, kv_count, kv_destroy, plus a kv_free_value so the string returned by kv_get can be released. Write a Python class that wraps the opaque handle, declares every signature, encodes/decodes strings explicitly, and frees the C string in a finally block.

Then prove the two things that are easy to get wrong. First, run it under -fsanitize=address (via DYLD_INSERT_LIBRARIES, or LD_PRELOAD on Linux) and confirm a full create/populate/destroy cycle leaks nothing — then deliberately drop the kv_free_value call and confirm the leak is reported. Second, call the same store from four Python threads at once and verify the pthread_rwlock still holds up; then explain why Python's GIL does not protect your C code, and what would happen if the store were not internally locked.