W języku C nie mamy czegoś takiego jak klasy. o ile chcemy pisać programy w sposób obiektowy musimy wykorzystać w tym celu struktury i funkcje. Traktowanie modułów większego systemu jako obiektów zawierających pewne dane i umożliwiających operacje na nich jest sposobem na ukrycie szczegółów implementacyjnych. Abstrakcje pomagają zapanować nad złożonością projektów. Dlatego mimo, iż obiektowość w C musimy emulować, chętnie sięgamy choćby po niektóre elementy obiektowości. W tym wpisie pokażę jak poprawnie implementować obiektowość w C.
Obiekt jako struktura
Jak to wygląda w kodzie? Musimy mieć strukturę przechowującą stan obiektu oraz funkcje definiujące możliwe operacje:
struct circular_buffer { uint32_t head; uint32_t tail; item_t items[BUF_SIZE]; }; struct circular_buffer my_buffer; void my_buffer_init(void); void my_buffer_push(item_t item); item_t my_buffer_pop(void); bool my_buffer_is_full(void); bool my_buffer_is_empty(void);W powyższym przykładzie mamy tylko jedną instancję obiektu circular_buffer i każda funkcja działa na nim. o ile chcielibyśmy mieć możliwość korzystania z różnych instancji obiektu, musimy go przekazać jako parametr:
void circular_buffer_init(struct circular_buffer *this); void circular_buffer_push(struct circular_buffer *this, item_t item); ...To samo robią pod maską języki, które mają wbudowane wsparcie dla obiektowości np. C++. W Adzie, czy Ruście z kolei deklaracja metody przyjmuje obiekt jako pierwszy parametr, ale wywołania metody mają już taką samą składnię jak C++, czyli obiekt.metoda().
Prywatne pola i metody
Klasy posiadają możliwość ukrywania niektórych metod i pól jako prywatne. Nie są one wtedy dostępne dla zewnętrznych modułów pracujących z tymi obiektami. Aby podobny efekt osiągnąć w C musimy wykorzystać podział na pliki .c i .h oraz modyfikator static. Cały publiczny interfejs klasy umieszczamy w pliku .h, natomiast metody prywatne deklarujemy jedynie w pliku .c jako static. Pozostaje jeszcze problem ukrywania danych, czyli naszej struktury.
Rozwiązujemy go dzięki wskaźnika do niepełnego typu. W internecie można znaleźć to rozwiązanie pod nazwą Abstract Data Type. Chodzi o to, iż w headerze możemy wykorzystywać wskaźnik do struktury dla której nie podajemy definicji. Będzie to działać tylko jeżeli kod wykorzystujący header nie wykona dereferencji pointera. W ten sposób kompilator obroni nas przed niechcianym dostępem do wewnętrznych elementów obiektu. Przejdźmy więc do kodu. W headerze deklarujemy nasz wskaźnik w następujący sposób:
typedef struct circular_buffer * cbuf_t;Funkcje wykorzystujące obiekt przyjmują wtedy postać:
void circular_buffer_init(cbuf_t this); void circular_buffer_push(cbuf_t this, item_t item);W pliku C podobnie jak poprzednio umieszczamy deklarację struktury, dzięki czemu możemy spokojnie używać tu jej pól.
Tworzenie obiektów
Takie podejście ma jedną dużą wadę – aby stworzyć obiekt tego typu kompilator musi mieć dostęp do pełnej deklaracji. Aby obejść to ograniczenie często stosuje się dynamiczną alokację pamięci. Plik .c zawiera wtedy funkcję:
cbuf_t circular_buffer_create(void) { cbuf_t buf; buf = malloc(sizeof(struct circular_buffer)); memset(buf, 0, sizeof(struct circular_buffer)); return buf; }Jeżeli robimy projekty embedded często jednak nie możemy stosować malloca. Wtedy możemy w pliku .c umieścić tablicę i w funkcji create zwracać jej kolejne elementy. Alternatywą jest udostępnienie całej struktury w pliku .h i liczenie na to, iż programiści powstrzymają się od edytowania jej pól gdzie indziej.
Dziedziczenie i polimorfizm
Klasy w językach obiektowych dają więcej możliwości niż tylko definiowanie danych i operacji na nich. Należą do nich dziedziczenie i polimorfizm. Oczywiście w C można je zaimplementować. W tym celu musimy się posługiwać wskaźnikami na funkcje tworzącymi tablice wirtualne oraz rzutowaniem jednych struktur na inne. Nie pokażę jednak jak to implementować w C, ponieważ jest to skomplikowane, podatne na błędy i trudne w utrzymaniu. o ile rzeczywiście potrzebujesz dziedziczenia i polimorfizmu to używaj języków które mają wbudowane wsparcie!
Obiekty w C w praktyce
To tyle o ile chodzi o implementację obiektowości. Jednak znajomość implementacji to jedno, a poprawne wykorzystanie to drugie. Programiści C nie są tak biegli w programowaniu obiektowym jak nasi koledzy zajmujący się językami wyższych poziomów. Nie ma w tym nic dziwnego – po prostu w C programujemy proceduralnie i tylko czasem wykorzystujemy elementy obiektowości. Niektóre koncepcje z innych języków są trudne do przeniesienia na grunt C. Poza tym specjalizujemy się w programowaniu niskopoziomowym i często mamy jednak pewne braki w wiedzy z innych dziedzin. Dlatego często wykorzystanie obiektowości nie broni naszych projektów przed przeobrażeniem się w wielkiego potwora spaghetti. Aby poprawnie korzystać z obiektów musimy przede wszystkim przestać traktować je jak worki na dane.
Najlepiej to o czym mówię zilustrować na przykładzie. Załóżmy, iż mamy następującą strukturę:
struct destination_point { float x; float y; bool is_updated; };Aktualizacje destination point otrzymujemy po porcie szeregowym, a następnie przekazujemy do modułu planowania trasy, który steruje silnikami aby dotrzeć do zadanego punktu. Wiemy, iż zewnętrzne moduły nie powinny bezpośrednio manipulować polami struktury, dlatego tworzymy funkcje:
float dest_point_x_get(void); void dest_point_x_set(float val); float dest_point_y_get(void); void dest_point_y_set(float val); bool dest_point_updated_get(void); void dest_point_updated_set(bool val);Niby nie umożliwiamy bezpośrednio manipulowania polami struktury, ale w praktyce dalej trzeba je edytować tylko dzięki funkcji. Czyli nic się nie zmieniło na lepsze. Kod wyższego poziomu korzystający z naszego modułu w dalszym ciągu musi mieć szczegółową wiedzę o zależnościach między parametrami, kolejnością ich ustawiania itp. Funkcja interfejsu szeregowego będzie wyglądać tak:
void com_rx(uint8_t *buf) { frame_t *frame = (frame_t *)buf; dest_point_x_set(buf->x); dest_point_y_set(buf->y); dest_point_updated_set(true); }A funkcja modułu planowania trasy:
void planner_check_update(void) { if (TRUE == dest_point_updated_get()) { float x = dest_point_x_get(); float y = dest_point_y_get(); planner_start(x, y); dest_point_updated_set(false); } }W tym rozwiązaniu udostępniamy na zewnątrz szczegóły implementacyjne. Wiemy choćby, iż istnieje jakaś flaga updated ustawiana, gdy przyjdą nowe dane i kasowana po ich odczytaniu. Musimy też sami pamiętać o jej wyczyszczeniu po odczycie. Ewentualna zmiana implementacji jest utrudniona, bo użytkownicy dest_point korzystają z tej flagi.
O ile łatwiej by było, gdybyśmy myśleli o obiekcie i operacjach na nim, a nie o konkretnych danych. Funkcje dest_point mogłyby wyglądać wtedy następująco:
void dest_point_update(float x, float y); bool dest_point_is_new(void); void dest_point_read(float *x, float *y);Teraz z zewnątrz w ogóle nie wiadomo o żadnej fladze updated. Jest ona szczegółem implementacyjnym. W funkcji update ją ustawiamy, w is new sprawdzamy, a w read kasujemy. Jednak użytkownicy modułu nie muszą się tym martwić. Możemy również zastąpić flagę jakimś innym mechanizmem bez potrzeby edytowania wszystkich miejsc w kodzie.
Podsumowanie
Obiektowość jest ważnym narzędziem programisty pozwalającym tworzyć lepszą architekturę systemów. Mimo, iż C domyślnie nie wspiera obiektowości, warto korzystać z jej dobrodziejstw. Jako programiści C powinniśmy również czerpać dobre praktyki z innych języków. Tam koncepcje związane z obiektowością są prostsze do zrozumienia dzięki bardziej rozbudowanej składni języka.
W realnych projektach wyodrębnienie obiektów i określenie minimalnego zestawu operacji na nich bywa trudne. Często tworzymy błędne rozwiązania i zatruwają nam one architekturę do końca życia projektu. Dlatego warto poświęcić na to więcej czasu, wykonać kilka podejść, porównać rezultaty. Lepiej poświęcić kilka dni więcej na implementację, niż potem mierzyć się ze skutkami złych decyzji przez miesiące czy lata.