Content
In the previous section you saw the minimal mechanism: PYBIND11_MODULE + m.def(...).
Now we focus on what beginners usually find confusing: type conversion and function signatures.
The key mental model is:
Python values are dynamic objects; C++ values have static types. A binding describes the static C++ signature, and pybind11 translates Python objects into it.
We’ll use short, realistic snippets here. The full runnable code lives in the repositories, but the goal is that this page is readable on its own.
Scalars: the “it just works” case
Consider a plain C++ function:
double scale(double x, double factor) {
return factor * x;
}
Bound with:
m.def("scale", &scale, "Multiply x by factor");
Python can call it directly:
scale(3.0, 2.5) # -> 7.5
What happened? pybind11 converted Python floats into C++ double, called the function,
then converted the C++ double back into a Python float.
When conversion fails (and why that’s good)
If the Python call cannot be converted into the C++ signature, you get a Python TypeError.
That error is useful: it tells you exactly which signatures are valid. In teaching, it’s worth
triggering it once on purpose so learners get comfortable reading the message.
Making bindings “Pythonic”: keyword arguments and defaults
Researchers do not want to remember argument order for every numeric function. A small improvement is to give names and defaults in the binding layer.
namespace py = pybind11;
double smooth(double x, double alpha) {
return alpha * x;
}
m.def("smooth", &smooth,
py::arg("x"),
py::arg("alpha") = 0.5,
"Toy example with a default argument");
Now Python users can write:
smooth(2.0) # uses alpha=0.5
smooth(2.0, alpha=1.0) # keyword argument
This doesn’t change the C++ algorithm at all — it just makes the Python interface less error-prone and more readable.
Returning multiple values
Python functions commonly return multiple values as a tuple:
mn, mx = minmax(data)
C++ does not have a built-in “tuple return syntax” like Python. Instead, the standard library provides types for grouping values together.
The two most common are:
std::pair<T1, T2>— exactly two valuesstd::tuple<T1, T2, ...>— any number of values
These come from the standard headers:
#include <utility> // std::pair
#include <tuple> // std::tuple
For simple “two results” functions, std::pair is usually the cleanest choice.
Example: returning the minimum and maximum
Here is a small C++ function that returns both the minimum and maximum of a vector:
#include <utility>
#include <vector>
#include <algorithm>
std::pair<double,double> minmax(const std::vector<double>& v) {
auto mm = std::minmax_element(v.begin(), v.end());
return {*mm.first, *mm.second};
}
Let’s unpack what the code is doing.
1) The return type
std::pair<double,double>
This means: “return two doubles packaged together.” The two fields are accessible as
pair.first and pair.second on the C++ side.
2) Finding min and max efficiently
auto mm = std::minmax_element(v.begin(), v.end());
std::minmax_element (from <algorithm>) scans the vector once and returns a pair of iterators:
mm.firstpoints to the smallest elementmm.secondpoints to the largest element
Because these are iterators, you dereference them (*mm.first) to get the values.
3) Constructing the return pair
return {*mm.first, *mm.second};
This builds a std::pair<double,double> containing (min_value, max_value) using C++ list initialization.
What Python sees
If you bind the function with:
m.def("minmax", &minmax, "Return (min, max) of a vector");
then Python receives a normal tuple:
mn, mx = minmax([3.0, 1.0, 2.0])
A nice quick sanity check is:
result = minmax([3.0, 1.0, 2.0])
print(result)
print(type(result))
You should see a tuple like (1.0, 3.0) and <class 'tuple'>.
Exceptions: failing loudly is a feature
In numerical code, invalid inputs happen (empty arrays, wrong shapes, bad parameters). Don’t hide these with magic return values. Throw a C++ exception and let Python see it.
#include <stdexcept>
std::pair<double,double> minmax(const std::vector<double>& v) {
if (v.empty())
throw std::runtime_error("minmax: empty input");
auto mm = std::minmax_element(v.begin(), v.end());
return {*mm.first, *mm.second};
}
From Python this becomes an exception you can catch with try/except.
For learners, this is an important message: good bindings are not just fast — they are safe and explicit.
A research-oriented design checkpoint
Before exposing a function, ask:
- Will Python call this once per dataset, or once per scalar in a loop?
- Does the binding encourage a “vectorized” workflow (good) or per-element calls (often bad)?
- Is the interface readable months later in a notebook?
These questions matter more than syntax once your first module compiles.