Primero pasos en Pybind11

Jose Melo
8 min readJan 28, 2023

Desde hace un rato que en el equipo estamos desarrollando algunas aplicaciones financieras y siempre nos surge la necesidad de integrarlas con otras herramientas que generalmente estan en otros lenguajes. Para la integración las APIs REST son muy utiles, pero lamentablemente C++ (lenguaje que ocupamos para estas herramientas) no tiene buenas herramientas de integración, por lo que una buena alternativa es traducir C++ a otro lenguaje y avanzar desde ahí. Para lo anterior pybind11 es una buena alternativa, ya que nos permite migrar C++ a 🐍 de forma sencilla. A continuación les dejo un pequeño manual con ejemplos.

Objetivo del tutorial

Parto con un supuesto: si estamos tratando de hacer esta migración de lenguajes es por que estamos buscando utilizar herramientas que se encuentran en el otro lado ya que nos podrian ayudar en nuestro desarrollo. En este sentido, las logicas de nuestra aplicacion ya se encuentran listas, implementadas, por lo que buscamos que el framework que ocupemos para movernos de un lado al otro nos ponga las cosas de forma fácil (en general, aqui ya estamos con la cabeza frita y solo queremos que las cosas funciones). Lo bueno de pybind11 es que precisamente nos proveen un framework sencillo. Existen otras herramientas que pueden hacer lo mismo (como SWIG), que como todo, tienen ventajas y desventajas, pero que en este tutorial no abordaré, pero el lector esta invitado a investigarlas.

Setup

Lo primero que necesitaremos es obviamente un proyecto en C++. Creé un repositorio donde puede encontrar los archivos de este tutorial:

https://github.com/jmelo11/pybindtutorial

Para poder correr el tutorial necesitamos tener instalado:

  • Algún compilador de C++, estándar > 11. Tanto en windows como mac se pueden instalar los build tools de C++ para cada lenguaje y utilizar VS Code como IDE -anda super bien, pero requiere configuración-.
  • Instalar pybind11 en Python. Para esto lo clásico: pip install pybind11
  • También es recomendable utilizar CMake ya que facilita mucho el pipeline de compilación entre diversas plataformas, en particular si se quiere después trabajar con Docker.

Libreria en C++

Creamos una pequeña librería, la cual nos interesa migrar a 🐍. En este tutorial ocuparemos como ejemplo lo siguiente:

#ifndef D3C623E9_FC36_4EEF_9ED6_46B293F1B764
#define D3C623E9_FC36_4EEF_9ED6_46B293F1B764

/*
* Created on Fri Jan 27 2023
*
* Copyright (c) 2023 Jose Melo
*/

#include <map>

namespace FinLib {

using Date = double;

/***
* @brief Cashflows: A map of dates and cashflows
*/
using Cashflows = std::map<Date, double>;

enum Frequency { Monthly, Quarterly, Semiannual, Annual, Once };

/***
* @brief Bond: A bond is a fixed income security.
*
*/
class Bond {
public:
Bond(Date startDate, Date endDate, Frequency paymentFrequency,
double notional, double couponRate);

Cashflows cashflows() const;

private:
Date startDate_;
Date endDate_;
Frequency paymentFrequency_;
double notional_;
double couponRate_;
Cashflows cashflows_;
};

/***
* @brief Deposit: Inherited from Bond.
*
*/
class Deposit : public Bond {
public:
Deposit(Date startDate, Date endDate, double notional, double rate);
};

/***
* @brief Calculates the present value of a set of cashflows
*
* @param cashflows
* @param discountRate
* @return double
*/
inline double pv(const Cashflows& cashflows, double discountRate) {
double pv = 0;
for (auto& cf : cashflows) {
pv += cf.second / (1 + discountRate * cf.first);
}
return pv;
}

/***
* @brief Calculates the present value of a bond
*
* @param bond
* @param discountRate
* @return double
*/
inline double pv(const Bond& bond, double discountRate) {
return pv(bond.cashflows(), discountRate);
}

}; // namespace FinLib

#endif /* D3C623E9_FC36_4EEF_9ED6_46B293F1B764 */

Lo relevante de este script no es lo que hacen las clases ni los métodos, si no lo que nos gustaría exportar. En este caso buscamos exportar un enum, clase y función. Una leve descripción:

  • Tenemos dos clases, Bond y Deposit, donde la segunda hereda de Bond.
  • Tenemos un método inline, pv, pero también podría ser estático
  • En este ejemplo, podemos ver que el enum Frequency esta nivel de namespace.

La implementación se encuentra en el archivo mylibrary.cpp, la cual no es de relevancia para este tutorial pero los interesados pueden revisarla de cualquier forma.

module.cpp

