C & C++ Interoperability

C & C++ Interoperability

Kairo provides zero-overhead, bidirectional interoperability with C and C++. There is no serialization layer, no binding generator, and no runtime bridge — Kairo emits ABI-compatible object code and consumes C/C++ headers directly.

The guarantee that makes this work is not a clever calling convention. It is that Kairo ships its own pinned Clang, and every translation unit on both sides of the boundary goes through it. The failure mode that breaks C++ interop for most languages — header parsed by compiler A, code compiled by compiler B, ABI mismatch discovered at runtime — is designed out rather than mitigated.

This page covers the toolchain model, calling C/C++ from Kairo, exposing Kairo to C/C++, inline C++ and assembly, pointer and reference passing, templates and concepts, allocators and ownership, the exception model, and the ABI contract enforced at link time.


Coverage Matrix

The table below summarizes which C and C++ features Kairo can consume and expose. Rows marked bidirectional work in both directions.

FeatureDirectionNotes
FunctionsBidirectionalIncludes variadic functions
StructsBidirectionalLayout-compatible; see Structs
UnionsBidirectionalSee Unions
EnumsBidirectionalSee Enums
ClassesBidirectionalVtable-compatible; see Classes
TuplesBidirectionalEmitted as named structs; see Tuples across the boundary
TemplatesBidirectionalInstantiation across the boundary; see below
ConceptsBidirectionalKairo’s impl constraints map to C++20 concepts
NamespacesBidirectional
Pointers & ReferencesBidirectionalRequires unsafe on the Kairo side; see below
Operator OverloadingBidirectionalSee Operators
LambdasBidirectional
ExceptionsC++ → Kairo onlyKairo never unwinds; errors are values. See Exceptions
MacrosC++ → KairoPreprocessor macros are expanded before Kairo sees them
Preprocessor DirectivesC++ → Kairo
Inline AssemblyKairo → C++Via inline "asm" blocks
CoroutinesBidirectional
Heap ownershipBidirectionalVia class-specific operator new/delete; see Allocators and Ownership
Named Modules (import std;)Not yetSee Modules note

The Toolchain Model

Kairo ships two drivers. Both embed the same pinned Clang. Neither invokes a system compiler.

kairo — the Kairo compiler

kairo foo.k              # produces foo.out

When foo.k contains ffi "c++" import "header.h", kairo runs the pinned Clang’s frontend over header.h to extract declarations, then lowers foo.k to a Clang token stream and compiles it with that same Clang instance. The compiler that read the header and the compiler that generated the code are byte-identical, so there is no version skew to reconcile.

kcc — the C++ driver

kcc is a drop-in replacement for clang++, with two additions:

  • #include "foo.k" works. A Kairo file can be included directly into a C++ translation unit.
  • Optionally, the Kairo standard library can be used in place of the C++ standard library, via a separate header. This is opt-in and off by default.

kcc is the supported way to compile C++ in a Kairo project. Using the system clang++ or g++ will usually work, but forfeits the link-time ABI verification described below.

Why a pinned Clang

The Clang version is identical across Kairo releases and identical across the language boundary. This is the consistency guarantee the whole interop story rests on:

  • The header you ffi in and the object you link against were processed by the same frontend.
  • A Kairo type’s layout and a C++ type’s layout are computed by the same code.
  • ABI-affecting behavior does not drift between the two sides of a call.

The trade-off is that Kairo does not use whatever compiler is installed on the machine. That is deliberate.

Cross-compilation

Both kairo and kcc are full native cross-compilers. Targets are selected by triple, and system libraries come from curated sysroots hosted for download rather than from the host machine:

kcc --target=x86_64-pc-windows-msvc foo.cc
kairo --target=aarch64-unknown-linux-gnu foo.k

Sysroots are pinned to specific library versions (a particular glibc, a particular Windows SDK), so a build is reproducible across machines. Custom sysroots can be curated and registered locally.

Note

Cross-compiling to a GCC or MSVC target does not mean invoking GCC or MSVC. kairo and kcc produce object code for those platforms’ ABIs directly, using the pinned Clang and the curated sysroot.


Calling C/C++ from Kairo

