A class decorated with @cpp is laid over objects made by C++. tslang calls its virtual methods
through the C++ object's own vtable, and its non-virtual methods by their symbols, so a C++
interface such as NVRHI's nvrhi::IDevice can be used from TypeScript without a flat
extern "C" wrapper per method:
@cpp
class NvResource {
@slot(0) _destructor(): void;
@slot(1) AddRef(): u32;
@slot(2) Release(): u32;
@slot(3) GetRefCount(): u32;
}
@cpp
class NvDevice extends NvResource {
@slot(39) getGraphicsAPI(): u8; // slots 4..38 aren't declared: nothing calls them
}
const device = <NvDevice>Donut_GetNvrhiDevice(app); // an Opaque from C/C++
print(device.getGraphicsAPI());(@cpp was called @cppvtable before @slot existed, and numbered the methods by their order.
The old name is an error now.)
A tslang class already matches a C++ single-inheritance object on 64-bit targets in the following ways:
- the vtable pointer is the object's first word;
- a method call loads slot N of that table and calls it as
fn(this, args...), with no hidden parameters, in the target's C calling convention (on x64 Windows that is the MSVC member function convention); - a base class's slots come first, and a base object is the prefix of a derived one.
What @cpp changes is what is virtual, and where. In an ordinary class every method is virtual,
and the vtable has .instanceOf in slot 0, .new, a slot for each implemented interface, and
slots for its static fields (.rtti, .size). A @cpp class has none of these, and a method is
virtual only as in C++:
| Method | What it is |
|---|---|
@slot(N) name(...): T; |
a C++ virtual method: slot N of the vtable, counted from 0 with the base classes' slots |
name(...): T; overriding a base class's @slot method |
the same slot, as in C++ (@slot may be repeated, with the same number) |
@linkname("symbol") name(...): T; |
a C++ non-virtual method, called by its (mangled) symbol as symbol(this, args...) |
name(...): T { ... } with no @slot, overriding nothing |
a method of TypeScript's own, called directly; not in the vtable |
The slots may be given in any order, and a slot no method names is skipped: declare only the methods you call. Two methods in one slot are an error.
The class gets no vtable, constructor, RTTI or allocation of its own. It is a declaration: its C++
methods have no bodies, and an abstract class with abstract methods (each with its @slot)
reads the same. (A class whose virtual methods have bodies is the other way round: see
"Implementing C++ methods".)
Fields declared on the class are the C++ object's fields after the vtable pointer, laid out like a tuple's.
The compiler rejects anything that would add a slot, run code of its own, or ask the object what it is. A wrong slot calls a different C++ method with no error at all, which is why these are errors and not warnings.
| Not allowed | Why |
|---|---|
a method without a body and without @slot or @linkname, overriding nothing |
it's neither a slot nor a symbol: a forgotten @slot |
an abstract method without @slot |
it is virtual, so it needs its slot |
@slot on a method of an ordinary class |
an ordinary class's vtable is tslang's own |
| a constructor or a field initializer, in a class none of whose virtual methods has a body | the objects and their code are C++'s (see "Implementing C++ methods") |
static members, get/set accessors, accessor fields |
they would take vtable slots |
implements |
an implemented interface takes a slot |
extends an ordinary class, or an ordinary class extending a @cpp one |
the ordinary class's slots (.instanceOf, .rtti, ...) would shift the numbering |
instanceof, a cast to or from any, a cast to an interface |
each asks the object through slot 0, which is a C++ method |
-mm=rc / -mm=own |
retains and releases write a count in the word before an object, inside the C++ object |
| a 32-bit x86 MSVC target | C++ member functions are __thiscall there (this in ECX) |
| a virtual method returning a tuple, on a target other than MSVC and Itanium x86-64 / AArch64 | each ABI returns records its own way (see below) |
| on Itanium, a virtual method returning a trivial tuple with floating-point fields, unmarked | SysV / AAPCS64 return it in SSE registers / as an HFA, which isn't done (see below) |
| on Itanium, a call of a method returning a trivial tuple of more than 16 bytes, unmarked | it is returned in memory: mark the method @cppnontrivial (see below) |
| on Itanium, a call passing a tuple with floating-point fields by value | SysV / AAPCS64 pass it in SSE registers / as an HFA, which isn't done (see below) |
| a non-virtual C++ method returning or taking a tuple | not supported yet: its symbol would have to be declared as each ABI passes records |
| a method implementing a C++ method (with a body) taking a tuple | not supported yet: its body would have to read it as the ABI passes it |
A generic or static class can't be @cpp either.
tsbindgen writes the binding from the C++ header, with the slots, the mangled symbols and the
@cppnontrivial marks clang computes for the target (README, "C++ classes"):
tsbindgen nvrhi.hpp --target x86_64-pc-windows-msvc --filter 'nvrhi::I*' -- -std=c++17.
Writing one by hand:
-
Slot numbers. Take them from the compiler, not by counting: MSVC prints a class's vtable with
cl /c /Zs /d1reportSingleClassLayout<Name> file.cpp; clang (Itanium) withclang++ -S -emit-llvm -o /dev/null -Xclang -fdump-vtable-layouts file.cpp, for a class whose vtable the file emits (it defines the first virtual method that isn't inline), where slot N is entry N + 2 (entries 0 and 1 are the offset to top and the RTTI). The numbers differ between the two ABIs (the destructor, below), so a binding for both is two files, picked by the target (<name>.<os>.ts). -
The destructor is one slot under MSVC (the scalar deleting destructor) and two under the Itanium ABI. Don't call it: release objects the way the C++ library does (
Release()). -
Overloads. Declare each C++ overload under the C++ name, with its own
@slot(or@linknamefor a non-virtual one). A call picks the overload from its arguments, at compile time as C++ does: an argument of the parameter's own type fits best, then an integer for an integer or a float for a float, or a class derived from the parameter's class, then any other conversion. A class is a C++ pointer, soTandT | nullfit aT | nullparameter alike. A call that fits none, or fits two as well, is an error that lists the overloads. MSVC groups overloads in the vtable, in reverse declaration order: take the numbers from the layout report (tsbindgen does).@slot(8) over(x: s32): s32; @slot(7) over(x: f64): s32; @slot(6) over(a: s32, b: s32): s32; // methods.over(5), methods.over(2.5), methods.over(6, 7)
A derived class overriding one overload names its slot: by name alone it would be ambiguous.
-
Non-virtual methods are bound by their mangled symbol:
@linkname("?scaled@Methods@@QEAAHH@Z")under MSVC,@linkname("_ZN7Methods6scaledEi")under the Itanium ABI. The symbol has to be exported by the C++ library (__declspec(dllexport)on the method or the class). -
Struct returned by value. A C++ virtual method that returns a class or struct by value (for example
nvrhi::RefCountPtr<T>ornvrhi::Object) returns a record. Declare its return type as a tuple with the record's layout:type RefCountPtr = [pointer: Opaque | null]; // nvrhi::RefCountPtr<T> @cppnontrivial // it has a destructor @slot(17) createBuffer(desc: Ref<BufferDesc>): RefCountPtr; // const handle = device.createBuffer(Ref(desc))
MSVC returns a record from an instance method through a hidden pointer, whatever its size. The pointer is passed after
this, and the method returns it as well. A call to such a method passes a local for the result and reads it back, so the call's value is the record. This is clang'sMicrosoftCXXABI::classifyReturnType: "MSVC always returns structs indirectly from C++ instance methods". A method returning a class (getLayout(): IBindingLayout, a C++ pointer) or a number still returns it in a register.The Itanium ABI (g++, clang) decides by the record, and tslang does it on x86-64 and AArch64 (Linux, Android):
- a record with a destructor or a copy constructor -
RefCountPtr<T>- comes back through a hidden pointer passed beforethis, as the call'ssretargument (in RDI on x86-64, X8 on AArch64). A tuple can't say the C++ type has them, so mark the method@cppnontrivial. A trivial record of more than 16 bytes is returned the same way, so mark that one too (a call of it unmarked is an error). MSVC ignores the mark; - a trivial record of up to 16 bytes of integers and pointers -
nvrhi::Object, a struct of threebools, one of threeuint32_ts - comes back in RAX:RDX / X0:X1, packed as the record is in memory: the call reads the two registers back as the record. No mark; - one with floating-point fields comes back in SSE registers (SysV) or as an HFA (AAPCS64), which isn't done: such a method is an error, as is any method returning a tuple on another Itanium target (32-bit x86, say).
The record isn't destroyed: a
RefCountPtrthe method returns holds a reference, which the caller gives back withRelease(). A method stored in a variable and called later takes the hidden pointer as an argument; call it directly. - a record with a destructor or a copy constructor -
-
Structs passed by value. A parameter of a virtual method that is a trivially copyable struct by value (
nvrhi::TextureSubresourceSet subresources,nvrhi::Object texture) is a tuple with its layout, passed as the C++ ABI passes the struct:type TextureSubresourceSet = [baseMipLevel: u32, numMipLevels: u32, baseArraySlice: u32, numArraySlices: u32]; @slot(9) clearTextureFloat(t: ITexture | null, subresources: TextureSubresourceSet, clearColor: Ref<Color>): void; // commandList.clearTextureFloat(texture, [0, 1, 0, 1], Ref(color))
The call copies the tuple into a slot of its own (at least 16 bytes, zeroed), then:
- MSVC x64 passes a struct of 1, 2, 4 or 8 bytes in a register, as an integer of its size, and any other by a pointer to the copy (which the callee may change: the caller's tuple doesn't);
- SysV x86-64 passes one of up to 16 bytes of integers and pointers in one or two 64-bit
registers, when that many of the six remain (else all of it on the stack), and a bigger one on
the stack (
byval); - AAPCS64 passes one of up to 16 bytes in one or two consecutive 64-bit registers (
[N x i64], all of it in registers or all on the stack), and a bigger one by a pointer to the copy.
A struct with a destructor or a copy constructor (which MSVC destroys in the callee, and the Itanium ABI passes by a pointer to a copy the caller destroys) can't be a tuple parameter: take it as a
Ref<T>in a wrapper of your own, or leave the method out. Nor can a struct with floating-point fields under the Itanium ABI (SSE registers, an HFA's), nor any struct taken by a non-virtual method or by one with a body. -
Narrow parameters. A
boolis aboolean, anuint8_t(or anenum class : uint8_t) au8, anint16_tans16: a call of a C++ method extends an argument narrower than 32 bits as a C++ caller does (zeroext,signextfor a signed type), which clang's code relies on. -
Structs by reference (
const BufferDesc&) are aRef<T>to a named tuple with the C++ layout. That layout depends on the C++ build:std::string, for example, is 32 bytes with the release CRT and 40 with the debug CRT. -
Casting.
<NvDevice>opaqueValueis a plain pointer cast with no check: the object must really be one. -
Methods of your own. A method with a body and no
@slotis TypeScript's: a helper over the C++ methods, compiled as an ordinary function takingthis.
A @cpp class whose virtual methods have bodies implements C++ methods: C++ calls them through
the object's vtable. An example is a callback interface such as nvrhi::IMessageCallback. Declare
the C++ interface as above, and extend it:
@cpp
class IMessageCallback { // nvrhi::IMessageCallback (MSVC)
@slot(0) _destructor(): void;
@slot(1) message(severity: u8, text: string): void;
}
@cpp
class MessageLog extends IMessageCallback {
errors = 0;
message(severity: u8, text: string): void { // overrides slot 1
if (severity >= 2) {
this.errors++;
}
}
}
const log = new MessageLog(); // pass <Opaque>log to C++- The vtable is the C++ layout and nothing else: the slots, with no
.instanceOf, interface or static slots.newpoints the object's first word at it. A method with a body overrides the base's slot of the same name. A slot left to a method of the C++ interface (a method with no body, such as the destructor), or one no method names, is null, as a pure virtual's would be: C++ calling it crashes. A new virtual method takes the slot its@slotgives it. - The object is allocated by tslang: the vtable pointer, the C++ interface's fields, then the class's own fields, laid out like a tuple's. It may have a constructor and field initializers.
- Calls from C++ come in as C++ calls them:
thisfirst, then the arguments. A narrow result (boolean,u8, ...) is extended to 32 bits, as C++ callers expect. Declare by-value struct parameters the way the ABI passes them (see "Writing a binding"). - Lifetime. C++ keeps a raw pointer that the collector can't see. Hold the object in tslang,
for example in a global, for as long as C++ may call it. A
stringargument is C++'s buffer: copy it to keep it past the call. (The other way, astringa C++ method returns to TypeScript is copied by the call; declare the method to returnOpaquefor the pointer.) - Not yet supported:
- returning a struct (an error);
- exporting the class (an error: an importer would get the methods but not the vtable);
- type information: C++ must not
dynamic_castortypeidthe object; - throwing from a method C++ calls.
tslang/test/tester/tests/00cppvtable_class.tschecks the slots, fields,extendsandabstractagainst a vtable made by hand, andtests/00cpp_slot.tsslots out of order, skipped slots and a method of TypeScript's own. Both are registered for AOT and JIT.tslang/test/tester/cppvtable/*.tsholds one compile error per rule above.test-jit-cppvtable-abichecks against code from a real C++ compiler.cppvtable/abi_host.cppis built as a shared library by the host's compiler (MSVC, or g++/clang).abi_msvc.tsorabi_itanium.tsthen:- calls its object's virtual methods, including struct returns and narrow arguments, and a non-virtual one by its mangled symbol;
- implements its
Callbackinterface (Counter), which C++ calls back, with a method of TypeScript's own beside the overrides.