How to interoperate with C++?

By using the Compiler as a Service

Vipul Cariappa

FPLaunchpad, IIT Madras

05 September 2026

Where we are going

  • Problem/Motivation: Why do we want to interoperate with C/C++? Why is this difficult?
  • Build minimal interop for Python: Use Clang to build a working C++ introspection and execution layer. In C. Then drive it from Python.
  • Production system; CppJIT: Automatic Python bindings, no manual wrappers, no interface files.
  • Demos: CUDA, Numba, & the browser.

Motivation

Why do you want to interoperate with C/C++?

  • Speed: Scripting languages are slow. C/C++ is where the actual compute happens.

  • Rapid iteration: Mixing in a scripting language gives fast prototyping. Try an idea in Python, without a compile step, then drop into C++ only where it matters.

  • Reuse: Decades of existing C/C++ libraries. Nobody wants to rewrite BLAS, or ROOT, or Thrust, to use it from another language.

The Problem

C++ does not want to be bound

Every language wants to call C++. Almost nobody enjoys making it happen.

  • SWIG — you write interface files, then regenerate.
  • pybind11, nanobind — you write C++ to describe your C++, then recompile.
  • ctypes, cffi — fine for C. C++ has name mangling, overloads, templates.
  • All of them are ahead-of-time. They must decide, before you run anything, exactly which types and instantiations exist.

The part that actually hurts


template <typename T>
class Matrix { /* ... */ };


How many bindings does this need?

Unbounded. Matrix<float>, Matrix<double>, Matrix<std::complex<T>>, Matrix<MyType> — the set is open, and it depends on code the binding author has never seen.

A template is not a type. It is a function from types to types, and the only thing in the world that can evaluate it is a C++ compiler.

So keep the compiler around

  • Traditional model: compiler runs, produces a binary, exits. All knowledge of types, overloads and templates dies with it.
  • What if it didn’t exit?
  • Keep a live clang::Interpreter. Ask it questions at runtime: what is this type? what are its methods? instantiate this template for me. give me the address of that function.
  • That is Compiler as a Service.

The binding layer stops being generated code. It becomes a query.

Building a minimal InterOp Engine

Clang as a library, from clang::Interpreter up to Python proxies

Clang is a library, not just a compiler


  • Clang is a compiler which supports C, C++, Objective-C, and Objective-C++ programming languages, as well as the OpenMP, OpenCL, RenderScript, CUDA, SYCL, and HIP frameworks.
  • Just like LLVM, Clang is built by a set of reusable components and can be used as a library.


flowchart LR
  subgraph Frontend
    L[Lex] --> P[Parse] --> S[Sema]
  end
  subgraph Backend
    CG[CodeGen] --> IR[LLVM IR] --> MC[MC / ORC JIT]
  end
  SRC[C / C++ / CUDA] --> L
  S -- AST --> CG

A C++ REPL in about twenty lines

clang::IncrementalCompilerBuilder CB;
CB.SetCompilerArgs({"-resource-dir", ResourceDir.c_str(), "-std=c++20"});
// pass `-xc` instead, and you have a C REPL

auto CI     = ExitOnErr(CB.CreateCpp());
auto Interp = ExitOnErr(clang::Interpreter::create(std::move(CI)));

llvm::LineEditor LE("llvmBlr-repl");
while (std::optional<std::string> Line = LE.readLine()) {
  if (*Line == "%quit")
    break;
  if (auto Err = Interp->ParseAndExecute(*Line))
    llvm::logAllUnhandledErrors(std::move(Err), llvm::errs(), "error: ");
}

ParseAndExecute is the whole interface: it adds one PTU and runs it.

Demo 1 — a C++ REPL

./build/bin/ex1

Getting values back out: clang::Value

Running code is half the job. You need the result, typed.

clang::Value V;
Interp->ParseAndExecute("auto x = Math::pow2<int>(7); x", &V);
if (V.getKind() == clang::Value::K_Int)
  printf("%d\n", V.getInt());          // 49