Import a C or C++ header with the ffi directive. The compiler parses the header, extracts declarations, and makes them available as native Kairo symbols — no wrapper code required.

// main.k
ffi "c++" import "my_code.hh";

fn main() {
    var obj = MyClass("Kairo")
    std::println(f"name = {obj.get_name()}")
    my_function(42)
}

Given this C++ header:

// my_code.hh
#include <string>
#include <iostream>

class MyClass {
public:
    MyClass(std::string name) : name(name) {}
    std::string get_name() const { return name; }
private:
    std::string name;
};

void my_function(int x) {
    std::cout << "Hello from C++! x = " << x << std::endl;
}

Build and run:

kairo main.k
./main
name = Kairo
Hello from C++! x = 42
Note

ffi "c++" invokes the pinned Clang’s frontend internally to parse the header. All exported declarations — functions, classes, enums, templates — become available in Kairo’s scope with their original names and signatures. No code generation or binding step is visible to the user.


Exposing Kairo to C++

Use the kcc driver, which makes #include "file.k" work transparently in C++ translation units.

// my_code.k
fn my_kairo_function(x: i32) {
    std::println(f"Hello from Kairo! x = {x}")
}

class MyKairoClass {
    pub var name: string

    fn MyKairoClass(self, name: string) {
        self.name = name
    }

    fn get_name(self) -> string {
        return self.name
    }
}
// main.cpp
#include "my_code.k"
#include <iostream>

int main() {
    MyKairoClass obj("C++");
    std::cout << "name = " << obj.get_name() << std::endl;
    my_kairo_function(42);
    return 0;
}
kcc main.cpp -o main
./main
name = C++
Hello from Kairo! x = 42

How kcc works

kcc is the pinned Clang driver with a preprocessor hook that intercepts #include directives. When the included file has a .k extension, kcc:

  1. Invokes the Kairo compiler in-process as a library to produce a C++-compatible header containing forward declarations and wrapper signatures.
  2. Compiles the .k file into an object file.
  3. Links the Kairo object into the final binary at the end of the pipeline.

Auto-linking can be disabled with -fno-kairo-link if you need manual control over the link step.

Manual workflow (without kcc)

If you prefer a standard C++ build process, compile the Kairo source to a static library and a generated header, then link normally:

kairo my_code.k -c -o my_code -xc++ -header my_code.hh
kcc main.cpp my_code.o -o main

Using a third-party compiler at this step will work, but see Link-time ABI verification for what you give up.

Tip

kcc is the simplest path for mixed codebases. The manual workflow is better when Kairo is a dependency consumed by an existing CMake/Meson/Bazel project that manages its own link step.


Compiler Flags Across the Boundary

Kairo’s flags and Clang’s flags are not the same set. Some flags exist on one side only. kcc and kairo perform bidirectional translation, not passthrough: a flag given to one driver is translated to its equivalent on the other side when a translation unit is mixed.

Flags fall into three categories.

1. Translatable

The flag has an equivalent on both sides. It is translated and applied to both. This is the common case and requires nothing from the user.

2. Single-language

The flag exists on one side only. This is legal as long as the translation unit stays in one language. The moment the TU becomes mixed — a .cc that includes a .k, or a .k that ffi-imports a header — the flag is an error:

kcc foo.cc --fno-float-prec          # fine: foo.cc is pure C++
error: '--fno-float-prec' has no Kairo equivalent and cannot be used in a mixed translation unit
  note: foo.cc:12 includes "bar.k", which makes this translation unit mixed
  note: remove the flag, or move the Kairo dependency into a separate translation unit

The diagnostic always names both the flag and the include that made the TU mixed. A flag that has worked for years being rejected is only actionable if the reason is visible.

3. Layout-affecting

Some flags change how C++ lays out types or shapes vtables — -fno-rtti, -fshort-enums, struct-packing flags. Kairo’s ABI is fixed and cannot follow them. These are rejected in any translation unit that touches Kairo types, and unlike category 2 there is no way to make them work:

error: '-fno-rtti' changes C++ type layout and cannot be used in a translation unit containing Kairo types
  note: Kairo's ABI is fixed; this flag would move it out from under the Kairo side

