Skip to content

Binding a type

Every rod goes through the same entry point — welder::welder<Rod>::weld_type<T>(m), where Rod is any of the shipped rods (welder::rods::pybind11::rod<>, welder::rods::nanobind::rod<>, welder::rods::sol2::rod, welder::rods::luabridge::rod) — which reflects T and emits its whole surface. This page covers what "whole surface" means for a class: data members, constructors, methods, and operators. The annotations and the resolution are identical across rods; only the emitted target-language surface differs. Each member obeys the resolution rule — excludes, includes, and the type's policy decide what participates.

The examples below weld one struct and show how it looks from each language. The C++ is the same; pick your tab.

In the cookbook

Recipe 01 — One of everything welds a type (fields, methods, operators, the synthesized aggregate constructor) alongside an enum, a free function and a namespace variable; Recipe 06 does the same for template instantiations.

Data members

Public data members bind as read/write attributes. (Protected members can join them — see policy::weld_protected; private members never bind.)

struct [[=welder::weld(welder::lang::py, welder::lang::lua)]]
Point {
    double x{0.0};
    double y{0.0};
};
>>> p = Point(); p.x = 3.0; p.y = 4.0
>>> p.x, p.y
(3.0, 4.0)
local p = Point(); p.x = 3.0; p.y = 4.0
print(p.x, p.y)   --> 3.0  4.0

Every bound member's type must pass the bindability gate — if the rod can't convert it to a meaningful value in the target language, you get a compile error naming the type, never a silent skip.

Constructors

welder binds:

  • the default constructor, if present;
  • each public, non-copy/non-move constructorpybind11::init<…>;
  • for a baseless aggregate, a synthesized field constructor that brace-inits it — giving Python T(f0, f1, …).

Why aggregates are special

Aggregate initialization is positional and all-or-nothing, so the synthesized constructor is only offered when every field binds.

struct [[=welder::weld(welder::lang::py, welder::lang::lua)]]
Rect {                 // an aggregate: no user ctors, no bases
    double w{0.0};
    double h{0.0};
};
>>> Rect(2.0, 3.0).w      # synthesized field constructor
2.0
print(Rect(2.0, 3.0).w)   --> 2.0   (synthesized field constructor)

Compare with a type that declares its own constructors — each public one binds:

struct [[=welder::weld(welder::lang::py, welder::lang::lua)]]
Rect {
    double w{0.0};
    double h{0.0};

    Rect() = default;
    Rect(double width, double height) : w{width}, h{height} {}
};

Parameter names → keyword arguments (Python)

When every parameter of a signature is named, welder passes the names through as py::arg, so they work as Python keyword arguments:

>>> Rect(width=2.0, height=3.0).area()
6.0

Lua has no keyword arguments, so this is a Python-only convenience; the same constructor is still callable positionally there.

Methods and static methods

Member functions bind as methods; static member functions as static/free functions on the type. Overloads are all registered on every rod — the Python rods (pybind11/nanobind) chain them, and the sol2 rod groups a name's overloads into one sol::overload(…) — so each overload dispatches on its arguments at call time.

struct [[=welder::weld(welder::lang::py, welder::lang::lua)]]
Rect {
    double w{0.0}, h{0.0};

    [[=welder::doc("The area of the rectangle.")]]
    double area() const { return w * h; }

    static Rect square(double s) { return Rect{s, s}; }
};
>>> Rect(2.0, 3.0).area()
6.0
>>> Rect.square(5.0).area()
25.0
print(Rect(2.0, 3.0):area())     --> 6.0   (method call uses `:`)
print(Rect.square(5.0):area())   --> 25.0  (static uses `.`)

Overloaded operators

A member operator binds under the target language's special method / metamethod, told apart unary vs. binary by arity. The mapping differs per language:

C++ Python C++ Python
operator+ __add__ operator== __eq__
operator- (binary) __sub__ operator- (unary) __neg__
operator* __mul__ operator[] __getitem__
operator() __call__ operator< __lt__

Arithmetic, bitwise, comparison, call and subscript operators are covered. See the Python rods page for the full table.

C++ Lua C++ Lua
operator+ __add operator== __eq
operator- (binary) __sub operator- (unary) __unm
operator* __mul operator[] __index
operator() __call operator< __lt

