🔝 Retour au Sommaire
La section précédente a mis en place l'environnement : pybind11 est installé, CMake est configuré, le module compile et se charge depuis Python. Cette section entre dans le cœur du sujet : la syntaxe des bindings — comment déclarer à pybind11 quelles fonctions, classes, méthodes, propriétés et types C++ doivent être accessibles depuis Python, et sous quelle forme.
pybind11 adopte une approche déclarative. On ne génère pas de code intermédiaire (contrairement à SWIG), on n'écrit pas de fichier de description externe (contrairement à Cython). On écrit directement du C++ dans le bloc PYBIND11_MODULE, en chaînant des appels .def(...) qui décrivent chaque élément exposé. Le compilateur C++ déduit les types à partir des pointeurs de fonctions et de méthodes, et pybind11 génère automatiquement le code de conversion.
Tout au long de cette section, on utilisera un en-tête commun dans les fichiers de binding :
#include <pybind11/pybind11.h>
#include <pybind11/stl.h> // Conversions std::vector, std::map, etc.
#include <pybind11/operators.h> // Surcharge d'opérateurs (si nécessaire)
namespace py = pybind11;
using namespace pybind11::literals; // Permet la syntaxe "name"_a pour les arguments La forme la plus simple d'un binding est l'exposition d'une fonction libre C++ :
// C++ — code métier (dans un .h/.cpp séparé)
double celsius_to_fahrenheit(double celsius) {
return celsius * 9.0 / 5.0 + 32.0;
}// Binding
PYBIND11_MODULE(tempconv, m) {
m.doc() = "Temperature conversion utilities";
m.def("celsius_to_fahrenheit", &celsius_to_fahrenheit,
"Convert a temperature from Celsius to Fahrenheit");
}# Python
import tempconv
print(tempconv.celsius_to_fahrenheit(100.0)) # 212.0 Le premier argument de m.def() est le nom visible en Python (il peut différer du nom C++). Le second est un pointeur vers la fonction. Le troisième (optionnel) est la docstring.
Par défaut, les arguments d'une fonction exposée n'ont pas de nom côté Python — l'autocomplétion affiche arg0, arg1, etc. Pour fournir des noms significatifs :
m.def("celsius_to_fahrenheit", &celsius_to_fahrenheit,
py::arg("celsius"),
"Convert a temperature from Celsius to Fahrenheit");Avec le using namespace pybind11::literals, on peut utiliser la syntaxe abrégée "name"_a :
m.def("celsius_to_fahrenheit", &celsius_to_fahrenheit,
"celsius"_a,
"Convert a temperature from Celsius to Fahrenheit");En Python, les deux formes permettent l'appel nommé :
tempconv.celsius_to_fahrenheit(celsius=37.0) # 98.6Les valeurs par défaut des arguments C++ ne sont pas accessibles via un pointeur de fonction — pybind11 ne peut pas les déduire automatiquement. Il faut les redéclarer dans le binding :
// C++
std::string format_value(double value, int precision = 2, bool scientific = false);// Binding — les valeurs par défaut doivent être répétées
m.def("format_value", &format_value,
"value"_a,
"precision"_a = 2,
"scientific"_a = false,
"Format a numeric value as a string");# Python — les trois formes sont valides
format_value(3.14159)
format_value(3.14159, precision=4)
format_value(3.14159, scientific=True) Le C++ permet la surcharge de fonctions — plusieurs fonctions portant le même nom avec des signatures différentes. Un pointeur brut &compute est ambigu si plusieurs surcharges existent. pybind11 fournit le cast explicite pour lever l'ambiguïté :
// C++ — deux surcharges
double compute(double x);
double compute(double x, double y); // Binding — cast explicite vers la bonne signature
m.def("compute", static_cast<double(*)(double)>(&compute),
"x"_a);
m.def("compute", static_cast<double(*)(double, double)>(&compute),
"x"_a, "y"_a);Python gère naturellement la dispatch par nombre d'arguments :
compute(3.0) # Appelle compute(double)
compute(3.0, 4.0) # Appelle compute(double, double) pybind11 essaie chaque surcharge dans l'ordre d'enregistrement et choisit la première qui correspond aux types des arguments passés. En cas d'ambiguïté (par exemple int vs double pour un argument Python int), l'ordre d'enregistrement détermine la priorité.
💡 C++14 et ultérieur :
py::overload_castoffre une syntaxe plus lisible questatic_cast:
m.def("compute", py::overload_cast<double>(&compute), "x"_a);
m.def("compute", py::overload_cast<double, double>(&compute), "x"_a, "y"_a); On peut exposer directement une lambda C++ comme fonction Python. C'est utile pour adapter une interface C++ qui ne correspond pas exactement à ce qu'on veut exposer en Python :
m.def("version", []() {
return std::string("1.0.0");
});
// Adapter une API existante
m.def("quick_compute", [](double x) {
// Appelle une API C++ interne complexe avec des paramètres par défaut raisonnables
Config cfg = Config::default_config();
return internal_compute(x, cfg);
}, "x"_a, "Simplified compute with default configuration");Les lambdas sont aussi le mécanisme principal pour exposer des fonctions dont la signature C++ n'est pas directement traduisible en Python (paramètres de sortie par pointeur, valeurs de retour multiples, etc.) :
// C++ — Signature typique C avec paramètres de sortie
bool parse_coordinates(const char* input, double* lat, double* lon);// Binding — La lambda adapte l'interface
m.def("parse_coordinates", [](const std::string& input) {
double lat, lon;
bool ok = parse_coordinates(input.c_str(), &lat, &lon);
if (!ok) throw std::runtime_error("Invalid coordinate string");
return std::make_tuple(lat, lon); // Retourne un tuple Python
}, "input"_a, "Parse a coordinate string, returns (lat, lon)");lat, lon = parse_coordinates("48.8566,2.3522")// C++ — code métier
class Sensor {
public:
explicit Sensor(std::string id) : id_(std::move(id)), value_(0.0) {}
void record(double measurement) { value_ = measurement; }
double value() const { return value_; }
const std::string& id() const { return id_; }
private:
std::string id_;
double value_;
};// Binding
PYBIND11_MODULE(sensors, m) {
py::class_<Sensor>(m, "Sensor")
.def(py::init<std::string>(), "id"_a)
.def("record", &Sensor::record, "measurement"_a)
.def("value", &Sensor::value)
.def("id", &Sensor::id);
}s = Sensor("temp_01")
s.record(22.5)
print(s.value()) # 22.5
print(s.id()) # "temp_01" Anatomie de py::class_<Sensor>(m, "Sensor") :
- Le paramètre template
Sensorest le type C++ à exposer. mest le module dans lequel la classe est enregistrée."Sensor"est le nom visible en Python (peut différer du nom C++).- L'objet retourné supporte le chaînage de
.def(...)pour déclarer constructeurs, méthodes et propriétés.
py::init<...>() déclare un constructeur. Les paramètres template correspondent aux types des arguments du constructeur C++ :
py::class_<Sensor>(m, "Sensor")
// Constructeur avec un argument std::string
.def(py::init<std::string>(), "id"_a)
// Plusieurs constructeurs (surcharge)
.def(py::init<std::string, double>(), "id"_a, "initial_value"_a);Pour des constructeurs complexes (factory, paramètres non triviaux), on utilise py::init(lambda) :
py::class_<Engine>(m, "Engine")
.def(py::init([](const std::string& config_path) {
auto cfg = Config::load_from_file(config_path);
return std::make_unique<Engine>(std::move(cfg));
}), "config_path"_a, "Create an engine from a configuration file");Les méthodes sont exposées avec .def("nom_python", &Classe::methode). Le premier argument est le nom visible en Python, le second est un pointeur de méthode :
py::class_<Sensor>(m, "Sensor")
.def(py::init<std::string>(), "id"_a)
// Méthode non-const
.def("record", &Sensor::record, "measurement"_a)
// Méthode const
.def("value", &Sensor::value)
// Nom Python différent du nom C++
.def("get_identifier", &Sensor::id, "Return the sensor identifier");Comme pour les fonctions libres, la surcharge de méthodes nécessite un cast explicite ou py::overload_cast :
class Logger {
public:
void log(const std::string& message);
void log(const std::string& message, int severity);
};py::class_<Logger>(m, "Logger")
.def(py::init<>())
.def("log", py::overload_cast<const std::string&>(&Logger::log),
"message"_a)
.def("log", py::overload_cast<const std::string&, int>(&Logger::log),
"message"_a, "severity"_a);Pour lever l'ambiguïté entre une surcharge const et une surcharge non-const de la même signature, ajouter py::const_ :
class Container {
public:
int& at(size_t index); // non-const
const int& at(size_t index) const; // const
};// Exposer la version const
.def("at", py::overload_cast<size_t>(&Container::at, py::const_), "index"_a)Plutôt que d'exposer un getter comme méthode (sensor.id()), on peut l'exposer comme propriété Python (sensor.id) — plus idiomatique :
py::class_<Sensor>(m, "Sensor")
.def(py::init<std::string>(), "id"_a)
.def_property_readonly("id", &Sensor::id)
.def_property_readonly("value", &Sensor::value);s = Sensor("temp_01")
s.record(22.5)
print(s.id) # "temp_01" — accès comme attribut, pas comme méthode
print(s.value) # 22.5
s.id = "other" # AttributeError: can't set attribute — lecture seule .def_property prend un getter et un setter :
class Config {
public:
int timeout() const { return timeout_; }
void set_timeout(int ms) { timeout_ = ms; }
private:
int timeout_ = 5000;
};py::class_<Config>(m, "Config")
.def(py::init<>())
.def_property("timeout", &Config::timeout, &Config::set_timeout);cfg = Config()
print(cfg.timeout) # 5000
cfg.timeout = 3000 # Appelle set_timeout(3000)
print(cfg.timeout) # 3000 Pour les structures simples avec des membres publics, def_readwrite et def_readonly évitent d'écrire des getters/setters :
struct Point {
double x = 0.0;
double y = 0.0;
double z = 0.0;
};py::class_<Point>(m, "Point")
.def(py::init<>())
.def_readwrite("x", &Point::x)
.def_readwrite("y", &Point::y)
.def_readwrite("z", &Point::z);p = Point()
p.x = 1.0
p.y = 2.0
print(p.x, p.y, p.z) # 1.0 2.0 0.0 Pour un membre const ou qu'on veut protéger en écriture :
.def_readonly("z", &Point::z)class Registry {
public:
static Registry& instance();
static int count();
void register_item(const std::string& name);
static constexpr int MAX_ITEMS = 1024;
private:
Registry() = default;
};py::class_<Registry>(m, "Registry")
// Méthode statique
.def_static("instance", &Registry::instance, py::return_value_policy::reference)
.def_static("count", &Registry::count)
// Méthode d'instance
.def("register_item", &Registry::register_item, "name"_a)
// Attribut statique en lecture seule
.def_readonly_static("MAX_ITEMS", &Registry::MAX_ITEMS);reg = Registry.instance()
print(Registry.count()) # 0
reg.register_item("sensor_a")
print(Registry.count()) # 1
print(Registry.MAX_ITEMS) # 1024 💡 Le
py::return_value_policy::referencepourinstance()indique à pybind11 de ne pas prendre la propriété de l'objet retourné — le singleton est géré côté C++. Les return value policies sont détaillées en section 43.2.3.
enum class LogLevel {
Debug = 0,
Info = 1,
Warning = 2,
Error = 3,
Fatal = 4
};py::enum_<LogLevel>(m, "LogLevel")
.value("Debug", LogLevel::Debug)
.value("Info", LogLevel::Info)
.value("Warning", LogLevel::Warning)
.value("Error", LogLevel::Error)
.value("Fatal", LogLevel::Fatal)
.export_values(); // Optionnel : rend les valeurs accessibles sans préfixefrom sensors import LogLevel
level = LogLevel.Warning
print(level) # LogLevel.Warning
print(int(level)) # 2
print(level == LogLevel.Warning) # True L'appel .export_values() rend les valeurs accessibles directement dans le module (sensors.Warning en plus de sensors.LogLevel.Warning). Pour les enums scopées C++, il est généralement préférable de ne pas appeler .export_values() afin de préserver le scoping.
On peut déclarer l'enum à l'intérieur du scope d'une classe Python :
py::class_<Logger> logger_class(m, "Logger");
py::enum_<LogLevel>(logger_class, "Level")
.value("Debug", LogLevel::Debug)
.value("Info", LogLevel::Info)
.value("Warning", LogLevel::Warning)
.value("Error", LogLevel::Error)
.value("Fatal", LogLevel::Fatal);
logger_class
.def(py::init<>())
.def("set_level", &Logger::set_level, "level"_a);logger = Logger()
logger.set_level(Logger.Level.Warning) Pour exposer une hiérarchie de classes, on indique la classe parente dans les paramètres template de py::class_ :
// C++
class Shape {
public:
virtual ~Shape() = default;
virtual double area() const = 0;
virtual std::string name() const = 0;
};
class Circle : public Shape {
public:
explicit Circle(double radius) : radius_(radius) {}
double area() const override { return 3.14159265 * radius_ * radius_; }
std::string name() const override { return "Circle"; }
double radius() const { return radius_; }
private:
double radius_;
};
class Rectangle : public Shape {
public:
Rectangle(double w, double h) : width_(w), height_(h) {}
double area() const override { return width_ * height_; }
std::string name() const override { return "Rectangle"; }
private:
double width_, height_;
};// Binding
py::class_<Shape>(m, "Shape")
.def("area", &Shape::area)
.def("name", &Shape::name);
// Le second paramètre template indique la classe parente
py::class_<Circle, Shape>(m, "Circle")
.def(py::init<double>(), "radius"_a)
.def_property_readonly("radius", &Circle::radius);
py::class_<Rectangle, Shape>(m, "Rectangle")
.def(py::init<double, double>(), "width"_a, "height"_a);Grâce à la déclaration d'héritage, le polymorphisme fonctionne naturellement en Python :
def print_shape_info(shape):
"""Accepte n'importe quel Shape"""
print(f"{shape.name()}: area = {shape.area():.2f}")
c = Circle(5.0)
r = Rectangle(3.0, 4.0)
print_shape_info(c) # Circle: area = 78.54
print_shape_info(r) # Rectangle: area = 12.00 Une fonction C++ qui accepte un Shape* ou un const Shape& acceptera un Circle ou un Rectangle depuis Python — la vtable C++ est correctement résolue.
Par défaut, les classes exposées via pybind11 ne peuvent pas être sous-classées en Python — les appels virtuels ne seraient pas redirigés vers les méthodes Python. Pour activer cette possibilité, il faut définir une classe trampoline :
// Trampoline : redirige les appels virtuels vers Python
class PyShape : public Shape {
public:
// Hérite des constructeurs
using Shape::Shape;
// Override de chaque méthode virtuelle
double area() const override {
PYBIND11_OVERRIDE_PURE(
double, // Type de retour
Shape, // Classe parente
area // Nom de la méthode (sans parenthèses)
);
}
std::string name() const override {
PYBIND11_OVERRIDE_PURE(
std::string,
Shape,
name
);
}
};// Binding avec la classe trampoline
py::class_<Shape, PyShape>(m, "Shape")
.def(py::init<>())
.def("area", &Shape::area)
.def("name", &Shape::name);Désormais, on peut sous-classer Shape en Python :
class Triangle(Shape):
def __init__(self, base, height):
super().__init__()
self.base = base
self.height = height
def area(self):
return 0.5 * self.base * self.height
def name(self):
return "Triangle"
t = Triangle(6.0, 4.0)
print_shape_info(t) # Triangle: area = 12.00 L'appel t.area() depuis C++ (par exemple dans une fonction qui prend un const Shape&) passe par le trampoline, qui redirige vers la méthode Python Triangle.area.
Les macros disponibles :
| Macro | Usage |
|---|---|
PYBIND11_OVERRIDE_PURE |
Méthode virtuelle pure (= 0) — le sous-classement Python est obligatoire |
PYBIND11_OVERRIDE |
Méthode virtuelle non-pure — retombe sur l'implémentation C++ si non overridée en Python |
pybind11 supporte l'héritage multiple en listant toutes les classes parentes dans les paramètres template :
class Serializable {
public:
virtual ~Serializable() = default;
virtual std::string serialize() const = 0;
};
class SerializableCircle : public Circle, public Serializable {
public:
using Circle::Circle;
std::string serialize() const override {
return "{\"type\":\"circle\",\"radius\":" + std::to_string(radius()) + "}";
}
};py::class_<Serializable>(m, "Serializable")
.def("serialize", &Serializable::serialize);
py::class_<SerializableCircle, Circle, Serializable>(m, "SerializableCircle")
.def(py::init<double>(), "radius"_a);pybind11 permet d'implémenter les méthodes spéciales Python (__repr__, __str__, __len__, __getitem__, etc.) pour que les objets C++ se comportent de manière idiomatique en Python.
py::class_<Sensor>(m, "Sensor")
.def(py::init<std::string>(), "id"_a)
// __repr__ : représentation non ambiguë (pour le debug)
.def("__repr__", [](const Sensor& s) {
return "<Sensor id='" + s.id() + "' value=" +
std::to_string(s.value()) + ">";
})
// __str__ : représentation lisible (pour l'affichage)
.def("__str__", [](const Sensor& s) {
return "Sensor(" + s.id() + ")";
});s = Sensor("temp_01")
repr(s) # "<Sensor id='temp_01' value=0.000000>"
str(s) # "Sensor(temp_01)"
print(s) # "Sensor(temp_01)" — print() utilise __str__ class DataSeries {
public:
void append(double v) { data_.push_back(v); }
size_t size() const { return data_.size(); }
double at(size_t i) const {
if (i >= data_.size()) throw std::out_of_range("Index out of range");
return data_[i];
}
const std::vector<double>& data() const { return data_; }
private:
std::vector<double> data_;
};py::class_<DataSeries>(m, "DataSeries")
.def(py::init<>())
.def("append", &DataSeries::append, "value"_a)
// Protocole de séquence
.def("__len__", &DataSeries::size)
.def("__getitem__", [](const DataSeries& ds, int64_t index) {
// Support des index négatifs (convention Python)
if (index < 0) index += static_cast<int64_t>(ds.size());
if (index < 0 || static_cast<size_t>(index) >= ds.size())
throw py::index_error("Index " + std::to_string(index) + " out of range");
return ds.at(static_cast<size_t>(index));
})
.def("__iter__", [](const DataSeries& ds) {
return py::make_iterator(ds.data().begin(), ds.data().end());
}, py::keep_alive<0, 1>()) // Garde l'objet vivant pendant l'itération
// Bonus : __contains__
.def("__contains__", [](const DataSeries& ds, double value) {
const auto& d = ds.data();
return std::find(d.begin(), d.end(), value) != d.end();
});ds = DataSeries()
ds.append(1.0)
ds.append(2.0)
ds.append(3.0)
len(ds) # 3
ds[0] # 1.0
ds[-1] # 3.0 — index négatif
2.0 in ds # True
for val in ds:
print(val) # 1.0 2.0 3.0Le py::keep_alive<0, 1>() dans __iter__ est une policy de durée de vie : elle garantit que l'objet DataSeries (argument 1) reste vivant tant que l'itérateur (retour, index 0) existe. Sans cette policy, le garbage collector Python pourrait détruire l'objet pendant l'itération.
pybind11 fournit des helpers dans <pybind11/operators.h> pour exposer les opérateurs C++ de manière concise :
#include <pybind11/operators.h>
class Vec2 {
public:
double x, y;
Vec2(double x, double y) : x(x), y(y) {}
Vec2 operator+(const Vec2& other) const { return {x + other.x, y + other.y}; }
Vec2 operator*(double scalar) const { return {x * scalar, y * scalar}; }
bool operator==(const Vec2& other) const { return x == other.x && y == other.y; }
};py::class_<Vec2>(m, "Vec2")
.def(py::init<double, double>(), "x"_a, "y"_a)
.def_readwrite("x", &Vec2::x)
.def_readwrite("y", &Vec2::y)
// Opérateurs via py::self
.def(py::self + py::self) // __add__
.def(py::self * double()) // __mul__
.def(py::self == py::self) // __eq__
.def("__repr__", [](const Vec2& v) {
return "Vec2(" + std::to_string(v.x) + ", " + std::to_string(v.y) + ")";
});a = Vec2(1.0, 2.0)
b = Vec2(3.0, 4.0)
c = a + b # Vec2(4.0, 6.0)
d = a * 2.0 # Vec2(2.0, 4.0)
a == Vec2(1.0, 2.0) # True Au-delà des conversions automatiques (section 43.2), pybind11 permet d'enregistrer des exceptions C++ personnalisées comme exceptions Python :
// C++ — Exception custom
class EngineError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
int error_code() const { return code_; }
void set_code(int c) { code_ = c; }
private:
int code_ = 0;
};// Binding — Enregistrement comme exception Python
// Le troisième argument est la classe parente Python (PyExc_RuntimeError)
static py::exception<EngineError> exc_engine(m, "EngineError", PyExc_RuntimeError);
// Si l'exception C++ a des attributs supplémentaires, on utilise un handler
py::register_exception_translator([](std::exception_ptr p) {
try {
if (p) std::rethrow_exception(p);
} catch (const EngineError& e) {
PyErr_SetString(exc_engine.ptr(), e.what());
}
});try:
engine.execute("invalid_command")
except EngineError as e:
print(f"Engine failed: {e}") # Fonctionne comme une RuntimeError
isinstance(e, RuntimeError) # True — héritage préservéPour les bibliothèques volumineuses, pybind11 permet d'organiser les bindings en sous-modules Python :
PYBIND11_MODULE(mylib, m) {
m.doc() = "Main library module";
// Sous-module "io"
auto io = m.def_submodule("io", "Input/Output utilities");
py::class_<FileReader>(io, "FileReader")
.def(py::init<std::string>(), "path"_a)
.def("read_all", &FileReader::read_all);
// Sous-module "math"
auto math = m.def_submodule("math", "Mathematical operations");
py::class_<Vec2>(math, "Vec2")
.def(py::init<double, double>(), "x"_a, "y"_a);
// Sous-module "math.linalg" (imbrication)
auto linalg = math.def_submodule("linalg", "Linear algebra");
linalg.def("dot", &dot_product, "a"_a, "b"_a);
}from mylib.io import FileReader
from mylib.math import Vec2
from mylib.math.linalg import dot Le code de binding ne doit jamais être mélangé avec le code métier C++. La bibliothèque C++ doit compiler et fonctionner sans aucune dépendance à pybind11 :
src/
mylib/
processor.h # Pur C++ — aucune mention de pybind11
processor.cpp # Pur C++ — aucune mention de pybind11
python/
bindings.cpp # Dépend de pybind11 et de mylib
Pour les projets volumineux, chaque domaine a son propre fichier de binding (voir aussi la section 43.2.1 sur la compilation parallèle) :
// python/bind_core.cpp
#include <pybind11/pybind11.h>
#include "mylib/core.h"
void bind_core(py::module_& m) {
py::class_<Engine>(m, "Engine")
.def(py::init<>())
.def("run", &Engine::run);
}// python/bind_io.cpp
#include <pybind11/pybind11.h>
#include "mylib/io.h"
void bind_io(py::module_& m) {
auto io = m.def_submodule("io");
py::class_<FileReader>(io, "FileReader")
.def(py::init<std::string>(), "path"_a);
}// python/bindings.cpp — Point d'entrée unique
#include <pybind11/pybind11.h>
void bind_core(py::module_& m);
void bind_io(py::module_& m);
PYBIND11_MODULE(mylib, m) {
bind_core(m);
bind_io(m);
}Ce pattern garantit la compilation parallèle, la compilation incrémentale, et la lisibilité dans les projets à plusieurs dizaines de classes.
| Forme | Usage |
|---|---|
m.def("name", &func) |
Fonction libre |
.def(py::init<Args...>()) |
Constructeur |
.def("name", &Class::method) |
Méthode d'instance |
.def_static("name", &Class::static_method) |
Méthode statique |
.def_property_readonly("name", &Class::getter) |
Propriété lecture seule |
.def_property("name", &Class::getter, &Class::setter) |
Propriété lecture-écriture |
.def_readwrite("name", &Class::member) |
Accès direct membre public |
.def_readonly("name", &Class::member) |
Accès direct lecture seule |
.def_readonly_static("name", &Class::static_member) |
Attribut statique lecture seule |
.def("__repr__", lambda) |
Méthode spéciale Python |
.def(py::self + py::self) |
Surcharge d'opérateur |
📎 La section suivante (43.2.3) approfondit la gestion des types : passage par référence vs copie, buffers NumPy, return value policies, et les subtilités de la gestion mémoire à la frontière C++/Python.