The distinction matters because the fix differs. Category 2 means drop the flag. Category 3 means this can never work in a mixed TU.


Flag translation only sees a single invocation. Objects compiled at different times, by different people, with different flags, and linked later are outside its reach. kld closes that gap.

Every object produced by kairo or kcc carries an ABI note section recording:

  • the set of ABI-affecting settings that were in effect, canonicalized and sorted
  • a hash of that set, for fast comparison
  • a format version

At link time, kld compares hashes. A mismatch is a clean link error naming the specific flag:

error: ABI mismatch between input objects
  a.o was compiled with -fno-rtti
  b.o was compiled with -frtti
  note: these settings change type layout and cannot be mixed in one binary

The raw setting list is kept alongside the hash precisely so this message is possible — a hash alone can only say that something mismatched, not what.

Note

The hash covers the semantic set of resolved settings, not the command-line string. Flag order and alternate spellings of the same setting produce the same hash.

Objects not built with the Kairo toolchain

kld links ordinary C++ objects, including prebuilt system libraries. Those have no ABI note, so nothing can be verified about them. kld reports what it could not check:

warning: 3 object(s) were not built with kcc or kairo; ABI compatibility unchecked
  libfoo.a(bar.o), libfoo.a(baz.o), /usr/lib/qux.o

If one of those objects was built with mismatched settings, the result is a runtime crash with no diagnostic. That is unavoidable — the information was never recorded — but the boundary of the guarantee is made visible rather than left implicit. Suppress with -Wno-unverified-abi.

This is the reason to use kcc rather than the system clang++. Every object that goes through the Kairo toolchain is covered.


The ffi Keyword

ffi controls linkage and name mangling. It can be applied to individual declarations or to blocks.

// C++ linkage — name mangling, overloading, classes, templates all permitted
ffi "c++" {
    fn compute(x: i32) -> i32 {
        return x + 1;
    }
}

// C linkage — no name mangling, same restrictions as extern "C" in C++
ffi "c" fn add(x: i32, y: i32) -> i32 {
    return x + y;
}

ffi "c" follows the same rules as extern "C" in C++: no classes, no overloading, no templates. ffi "c++" follows the same rules as extern "C++": full C++ feature set, Itanium or MSVC mangling depending on the target.

Name mangling

Kairo does not implement Itanium or MSVC mangling itself. Kairo constructs the corresponding Clang declaration and asks Clang’s MangleContext for the symbol. Both ABIs come from the same source of truth as the C++ side of the boundary, and there is no second implementation to drift.


Namespace Mapping

std means different things on the two sides of the boundary. The mapping is fixed:

SpellingOn the Kairo sideOn the C++ side
std::Kairo’s standard libraryC++‘s standard library
cxx::C++‘s standard library
kairo::Kairo’s own namespace, including its standard library

From Kairo, the C++ standard library is always reached through cxx:::

ffi "c++" import <vector>;

fn example() {
    var v: cxx::vector<i32>
    v.push_back(42)
}

Emitted Kairo code lands inside namespace kairo, which is why a C++ translation unit sees Kairo’s library under kairo:: and its own under the unqualified std:: it already uses. Nothing is renamed on the C++ side.

Note

inline "c++" blocks are not supported from Stage 1 onward. Use ffi "c++" to import declarations and call them as ordinary Kairo symbols. The older __inline_cpp("...") form is likewise removed.


Inline ASM

For cases where you need to embed hardware-specific instructions, use inline "asm" blocks. Kairo uses the extended assembly syntax (outputs, inputs, and clobbers) to allow safe interaction between assembly and Kairo variables.

fn get_timestamp() -> u64 {
    var low: u32
    var high: u32

    // The syntax follows: "instruction" : outputs : inputs : clobbers
    unsafe {
        inline "asm" {
            "rdtsc"
            : "=a"(low), "=d"(high)
            :
            :
        }
    }

    return (high as u64 << 32) | (low as u64)
}

