Niedawno natknąłem się na ciekawy materiał pokazujący zastosowanie zasad programowania obiektowego w języku C. Materiał jest częścią dokumentacji frameworka QP/C i dosyć ściśle z nim związany, ale niezależnie od tego sam w sobie wydaje mi się na tyle wartościowy że uznałem iż warto go omówić. Część technik tam omówionych, myślę że warto stosować w praktyce, a mi osobiście materiał pozwolił zrozumieć idee stojące u fundamentów programowania obiektowego.
Z oryginałem można się zapoznać tutaj: https://state-machine.com/doc/AN_OOP_in_C.pdf. Nie będę wchodził zbytnio w szczegóły tylko bardzo skrótowo opiszę podane zasady i przejdę do praktycznego ćwiczenia.
U podstaw programowania obiektowego leżą trzy zasady: hermetyzacja, dziedziczenie, polimorfizm.
1.Enkapsulacja to zamknięcie składowych klasy (klasa to, powiedzmy w tym wypadku, niewielki, jednoplikowy, moduł programowy) w jakiejś zamkniętej całości. Na gruncie języka C polega to na zgrupowaniu atrybutów klasy w strukturze. Instancje klasy powstają poprzez powołanie zmiennej o tymże typie strukturalnym. Wywołanie metody danej klasy następuje przez wywołanie funkcji z przekazaniem wskaźnika do tej zmiennej.
2.Dziedziczenie to tworzenie nowej klasy (subklasy) na podstawie klasy już istniejącej (klasy bazowej). Utworzona subklasa wykorzystuje metody i atrybuty klasy bazowej ale ją rozszerza przez wprowadzenie swoich własnych atrybutów i metod. Na gruncie języka C polega to na włączeniu struktury klasy bazowej do struktury subklasy. Struktura klasy bazowej musi być włączona na początku struktury subklasy dzięki temu zmienna będąca instancją subklasy może poprzez rzutowanie typu być wykorzystana w wywołaniu metod klasy bazowej jak i metod subklasy.
3.Polimorfizm to możliwość odmiennego zachowania danej metody przy wywołaniu dla klasy bazowej a innego dla subklasy. Pozwala to, z jednej strony na uniwersalne (generyczne) zaprogramowanie wywołania metody, z drugiej strony na uzyskanie odmiennego efektu dla różnych klas. Jest kilka odmian polimorfizmu, w przytoczonym materiale przedstawione są metody wirtualne, czyli ustalane na etapie wykonania (late binding). Polimorfizm w tym przypadku realizowany jest przez włączenie do struktury klasy wskaźnika na statyczną funkcję realizującą daną metodę.
To tyle jeśli chodzi o teorię. Ażeby teoria lepiej wchodziła do głowy warto ją przećwiczyć praktycznie. Na potrzeby treningu, jednym okiem patrząc na przykłady podane w przytoczonym materiale, wymyśliłem sobie bazową klasę o nazwie "Bufor" z dwiema klasami dziedziczącymi "Lifo" i "Fifo". Jak widać po nazwach klasy będą miały za zadanie umożliwić przechowanie jakiejś puli danych a następnie dostęp do tych danych. Klasa Lifo będzie "oddawać" zapisane dane w kolejności od ostatnio zapisanego elementu, klasa Fifo będzie oddawać od pierwszego zapisanego elementu. Klasa Bufor będzie realizować większość metod ale nie umożliwi dostępu do danych.
Nagłówek klasy Bufor niech będzie następujący:
Kod: Zaznacz cały
#ifndef BUFOR_H_
#define BUFOR_H_
#define MAXSIZE 100 //maksymalna wielkość bufora
struct BuforVtbl;
typedef struct {
struct BuforVtbl const *vptr;
char *buf; //wskaźnik na obszar pamięci bufora
int size; //wielkość bufora
int saved_num; //ilość zapisanych elementów
int last; //indeks do ostatnio zapisanego elementu
} Bufor;
struct BuforVtbl {
int (*fetch)(Bufor * const me, char *elem);
};
void Bufor_ctor(Bufor * const me, int size); //zainicjowanie bufora o wiekości size<=MAXSIZE
int Bufor_stash(Bufor * const me, char element); //zapisanie znaku w buforze
int Bufor_fetch(Bufor * const me, char *elem); //zdjęcie znaku z bufora
#endif /* BUFOR_H_ */
typedef {} Bufor; to typ klasy, struct BuforVtbl jest typem pomocniczym na potrzeby polimorfizmu. Metoda Bufor_ctor() to konstruktor klasy, który w języku C trzeba wywołać jawnie przed pierwszym użyciem klasy (w C++ konstruktor jest wywoływany niejawnie). Metody Bufor_stash() i Bufor_fetch() to oczywiście metody klasy, odpowiednio, zapisująca dane w buforze i odczytująca dane z bufora. W przypadku klasy Bufor metoda Bufor_fetch jest "pure virtual" czyli metodą abstrakcyjną, której nie można wywołać i której implementacja musi być dostarczona przez klasę dziedziczącą. A oto definicje metod tej klasy:
Kod: Zaznacz cały
#include <assert.h>
#include "bufor.h"
static int Bufor_fetch_(Bufor * const me, char *elem);
static struct BuforVtbl const vtbl = {
Bufor_fetch_
};
void Bufor_ctor(Bufor * const me, int size) //zainicjowanie bufora o wiekości size<=MAXSIZE
{
static char buffer[MAXSIZE];
me->vptr = &vtbl;
me->buf = buffer;
me->size = size > MAXSIZE ? MAXSIZE : size;
me->saved_num = 0;
me->last = -1;
}
int Bufor_stash(Bufor * const me, char element) //wepchnięcie danej do bufora
{
if(me->saved_num < me->size)
{
int tmp = ++me->last;
tmp %= me->size; //zawijanie indeksu dla bufora kołowego
me->saved_num++;
me->buf[tmp] = element;
return 0;
}
else
{
return -1;
}
}
//wywołanie funkcji wirtualnej
int Bufor_fetch(Bufor * const me, char *elem)
{
return (*me->vptr->fetch)(me,elem);
}
static int Bufor_fetch_(Bufor * const me, char *elem)
{
assert(0);/* purely-virtual function should never be called */
return -1;
}
Ażeby nie grzęznąć w alokacjach dynamicznych, na potrzeby bufora zarezerwowałem statyczną tablicę o jakiejś tam wielkości, z której konkretnie wykorzystywana jest tylko część podana w parametrze konstruktora klasy. Jak widać konstruktor oprócz inicjacji zwykłych atrybutów klasy, dokonuje też inicjacji zpolimorfizowanej metody _fetch. Odbywa się to przez przypisanie do vtbl wskaźnika na statyczną funkcję implementującą metodę Bufor_fetch_() (uwaga na podkreślnik w nazwie). Funkcja-metoda o widoczności zewnętrznej Bufor_fetch() (bez podkreślnika) jest tylko funkcją pośrednią, klejem łączącym interfejs z implementacją. Można równie dobrze zrealizować ją jako makro
Kod: Zaznacz cały
#define Bufor_fetch(me_,elem_) ((*(me_)->vptr->fetch)((me_),(elem_)))
Nagłówek klasy Lifo:
Kod: Zaznacz cały
#ifndef LIFO_H_
#define LIFO_H_
#include "bufor.h"
typedef struct {
Bufor super; //dziedziczenie klasy
} Lifo;
void Lifo_ctor(Lifo * const me, int size); //zainicjowanie bufora o wiekości size<=MAXSIZE
#endif /* LIFO_H_ */
Klasa Lifo nie wprowadza żadnych dodatkowych atrybutów i metod, ale dostarcza implementacji dla metody _fetch, dlatego potrzebuje własnego konstruktora.
Kod: Zaznacz cały
#include "lifo.h"
static int Lifo_fetch_(Bufor * const me, char *elem);
static struct BuforVtbl const vtbl = {
Lifo_fetch_
};
void Lifo_ctor(Lifo * const me, int size)
{
Bufor_ctor(&me->super, size);
me->super.vptr = &vtbl;
}
//Wirtualna funkcja Bufor_fetch dla klasy Lifo zwraca elementy "od końca"
static int Lifo_fetch_(Bufor * const me, char *elem)
{
Lifo * me_ = (Lifo*)me;
if( me_->super.saved_num > 0 )
{
me_->super.saved_num--;
*elem = me_->super.buf[me_->super.last--];
return 0;
}
else
return -1;
}
W konstruktorze klasy Lifo widać przejawy dziedziczenia: najpierw wywoływany jest konstruktor klasy bazowej, następnie wykonuje się uzupełniające operacje dla atrybutów i metod specyficznych dla klasy. W tym wypadku jest to reinicjacja wskaźnika dla zreimplementowanej metody _fetch().
No i w końcu kod dla klasy Fifo:
Kod: Zaznacz cały
#ifndef FIFO_H_
#define FIFO_H_
#include "bufor.h"
typedef struct {
Bufor super; //Dziedziczenie klasy Bufor
int first; //indeks komórki przed elementem kolejnym do zwrócenia
} Fifo;
void Fifo_ctor(Fifo * const me, int size); //zainicjowanie bufora o wiekości size<=MAXSIZE
#endif /* FIFO_H_ */
Klasa wprowadza dodatkowy atrybut potrzebny metodzie _fetch() (tak naprawdę funkcję można by napisać bez dodatkowego atrybutu, ale przecież chodzi o ćwiczenie).
Kod: Zaznacz cały
#include "fifo.h"
static int Fifo_fetch_(Bufor * const me, char *elem);
static struct BuforVtbl const vtbl = {
Fifo_fetch_
};
void Fifo_ctor(Fifo * const me, int size)
{
Bufor_ctor(&me->super, size);
me->super.vptr = &vtbl;
me->first = -1;
}
//Wirtualna funkcja Bufor_fetch dla klasy Fifo zwraca elementy "od początku"
static int Fifo_fetch_(Bufor * const me, char *elem)
{
Fifo * me_ = (Fifo*)me;
if( me_->super.saved_num > 0 )
{
int tmp = ++me_->first;
tmp %= me_->super.size;
me_->super.saved_num--;
*elem = me_->super.buf[tmp];
return 0;
}
else
return -1;
}
Tu nic nowego się nie dzieje więc nie ma co opisywać.
Na koniec prościutki program mający za zadanie powołać dwa bufory, jeden klasy Lifo, drugi klasy Fifo, zapisać do nich po 10 pierwszych liter alfabetu w kolejności rosnącej a następnie odczytać zapisane znaki. Dla bufora Lifo odczytane znaki będą pojawiać się w kolejności malejącej, dla bufora Fifo w kolejności rosnącej.
Kod: Zaznacz cały
#include <stdio.h>
#include <stdint.h>
#include "lifo.h"
#include "fifo.h"
int main()
{
Lifo lifo_buffer;
Fifo fifo_buffer;
Lifo_ctor(&lifo_buffer, 10);
Fifo_ctor(&fifo_buffer, 10);
for(char i = 0, ch='A'; i<10; i++, ch++)
{
Bufor_stash((Bufor*)&lifo_buffer, ch);
Bufor_stash((Bufor*)&fifo_buffer, ch);
}
for(char i = 0, ch; i<10; i++)
{
Bufor_fetch((Bufor*)&lifo_buffer, &ch);
putchar(ch);
}
putchar('\n');
for(char i = 0, ch; i<10; i++)
{
Bufor_fetch((Bufor*)&fifo_buffer, &ch);
putchar(ch);
}
putchar('\n');
return 0;
}
Jak widać, odczyt znaków z obu buforów odbywa się w identyczny sposób, jednak dzięki polimofizmowi uzyskane efekty są inne.
Niestety polimorfizm w tym kształcie jest bardzo kłopotliwy i nieodporny na błędy. Proponuję "omyłkowo" wywołać konstruktor dla bufora lifo w poniższy sposób i zobaczyć co się stanie.
Kod: Zaznacz cały
Bufor_ctor(&lifo_buffer, 10);
Z kolei wywołanie czegoś takiego
Kod: Zaznacz cały
Fifo_ctor(&lifo_buffer, 10);
prawdopodobnie skończy się katastrofą.
Sam autor materiału pisze: jeśli chcesz używać polimorfizmu na większą skalę, prawdopodobnie powinieneś przerzucić się na C++.
To właściwie tyle w tym temacie. Trochę niefajnie, że wnętrzności struktury klasowej są wystawione na widok w pliku nagłówkowym. Dlatego w drugiej fazie ćwiczenia przerobiłem trochę kod wprowadzając tzw. opaque pointer. W tej postaci klasa ujawnia publiczności tylko to co jest niezbędne. Druga wersja w załączniku.
Have fun!