DEV Community

Somenath Mukhopadhyay
Somenath Mukhopadhyay

Posted on • Originally published at som-itsolutions.blogspot.com on

nanobind: the bridge between C++ and Python...

Python is where ideas move fast. C++ is where performance lives. For years, gluing the two together meant choosing between power, safety, and sanity. Enter nanobind —a modern, minimalist bridge that lets C++ and Python talk to each other cleanly, efficiently, and without ceremony.

nanobind is a C++17-first binding library , inspired by pybind11 but redesigned with a sharper focus on performance, compile times, and modern C++ practices. It’s built for developers who care about control—over lifetimes, memory, ABI stability, and error handling—without drowning in boilerplate.

What makes nanobind special is how unapologetically low-level it is, while still feeling elegant. It avoids heavy template metaprogramming where possible, compiles fast, and produces smaller binaries. If you’re working on large C++ codebases—CAD kernels, physics engines, solvers, graphics pipelines—this matters a lot.

Memory management is another strong point. nanobind gives you explicit control over object ownership between C++ and Python, making it ideal for systems where Python is embedded inside a C++ application (think Blender, FreeCAD, game engines, or simulation tools). You’re not just exporting functions—you’re designing an API boundary.

Compared to older tools like SWIG, nanobind doesn’t try to “auto-magically” generate bindings. That’s a feature, not a bug. You write bindings intentionally, so the Python API feels Pythonic, not like a leaked C++ header. And compared to pybind11, nanobind is leaner, stricter, and more future-facing.

In short:

  • Python for orchestration and scripting

  • C++ for performance-critical logic

  • nanobind as the clean, modern handshake between them

If you’re building serious software where Python is a first-class citizen—but not the performance bottleneck—nanobind is one of the best bridges you can choose today.

Here's my today's exploration on nanobind - the Factory pattern written in C++ being given a python interface using nanobind.

Enjoy...

The Factory Pattern is written in C++.

/*
 * Food.h
 *
 *  Created on: Mar 10, 2021
 *      Author: som
 */

#ifndef FOOD_H_
#define FOOD_H_
#include <string>

using namespace std;

class Food {
public:
    virtual string getName() = 0;

    virtual ~Food(){

    }
};


#endif /* FOOD_H_ */

Enter fullscreen mode Exit fullscreen mode
\* Biscuit.h

\*

\* Created on: Mar 10, 2021

\* Author: som

\*/

/*
 * Biscuit.h
 *
 *  Created on: Mar 10, 2021
 *      Author: som
 */

#ifndef BISCUIT_H_
#define BISCUIT_H_

#include "Food.h"

class Biscuit: public Food {
public:
    Biscuit();
    string getName();
    ~Biscuit();
};

#endif /* BISCUIT_H_ */


/*
 * Biscuit.cpp
 *
 *  Created on: Mar 10, 2021
 *      Author: som
 */
#include <iostream>
#include "Biscuit.h"
using namespace std;


Biscuit::Biscuit() {
    // TODO Auto-generated constructor stub
    cout<<"Biscuit is made..."<<endl;

}

Biscuit::~Biscuit(){}


string Biscuit::getName(){
    return "It's a Biscuit";
}

Enter fullscreen mode Exit fullscreen mode
/*
 * Chocolate.h
 *
 *  Created on: Mar 10, 2021
 *      Author: som
 */

#ifndef CHOCOLATE_H_
#define CHOCOLATE_H_

#include <iostream>
#include "Food.h"

class Chocolate: public Food {
public:
    Chocolate();
    virtual ~Chocolate();
    string getName();
};


#endif /* CHOCOLATE_H_ */


/*
 * Chocolate.cpp
 *
 *  Created on: Mar 10, 2021
 *      Author: som
 */

#include "Chocolate.h"


Chocolate::Chocolate() {
    // TODO Auto-generated constructor stub
    cout<<"Chocolate is made..."<<endl;

}

Chocolate::~Chocolate() {
    // TODO Auto-generated destructor stub
}

string Chocolate::getName(){
    return "It's a Chocolate";
}

Enter fullscreen mode Exit fullscreen mode
/*
 * Factory.h
 *
 *  Created on: Mar 10, 2021
 *      Author: som
 */

#ifndef FACTORY_H_
#define FACTORY_H_

#include <nanobind/nanobind.h>
#include <iostream>
#include <string>

#include "Biscuit.h"
#include "Chocolate.h"