fn syscall_example(fd: i32, buf: *u8, len: usize) -> isize {
    var ret: isize
    unsafe {
        inline "asm" volatile {
            "syscall"
            : "=a"(ret)
            : "a"(1), "D"(fd), "S"(buf), "d"(len)
            : "rcx", "r11", "memory"
        }
    }
    return ret
}

Constraints and Safety

  • Volatile: Use inline "asm" volatile if the assembly has side effects that the optimizer might otherwise remove (like a syscall or hardware port I/O).
  • Clobbers: Always list registers modified by the assembly (like memory or specific registers) to prevent the Tether analysis and LLVM from making incorrect assumptions about the state of the machine.
  • Unsafe: Assembly code is inherently unsafe and should be wrapped in an unsafe block to indicate that the programmer is responsible for ensuring the correctness of the assembly.

Pointers and References

Kairo’s pointer model distinguishes safe pointers (*T, non-nullable, tracked) from raw pointers (unsafe *T, no tracking). Passing any pointer or reference across the FFI boundary requires explicit unsafe context because the compiler cannot enforce safety guarantees on the C/C++ side.

C++ reference parameters (T&, const T&, T&&) are not pointers. They map to Kairo’s parameter modes (@inout x: T, x: T, @move x: T). See Parameter passing modes.

Safe variable, unsafe pass

ffi "c++" import "my_code.hh";

fn main() {
    var x = 41

    // compile error: cannot pass reference to C function without unsafe block
    // add_one(&x)

    unsafe {
        add_one(unsafe &x)  // strips tracking; caller owns the memory contract
    }

    // for calling c++ with pointers and back, you must use unsafe blocks;
    // shorthand syntax is not allowed — `unsafe add_one(unsafe &x)` is a compile error

    std::println(f"x = {x}")  // x = 42
}

unsafe & creates a raw pointer from a safe binding. The compiler relinquishes tracking for that pointer — the caller is responsible for lifetime and aliasing correctness.

Raw pointer from the start

If the value will be passed to C/C++ repeatedly, allocate it as a raw pointer upfront:

ffi "c++" import "my_code.hh";

fn main() {
    var x: unsafe *i32 = @create i32(41)
    add_one(x)  // already unsafe — no block needed
    std::println(f"x = {*x}")  // x = 42
}
Warning

unsafe *T pointers can be null. Dereferencing a null unsafe *T is undefined behavior — the compiler will not insert a null check.


Allocators and Ownership

Heap ownership crosses the boundary in both directions, using the mechanism C++ already has for exactly this purpose.

Class-specific operator new and operator delete

Every exported Kairo class carries its own allocation operators, bound to Kairo’s global allocator:

class MyKairoClass {
public:
    static void *operator new(size_t);
    static void  operator delete(void *);
    static void *operator new[](size_t);
    static void  operator delete[](void *);
    ~MyKairoClass();
};

So from C++, the ordinary spelling is the correct one:

MyKairoClass *p = new MyKairoClass("hi");
delete p;   // runs Kairo's destructor, then returns memory to Kairo's global allocator

This works because Kairo emits the class, and therefore emits the destructor. ~MyKairoClass performs Kairo’s destruction semantics; C++‘s delete merely sequences destructor-then-deallocate, which is the same pair of operations Kairo spells as delete obj followed by @free.

Pointer parameters and return values stay raw *T — there is no wrapper type, no ABI change, and no cost at the call boundary.

Global operator new overrides do not affect Kairo objects

A class-specific operator new takes precedence over a global replacement. A C++ translation unit that overrides global new/delete — for a pool, an instrumented heap, a leak tracker — therefore does not touch Kairo allocations. The isolation is structural, not a restriction.

The operators bind to the global allocator, always

Kairo’s scoped allocator mechanism works by swapping the allocator the process is currently using. The emitted operator new and operator delete deliberately bypass that indirection and address the global allocator directly.

This matters. Consider a C++ delete that happens while a Kairo scoped allocator is active:

@mem::set_scoped_allocator(ArenaAllocator)
fn work() {
    call_into_cxx()      // C++ runs `delete p` on a globally-allocated Kairo object
}

If operator delete followed the ambient allocator, that object would be handed to the arena’s deallocator instead of the global one. Binding to the global allocator unconditionally makes the pairing correct regardless of what is active at the call site.

