Skip to content

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 values
  • std::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.first points to the smallest element
  • mm.second points 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.