Una vez tengamos escrita y funcionando nuestra librería en C++, debemos darle las instrucciones a pybind11 para que interprete nuestras clases y poder verlas como nos gustaría en python. Esto lo hacemos escribiendo un pequeño codigo fuente. El nombre del archivo no es relevante.

#include "../library/mylibrary.hpp"
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>

namespace py = pybind11;

PYBIND11_MODULE(FinLib, m) {
m.doc() = "Financial Library";

// Expose enums
py::enum_<FinLib::Frequency>(m, "Frequency")
.value("Monthly", FinLib::Frequency::Monthly)
.value("Quarterly", FinLib::Frequency::Quarterly)
.value("Semiannual", FinLib::Frequency::Semiannual)
.value("Annual", FinLib::Frequency::Annual)
.value("Once", FinLib::Frequency::Once)
.export_values();

// Expose classes
py::class_<FinLib::Cashflows>(m, "Cashflows").def(py::init<>());

py::class_<FinLib::Bond>(m, "Bond")
.def(py::init<FinLib::Date, FinLib::Date, FinLib::Frequency,
double, double>())
.def("cashflows", &FinLib::Bond::cashflows);

py::class_<FinLib::Deposit, FinLib::Bond>(m, "Deposit")
.def(py::init<FinLib::Date, FinLib::Date, double, double>());

// Expose functions
m.def("cashflowPV",
py::overload_cast<const FinLib::Cashflows&, double>(&FinLib::pv),
"Calculates the present value of a set of cashflows");
m.def("bondPV",
py::overload_cast<const FinLib::Bond&, double>(&FinLib::pv),
"Calculates the present value of a bond");
}

Lo primero que me gustaría resaltar son los encabezados de inclusion:

  • #include <pybind11/pybind11.h> : En este tutorial estaremos instalando nuestro modulo con setuptools, por lo que no es necesario descargar el repositorio de pybind11. De echo, al instalar el paquete de python de este complemento, los archivos de encabezados se descargan automáticamente.
  • #include <pybind11/stl.h>: Para poder migrar funciones de la librería estándar (en nuestro caso el map detrás de Cashflows), es necesario importar este encabezado. De esta forma, la traducción se hace de forma automática.

La sintaxis de pybind11 es un poco auto explicativa, pero de igual haremos un pequeño recorrido. Lo primero a mencionar es que el modulo se encuentra autocontenido dentro del macro PYBIND11_MODULE . Como argumentos de este macro le damos el nombre que nos gustaría nuestra librería - FinLib en el ejemplo- y un parámetro para una variable de tipo py::module_ , punto de entrada para todo nuestro modulo.

Importante: el nombre de nuestro modulo debe ir sin comillas y debe ser idéntico al que definamos en setup.py, archivo que veremos más adelante.

py::enum_

Es a travez de este template con el cual exportamos enumeraciones a python. Como parámetros del template utilizamos el mismo enum que creamos en nuestra librería. A continuación dejo algunas cosas utiles de saber:

  • Todos los nombres que utilicemos en nuestras definiciones, y no solo en el caso de enum si no que en todos los métodos de pybind11, no necesariamente deben coincidir con el nombre de la función real. Por ejemplo, uno podría tener una definición como la siguiente y el código seguiría siendo valido -aunque obviamente en python veríamos otros nombres-:
// Cambiamos las definiciones a español
py::enum_<FinLib::Frequency>(m, "Frecuencia")
.value("Mensual", FinLib::Frequency::Monthly)
.value("Cuatrimestral", FinLib::Frequency::Quarterly) // supongo que se dice asi en español 😅
.value("Semianual", FinLib::Frequency::Semiannual)
.value("Anual", FinLib::Frequency::Annual)
.value("UnicaVez", FinLib::Frequency::Once)
.export_values();
  • En el caso de ocupar enum planos y no enum class es necesario invocar al final de la definición export_values() dado que python requiere que estos enums no sean implícitamente convertibles a otros tipos.

py::class_

Creo que lo más interesante es hablar de py::init, la herencia y .def de clases dado que abordar lo más importante del mundo de las clases en general.

  • py::init es el constructor de nuestras clases. Es un template que recibe los parámetros de nuestra clase en C++ y los convierte en tipos de Python. Es importante mencionar que para que logre compilar, los tipos que definimos deben estar definidos como una clase, enum o tipo previamente. Dentro de paréntesis es posible pasar una función lamba para generar un constructor especializado, como por ejemplo:
// Clase base
py::class_<FinLib::Bond>(m, "Bond") <...>

// Clase derivada
py::class_<FinLib::Deposit, FinLib::Bond>(m, "Deposit")
.def(py::init<FinLib::Date, FinLib::Date, double, double>());

Esto es práctico ya que nos ahorra tener que escribir todos los métodos de la clase base en la clase derivada.