The invariant that makes this sound: objects reachable from C++ were allocated by the global allocator. A pointer allocated through a scoped allocator that escapes the allocator’s scope is a hard error under Tether, so a scoped-allocated object cannot reach a C++ delete in the first place.

What is still an error

The operators fix new/delete. They do not make every deallocation valid:

  • free() on a pointer from @create — wrong deallocator, no destructor.
  • delete on a pointer from @alloc@alloc returns untyped storage with no constructed object.
  • delete on an object allocated by a C++ operator new override in a TU that predates the Kairo declaration.

These are what the ownership annotations below are for.

CxxNewAllocator

For code that wants Kairo’s own allocations to follow C++‘s allocation path — so that a global operator new override does apply to them — core provides CxxNewAllocator:

@mem::set_allocator(core::CxxNewAllocator)

This is the unusual case, not the default. The intentional friction rule applies: the annotation must appear at the top of every file in the affected dependency graph.

Ownership annotations in generated headers

Declarations emitted for the C++ side carry Clang’s ownership attributes, tagged with the Kairo allocator identity:

void *kairo_alloc(size_t) __attribute__((ownership_returns(kairo_global)));
void  kairo_free(void *)  __attribute__((ownership_takes(kairo_global, 1)));

Clang’s static analyzer (MallocChecker) uses these to flag mismatched allocation and deallocation across the boundary. Because kcc controls the compiler invocation, this checking is available as a driver flag rather than requiring a separate scan-build step.

Note

The allocator identity string is part of the interop contract and is pinned in the ABI specification. Both sides must agree on it.

Kairo’s own semantic analysis enforces the same rule independently and produces a Kairo-quality diagnostic. The Clang attributes exist so that C++ consumers benefit too.

Lifetime annotations

Emitted declarations carry [[clang::lifetimebound]] and the [[gsl::Owner]] / [[gsl::Pointer]] pair where the Kairo side can prove the relationship. Unlike the ownership attributes, these are diagnosed during ordinary compilation via -Wdangling, so a lifetime relationship proven on the Kairo side becomes a real compile-time warning on the C++ side.


Templates and Concepts

Kairo generics and C++ templates are interchangeable across the boundary. A C++ concept can constrain a Kairo generic parameter, and a Kairo generic type can satisfy a C++ concept.

// my_code.hh
#include <concepts>

template<typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::same_as<T>;
};
// MyInt.k
ffi "c++" import "my_code.hh";

class <T> MyInt {
    pub var value: T

    fn MyInt(self, value: T) {
        self.value = value
    }

    fn op + (self, other: MyInt) -> MyInt {
        return MyInt(self.value + other.value)
    }
}

fn <T impl Addable> add(a: T, b: T) -> T {
    return a + b
}
// main.cpp
#include <iostream>
#include "my_code.hh"
#include "MyInt.k"

int main() {
    MyInt<int> a(5), b(10);
    MyInt<int> c = add(a, b);
    std::cout << "c.value = " << c.value << std::endl;  // 15

    int x = add(3, 4);
    std::cout << "x = " << x << std::endl;  // 7
}

T impl Addable in Kairo maps directly to Addable T in the generated C++ — the constraint is preserved across the boundary, not erased.


Tuples Across the Boundary

Kairo tuples — (i32, f32) — are emitted as ordinary named structs in a reserved namespace, one per distinct element-type list. Field order is source order, and .0 lowers to a plain member access.

This makes tuples first-class across the boundary: a C++ TU can name the type, take a reference to it, return it, and specialize on it.

The struct’s name is a structural function of its element types, not an ordinal. Two translation units that use (i32, f32) produce the same type and the same symbol, regardless of declaration order or which other tuple shapes appear in each TU. This is required for linking and is a departure from C++‘s anonymous-type mangling, which is deliberately TU-local.

Note

The exact structural encoding is part of the ABI and is specified separately. It is injective across all type constructors that can appear in a tuple, and unambiguous under nesting.


Exceptions

C++ → Kairo

Kairo can catch C++ exceptions using its standard try/catch syntax.

