Objektorientierte Programmierung in C – Teil 2

ANSI C goes OOP – Wiederverwendung und Vererbung
Kommentare

In Teil 1 von „ANSI C goes OOP“ haben wir uns mit der Geschichte und den Grundlagen von objektorientierter Programmierung beschäftigt. In diesem Artikel vertiefen wir das Thema. Ich demonstriere anhand eines komplexeren Beispiels (einer einfachen Job Queue) das Zusammenspiel mehrerer Klassen und gehe dabei auch auf das Thema der Wiederverwendung und Vererbung ein.

Am Beispiel einer einfachen Stringklasse habe ich in der letzten Ausgabe einfache Techniken der OOP in C in verschiedenen Variationen demonstriert. Im Mittelpunkt standen dabei Konzepte wie die Trennung von Schnittstelle und Implementierung, Information Hiding (Verbergen privater Daten), sowie die Bereitstellung von Methoden für entsprechende Datenstrukturen. Damit sind alle Grundsteine gesetzt, um tiefer in die Materie einzutauchen.

Benötigte Werkzeuge
Zum Nachvollziehen der Beispiele wird lediglich ein C-Compiler sowie das Unix-Werkzeug make benötigt. Unter unixoiden Betriebssystemem sind diese Werkzeuge meist bereits vorhanden, unter Windows normalerweise nicht. Auf jeden Fall können sie ggf. leicht installiert werden:

• Debian-basierte Systeme wie Ubuntu, Linux mint : Hier kann aus einer Shell apt benutzt werden: sudo apt-get install build-essential.
• Für andere unixoide System (Mac OS X) kann die Software von http://gcc.gnu.org/ manuell installiert werden.
• Windows: Hier empfiehlt sich die Benutzung von mingw, das ein minimales System für die Benutzung unter Windows bereitstellt.

Fallbeispiel: eine Job Queue

Als Beispiel für eine vertiefende Betrachtung der OOP in C soll uns eine einfache Job Queue dienen. Eine Job Queue kann benutzt werden, um den Vorgang der Erstellung von Aufgaben von deren Ausführung zu entkoppeln und so die asynchrone Verarbeitung zu ermöglichen. Näheres zu Job Queues finden sie hier.

Um das Beispiel einfach zu halten, gehen wir von folgendem Anwendungsfall aus: Es werden Jobs für Systemverwaltungsaufgaben generiert, die Dateien entweder löschen oder ein Backup einer Datei in einem übergebenen Pfad anlegen. Diese werden zunächst alle in die Job Queue eingestellt. Nachdem alle Jobs eingestellt wurden, wird die Jobverarbeitung der Job Queue gestartet, die die eingestellten Jobs nacheinander abarbeitet.

In einer realen Anwendung würden die Joberzeugung und die Jobverarbeitung in separaten Prozessen erfolgen. Dazu müssten Kommunikationsmöglichkeiten zwischen diesen Prozessen geschaffen werden, indem die Jobs in eine Datei oder eine Datenbank geschrieben würden. Das Erzeugerprogramm könnte die Jobs z. B. tagsüber in eine Jobdatei schreiben, die ein Job-Queue-Prozessor dann nachts ausliest und verarbeitet. Ein anderer Weg wäre das Ablegen in einer Datenbank. In unserem Beispiel werden wir darauf aber nicht eingehen, d. h. Joberzeugung und -verarbeitung geschehen durch dasselbe Programm. Abbildung 1 zeigt ein UML-Klassendiagramm für die beteiligten Klassen.

Abb. 1: UML-Diagramm für die beteiligten Klassen

Das Hauptprogramm („Clientcode“) erstellt eine Instanz der Klasse JobQueue und benutzt dann die Klassen DeleteJob und BackupJob, um Lösch- bzw. Sicherungsjobs zu erstellen. Diese werden dann der Job Queue hinzugefügt. Abschließend wird die Jobverarbeitung über die Methode run() der Job Queue gestartet. Diese iteriert über die Jobs und ruft jeweils deren process()-Methode auf. Für die interne Datenspeicherung der Jobs wird eine Klasse JobList benutzt.

Wiederverwendung und Vererbung – die Job-Klassen

Wiederverwendung stellt einen wichtigen Aspekt der OOP dar. Wiederwendung kann auf verschiedenen Ebenen betrachtet werden:

• Wiederverwendung von Implementierungen
• Wiederverwendung von Schnittstellen