using namespace std;

class Factory{
public:
    static Factory* instance;
    static Factory* getInstance();

    Food* makeFood(const string& type);

private:
    Factory(){}

    // Delete copy constructor & assignment operator (Singleton pattern)
    Factory(const Factory&) = delete;
    Factory& operator=(const Factory&) = delete;
};
//Factory* Factory:: instance =  NULL;


#endif /* FACTORY_H_ */


/*
 * Factory.cpp
 *
 *  Created on: Jan 30, 2025
 *      Author: som
 */
#include "Factory.h"
Factory* Factory::instance = NULL;

Factory* Factory:: getInstance(){
        if(Factory::instance == NULL){
            Factory::instance = new Factory();
        }
        return Factory::instance;
    }

Food* Factory::makeFood(const string& type){
        if(type.compare("bi") == 0){
            return new Biscuit();
        }
        if(type.compare("ch") == 0){
            return new Chocolate();
        }

        return NULL;
    }
Enter fullscreen mode Exit fullscreen mode

The bindings.cpp - defining the bridge...

#include <nanobind/nanobind.h>
#include <nanobind/stl/string.h> // Required for std::string support
#include "Factory.h"

namespace nb = nanobind;

// Use NB_MODULE to define the extension
NB_MODULE(foodfactory, m) {
    // 1. Wrap the Base Class
    // We use nb::class_<T> and provide the name it will have in Python
    nb::class_<Food>(m, "Food")
        .def("getName", &Food::getName);

    // 2. Wrap the Subclasses
    // Note: We specify the base class <Biscuit, Food> so Python knows the relationship
    nb::class_<Biscuit, Food>(m, "Biscuit")
        .def(nb::init<>());

    nb::class_<Chocolate, Food>(m, "Chocolate")
        .def(nb::init<>());

    // 3. Wrap the Singleton Factory
        nb::class_<Factory>(m, "Factory")
            .def_static("get_instance", &Factory::getInstance, // Renamed to snake_case for Python idiomatic style
                        nb::rv_policy::reference)

            .def("make_food", &Factory::makeFood, // Match your Python script's call
                 nb::rv_policy::take_ownership);
}

Enter fullscreen mode Exit fullscreen mode
## And here is the CMakeLists.txt for compiling and creating the shared object

cmake_minimum_required(VERSION 3.15)
project(FactoryPattern_nanobind)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# 1. Find Python
find_package(Python 3.9 COMPONENTS Interpreter Development REQUIRED)

# 2. Get nanobind paths manually to bypass the "Target Not Found" bug
execute_process(
    COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir
    OUTPUT_VARIABLE NB_DIR OUTPUT_STRIP_TRAILING_WHITESPACE
)
execute_process(
    COMMAND "${Python_EXECUTABLE}" -m nanobind --include_dir
    OUTPUT_VARIABLE NB_INC OUTPUT_STRIP_TRAILING_WHITESPACE
)

# 3. Load the nanobind logic
list(APPEND CMAKE_PREFIX_PATH "${NB_DIR}")
find_package(nanobind REQUIRED CONFIG)

# 4. Create the module
nanobind_add_module(foodfactory
    LTO
    Biscuit.cpp
    Chocolate.cpp
    Factory.cpp
    bindings.cpp
)

# 5. MANUALLY fix the missing target link
# This bypasses the error by providing the includes and libraries directly
target_include_directories(foodfactory PRIVATE "${NB_INC}" "${Python_INCLUDE_DIRS}")
target_link_libraries(foodfactory PRIVATE Python::Module)

# If nanobind still complains about the target, we define it as an alias
if(NOT TARGET nanobind::nanobind)
    add_library(nanobind::nanobind INTERFACE IMPORTED)
    set_target_properties(nanobind::nanobind PROPERTIES
        INTERFACE_INCLUDE_DIRECTORIES "${NB_INC}"
    )
endif()
Enter fullscreen mode Exit fullscreen mode

After compiling and creating the shared object, you need to put it inside the Python project and import it.

The Python code looks as follows.

import foodfactory
# Access get_instance THROUGH the Factory class
factory = foodfactory.Factory.get_instance()
biscuit = factory.make_food("bi")
print(biscuit.getName())
chocolate = factory.make_food("ch")
print(chocolate.getName())
Enter fullscreen mode Exit fullscreen mode

Enjoy...

Top comments (0)