Use pybind11 for a detailed but simple example

Introduction

Pybind11 can be used to import C++ libraries into Python. Here I create a C++ project, the robot, to resemble real-life software as much as possible. Then, I write a piece of code to compile the project as a Python module. The key point is to only expose end-user (API) classes and functions.

Goal

A detailed C++ project, the robot, is created, then its API is exposed to Python.

In the example we see:

  • C++ classes and their components are re-defined as python classes:
    • constructors
    • methods
    • public data members
    • setter and getter (property)
  • A class template is also exposed.
  • Polymorphic objects are injected into constructors.
  • C++ functions are converted to Python functions
  • Polymorphic objects are passed to functions in Python
  • STL containers data are received in Python

Final Code

The final code is on GitHub. It is explained in the following sections.

Robot Code

The code creates robots and asks them to do tasks.

An interface for the robot is:

class IRobot
{
public:
  std::string name;
  ISpeech &speech;

  IRobot(std::string name_, ISpeech &speech_) : 
    name(name_), speech(speech_){};

  virtual void Walk() = 0;
  void Talk() { speech.Talk(); }
};

Where we have two different speech devices, V1 and V2:

class ISpeech
{
protected:
    std::string model;

public:
    ISpeech(std::string model_) : model(model_){};

    virtual void Talk() = 0;
};

class SpeechV1 : public ISpeech
{
public:
    SpeechV1(std::string model_) : ISpeech(model_){};
    void Talk() override { 
        std::cout << "Talking with Speech version 1.0. \n"; }
};

class SpeechV2 : public ISpeech
{
public:
    SpeechV2(std::string model_) : ISpeech(model_){};
    void Talk() override { 
        std::cout << "Talking with Speech version 2.0. \n"; }
};

Robot T800 specs:

  • A polymorphic class made of IRobot
  • Has constructor accepts ISpeech objects
  • has Public member: year
  • Has virtual function: Walk()
  • Provides a complex STL container: GetData()
class T800 : public IRobot
{

public:
    int year;
    T800(std::string name, int year_, ISpeech &speech_) : IRobot(name, speech_), year(year_){};
    virtual void Walk() override{std::cout << "T-800 is walking...\n";};
    auto GetData(){
        std::vector<std::tuple<std::string,double>> data;
        data.push_back(std::make_tuple("book", 1.5));
        data.push_back(std::make_tuple("table", 2.5));
        data.push_back(std::make_tuple("wall", 3.5));
        return data;
    }
};

And Robot T1000 specs:

  • is a class template
  • is a polymorphic class made of IRobot
  • Has constructor accepts ISpeech objects
  • Has property Height
  • Has virtual function Walk
template <typename T>
class T1000 : public IRobot
{
    T height;

public:
    T1000(std::string name_, T height_, ISpeech &speech_)
        : IRobot(name_, speech_), height(height_){};

    const auto &GetHeight() { return height; }
    auto SetHeight(T height_) { height = height_ ;}
    virtual void Walk() override { std::cout << "T-1000 is walking...\n"; };
};

And finally we have a helper function which accepts any robot:

void Move(IRobot& robot){
    std::cout<< robot.name <<"\n";
    robot.Walk();
}

pybind11 binding

The classes and their members are declared in the following way:

PYBIND11_MODULE(moduleName, m)
{
  // define all classes
  py::class_<NameOfClass, ClassInterface>(m, "NameOfClass")
        .def(py::init<type1, type2, type3>()) // constructor
        .def("aFunction", &NameOfClass::aFunction)
        .def_readwrite("aPublicMember", &NameOfClass::aPublicMember);

  // define all standalone functions
  m.def("StandAloneFunction", &StandAloneFunction);
}
  • moduleName: the name of the module in Python
  • m : pybind11 handle, accept it as it is
  • NameOfClass: the name of the class to be exposed to Python
  • ClassInterface: the interface or parent of the class. It is not necessary, if polymorphism is not needed to be captured in Python.
  • type1, typ2,…: the type of arguments to the constructor of the class
  • aFunction: name of a function member of the class
  • aPublicMember: a public member of the class