Wiederverwendung von Implementierungen wird benutzt, um existierende Algorithmen und Datenstrukturen im Sinne von DRY (Don’t Repeat Yourself) nutzen zu können. Vor der Einführung der OOP wurde Wiederverwendung im Wesentlichen über Funktionen, Prozeduren und Module realisiert. In der OOP wird Wiederverwendung von Implementierungen über die Wiederverwendung von Klassen und Schnittstellen realisiert. Als Beispiel kann die im letzten Artikel vorgestellte wiederverwendbare Stringklasse dienen.

Wiederverwendung von Schnittstellen ist dann von Interesse, wenn eine Menge von Klassen dieselben Methodensignaturen haben soll (im Smalltalk-Jargon: auf dieselben Nachrichten reagieren können soll), es also entscheidend ist, dass an einer bestimmten Stelle Instanzen verschiedener Klassen verwendet werden können sollen. Diese Art von Wiederverwendung ist insbesondere für unsere Job-Queue von Interesse, die verschiedene Arten von Jobs aufnehmen soll. Abbildung 2 zeigt als Ausschnitt ein UML-Klassendiagramm für die Hierarchie der Job-Klassen.

Abb. 2: UML-Diagramm für die Job-Klassen

Ganz oben in der Jobhierarchie befindet sich die Klasse Job. Sie hat als Attribute eine eindeutige Identifikation id und eine Priorität priority als Integer. Es gibt Methoden zur Erzeugung eines Jobs (Job_new()), zur Erzeugung der ID (getId()), zur Freigabe des Speichers (delete()) sowie eine Methode zur Abarbeitung eines Jobs (process()).

/* job.h */

#include "cstring.h"

#ifndef JOB_H
  #define JOB_H

  /* class definition */
  struct JobStruct {
    char *jobClass;
    CStringPtr id;
    int priority;
    int (*process) (void *jobPtr);
    void (*delete) (void *jobPtr);
  };

  typedef struct JobStruct Job;

  /* constructor */
  Job Job_new(int priority);

  /* process() base implementation */
  int Job_process(void *self);

  /* destructor */
  void Job_delete(void *self);

#endif

Listing 1 zeigt die Headerdatei für die Job-Klasse. Die Klasse wird in der im letzten Artikel beschriebenen Weise als struct modelliert, die Methoden als Funktions-Pointer des struct, denen Funktionen mit passender Signatur entsprechen. Das Konstrukt

#ifndef JOB_H
  #define JOB_H
  <Quellcode>
#endif 

stellt mithilfe des Präprozessors sicher, dass der Headercode bei mehrfacher Inklusion nur einmal kompiliert wird, da es sonst beim Kompilieren wegen vermeintlicher Neudefinition zu Fehlermeldungen kommt. Ansonsten ist der Code an dieser Stelle selbsterklärend. Eine Besonderheit stellt hier das Attribut jobClass dar, das eine Beschreibung der Klasse des Jobs als char Pointer aufnimmt.

/* file job.c */

#include <stdlib.h>
#include <stdio.h>

#include "job.h"

/* job class */
static char* JOBCLASS = "BaseJob";

/* private method to create an ID */
static char *getJobId() {
  char* jobIdCharPtr = malloc(sizeof(char[5]));
  static int jobNo = 0;
  sprintf(jobIdCharPtr, "%05d", jobNo);
  jobNo++;
  return jobIdCharPtr;
}

/* constructor */
Job Job_new(int priority) {
  Job job;
  job.jobClass = JOBCLASS;
  job.id = CString_new(getJobId());
  job.priority = priority;
  job.process = &Job_process;
  job.delete = &Job_delete;
  return job;
}

/* process() base implementation */
int Job_process(void *self) {
  Job* job = (Job*) self;
  printf(
    "processing Job class '%s' #%sn", 
    job->jobClass,
    CString_asCharPtr(job->id)
  );
  return 1;
}

/* destructor */
void Job_delete(void *self) {
  Job *job = (Job*) self;
  CString_delete(job->id);
}

Listing 2 zeigt die Implementierungsdatei für die Job-Klasse. Der char Pointer JOBCLASS enthält einen beschreibenden Namen der Job-Klasse.

Die statische Funktion getJobId() gibt eine Job-ID zurück. Diese wird mithilfe des statischen Zählers jobNo generiert. In einer realen Implementierung sollte sichergestellt werden, dass es zu keinem Überlauf kommt, da der Maximalwert für die Job-ID bei fünf Ziffern (99999) liegt.