Importante: si no definimos de que clase heredan nuestras clases, nuestro código de igual manera compilará. Sin embargo, python no sera capaz de asociar las clases derivadas con las base.

.def

Por ultimo, nos queda hablar de .def. Como ya habrán visto, este método nos permite definir funciones, ya sea que correspondan a alguna clase o struct en particular o funciones inline o static. Quizás lo más interesante de mostrar es que pasa cuando tenemos funciones sobrecargadas:

// version que ocupa cashflows
m.def("cashflowPV",
py::overload_cast<const FinLib::Cashflows&, double>(&FinLib::pv),
"Calculates the present value of a set of cashflows");

// version que ocupa bonds
m.def("bondPV",
py::overload_cast<const FinLib::Bond&, double>(&FinLib::pv),
"Calculates the present value of a bond");

En este caso, es necesario darle a pybind11 las herramientas para determinar que método es cual. Para esto utilizamos py::overload_cast, función que hace un match en base a los tipos de los argumentos de nuestros métodos.

Importante: en python no existe el overloading de métodos ya que no tenemos tipos para poder determinar en que ocasión ocupar que función. Es por esto que debemos definir los métodos con diferentes nombres.

setup.py

Una vez tengamos definida nuestra libreria y el modulo a exportar, solo basta con armar un archivo setup.py para poder compilar e instalar nuestra libreria.

from pybind11.setup_helpers import Pybind11Extension, build_ext
from setuptools import setup
from pathlib import Path

__version__ = "1.0.0"

libraryName = "FinLib"
LIBDIR = (Path(__file__).parent.parent / "library").resolve() # Path to the library

ext_modules = [
Pybind11Extension(libraryName,
["module.cpp"],
include_dirs=[str(LIBDIR)],
library_dirs=[str(LIBDIR / "build")],
libraries=[libraryName],
define_macros=[('VERSION_INFO', __version__)],
language="c++20"
),
]

setup(
name=libraryName,
version=__version__,
ext_modules=ext_modules,
cmdclass={"build_ext": build_ext},
python_requires=">=3.7",
)

Este archivo basicamente tiene las instrucciones para compilar module.cpp de la forma correcta. Es necesario darle las rutas de archivos de encabezado y libreria compilada, tal como uno lo haria con cualquier proyecto en c++. Como mencione en un inicio, para que todo funcione de la forma correcta tambien es necesario tener instalada la libreria de pybind en python, lo cual podemos hacer a traves de pip .

Instalación

Ya con todos los archivos en manos, comenzamos a compilar. En este caso, seguiremos con el supuesto de que los archivos que tenemos vienen del repositorio del principio. Los comandos estan pensados en la siguiente estructura de carpetas:

Los pasos que tendriamos que seguir para tener todo instalado seria:

  1. CMake: creamos un directorio build dentro de library y nos vamos a esa ruta con la terminal. Ejecutamos:
  • cmake ..
  • cmake --build . --config Release

Esto ejecutara Cmake y compilara nuestra libreria inicial (FinLib). Si todo salio bien deberiamos ver un archivo libFinLib.a si estamos en Mac\Linux o FinLib.lib si estamos en Windows.

2. Una vez compilada la libreria, debemos ir al directorio module y ejecutar desde la consola:

  • pip setup.py build --force
  • pip setup.py install

Tambien se podria utilizar pip install . , sin embargo me he dado cuenta que en general cuando uno esta desarrollando tiende a compilar varias veces, por lo que con los comandos anteriores nos aseguramos que cada vez se recompile el modulo completo y se reflejen los cambios de forma correcta.

Resultados y conclusiones

Despues de haber instalado el modulo en python, deberiamos ser capances de correr scripts .py que utilicen la libreria. A continuacion presento un ejemplo y breves comentarios:

startDate = 0
endDate = 5

rate = 0.03
notional = 100
frequency = FinLib.Semiannual

bond = FinLib.Bond(startDate, endDate, frequency, notional, rate)

for date, cashflow in bond.cashflows().items():
print('Date:\\t{d}, Cashflow:\\t{c}'.format(d=date, c=cashflow))

Al ejecutar, deberiamos ver un resultado como:

y listo! nuestro modulo queda disponible en un lenguaje de alto nivel.

Llegamos al final del tutorial. Como mencioné al principio, pybind es una de varias herramientas destinadas a lograr interoperabilidad entre lenguajes. A los interesados en migrar lenguajes, les recomiendo mirar como alternativa SWIG, la cual tiene una forma de funcionar similar y permite moverse a otros lenguajes como C# o JavaScript, lo cual puede ser util para otras aplicaciones. Además, quedan invitados a revisar la documentacion de pybind11, ya que existen otras funcionalidades interesantes que no abordamos en este tutorial, como la posibilidad de usar tipos y librerias de python en c++ (al reves de lo que hicimos aca).

--

--