Sytuacja kobiet w IT w 2024 roku
21.01.20216 min
Karol Marszałek

Karol MarszałekC/C++ Software Development Engineer

CPython - implementacja obiektowości

Sprawdź, jak wygląda implementacja obiektowości w CPythonie, który napisany jest w języku C, który jest proceduralny.

CPython - implementacja obiektowości

Wsparcie obiektowości jest obecnie jedną z najbardziej pożądanych właściwości współczesnych języków programowania. Podejście obiektowe do projektowania oraz implementacji aplikacji ułatwia nam modelowanie rzeczywistości w strukturach komputera. Z tego też powodu większość współczesnych języków programowania posiada wsparcie dla programowania zorientowanego obiektowo. W tym tekście przyjrzymy się realizacji tego wsparcia w najpopularniejszej implementacji Pythona - CPythonie. 

CPython napisany jest w C, który sam w sobie nie posiada wsparcia dla obiektowości. W związku z tym próby implementacji obiektowości wiążą się z koniecznością stworzenia modelu danych “rozumiejącego” koncepcję obiektu i potrafiącego przełożyć go na proste typy danych dostępne w ramach języka C. 

W tym celu niezbędne jest zdefiniowanie zbioru struktur danych oraz algorytmów pozwalających na wykorzystanie właściwości obiektowości z punktu widzenia użytkownika końcowego, którym jest programista Pythona. Dodatkowo maszyna wirtualna Pythona wspiera programistę w zakresie zarządzania pamięcią oraz czasem życia obiektów - w związku z tym przyjrzymy się również mechanizmom zarządzania pamięcią zaimplementowanym w maszynie wirtualnej. W tym tekście skupimy się na Pythonie 3, a na etapie przedstawiania kolejnych aspektów implementacji obiektowości będę posługiwał się analogiami do języka C++.

Programiści Pythona doskonale wiedzą, że w Pythonie wszystko jest obiektem. Co to jednak właściwie oznacza? Punktem wyjścia do rozważań o obiektowości Pythona jest struktura PyObject. Poniżej widzimy jej definicję wraz z komentarzem, który wyjaśnia jej znaczenie w modelu danych.

Pierwszym polem w strukturze jest makro _PyObject_HEAD_EXTRA, które służy do śledzenia referencji między obiektami i jest aktywne tylko podczas kompilacji interpretera z odpowiednimi dyrektywami preprocesora. Kolejnym polem jest licznik referencji (ang. reference count), który służy do implementacji automatycznego zarządzania pamięcią. Na ten moment najbardziej interesującym dla nas polem struktury będzie pole PyTypeObject *ob_type. Jest to typ obiektu, który to typ definiuje zachowanie obiektu i jest jedną z kluczowych struktur modelu danych maszyny wirtualnej. Poniżej możemy zobaczyć fragment struktury PyTypeObject ( na potrzeby artykułu zmieniono kolejność kilku pól oraz dodano komentarze, oryginał struktury możemy znaleźć w pliku : cpython/object.h ). 

Jak łatwo zauważyć, struktura jest raczej duża. Składa się ona głównie ze wskaźników na funkcje, co być może nie jest widocznie na pierwszy rzut oka z uwagi na fakt, że są one zdefiniowane poprzez typedef. Tworząc instancje struktury _typeobject (PyTypeObject) oraz implementując odpowiednie funkcje możemy definiować własne, statyczne typy (fragment przykładu z dokumentacji Pythona):

Widzimy zatem, że wewnątrz maszyny wirtualnej Pythona typy, a więc również klasy, są obiektami. Warto zwrócić uwagę na fakt, że typy zdefiniowane w ten sposób są statyczne. Typy mogą być również alokowane na stercie (heap-allocated types) poprzez wypełnienie struktury PyType_Spec oraz wywołanie funkcji PyType_FromSpecWithBases

Omawianie kolejnych pól struktury typu zajęłoby zbyt dużo miejsca, a ich dokładny opis jest dostępny w dokumentacji Pythona. Zwróćmy jednak uwagę na kilka pól:

  • struct PyMethodDef *tp_methods- wskaźnik na strukturę definiującą dodatkowe metody dla typu.
  • struct PyMemberDef *tp_members- wskaźnik na strukturę definiującą dodatkowe pola danych dla typu.
  • struct PyGetSetDef *tp_getset- wskaźnik na strukturę definiującą atrybuty typu.
  • PyObject *tp_dict- Słownik typu ( __dict__ ). Dla każdego wpisu w powyższych strukturach metoda PyType_Ready generuje odpowiedni wpis w tej strukturze. 
  • struct _typeobject *tp_base- Wskaźnik na typ bazowy. 
  • PyObject* tp_bases- Wskaźnik na krotkę zawierającą informację o klasach bazowych dla typów alokowanych dynamicznie, co pozwala na implementację wielodziedziczenia.
  • PyObject* tp_mro- Wskaźnik na krotkę zawierającą method resolution order wyznaczony przez funkcję PyType_Ready


Z punktu widzenia obiektowości istotnym elementem jest mechanizm dziedziczenia metod. W C++ mamy do czynienia z metodami wirtualnymi pozwalającymi na korzystanie z polimorfizmu. Obiekty Pythona posiadają tylko metody wirtualne, a decyzja o wywołaniu metody z danej klasy lub jej typów bazowych jest podejmowana na podstawie krotki tp_mro wyznaczonej na etapie inicjalizacji typu. Z naszego punktu widzenia istotne są dwa fakty: wewnątrz typu istnieje pole opisujące kolejność rozwiązywania metod oraz fakt, że jest ono tworzone na etapie inicjalizacji typu na podstawie hierarchii dziedziczenia. Z poziomu interpretera możemy sprawdzić wyznaczoną krotkę odwołując się do pola __mro__ dla typu. Sprawdźmy wyznaczone pole tp_mro dla przykładowej hierarchii klas:

