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);
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
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)
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>
Then run:
python -m nanobind.stubgen -m _mylib -o _mylib.pyi
polybind _mylib.pyi
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
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,
}
__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
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,@propertydecorators from the stub — returning wrapper instances, not raw C++ objects - Carries docstrings through and rewrites variant class names
(
_Box__int32→Box) - Generates full type annotations using
typing.Unionfor method signatures - Accepts
np.dtypeobjects in thedtypesargument if numpy is installed - Registers all C++ classes as virtual subclasses of the wrapper via
ABC.register(), soisinstance(raw_cpp_obj, Box)is alsoTrue
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
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)