We can also declare functions that don’t belong to any class:

PYBIND11_MODULE(moduleName, m)
{
  // define all classes
  // ...

  // define all standalone functions
  m.def("StandAloneFunction", &StandAloneFunction);
}

Robot to Python

Now we can declare robot code to Python. I create a folder pythonApi outside src folder and put binding code there. In this way, the main code is not polluted.

The binding code is:

#include "../src/t800.h"
#include "../src/t1000.h"
#include "../src/helper.h"

#include "../pybind11/include/pybind11/pybind11.h"
// to convert C++ STL containers to python list, see T800.GetData()
#include "../pybind11/include/pybind11/stl.h" 

namespace py = pybind11;
using namespace std;
PYBIND11_MODULE(robot, m)
{
    // Do not add abstract class constructor
    // We are just declaring it to python. Because
    // It is an argument type in T800, T1000  constructors
    // and also an argument type of Move() function.
    py::class_<ISpeech>(m, "ISpeech");

    // Add the base class to work polymorphism.
    // For example T800 constructed with ISpeech, if
    // we don't declare it here, python doesn't allow
    // injectign SpeechV1 to T800 constructor.
    py::class_<SpeechV1, ISpeech>(m, "SpeechV1")
        .def(py::init<string>()); //Constructor

    py::class_<SpeechV2, ISpeech>(m, "SpeechV2")
        .def(py::init<string>()); // Constructor

    py::class_<IRobot>(m, "IRobot"); // Abstract, do not add constructor
    
    py::class_<T800, IRobot>(m, "T800")
        .def(py::init<string, int, ISpeech &>()) // constructor
        .def("Walk", &T800::Walk)
        .def("Talk", &T800::Talk)
        .def("GetData", &T800::GetData)
        // read-write public data memeber
        // you can use def_readonly as well.
        .def_readwrite("year", &T800::year); 

    using T = double;
    py::class_<T1000<T>, IRobot>(m, "T1000")
        .def(py::init<string, T, ISpeech &>()) // constructor
        .def("Walk", &T1000<T>::Walk) // method
        .def("Talk", &T1000<T>::Talk) // method
        // Define property with getter and setter
        .def_property("height", &T1000<T>::GetHeight,&T1000<T>::SetHeight); 

    m.def("Move", &Move);
}

Compile

Clone pybind11

In the project folder, clone pybind11

git clone https://github.com/pybind/pybind11.git

Look at .gitignore file, pybind11 repo is ignored as we just read it.

CMake

The CMake Code is as simple as:

cmake_minimum_required(VERSION 3.1.0)
project(robot)
set (CMAKE_CXX_STANDARD 20)


add_subdirectory(pybind11)

pybind11_add_module(robot "./pythonApi/robot.cpp")

Note that name robot is consistent with the module name we use in bindings

PYBIND11_MODULE(robot, m) {/* code */}

Make

Open a terminal, in the project folder, run

mkdir build
cd build
cmake ..
make 

In the build directory, you should have a compiled module with the name similar to:

robot.cpython-39-x86_64-linux-gnu.so

Run

In a terminal being in the build directory, run

python

in Python, import the library

import robot 

and work with the classes and functions imported.

Example

There is a Python example in pythonApi/example.py to use robot module:

from robot import *

s1 = SpeechV1("model_V1_ab")
s2 = SpeechV2("model_V2_yz")

# Create robots with different Speech systems
t800 = T800("Jack", 2020, s1)
t1000 = T1000("Kate", 0, s2)

print("t800:")
# returns STL container in python list
print(t800.GetData())
t800.Talk()

print("t1000:")
t1000.Talk()

# set, get property
t1000.height=1.90
print(t1000.height)

# Moves any robot
Move(t800)
Move(t1000)

Related Tags


Latest Posts