Poprzednio pisałem o obiektowości w C, dzisiaj kolej na kolejny wzorzec zapożyczony z języków wyższego poziomu – interfejsy. W tym artykule opiszę jak je implementować w C i jakie dzięki temu możemy odnieść korzyści.
Do czego służą interfejsy?
W dobrze zaprojektowanej architekturze warstwy znajdujące się na wyższym poziomie abstrakcji nie zależą od konkretnej implementacji elementów z warstw niższych. Dzięki temu łatwiej jest wprowadzać zmiany w projekcie, a w przypadku edycji tych niższych warstw kompilacja inkrementalna trwa krócej. Jest to zgodne z jedną zasad SOLID – Open-Closed Principle głoszącą, iż powinniśmy mieć możliwość rozszerzania funkcjonalności modułu bez zmieniania jego implementacji. W programowaniu obiektowym realizujemy tę zasadę wykorzystując interfejsy. Definiują nam one zestaw operacji, jakie możemy wykonać na danym obiekcie, jednak ich realizacja zależy od konkretnej implementacji. Interfejsy są nieodzownym elementem architektury systemów pisanych w nowoczesnych językach.
Jak to wygląda w C?
Jako, iż C jest językiem proceduralnym – naszym głównym orężem są funkcje. W kodzie wyższych warstw najczęściej bezpośrednio wywołujemy funkcje niższego poziomu. Nieraz zdarzają nam się również odwrotne zależności – kod wysokopoziomowy wywołany na niższej warstwie. Bardzo łatwo stracić panowanie nad takim kodem i skończyć z niemożliwym do utrzymania spaghetti. Stosowanie interfejsów może nas przed tym uratować, bo wymusza na programiście lepsze przemyślenie API swoich modułów.
Implementacja interfejsów w C
C nie został domyślnie wyposażony w interfejsy – musimy więc je sobie sami zaimplementować. Na szczęście mamy wskaźniki na funkcje. Przykładowy interfejs drivera komunikacyjnego może wyglądać tak:
typedef void (*transmit_t)(uint8_t *buf, size_t size); typedef size_t (*receive_t)(uint8_t *buf, size_t max_size); typedef bool (*transfer_complete_t)(void); typedef bool (*new_data_ready_t)(void); struct com_driver_interface { transmit_t tx; receive_t rx; transfer_complete_t transfer_complete; new_data_ready_t new_data_ready; }Definicja takiego interfejsu powinna znaleźć się w headerze warstwy wyższej. Konkretna implementacja drivera powinna includować ten header i dostarczać funkcje realizujące zadany interfejs:
void uart_transmit(uint8_t *buf, size_t size); size_t uart_receive(uint8_t *buf, size_t max_size); bool uart_transfer_complete(void); bool uart_new_data_ready(void);Konkretny driver powinien również zawierać funkcję zwracającą implementację interfejsu:
static const struct com_driver_interface = { uart_transmit, uart_receive, uart_transfer_complete, uart_new_data_ready } const struct com_driver_interface * uart_interface_get(void) { return &com_driver_interface; }Zwracamy wskaźnik do interfejsu jako const, żeby uniemożliwić jego późniejsze modyfikacje. Kod wyższej warstwy może dostawać ten wskaźnik w inicie i dzięki temu nie musi includować konkretnej implementacji. Jest to przykład realizacji kolejnej zasady SOLID – Dependency Inversion Principle.
Testowalność
Dzięki stosowaniu interfejsów dużo łatwiejsze staje się testowanie kodu. Mocki będą zestawem funkcji implementujących ten sam interfejs co kod produkcyjny. Oba interfejsy możemy na zmianę wykorzystywać w różnych testach. Oczywiście pozwala nam to również łatwe rozszerzenie kodu produkcyjnego na przykład o kilka implementacji drivera używanych w różnych kontekstach. Korzystając ze zwykłych funkcji nie mielibyśmy takiej swobody.
o ile chcemy mockować zwykłe funkcje, musimy je podmieniać w fazie linkowania. Możemy także zdecydować się na jakąś ekwilibrystykę z preprocesorem. W obu przypadkach jednak musimy rozbić testy na wiele kompilowanych oddzielnie programów. Nie możemy w tym samym programie wywołać mocka i funkcji produkcyjnej o tej samej nazwie.
James Grenning w swojej książce „Test Driven Development for Embedded C” zalecał z tego powodu wykorzystanie wskaźników do funkcji, ale bez łączenia ich w struktury. Chodziło o to, żeby wywołania funkcji były takie same:
//oryginalna funkcja int FormatOutput(const char *, ...); //wskaźnik na funkcję - wywołanie jest takie samo extern int (*FormatOutput)(const char *, ...);Do tego pomysłu jakoś się nigdy nie przekonałem. Wizja posiadania headerów wypełnionych luźnymi wskaźnikami na funkcję wydawała mi się ogromną komplikacją i zaciemnieniem projektu. Połączenie tych wskaźników w strukturę i zdefiniowanie ich na wyższej warstwie jako wymaganie dla niższej wprowadza do kodu więcej porządku.
Podsumowanie
Interfejsy są bardzo przydatnym narzędziem z punktu widzenia architektury systemu. Szczególnie, jeżeli nasz projekt jest większy. Interfejsy pozwalają lepiej zarządzać kierunkiem zależności między warstwami, umożliwiają korzystanie z kilku alternatywnych implementacji tej samej funkcjonalności i ułatwiają testowanie. Przy okazji wymagają zmiany spojrzenia w stosunku do standardowego proceduralnego stylu pisania aplikacji w C.