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.
| Feature | Direction | Notes |
|---|---|---|
| Functions | Bidirectional | Includes variadic functions |
| Structs | Bidirectional | Layout-compatible; see Structs |
| Unions | Bidirectional | See Unions |
| Enums | Bidirectional | See Enums |
| Classes | Bidirectional | Vtable-compatible; see Classes |
| Tuples | Bidirectional | Emitted as named structs; see Tuples across the boundary |
| Templates | Bidirectional | Instantiation across the boundary; see below |
| Concepts | Bidirectional | Kairo’s impl constraints map to C++20 concepts |
| Namespaces | Bidirectional | |
| Pointers & References | Bidirectional | Requires unsafe on the Kairo side; see below |
| Operator Overloading | Bidirectional | See Operators |
| Lambdas | Bidirectional | |
| Exceptions | C++ → Kairo only | Kairo never unwinds; errors are values. See Exceptions |
| Macros | C++ → Kairo | Preprocessor macros are expanded before Kairo sees them |
| Preprocessor Directives | C++ → Kairo | |
| Inline Assembly | Kairo → C++ | Via inline "asm" blocks |
| Coroutines | Bidirectional | |
| Heap ownership | Bidirectional | Via class-specific operator new/delete; see Allocators and Ownership |
Named Modules (import std;) | Not yet | See 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
ffiin 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.
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
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:
- Invokes the Kairo compiler in-process as a library to produce a C++-compatible header containing forward declarations and wrapper signatures.
- Compiles the
.kfile into an object file. - 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.
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.
Link-time ABI Verification
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.
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:
| Spelling | On the Kairo side | On the C++ side |
|---|---|---|
std:: | Kairo’s standard library | C++‘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.
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" volatileif 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
memoryor 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
unsafeblock 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
}
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.deleteon a pointer from@alloc—@allocreturns untyped storage with no constructed object.deleteon an object allocated by a C++operator newoverride 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.
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.
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()}")
}
}
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/catcharound a Kairo call. - No unwind tables are generated at the boundary.
noexcept-conditional C++ code that calls into Kairo takes thenoexcept(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.
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:
- Every Kairo function is
noexcept. See above. - ABI settings are recorded in the object and verified at link. See Link-time ABI verification.
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:
| Stage | Emits |
|---|---|
| Stage 1 | The core lowering: declarations, definitions, ordering, mangling. |
| Stage 2 | The 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:
- Forward declarations for every user type, in any order. This satisfies every pointer and reference edge.
- Function and method declarations, in any order.
- Type definitions, in dependency order over by-value edges only: value members, bases, fixed-array elements, and template arguments that land by value.
- 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.