Jak uzyskać obiektowość w C?

W języku C nie mamy czegoś takiego jak klasy. Jeżeli 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. Jeżeli 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 za pomocą wskaźnika do niepełnego typu. W internecie można znaleźć to rozwiązanie pod nazwą Abstract Data Type. Chodzi o to, że w headerze możemy wykorzystywać wskaźnik do struktury dla której nie podajemy definicji. Będzie to działać tylko jeśli 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, że 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. Jeżeli rzeczywiście potrzebujesz dziedziczenia i polimorfizmu to używaj języków które mają wbudowane wsparcie!

Obiekty w C w praktyce

To tyle jeżeli 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, że 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, że 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 za pomocą 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, że 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, że 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.

2 Comments

  1. Jakub Standarski

    13 lutego 2020 at 11:26

    Świetny artykuł mający na celu głównie uświadomienie programistów C jakie błędy popełniają podczas projektowania czy to całych systemów czy pojedynczych funkcjonalności. Brakuje tego typu tematów poruszanych w świecie embeddded, skupiamy się głównie na nowinkach technologicznych, na nowym HW, na nowych językach a zapominamy o fundamentach SOLIDnego oprogramowania. Mam nadzieję, że „embedded” będzie czerpać garściami od wysoko-poziomowych kolegów.

  2. Ja czasami robię sobie wzorzec singleton w C ale wątpię aby to był dobry kod, tak więc nikomu go nie polecam. Ale przynajmniej ładnie wygląda kiedy się go wywołuje.
    W pliku nagłówkowym piszę sobie wtedy np:

    typedef enum {
    OPTION_1,
    OPTION_2,
    } Option;

    typedef struct {
    void (* Init) (int param1, int param2);
    void (* Reset) (void);
    void (* Configure) (Option option);
    } SysCtrl;
    extern SysCtrl sysCtrl;

    i potem w pliku źródłowym .c:

    static void SysCtrl_Init(int param1, int param2) {
    //…
    }
    static void SysCtrl_Reset(void) {
    //…
    }
    static void SysCtrl_Configure(Option option) {
    //…
    }
    SysCtrl sysCtrl = {
    .Init = SysCtrl_Init,
    .Reset = SysCtrl_Reset,
    .Configure = SysCtrl_Configure,
    };

    i potem gdzieś mogę w kodzie wywoływać funkcje na obiekcie sysCtrl:

    int main() {
    sysCtrl.Configure(OPTION_1);
    return 0;
    }

    Co do programowania obiektowego można się naprawdę pobawić jak to ogarnąć w języku C. Swego czasu interesowałem się tym tematem i różne pomysły chodziły mi po głowie, i próbowałem jakoś to zrobić z pomocą makr, niestety zawsze w taki sposób kończy się na wywołaniu funkcji które tak naprawdę jest w jakimś wywołaniu makra (zawsze bez sensu jest wołać funkcję na rzecz obiektu i ten obiekt jeszcze przekazywać jako parametr):
    #define oo_call

    oo_call(my_obj, my_func, param1, param2); // cos w tym stylu
    // zamiast my_obj->my_func(my_obj, param1, param2);

    myślałem co prawda nad tym, czy by się nie dało jakoś w funkcji przez wstawkę asemblerową odczytać adres obiektu na którego rzecz została wywołana funkcja, ale pierwsza rzecz to wątpię by to było możliwe, druga asembler jest specyficzny od platformy, więc to na nic.

    Poza tym polecam dokument dla ciekawskich tej tematyki:
    https://www.cs.rit.edu/~ats/books/ooc.pdf

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *