Explanation

Carriers and the construct table

How each C++ construct reaches C++, and which ones are supported.

A carrier is the mechanism that carries one call from Go across the boundary into C++. There are exactly three: a few instructions of Go assembly that jump straight to the declaration; a C++ function that cppgo writes into its shim — the C++ file the generator creates and compiles beside your library — and that calls the declaration as C++; and a third form that enters C++ the way cgo does, so that C++ may call back into Go. Every binding — one C++ declaration turned into a callable Go function — is assigned exactly one of them before anything is written. That assignment is the generator’s central decision, and this page is about it.

So the page covers which carrier a declaration gets, what each of the three is, and the C++ constructs the generator supports today. It is the companion to Architecture, where one call is followed step by step; the API a caller sees is in Generated API, and the cost of each carrier is in Performance.

The carrier is invisible in the generated API. It shows up in the report cppgo gen prints, and in the comment above each generated function.

Direct

The generated assembly calls the C++ symbol in your library. All of these must hold:

  • the declaration has an out-of-line body — one defined in a .cpp file rather than inside the class body — and its symbol is exported by the library (a header-only declaration whose symbol happens to be exported is direct too);
  • it is not virtual: virtual means the implementation a call reaches is chosen at run time from the object’s type, and the direct call goes to a fixed address, so dispatch has to stay a concern of the C++ side;
  • it is noexcept — the C++ keyword by which a function promises not to throw. The direct path has no handler above it, so an exception that escapes terminates the process;
  • the signature is register-representable: scalars, enums, pointers and references, and a POD of at most 16 bytes with no MEMORY-class part. No std:: type.

Three of those words need unpacking. A POD — plain old data — is a struct with no constructor, no virtual function and no private members, whose bytes are its whole representation. The ABI — application binary interface — is how a compiled call is laid out: which registers and which memory carry its arguments and its result. MEMORY-class is a classification the ABI makes for an argument it passes in memory rather than in a register.

Thunk

A thunk is a generated extern "C" function — C++ with C linkage, so it has the C calling convention and a name that is not mangled — compiled by cppgo into the shim, and it calls the declaration as C++. Everything the direct path cannot express goes here: inline bodies (defined inside the class body, so the library exports no symbol for them), virtual members, std:: types, by-value records, constructors and destructors (the functions that create and destroy an object), and every call that may throw. One such translation unit — one .cpp file, compiled as a unit — is examples/mathx/cppgo_shim/zz_cppgo_shim.cpp.

The thunk is where exceptions are caught: each one clears the error message stored for the current thread (a thread_local, the C++ storage class that gives each thread its own copy), runs the call inside try/catch, and returns a status — 0 for success, non-zero after a caught exception. That is why a non-noexcept declaration is never direct, whatever its signature.

Reentrant

The reentrant carrier is a thunk reached through runtime.cgocall — the Go runtime’s entry point for “this thread is now inside foreign code” — instead of through the assembly trampoline. That entry is the only state in which the Go runtime accepts a callback: a call on the direct path never enters it, so a callback reached that way kills the process. A binding whose signature passes a Go function to C++ therefore gets this carrier.

A retained callback — one the callee stores with -retain and calls during later calls of its own — makes every binding of its package reentrant, because any call made from Go may deliver a record and the header cannot say which. The lifetime is the other half of the rule, and it is the caller’s: the registration the registering call returns keeps the Go function alive, and Close releases it. A C++ thread that Go did not start is outside the rule altogether: it has no goroutine to run the callback on, so a library that delivers from a thread of its own cannot be bound this way.

Template calls go through this carrier as well: the generated generic code calls cppgo.CallReentrant (runtime reference), so a template binding costs what the reentrant carrier costs. That is one implementation detail the generated package exposes in its source, not in its API.

The report

cppgo gen prints one line per binding, naming the carrier it chose:

mathx::Vector::scale(double) noexcept        direct _ZN5mathx6Vector5scaleEd     noexcept, not virtual, register class; the library exports the symbol
mathx::Vector::norm() const                  thunk  cppgo_thunk_49993cda6560     not noexcept: an exception would unwind into Go
mathx::midpoint(Point, Point)                thunk  cppgo_thunk_a66614728a62     a record passed or returned by value: the thunk copies it