// or skip the interpreter entirely and call the JIT'd code directly
auto Addr  = ExitOnErr(Interp->getSymbolAddress("sq")); //mangled name
auto sqPtr = Addr.toPtr<int(*)(int)>();
printf("%d\n", sqPtr(13));             // 169
auto xPtr  = ExitOnErr(Interp->getSymbolAddress("x"));
assert(*xPtr.toPtr<int*>() == 49);
  • clang::Value is ref-counted, small-buffer-optimised, and knows its own C++ type.
  • getSymbolAddress is the bridge back to compiled code — an ordinary pointer.

Demo 2 — clang::Value and getSymbolAddress

./build/bin/ex2

The building blocks of a CaaS

Five C functions. That is the entire surface we need.

/// Process C++ code — adds a PTU.
void Clang_Parse(const char *Code);

/// Look an entity up by name, optionally within a scope.
Decl_t Clang_LookupName(const char *Name, Decl_t Context);

/// In-memory address of the JIT'd function for a declaration.
FnAddr_t Clang_GetFunctionAddress(Decl_t D);

/// Allocate storage sized from the declaration, and construct into it.
void *Clang_CreateObject(Decl_t RecordDecl);

/// Instantiate a member template, on demand, for the given arguments.
Decl_t Clang_InstantiateTemplate(Decl_t D, const char *Name,
                                 const char *Args);

Note the types: void* and char*. No Clang types leak out. This header is callable from C, Python, Julia, or anything with an FFI.

The whole demo; end-to-end

main(), driving it with the only five C functions with out any Clang headers:

Clang_Parse(Code);

Decl_t TemplatedClass = Clang_LookupName("B", 0);
Decl_t TypeA = Clang_LookupName("A", 0);
Decl_t TypeB = Clang_LookupName("B", 0);

void *ObjA = Clang_CreateObject(TypeA);
void *ObjB = Clang_CreateObject(TypeB);

Decl_t CallmeOnA =
    Clang_InstantiateTemplate(TemplatedClass, "callme", "A");
Decl_t CallmeOnB =
    Clang_InstantiateTemplate(TemplatedClass, "callme", "B");

fn_def CallmeOnAPtr = (fn_def)Clang_GetFunctionAddress(CallmeOnA);
fn_def CallmeOnBPtr = (fn_def)Clang_GetFunctionAddress(CallmeOnB);

CallmeOnAPtr(ObjA);
CallmeOnBPtr(ObjB);

The code that gets JIT-compiled:

extern "C" int printf(const char*, ...);

class A {};
class B : A {
public:
  template <typename T>
  static void callme(T*) {
    printf("Instantiated with [%s]\n",
           typeid(T).name());
  }
};

Five function calls, and B::callme is instantiated twice, once over A & once over B, with no template argument written anywhere in the program’s own source.

Demo 3 — instantiating and calling C++ templates from C

./build/bin/ex3

Teaching Python to speak C++

Three small classes over the same C API:

  • InteropLayerWrapper: ctypes bindings for the five functions.
  • CallCppFunc: wraps a raw address in a ctypes.CFUNCTYPE and calls it.
  • TemplateWrapper: implements __getitem__, so obj.callme["A"] becomes an explicit instantiation, and __call__, to obj.callme(a).

The square bracket is the whole user-facing idea: Python’s __getitem__ is spelled the same way as C++’s template argument list.

b.callme["A"](a)          # explicit:  B::callme<A>
b.callme["B"](b)          # explicit:  B::callme<B>

InteropLayerWrapper

libInterop = ctypes.CDLL("/path/to/library", mode=ctypes.RTLD_GLOBAL)

class InteropLayerWrapper:
    _construct = libInterop.Clang_CreateObject
    _construct.restype = ctypes.c_void_p
    _construct.argtypes = [ctypes.c_size_t]

    @classmethod
    def construct(cls, cpptype):
        return cls._construct(cpptype)
    
    ... # use ctype to wrap other API functions
  • ctypes is part of standard Python library, used to interoperate with C.
  • One ctypes binding per C function — the whole interop layer is this thin.
  • The ctypes wrapped five C API is what everything else is built on.

