DEV Community

Cover image for Stop Writing Boilerplate Wrappers for C++ Bindings — Meet polybind
Mohammad Raziei
Mohammad Raziei

Posted on

Stop Writing Boilerplate Wrappers for C++ Bindings — Meet polybind

If you've spent time writing Python bindings for a C++ library with template
classes, you know the pattern. You expose Box<int32_t> as Box_int32,
Box<double> as Box_float64, and then you spend an afternoon writing the
same dispatch logic in Python to pretend they're one class. And then you do
it again for Matrix, Tensor, Pair, and every other template in the
library.

This post is about why that happens, and how one command fixes it.

The root of the problem

C++ templates don't exist at runtime — they're resolved at compile time.
When you use nanobind, pybind11, or Cython to expose a Box<T>, the binding
layer has no generic T to offer Python. You register each specialisation
separately:

// nanobind
nb::class_<Box<int32_t>>(m, "_Box__int32")
    .def(nb::init<int32_t>())
    .def("value", &Box<int32_t>::value);

nb::class_<Box<double>>(m, "_Box__float64")
    .def(nb::init<double>())
    .def("value", &Box<double>::value);
Enter fullscreen mode Exit fullscreen mode

So Python gets two completely separate classes. isinstance, type(), and
every type-check in your codebase sees them as unrelated:

b = _mylib._Box__int32(10)
isinstance(b, _mylib._Box__float64)  # False
type(b) is _mylib._Box__int32        # True
Enter fullscreen mode Exit fullscreen mode

The usual fix is a hand-written dispatcher:

_MAP = {int: _mylib._Box__int32, float: _mylib._Box__float64}

class Box:
    def __new__(cls, val):
        return _MAP[type(val)](val)
Enter fullscreen mode Exit fullscreen mode

This breaks type(b) is Box, loses docstrings, kills IDE autocomplete, and
needs to be written again for every template class in the project.

Multi-parametric templates make it worse

When you have Pair<T1, T2>, you now need a two-dimensional dispatch table.
Pair__float64__int32, Pair__int32__int64, maybe more combinations. The
hand-written approach becomes a maintenance problem very quickly.

The polybind approach

polybind solves this by reading the .pyi stub your binding tool already
produces and generating the wrapper for you.

The naming convention is intentional: use double underscores to separate
template parameters in your class names, following numpy scalar type names:

_Box__int32           →  Box<int32_t>
_Box__float64         →  Box<double>
_Pair__float64__int32 →  Pair<double, int32_t>
Enter fullscreen mode Exit fullscreen mode

Then run:

python -m nanobind.stubgen -m _mylib -o _mylib.pyi
polybind _mylib.pyi
Enter fullscreen mode Exit fullscreen mode

That's it. mylib.py is written and ready to import.

What you get

from mylib import Box, Pair

# single-type: auto-detect from argument
b_int   = Box(42)
b_float = Box(3.14)
b_str   = Box("hello")

type(b_int) is Box          # True  ✅
isinstance(b_float, Box)    # True  ✅
b_int.value()               # 42    ✅

# multi-type: auto-detect from both arguments
p = Pair(3.14, 5)
p.first()                   # 3.14
p.second()                  # 5
type(p) is Pair             # True

# explicit dtype when auto-detect isn't enough
Box(1, dtypes=["float64"])
Pair(1, 2, dtypes=["int32", "int64"])

# partial dict — specify what matters, rest is auto
Pair(1.0, 2, dtypes={"first": "float64"})

# subscript to get the raw C++ class
Box["int32"]                # → _mylib._Box__int32
Pair[("float64", "int32")]  # → _mylib._Pair__float64__int32
Enter fullscreen mode Exit fullscreen mode

How the type map works

The generated wrapper stores a map keyed by suffix tuples:

_type_map_box: ClassVar[Dict[tuple, type]] = {
    ('int32',):   _mylib._Box__int32,
    ('float64',): _mylib._Box__float64,
    ('str_',):    _mylib._Box__str_,
}

_type_map_pair: ClassVar[Dict[tuple, type]] = {
    ('float64', 'int32'): _mylib._Pair__float64__int32,
    ('int32',   'int64'): _mylib._Pair__int32__int64,
}
Enter fullscreen mode Exit fullscreen mode

__new__ maps argument types to suffix tuples via _NUMPY_TYPE_MAP and
looks them up. For Pair(3.14, 5):

type(3.14).__name__ == 'float'    suffix 'float64'
type(5).__name__    == 'int'      suffix 'int32'
key = ('float64', 'int32')        _Pair__float64__int32
Enter fullscreen mode Exit fullscreen mode

Binding-method agnostic

polybind never imports your C++ module at generation time. It only reads the
.pyi stub — plain text that every binding tool can produce:

Tool Stub command
nanobind python -m nanobind.stubgen -m _mylib
pybind11 pybind11-stubgen _mylib
Cython stubgen via mypy

Switch tools tomorrow — the polybind command stays the same.

What else is preserved

Beyond dispatch, polybind also:

  • Reproduces @staticmethod, @classmethod, @property decorators from the stub — returning wrapper instances, not raw C++ objects
  • Carries docstrings through and rewrites variant class names (_Box__int32Box)
  • Generates full type annotations using typing.Union for method signatures
  • Accepts np.dtype objects in the dtypes argument if numpy is installed
  • Registers all C++ classes as virtual subclasses of the wrapper via ABC.register(), so isinstance(raw_cpp_obj, Box) is also True

A note on when dtypes is required

polybind infers template types from constructor arguments by matching Python
type annotations. If a template parameter isn't represented in the
constructor (a tag-dispatch pattern, for example), auto-detection isn't
possible. The generated wrapper will raise a clear TypeError at runtime
asking for an explicit dtypes list.

This is a deliberate design choice: fail loudly at construction time rather
than silently select the wrong variant.

Getting started

pip install polybind
polybind _mylib.pyi          # generates mylib.py
polybind _mylib.pyi --dry-run  # preview without writing
Enter fullscreen mode Exit fullscreen mode

Source and docs: github.com/mohammadraziei/polybind

Feedback is very welcome, especially from projects using less common binding
tools or unusual template patterns. Open an issue with a sample .pyi and
I'll make sure it's handled correctly.

Top comments (0)