The thunk names in that report are functions in the shim linked under Thunk above, one per non-direct binding.

The header decides, not a configuration file, and examples/stats is the example where the difference is the whole point. These two declarations come from examples/stats/include/stats/stats.hpp:

// mean of n values, or 0 for n == 0.
double mean(const double *xs, size_t n) noexcept;

// histogram counts the values into nbins buckets spanning [lo, hi] and writes
// the counts straight into the caller's array: the output crosses as a
// (pointer, length) pair too. Values outside [lo, hi] (and NaNs) are dropped.
//
// It reports failure by throwing std::invalid_argument. The shim catches it, so
// the generated Go signature is just an error — the C++ function has no status
// return to explain and none appears in Go.
void histogram(const double *xs, size_t n, double *bins, size_t nbins, double lo,
               double hi);

mean is out of line and noexcept, so it takes the direct carrier; histogram throws, so it takes a thunk. The other three noexcept functions in the header, stddev, count_above and data_pointer, are direct for the same reason.

The construct table

“Supported” means a generated package does it today. “Refused” means the construct does not bind: a declaration is named in a warning and left out of an otherwise complete package, and a template pattern the generic API cannot model stops the run. Either way the message names the declaration and the fix, and Limits carries the full list. “Designed” means the mechanism is stated in the design and is not in this build. A row that adds a clause of its own says exactly how far the construct has been carried: measured means the test fixture for the carrier calls it against a real compiler, generated and compiled, not executed means the code is emitted and built but never called, and emitted, not exercised by an example means no example runs it.

Two words in the table are the convention’s names for its argument registers: INTEGER, the six general-purpose registers a call passes integers and pointers in, and SSE, the eight floating-point registers beside them.

ConstructCarrierStatus
inline / header-only bodythunksupported
virtual member functionthunk (real vtable dispatch: the table of function pointers a virtual call reads the address from)supported
virtual destructorthunksupported
multiple / virtual inheritancethunk (static_cast/dynamic_cast for this)supported: inherited methods, and As<Base>() / As<Derived>() views
std::stringthunk (constructed and copied in the C++ side)supported
std::string_view, std::spanthunk (borrowed, valid for the call)supported
std::vector<T>thunksupported
std::array<T,N>thunk, by value (a fixed-size array)supported
std::optional<T>thunk; Go value is *T (a C++ value that may be absent)supported
std::unique_ptr, std::shared_ptr resultthunk: the shim moves the pointer into a holder it allocates, Go owns the holder and releases it oncesupported: the Go value is the pointee, and Close drops Go’s reference
std::unique_ptr, std::shared_ptr parameter—refused: Go has no owning pointer to hand over
POD ≤ 16 bytes by valuethunksupported
POD > 16 bytes / MEMORY class by valuethunk, through an sret pointer (a hidden first argument through which the callee writes a large result)generated and compiled, not executed
non-POD by valuethunk, copied into Go’s storage (a *T the Go side owns)measured, supported: Close runs the declared destructor once; a record that declares none releases the storage and runs nothing
a record Go cannot mirror: a bitfield, a packed struct, a union, a private or mixed-access fieldthunk, one getter and setter per public fieldsupported as an opaque record: Go holds the pointer and the fields are methods
more than 6 INTEGER / 8 SSE argumentsthunk, through an argument block both sides assertsupported
exception-throwing callthunk (try/catch)supported
callback parameter: a function pointer, a std::function (C++’s wrapper around any callable), or a typedef of onereentrantsupported
a callback the callee stores (-retain)reentrant, for every binding of the packagesupported: the registering call returns a registration, and Close releases it
class / function templatethunk, called reentrantlysupported for scalar type arguments and non-type dimensions; a public field, a dependent signature and a parameter pack stop the run
static memberthunk, no this (the object a method was called on)supported
enum—supported: a named Go integer type over the underlying width
operators, conversion operators (an operator that converts the object to another type)the carrier the call needssupported: named methods and functions from the naming table
function-like macro that is the API (a macro is a text substitution performed before compilation)—not bindable: a macro’s expansion is not a declaration