Objektorientierte Programmierung: OOP in C nutzen
Anders als die OOP-Sprachen C++ und Objective-C bringt C von Hause aus keine objektorientierten Features mit. Aufgrund der großen Verbreitung der Sprache und der Beliebtheit objektorientierter Programmierung existieren Ansätze, OOP in C einzusetzen.
OOP in C – geht das eigentlich?
Die C-Programmiersprache ist an sich nicht für die objektorientierte Programmierung gedacht. Die Sprache ist ein Paradebeispiel des strukturierten Programmierstils innerhalb der imperativen Programmierung. Dennoch ist es möglich, in C objektorientierte Ansätze nachzubilden. Die Sprache hat alle dafür benötigten Komponenten an Bord. So diente C u. a. als Grundlage für die objektorientierte Programmierung in Python.
Mit OOP lassen sich eigene „abstrakte Datentypen“ (ADT) definieren. Einen ADT kann man sich als Menge möglicher Werte und darauf operierender Funktionen vorstellen. Wichtig ist, dass die nach außen hin sichtbare Schnittstelle und die interne Umsetzung (Implementation) voneinander entkoppelt sind. So können Sie als Nutzer bzw. Nutzerin darauf vertrauen, dass Objekte des Typs sich der Beschreibung entsprechend verhalten.
Objektorientierte Sprachen wie Python, Java und C++ nutzen das Konzept der „Klasse“, um abstrakte Datentypen zu modellieren. Klassen dienen als Vorlage zum Erzeugen gleichartiger Objekte; man spricht dabei auch von Instanziierung. Von Hause aus kennt C keine Klassen, und diese lassen sich innerhalb der Sprache auch nicht nachbilden. Stattdessen existieren verschiedene Ansätze, OOP-Features in C zu implementieren.
Wie funktioniert OOP in C?
Um zu verstehen, wie OOP in C funktioniert, stellen wir uns zunächst die Frage: Was ist objektorientierte Programmierung (OOP)? OOP ist ein verbreiteter Programmierstil, der eine Ausprägung des imperativen Programmierparadigmas darstellt. Damit steht OOP im Gegensatz zur deklarativen Programmierung und deren Spezialisierung, der funktionalen Programmierung.
Die grundlegende Idee der objektorientierten Programmierung besteht darin, Objekte zu modellieren und untereinander interagieren zu lassen. Der Programmfluss ergibt sich aus den Interaktionen der Objekte und steht somit erst zur Laufzeit fest. Im Kern umfasst OOP lediglich drei Eigenschaften:
- Objekte kapseln ihren internen Zustand.
- Objekte empfangen Nachrichten über ihre Methoden.
- Die Zuordnung der Methoden erfolgt dynamisch zur Laufzeit.
Ein Objekt in einer reinen OOP-Sprache wie Java ist eine in sich geschlossene Einheit. Diese umfasst eine beliebig komplexe Datenstruktur sowie Methoden (Funktionen), die darauf operieren. Der interne Zustand des Objekts, abgebildet in den enthaltenen Daten, lässt sich nur über die Methoden auslesen und verändern. Für die Speicherverwaltung von Objekten kommt in der Regel ein „Garbage Collector“ genanntes Sprach-Feature zum Einsatz.
Eine Verbindung von Datenstruktur und Funktionen zu Objekten ist in C nicht ohne Weiteres möglich. Stattdessen strickt man sich ein überschaubares System aus Datenstrukturen, Typdefinitionen, Zeigern und Funktionen. Wie in C üblich, ist der Programmierer bzw. die Programmiererin für die ordnungsgemäße Zuteilung und Freigabe von Speicher zuständig.
Der dabei entstehende objektbasierte C-Code sieht nicht ganz so aus wie von OOP-Sprachen gewohnt, funktioniert aber. Wir geben einen Überblick über die zentralen OOP-Konzepte samt ihrer Entsprechung in C:
OOP-Konzept | Entsprechung in C |
---|---|
Klasse | Struct-Typ |
Klassen-Instanz | Struct-Instanz |
Instanz-Methode | Funktion, die Zeiger auf Struct-Variable entgegennimmt |
this-/self-Variable | Zeiger auf Struct-Variable |
Instanziierung | Allozierung und Referenz via Zeiger |
new-Schlüsselwort | Aufruf von malloc |
Objekte als Datenstrukturen modellieren
Schauen wir uns zunächst an, wie sich die Datenstruktur eines Objekts im Stil von OOP-Sprachen in C modellieren lässt. C ist eine kompakte Sprache, die mit wenigen Sprachkonstrukten auskommt. Zum Erzeugen beliebig komplexer Datenstrukturen kommen die sogenannten „Structs“ zum Einsatz, deren Name sich vom Begriff „Data Structure“ herleitet.
Eine C-Struct definiert eine Datenstruktur, die „Members“ genannte Felder umfasst. In anderen Sprachen wird ein derartiges Konstrukt auch als „Record“ bezeichnet, und so kann man sich eine Struct gut wie die Zeile einer Datenbanktabelle vorstellen: ein Verbund mehrerer Felder ggf. unterschiedlichen Typs.
Die Syntax einer Struct-Deklaration in C ist denkbar einfach:
struct struct_name;
Optional definieren wir die Struct auch gleich, indem wir die Members mit Namen und Typ angeben. Betrachten wir als Standardbeispiel einen Punkt im zweidimensionalen Raum mit x- und y-Koordinate. Wir zeigen die Struct-Definition:
struct point {
/* X-coordinate */
int x;
/* Y-coordinate */
int y;
};
In herkömmlichem C-Code folgt anschließend die Instanziierung einer Struct-Variable. Wir erzeugen die Variable und initialisieren beide Felder mit 0:
struct point origin = {0, 0};
Im Anschluss lassen sich die Werte der Felder auslesen und neu setzen. Der Member-Zugriff erfolgt über die aus anderen Sprachen vertraute Syntax origin.x und origin.y:
/* Read struct member */
origin.x == 0
/* Assign struct member */
origin.y = 42
Damit verletzen wir jedoch die Anforderung der Kapselung: Auf den internen Zustand eines Objekts darf nur über dafür definierte Methoden zugegriffen werden. Unserem Ansatz fehlt also noch etwas.
Typen zur Erzeugung von Objekten definieren
Wie erwähnt kennt C kein Konzept der Klasse. Stattdessen lassen sich Typen mit der typedef-Anweisung definieren. Mit typedef geben wir einem Datentyp einen neuen Namen:
typedef <old-type-name> <new-type-name>
So lässt sich für unsere point-Struct ein entsprechender Point-Typ definieren:
typedef struct point Point;
Die Kombination von typedef mit einer struct-Definition entspricht in etwa einer Klassendefinition in Java:
typedef struct point {
/* X-coordinate */
int x;
/* Y-coordinate */
int y;
} Point;
Im Beispiel ist „point“ der Name der Struct, wohingegen „Point“ der Name des definierten Typs ist.
Hier die entsprechende Klassendefinition in Java:
class Point {
private int x;
private int y;
};
Die Nutzung von typedef erlaubt uns, eine Point-Variable ohne Nutzung des struct-Schlüsselworts zu erzeugen:
Point origin = {0, 0}
/* Instead of */
struct point origin = {0, 0}
Was weiterhin fehlt, ist die Kapselung des internen Zustands.
Kapselung des internen Zustands
Objekte bilden ihren internen Zustand in ihrer Datenstruktur ab. In OOP-Sprachen wie Java kommen die Schlüsselwörter „private“, „protected“ etc. zum Einsatz, um den Zugriff auf Objektdaten zu beschränken. So wird der direkte Zugriff von außen verhindert und die Trennung von Schnittstelle und Implementation sichergestellt.
Um OOP in C zu realisieren, kommt ein anderer Mechanismus zum Einsatz. Wir nutzen als Schnittstelle eine sogenannte Forward-Deklaration in der Header-Datei und erzeugen damit einen „Incomplete type“:
/* In C header file */
struct point;
/* Incomplete type */
typedef struct point Point;
Die Implementation der point-Struct folgt in einer separaten C-Quelltext-Datei, die den Header per include-Macro einbindet. Dieser Ansatz verhindert die Erzeugung statischer Variablen des Point-Typs. Weiterhin möglich ist die Verwendung von Zeigern des Typs. Da es sich bei Objekten um dynamisch erzeugte Datenstrukturen handelt, werden sie ohnehin mit Zeigern referenziert. Zeiger auf Struct-Instanzen entsprechen in etwa den in Java zum Einsatz kommenden Objekt-Referenzen.
Methoden durch Funktionen ersetzen
In OOP-Sprachen wie Java und Python umfassen Objekte neben ihren Daten die darauf operierenden Funktionen. Diese werden als Methoden bzw. Instanz-Methoden bezeichnet. Wenn wir OOP-Code in C schreiben, nutzen wir statt Methoden Funktionen, die einen Zeiger auf eine Struct-Instanz entgegennehmen:
/* Pointer to `Point` struct */
Point * point;
Da C keine Klassen kennt, ist es nicht möglich, die zu einem Typ gehörenden Funktionen unter einem gemeinsamen Namen zu gruppieren. Stattdessen versehen wir die Funktionsnamen mit einem Präfix, der den Namen des Typs enthält. Die entsprechenden Funktions-Signaturen werden zunächst in der C-Header-Datei deklariert:
/* In C header file */
/* Function to move update a point's coordinates */
void Point_move(Point * point, int new_x, int new_y);
Im Anschluss implementieren wir die Funktion in der C-Quelltext-Datei:
/* In C source file */
void Point_move(Point * point, int new_x, int new_y) {
point->x = new_x;
point->y = new_y;
};
Der Ansatz erinnert an Python-Methoden, die normale Funktionen sind, die self als ersten Parameter entgegennehmen. Ferner entspricht der Zeiger auf eine Struct-Instanz in etwa der this-Variable in Java oder JavaScript. Der Unterschied liegt darin, dass beim Aufruf der C-Funktion der Zeiger explizit übergeben wird:
/* Call function with pointer argument */
Point_move(point, 42, 51);
Beim äquivalenten Funktionsaufruf in Java steht das point-Objekt innerhalb der Methode als this-Variable zur Verfügung:
// Call instance method from outside of class
point.move(42, 51)
// Call instance method from within class
this.move(42, 51)
Python erlaubt, Methoden als Funktionen mit explizitem Self-Argument aufzurufen:
# Call instance method from outside or from within class
self.move(42, 51)
# Function call from within class
move(self, 42, 51)
Objekte instanziieren
Eine prägende Eigenschaft von C ist die manuelle Speicherverwaltung: Programmierer und Programmiererinnen sind dafür zuständig, Speicher für Datenstrukturen zu allozieren. Objektorientierte, dynamische Sprachen wie Java und Python nehmen ihnen die Arbeit ab. In Java kommt zur Instanziierung eines Objekts das new-Schlüsselwort zum Einsatz. Unter der Haube wird dabei automatisch Speicher alloziert:
// Create new Point instance
Point point = new Point();
Wenn wir OOP-Code in C schreiben, definieren wir für die Instanziierung eine spezielle Konstruktor-Funktion. Diese alloziert Speicher für unsere Struct-Instanz, initialisiert diese und gibt einen Zeiger darauf zurück:
Point * Point_new(int x, int y) {
/* Allocate memory and cast to pointer type */
Point * point = (Point *) malloc(sizeof(Point));
/* Initialize members */
Point_init(point, x, y);
// return pointer
return point;
};
In unserem Beispiel entkoppeln wir die Initialisierung der Struct-Members von der Instanziierung. Wiederum kommt eine Funktion mit dem Point-Präfix zum Einsatz:
void Point_init(Point * point, int x, int y) {
point->x = x;
point->y = y;
};
Wie lässt sich ein C-Projekt objektorientiert neu aufsetzen?
Ein bestehendes Projekt mit den beschriebenen OOP-Techniken in C neu zu schreiben, ist nur in Ausnahmefällen zu empfehlen. Sinnvoller sind die folgenden Ansätze:
- Projekt in einer C-artigen Sprache mit OOP-Features neu schreiben und die existierende C-Codebasis als Spezifikation nutzen
- Teile des Projekts in einer OOP-Sprache neu schreiben und spezifische C-Komponenten beibehalten
Sofern die C-Codebasis sauber geschrieben ist, sollte der zweite Ansatz gute Ergebnisse liefern. Es ist gang und gäbe, performanzkritische Programmteile in C zu implementieren und aus anderen Sprachen darauf zuzugreifen. Wohl keine andere Sprache eignet sich dafür besser als C. Doch welche Sprachen sind geeignet, um ein bestehendes C-Projekt unter Nutzung von OOP-Prinzipien neu aufzusetzen?
C-artige objektorientierte Sprachen
Es gibt eine reichhaltige Auswahl C-ähnlicher Sprachen mit eingebauter Objektorientierung. Die wohl bekannteste ist C++; jedoch ist die Sprache berüchtigt für ihre Komplexität, was in den vergangenen Jahren zu einer Abkehr von C++ führte. Auf Grund der weitgehenden Übereinstimmung der grundlegenden Sprachkonstrukte lässt sich C-Code relativ einfach in C++ einbinden.
Sehr viel leichtgewichtiger als C++ ist Objective-C. Der an die Original-OOP-Sprache Smalltalk angelehnte C-Dialekt kam vor allem zur Programmierung von Applikationen auf Mac- und frühen iOS-Betriebssystemen zum Einsatz. Später folgte darauf aufbauend die Entwicklung der Apple-eigenen Sprache Swift. Aus beiden Sprachen heraus lassen sich in C geschriebene Funktionen aufrufen.
Auf C aufbauende objektorientierte Sprachen
Andere OOP-Programmiersprachen, die der Syntax nach nicht mit C verwandt sind, eignen sich ebenfalls zum Neuschreiben eines C-Projekts. Für Python, Rust und Java existieren Standardansätze zum Einbinden von C-Code.
In Python erlauben die sogenannten Python Bindings das Einbinden von C-Code. Dabei müssen ggf. Python-Datentypen in die entsprechenden ctypes übersetzt werden. Ferner gibt es das C Foreign Function Interface (CFFI), das das Übersetzen der Typen in gewissem Rahmen automatisiert.
Auch Rust unterstützt das Aufrufen von C-Funktionen mit geringem Aufwand. Mithilfe des extern-Schlüsselworts lässt sich ein dahingehendes Foreign Function Interface (FFI) definieren. Rust-Funktionen, die auf externe Funktionen zugreifen, müssen als unsafe deklariert werden:
extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
unsafe {
println!("Absolute value of -3 according to C: {}", abs(-3));
}
}