Αντικειμενοστρεφής προγραμματισμός στη C
Η βασική διαφορά του αντικειμενοστρεφούς προγραμματισμού από τον διαδικαστικό προγραμματισμό είναι η συσχέτιση των δεδομένων με τις λειτουργίες που μπορούν να δεχθούν/πραγματοποιήσουν. Η συσχέτιση αυτή πραγματοποιείται με μία από τις βασικές μονάδες του αντικειμενοστρεφούς προγραμματισμού, την κλάση.
Αν και η γλώσσα προγραμματισμού C είναι πολύ ισχυρή, με αυξημένη δυνατότητα ελέγχου του υλικού και των παραμέτρων αυτού, δεν είναι αντικειμενοστρεφής. Εντούτοις, και με την χρήση των δυνατοτήτων της γλώσσας, μπορεί να προσομοιωθεί η λειτουργικότητα που προσφέρουν οι αντικειμενοστρεφείς γλώσσες και ο αντικειμενοστρεφής προγραμματισμός.
Σε αυτό το άρθρο, θα παρουσιαστούν οι τρόποι με τους οποίους μπορούν να προσομοιωθούν οι εξής λειτουργίες:
- Κληρονομικότητα
- Πολυμορφισμός
- Πρότυπα
Μέσα από τις προαναφερθείσες λειτουργίες θα αναδειχθούν οι τρόποι και οι δυνατότητες της γλώσσας C με τους οποίους μπορεί να προσομοιώσει τις αναπτυγμένες λειτουργίες των αντικειμενοστρεφών γλωσσών.
Πολυμορφισμός στην C
ΕπεξεργασίαΟ πολυμορφισμός είναι μία από τις πιο σημαντικές έννοιες στον αντικειμενοστρεφή προγραμματισμό. Αν κοιτάξουμε όλα τα αρχικά πρότυπα σχεδίασης, σχεδόν όλα τους, χρησιμοποιούν τον πολυμορφισμό και τις ιδιότητές του.
Μέθοδοι Constructor και Destructor
ΕπεξεργασίαΣτην C++, οι δύο αυτές μέθοδοι είναι κάποιες ειδικές μέθοδοι που καλούνται όταν ένα αντικείμενο δημιουργείται ή καταστρέφεται αντιστοίχως. Ο χειριστής new κατανέμει την μνήμη για την κλάση και μετά καλεί τον κατασκευαστή (Constructor) της κλάσης αυτής. Αντίστοιχα, ο χειριστής delete καλεί πρώτα τον Destructor της κλάσης και μετά αποδεσμεύει την μνήμη που χρησιμοποιούσε η κλάση.
Διάταξη μνήμης ενός αντικειμένου
ΕπεξεργασίαΣτην C πρέπει να δημιουργήσουμε δύο μεθόδους για κάθε κλάση. Μία για τον Constructor και μία για τον Destructor. Το όνομα της μεθόδου του Constructor θα είναι το όνομα της κλάσης μαζί με το _Ctor και το όνομα της μεθόδου του Destructor θα είναι το όνομα της κλάσης μαζί με το _Dtor (π.χ. για την κλάση X, θα έχουμε: X_Ctor και X_Dtor).
Στην C++, όταν δημιουργείται ένα αντικείμενο, κατανέμεται μνήμη μόνο για τα δεδομένα της κλάσης. Υπάρχει μόνο ένα αντίγραφο των μεθόδων μελών και διαμοιράζονται σε όλα τα στιγμιότυπα της κλάσης.
Στην C, για να υλοποιήσουμε αυτή την συμπεριφορά, δημιουργούμε όλες τις μεθόδους όλων των κλάσεων ως καθολικές (global )μεθόδους (αυτό συμπεριλαμβάνει και τις μεθόδους Constructor και Destructor). Τα ονόματα των μεθόδων θα έχουν στην αρχή το όνομα της κλάσης στην οποία ανήκουν και μετά underscore και το όνομά τους (π.χ. για την μέθοδο Two της κλάσης X, θα είναι X_Two). Επιπλέον είμαστε υποχρεωμένοι να βάλουμε τα δεδομένα της κλάσης σε μία δομή (structure) της C καθότι η λέξη class δεν είναι διαθέσιμη στην C.
Στην C++, μόνο ένα αντίγραφο των μεθόδων μελών υπάρχει στην μνήμη και για να διακρίνουμε τα στιγμιότυπα των κλάσεων μεταξύ τους, χρησιμοποιούμε τον δείκτη this, ο οποίος δείχνει κάθε στιγμή στο στιγμιότυπο που δημιουργήθηκε. Οι μέθοδοι χρησιμοποιούν τον δείκτη this για να αποκτούν πρόσβαση στα δεδομένα. Εσωτερικά, η διεύθυνση στον δείκτη this δίνεται στις μεθόδους μέλη μέσα από τον καταχωρητή ecx του μικροεπεξεργαστή.
Στην C, για να υλοποιήσουμε τον δείκτη this, δηλώνουμε μία μεταβλητή global integer που ονομάζουμε ECX. Πριν καλέσουμε οποιαδήποτε μέθοδο, η μεταβλητή ECX θα τεθεί ώστε να δείχνει στην διεύθυνση μνήμης που χρησιμοποιεί η δομή δεδομένων μας. Όλες οι μέθοδοι χρησιμοποιούν, έτσι, την μεταβλητή ECX ώστε να αποκτήσουν πρόσβαση στα μέλη της δομής.
Εικονικός πίνακας και δείκτης εικονικού πίνακα
ΕπεξεργασίαΣτην C++, κάθε κλάση που έχει τουλάχιστον μία εικονική μέθοδο, θα έχει έναν σχετικό εικονικό πίνακα. Δεν πρόκειται για τίποτα άλλο παρά για ένα πίνακα με δείκτες μεθόδων. Αυτός ο πίνακας για μία κλάση περιέχει τις διευθύνσεις των εικονικών μεθόδων που περιέχονται στην κλάση καθώς και τις διευθύνσεις των εικονικών μεθόδων που κληρονομεί η κλάση. Στην περίπτωση των κληρονομούμενων μεθόδων, λαμβάνονται υπ' όψιν μόνο αυτές που δεν υπερκαλύπτονται. (Function Override)
Η σειρά με την οποία συμπληρώνεται ο εικονικός πίνακας έχει ως εξής:
- Προστίθεται στον πίνακα η διεύθυνση εικονικού Destructor.
- Προστίθεται στον πίνακα η διεύθυνση των εικονικών μεθόδων που κληρονομούνται από την αρχική κλάση, βάσει της σειράς δήλωσης.
- Οι διευθύνσεις των μεθόδων που υπερκαλύπτουν θα αντικαταστήσουν τις διευθύνσεις των κληρονομούμενων μεθόδων.
- Οι διευθύνσεις των νέων εικονικών μεθόδων που δηλώνονται στην κλάση προστίθεται, βάσει της σειράς δήλωσης.
Στην C, θα υλοποιήσουμε τις εικονικές μεθόδους ως global πίνακες δεικτών void και θα γεμίσουμε τον πίνακα με τις διευθύνσεις των εικονικών μεθόδων της κλάσης. Στην περίπτωση μας, υπάρχουν τρεις κλάσεις με εικονικές μεθόδους και έτσι, θα υπάρχουν τρεις εικονικοί πίνακες.
Ο εικονικός πίνακας για την κλάση X θα περιέχει τις διευθύνσεις του Destructor και τις διευθύνσεις των τριών εικονικών μεθόδων.
Ο εικονικός πίνακας για την κλάση Y θα περιέχει τις διευθύνσεις του Destructor και τις διευθύνσεις των εικονικών μεθόδων που κληρονομούνται από την κλάση X. Στην περίπτωση της μεθόδου Y_One που υπερκαλύπτει την X_One, η διεύθυνση της δεύτερης θα αντικατασταθεί από την διεύθυνση της πρώτης στον πίνακα.
Αντιστοίχως, ο εικονικός πίνακας για την κλάση Z θα περιέχει την διεύθυνση τους Destructor και τις διευθύνσεις των εικονικών μεθόδων που κληρονομούνται από την κλάση Y. Η διεύθυνση της υπερκαλυπτόμενης μεθόδου Z_Two θα αντικαταστήσει την διεύθυνση της κληρονομούμενης μεθόδου X_Two.
Κληρονομικότητα
ΕπεξεργασίαΚληρονομικότητα στον αντικειμενοστρεφή προγραμματισμό αναφέρεται στη δυνατότητα επαναχρησιμοποίησης κώδικα από ήδη υπάρχοντα αντικείμενα, στη δημιουργία νέων αντικειμένων τα οποία μοιράζονται ιδιότητες με ήδη υπάρχοντα ή και τα δύο ανάλογα τη γλώσσα προγραμματισμού. Ένα αντικείμενο αντικείμενο αποτελεί ένα στιγμιότυπο μιας κλάσης, οι κλάσεις μπορούν να κληρονομούν από άλλες κλάσεις τόσο τα γνωρίσματα όσο και της μεθόδους. Η κύρια κλάση ονομάζεται βάση (base class), ενώ οι κλάσεις οι οποίες κληρονομούν από αυτή ονομάζονται παραγόμενες (derived). Η "base class" επιτρέπει τη δημιουργία μίας γενικής διεπαφής που παρέχει πιο εξειδικευμένες λειτουργίες με τη χρήση εικονικών μεθόδων (virtual functions).
Από τη C++ στη C
ΕπεξεργασίαΠριν παρουσιάσουμε την κληρονομικότητα στη C πρέπει να δούμε πως αναπαριστούμε μία κλάση. Ορίζουμε λοιπόν την κλάση "Person" στη γλώσσα C++, η οποία αποτελείται από τα πεδία pFirstName, pLastName; καθώς και κάποιες μεθόδους οι οποίες επιδρούν στα δεδομένα αυτά.
class Person
{
private:
char* pFirstName;
char* pLastName;
public:
Person(const char* pFirstName, const char* pLastName);
~Person();
void displayInfo();
void writeToFile(const char* pFileName);
};
Για να αναπαραστήσουμε την κλάση "Person" στη γλώσσα C θα χρησιμοποιήσουμε δομές, και συναρτήσεις που επιδρούν πάνω τους. Για παράδειγμα η κλάση "Person" θα μπορούσε να γραφεί στη γλώσσα C ως εξής[1]:
typedef struct _Person Person;
typedef void (*fptrDisplayInfo)(Person*);
typedef void (*fptrWriteToFile)( Person*, const char*);
typedef void (*fptrDelete)( Person *) ;
typedef struct _Person
{
char* pFName;
char* pLName;
fptrDisplayInfo Display;
fptrWriteToFile WriteToFile;
fptrDelete Delete;
} Person;
person* new_Person(const char* const pFirstName, const char* const pLastName);
void delete_Person(Person* const pPersonObj);
void Person_DisplayInfo(Person* const pPersonObj);
void Person_WriteToFile(Person* const pPersonObj, const char* pFileName);
Η χρήση δεικτών σε συναρτήσεις γίνεται ώστε να επιτευχθεί η ενθυλάκωση, η σύνδεση δηλαδή των δεδομένων με τις μεθόδους. Οι αντικειμενοστρεφείς γλώσσες προγραμματισμού παρέχουν τη δυνατότητα άμεσης πρόσβασης των δεδομένων μέσα από τις μεθόδους της κλάσης με τη χρήση του δείκτη "this". Καθώς ο δείκτης αυτός δεν υπάρχει στη γλώσσα C θα πρέπει με κάποιον τρόπο οι μέθοδοι να γνωρίζουν από ποιο αντικείμενο να αντλήσουν και να τροποποιήσουν τα δεδομένα. Για να γίνει αυτό θα πρέπει να υπάρχει μια αναφορά στο αντικείμενο το οποίο θα τροποποιήσουν, η οποία δίνεται σαν όρισμα.
Κληρονομικότητα C
ΕπεξεργασίαΓια να επιτύχουμε την κληρονομικότητα στη C θα πρέπει με κάποιον τρόπο η παραγόμενη κλάση να γνωρίζει ποια είναι η κλάση βάση, να έχει δηλαδή μία αναφορά προς την κλάση βάση. Αυτό επιτυγχάνεται με την προσθήκη ενός στιγμιότυπου, της κλάσης βάση, στην παραγόμενη κλάση. Μπορούμε λοιπόν να μετατρέψουμε (casting) την παραγόμενη κλάση στην κλάση βάση (πολυμορφισμός), αλλά και να χρησιμοποιήσουμε της μεθόδους της κλάσης βάσης (κληρονομικότητα).
struct base
{
/* base class members */
};
struct derived
{
struct base super;
/* derived class members */
};
struct derived d;
struct base *base_ptr = (struct base *)&d; // upcast
struct derived derived_ptr = (struct derived *)base_ptr; // downcast
Παράδειγμα
ΕπεξεργασίαΘα δημιουργήσουμε μία κλάση "Point" η οποία αντιπροσωπεύει ένα σημείο στο δισδιάστατο χώρο. Η κλάση αυτή θα περιέχει δύο ακέραιες μεταβλητές, οι οποίες αντιπροσωπεύουν τις συντεταγμένες του σημείου. Θα παρέχουμε επίσης τη δυνατότητα δημιουργίας και καταστροφής του αντικειμένου καθώς επίσης και τη δυνατότητα μετακίνησής του. Θέλουμε επίσης και την κλάση "Circle" η οποία θα αντιπροσωπεύει ένα κύκλο. Η κλάση αυτή θα αποτελείται απ ο ένα σημείο καθώς επίσης και μία επιπλέον μεταβλητή η οποία θα αποτελεί την ακτίνα του κύκλου και η κλάση αυτή θα παρέχει μία μέθοδο δημιουργίας και διαγραφής καθώς επίσης και μία μέθοδο μετακίνησης.
Η κλάση "Class"
ΕπεξεργασίαΘέλουμε να κατασκευάσουμε την κλάση "Point" με τέτοιο τρόπο ώστε η κλάση "Circle" να μπορεί να κληρονομήσει απ ο αυτή. Κάθε κλάση στον αντικειμενοστρεφή προγραμματισμό έχει κάποια κοινά χαρακτηριστικά με τις υπόλοιπες κλάσεις (κάθε κλάση έχει ένα κατασκευαστή), για να διατηρήσουμε μία συνοχή με τις αντικειμενοστρεφείς γλώσσες θα πρέπει να παρέχουμε μία παρόμοια διεπαφή. Καθώς το να παρέχουμε ένα γενικό τρόπο κατασκευής και δημιουργίας αντικειμένων προσθέτει αρκετά προβλήματα, κάνει τον κώδικα επιρρεπή σε λάθη και περιορίζει τις δυνατότητες θα πρέπει το κάθε αντικείμενο να γνωρίζει τη πόρους χρειάζεται καθώς και πώς να τους ελευθερώσει. Έτσι μπορούμε να χρησιμοποιούμε τη γενική συνάρτηση "new()" η οποία θα αναλαμβάνει να δημιουργεί τα αντικείμενα και η συνάρτηση "delete()" η οποία θα καταστρέφει το αντικείμενο.
struct Class {
size_t size;
void * (* ctor) (void * self, va_list * app);
void * (* dtor) (void * self);
};
Η μεταβλητή "size" περιέχει το μέγεθος το οποίο θα δεσμεύσει η "new()" για το αντικείμενο, "ctor" είναι η συνάρτηση δημιουργίας του αντικειμένου η οποία λαμβάνει σαν είσοδο τη μνήμη που έχει δεσμεύσει η "new()" καθώς και επιπλέον ορίσματα για την αρχικοποίηση των μεταβλητών, "dtor" είναι η συνάρτηση καταστροφής ενός αντικειμένου οι οποία δέχεται το αντικείμενο προς διαγραφή.
void * new (const void * _class, ...){
const struct Class * class = _class;
void * p = calloc(1, class -> size);
assert(p);
* (const struct Class **) p = class;
if (class -> ctor){
va_list ap;
va_start(ap, _class);
p = class -> ctor(p, & ap);
va_end(ap);
}
return p;
}
Η συνάρτηση "new()" δημιουργεί το νέο αντικείμενο και επιστρέφει ένα δείκτη σε αυτό. Μετά τη δημιουργία του αντικειμένου η μεταβλητή "p" δείχνει στο νέο αντικείμενο και ο δείκτης "class" του αντικειμένου "δείχνει" στην αρχή του αντικειμένου. Εάν υπάρχει κατασκευαστής για το αντικείμενο τότε καλούμε τον κατασκευαστή και επιστρέφουμε το αποτέλεσμά του, δηλαδή το νέο αντικείμενο.
Η συνάρτηση "delete()" διαγράφει το αντικείμενο καλώντας τη συνάρτηση καταστροφής του αντικειμένου, η οποία είναι υπεύθυνη για την απελευθέρωση τον πόρων που έχει δεσμεύσει.
void delete (void * self){
const struct Class ** cp = self;
if (self && * cp && (* cp) -> dtor)
self = (* cp) -> dtor(self);
free(self);
}
Η κλάση "Point"
ΕπεξεργασίαΜπορούμε να χρησιμοποιήσουμε την κλάση "Class" προσθέτοντας ένα δείκτη προς τη μέθοδο void (* draw) (const void * self) η οποία θα σχεδιάζει το σημείο.
struct Point {
const void * class;
int x, y; /* coordinates */
};
static void * Point_ctor (void * _self, va_list * app){
struct Point * self = _self;
self -> x = va_arg(* app, int);
self -> y = va_arg(* app, int);
return self;
}
static void Point_draw (const void * _self){
const struct Point * self = _self;
printf("Draw point at (%d,%d)", self -> x, self -> y);
}
Η συνάρτηση "move()" δεν χρειάζεται να "συνδεθεί" με την κλάση καθώς η μεταφορά του σημείου και του κύκλου είναι ίδια καθώς ο κύκλος αποτελείται από ένα σημείο και την ακτίνα.
Η κλάση "Circle"
ΕπεξεργασίαΗ κλάση "Circle" αποτελείται απ ο ένα σημείο και την ακτίνα άρα η κλάση "Circle" γίνεται:
stuct Circle{
const struct Point _p;
int rad;
};
static void * Circle_ctor (void * _self, va_list * app){
struct Point * self = _self;
self -> x = va_arg(* app, int);
self -> y = va_arg(* app, int);
self -> rad = va_arg(* app, int);
return self;
}
static void Point_draw (const void * _self){
printf("Draw circle at (%d,%d) with radius %d\n",
self -> x, self -> y, self -> rad);
}
Πρότυπα (Templates)
ΕπεξεργασίαΤα πρότυπα είναι μία αρκετά εύχρηστη λειτουργία των αντικειμενοστρεφών γλωσσών με την οποία μπορούν να δημιουργηθούν αλγόριθμοι και αφηρημένες μέθοδοι για να προσδιοριστούν έπειτα αναλόγως με τους τύπους δεδομένων που θα συσχετιστούν.
Εν γένει, τα πρότυπα είναι λειτουργία μεταπρογραμματισμού (metaprogramming), χρησιμοποιούμενη από αρκετές γλώσσες μεταξύ των οποίων και η C++, με την οποία επιτρέπεται η δημιουργία ενός γενικού αλγόριθμου ο οποίος μπορεί να χρησιμοποιηθεί όταν συσχετιστεί με κάποιον τύπο δεδομένων και για οποιονδήποτε τύπο δεδομένων.
Η πιο συνηθισμένη χρήση των templates είναι για την υλοποίηση δομών δεδομένων(Containers) οι οποίες μπορούν να χρησιμοποιηθούν για οποιονδήποτε τύπο δεδομένων. Έτσι, ο προγραμματιστής μπορεί να χρησιμοποιήσει έτοιμο αλγόριθμο, όπως η λειτουργία μίας λίστας, με διαφορετικούς τύπους δεδομένων αρχικοποιώντας κάθε φορά την δομή αυτή με τον τύπο δεδομένων που επιθυμεί. Ένα παράδειγμα είναι η κλάση CAtlArray, της βιβλιοθήκης atlcoll.h, που ανήκει στη βιβλιοθήκη ATL (Active Template Library). Ενδεικτικά, για τη λειτουργία της αρχικοποίησης:
CAtlArray<int> myTestArray;
int theElement;
int maxSizeOfArray = 100;
for (theElement = 0; theElement < maxSizeOfArray; theElement++)
{
myTestArray.add(theElement);
}
Πρότυπα στην C
ΕπεξεργασίαΣτη C τα πρότυπα (templates), μπορούν να προσομοιωθούν με την χρήση των μακροεντολών (macros) που προσφέρει ο προεπεξεργαστής (preprocessor). Έτσι, την «αντιληπτική» ικανότητα των προτύπων να αρχικοποιούνται και να συμπεριφέρονται αναλόγως με τον τύπο δεδομένων την προσομοιώνει η C με την χρήση των μακροεντολών, όπου παράγεται κώδικας με την διαχείριση και αντικατάσταση κειμένου που γράφεται στις μακροεντολές κατά το χρόνο μεταγλώτισσης.
Μία από τις πιο έντονες χρήσεις της προσομοίωσης των templates απαντάται στον κώδικα του πυρήνα του Linux, όπου έχουν υλοποιηθεί δομές όπως η λίστα, η ούρα κτλ., και μπορεί να βρεθεί στην βιβλιοθήκη sys/sys/queue.h[2].
Αντιγράφοντας από τον προαναφερθέντα κώδικα μπορεί κανείς να παρατηρήσει τη διαχείριση της αντικατάστασης κειμένου για την παραγωγή κώδικα με σκοπό την δημιουργία ενός στοιχείου της λίστας καθώς και της κεφαλής της λίστας:
/*
* Singly-linked List declarations.
*/
#define SLIST_HEAD(name, type) \
struct name { \
struct type *slh_first; /* first element */ \
}
#define SLIST_HEAD_INITIALIZER(head) { NULL }
#define SLIST_ENTRY(type) \
struct { \
struct type *sle_next; /* next element */ \
}
Έτσι, εάν είναι επιθυμητή η δημιουργία μίας λίστας με τύπους int, τότε μπορεί να κανείς να γράψει:
SLIST_HEAD(myListHead, int) myHead = SLIST_HEAD_INITIALIZER(myHead);
Έτσι ο προεπεξεργαστής της C θα αντικαταστήσει το όνομα του struct με το όνομα myListHead και τον τύπο με το κείμενο int και θα γίνει η κλήση της εντολής σαν να την είχε γράψει ο προγραμματιστής. Επίσης, θα κληθεί η μακροεντολή SLIST_HEAD_INITIALIZER όπου θα αντικαταστήσει το κείμενο και θα αποδώσει την τιμή NULL στην μεταβλητή myHead.
Το ίδιο μπορεί να γίνει και για την λειτουργικότητα που μπορεί να προσφέρει μία λίστα. Π.χ.,
#define SLIST_NEXT(elm, field) ((elm)->field.sle_next)
#define SLIST_FOREACH(var, head, field) \
for ((var) = SLIST_FIRST((head)); (var); (var) = SLIST_NEXT((var), field))
Όπου, και πάλι, μπορεί να φανεί η διαχείριση του κειμένου από τον prepocessor για την παραγωγή κώδικα και την προσομοίωση της λειτουργικότητας των προτύπων. Έτσι, για τη λειτουργία foreach ο προεπεξεργαστής:
- θα αποδώσει την διεύθυνση μνήμης της κεφαλής της λίστας στην μεταβλητή,
- θα ελέγξει εάν η τιμή είναι NULL,
- στην επανάληψη θα αποδώσει στην μεταβλητή var την τιμή του επόμενου πεδίου της λίστας.