Wrapping a C++ container in Python

When moving to Python, the real big problem that arises is the transformation of a Python array into the C++ container the team used for years.

Let’s set some hypothesis :

  • there is a separation between the class containing the data and the class that uses the data (iterators, …)
  • the containing class can be changed (policy or strategy pattern)

The first hypothesis is derived from the responsibility principle, the two classes have two distinct responsibilities, the first allocates the data space and allows simple access to it, the second allows usual operations (assignation, comparison tests or iterations for instance).

The second one will be the heart of the wrapper. It allows to change the way data is stored and accessed in a simple way.

So here is a simplification of a 3D container class that will store a 3D numpy array. It must be capable of creating a container from a PyObject* and have a method to get the stored array, here getContainer():

  1. #include <Python.h>
  2. #include <numpy/arrayobject.h>
  3.  
  4.   template<class Im3DValue>
  5.   class Container3DPython
  6.   {
  7.   protected:
  8.     /// The inner data object
  9.     PyArrayObject* imageData;
  10.  
  11.     /// Shortcut for self type reference
  12.     typedef Container3DPython<Im3DValue> Self;
  13.   public:
  14.     /// So that other templates know what Im3DValue is
  15.     typedef Im3DValue value_type;
  16.  
  17.   protected:
  18.     /**
  19.      * Tries to allocate a new 3D array
  20.      * @param width is the width of the new array
  21.      * @param depth is the depth of the array
  22.      * @param height is the height of the array
  23.      */
  24.     void allocate(int width, int height, int depth)
  25.     {
  26.       deallocate();
  27.       PyArray_Dims dims;
  28.       dims.ptr = (npy_intp*)malloc(3 * sizeof(npy_intp));
  29.       dims.len = 3;
  30.       dims.ptr[2] = width;
  31.       dims.ptr[1] = height;
  32.       dims.ptr[0] = depth;
  33.  
  34.       imageData = reinterpret_cast<PyArrayObject*>(PyArray_SimpleNewFromDescr(dims.len, dims.ptr, PyArray_DescrNewFromType(DataTypeTraits<value_type>::type_num)));
  35.       Py_INCREF(imageData);
  36.  
  37.       free(dims.ptr);
  38.     }
  39.  
  40.     /**
  41.      * Tries to allocate an array based on a Python object
  42.      * @param obj is the Python object that will be shared or copied, depending on its content and type
  43.      */
  44.     void allocate(PyObject* obj)
  45.     {
  46.       deallocate();
  47.       PyArrayObject* array = reinterpret_cast<PyArrayObject*>(PyArray_FromAny(obj, NULL, 3, 3, NPY_FARRAY, NULL));
  48.       imageData = reinterpret_cast<PyArrayObject*>(PyArray_CastToType(array, PyArray_DescrNewFromType(DataTypeTraits<value_type>::type_num), 0));
  49.       Py_INCREF(imageData);
  50.       Py_DECREF(array);
  51.     }
  52.  
  53.     /// Deallocates the Python object
  54.     void deallocate()
  55.     {
  56.       Py_XDECREF(imageData);
  57.       imageData = NULL;
  58.     }
  59.  
  60.   public:
  61.     ///@{ \name Image3D Accessors
  62.     /**
  63.      * Returns the width of the image
  64.      */
  65.     unsigned int width() const
  66.     {
  67.       return imageData->dimensions[2];
  68.     }
  69.     /**
  70.      * Returns the depth of the image
  71.      */
  72.     unsigned int height() const
  73.     {
  74.       return imageData->dimensions[1];
  75.     }
  76.     /**
  77.      * Returns the depth of the image
  78.      * @return the depth of the image
  79.      */
  80.     unsigned int depth() const
  81.     {
  82.       return imageData->dimensions[0];
  83.     }
  84.     /**
  85.      * Gets the value at a position in the image
  86.      * @param x is the first coordinate
  87.      * @param y is the second coordinate
  88.      * @param z is the third coordinate
  89.      * @return a reference to the value
  90.      */
  91.     Im3DValue& operator()(int x,int y,int z)
  92.     {
  93.       return reinterpret_cast<value_type*>(imageData->data)[x+y*width()+z*width()*height()];
  94.     }
  95.     /**
  96.      * Gets the value at a position in the image
  97.      * @param x is the first coordinate
  98.      * @param y is the second coordinate
  99.      * @param z is the third coordinate
  100.      * @return a const reference to the value
  101.      */
  102.     const Im3DValue& operator()(int x,int y,int z) const
  103.     {
  104.       return reinterpret_cast<value_type*>(imageData->data)[x+y*width()+z*width()*height()];
  105.     }
  106.     /**
  107.      * Returns the inner shared pointer
  108.      * @return the shared pointer
  109.      */
  110.     PyArrayObject* getContainer() const
  111.     {
  112.       Py_XINCREF(imageData);
  113.       return imageData;
  114.     }
  115.     ///@}
  116.  
  117.     ///@{ \name Operators
  118.     /**
  119.      * Assignment operator from a different type of image
  120.      * @param other is the array
  121.      * @return self
  122.      */
  123.     Container3DPython& operator=(PyObject* other)
  124.     {
  125.       allocate(other);
  126.       return *this;
  127.     }
  128.  
  129.     /// Destructor
  130.     virtual ~Container3DPython()
  131.     {
  132.       deallocate();
  133.     }
  134.     /**
  135.      * Simple constructor
  136.      * @param width is the width of the new image
  137.      * @param height is the height of the new image
  138.      * @param depth is the depth of the new image
  139.      */
  140.     Container3DPython(unsigned int width, unsigned int height, unsigned int depth)
  141.     : imageData(NULL)
  142.     {
  143.       allocate(width, height, depth);
  144.     }
  145.  
  146.     /**
  147.      * Constructor from an array
  148.      * @param array is the array to copy
  149.      */
  150.     Container3DPython(PyObject* array)
  151.     :imageData(NULL)
  152.     {
  153.       allocate(array);
  154.     }
  155.     ///@}
  156.   };