Der Konstruktor Job_new(int priority) erzeugt eine Instanz der Job-Klasse. Als Name für die Job-Klasse wird der char Pointer JOBCLASS benutzt. Alle Instanzen der Klasse benutzen entsprechend einen Pointer auf dieselbe Adresse. Den Methodenfeldern werden die Adressen der korrespondierenden Methoden zugeordnet.

Die Funktion Job_process(void *self) gibt einen Text aus, der Job-Klasse und -priorität ausgibt und den Wert 1 für erfolgreiche Ausführung zurückgibt. Eine Besonderheit ist an dieser Stelle, dass hier nicht ein Job-Pointer, sondern ein void Pointer übergeben wird, der dann zu einem Job-Pointer gecastet wird. Wir werden auf diesen Punkt später eingehen.

/* filejob.h */

#ifndef FILEJOB_H
  #define FILEJOB_H

  #include "cstring.h"
  #include "job.h"

  /* class definition */
  struct FileJobStruct {
    Job job;
    CStringPtr filePath;
  };

  typedef struct FileJobStruct FileJob;

  /* constructor */
  FileJob FileJob_new(int priority, char *filePath);

  /* FileJob process() implementation */
  int FileJob_process(void *self);

  /* destructor */
  void FileJob_delete(void *self); 

#endif

In Listing 3 findet sich die Header-Datei für die FileJob-Klasse. Diese stellt eine Spezialisierung der Job-Klasse dar. Spezialisierung wird in Sprachen mit Unterstützung für OOP meistens über Vererbung realisiert. Da ANSI C diese nicht bereitstellt, benutzen wir hier stattdessen die Methode der Objektkomposition. Das heißt wir machen die übergeordnete Klasse zu einem Attribut der spezialisierenden Klasse, und zwar zu dem ersten. Ein weiteres Attribut ist der Pfad der zu berücksichtigenden Datei filePath:

struct FileJobStruct {
  Job job;
  CStringPtr filePath;
};
/* file filejob.c */

#include <stdlib.h>
#include <stdio.h>

#include "filejob.h"

/* job class */
static char* JOBCLASS = "FileJob";

/* constructor */
FileJob FileJob_new(int priority, char *filePath) {
  FileJob fileJob;
  fileJob.job = Job_new(priority);
  fileJob.job.jobClass = JOBCLASS;
  fileJob.job.process = &FileJob_process;
  fileJob.job.delete = &FileJob_delete;
  fileJob.filePath = CString_new(filePath);
  return fileJob;
}

/* FileJob process() implementation */
int FileJob_process(void *self) {
  FileJob* fileJob = (FileJob*) self;
  printf(
    "processing Job class '%s' #%s, path:'%s'n", 
    fileJob->job.jobClass,
    CString_asCharPtr(fileJob->job.id),
    CString_asCharPtr(fileJob->filePath)
  );
  return 1;
}

/* destructor */
void FileJob_delete(void *self) {
  FileJob* fileJob = (FileJob*) self;
  CString_delete(fileJob->filePath);
  CString_delete(fileJob->job.id);
}

In Listing 4 findet sich die entsprechende Implementierung. Innerhalb von FileJob_new() wird das Attribut job mit der Job_new()-Methode der Basisklasse initialisiert. Der Methode process() von Job wird die Adresse der Methode FileJob_process() zugewiesen. Für den übergebenen Parameter filePath wird mit CString_new() dem gleichnamigen Attribut ein neuer CStringPtr zugewiesen.

Die Methode FileJob_process(void *self) stellt die für diese Job-Klasse spezifische Implementierung der Methode process() dar. Hier wird jetzt deutlich, warum die Methode process() der Basisklasse (Job) als Parameter nicht einen Job-Pointer erhält. Die Signatur der Basisklasse muss kompatibel sein mit der Signatur der abgeleiteten Klasse. Daher wird hier jeweils ein void Pointer verwendet. Die Methode gibt einen Text aus, der die Job-Klasse, die Job-ID und das Attribut filePath enthält. Es folgen jetzt die Job-Klassen für Lösch- und Backup-Jobs.

/* deletejob.h */

#include "cstring.h"
#include "filejob.h"

/* class definition */
struct DeleteJobStruct {
  FileJob fileJob;
};

typedef struct DeleteJobStruct DeleteJob;

/* constructor */
DeleteJob DeleteJob_new(int priority, char *filePath);