CallCppFunc

class CallCppFunc:
  def __init__(self, func):
    proto = ctypes.CFUNCTYPE(None, ctypes.c_void_p)
    self._funcptr = proto(InteropLayerWrapper.get_funcptr(func)) # Clang_GetFunctionAddress

  def __call__(self, *args):
    return self._funcptr(args[0].cppobj)

Clang_GetFunctionAddress hands back a raw integer; ctypes.CFUNCTYPE is what turns that address into something Python can actually call.

TemplateWrapper

class TemplateWrapper:
  def __getitem__(self, *args):
    # obj.callme["A"]  ->  explicit instantiation
    return InteropLayerWrapper.get_template(self._scope,
                                            self._name,
                                            tmpl_args=args)

  def __call__(self, *args):
    # obj.callme(a)  ->  instantiate from the argument types instead
    ol = InteropLayerWrapper.get_template(self._scope,
                                          self._name,
                                          tpargs=[type(a) for a in args])
    return ol(*args)

Square brackets and template angle brackets are the same idea in two languages: __getitem__ looks up an instantiation by name, __call__ deduces it from the arguments instead.

Python types backed by C++ scopes

def cpp_allocate(proxy):
    pyobj = object.__new__(proxy)
    proxy.__init__(pyobj)
    pyobj.cppobj = InteropLayerWrapper.construct(proxy.handle)   # Clang_CreateObject
    return pyobj

CppA = type("A", (), {
    "handle":  InteropLayerWrapper.get_scope("A"),               # Clang_LookupName
    "__new__": cpp_allocate,
})

CppB = type("B", (CppA,), {
    "handle":  InteropLayerWrapper.get_scope("B"),
    "__new__": cpp_allocate,
    "callme":  TemplateWrapper(InteropLayerWrapper.get_scope("B"), "callme"),
})

Python’s type() builds the proxy class at runtime.

C++ inheritance is mapped onto Python inheritance by passing CppA as a base.

Demo 4 — Python driving C++ templates

python3 examples/ex3/teach_python_cpp.py

What we have, and what is missing

Working

  • Look up types and functions
  • Instantiate templates on demand
  • Construct objects
  • Call JIT’d code at full speed

Missing

  • Overload resolution
  • Object lifetime / ownership
  • Exceptions across the boundary
  • Inheritance from Python
  • Standard library niceties
  • Anything resembling performance work

Roughly 200 lines got us the left column. The right column is the next decade — and it already exists, as CppInterOp and CppJIT.

Production ready System

CppInterOp, CppJIT, and what falls out for free

CppInterOp

  • A stable, minimal API over Clang/LLVM internals — the productised version of the header we just wrote.
  • On-demand reflection, template instantiation, and JIT execution.
  • Embeds Clang and LLVM as libraries in a backward-compatible way, so downstream tools do not track Clang’s ABI.
  • Also ships a C API and a dispatch-table entry point, for consumers that cannot link the C++ ABI at all.



flowchart TB
  I[clang-repl] <--> C[CppInterOp]
  C <--> P[Python / CppJIT]
  C <--> J[jank - Clojure]
  C <--> JL[CppInterOp.jl]
  C <--> X[xeus-cpp]

cppinterop.readthedocs.io · github.com/compiler-research/CppInterOp

The API, concretely

Cpp::Declare(R"(
  template <typename T> struct S {
    bool operator<(T &a) { return 0 < a; }
  };
)", Decls);

auto Instance = Cpp::InstantiateTemplate(
    Decls[0], {Cpp::GetType("int")}, 1);
auto *obj = Cpp::Construct(Instance);

std::vector<TCppFunction_t> ops;
Cpp::GetOperator(Instance, Cpp::Operator::OP_Less, ops);
Cpp::JitCall call = Cpp::MakeFunctionCallable(ops[0]);
call.Invoke(&result, {args, /*arg_count=*/1}, obj);

Same five ideas as our C header — declare, instantiate, construct, look up, invoke — with overloads, operators and calling conventions handled properly.

CppJIT