ffi "c++" import "my_code.hh";

fn main() {
    try {
        might_throw(true);
    } catch e: std::exception {
        std::println(f"Caught: {e.what()}")
    }
}
Note

Unlike Kairo’s panic system, the compiler cannot statically determine every exception type a C++ function might throw. A catch block that doesn’t handle a thrown type will propagate the exception up the stack. If nothing catches it, the runtime calls std::terminate.

Kairo → C++: every Kairo function is noexcept

Kairo does not throw. panic is a checked effect with a typed, inferred set — a Kairo error is a tagged union value returned to the caller, not an object propagated by the unwinder. There are no unwinding edges out of Kairo code.

Emitted declarations are therefore noexcept unconditionally, and this is a structural property rather than a promise:

  • A C++ caller never needs a try/catch around a Kairo call.
  • No unwind tables are generated at the boundary.
  • noexcept-conditional C++ code that calls into Kairo takes the noexcept(true) branch.

The value API

A fallible Kairo function returns its result union directly. C++ sees a type carrying the tag and the possible outcomes, and inspects it without any unwinding involved.

Converting to an exception

Because chaining over a tagged union is unidiomatic in C++, the result type also offers a conversion to a thrown exception. The conversion is a method on the result type rather than a second entry point per function: one generated symbol, no name collisions, and it composes with the rest of an expression.

The conversion is generated C++. It inspects the tag and throws from the C++ side of the boundary. Kairo itself still never unwinds, and the noexcept guarantee on the real entry point is unaffected. C++ callers who want exceptions get them; callers who want values keep the value API.

Throwing a Kairo error out of Kairo is not a roadmap item — it is excluded by the effect system. The conversion is a translation at the boundary, not a change to how Kairo signals failure.

Note

The exact spelling of the result type and its conversion method is not yet finalized.


ABI Compatibility

Kairo emits object code conforming to the platform’s native C++ ABI:

  • Unix-like systems: Itanium C++ ABI
  • Windows: Microsoft C++ ABI

Name mangling, vtable layout, RTTI, and struct layout all follow the platform convention, so Kairo .o/.obj files link with object files from any ABI-compliant C++ compiler without shims or translation layers.

ffi "c++" declarations use C++ mangling. ffi "c" declarations use C mangling (no decoration). This matches the behavior of extern "C++" and extern "C" in C++.

Two Kairo-specific guarantees strengthen the baseline:

Emission stages

Kairo lowers to a Clang token stream at every stage. Later stages emit more information, not different information — the ABI does not change:

StageEmits
Stage 1The core lowering: declarations, definitions, ordering, mangling.
Stage 2The same, plus lifetime, aliasing, and ownership attributes derived from Tether analysis.

Objects from either stage are ABI-compatible. Stage 2’s additions give the C++ side more static checking, not a different layout.


Declaration Ordering

C++ requires a type to be complete before it is used by value. Kairo does not impose that ordering on the programmer — declarations may appear in any order in a .k file — so the emitter reconstructs a valid order.

Emission proceeds in four phases:

  1. Forward declarations for every user type, in any order. This satisfies every pointer and reference edge.
  2. Function and method declarations, in any order.
  3. Type definitions, in dependency order over by-value edges only: value members, bases, fixed-array elements, and template arguments that land by value.
  4. Function bodies, in source order.

A cycle in the phase-3 graph is a genuine error — a type whose definition requires its own layout has no finite size — and is diagnosed on the Kairo side with the full cycle path:

error: recursive type definition requires infinite size
  `A` contains `B` by value
  `B` contains `A` by value
  note: use `*A` to break the cycle

Pointer and reference members do not create this edge, which is why struct B { *A a; } is legal alongside struct A { B b; }.


A Note on C++ Modules

C++20 named modules (import std;, import my_module;) are not currently supported. The interop layer relies on header-based inclusion via Clang’s preprocessor, and module interface deserialization (consuming pre-compiled BMIs) is a future roadmap item.

Exporting Kairo code as a C++ module interface unit (.cppm) is also planned but not yet implemented.

For now: use header-based interop (ffi "c++" + kcc) for all cross-language boundaries.