/* DeleteJob process() implementation */
int DeleteJob_process(void *self);

Die Header-Datei für die Löschjobs ist in Listing 5 wiedergegeben. Da DeleteJob von FileJob erbt, wird analog zu dem bisher beschriebenen eine Instanz von FileJob zu einem Attribut von DeleteJob gemacht.

/* deletejob.c */

#include <stdlib.h>
#include <stdio.h>

#include "deletejob.h"
#include "filejob.h"

/* job class */
static char* JOBCLASS = "DeleteJob";

/* constructor */
DeleteJob DeleteJob_new(int priority, char *filePath) {
  DeleteJob deleteJob;
  deleteJob.fileJob = FileJob_new(priority, filePath);
  deleteJob.fileJob.job.jobClass = JOBCLASS;
  deleteJob.fileJob.job.process = &DeleteJob_process;
  deleteJob.fileJob.job.delete = &FileJob_delete;
  return deleteJob;
}

/* DeleteJob process() implementation */
int DeleteJob_process(void *self) {
  DeleteJob* deleteJob = (DeleteJob*) self;
  printf(
    "processing Job class '%s' #%s, path:'%s'n", 
    deleteJob->fileJob.job.jobClass,
    CString_asCharPtr(deleteJob->fileJob.job.id),
    CString_asCharPtr(deleteJob->fileJob.filePath)
  );
  return 1;
}

Die Implementierung in Listing 6 ist weitgehend analog zur Implementierung der Klasse FileJob. Entscheidend ist in diesem Zusammenhang, dass die Zuweisungen der Job-Klasse und der Funktions-Pointer für die Methoden jeweils auf dem enthaltenen Objekt der Basisklasse (Job) erfolgen, wie beispielsweise in deleteJob.fileJob.job.jobClass = JOBCLASS;.

/* backupjob.h */

#include "cstring.h"
#include "filejob.h"

/* class definition */
struct BackupJobStruct {
  FileJob fileJob;
  CStringPtr backupDir;
};

typedef struct BackupJobStruct BackupJob;

/* constructor */
BackupJob BackupJob_new(int priority, char *filePath, char *backupDir);

/* BackupJob process() implementation */
int BackupJob_process(void *self);

/* destructor */
void BackupJob_delete(void *self); 
/* backupjob.c */

#include <stdlib.h>
#include <stdio.h>

#include "backupjob.h"
#include "filejob.h"

/* job class */
static char* JOBCLASS = "BackupJob";

/* constructor */
BackupJob BackupJob_new(int priority, char *filePath, char *backupDir) {
  BackupJob backupJob;
  backupJob.fileJob = FileJob_new(priority, filePath);
  backupJob.fileJob.job.jobClass = JOBCLASS;
  backupJob.fileJob.job.process = &BackupJob_process;
  backupJob.backupDir = CString_new(backupDir);
  return backupJob;
}

/* BackupJob process() implementation */
int BackupJob_process(void *self) {
  BackupJob* backupJob = (BackupJob*) self;
  printf(
    "processing Job class '%s' #%s, path:'%s' backup path:'%s'n", 
    backupJob->fileJob.job.jobClass,
    CString_asCharPtr(backupJob->fileJob.job.id),
    CString_asCharPtr(backupJob->fileJob.filePath),
    CString_asCharPtr(backupJob->backupDir)
  );
  return 1;
}

/* destructor */
void BackupJob_delete(void *self) {
  BackupJob* backupJob = (BackupJob*) self;
  CString_delete(backupJob->backupDir);
  backupJob->fileJob.job.delete(&(backupJob->fileJob));
}

Listing 7 und 8 zeigen die Header- und Implementierungsdatei für Backup-Jobs. Der Unterschied zu Löschjobs besteht darin, dass bei der Joberstellung ein weiterer Parameter backupDir übergeben wird und die Klasse ein entsprechendes Attribut besitzt. Außerdem hat BackupJob eine eigene delete()-Implementierung, um das zusätzliche Attribut freizugeben.

Damit ist die Diskussion der eigentlichen Job-Klassen der Anwendung abgeschlossen. Entscheidende Punkte waren:

• Ableitende Klassen benutzen Objektkomposition. Die ableitende Klasse hat als erstes Attribut die Basisklasse.
• Der Konstruktor der abgeleiteten Klasse instanziiert ein Objekt der Basisklasse und weist es dem betreffenden Attribut zu.
• Dem Funktions-Pointer der abgeleiteten Klasse wird die Adresse der Methode der abgeleiteten Klasse übergeben.

Das Speicherlayout der verschiedenen Job-Klassen ist in Abbildung 3 dargestellt. Zu sehen ist, dass erbende Klassen an ihrem Anfang immer dasselbe Layout haben wie ihre Basisklasse. Das ist wichtig, denn es ermöglicht es, die erbende Klasse wie ihre Basisklasse zu behandeln, da sie für diesen Bereich im Aufbau identisch ist. Mehr dazu später.

Abb. 3: Speicherlayout der verschiedenen „Job“-Klassen

Wir betrachten nun die Datenstruktur für die Ablage der Jobs.

Aufmacherbild: Technology in the hands of businessmen von Shutterstock / Urheberrecht: Nata-Lia

[ header = Seite 2: Eine dynamische Datenstruktur für Jobs: die „Job“-Liste als interne Datenstruktur ]

Eine dynamische Datenstruktur für Jobs: die „Job“-Liste als interne Datenstruktur

Die Natur der Anwendung bedingt, dass die Anzahl der Jobs von vornherein nicht feststeht. Ein Array mit einer festen Feldlänge ist daher nicht geeignet. Eine naheliegende Datenstruktur wäre hier eine verkettete Liste. In Java und C++ gibt es als Datenstruktur auch Vektoren, die im Grunde dynamische Arrays darstellen. Eine Array-Struktur bietet den Vorteil, dass sie in C mithilfe der Bibliotheksfunktion qsort() sehr effizient sortiert werden kann. Daher entscheiden wir uns hier für eine Struktur ähnlich einem Vektor. Abbildung 4 zeigt als Ausschnitt ein UML-Klassendiagramm für die Job-Liste. Die entsprechende Header-Datei findet sich in Listing 9.

Abb. 4: UML-Diagramm für die „Job“-Liste

Die Klasse enthält als Attribute die Länge des allozierten Speichers (length), einen Pointer auf einen Job-Pointer, den Index des letzten enthaltenen Elements sowie Funktions-Pointer für die Methoden add(), delete() und removeEmptyJobs().

Bei einem Array ist der Index des letzten Elements immer gleich der Länge des allozierten Speichers minus eins. Da das Allozieren von Speicher aber eine vergleichsweise teure Operation ist, sehen wir vor, dass beim Hinzufügen von Jobs zunächst mehr Speicher reserviert als unmittelbar benötigt wird, beispielsweise Platz für fünf Jobs. Diese reservierte Speichermenge wird in length abgelegt. Ein neuer Job wird dann an der Position end+1 abgelegt. Neuer Speicher wird nur dann alloziert, wenn der reservierte Speicher nicht mehr ausreicht; in diesem Beispiel nach dem fünften hinzugefügten Job.

/* joblist.c */

#include <stdlib.h>
#include "stdio.h"

#include "joblist.h"

int CAPACITY = 2;

/* constructor */
JobList JobList_new() {
  JobList jobList;
  jobList.length = 0;
  jobList.jobs = NULL;
  jobList.end = 0;
  jobList.add = &JobList_add;
  jobList.delete = &JobList_delete;
  jobList.removeEmptyJobs = &JobList_removeEmptyJobs;
  return jobList;
}

/* add a job to the list */
void JobList_add(JobList *jobList, Job *job) {
  if (jobList->end >= jobList->length) {
    // get some new memory
    jobList->length += CAPACITY;
    jobList->jobs = (Job**)realloc(jobList->jobs, jobList->length * sizeof(Job*));
  }
  jobList->jobs[jobList->end] = job;
  jobList->end++;
}

/* compare function to enable to move the NULLs to the end */
int sortNullToEnd(const void *job1, const void *job2) {
  int ret;
  Job **jobPtr1 = (Job**) job1;
  Job **jobPtr2 = (Job**) job2;
  if ((*jobPtr1 == NULL) && (*jobPtr2 != NULL)) {
    ret = 1;
  }
  else if ((*jobPtr1 != NULL) && (*jobPtr2 == NULL)) {
    ret = -1;
  }
  else {
    ret = 0;
  }
  return ret;
}

/* remove empty jobs from the list and resize memory */
void JobList_removeEmptyJobs(JobList *jobList) {
  qsort(jobList->jobs, jobList->length, sizeof(Job*), sortNullToEnd);
  int i;
  for (i = 0; i < jobList->length; i++) {
    Job *job = jobList->jobs[i];
    if (NULL == job) {
      break;
    }
  }
  jobList->jobs = (Job**)realloc(jobList->jobs, i * sizeof(Job*));
  jobList->length = i; 
}

/* destructor */
void JobList_delete(JobList jobList) {
  int i;
  for (i = 0; i < jobList.length; i++) {
    Job *job = jobList.jobs[i];
    if (NULL != job) {
      job->delete(job);
      jobList.jobs[i] = NULL;
    }
  }
  free(jobList.jobs);
}

Die Funktion JobList_new() dient wieder als Konstruktor. Die Attribute length und end werden mit 0, der interne Pointer jobs mit NULL initialisiert. Den Funktions-Pointern für die Methoden werden wieder die Adressen der korrespondierenden Funktionen zugewiesen.

Die Funktion JobList_add() erhält als Parameter einen Pointer auf die Job-Liste sowie einen Pointer auf einen Job. Es werden Pointer benutzt, um mit Call-by-Reference zu arbeiten, d. h. die Objekte selbst zu ändern, statt auf Kopien zu arbeiten, siehe dazu auch hier.

Beim Hinzufügen eines Jobs wird zunächst geprüft, ob noch freier Speicher am Ende verfügbar ist, d. h. length größer oder gleich end ist. Wenn dies nicht der Fall ist, wird zum Attribut length der Wert der Konstanten CAPACITY addiert und mit realloc() Speicher für length Job-Pointer alloziert. Anschließend wird jobs an Position end mit dem übergebenen Job-Pointer belegt.

Die Funktion sortNullToEnd() erhält als Argumente zwei void Pointer. Die beiden void Pointer werden zu den Pointern auf Job-Pointer jobPtr1 und jobPtr2 gecastet. Dann werden die dereferenzierten Pointer verglichen. Hat der erste den Wert NULL und ist der zweite ungleich NULL, wird 1 zurückgegeben. Im umgekehrten und allen übrigen Fällen wird -1 zurückgegeben. Die Funktion wird in der anschließend diskutierten Funktion verwendet.

Die Funktion JobList_removeEmptyJobs() benutzt die Bibliotheksfunktion qsort(), um die Job-Liste zu sortieren. Als Parameter erhält qsort() ein Array bzw. einen Pointer auf einen Pointer (jobList->jobs), die Länge des zu sortierenden Speicherbereichs (jobList->length), die Größe eines einzelnen Elements sowie die Adresse einer Funktion, die für die Sortierung benutzt werden soll. Hier wird die Funktion sortNullToEnd übergeben. Als Resultat sind nach Aufruf der Funktion alle leeren Jobs (Job-Pointer, die auf NULL gesetzt sind) am Ende des Speicherbereichs positioniert.

Anschließend wird über die Elemente von jobList->jobs iteriert, um den Index des ersten leeren Elements zu ermitteln. Anschließend wird die Länge des allozierten Speichers entsprechend angepasst und das Attribut length auf diese Länge gesetzt.

Die Funktion JobList_delete(JobList()) iteriert über alle in der Job-Liste enthaltenen Jobs und ruft für nicht leere Jobs die entsprechende delete()-Methode auf, um den entsprechenden Speicher freizugeben. Anschließend wird der für die Job-Liste allozierte Speicher (jobList->jobs) freigegeben.

Die Job Queue: die öffentliche Schnittstelle

Als letzte Klasse verbleibt jetzt noch die Job Queue selbst. Sie stellt die öffentliche Schnittstelle dar, die die im vorigen Abschnitt vorgestellte Job-Liste als interne Datenstruktur benutzt. Abbildung 5 zeigt als Ausschnitt das UML-Diagramm der Job Queue.

Abb. 5: UML-Diagramm für die Job-Queue-Klasse

Die Klasse enthält als Attribut eine Instanz von JobList sowie neben dem Konstuktor Methoden zum Hinzufügen eines Jobs, zur Durchführung der Jobverarbeitung sowie eine delete()-Methode als Destruktor.

Listing 11 zeigt die entsprechende Header-Datei. Wir verzichten aus Gründen der Lesbarkeit darauf, das in dem UML-Diagramm als privat gekennzeichnete Attribut jobList vor dem Zugriff durch den Anwender zu verbergen (wie sich dieses in C modellieren lässt, wurde im ersten Teil der Artikelserie gezeigt).

/* jobqueue.c */