Lua's metamethod set is smaller and asymmetric — !=, >, >= are derived from __eq, __lt, __le, so you don't bind them. See the Lua rod page for the full table.

struct [[=welder::weld(welder::lang::py, welder::lang::lua)]]
Vec2 {
    double x{0.0}, y{0.0};
    Vec2 operator+(const Vec2& o) const { return {x + o.x, y + o.y}; }
    Vec2 operator-() const { return {-x, -y}; }        // unary → __neg__ / __unm
    bool operator==(const Vec2& o) const { return x == o.x && y == o.y; }
};

Deliberately not mapped

In-place compound assignment (operator+=) is not mapped — Python falls back to a = a + b via __add__. Nor are <=>, &&, ||, ++, --, or operator= (a special member). Free (non-member) operators aren't bound yet.

Nested types

A class or enum declared inside a welded type resolves like any other class member: the outer's policy plus the nested type's own exclude / include / only marks decide participation. A nested type never carries (or needs) a weld of its own — nested types are interface helpers of their enclosing type, and the enclosing weld is the discovery marker. Nesting recurses (Outer::Inner::Innermost), private nested types never bind, and protected ones follow policy::weld_protected.

struct [[=welder::weld(welder::lang::py, welder::lang::lua)]]
Robot {
    struct Sensor { double range{1.5}; };          // binds as Robot.Sensor
    enum class Mode { idle, active };              // binds as Robot.Mode
    struct [[=welder::mark::exclude]] Impl { };    // bound nowhere

    Sensor sensor{};                               // fine: Sensor is registered
    void set_mode(Mode m);                         // fine: Mode is registered
};

Because the nested types register with the outer, members whose signatures use them pass the bindability gate with no extra annotation — and a signature naming a nested type that does not participate (excluded, private, or forward-declared) is a hard compile error, not a runtime surprise.

The nested type is registered with the enclosing class as its scope, exactly like a hand-written py::class_<Robot::Sensor>(robot_cls, "Sensor"):

s = mymod.Robot.Sensor()        # scoped: module.Outer.Inner
Robot.Sensor.__qualname__       # "Robot.Sensor" — stubs nest too
mymod.Robot.Mode.active         # a nested IntEnum

An unscoped nested enum exports its values onto the class (Robot.quiet), mirroring C++'s Robot::quiet.

Both Lua rods expose the same access chain — mod.Robot.Sensor (sol2 places the usertype on the outer's table; LuaBridge3 moves the class table onto the outer as a static entry). The generated LuaCATS stub declares it under the dotted name (---@class mod.Robot.Sensor):

local s = mod.Robot.Sensor.new()
print(mod.Robot.Mode.active)    -- a nested value table
print(mod.Robot.quiet)          -- unscoped enum: mirrored onto the class

Excluding + welding manually

To keep a nested type out of the outer's surface but still bind it (flat, under a name of your choosing), combine mark::exclude with an explicit weld and weld it manually — the exclude removes it from the sweep, the weld keeps the gate satisfied for manual registration:

struct [[=welder::weld(welder::lang::py), =welder::mark::exclude]]
Cursor { /* … */ };  // nested inside Outer
// …
weld::weld_type<Outer::Cursor>(m, "OuterCursor");  // flat, renamed

Flattened bases keep their nested types to themselves

A nested type registers exactly once, with its declaring class. A non-welded base's members are flattened into the derived binding, but its nested types are not — two derived types flattening one mixin would register the same type twice. A flattened signature naming one therefore fails the gate until you weld the base (or trust/exclude the member).

Chaining on the returned handle

weld_type returns the rod's own class handle — pybind11's py::class_<T>, nanobind's nb::class_<T>, sol2's sol::usertype<T> — so hand-written framework registrations chain right on: welder lays the reflected boilerplate, you add what it shouldn't guess (a lambda-backed helper, a member you excluded to bind manually, a custom return-value policy):

auto cls = weld::weld_type<Rectangle>(m);          // welder binds the reflected surface
cls.def("scaled", [](const Rectangle& r, double k) // …and you weld on by hand
        { return Rectangle{r.width * k, r.height * k}; });

weld_function likewise returns the bound function object where the framework has one (the Python rods, sol2), and weld_namespace_as_submodule returns the new submodule handle — every entry point hands back its framework object so welder-generated and hand-written bindings mix freely.

Next: Enums.