Wartym zauważenia faktem jest przeglądanie hierarchii dziedziczenia “w głąb”, a więc w przypadku nieznalezienia danej metody w klasie F oraz D, następną sprawdzaną klasą jest klasa A jako pierwsza klasa bazowa klasy D. Jest to o tyle istotne, że można by się spodziewać, iż w tym przypadku najpierw powinna zostać sprawdzona klasa E, jako druga klasa bazowa klasy F. Dodatkowo, w Pythonie 3 ostatnią klasą sprawdzaną w MRO jest zawsze klasa object z uwagi na fakt, że wszystkie typy w Pythonie 3 niejawnie dziedziczą po typie object

Znając już podstawowe aspekty związane z problemem realizacji obiektowości w CPythonie, przyjrzymy się mechanizmom zarządzania pamięcią. Dla nikogo nie będzie zaskoczeniem stwierdzenie, że Python śledzi referencje do obiektów za pomocą mechanizmu reference counting. Do zarządzania licznikami referencji obiektów służą makra: Py_INCREF(op) oraz Py_DECREF(op). Jak można się domyślić, pierwsza z nich zwiększa licznik referencji o jeden. Nas będzie interesowało bardziej drugie makro, które służy do zmniejszenia licznika referencji. W momencie gdy licznik referencji danego obiektu jest równy 0 w wyniku wywołania Py_DECREF, wywoływana jest funkcja void _Py_Dealloc(PyObject *op), która pobiera z typu obiektu pole tp_dealloc i wywołuje ją na rzecz niszczonego obiektu.

Sam mechanizm zliczania referencji jest koncepcyjnie prosty, więc na tym  poprzestaniemy z jego wyjaśnianiem. Na koniec warto jeszcze wspomnieć, że licznik referencji obiektu jest ustawiany na 1 przez funkcję tp_alloc

O ile mechanizm licznika referencji jest wydajny, to wiąże się on z popularnym problemem cyklicznych zależności. Jeżeli obiekt A posiada referencję do obiektu B, który z kolei posiada referencję z powrotem do obiektu A, przy klasycznym podejściu z licznikiem referencji nigdy nie zostałyby one zwolnione, ponieważ liczniki zawsze będą wskazywały wartość większą od 0. Rozwiązywaniem problemu cyklicznych zależności w kontenerach zajmuje się moduł garbage collectora - gc. Działanie modułu gc można ująć w ten sposób: usuwa on obiekty znajdujące się w cyklach, które nie są osiągalne z zewnątrz. O cyklu obiektów możemy powiedzieć, że jest osiągalny z zewnątrz gdy co najmniej jeden obiekt w cyklu jest osiągalny z obiektu spoza cyklu. Prześledźmy zachowanie algorytmu na przykładzie.

Załóżmy, że garbage collector rozpoczyna pracę od takiej struktury obiektów. Zwróćmy uwagę na nowe pole wymagane przez moduł gc, nazwane na ilustracji gc_ref_cnt, które początkowo ma taką samą wartość jak wyjściowy licznik referencji ref_cnt:

W pierwszym kroku gc wykorzystuje funkcję tp_traverse kontenera w celu dekrementacji pola gc_ref_cnt. W wyniku tej operacji jedynie obiekty, które posiadają referencje spoza cyklu będą posiadały będą spełniały warunek: gc_ref_cnt > 0

Kolejnym krokiem jest oznaczenie wszystkich obiektów, dla których gc_ref_cnt = 0 jako potencjalnie nieosiągalne. Obiekty nie są uznawane jeszcze za nieosiągalne, ponieważ nadal mogą istnieć do nich odwołania z innych, osiągalnych obiektów.

W chwili gdy gc napotka obiekt, który jest osiągalny, wykorzystywana jest jego funkcja tp_traverse w celu oznaczenia wszystkich obiektów, które są z niego osiągalne. Ta sama operacja jest powtarzana dla wszystkich obiektów, które wcześniej były oznaczone jako potencjalnie nieosiągalne, a które okazały się osiągalne w wyniku poprzedniej operacji. W ten sposób graf obiektów przeglądany jest “w głąb” w celu oznaczenia wszystkich osiągalnych obiektów. Po przeglądnięciu wszystkich obiektów wiadomo, że obiekty wcześniej oznaczone jako potencjalnie nieosiągalne są rzeczywiście nieosiągalne i mogą zostać zniszczone.

Powyższe wyjaśnienie działania modułu garbage collectora pomija aspekty związane z optymalizacją czasu działania algorytmu skupiając się wyłącznie na ilustracji jego działania. Zagadnienia związane z optymalizacją są interesujące, jednak nie zostaną one omówione w ramach tego artykułu.

Zagadnienia poruszone w artykule mogą pomóc zrozumieć zachowanie interpretera w określonych sytuacjach, a także ich analiza jest swego rodzaju case study z zakresu architektury kodu zorientowanego obiektowo z wykorzystaniem języków programowania nie posiadających wsparcia obiektowości. Poznanie realizacji określonych odpowiedzialności maszyny wirtualnej na przykładzie PVM, może pomóc również w zrozumieniu zasady działania innych, podobnych środowisk. 

<p>Loading...</p>