The successor to cppyy, rebuilt on CppInterOp and clang-repl.

  • Give it a header or a declaration; it gives you Python objects. No interface files, no code generation, no build step.
  • Bindings are created lazily, on attribute access — you only pay for what you touch.
  • The CPython runtime: proxies for scopes, methods, overloads, data members, enums; converters and executors for the calling convention; GIL management; a memory regulator keeping Python and C++ lifetimes in sync.
import cppjit
cppjit.include("mylib.h")
cppjit.load_library("mylib")
cppjit.gbl.MyClass(42).do_something()

Demo 5 — one interpreter, two languages

python-demo.ipynb

Cross-language inheritance

The hard one, and the one that makes C++ frameworks usable from Python.

Defined in a C++ cell:

class MyClass {
public:
  virtual int add_int(int i)
    { return m_data + i; }
  int m_data;
};

int callback(MyClass *m, int i)
  { return m->add_int(i); }

Subclassed in Python:

class PyMyClass(cppjit.gbl.MyClass):
    def add_int(self, i):        # override
        return self.m_data + 2 * i

cppjit.gbl.callback(MyClass(0), 2)   # 2
cppjit.gbl.callback(PyMyClass(1), 2) # 5

The same C++ callback, taking the same MyClass* — dispatching into Python.

  • CppJIT synthesises a C++ dispatcher subclass whose virtuals call back into the Python object.
  • The C++ side only ever sees a MyClass*. Virtual dispatch does the rest.
  • This is what lets you subclass a framework’s abstract interface in Python.

Templates, callbacks, exceptions

  • Templates on demandgbl.apply["double"] instantiates apply<double> at that moment. Not a lookup in a table of pre-generated types.
  • Python callables as std::function — a Python lambda is wrapped so C++ can call it through the normal std::function interface.
  • Exceptions translate — a std::runtime_error thrown in C++ arrives in Python as cppjit.gbl.std.runtime_error, catchable with except.
template <typename T>
T apply(std::function<T(T)> fn, T v) {
    if (v == 0)
        throw std::runtime_error("Zero value not allowed");
    return fn(v);
}


gbl.apply["double"](lambda x: x * 2, 21)          # -> 42.0
try:
    gbl.apply["double"](lambda x: x * 2, 0)
except gbl.std.runtime_error as e:                # C++ exception, Python except
    print("Caught C++ exception in Python")

Demo 6 — templates, callbacks, exceptions

cppjit-templates-demo.ipynb

CUDA, in a notebook cell

Clang already compiles CUDA. Clang-REPL therefore compiles CUDA incrementally.

flowchart TB
  S["__global__ kernel in a cell"] --> CR[Clang-REPL]
  CR --> H[host IR] --> X[ORC JIT / x86]
  X --> CPU[CPU]
  CR --> D[device IR] --> PTX[NVPTX / PTX]
  PTX --> GPU[GPU]

  • One extra flag: --cuda.
  • Device code goes down the NVPTX path, gets packed into a fatbin, and is registered with the CUDA runtime — per PTU.
  • Kernels are then reachable from Python: gbl.scaleKernel[1, 256](d, n, 2.0) is <<<1, 256>>>.

Demo 7 — Launching CUDA kernel from Python

cppjit-cuda-demo.ipynb

Existing CUDA libraries: Thrust

No precompiled library, no C/C++ helper functions — Thrust’s own template API, called straight from Python.

data = gbl.thrust.device_vector["double"](100000)
gbl.thrust.sequence(gbl.thrust.device, data.begin(), data.end())

total = gbl.thrust.reduce(gbl.thrust.device, data.begin(), data.end(), 0.0)

square = gbl.thrust.square["double"]()
sum_sq = gbl.thrust.transform_reduce(
    gbl.thrust.device, data.begin(), data.end(), square, 0.0, gbl.thrust.plus["double"]())
  • device_vector, sequence, reduce, transform_reduce — Thrust’s actual template functions, instantiated and called from Python, on data that never leaves the GPU.
  • thrust::square / thrust::plus stand in for a custom device functor — a Python lambda could never be one; there is no way to run Python bytecode on the GPU.

