Objektorientierte Programmierung in ANSI C

ANSI C goes OOP
Kommentare

Objektorientierte Programmierung (OOP) in Sprachen, welche die entsprechenden Konzepte unterstützen, ist relativ einfach. Wie steht es aber mit Sprachen, die das nicht tun? Kann man in denen nicht objektorientiert programmieren? Yes, we can! Objektorientiertes Programmieren ist eher ein Programmierstil als eine Spracheigenschaft, der Nutzen ist dabei nicht an bestimmte Sprachen gebunden. Am Beispiel von ANSI C wird gezeigt, wie sich mit dieser Sprache objektorientierte Techniken realisieren lassen.

OOP ist das Programmierparadigma, das spätestens seit den 90er Jahren mit dem Aufkommen von Java den Massenmarkt endgültig erobert hat. Da es die meisten moderneren Programmiersprachen (C++, Java, C#, Python) syntaktisch unterstützen oder, wie im Fall von Java und Scala, sogar erzwingen, begegnet man oft der Auffassung, OOP wäre die Eigenschaft bestimmter Sprachen. Tatsache ist, dass OOP durch bestimmte syntaktische Eigenschaften einiger Sprachen unterstützt wird. Sprachen wie Java und die „Mutter“ der OOP, Smalltalk, erzwingen die Verwendung von OOP, andere, wie z. B. C++ und Python erlauben sie. Interessant ist in diesem Zusammenhang, dass eine sehr moderne Sprache (Googles Go) vieles von dem, was gemeinhin als definierendes Merkmal von OOP gesehen wird, nicht direkt unterstützt. Ähnliches gilt für JavaScript. Das führt oft zu der Ansicht, dass OOP und ihre Vorteile bei der Benutzung dieser Sprachen nicht genutzt werden könnte. Tatsache ist, dass OOP zunächst eine Denkweise und ein Programmierstil ist, der nicht an eine bestimmte Sprache gebunden ist, sondern mehr mit der Modellierung von Daten und Schnittstellen zu tun hat. Große Bibliotheken wie GTK sind z. B. komplett in C implementiert und folgen trotzdem dem OOP-Paradigma. Am Beispiel von C soll daher in diesem Artikel demonstriert werden, wie OOP auch mit einer Sprache implementiert werden kann, die dieses Paradigma syntaktisch direkt nicht unterstützt. Gezeigt werden soll dabei, wie Konzepte von OOP trotz fehlender Sprachunterstützung vollwertig umgesetzt werden können. Nebenbei erhält der Leser dabei einen Einblick, was die Implementierung nach OO „unter der Haube“ bedeutet. Die Sprache C wurde gewählt, da das Beispiel von GTK zeigt, dass eine objektorientierte Programmierung mit C durchaus möglich und gängig ist, und andererseits C als sehr minimale Sprache in vielen Einsatzbereichen (Microcontroller etc.) genutzt werden kann, wo etwa C++ nicht verfügbar ist.

Geschichte der OOP

Die Evolution der Programmierung hin zur OOP lässt sich grob in fünf Phasen einteilen:

1.    Maschinenprogrammierung
2.    Assembler-Programmierung
3.    Erste Hochsprachen
4.    Strukturierte Programmierung
5.    OOP

Das Wesen der Maschinenprogrammierung ist dadurch gekennzeichnet, dass der Programmierer sich vollständig auf den Instruktionssatz der betreffenden Maschine, d. h. des Prozessors beschränken musste. Das heißt er musste wirklich alles, was umgesetzt werden sollte, in Form von binären oder hexadezimalen Codes direkt in der Sprache formulieren, die der Prozessor „verstand“.
Die Assembler-Programmierung erlaubte es dem Programmierer, statt Zahlen symbolische Namen zu verwenden. Ein eigenes Programm, der Assembler, übersetzte die symbolischen Namen dann in für den Computer ausführbaren Maschinencode.
In der Folge entstanden die ersten Hochsprachen. Wesentlich war in diesem Zusammenhang, dass sie erlauben sollten, Probleme zu lösen, ohne über die Interna der verwendeten Maschinensprache Bescheid zu wissen. Dazu wurden spezielle Programme bereitgestellt – Compiler und Interpreter. Sie erlaubten es, Programme auf eine abstraktere, von der konkreten Maschine unabhängige Weise zu formulieren. Als Nebeneffekt entstanden auf diese Weise portablere Programme, da die Hochsprachen von den konkreten Architekturen abstrahierten. Eine der erfolgreichsten Sprachen dieser Zeit war BASIC (Beginners All purpose Symbolic Instruction Code), da sie auf den meisten Homecomputern dieser Zeit verfügbar war.
Ein Problem bei den ersten Hochsprachen, insbesondere bei BASIC war, dass die für Programme typischen Verzweigungsstrukturen in Form von Sprüngen realisiert wurden. Diese Sprünge stellen für einen Prozessor oder einen Computer kein Problem dar – für den Programmierer aber insofern schon, als Programme, deren Struktur auf solchen Sprüngen basieren, für Menschen nicht gut nachzuvollziehen sind. Die entstehenden Programme waren bei aller Korrektheit schlicht für Menschen kaum wartbar – das Wort vom „Spagetticode“ kam auf. In der Folge wurde die strukturierte Programmierung entwickelt, die im Wesentlichen zwei Punkte in den Mittelpunkt stellte. Der erste Punkt war der Verzicht auf den so genannten GOTO-Befehl, der es erlaubte, in beliebige Adressen des Codes zu „springen“. Der zweite bestand in der konsequenten Umsetzung des Konzepts des Unterprogramms, das zur Ausführung eines bestimmten Algorithmus mit einer definierten Menge an Eingabeparametern und einem definierten Rückgabewert benutzt werden sollte. Dieses Unterprogramm ist der Vorläufer der heutigen Prozeduren, Funktionen und Methoden und definierte erstmals eine Schnittstelle, indem die entsprechende Signatur festlegte, was Eingangs- und was Ausgangsdaten waren. Entsprechende Sprachen sind z. B. Pascal und C. Grundsätzlich war damit eine weitere Ebene von Wiederverwendung gegeben: ein Unterprogramm konnte separat wiederverwendet werden; insbesondere erlaubte C separate Kompilierung, d. h. das Kompilieren von einzelnen Codeeinheiten, was den Aufbau separater Bibliotheken ermöglichte.
Die OOP, eingeführt mit Smalltalk, führte den Gedanken der Wiederverwendung noch weiter. Auf die Wiederverwendung von Algorithmen durch Unterprogramme folgte die Wiederverwendung von Codeeinheiten, die Datenstrukturen und die dazugehörenden Algorithmen zusammenfasste. Insbesondere im Kontext von GUIs (Fenstersystemen) setzte sich die OOP schnell durch. Bestehende Sprachen wie C (C++. Objective C) und Pascal (Object Pascal, Delphi) wurden um objektorientierte Konstrukte erweitert. Die Sprache Java wurde von vornherein als objektorientierte Sprache konzipiert. Spätere Sprachen, wie C#, folgten diesem Weg.

Aufmacherbild: Hacker programing in technology enviroment with cyber icons and symbols von Shutterstock / Urheberrecht: ra2studio

[ header = Seite 2: Was ist OOP? ]

Was ist OOP?

Was genau OOP ist, ist nicht scharf definiert. Eine Übersicht findet sich unter [1]. Im Allgemeinen werden folgende Konzepte als charakterisierend für OOP beschrieben:

Behandlung von Datenstrukturen (abstrakte Datentypen) und der auf ihnen zur Verfügung stehenden Operationen als Einheit: Eine Klasse definiert den abstrakten Datentyp und die dazugehörigen Operationen (Methoden). Eine entsprechende Instanz einer Klasse heißt Objekt. Eigenschaften von Klassen/Objekten werden als Attribute bezeichnet und sind ebenfalls Bestandteil der Klassendefinition.

Die Programmausführung geschieht im Wesentlichen durch Erstellung von Objekten als Instanzen von Klassen und dem Aufruf von Methoden der Objekte: In Smalltalk werden die Methoden als Nachrichten (Messages) bezeichnet, der Aufruf einer Methode doSomething() eines Objekts ObjectA durch ein Objekt ObjectB wird in dieser Terminologie als das Schicken der Nachricht doSomething von ObjectB an ObjectA bezeichnet.

Datenkapselung oder Information Hiding: Es wird getrennt zwischen der internen Implementierung von Klassen und ihrer nach außen zur Verfügung gestellten Schnittstelle. Zugriffe auf die Objekte erfolgen über die definierte Schnittstelle, die in der entsprechenden Terminologie öffentlich (public z. B. in Java oder C++) ist. Die internen Daten des Objekts werden verborgen, sie sind privat (private in Java oder C++). Diese Trennung von öffentlicher Schnittstelle von der internen Implementierung erlaubt es, ggf. innerhalb von Klassen die Implementierung auszutauschen, ohne dass der aufrufende Code angepasst werden muss.

Wiederverwendung/Vererbung: Wiederverwendung einer Klasse ClassA wird durch Spezialisierung oder Vererbung ermöglicht. Eine Klasse ClassB, die von ClassA erbt, hat als Schnittstelle alle Methoden und Attribute von ClassA, ggf. mit weiteren Methoden und Attributen. Es handelt sich zwischen ClassB und ClassA um eine „ist-ein“-Beziehung, d. h. eine Instanz von ClassB ist in diesem Fall automatisch auch eine Instanz von ClassA (umgekehrt ist das nicht der Fall). Infolgedessen kann an Stellen, wo eine Instanz von ClassA benötigt wird, auch eine Instanz von ClassB verwendet werden. ClassA heißt dann Basisklasse von ClassB. Einige Sprachen erlauben Mehrfachvererbung, d. h. eine Klasse ClassZ kann sowohl von einer Klasse ClassX als auch von einer Klasse ClassY erben. Die Schnittstelle von ClassZ stellt dann die Vereinigungsmenge der Schnittstellen von ClassX und ClassY dar. Ein Problem stellt in diesem Zusammenhang die Existenz gleichnamiger Methoden/Attribute in beiden Basisklassen dar. Die damit verbundenen Probleme führten in der Vergangenheit dazu, dass Mehrfachvererbung von Klassen in einigen moderneren Sprachen nicht erlaubt wird.

Es soll jetzt die Umsetzung einiger dieser Konzepte am Beispiel einer Stringklasse für C demonstriert werden.

CString: eine einfache Stringklasse für C

Ein verbreiteter Kritikpunkt an der Sprache C ist die mangelnde Unterstützung von Datentypen, die heutzutage als selbstverständlich angesehen werden, insbesondere die Verfügbarkeit eines sicher zu benutzenden Zeichenketten-/Stringdatentyps. Ob diese Kritik berechtigt ist, sei dahingestellt – C wurde als Sprache für die Implementierung von Betriebssystemen (UNIX) konzipiert und ist als solche nach wie vor sehr erfolgreich. Allerdings verlangt C sehr viel Verantwortung und Know-how auf Seiten des Programmierers und birgt für den unerfahrenen Programmierer ein hohes Fehlerpotenzial. Ein großer Teil der Sicherheitsprobleme (Buffer-Overflow-Angriffe) ist wohl auf die Tatsache zurückzuführen, dass die Zeichenkettenoperationen, die C zur Verfügung stellt, für einen unerfahrenen Programmierer viele Fallen aufweisen und insofern unsicher sind, als hier oft das Risiko besteht, über reservierte Speicherbereiche hinaus zu schreiben. Entsprechende Probleme werden z. B. unter [2] diskutiert. Es bietet sich daher an, eine Stringklasse zu implementieren, die die entsprechenden Probleme minimiert, und oft benötige Funktionalität bereitstellt. Wir gehen hier der Einfachheit halber davon aus, dass ein String als ein Vector/Array von char-Elementen repräsentiert wird, bei dem jedes Zeichen des Strings durch genau ein Element vom Typ char dargestellt wird. Außerdem berücksichtigen wir keine Multibyte-Strings und Encodings.

Kasten: 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 (http://www.mingw.org/), das ein minimales System für die Benutzung unter Windows bereitstellt.

[ header = Seite 3: Von der Klasse bereitzustellende Funktionalität ]

Von der Klasse bereitzustellende Funktionalität

Beginnen wir mit einem Überblick über die Schnittstelle der Klasse CString:

1. Erstellen einer Instanz der Klasse aus einem übergebenen char-Pointer (CString create(char *)). Die entsprechende Methode zur Erstellung einer Instanz wird im Allgemeinen als Konstruktor bezeichnet.
2. „Zerstören“/Freigeben einer gegebenen Instanz. Dabei sollte auch der entsprechende Speicher freigegeben werden. Diese Methode wird gemeinhin als Destruktor bezeichnet (void delete()).
3. Zugriff auf den entsprechenden char-Pointer, um ihn für Standard-C-Funktionen verwenden zu können, die diesen Datentyp erwarten (char * asCharPtr()).
4. Zugriff auf das n-te Zeichen der Zeichenkette (char charAt(int index)). Das kann in C durch den Indexzugriff mit [index] erreicht werden. Allerdings wird in C hier nicht geprüft, ob index im gültigen Bereich liegt.
5. Verbinden zweier Strings (CString concat(CString cs)). Das verlangt dynamische Speicherallokation, die in C immer wieder zu Fehlern führen kann.
6. Prüfen, ob ein String mit einem anderen String beginnt (startsWith(CString cs)).
7. Extrahieren eines Substrings mit Beginn und Ende (CString slice(int start, int end)). Wieder ist dynamische Allokation nötig.
8. Entfernen von Leerzeichen am Beginn und am Ende (CString trim()).

Abbildung 1 zeigt ein UML-Klassendiagramm für die Klasse CString. Zusätzlich zu den genannten Operationen ist hier noch die interne Datenstruktur in Form zweier Attribute aufgenommen. Das Attribut length enthält die Länge des Strings, der char-Pointer internalString die interne Darstellung des Strings.

Abb. 1: UML-Diagramm für die Stringklasse

Ein erster Wurf

Listing 1 enthält die Headerdatei für die neue Klasse. Das struct CStringStruct am Beginn der Datei enthält die genannten Attribute. Das anschließende typedef erlaubt es, statt struct CStringStruct in der Folge CString zu schreiben.

/* file cstring.h */

struct CStringStruct {
  char *internalCharPtr;
  unsigned int length;
};

typedef struct CStringStruct CString ;

CString CString_new(const char *s);
void CString_delete(CString cs);
char* CString_asCharPtr(CString cs);
char CString_charAt(CString cs, int index);
CString CString_concat(CString cs, CString cs2);
int CString_startsWith(CString cs1, CString cs2);
CString CString_slice(CString cs, int start, int end);
CString CString_trim(CString cs);

Darunter befinden sich die Deklarationen der Methoden. Als Konvention werden die Funktionen mit dem vorangestellten Namen der Klasse und einem Unterstrich versehen, um sie als Methoden der Klassen zu kennzeichnen. Bis auf den Konstruktor CString_new haben alle Methoden als ersten Parameter eine Instanz von CString. Das ist das entscheidende Merkmal einer Methode bei Modellierung einer Methode in C: Sie benötigt die Instanz, auf der gearbeitet werden soll. In objektorientierten Sprachen geschieht das implizit, d. h. eine Instanzmethode hat dort automatisch Zugriff auf die Instanz, in C muss das hier explizit durch Übergabe der Instanz als Parameter ermöglicht werden. Die Implementierung der Klasse ist in Listing 2 enthalten.

/* file cstring.c */

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

#include "cstring.h"

/* constructor */
CString CString_new(const char *s) {
  CString cs;
  int length = strlen(s);
  cs.internalCharPtr = malloc(length + 1);
  strcpy(cs.internalCharPtr, s);
  cs.length = length;
  return cs;
}

void CString_delete(CString cs) {
  free(cs.internalCharPtr);
  cs.internalCharPtr = NULL;
}

char * CString_asCharPtr(CString cs) {
  return cs.internalCharPtr;
}

char CString_charAt(CString cs, int index) {
  char c;
  if (index > (cs.length - 1)) {
      printf(
        "Runtime error: index '%d' is out of bounds " 
        "of char sequence '%s' (length:%d)",
        index, cs.internalCharPtr, cs.length
      );
    }
  else {
    c = cs.internalCharPtr[index];
  }
  return c;
}

CString CString_concat(CString cs1, CString cs2) {
  CString cs;
  int length1 = cs1.length;
  int length2 = cs2.length;
  int newLength = length1 + length2;
  cs.internalCharPtr = (char*) malloc(newLength + 1);
  memcpy(cs.internalCharPtr, CString_asCharPtr(cs1), length1);
  memcpy(cs.internalCharPtr + length1, CString_asCharPtr(cs2), length2 + 1);
  cs.length = newLength;
  return cs;
}

int CString_startsWith(CString cs1, CString cs2) {
  char *found = strstr(cs1.internalCharPtr, cs2.internalCharPtr);
  int startsWith = (found == cs1.internalCharPtr);
  return startsWith;
}

CString CString_slice(CString cs, int start, int end) {
  assert (end >= start);
  assert (start < cs.length);
  assert (end < cs.length);
  assert (start <= end);
  int newLength = end - start + 1;
  CString newCs;
  newCs.internalCharPtr = malloc(newLength);
  memcpy(newCs.internalCharPtr, 
         cs.internalCharPtr + (sizeof(char) * start), 
         newLength);
  newCs.internalCharPtr[newLength] = '';
  newCs.length = newLength;
  return newCs;
}

CString CString_trim(CString cs) {
  int startNonSpace;
  int endNonSpace;
  int index = 0;
  while (isspace(cs.internalCharPtr[index]) && index < cs.length) {
    index++;
  }
  startNonSpace = index;
  index = cs.length - 1;
  while (isspace(cs.internalCharPtr[index]) && index) {
    index--;
  }
  endNonSpace = index;
  CString newCs = CString_slice(cs, startNonSpace, endNonSpace);
  return newCs;
}

Der Konstruktor CString_new bekommt einen char-Pointer übergeben und alloziert für diesen dynamisch Speicherplatz. Die Länge des Strings wird im Attribut length, der Inhalt in internalString gespeichert.
Die Methode CString_asCharPtr(CString cs) gibt den internen char-Pointer zurück, um ihn in C-Funktionen, die char-Pointer benötigen, verwenden zu können.
Die Methode CString_charAt(CString cs, int index) gibt das Zeichen mit dem Index index innerhalb des Strings zurück. Falls der Index die Länge des Strings übersteigt, wird eine Fehlermeldung ausgegeben und das Programm mit einem „Runtime error“ beendet.
Die Methode CString_concat(CString cs1, CString cs2) bekommt zwei Instanzen von CString übergeben, ermittelt den benötigten Speicherplatz (die Summe der Längen beider Strings) und alloziert den benötigten Speicher. Dann wird der Inhalt der übergebenen Strings hintereinander in den neu allozierten Speicherbereich kopiert und eine neue Stringinstanz zurückgegeben.
Die Methode CString_startsWith(CString cs1, CString cs2) prüft mithilfe der C-Funktion strstr(), ob der erste String mit dem zweiten String beginnt, und gibt das Ergebnis zurück.
Die Methode CString_slice(CString cs, int start, int end) prüft zunächst die Gültigkeit der Parameter start und end, die innerhalb des Strings liegen müssen. Dann wird eine neue Instanz von CString erzeugt, der entsprechende Substring hineinkopiert und die neue Instanz zurückgegeben.
Die Methode CString_trim(CString cs) sucht vom Anfang ausgehend den ersten Character, der kein Space ist, als Startwert, und vom Ende des Strings ausgehend, den letzten Character, der kein Space ist, als Endwert. Dann wird die Methode CString_slice mit den gefundenen Werten aufgerufen und das entsprechende Ergebnis zurückgegeben.

Der Clientcode, der die neue Klasse testet und die Benutzung demonstriert, ist in Listing 3 (auf der Website zu finden) wiedergegeben. Die Resultate werden der Einfachheit halber über printf() ausgegeben. Gut zu erkennen ist, dass nicht auf die internen Details von CString zugegriffen werden muss, um Manipulationen vorzunehmen – diese werden ausschließlich über entsprechende Methodenaufrufe realisiert. Da eine automatische Speicherverwaltung in C schwer umzusetzen ist, muss der Speicher der generierten Instanzen abschließend durch expliziten Aufruf des Destruktors CString_delete() für jede Instanz freigegeben werden.
Zum Kompilieren der entsprechenden Dateien benutzen wir ein einfaches Makefile (Listing 4). Als Compiler wird hier gcc benutzt, tcc oder ein anderer C-Compiler wäre aber genauso möglich.

CC = gcc
	
test_cstring.exe: cstring.o test_cstring.o 
                  $(CC) test_cstring.o cstring.o -o test_cstring.exe 
test_cstring.o: test_cstring.c
                $(CC) -c test_cstring.c
cstring.o: cstring.h cstring.c
           $(CC) -c cstring.c

[ header = Seite 4: Was haben wir bis jetzt erreicht? ]

Was haben wir bis jetzt erreicht?

Durch die Trennung von Headerdatei und der dazugehörigen Implementierungsdatei ist in C automatisch eine Trennung von Schnittstelle und Implementierung gegeben, wenn man sich daran hält, in der Headerdatei lediglich Variablen und Funktionen zu deklarieren, die Funktionen jedoch nicht zu implementieren. Die öffentliche Schnittstelle entspricht dann dem, was in der Headerdatei enthalten ist. Dieses verbessert die Modularisierung des Quellcodes.
Der Benutzer der Klasse muss sich nicht mehr explizit um die Bereitstellung von Speicher für die interne Datenstruktur kümmern (das erledigt der Konstruktor). Allerdings muss an dieser Stelle darauf hingewiesen werden, dass hier lediglich der Speicher für den char-Pointer durch new() alloziert wird. Der Speicher für den CString selbst wird durch Deklaration einer entsprechenden Variablen angefordert, d. h. automatisch verwaltet. Der Benutzer der Klasse muss dennoch die delete()-Methode aufrufen, um den dynamisch angelegten Speicher für den char-Pointer freizugeben.

Der Clientcode ruft lediglich Methoden der Klasse auf, die in der Schnittstelle enthalten sind. Es wird nicht versucht, direkt auf die interne Datenstruktur zuzugreifen. Das hat zur Konsequenz, dass der Clientcode nicht geändert werden muss, wenn die interne Implementierung der Klasse geändert wird; sofern die Schnittstelle nicht geändert wird.
Diese Punkte stellen für sich genommen schon eine bedeutende Verbesserung gegenüber einer Arbeitsweise dar, bei der die Daten unabhängig von den aus ihnen arbeitenden Funktionen betrachtet werden und der Anwender für die Allokation von Speicher selbst zuständig ist. Aber wie ist es mit dem Aspekt von Datenkapselung? Der Benutzer der Klasse hat über die Headerdatei Zugriff auf die interne Datenstruktur. Hier scheiden sich die Geister – während etwa die Philosophie von Python davon ausgeht, dass der Anwender grundsätzlich auch auf die internen Strukturen zugreifen kann, kennen die meisten objektorientierten Sprachen das Konzept privater Methoden und Attribute. Das heißt hier wird strikt getrennt zwichen der öffentlichesn Schnittstelle und Interna der Klasse, die nicht herausgereicht werden. In unserer bestehenden Implementierung sind gemäß dieser Betrachtungsweise alle Attribute und Methoden public. Der hier wiedergegebene Clientcode (test_string.c) verzichtet darauf, auf die Interna von CStringStruct bzw. CString zuzugreifen, obwohl sie öffentlich sind. In vielen Fällen reichen hier Konventionen. Zum Beispiel könnte man, wie in Python, private Daten und Methoden durch einen führenden Unterstrich kennzeichnen – das signalisiert dem Benutzer der Klasse, dass er auf diese Methoden und Attribute von außen nicht zugreifen soll. Allerdings gibt es auch Situationen, wo die Beschränkung des Zugriffs erzwungen werden soll, z. B. aus Sicherheitsgründen oder weil man sich in bestimmten Projekten nicht darauf verlassen kann, dass die entsprechenden Konventionen beachtet werden. Benötigt werden hierzu private Methoden und Attribute. Glücklicherweise lassen sich auch diese in C realisieren. Wir werden darauf im nächsten Abschnitt eingehen.

An dieser Stelle sollen noch zwei Schwächen dieser Implementierung erwähnt werden: Zum einen erzeugt die Rückgabe von struct-Elementen in diesem Fall eine Kopie mit zusätzlichem Aufwand, der performancerelevant sein kann. Zum anderen haben wir bei der dynamischen Speicheranforderung auf eine Prüfung verzichtet, ob der angeforderte Speicher wirklich verfügbar ist. Wir werden im Rahmen des Artikels darauf nicht weiter eingehen. In einer realen Anwendung sollte das immer behandelt werden:

cs.internalCharPtr = malloc(length + 1);
if cs.internalCharPtr == NULL {
  /* handle this by returning an error, logging, assertion */
}

Variation 1: Datenkapselung – private Methoden und Attribute

Private Methoden und Attribute tauchen in objektorientierten Sprachen meistens in der Klassendefinition auf. Das heißt, dass ein Benutzer der Klasse sie kennt, auch wenn er nicht auf sie zugreifen kann. Streng genommen ist das gar nicht erwünscht – Dinge, die ich nicht kennen sollte (das ist das Prinzip von Information Hiding), sollte ich gar nicht zu Gesicht bekommen. Das bedeutet, dass die privaten Elemente einer Klasse gar nicht Bestandteil der Schnittstellendefinition sein sollten. Abbildung 2 zeigt ein UML-Klassendiagramm für die Klasse CString, bei der alle Attribute privat sind.

Abb. 2: UML-Diagramm für die Stringklasse

Private Methoden lassen sich dabei sehr einfach dadurch definieren, dass man sie nur in der Implementierungsdatei (cstring.c) aufnimmt. Das heißt wenn eine Methode CString_privateMethod(void) lediglich dort aufgenommen wird, ist sie für den Benutzer der Klasse erst einmal nicht sichtbar. Trotzdem könnte diese Methode vom Clientcode aufgerufen werden. Wenn auch das verhindert werden soll, kann das wie folgt erfolgen:

static void CString_privateMethod(void) {
  /* do something private */
  ...
}

Objekte, denen das Schlüsselwort static vorangestellt wird, können in C nur aus derselben Datei verwendet werden. Damit kann also wirksam verhindert werden, dass die Methode von außerhalb aufgerufen wird.
Soll auch die interne Datenstruktur privat gemacht werden, ist notwendig, dass die Definition der internen Datenstruktur (der struct CStringStruct) aus der Headerdatei verschwindet. In C ist das möglich, indem ein unvollständiger struct deklariert wird.
Listing 5 zeigt die modifizierte Headerdatei. Der struct CStringStruct wird hier unvollständig deklariert, d. h. die Felder innerhalb des structs sind nicht sichtbar. Allerdings kann der Compiler jetzt nicht mehr ermitteln, wie viel Speicher für den struct benötigt wird. Aus diesem Grund ist CString jetzt nicht mehr ein Alias für den struct selbst, sondern für einen Pointer auf diesen struct. Solch ein Pointer wird auch als „opaque pointer“ bezeichnet, ein Pointer auf einen unvollständig definierten Datentyp. Einzelheiten dazu finden sich unter [3]. Für den Pointer kann der Compiler wieder den Speicherplatz ermitteln.

/* file cstring.h */

struct CStringStruct; /* incomplete declaration */
typedef struct CStringStruct *CString; /* opaque pointer */

CString CString_new(const char *s);
...

Der Benutzer der Klasse hat nicht die Möglichkeit, auf Interna des unvollständig definierten structs zuzugreifen – ein entsprechender Versuch führt zu einer Fehlermeldung im Compiler. Wir könnten etwa folgende Codezeile in test_string.c einfügen:

int length = cs->length;

Beim Versuch, den Code zu kompilieren, kommt es dann zu folgender Fehlermeldung (bei Benutzung von gcc): „test_cstring.c|33 col 18| Fehler: Dereferenzierung eines Zeigers auf unvollständigen Typen“. Der Rest der Headerdatei kann unverändert bleiben. Allerdings ändert sich die Implementierung in cstring.c, da CString jetzt ein Pointer ist.

Listing 6 (auf der Website zum Magazin) zeigt die überarbeitete Implementierung in cstring.c. Die wesentlichen Änderungen sind an dieser Stelle:

• Die vollständige Definition des struct CStringStruct wurde in diese Datei verlagert.
CString ist nun ein Pointer. Das hat zwei Konsequenzen:
         o Um Speicher für Instanzen von CString zu allozieren, muss dieser nun explizit angefordert werden: CString cs = malloc(sizeof(CString));
         o Alle Zugriffe auf Elemente müssen jetzt über die Pointer-Notation erfolgen, ein Zugriff aus Attribute einer Instanz cs von CString erfolgt jetzt statt über cs.<feldname> über cs-><feldname>

Damit haben wir erreicht, dass die interne Struktur komplett privat ist – der entsprechende Schutz ist an dieser Stelle sogar etwas besser als in C++, wo die entsprechenden Attribute zwar als private deklariert werden könnten, der Aufbau der Klasse aber bekannt ist – über Casts bestünde hier immer noch die Möglichkeit, auf die entsprechenden Attribute zuzugreifen.
Zu beachten ist an dieser Stelle, dass sich auf Seite des Clientcodes keine Änderungen ergeben; die Benutzung der Klasse ist an dieser Stelle um nichts schwieriger geworden. Die geänderte Implementierung (dass CString jetzt ein Pointer ist) hat für den Benutzer kaum Folgen. Das illustriert einen wichtigen Aspekt der OOP: dadurch, dass über Datenstrukturen nur über definierte Schnittstellen zugegriffen wird, besteht die Möglichkeit, die entsprechenden Implementierungen für die Klassen auszutauschen, ohne den Anwendungscode anpassen zu müssen.

Es bleibt anzumerken, dass eine Umbenennung des Datentyps in CStringPtr an dieser Stelle wünschenswert wäre, um dem Benutzer der Klasse klar zu machen, dass es sich hier um einen Pointer handelt. Die Benutzung eines Pointers an dieser Stelle bedeutet, dass wir hier mit Call-by-Reference arbeiten, bei der die Datenobjekte nicht kopiert, sondern in-place bearbeitet werden. Das sorgt insbesondere im Fall dynamisch erzeugter Objekte für eine Verbesserung der Performance, da zeitraubende Kopiervorgänge entfallen. Es bedeutet aber auch, dass die Stringobjekte jetzt veränderlich sind, was erwünscht sein, aber auch zu Fehlern führen kann, wenn man einer Funktion das entsprechende Objekt übergibt und diese das Objekt selbst ändert. In Python sind Strings etwa „immutables“ und können nicht verändert werden, d. h. Stringoperationen geben hier immer einen neuen String zurück, während Collections (Listen etc.) in-place geändert werden können. Zusammenfassend kann festgehalten werden:

• Call-by-Value-Operationen, bei denen ein struct zurückgegeben wird, bewirken implizit eine Kopie und sind entsprechend langsamer, vermindern aber das Risiko von Seiteneffekten.
• Call-by-Reference-Operationen geben das Objekt direkt zurück (einen Pointer auf den struct) und sind entsprechend schneller, erhöhen aber das Risiko von Seiteneffekten, da die zurückgegebenen Objekte manipuliert werden können.

[ header = Seite 5: Variation 2: Bereitstellung öffentlicher und privater Elemente ]

Variation 2: Bereitstellung öffentlicher und privater Elemente

Wir haben jetzt gezeigt, wie die komplette interne Datenstruktur verborgen bzw. privat gemacht werden kann. Allerdings kann es den Wunsch geben, sowohl private als auch öffentliche Elemente nebeneinander zu verwenden. Beispielsweise könnte die Klasse mit einem Attribut encoding ausgestattet werden, das es ermöglicht, das Encoding zur Laufzeit zu ermitteln. Dieses soll als öffentlich sichtbares Element bereitgestellt werden. Abbildung 3 zeigt ein UML-Klassendiagramm für die Klasse CString mit öffentlichen und privaten Attributen.

Abb. 3: UML-Diagramm für die Stringklasse

Um das zu erreichen, können wir die bisher benutzten Verfahren kombinieren. Für die Repräsentation der Klasse benutzen wir einen vollständig deklarierten struct, dessen Elemente entsprechend öffentlich sind. Für die privaten Daten benutzen wir wie im letzten Beispiel wieder einen unvollständig definierten struct. Einen Pointer darauf machen wir dann zu einem Element von CStringStruct. Die Headerdatei sieht dann so aus:

/* file cstring.h */

struct CStringStruct_internal;
typedef struct CStringStruct_internal CStringPrivate;

struct CStringStruct {
  char *encoding;
  CStringPrivate *privateData;
};

typedef struct CStringStruct CString;
...

Zunächst wird mit CStringStruct_internal eine Struktur für die privaten Daten deklariert und CStringPrivate als Alias dafür definiert.
CStringStruct wiederum repräsentiert die eigentliche Datenstruktur der Klasse. Das Attribut encoding ist frei zugänglich. Die privaten Daten sind dagegen nicht sichtbar, hier existiert nur ein Pointer auf die entsprechende Struktur, die unvollständig definiert wurde. Der restliche Code der Headerdatei bleibt unverändert. Größer sind die Änderungen in der Implementierungsdatei.

/* file cstring.c */
...
#include "cstring.h"

struct CStringStruct_internal {
  char *internalCharPtr;
  unsigned int length;
};

Wie im letzten Beispiel schon verlegen wir die vollständige Definition der privaten Daten in die Implementierungsdatei und verbergen damit die Datenstruktur. Im Folgenden müssen wir jetzt nur dafür sorgen, dass für die privaten Daten auch Speicher alloziert wird:

/* constructor */
CString CString_new(const char *s) {
  CString cs;
  /* create empty char pointer */
  cs.encoding = malloc(sizeof(char));
  strcpy(cs.encoding, "");
  int length = strlen(s);
  cs.privateData = malloc(sizeof(CStringPrivate));
  cs.privateData->internalCharPtr = malloc(length + 1);
  strcpy(cs.privateData->internalCharPtr, s);
  cs.privateData->length = length;
  return cs;
}

Das wird durch die Zeile cs.privateData = malloc(sizeof(CStringPrivate)); bewirkt. Sie ist generell überall einzufügen, wo eine Deklaration der Form CString cs; erfolgt, da diese nur statisch den Speicher für den öffentlichen struct belegt, während für privateData der Speicher dynamisch alloziert werden muss. Desweiteren müssen Zugriffe der Form cs.<Attributname> … für Attribute aus der privaten Datenstruktur wie folgt geschrieben werden: cs.privateData-><Attributname>… Für den Zugriff auf die Länge ergibt sich etwa dann die Schreibweise cs.privateData->length
Listing 7 zeigt die entsprechende Implementierungsdatei. Neben den erwähnten Anpassungen für die Reservierung von Speicher für die private Datenstruktur und für den Zugriff auf die Elemente der privaten Datenstruktur hat sich auch der Destruktor geändert, da der zusätzliche dynamisch angeforderte Speicher ebenfalls freigegeben werden muss. Außerdem wurden Methoden CString_setEncoding() und CString_getEncoding() hinzugefügt, um auf das Encoding zugreifen zu können.
Listing 8 zeigt den angepassten Testcode, der auf das Attribut einmal direkt (als Attributzugriff) und über die erwähnten set/getEncoding()-Methoden zugreift.

/* file test_cstring.c */
#include "stdio.h"
#include "cstring.h"

int main() {
  ...
  CString trimmedCs = CString_trim(csWithSpaces);
  printf("String '%s' after trim is '%s'n", 
         CString_asCharPtr(csWithSpaces), 
         CString_asCharPtr(trimmedCs)
  );

  CString encodingString = CString_new("String without encoding");

  printf("Encoding of encodingString:'%s'n", 
         encodingString.encoding);

  printf("Encoding of encodingString:'%s'n", 
         CString_asCharPtr(CString_getEncoding(encodingString))
  );

  encodingString = CString_setEncoding(encodingString, "ascii");

  printf("Encoding of encodingString:'%s'n", 
         CString_asCharPtr(CString_getEncoding(encodingString))
  );

  ...
  CString_delete(trimmedCs);
  CString_delete(encodingString);

  return 0;
}

[ header = Seite 6: Variation 3: Methoden als Elemente der Klasse ]

Variation 3: Methoden als Elemente der Klasse

Ein Vorteil, den objektorientierte Sprachen bieten, ist, dass sie den Zusammenhang zwischen Methoden und Klassen syntaktisch verdeutlichen. So sieht ein Aufruf einer Methode eines Objekts in einer entsprechenden Sprache etwa so aus: myString.trim().
Das macht sehr deutlich, dass hier eine Methode von myString aufgerufen wird. Das ist in C so nicht möglich. Allerdings können wir den Datentyp (den struct) über Funktions-Pointer mit den definierten Methoden verknüpfen. Ein entsprechender Aufruf sähe dann so aus:

CString trimmedCs2 = csWithSpaces.trim(csWithSpaces);

Das ist zwar nicht so komfortabel wie etwa in C++, da immer noch die Instanz csWithSpaces als Parameter übergeben werden muss, zeigt aber, dass trim() eine Methode von csWithSpaces ist.
Listing 9 zeigt die angepasste Headerdatei. Der struct wird erweitert um Felder für die Funktions-Pointer für die Methoden.

/* file cstring.h */

struct CStringStruct_internal;
typedef struct CStringStruct_internal CStringPrivate;
struct CStringStruct;

typedef struct CStringStruct CString;

struct CStringStruct {
  char *className;
  CStringPrivate *privateData;
  void (*delete)(CString cs);
  char* (*asCharPtr)(CString cs);
  char (*charAt)(CString cs, int index);
  CString (*concat)(CString cs, CString cs2);
  int (*startsWith)(CString cs1, CString cs2);
  CString (*slice)(CString cs, int start, int end);
  CString (*trim)(CString cs);

};

CString CString_new(const char *s);
void CString_delete(CString cs);
char* CString_asCharPtr(CString cs);
char CString_charAt(CString cs, int index);
CString CString_concat(CString cs, CString cs2);
int CString_startsWith(CString cs1, CString cs2);
CString CString_slice(CString cs, int start, int end);
CString CString_trim(CString cs);

Mit void (*delete)(CString cs); wird etwa ein Pointer auf eine Funktion definiert, die keinen Rückgabewert hat und als Parameter ein Argument vom Typ CString erwartet. In gleicher Weise werden für alle Methoden Funktions-Pointer-Felder aufgenommen, mit Ausnahme des Konstruktors. Als Name des jeweiligen Felds wird der Name der Methodenfunktion ohne das Präfix benutzt.
Der Grund dafür, dass der Konstruktor hier nicht mit aufgenommen wird, ist, dass die Zuordnung von Funktionen zu den Funktions-Pointern erst bei der Erzeugung des Objekts im Konstruktor erfolgt.

...
struct CStringStruct_internal {
  char *internalCharPtr;
  unsigned int length;
};

void CString_delete(CString cs);
char* CString_asCharPtr(CString cs);
char CString_charAt(CString cs, int index);
CString CString_concat(CString cs1, CString cs2);
int CString_startsWith(CString cs1, CString cs2);
CString CString_slice(CString cs, int start, int end);
CString CString_trim(CString cs);

/* constructor */
CString CString_new(const char *s) {
  CString cs;
  int length = strlen(s);
  cs.privateData = malloc(sizeof(CStringPrivate));
  cs.privateData->internalCharPtr = malloc(length + 1);
  strcpy(cs.privateData->internalCharPtr, s);
  cs.privateData->length = length;
  cs.delete = &CString_delete;
  cs.asCharPtr = &CString_asCharPtr;
  cs.charAt = &CString_charAt;
  cs.concat = &CString_concat;
  cs.startsWith = &CString_startsWith;
  cs.slice = &CString_slice;
  cs.trim = &CString_trim;
  return cs;
}

void CString_delete(CString cs) {
  free(cs.privateData->internalCharPtr);
  free(cs.privateData);
  cs.privateData = NULL;
} 
...

Listing 10 gibt die modifizierte Version von cstring.c wieder. Zunächst werden die Funktionen, die als Methoden dienen sollen, prototypisch deklariert, damit der Code im Konstruktor CString_new die Signatur kennt. Anschließend werden dann zusätzlich den Funktions-Pointern die Adressen der korrespondierenden Funktionen zugewiesen:

cs.asCharPtr = &Cstring_asCharPtr;
... 

Im Clientcode kann die Methode dann wie folgt benutzt werden:

CString trimmedCs2 = csWithSpaces.trim(csWithSpaces);

Dadurch wird klarer dargestellt, dass trim() eine Methode der Instanz csWithSpaces ist. Ob dieses Vorgehen sinnvoll ist, hängt vom Anwendungsfall ab – die Methode erhöht zunächst die Lesbarkeit des Clientcodes. Zu bedenken ist aber, dass der Gewinn der Lesbarkeit zu deutlich aufwändigerem Code für die Implementierung der Klasse selbst führt: für jede Methode muss ein Feld für den entsprechenden Funktions-Pointer angelegt und eine entsprechende Funktionsadresse zugewiesen werden. Schlussendlich ist auch zu beachten, dass die Größe der Instanzen durch die zusätzlichen Felder für die Funktions-Pointer pro Methode entsprechend zunimmt, was allerdings heutzutage immer weniger ein Problem darstellt.

Fazit

Bis hierhin habe ich Ihnen einen kurzen Überblick über die Geschichte der Programmierung bis hin zur OOP gegeben und grundlegende, klassische Begriffe der OOP (Klassen, Objekte/Instanzen, Methoden, Attribute) eingeführt. Als Beispiel wurden verschiedene Variationen der Implementierung einer Stringklasse in ANSI C diskutiert.
Ziel des Ganzen war nicht, C als gleichwertige oder gar überlegene Sprache für die OOP darzustellen. C wurde im Gegenteil gerade wegen seiner Begrenzungen gewählt, um zu zeigen, dass sich die entsprechenden Konzepte zumindest teilweise auch umsetzen lassen, wenn eine Sprache sie nicht unterstützt.

Ausblick

Im nächsten Teil dieser Artikelserie „ANSI C goes OOP – Wiederverwendung und Vererbung“ soll es darum gehen, die gezeigten Techniken im Rahmen eines etwas komplexeren Beispiels darzustellen, bei dem nicht nur die Implementierung einer einzelnen Klasse, sondern das Zusammenspiel verschiedener Klassen und Objekte umgesetzt wird.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -