Skoči na vsebino

Vsebina

Ta modul uporablja:

  • https://github.com/leskovec/pyC_part.I

V modulu 03 smo večinoma vezali funkcije. Tukaj naredimo naslednji večji korak: zvežemo C++ razred.

Učni repozitorij implementira majhen C++ razred VectorInt, ki cela števila hrani v std::vector<int>, in ga v Python izpostavi kot razred PyVectorInt.

Zakaj je ta zasnova uporabna za začetnike:

  • jasno vidite, kako C++ objekt postane Python objekt
  • naučite se Python »dunder« metod (__len__, __getitem__, __iter__, ...)
  • spoznate izpostavljanje preobremenjenih metod, enumov in celo povratnega klica

Osrednja tema ostaja enaka: tanek vezavni sloj mapira C++ -> Python. Le da je zemljevid zdaj bogatejši kot en sam klic m.def(...).


C++ razred: vektor, skrit za objektom

Deklaracija razreda (iz vector_int.h) je kratka, a poučna:

#pragma once

#include <vector>
#include <string>

enum Access { READONLY, READWRITE };

class VectorInt {
  using iterator = typename std::vector<int>::iterator;

public:
  VectorInt();
  VectorInt(const size_t n);

  iterator begin();
  iterator end();

  void push_back(const int val);
  void push_back(const int val, const size_t n);

  int get(const size_t idx) const;
  size_t size() const;

  bool is_empty() const;
  void clear();
  std::string to_string() const;

private:
  std::vector<int> _vec;

public:
  Access _access;
};

Nekaj stvari je dobro opaziti takoj:

  • VectorInt je običajen C++ razred. V njem ni nič »pythonovskega«.
  • Dejanski podatki so v _vec, torej zasebnem std::vector<int>.
  • Razred ima majhno »nastavitveno stikalo« _access tipa Access (enum).
  • Razred podpira iteracijo (begin(), end()), kar bomo potrebovali pri __iter__ na Python strani.

Če si iz tega modula zapomnite eno idejo, naj bo to:

Ne vežemo std::vector<int> neposredno. Vezan je C++ razred, ki znotraj uporablja std::vector<int>.

To je v realnih projektih zelo pogost vzorec: podrobnosti implementacije skrijete za stabilnim vmesnikom.


Kaj C++ metode dejansko počnejo

Implementacija (vector_int.cpp) je namenoma enostavna.

Konstruktorji

VectorInt::VectorInt() : _vec(), _access(Access::READWRITE) { }

VectorInt::VectorInt(const size_t n) : _vec(n), _access(Access::READWRITE) { }
  • Privzeti konstruktor: začnemo s praznim vektorjem.
  • Konstruktor z velikostjo: vektor rezervira n privzeto inicializiranih celih števil.
  • V obeh primerih je privzeta politika dostopa READWRITE.

Push back

void VectorInt::push_back(const int val) {
  _vec.push_back(val);
}

void VectorInt::push_back(const int val, const size_t n) {
  for (size_t i = 0; i < n; ++i) {
    _vec.push_back(val);
  }
}

To je odličen primer, zakaj so razredi didaktično uporabni:

  • v Pythonu lahko izpostavite naravno ime metode push_back
  • hkrati pa morate obravnavati dejstvo, da C++ podpira preobremenitve (isto ime, različni podpisi)

To bomo rešili v vezavni plasti.

Dostop po indeksu in velikost

int VectorInt::get(const size_t idx) const {
  return _vec[idx];
}

size_t VectorInt::size() const {
  return _vec.size();
}

Ti metodi bomo uporabili za Python __getitem__ in __len__.

Nizovna predstavitev

std::string VectorInt::to_string() const {
  std::stringstream ss;
  ss << "[";
  for (size_t i = 0; i < size() - 1; ++i) {
    ss << get(i) << ", ";
  }
  ss << get(size() - 1) << "]";
  return ss.str();
}

Funkcija zgradi berljiv niz, npr. [1, 2, 3]. V Python terminologiji ga bomo uporabili kot __repr__ (za razhroščevanje) in __str__ (za izpis).


Vezavna datoteka: kako C++ razred postane Python razred

Vezavna koda je v module.cpp. To je »mostna« datoteka.

Glave: zakaj vključimo ravno te

#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <pybind11/functional.h>

#include "vector_int.h"
namespace py = pybind11;
  • pybind11/pybind11.h: osnovni vezavni API
  • pybind11/stl.h: pretvorbe tipov za STL tipe (seznami ↔ vektorji itd.)
  • pybind11/functional.h: potrebna glava, kadar vežete std::function (callback)

Tudi če callbackov še ne uporabljate, je koristno vedeti, da je functional.h »opt-in« glava za klice Python↔C++.


Vezava razreda z py::class_

Osnovna oblika je:

py::class_<VectorInt>(m, "PyVectorInt", py::dynamic_attr())

To lahko berete takole:

  • »Izpostavi C++ tip VectorInt ...
  • ... v modulu m ...
  • ... pod Python imenom PyVectorInt

Dodatni py::dynamic_attr() izboljša uporabnost na Python strani: omogoča, da instanci med izvajanjem dodate nove atribute (kot pri mnogih čistih Python razredih).

Primer v Pythonu:

v = vecint.PyVectorInt()
v.label = "trial run 7"   # dinamično dodan atribut

Če dynamic_attr izpustite, takšna dodelitev lahko odpove, odvisno od načina vezave razreda.


Naj se razred obnaša pythonovsko z dunder metodami

Konstruktorji -> __init__

.def(py::init<>())              // PyVectorInt()
.def(py::init<const size_t>())  // PyVectorInt(n)

V Pythonu to omogoča dva načina konstrukcije:

  • prazen vektor
  • vektor vnaprej določene velikosti

Izpis: __repr__ in __str__

.def("__repr__", &VectorInt::to_string)
.def("__str__",  &VectorInt::to_string)

V Pythonu:

  • print(v) kliče __str__
  • interaktivni poziv uporablja __repr__

V učnem primeru ju pustimo enaka, kar je povsem v redu.

Indeksiranje: v[i]

.def("__getitem__", &VectorInt::get)

V Pythonu lahko zdaj pišete:

v[0]

Opomba: C++ get() trenutno uporablja _vec[idx] in ne preverja meja. Za produkcijsko kodo bi navadno dodali preverjanje in vrgli izjemo ob neveljavnem indeksu.

Dolžina: len(v)

.def("__len__", &VectorInt::size)

V Pythonu:

len(v)

Iteracija: for x in v: ...

Tu postane zanimivo:

.def("__iter__", [](VectorInt& vec) {
    return py::make_iterator(vec.begin(), vec.end());
}, py::keep_alive<0,1>())

Kaj se dogaja?

  • make_iterator(begin, end) ustvari Python iterator, ki hodi po C++ iteratorjih.
  • keep_alive<0,1>() je pomemben: pybind11 pove, naj C++ vsebnik (vec) ostane živ toliko časa, kot obstaja Python iterator.

Brez keep_alive je precej lažje nehote končati z visečim iteratorjem, če izvorni objekt gre iz dosega.


Izpostavljanje običajnih metod (tudi preobremenjenih)

Preobremenjeni push_back

C++ podpira preobremenitve, Python pa jih ne razrešuje po tipih na isti način. Zato v vezavi pogosto eksplicitno izberemo pravi podpis:

.def("push_back",
     static_cast<void (VectorInt::*)(const int)>(&VectorInt::push_back),
     "Insert one element", py::arg("elem"))

.def("push_back",
     static_cast<void (VectorInt::*)(const int, const size_t)>(&VectorInt::push_back),
     "Insert elem n times", py::arg("elem"), py::arg("n"))

static_cast<...> je ključni del: odstrani dvoumnost in pove, katero preobremenitev vežete.

V Pythonu sta obe pod istim imenom:

v.push_back(7)
v.push_back(7, 10)   # vstavi 10 kopij

Ostale metode

To so preproste vezave:

.def("is_empty", &VectorInt::is_empty)
.def("clear",    &VectorInt::clear)
.def("size",     &VectorInt::size)

Repozitorij veže tudi length kot lastnost samo za branje:

.def_property_readonly("length", &VectorInt::size)

V Pythonu deluje oboje:

v.size()
v.length

Vezava javnega podatkovnega člana: .def_readwrite

Razred ima javni član _access. Vezava uporablja:

.def_readwrite("access", &VectorInt::_access)

S tem dobite Python atribut v.access, ki je berljiv in zapisljiv.

Za poučevanje je to odličen primer: izpostavite lahko tudi podatkovne člane, ne samo metod. V resničnih projektih boste pogosto raje uporabili def_property, da lahko ob zapisu izvajate validacijo, a def_readwrite je dober prvi korak.


Vezava enuma: Access

Repozitorij izpostavi C++ enum v Python:

py::enum_<Access>(m, "Access")
  .value("READONLY",  Access::READONLY)
  .value("READWRITE", Access::READWRITE);

Tako lahko uporabnik v Pythonu zapiše:

v.access = vecint.Access.READONLY

To je bistveno bolje kot uvajanje »čarobnih celih števil«.


Primer callbacka: C++ kliče nazaj v Python

Vezava vključuje tudi metodo, ki sprejme klicljiv objekt:

.def("doForAllElements",
     [](VectorInt& vec, std::function<void(int)>& f) {
         for (int elem : vec) {
             f(elem);
         }
     });

To je pomemben konceptualni korak:

  • Python poda funkcijo (ali lambda) v C++
  • C++ iterira po internem vektorju
  • C++ za vsak element pokliče Python funkcijo

V Pythonu je to videti takole:

def printer(x):
    print("element:", x)

v.doForAllElements(printer)

To ni najhitrejši način numeričnega računanja (večkratni klici v Python so dragi), je pa didaktično zelo uporaben za razlago:

  • kako std::function na C++ strani predstavlja klicljiv objekt
  • zakaj ste vključili pybind11/functional.h
  • kako enostavno je sestaviti C++ vsebnike in Python vedenje

Dobra učna poanta o zmogljivosti:

Če Python kličete enkrat na element, ceno prehoda plačate velikokrat. Za hitrost so boljša jedra, ki obdelajo več podatkov v enem klicu, callbacke pa razumite kot orodje.


Ključni povzetek

Ta modul je več kot »vezava vektorja«. Pokaže vzorce, ki jih boste uporabljali pri realnih razredih:

  • konstruktorji -> py::init
  • Python vedenje prek dunder metod (__len__, __getitem__, __iter__, ...)
  • razreševanje preobremenitev s static_cast
  • lastnosti in javni člani (def_property_readonly, def_readwrite)
  • enumi (py::enum_)
  • callbacki (std::function + pybind11/functional.h)

Ko znate prebrati in napisati takšno vezavo, ste pripravljeni izpostaviti kompleksnejše znanstvene objekte z jasnim API-jem, malo prehodi med jeziki in predvidljivim pomnilniškim obnašanjem.