Demo 8 — Thrust on the GPU, from Python

thrust-demo.ipynb

Putting it together

An image pipeline, built up one cell at a time:

  1. Load a solar EUV image with PIL, into a NumPy array.
  2. Write a CPU Sobel edge detector in C++, in a cell. Call it from Python on the NumPy buffer.
  3. Write a CUDA tiled blur kernel — shared memory, __constant__ kernel weights — in a cell. Call it from Python.
  4. Render each stage inline, from C++, via the xeus display protocol.

Prototype in Python, compute in C++ and CUDA, iterate without ever restarting the process.

Demo 9 — exploratory programming, end to end

solar.ipynb

Numba: skipping the interpreter entirely

Calling C++ from Python still costs a Python call. Inside @numba.njit, it does not have to.

import cppjit.numba_ext          # registers the C++ <-> Numba bridge
import numba

@numba.njit                       # nopython=True
def sum_add3(x):
    total = 0.0
    for row in x:
        total += cppjit.gbl.add3(row[0], row[1], row[2])
    return total
  • No boxing, no PyObject, no GILrequires_gil = False.
  • The C++ call lowers to a direct call through the JIT’d function pointer.

How the Numba bridge works

flowchart LR
  A["cppjit.gbl.add3"] -->|typeof_impl| B[CppFunctionNumbaType]
  B -->|get_call_type| C["reflection<br/>+ overload match"]
  C --> D[numba Signature]
  D -->|lower_builtin| E["add_dynamic_addr<br/>call_function_pointer"]
  E --> F[JIT'd C++ machine code]

  • typeof_impl maps a CppJIT proxy to a Numba type.
  • get_call_type picks the C++ overload from the Numba argument types, then asks reflection for the return type.
  • Lowering emits ExternalFunctionPointer at the address from cppjit.addressof(overload). Nothing Python-shaped survives.
  • Classes work too: field offsets and method addresses come from reflection, so d.fField and d.get_field() compile in nopython mode.

Demo 10 — C++ inside nopython Numba

numba-demo.ipynb

And it runs in a browser

  • The whole stack builds for emscripten-wasm32: LLVM, CppInterOp, CppJIT, xeus-cpp.
  • xeus-cpp-lite + JupyterLite: a C++ interpreter, in a tab, with no server and nothing installed.
  • Same Clang. Same reflection. Same on-demand template instantiation. The JIT targets WebAssembly instead of x86.

Teaching, documentation, reproducible papers, and “try it now” links — without asking anyone to install a C++ toolchain.

The ecosystem

  • xeus-cpp — the Jupyter kernel used for every notebook today.
  • CppJIT — Automatic Python bindings.
  • jank — A Clojure dialect on LLVM, with first-class C++ interop.
  • CppInterOp.jl — The same API from Julia.
  • ROOT — CERN’s analysis framework; exabytes of physics data, driven by an interactive C++ interpreter.

The interpreter is not a toy. It has been in production at CERN for two decades, first as CINT, then Cling, and now upstream in LLVM as clang-repl.

Takeaways

  1. Templates are open-ended. No ahead-of-time tool — SWIG, pybind11, ctypes — can enumerate an unbounded set of instantiations.
  2. Keep the compiler alive instead. A clang::Interpreter makes C++ queryable at runtime: look up, instantiate, construct, call.
  3. Few C functions are enough — demonstrated end to end, with no template argument ever written in the driving program.
  4. CppInterOp and CppJIT productionise the same idea, with zero interface files and zero code generation.
  5. The same reflection machinery scales out for free — cross-language inheritance, CUDA, Thrust, Numba, and an unmodified WebAssembly build.

This is not a research toy. The same interpreter has run physics analysis at CERN for two decades.

Get involved

Please get involved in our project, if it interests you.

Open projects are listed on the site.

Star the project in GitHub to show that you use our project.

Thank you

Vipul Cariappa · FPLaunchpad, IIT Madras

Work by the Compiler-Research group, Princeton University & CERN

Questions?