#include <stdlib.h>

#include "jobqueue.h"
#include "joblist.h"
#include "cstring.h"

/* constructor */
JobQueue JobQueue_new() {
  JobQueue jobQueue;
  jobQueue.jobList = JobList_new();
  jobQueue.add = &JobQueue_add;
  jobQueue.run = &JobQueue_run;
  jobQueue.delete = &JobQueue_delete;
  return jobQueue;
}

/* add a job to the queue */
void JobQueue_add(JobQueue *jobQueue, Job *job) {
  jobQueue->jobList.add(&(jobQueue->jobList), job);
}

/* start job processing */
void JobQueue_run(JobQueue *jobQueue) {
  int length = jobQueue->jobList.length;
  int i ;
  for (i = 0; i < length; i++) {
    Job *job = jobQueue->jobList.jobs[i];
    int result = job->process(job);
    if (result) {
      job->delete(job);
      jobQueue->jobList.jobs[i] = NULL;
    }
  }
  jobQueue->jobList.removeEmptyJobs(&(jobQueue->jobList));
}

/* destructor */
void JobQueue_delete(JobQueue *jobQueue) {
  jobQueue->jobList.delete(jobQueue->jobList);
}

Die dazugehörige Implementierung findet sich in Listing 12. Der Konstruktor ist wie jetzt schon gewohnt implementiert und gibt einen struct zurück, dessen Attribut jobList eine neue Instanz von JobList zugewiesen wird und dessen Methodenfeldern die entsprechenden Funktions-Pointer zugewiesen werden.

Die Methode JobQueue_add() delegiert den Aufruf einfach an die add()-Methode von jobList. JobQueue_run() iteriert über alle Jobs der Queue (d. h. der internen Job-Liste) und ruft für die jeweiligen Jobs deren process()-Methode auf. Ist das Ergebnis ungleich 0, was einer erfolgreichen Arbarbeitung entspricht, wird der entsprechende Job-Pointer auf NULL gesetzt. Abschließend werden die leeren Jobs entfernt, indem die Methode removeEmptyJobs() der internen Job-Liste aufgerufen wird.

JobQueue_delete() ruft wieder einfach die korrespondierende Methode der JobQueue auf, um die Jobs und die Job-Liste freizugeben.

[ header = Seite 3: Integration zu einer lauffähigen Anwendung ]

Integration zu einer lauffähigen Anwendung

Alle Komponenten, die zur Implementierung der Job Queue benötigt werden, sind jetzt vorhanden.

/* file test_jobqueue.c */

#include "cstring.h"
#include "filejob.h"
#include "deletejob.h"
#include "backupjob.h"
#include "jobqueue.h"

int main() {

  /* create job class instances */
  Job job = Job_new(3);
  FileJob fileJob = FileJob_new(5, "/tmp/filename.txt");
  DeleteJob deleteJob = DeleteJob_new(4, "/tmp/file2delete.txt");
  BackupJob backupJob = BackupJob_new(4, "/tmp/file2backup.txt", "/tmp/backup");

  /* create job queue */
  JobQueue jobQueue = JobQueue_new();

  /* add jobs */
  jobQueue.add(&jobQueue, &job);
  jobQueue.add(&jobQueue, (Job*) &fileJob);
  jobQueue.add(&jobQueue, (Job*) &deleteJob);
  jobQueue.add(&jobQueue, (Job*) &backupJob);

  /* start job processing */
  jobQueue.run(&jobQueue);
  /* clean up */
  jobQueue.delete(&jobQueue);

  return 0;
} 

Das entsprechende Clientprogramm ist in Listing 13 wiedergegeben. Zunächst werden vier verschiedene Jobs der verschiedenen Job-Klassen instanziiert. Es wird dann eine Instanz von JobQueue erzeugt und dieser nacheinander die Jobs hinzugefügt. Entscheidend ist an dieser Stelle, dass die Instanzen der abgeleiteten Job-Klassen nach (Job*) gecastet werden. In C ist es erlaubt, einen Pointer auf einen struct auf einen Pointer auf dessen erstes Element zu casten. Damit kann der struct der abgeleiteten Klasse so benutzt werden, als handle es sich um den ersten enthaltenen struct (die Basisklasse). Damit ist es möglich, zu modellieren, dass die Instanz der abgeleiteten Klasse auch eine Instanz der Basisklasse ist und über dieselben Attribute und Methoden verfügt. Zum Abschluss wird der Destruktor der JobQueue aufgerufen, um den angeforderten Speicher wieder freizugeben.

