Let’s take a deeper look at binding tools. We’ll focus on pybind11 and nanobind. These are:
No dependencies required
No pre-process step
Not a custom language -- just (advanced) C++
Easy to get started with
Built up one piece at a time
Great CMake support
Intro to pybind11¶
Before we tackle a real library, let’s see the binding patterns on a couple of toy classes.
pybind11 is similar to Boost::Python, but much easier to build: it’s pure C++11 with no dependencies, no preprocessing step, and no new language to learn. It’s used in projects like SciPy, PyTorch, boost-histogram, and GooFit (including for CUDA). The main downside is that it’s a little verbose -- but that verbosity buys you a highly customizable interface.
A simple class¶
Here’s a minimal C++ class:
#pragma once
class Simple {
int x;
public:
Simple(int x) : x(x) {}
int get() const { return x; }
};
Binding it takes just a few lines:
#include <pybind11/pybind11.h>
#include "SimpleClass.hpp"
namespace py = pybind11;
PYBIND11_MODULE(simpleclass, m) {
py::class_<Simple>(m, "Simple")
.def(py::init<int>())
.def("get", &Simple::get);
}
The PYBIND11_MODULE macro defines the module -- its name must match the
compiled file. py::class_<Simple> exposes the class, py::init<int>() binds
the constructor, and each .def(...) binds a method.
More binding situations¶
A slightly richer class shows off more features -- properties and operators:
#pragma once
class Vector2D {
double x;
double y;
public:
Vector2D(double x, double y) : x(x), y(y) {}
double get_x() const { return x; }
double get_y() const { return y; }
void set_x(double val) { x = val; }
void set_y(double val) { y = val; }
Vector2D &operator+=(const Vector2D &other) {
x += other.x;
y += other.y;
return *this;
}
Vector2D operator+(const Vector2D &other) const {
return Vector2D(x + other.x, y + other.y);
}
};
And the binding code:
#include <pybind11/operators.h>
#include <pybind11/pybind11.h>
#include "VectorClass.hpp"
namespace py = pybind11;
using namespace pybind11::literals;
PYBIND11_MODULE(vectorclass, m) {
py::class_<Vector2D>(m, "Vector2D")
.def(py::init<double, double>(), "x"_a, "y"_a)
.def_property("x", &Vector2D::get_x, &Vector2D::set_x)
.def_property("y", &Vector2D::get_y, &Vector2D::set_y)
.def(py::self += py::self)
.def(py::self + py::self)
.def("__repr__", [](py::object self) {
return py::str("{0.__class__.__name__}({0.x}, {0.y})").format(self);
});
}
A few new points:
Constructors are easy, even when overloaded: just use
py::init<...>().You can name arguments with
"..."_aandusing namespace pybind11::literals.def_propertytakes a getter and a setter.The
pybind11/operators.hheader lets you bind operators withpy::self.You can bind a lambda instead of a real method -- handy for
__repr__.pybind11 provides Python types like
py::str(with methods like.format), and.attrreaches any Python attribute.
Intro to nanobind¶
nanobind is a newer library from the same original author, Wenzel Jakob. It keeps pybind11’s design but expects the code to conform to nanobind, rather than trying to support all of C++, and in exchange it compiles quicker, produces much smaller binaries, and has lower call overhead.
The API is deliberately close to pybind11, so most of what you just learned
carries straight over -- nb:: instead of py::, NB_MODULE instead of
PYBIND11_MODULE. The main differences you’ll hit when we wrap Minuit2:
STL casters are opt-in per type (
<nanobind/stl/vector.h>,<nanobind/stl/string.h>) instead of one catch-all<pybind11/stl.h>.Trampolines for overriding virtuals in Python use the
NB_TRAMPOLINEandNB_OVERRIDE_*macros.A factory constructor (a lambda rather than
nb::init<>) binds through__init__with a placementnew, instead of pybind11’spy::init([]{ ... }).
We are going to try a non-trivial project! Let’s wrap Minuit2 -- and since the two libraries are so similar, we’ll show both side by side.
Intro to Minuit2¶
Before we see it in Python, let’s see what Minuit2 is.
You should know what the C++ looks like, and know what you want the Python to look like. For now, let’s replicate the C++ experience.
For example: a simple minimizer for (should quickly find 0 as minimum), the procedure should be:
Define FCN
Setup parameters
Minimize
Print result
Define the FCN¶
We define the function to minimize by subclassing FCNBase:
#pragma once
#include <iostream>
#include <vector>
#include <Minuit2/FCNBase.h>
#include <Minuit2/FunctionMinimum.h>
#include <Minuit2/MnPrint.h>
#include <Minuit2/MnMigrad.h>
using namespace ROOT::Minuit2;
class SimpleFCN : public FCNBase {
// Always 0.5 for these sorts of fits
double Up() const override {return 0.5;}
// This computes whatever you are going to minimize
double operator()(const std::vector<double> &v) const override {
std::cout << "val = " << v.at(0) << std::endl;
return v.at(0)*v.at(0);
}
};
Run the minimizer¶
Then we set up the parameters, minimize, and print the result:
#include "SimpleFCN.hpp"
int main() {
SimpleFCN fcn;
MnUserParameters upar;
upar.Add("x", 1., 0.1);
MnMigrad migrad(fcn, upar);
FunctionMinimum min = migrad();
std::cout << min << std::endl;
}
Build configuration¶
We use CMake with FetchContent to grab Minuit2:
cmake_minimum_required(VERSION 3.26...4.4)
project(Minuit2SimpleExample LANGUAGES CXX)
include(FetchContent)
FetchContent_Declare(
Minuit2
GIT_REPOSITORY https://github.com/GooFit/Minuit2.git
GIT_TAG v6-40-02
GIT_SHALLOW TRUE
FIND_PACKAGE_ARGS
)
FetchContent_MakeAvailable(Minuit2)
add_executable(simpleminuit simpleminuit.cpp SimpleFCN.hpp)
target_link_libraries(simpleminuit PRIVATE Minuit2::Minuit2)
Build and run¶
To build it:
cmake -S . -B build
cmake --build buildAnd run:
./build/simpleminuitYou should see something like this:
val = 1
val = 1.001
val = 0.999
val = 1.0006
val = 0.999402
val = -8.23008e-11
val = 0.000345267
val = -0.000345267
val = -8.23008e-11
val = 0.000345267
val = -0.000345267
val = 6.90533e-05
val = -6.90535e-05
Valid : yes
Function calls: 13
Minimum value : 6.773427082e-21
Edm : 6.773427082e-21
Internal parameters: [ -8.230083282e-11]
Internal covariance matrix:
[[ 1]]]
External parameters:
Pos | Name | type | Value | Error +/-
0 | x | free | -8.230083282e-11 | 0.7071067812Binding Minuit2¶
The great thing about pybind11 and nanobind is that we can just bind the parts we need. There’s a lot more to Minuit2, but we don’t care. If we used an auto-binding tool (like SWIG), we’d have to work out issues for all the parts we aren’t using first.
We’ll build the same module both ways -- pick a tab to see each tool.
The main module¶
These programs are best split into a main module, which allows you to build the parts separately, with minimal header overlap, and then link it all at the end.
#include <pybind11/pybind11.h>
namespace py = pybind11;
void init_FCNBase(py::module &);
void init_MnUserParameters(py::module &);
void init_MnMigrad(py::module &);
void init_FunctionMinimum(py::module &);
PYBIND11_MODULE(minuit2, m) {
init_FCNBase(m);
init_MnUserParameters(m);
init_MnMigrad(m);
init_FunctionMinimum(m);
}
#include <nanobind/nanobind.h>
namespace nb = nanobind;
void init_FCNBase(nb::module_ &);
void init_MnUserParameters(nb::module_ &);
void init_MnMigrad(nb::module_ &);
void init_FunctionMinimum(nb::module_ &);
NB_MODULE(minuit2, m) {
init_FCNBase(m);
init_MnUserParameters(m);
init_MnMigrad(m);
init_FunctionMinimum(m);
}
Each init_* function fills in one piece of the module. We forward-declare them
here and call them in the module macro (PYBIND11_MODULE / NB_MODULE); the
definitions live in their own files.
Binding the FCN¶
The FCN is an abstract base class in C++. To let Python subclass it, we use a “trampoline” class that routes the virtual calls back into Python:
#include <pybind11/pybind11.h>
#include <pybind11/stl.h> // std::vector<double> <-> Python list conversion
#include <Minuit2/FCNBase.h>
namespace py = pybind11;
using namespace ROOT::Minuit2;
class PyFCNBase : public FCNBase {
public:
using FCNBase::FCNBase;
double operator()(const std::vector<double> &v) const override {
PYBIND11_OVERRIDE_PURE_NAME(
double, FCNBase, "__call__", operator(), v);}
double Up() const override {
PYBIND11_OVERRIDE_PURE(double, FCNBase, Up, );}
};
void init_FCNBase(py::module &m) {
py::class_<FCNBase, PyFCNBase>(m, "FCNBase")
.def(py::init<>())
.def("__call__", &FCNBase::operator())
.def("Up", &FCNBase::Up);
}
#include <nanobind/nanobind.h>
#include <nanobind/stl/vector.h> // std::vector<double> <-> Python list conversion
#include <nanobind/trampoline.h>
#include <Minuit2/FCNBase.h>
namespace nb = nanobind;
using namespace ROOT::Minuit2;
class PyFCNBase : public FCNBase {
public:
NB_TRAMPOLINE(FCNBase, 2);
double operator()(const std::vector<double> &v) const override {
NB_OVERRIDE_PURE_NAME("__call__", operator(), v);}
double Up() const override {
NB_OVERRIDE_PURE(Up);}
};
void init_FCNBase(nb::module_ &m) {
nb::class_<FCNBase, PyFCNBase>(m, "FCNBase")
.def(nb::init<>())
.def("__call__", &FCNBase::operator())
.def("Up", &FCNBase::Up);
}
PYBIND11_OVERRIDE_PURE_NAME maps C++'s operator() to Python’s __call__;
nanobind’s equivalent is NB_OVERRIDE_PURE_NAME, after declaring the trampoline
with NB_TRAMPOLINE. Including <pybind11/stl.h> -- or, for nanobind, the
per-type <nanobind/stl/vector.h> -- gives us automatic std::vector<double> ↔
list conversion.
Binding the parameters¶
MnUserParameters holds the parameters to minimize. We bind the constructor and
the two Add overloads (fixed and with an error) using py::overload_cast
(nb::overload_cast for nanobind, which also needs <nanobind/stl/string.h>
for the parameter name):
#include <pybind11/pybind11.h>
#include <Minuit2/MnUserParameters.h>
namespace py = pybind11;
using namespace ROOT::Minuit2;
void init_MnUserParameters(py::module &m) {
py::class_<MnUserParameters>(m, "MnUserParameters")
.def(py::init<>())
.def("Add", py::overload_cast<const std::string &, double>(&MnUserParameters::Add))
.def("Add", py::overload_cast<const std::string &, double, double>(&MnUserParameters::Add))
;
}
#include <nanobind/nanobind.h>
#include <nanobind/stl/string.h> // std::string <-> Python str conversion
#include <Minuit2/MnUserParameters.h>
namespace nb = nanobind;
using namespace ROOT::Minuit2;
void init_MnUserParameters(nb::module_ &m) {
nb::class_<MnUserParameters>(m, "MnUserParameters")
.def(nb::init<>())
.def("Add", nb::overload_cast<const std::string &, double>(&MnUserParameters::Add))
.def("Add", nb::overload_cast<const std::string &, double, double>(&MnUserParameters::Add))
;
}
Binding the minimizer¶
MnMigrad runs the minimization. It inherits from MnApplication, so we bind
both and declare the relationship (py::class_<MnMigrad, MnApplication>, or the
nb:: equivalent). We use a lambda for the constructor so we can take a plain
unsigned int strategy instead of an MnStrategy object: pybind11 wraps it in
py::init(...), while nanobind binds __init__ directly and placement-news
into the object. The same _a literals give named arguments with defaults, as
we saw with Vector2D:
#include <pybind11/pybind11.h>
#include <Minuit2/MnMigrad.h>
#include <Minuit2/MnApplication.h>
#include <Minuit2/MnUserParameters.h>
#include <Minuit2/FCNBase.h>
#include <Minuit2/FunctionMinimum.h>
namespace py = pybind11;
using namespace pybind11::literals;
using namespace ROOT::Minuit2;
void init_MnMigrad(py::module &m) {
py::class_<MnApplication>(m, "MnApplication")
.def("__call__",
&MnApplication::operator(),
"Minimize the function, returns a function minimum",
"maxfcn"_a = 0,
"tolerance"_a = 0.1);
py::class_<MnMigrad, MnApplication>(m, "MnMigrad")
.def(py::init([](const FCNBase &fcn, const MnUserParameters &par, unsigned int stra) {
return MnMigrad(fcn, par, MnStrategy(stra));
}),
"fcn"_a, "par"_a, "stra"_a = 1)
;
}
#include <nanobind/nanobind.h>
#include <Minuit2/MnMigrad.h>
#include <Minuit2/MnApplication.h>
#include <Minuit2/MnUserParameters.h>
#include <Minuit2/FCNBase.h>
#include <Minuit2/FunctionMinimum.h>
namespace nb = nanobind;
using namespace nanobind::literals;
using namespace ROOT::Minuit2;
void init_MnMigrad(nb::module_ &m) {
nb::class_<MnApplication>(m, "MnApplication")
.def("__call__",
&MnApplication::operator(),
"Minimize the function, returns a function minimum",
"maxfcn"_a = 0,
"tolerance"_a = 0.1);
nb::class_<MnMigrad, MnApplication>(m, "MnMigrad")
.def("__init__", [](MnMigrad *self, const FCNBase &fcn, const MnUserParameters &par, unsigned int stra) {
new (self) MnMigrad(fcn, par, MnStrategy(stra));
},
"fcn"_a, "par"_a, "stra"_a = 1)
;
}
Binding the result¶
Finally, FunctionMinimum is the result. We only need to print it, so we bind
__str__ to stream the C++ object into a string (nanobind needs
<nanobind/stl/string.h> to return it):
#include <pybind11/pybind11.h>
#include <sstream>
#include <Minuit2/MnPrint.h>
#include <Minuit2/FunctionMinimum.h>
namespace py = pybind11;
using namespace ROOT::Minuit2;
void init_FunctionMinimum(py::module &m) {
py::class_<FunctionMinimum>(m, "FunctionMinimum")
.def("__str__", [](const FunctionMinimum &self) {
std::stringstream os;
os << self;
return os.str();
})
;
}
#include <nanobind/nanobind.h>
#include <nanobind/stl/string.h> // std::string return value -> Python str
#include <sstream>
#include <Minuit2/MnPrint.h>
#include <Minuit2/FunctionMinimum.h>
namespace nb = nanobind;
using namespace ROOT::Minuit2;
void init_FunctionMinimum(nb::module_ &m) {
nb::class_<FunctionMinimum>(m, "FunctionMinimum")
.def("__str__", [](const FunctionMinimum &self) {
std::stringstream os;
os << self;
return os.str();
})
;
}
Build configuration¶
The CMake is much like before, but we use pybind11_add_module (or
nanobind_add_module) instead of a plain executable, glob the source files
together, and install the resulting module. nanobind also wants an explicit
find_package(Python ...):
cmake_minimum_required(VERSION 3.26...4.4)
project(pyminuit2 LANGUAGES CXX)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
set(CMAKE_CXX_STANDARD 17 CACHE STRING "The C++ standard to use")
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
include(FetchContent)
FetchContent_Declare(
Minuit2
GIT_REPOSITORY https://github.com/GooFit/Minuit2.git
GIT_TAG v6-40-02
GIT_SHALLOW TRUE
FIND_PACKAGE_ARGS
)
FetchContent_MakeAvailable(Minuit2)
find_package(pybind11 CONFIG REQUIRED)
file(GLOB SOURCES CONFIGURE_DEPENDS pyminuit2/*.cpp)
pybind11_add_module(minuit2 ${SOURCES})
target_link_libraries(minuit2 PRIVATE Minuit2::Minuit2)
install(TARGETS minuit2 DESTINATION .)
cmake_minimum_required(VERSION 3.26...4.4)
project(pyminuit2 LANGUAGES CXX)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
set(CMAKE_CXX_STANDARD 17 CACHE STRING "The C++ standard to use")
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
include(FetchContent)
FetchContent_Declare(
Minuit2
GIT_REPOSITORY https://github.com/GooFit/Minuit2.git
GIT_TAG v6-40-02
GIT_SHALLOW TRUE
FIND_PACKAGE_ARGS
)
FetchContent_MakeAvailable(Minuit2)
find_package(Python 3.9 REQUIRED COMPONENTS Interpreter Development.Module)
find_package(nanobind CONFIG REQUIRED)
file(GLOB SOURCES CONFIGURE_DEPENDS pyminuit2/*.cpp)
nanobind_add_module(minuit2 ${SOURCES})
target_link_libraries(minuit2 PRIVATE Minuit2::Minuit2)
install(TARGETS minuit2 DESTINATION .)
We drive the build with scikit-build-core, so we need a pyproject.toml -- just
list the right binding tool in build-system.requires:
[build-system]
requires = ["scikit-build-core>=1", "pybind11>=3"]
build-backend = "scikit_build_core.build"
[project]
name = "pyminuit2"
version = "0.0.1"
requires-python = ">=3.14"
# Rebuild the editable install when the build config or C++ sources change
[tool.uv]
cache-keys = [
{ file = "pyproject.toml" },
{ file = "CMakeLists.txt" },
{ file = "pyminuit2/**/*.cpp" },
{ file = "pyminuit2/**/*.hpp" },
]
[build-system]
requires = ["scikit-build-core>=1", "nanobind>=2"]
build-backend = "scikit_build_core.build"
[project]
name = "pyminuit2"
version = "0.0.1"
requires-python = ">=3.14"
# Rebuild the editable install when the build config or C++ sources change
[tool.uv]
cache-keys = [
{ file = "pyproject.toml" },
{ file = "CMakeLists.txt" },
{ file = "pyminuit2/**/*.cpp" },
{ file = "pyminuit2/**/*.hpp" },
]
Build and run¶
Install and run it in one step with uv:
uv run sample.pyThe Python API is identical either way, so the same sample script -- a mirror of the C++ program -- runs against both modules:
import minuit2
class SimpleFCN(minuit2.FCNBase):
def Up(self):
return 0.5
def __call__(self, v):
print("val =", v[0])
return v[0] ** 2
fcn = SimpleFCN()
upar = minuit2.MnUserParameters()
upar.Add("x", 1.0, 0.1)
migrad = minuit2.MnMigrad(fcn, upar)
minimum = migrad()
print(minimum)
You should see the same minimization output as the C++ version, ending with the
printed FunctionMinimum.