Once this skeleton is ready, it is possible to create typedefs to reference new containers.

typedef Container<Container3DPython<float> > Container3DfPython;

Once this is done, the associated swig file will declare some simple typemaps :

  1. %{
    
  2. #define SWIG_FILE_WITH_INIT
    
  3. #define PY_ARRAY_UNIQUE_SYMBOL PyArray_API
    
  4. %}
    
  5. %include "numpy.i"
    
  6. %init %{
    
  7. import_array();
    
  8. %}
    
  9. %{
    
  10. #include "stdio.h"
    
  11. #include <numpy/arrayobject.h>
    
  12. #include <Container3DPython.hpp>
    
  13. #include <Container.hpp>
    
  14. %}
    
  15.  
  16. %typemap(in) Container3DfPython
    
  17. {
    
  18.   $1 = new Container3DfPython($input);
    
  19. }
    
  20.  
  21. %typemap(freearg) Container3DfPython
    
  22. {
    
  23.   delete $1;
    
  24. }
    
  25.  
  26. %typemap(out) Container3DfPython
    
  27. {
    
  28.   $result = reinterpret_cast<PyObject*>($1->getContainer());
    
  29.   delete $1;
    
  30. }

With this method, the wrapped container can be passed to a class constructor and stored in an instance, deleted at the end. If the numpy SWIG wrappers are used, only pointers are given to the function, and it is freed at the end of the function, thus class instance cannot be created and used later.

Note that the described container class will cast the array into the appropriate type thanks to a trait structure DataTypeTraits with the attribute type_num. This means that the array may not be modified in place. If this is not intended, use typechecks.

Note also that if you have multiple C++ files that uses the container, you will have to define NO_IMPORT_ARRAY for them (but not for the SWIG generated file).

One thought on “Wrapping a C++ container in Python”

Leave a Reply