CC = gcc

LANG = "en_GB.UTF-8"

test_jobqueue.exe: test_jobqueue.o cstring.o job.o filejob.o deletejob.o 
backupjob.o joblist.o jobqueue.o
$(CC) -g test_jobqueue.o cstring.o job.o filejob.o deletejob.o 
backupjob.o joblist.o jobqueue.o -o test_jobqueue.exe

test_jobqueue.o: test_jobqueue.c
$(CC) -g -c test_jobqueue.c

job.o: job.h job.c
$(CC) -g -c job.c

filejob.o: filejob.h filejob.c
$(CC) -g -c filejob.c

deletejob.o: deletejob.h deletejob.c
$(CC) -g -c deletejob.c

backupjob.o: backupjob.h backupjob.c
$(CC) -g -c backupjob.c

joblist.o: joblist.h joblist.c
$(CC) -g -c joblist.c

jobqueue.o: jobqueue.h jobqueue.c
$(CC) -g -c jobqueue.c

cstring.o: cstring.h cstring.c
$(CC) -g -c cstring.c

Zum Kompilieren kann das Makefile aus Listing 14 benutzt werden. Im Beispiel wurde als Compiler gcc benutzt, hier sollte aber ein beliebiger ANSI-C-Compiler benutzt werden können. Im Folgenden ist das Ergebnis der Programmausführung zu sehen. Gezeigt wird dabei, wie nacheinander Jobs der verschiedenen Job-Klassen mit ihren jeweiligen Parametern abgearbeitet werden:

processing Job class 'BaseJob' #00000
processing Job class 'FileJob' #00001, path:'/tmp/filename.txt'
processing Job class 'DeleteJob' #00002, path:'/tmp/file2delete.txt'
processing Job class 'BackupJob' #00003, path:'/tmp/file2backup.txt' backup path:'/tmp/backup' 

Fazit

Anhand des Beispiels einer Job Queue wurde dargestellt, wie Vererbung in C implementiert und wie das Zusammenspiel verschiedener Klassen/Objekte realisiert werden kann. Entscheidender Punkt war hierbei die Möglichkeit, Instanzen verschiedener Klassen in einer dynamischen Datenstruktur ablegen und über eine einheitliche Schnittstelle auf sie zugreifen zu können.

Auf die wirkliche Implementierung von Methoden zum Löschen und Sichern von Dateien wurde verzichtet, da Dateisystemoperationen in C nicht plattformunabhängig sind. Außerdem hätte das Ausprogrammieren das Verständnis des zugrunde liegenden Prinzips nicht verbessert, die Lesbarkeit dagegen verringert. Das Beispiel kann als Ausgangspunkt eigener Weiterentwicklungen dienen. Wünschenswerte Erweiterungen wären beispielsweise:

• natürlich die Implementierung des Löschens und Sicherns von Dateien
• die Möglichkeit zum Serialisieren/Deserialisieren von Jobs in/aus Dateien oder über eine Datenbank
• die Bereitstellung von Sortiermöglichkeiten für die Jobverarbeitung (z. B. nach Priorität und/oder Job-Klasse)
• verbessertes Fehler-Reporting bei fehlgeschlagenen Jobs
• usw.

Diese Erweiterungen sollten einen interessierten C-Programmierer nicht vor zu große Herausforderungen stellen.

Es wurde anhand von ANSI C gezeigt, dass sich Prinzipien der OOP auch mit Sprachen realisieren lassen, die dafür aufgrund des Sprachentwurfs keine explizite Unterstützung bieten. In ähnlicher Weise kann OOP auch mit Sprachen wie z. B. LISP oder Lua umgesetzt werden. OOP ist in erster Linie eben nicht ein Sprachfeature, sondern ein Konzept für die Modellierung von Datentypen und Operationen. Mit einem gewissen Verständnis der Prinzipien der OOP kann dann auch in Umgebungen objektorientiert programmiert werden, in denen eine „objektorientierte Programmiersprache“ nicht zur Verfügung steht, wie z. B. im Umfeld von Mikrocontrollern.

Es soll nicht verschwiegen werden, dass die Implementierung von OOP in Sprachen, die dieses Konzept nicht unterstützen, mehr Verantwortung und Sorgfalt von Seiten des Programmierers erfordert, als Beispiel sei nur die Speicherverwaltung und -freigabe genannt.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -