c systems notebook

Objektorientierte Prinzipien mit C

Objektorientierte Programmierung (OOP) ist ein zentrales Paradigma in der Softwareentwicklung. Seit den 1990er Jahren beeinflusst sie die Gestaltung großer Systeme. Sprachen wie Smalltalk, C++, Java oder Python erscheinen in Ausbildungsplänen und finden breite Anwendung in der Industrie. Dieser Ansatz modelliert Probleme durch Objekte, die Daten und Funktionen zusammenfassen.

Objektorientierte Programmierung: Ursprung und Wandel des Begriffs

Die norwegischen Informatiker Ole-Johan Dahl und Kristen Nygaard entwickelten ab 1961 am Norwegian Computing Center die Sprache Simula, um komplexe Simulationen zu modellieren. Simula 67 (1967) führte die Kernelemente ein, die heute objektorientierte Sprachen definieren: Kapselung von Daten und Funktionen, Vererbung (einschließlich später Bindung) und dynamische Objekterzeugung. Ein ›Objekt‹ war dort eine Instanz einer Klasse: eine Einheit mit eigenem Zustand und Prozeduren (Methoden). Objekte entstanden zur Laufzeit und interagierten durch Nachrichten oder Methodenaufrufe. Simula bot eine natürliche Struktur für komplexe Aufgaben. Diese Techniken erhielten den Namen ›objektorientierte Programmierung‹ (OOP) erst später; Dahl und Nygaard verwendeten ihn zunächst nicht.

Unabhängig davon lernte in den USA Alan Kay Simula 1967 kennen und prägte den Begriff 'object-oriented programming' in den folgenden Jahren. Er sah Software als Netzwerk gekapselter ›kleiner Rechner‹: Objekte als unabhängige Einheiten, die nur über Nachrichten interagieren.Das überwand die Trennung von Daten und Prozeduren. Am Xerox Palo Alto Research Center (PARC) entwickelten Kay, Dan Ingalls, Adele Goldberg und andere in den 1970er Jahren Smalltalk. Smalltalk war die erste rein objektorientierte Sprache, in der alles – von Zahlen bis Klassen – ein Objekt ist. Die Interaktion erfolgte ausschließlich über Nachrichten. Vererbung spielte zunächst eine untergeordnete Rolle; frühe Versionen kamen ohne Unterklassen aus. Das entsprach Kays Fokus auf flexible Objekte und Kommunikation statt starrer Hierarchien.

Ab den 1980er Jahren wurde OOP mainstream. Bjarne Stroustrup integrierte Objektorientierung in C und schuf C++, was OOP für System- und Anwendungsprogrammierung relevant machte. Ein Jahrzehnt später folgte Java (von James Gosling bei Sun entwickelt), das von Anfang an objektorientiert war. Mit C++ und Java etablierte sich OOP in der Industrie als dominierender Ansatz.

Abbildung

Hauptmerkmale der Objektorientierung

Durch die weite Verbreitung kristallisierte sich allmählich heraus, was allgemein unter objektorientierter Programmierung zu verstehen ist. In der Literatur werden meist drei Hauptmerkmale genannt:

  • Kapselung: Objekte bündeln Daten (Zustand) und Methoden in einer Einheit. Externe Teile greifen nur über Schnittstellen (Methodenaufrufe) darauf zu.

  • Vererbung (mit Polymorphie durch späte Bindung): Objekte sind Instanzen von Klassen, die hierarchisch organisiert sein können. Unterklassen erben Eigenschaften und Methoden und können sie überschreiben. Das fördert Code-Wiederverwendung; späte Bindung bestimmt zur Laufzeit, welche Methode ausgeführt wird.

  • Dynamische Objekterzeugung: Objekte entstehen zur Laufzeit. Das ermöglicht flexible Modelle und Datenstrukturen.

Simula 67 realisierte diese Merkmale erstmals vollständig. In den folgenden Jahrzehnten variierte die Interpretation. Viele Sprachen betonten Klassenhierarchien und Vererbung. Alan Kay priorisierte flexible Kommunikation und lose Kopplung; starre Strukturen sah er als nebensächlich. In einer E-Mail von 2003 definierte er OOP als ›Nachrichtenübermittlung, lokale Beibehaltung und Kapselung von Zustand und Prozess sowie späte Bindung‹. Vererbung hielt er für optional.

Diese Unterschiede zeigen: OOP ist kein Dogma, sondern ein Kanon mit kontextuellen Schwerpunkten.

Bedeutung der Objektorientierung heute

OOP etablierte sich, da sie Vorteile bei der Strukturierung großer Software bietet. Statt monolithischer Abläufe zerlegt man Systeme in modulare Objekte, die Verantwortlichkeiten kapseln. Komplexe Probleme lassen sich so abbilden, da Objekte reale Entitäten widerspiegeln. Das steigert Wiederverwendbarkeit und Wartbarkeit; Änderungen wirken nur über Schnittstellen.

Seit den 1990er Jahren dominiert OOP die Softwareentwicklung. Dahl und Nygaard erhielten 2001 den Turing Award für OOP. Das Konzept evolvierte, bleibt aber essenziell für überschaubare Software.

Struktur mit freien Funktionen

Bei diesem ersten Ansatz steht eine schlichte Kombination aus struct und freien Funktionen im Mittelpunkt. Die Daten werden in einer Struktur gebündelt, sämtliche Operationen darauf sind als normale globale Funktionen definiert, die einen Zeiger auf diese Struktur erhalten. Das bildet so etwas wie ›Methoden‹ nach, ohne dass Sprachelemente wie in C++ oder Java zur Verfügung stehen.

Zunächst wird im Header die öffentliche Schnittstelle festgelegt. Sie besteht aus der Definition des Datentyps YearMonth und den Funktionsprototypen, die darauf arbeiten:

// year_month.h
#ifndef YEAR_MONTH_H
#define YEAR_MONTH_H

typedef struct {
    int year;
    int month;
} YearMonth;

void YearMonth_init(YearMonth *ym, int year, int month);
void YearMonth_add_months(YearMonth *ym, int n);
int  YearMonth_diff_months(const YearMonth *a, const YearMonth *b);

#endif

Der Header trennt damit klar zwischen ›Was ist von außen sichtbar?‹ und ›Wie ist es implementiert?‹. Der Typ YearMonth ist hier ein einfacher POD-Typ (›plain old data‹) mit zwei int Mitgliedern. Er kann auf dem Stack angelegt, per Zuweisung kopiert und in Containern verwendet werden. Funktionen haben den bekannten Präfix YearMonth_, um Namenskollisionen im globalen Namensraum zu vermeiden.

Die Implementierung in der .c-Datei zeigt ein typisches C-Muster: Eine Hilfsfunktion wird per static auf die Implementierungsdatei beschränkt und damit gewissermaßen ›privat‹ gehalten. Die öffentlichen Funktionen teilen sich diese interne Logik:

// year_month.c
#include "year_month.h"

static void YearMonth_normalize(YearMonth *ym) {
    if (!ym) return;
    int total = ym->year * 12 + (ym->month - 1);
    ym->year  = total / 12;
    ym->month = total % 12 + 1;
}

void YearMonth_init(YearMonth *ym, int year, int month) {
    ym->year  = year;
    ym->month = month;
    YearMonth_normalize(ym);
}

void YearMonth_add_months(YearMonth *ym, int n) {
    ym->month += n;
    YearMonth_normalize(ym);
}

int YearMonth_diff_months(const YearMonth *a, const YearMonth *b) {
    return (b->year - a->year) * 12 + (b->month - a->month);
}

YearMonth_normalize() ist nur innerhalb von year_month.c sichtbar und kann von außen nicht direkt verwendet werden. Damit entsteht eine einfache Form der Kapselung: Die Normalisierungslogik ist zentral an einer Stelle implementiert und kann nicht versehentlich von fremdem Code missbraucht oder inkonsistent nachgebaut werden. Die öffentlichen Funktionen YearMonth_init() und YearMonth_add_months() garantieren, dass ein YearMonth nach jedem Aufruf wieder in einem gültigen Zustand ist (Monatsbereich 1 bis 12).

Ein zentraler Punkt dieses Ansatzes ist der fundamentale Unterschied zu Sprachen mit echten Methoden: In C können Funktionen und Speicher nicht zu einer syntaktischen Einheit ›Objekt‹ zusammengefasst werden. Eine Funktion wie YearMonth_add_months() ist eine freie Funktion, die zufällig einen YearMonth* erwartet. Die Bindung zwischen Funktion und Daten entsteht rein konventionell durch den gemeinsamen Namenspräfix und durch die Parameterliste, nicht durch Sprachmechanismen. Statt ym.add_months(2) wie in einer OO-Sprache wird YearMonth_add_months(&ym, 2) aufgerufen. Die Funktionen ›gehören‹ dem Objekt nicht; sie operieren nur auf einem Zeiger auf dessen Speicher.

Das gilt auch für die Unterschiede zwischen Lese- und Schreibfunktionen. In der Schnittstelle wird für reine Leseoperationen const verwendet (const YearMonth *a, const YearMonth *b in YearMonth_diff_months), während schreibende Funktionen einen nicht-konstanten Zeiger erhalten. Auf diese Weise lässt sich zumindest teilweise ausdrücken, welche Funktionen den Zustand verändern und welche nicht.

Die Nutzung des Typs bleibt bewusst einfach: YearMonth wird als Stack-Objekt angelegt, initialisiert und dann über seine Adresse an die Funktionen übergeben. Kopieren geschieht als flache Kopie per Zuweisung:

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

int main() {
    YearMonth a;
    YearMonth b;

    YearMonth_init(&a, 2025, 6);
    b = a;  // flache Kopie
    YearMonth_add_months(&b, 2);

    printf("a: %04d-%02d\n", a.year, a.month);
    printf("b: %04d-%02d\n", b.year, b.month);

    int diff = YearMonth_diff_months(&a, &b);
    printf("Diff in months: %d\n", diff);
}

YearMonth kann ohne Weiteres kopiert werden, weil nur elementare Werte enthalten sind. Es existiert kein versteckter Heap-Speicher und keine Ownership-Frage. Der Lebenszyklus des Objekts folgt dem Lebenszyklus der Variablen auf dem Stack, es sind keine create()/destroy()-Funktionen nötig, und es gibt keinen zusätzlichen Overhead durch Zeiger oder VTables.

Gleichzeitig werden auch die Grenzen dieses Ansatzes sichtbar. Verhalten ist nicht pro Instanz austauschbar, da alle Objekte dieselben freien Funktionen verwenden. Polymorphie entsteht, wenn überhaupt, nur durch manuelle Konstrukte wie enum + switch innerhalb der Funktionen. Die Strukturdefinition im Header legt alle Mitglieder offen; eine Änderung an YearMonth erfordert eine Neukompilierung aller Übersetzungseinheiten, die diesen Header einbinden. Echte, durch die Sprache erzwungene Kapselung existiert hier nicht.

Dieser Stil ist damit gut geeignet für kleine, klar abgegrenzte Datentypen mit trivialem Speicherlayout. Er bietet einen Ausgangspunkt, um in C strukturierter zu arbeiten und eine API um einen Datentyp herum aufzubauen.

Einbettung als einfache Vererbung

In objektorientierten Sprachen beschreibt Vererbung eine Beziehung zwischen Typen. Eine Unterklasse ist ein Spezialfall ihrer Oberklasse und übernimmt deren gemeinsame Eigenschaften. Gemeinsamer Zustand und gemeinsames Verhalten werden in den Obertyp ausgelagert, und spezialisierte Typen ergänzen oder verändern nur das, was zusätzlich nötig ist. Typische Formulierung: Ein BoundedYearMonth ist ein YearMonth, aber mit zusätzlichen Grenzen.

Vererbungsbeziehungen sind damit eine konkrete Art von Beziehung zwischen Typen: Eine Unterklasse steht in einer ist-ein-Beziehung zur Oberklasse. Dem gegenüber stehen andere Beziehungen, etwa hat-ein (Komposition oder Aggregation): Ein Objekt enthält ein anderes Objekt als Bestandteil, ohne dessen Identität zu übernehmen. In klassischen OO-Sprachen gibt es dafür eigene Sprachmittel (Schlüsselwörter für Vererbung, virtuelle Methoden, sichtbarkeitsgesteuerte Mitglieder).

C kennt solche Vererbungsmechanismen nicht. Strukturen können keine Obertypen ableiten, und der Compiler kennt keine ist-ein-Beziehungen zwischen struct-Typen. Technisch kann man aber einen ähnlichen Effekt erzeugen, indem man eine hat-ein-Beziehung gezielt so konstruiert, dass sie wie eine einfache Vererbung nutzbar wird: Die ›Unterklasse‹ enthält die ›Oberklasse‹ als erstes Mitglied. Damit liegt der Basisteil am Anfang der abgeleiteten Struktur im Speicher, und ein Zeiger auf die Unterklasse kann an Stellen verwendet werden, an denen ein Zeiger auf die Oberklasse erwartet wird. Die Beziehung ist formal eine Assoziation (hat-ein), wird aber so genutzt, als ob eine einfache Vererbung vorläge.

Genau das passiert im Beispiel BoundedYearMonth. Wir haben bereits einen Basistyp YearMonth, der Jahr und Monat kapselt und passende Funktionen zur Initialisierung und zur Monatsarithmetik bereitstellt. BoundedYearMonth definiert nun einen Untertyp, der den YearMonth-Zustand übernimmt, aber zusätzlich minimale und maximale Jahresgrenzen verwaltet. Der Basisteil wird als erstes Mitglied eingebettet:

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

// Unterklasse: YearMonth mit Mindest- und Höchstjahr
typedef struct {
    YearMonth base;   // geerbter Teil: year, month
    int min_year;
    int max_year;
} BoundedYearMonth;

// kleine Hilfsfunktion
static inline int clamp(int x, int lo, int hi) {
    return x < lo ? lo : (x > hi ? hi : x);
}

// Initialisierung: wiederverwenden der Basislogik
void BoundedYearMonth_init(BoundedYearMonth *bym,
                           int year, int month,
                           int min_year, int max_year)
{
    bym->min_year = min_year;
    bym->max_year = max_year;

    // Basis-Initialisierung wiederverwenden
    YearMonth_init(&bym->base, year, month);

    // in Grenzen bringen
    bym->base.year = clamp(bym->base.year, bym->min_year, bym->max_year);
}

// Monate addieren, danach clamp
void BoundedYearMonth_add_months(BoundedYearMonth *bym, int n) {
    YearMonth_add_months(&bym->base, n);
    bym->base.year = clamp(bym->base.year, bym->min_year, bym->max_year);
}

// Upcast-Demo: Funktion akzeptiert YearMonth*, funktioniert mit beiden Typen
static void print_year_month(const char *label, const YearMonth *ym) {
    printf("%s: %04d-%02d\n", label, ym->year, ym->month);
}

int main() {
    YearMonth plain;
    YearMonth_init(&plain, 2025, 6);

    BoundedYearMonth bym;
    BoundedYearMonth_init(&bym, 2029, 11, 2020, 2030);

    print_year_month("plain start   ", &plain);
    print_year_month("bounded start ", &bym.base);  // Upcast

    // jeweils +24 Monate
    YearMonth_add_months(&plain, 24);
    BoundedYearMonth_add_months(&bym, 24);

    puts("");
    print_year_month("plain +24   ", &plain);
    print_year_month("bounded +24 ", &bym.base);  // Upcast
}

Der geerbte Zustand steckt im Mitglied base. Die zusätzlichen Mitglieder min_year und max_year sind spezifisch für den Subtyp. Die Initialisierung nutzt die bestehende Logik des Obertyps: BoundedYearMonth_init ruft YearMonth_init auf +bym->base+ auf und führt erst danach die Subtyp-spezifische Begrenzung durch. Damit ist klar getrennt, was der Basistyp verantwortet (korrekte Normalisierung von Jahr und Monat) und was der Untertyp ergänzt (Einhaltung eines Jahresintervalls).

Die Beziehung zwischen BoundedYearMonth und YearMonth zeigt sich auch im Aufrufverhalten. An Stellen, an denen eine Funktion YearMonth* erwartet, kann ein BoundedYearMonth über sein erstes Mitglied übergeben werden, z. B. +&bym->base+. Dieser ›Upcast‹ ist in C kein echter Typsystem-Mechanismus, sondern ein bewusster Trick: Weil base als erstes Mitglied steht, deckt sich die Adresse von bym.base mit dem Beginn der Gesamtstruktur. Das ist eine Konvention, die der Programmierer einhalten muss. Der Compiler überprüft die Vererbungsbeziehung nicht, er sieht nur verschachtelte Strukturen.

Wichtig ist auch, was hier nicht passiert: Es gibt keine dynamische Bindung. Welche Funktion aufgerufen wird, entscheidet sich allein über den expliziten Funktionsnamen. Ruft der Aufrufer YearMonth_add_months() auf, wird immer die Basisimplementierung genutzt. Ruft er BoundedYearMonth_add_months() auf, erhält er die Variante mit Grenzen. Es existiert keine virtuelle Dispatch-Mechanik, die zur Laufzeit je nach konkretem Typ zwischen Implementierungen umschaltet. Die Vererbungsbeziehung bleibt rein konzeptionell, technisch basiert sie auf Einbettung und expliziter Auswahl der Funktionen.

Damit lässt sich mit C ein Teil der Idee von Vererbung umsetzen: gemeinsamer Zustand und gemeinsame Logik in einem Basistyp, Spezialisierung durch erweiterte Strukturen, und Wiederverwendung über Einbettung und Funktionsaufrufe. Die Sprache selbst bietet keine Vererbungsfeatures, aber über gezielte Assoziationen zwischen Strukturen kann man ähnliche Beziehungen modellieren und im Code konsistent nutzen.

Methoden über Funktionszeiger in der Instanz

Jetzt wechseln wir von rein datenhaltigen Strukturen zu Strukturen, die ihr Verhalten selbst mitbringen. Dazu erweitern wir YearMonth um Funktionszeiger-Mitglieder, die die ›Methoden‹ repräsentieren. Die Struktur ist damit nicht mehr nur ein passiver Datenträger, sondern trägt auch die Information, wie sie sich verhalten soll.

Wir ändern die bisherige Strukturdefinition von YearMonth, die zuvor nur aus den Mitgliedern year und month bestand. Nun kommen zwei Funktionszeiger als weitere Mitglieder hinzu:

typedef struct YearMonth YearMonth;
struct YearMonth {
    int year;
    int month;
    // öffentliche Methoden per Funktionszeiger
    void (*add_months)(YearMonth *self, int n);
    int (*diff_months)(const YearMonth *self, const YearMonth *other);
};

Die Struktur wird in zwei Schritten angelegt: Zuerst eine Vorwärtsdeklaration mit typedef struct YearMonth YearMonth;, damit die Funktionszeiger auf den eigenen Typ zeigen können. Danach folgt die eigentliche Definition.

#include <stdio.h>

typedef struct YearMonth YearMonth;

struct YearMonth {
    int year;
    int month;

    // öffentliche Methoden per Funktionszeiger
    void (*add_months)(YearMonth *self, int n);
    int (*diff_months)(const YearMonth *self, const YearMonth *other);
};

// Standardimplementierungen
static void YearMonth_add_months_default(YearMonth *self, int n) {
    int total = self->year * 12 + (self->month - 1) + n;
    self->year  = total / 12;
    self->month = total % 12 + 1;
}

static int YearMonth_diff_months_default(const YearMonth *self,
                                         const YearMonth *other) {
    return (other->year - self->year) * 12 + (other->month - self->month);
}

// Initialisierung: setzt Funktionszeiger
void YearMonth_init(YearMonth *ym, int y, int m) {
    ym->year  = y;
    ym->month = m;
    ym->add_months  = YearMonth_add_months_default;
    ym->diff_months = YearMonth_diff_months_default;
}

int main() {
    YearMonth a;
    YearMonth b;

    YearMonth_init(&a, 2025, 6);  // 2025-06
    b = a;                        // Kopie inkl. Funktionszeiger

    a.add_months(&a, 2);          // a -> 2025-08
    b.add_months(&b, -3);         // b -> 2025-03

    printf(
        "a: %04d-%02d\n"          // 📣 a: 2025-08
        "b: %04d-%02d\n"          // 📣 b: 2025-03
        "Diff a->b: %d Monate\n"  // 📣 Diff a->b: -5 Monate
        "Diff b->a: %d Monate\n", // 📣 Diff b->a: 5 Monate
        a.year, a.month, b.year, b.month,
        a.diff_months(&a, &b), b.diff_months(&b, &a)
    );
}

Die eigentlichen Funktionsdefinitionen, YearMonth_add_months_default() und YearMonth_diff_months_default() liegen im gleichen Modul. Sie arbeiten direkt auf dem übergebenen YearMonth *self und kapseln die Logik zum Verschieben und Vergleichen von Monaten. Diese Funktionen sind die ›Standardmethoden‹, die wir neuen Objekten zuweisen.

Die Zuweisung passiert in der Initialisierungsfunktion YearMonth_init(). Sie setzt zunächst die Daten-Mitglieder (year, month) und weist anschließend die Funktionszeiger-Mitglieder auf die gewünschten Implementierungen:

ym->add_months  = YearMonth_add_months_default;
ym->diff_months = YearMonth_diff_months_default;

Zur Laufzeit bestimmt sich das Verhalten pro Instanz. Das erlaubt Polymorphie auf Instanzebene: Zwei YearMonth-Objekte können denselben Zustandsaufbau haben, sich aber in ihren Methoden unterscheiden, wenn bei der Initialisierung andere Funktionszeiger gesetzt werden (z. B. Varianten mit Logging, Begrenzungen oder anderem Kalenderverhalten).

+---------------+
| YearMonth a   |
| year: 2025    |
| month: 6      |
| *add_months   |---------------------------------------------------->+-----------------------------------------+
| *diff_months  |----->+-----------------------------------------+    | Funktion  YearMonth_add_months_default  |
+---------------+      |Funktion  YearMonth_diff_months_default  |    +-----------------------------------------+
                       +-----------------------------------------+

Beim Aufruf unterscheidet sich die Benutzung kaum von einer ›echten‹ Methode. Im Beispiel steht:

a.add_months(&a, 2);
b.add_months(&b, -3);

Formal ist das ein Zugriff auf das Strukturmitglied add_months (einen Funktionszeiger) und ein anschließender Funktionsaufruf. Die Schreibweise erinnert aber an einen Methodenaufruf und signalisiert, dass die Operation zu diesem Objekt gehört. Da der Funktionszeiger Teil der Instanz ist, kann jedes Objekt auf andere Implementierungen zeigen.

Beim Kopieren dupliziert b = a die Struktur, inklusive Zeiger. Danach zeigen beide Instanzen auf dieselben Implementierungen. Das ist gewünscht und oft praktisch, weil so ›Verhalten plus Zustand‹ gemeinsam dupliziert wird. Es bedeutet aber auch: Wer die Funktionszeiger einer Instanz später ändert, beeinflusst nur diese Instanz, nicht die bereits kopierten Objekte.

Hinweis: Funktionszeiger sind so gebaut, dass sie wie Methoden wirken. Der erste Parameter ist das aktuelle Objekt (self). Das erinnert an objektorientierte Sprachen, nur dass C den Objektverweis nicht versteckt.

Wer Python kennt, sieht das sofort: Methoden definieren self als ersten Parameter, beim Aufruf wird es automatisch übergeben. Ein Beispiel:

class YearMonth:
    def __init__(self, year: int, month: int):
        self.year = year
        self.month = month

    def add_months(self, n: int):
        m = self.year * 12 + (self.month - 1) + n
        self.year = m // 12
        self.month = m % 12 + 1

# Beispiel
a = YearMonth(2025, 6)
a.add_months(2)         # 2025-08
print(a.year, a.month)  # 2025 8

Python ist nicht die einzige Sprache, die den Objektverweis sichtbar macht. Rust macht es ähnlich: Methoden haben als ersten Parameter self, &self oder &mut self; obj.method(x) entspricht Type::method(obj, x). Go hat denselben Grundgedanken. Methoden verwenden einen Receiver, der vor dem Funktionsnamen steht, statt in der Parameterliste:

func (ym *YearMonth) add_months(n int) {

Für Nutzer von Java, C++ oder C# wirkt das ungewohnt, weil dort this immer implizit ist.

Damit ist das Grundprinzip klar: Methoden entstehen durch Funktionszeiger in der Instanz. Das ist flexibel, erzeugt aber festen Aufwand. Jede Instanz enthält zwei zusätzliche Zeiger, auf 64-Bit-Systemen also rund 16 Byte mehr Bedarf. Außerdem muss YearMonth_init() immer vor der Nutzung aufgerufen werden. Ohne diese Initialisierung bleiben die Funktionszeiger undefiniert und führen beim Aufruf zu undefiniertem Verhalten. In der Praxis hält man deshalb eine klare Initialisierungsfunktion ein und vermeidet direkte Strukturzuweisungen per { ... }.

Damit ist dies ein einfacher und direkter Weg zu dynamischem Verhalten in C, der bereits viele Elemente objektorientierter Programmierung auf Instanzebene abbildet, ohne ein separates Methoden- oder VTable-Objekt einzuführen.

Methoden mit VTable

Bisher standen die Funktionszeiger direkt in der Struktur. Jede Instanz hielt also mehrere Methodenzeiger selbst vor. Sobald mehr Methoden dazukommen, wächst die Struktur und jede Instanz dupliziert dieselben Zeiger. Das kostet Platz, ohne dass es einen Mehrwert bringt.

Eine Lösung ist, die Methoden auszulagern und in einer gemeinsamen Tabelle zu bündeln. Diese Tabelle nennt man VTable. YearMonth verweist dann nur noch per Zeiger auf diese Tabelle. Die Struktur enthält damit nur die Daten und einen einzelnen Verweis auf die Methodenmenge.

Ohne VTable benötigt jede Instanz Speicher für zwei Integers plus alle Funktionszeiger. Mit VTable bleibt es bei zwei Integers plus einem einzelnen Zeiger, unabhängig von der Anzahl der Methoden.

Der Aufbau sieht dann so aus:

typedef struct YearMonth YearMonth;
typedef struct YearMonthVTable YearMonthVTable;

struct YearMonthVTable {
    void (*add_months)(YearMonth *self, int n);
    int  (*diff_months)(const YearMonth *self, const YearMonth *other);
};

struct YearMonth {
    const YearMonthVTable *vtable;
    int year;
    int month;
};

Jede Instanz enthält einen vtable-Zeiger zur VTable. Ein Methodenaufruf folgt dem Muster: +instanz->vtable->methode(instanz, args)+. So lässt sich das Verhalten pro Instanz oder für Gruppen von Instanzen zentral steuern. Wenn mehrere Instanzen dasselbe Verhalten nutzen sollen, teilen sie sich eine gemeinsame Tabelle.

Hinweis: Der Zeiger auf die VTable steht am Anfang der Struktur. Das ist nicht zwingend, aber zweckmäßig. Ändert man später die Reihenfolge der Mitglieder, verschiebt sich sonst der Offset des Zeigers und bestehende Binärdaten passen nicht mehr. Die Methoden bleiben zwar kompatibel, das Layout aber nicht. Neue Mitglieder kann man unten anhängen, ohne etwas zu brechen, solange der VTable-Zeiger oben steht. In Vererbungsbeziehungen wird das noch wichtiger, weil abgeleitete Strukturen oft voraussetzen, dass der gemeinsame Header an fester Stelle beginnt.

Die Initialisierung setzt die Daten und ordnet eine VTable zu. Ohne Angabe nutzt das Objekt eine Standardtabelle:

#include <stdio.h>
#include <stddef.h>

typedef struct YearMonth YearMonth;
typedef struct YearMonthVTable YearMonthVTable;

struct YearMonthVTable {
    void (*add_months)(YearMonth *self, int n);
    int  (*diff_months)(const YearMonth *self, const YearMonth *other);
};

struct YearMonth {
    int year;
    int month;
    const YearMonthVTable *vtable;
};

// Default-Implementierungen
static void YearMonth_add_months_default(YearMonth *self, int n) {
    int total = self->year * 12 + (self->month - 1) + n;
    self->year  = total / 12;
    self->month = total % 12 + 1;
}

static int YearMonth_diff_months_default(const YearMonth *a,
                                         const YearMonth *b) {
    return (b->year - a->year) * 12 + (b->month - a->month);
}

// Initialisierung mit optionaler VTable-Übergabe
void YearMonth_init(YearMonth *ym, int y, int m,
                    const YearMonthVTable *vtable) {
    if (ym == nullptr) return;

    static const YearMonthVTable DEFAULT_VTABLE = {
        .add_months  = YearMonth_add_months_default,
        .diff_months = YearMonth_diff_months_default
    };

    ym->year   = y;
    ym->month  = m;
    ym->vtable = (vtable != nullptr) ? vtable : &DEFAULT_VTABLE;
}

int main() {
    YearMonth a;
    YearMonth b;
    YearMonth c;

    // Alle drei Instanzen nutzen dieselbe DEFAULT_VTABLE
    YearMonth_init(&a, 2025, 6, nullptr);
    YearMonth_init(&b, 2025, 6, nullptr);
    YearMonth_init(&c, 2024, 12, nullptr);

    a.vtable->add_months(&a, 2);  // a -> 2025-08

    int diff = a.vtable->diff_months(&a, &b);

    printf("%04d-%02d\n", a.year, a.month);  // 📣 2025-08
    printf("diff=%d\n", diff);               // 📣 diff=-2

    // c teilt die VTable mit a und b
    printf("%04d-%02d\n", c.year, c.month);  // 📣 2024-12
}

Visualisiert sieht die Initialisierung so aus:

+--------------+
| YearMonth a  |
| year: 2025   |
| month: 6     |
| vtable       +---->+----------------------------+
+--------------+     | YearMonthVTable (DEFAULT)  |
                     | *add_months                |----------------------------------------------------->+-----------------------------------------+
                     | *diff_months               |---->+-------------------------------------------+    | Funktion  YearMonth_add_months_default  |
                     +----------------------------+     | Funktion  YearMonth_diff_ months_default  |    +-----------------------------------------+
                                                        +-------------------------------------------+

Der wichtige Aufruf passiert über:

ym->vtable->add_months(ym, 2);

Diese Auslagerung hat zwei Vorteile.

  1. Der Code kann komplette Verhaltenspakete austauschen. Das ist nützlich für Tests, Mock-Varianten oder optionale Funktionen.
  2. Die Datenstruktur bleibt stabil. Auch wenn neue Methoden dazukommen, ändert sich YearMonth nicht. Nur die VTable wird erweitert. Die Struktur behält ihre Mitglieder für Jahr und Monat und bleibt binärkompatibel.

Damit ergibt sich eine klare Trennung: Die Struktur hält nur die Daten und den Zeiger auf die Methodenmenge, und die VTable beschreibt das Verhalten vollständig. Das macht den Ansatz flexibel, ohne das Objekt aufzublähen.

Nullobjekt für Standardverhalten

Mit einer VTable lässt sich das gesamte Verhalten eines Objekts austauschen. Das Null Object Pattern zeigt das gut. Ein Nullobjekt führt die Methoden aus, verändert aber keinen Zustand. Ganz stimmt ›es passiert nichts‹ aber nicht: Die Methoden loggen Aufrufe, damit erkennbar bleibt, dass die Operationen ausgeführt wurden, nur ohne Wirkung.

// Null-Implementierungen: loggen, ändern nichts
static void YearMonth_add_months_null(YearMonth *self, int n) {
    printf("[null] add_months(%d) aufgerufen\n", n);
}

static int YearMonth_diff_months_null(const YearMonth *a,
                                      const YearMonth *b) {
    puts("[null] diff_months aufgerufen");
    return 0;  // Dummy-Wert
}

Die main-Funktion zeigt neu das Nullobjekt (c), das nur loggt:

int main() {
    // Nullobjekt: alle Methoden entkernt
    // Compound-Literal erzeugt temporäre VTable
    YearMonth c;
    YearMonth_init(&c, 2025, 6, &(YearMonthVTable){
        .add_months  = YearMonth_add_months_null,
        .diff_months = YearMonth_diff_months_null,
    });

    printf("c: %04d-%02d\n", c.year, c.month); // 📣 c: 2025-06
    // Nullobjekt: keine Änderung, nur Log
    c.vtable->add_months(&c, 5);
    // 📣 [null] add_months(5) aufgerufen
    printf("c: %04d-%02d\n", c.year, c.month); // 📣 c: 2025-06
}

Die VTable wird für c per Compound-Literal angegeben. Das erzeugt eine komplette Methodentabelle, ohne eine eigene Variable dafür anlegen zu müssen. Aber Vorsicht: Das Compound Literal hat hier automatische Speicherdauer und existiert nur bis zum Ende des umgebenden Blocks. Wenn c länger leben soll als dieser Block, gibt es ein Dangling-Pointer-Problem.

Ein Nullobjekt erspart dem Aufrufer jegliche nullptr-Prüfungen, weil immer eine gültige VTable existiert. Für optionale Komponenten oder abgeschaltete Features reicht es, die VTable durch eine harmlose Variante zu ersetzen. Auch in Tests ist das nützlich: Ein Test-Dummy kann alle Aufrufe annehmen, deren Effekte aber unterdrücken. Die Nutzung des Compound-Literals erleichtert das Erstellen solcher Varianten, weil sich damit eine vollständige VTable inline anlegen lässt.

Die VTable-Technik ermöglicht ›echten‹ Polymorphismus in C: Verschiedene Instanzen können unterschiedliches Verhalten zeigen, ohne dass der aufrufende Code davon wissen muss. Das ist die Grundlage für objektorientierte Muster in C und wird in vielen System-Bibliotheken eingesetzt.

Ein einfacher abgeleiteter Typ mit VTable

In diesem Abschnitt führen wir zwei Konzepte zusammen, die wir bisher einzeln kennengelernt haben:

  1. Vererbungsbeziehung durch Einbettung einer Basisstruktur als erstes Mitglied
  2. Virtuelle Methoden durch Extraktion von Funktionszeigern in eine VTable

Damit kehren wir zu unserem Beispiel mit YearMonth und BoundedYearMonth zurück. Um die Komplexität überschaubar zu halten, konzentrieren wir uns ausschließlich auf die Funktion add_months und lassen die Differenzberechnung weg. Das spart Code und ändert nichts am grundlegenden Prinzip.

Basistyp YearMonth

Der Basistyp YearMonth speichert Jahr und Monat sowie einen Zeiger auf eine VTable. Die VTable enthält Funktionszeiger für virtuelle Methoden, hier nur für add_months:

#include <stdio.h>

typedef struct YearMonth YearMonth;

// VTable
typedef struct {
    void (*add_months)(YearMonth *self, int n);
} YearMonthVTable;

// Basistyp
struct YearMonth {
    const YearMonthVTable *vt;
    int year;
    int month;
};

// Normalisierung
static void normalize(YearMonth *ym) {
    int total = ym->year * 12 + (ym->month - 1);
    ym->year  = total / 12;
    ym->month = total % 12 + 1;
}

// Standard-Implementierung
static void add_months_default(YearMonth *self, int n) {
    self->month += n;
    normalize(self);
}

// VTable des Basistyps
static const YearMonthVTable YEAR_MONTH_VT = {
    .add_months = add_months_default
};

// Initialisierung
void YearMonth_init(YearMonth *ym, int year, int month) {
    ym->vt = &YEAR_MONTH_VT;
    ym->year = year;
    ym->month = month;
    normalize(ym);
}

// Öffentlicher Aufruf
static inline void YearMonth_add_months(YearMonth *self, int n) {
    self->vt->add_months(self, n);
}

Abgeleiteter Typ BoundedYearMonth mit eigener VTable

Der abgeleitete Typ BoundedYearMonth erweitert YearMonth um minimale und maximale Jahresgrenzen. Wir simulieren Vererbung, indem wir YearMonth als erstes Mitglied einbetten. Dadurch liegt der Basisteil am Anfang des Speichers, und ein Zeiger auf BoundedYearMonth kann sicher als YearMonth * behandelt werden (wegen gleichem Layout am Anfang; Alignment und Padding in C sorgen dafür, dass der Cast sicher ist, solange keine zusätzlichen Mitglieder davorstehen).

Bevor wir zum Code kommen, wollen wir uns genauer anschauen, was wir mit ›virtuellen Methoden‹ meinen. In OOP-Sprachen wie C++ oder Java können abgeleitete Klassen Methoden der Basisklasse entweder übernehmen (erben) oder überschreiben. In unserer C-Simulation mit VTables entspricht das Folgendem:

  • Methode übernehmen (erben): Der abgeleitete Typ verwendet einfach die VTable des Basistyps. Das bedeutet, der Funktionszeiger in der VTable zeigt weiterhin auf die Standard-Implementierung (add_months_default). Es gibt keine eigene VTable für den abgeleiteten Typ; wir setzen nur bym\->base.vt = &YEAR_MONTH_VT;. Vorteil: Einfach, kein zusätzlicher Code. Nachteil: Keine Anpassung möglich; die Methode verhält sich genau wie im Basistyp, ohne Berücksichtigung der neuen Mitglieder (z. B. Grenzen). In unserem Fall würde add_months die Grenzen ignorieren, was nicht gewünscht ist.

  • Methode überschreiben: Wir erstellen eine eigene VTable für den abgeleiteten Typ, in der der Funktionszeiger auf eine neue Implementierung zeigt. Diese neue Implementierung kann die Basislogik wiederverwenden (indem sie die Standardfunktion direkt aufruft), aber ergänzt sie um spezifische Logik (hier: Clamping der Jahre). Das ermöglicht Polymorphie: Wenn ein YearMonth *-Zeiger auf einen BoundedYearMonth zeigt, wird die überschriebene Version aufgerufen. Vorteil: Flexibilität und Erweiterung. Nachteil: Mehr Code, und man muss vorsichtig sein, um die Basislogik nicht zu duplizieren (deshalb Wiederverwendung).

In diesem Beispiel wählen wir den zweiten Ansatz (Überschreiben), weil wir die Methode anpassen wollen, um die Jahresgrenzen zu berücksichtigen. Wenn wir den ersten Ansatz wählen würden, gäbe es keine Begrenzung, und der abgeleitete Typ wäre nutzlos. Nun zerlegen wir den Code schrittweise.

Schritt für Schritt, was wir machen:

  1. Definieren der BoundedYearMonth-Struktur mit eingebettetem YearMonth als erstem Mitglied.
  2. Implementieren einer Hilfsfunktion clamp_int() zum Begrenzen von Werten.
  3. "Überschreiben" der add_months()-Methode:
    • Erhalten einen YearMonth * (da virtuelle Aufrufe immer über den Basistyp gehen).
    • Casten zu BoundedYearMonth * (sicher, weil base bei uns als erstes Mitglied steht; der Zeiger zeigt auf denselben Speicherort).
    • Wiederverwenden der Basisimplementierung: Wir rufen add_months_default direkt auf (nicht virtuell über die VTable, um eine Rekursion zu vermeiden – das wäre eine Endlosschleife, wenn wir YearMonth_add_months() aufrufen würden).
    • Anwenden der neuen Logik: Clamping des Jahres.
  4. Erstellen einer eigenen statischen VTable, die auf die überschriebene Methode zeigt.
  5. Schreiben einer Initialisierungsfunktion: Setzen der eigenen Mitglieder, Setzen der abgeleiteten VTable im Basisteil, Setzen der Werte, Normalisieren und Anwenden der Grenzen.

Hier der vollständige Ergänzungscode (baut auf dem Basistyp auf):

typedef struct {
    YearMonth base;  // Basistyp als erstes Mitglied eingebettet (für Vererbung)
    int min_year;
    int max_year;
} BoundedYearMonth;

// Hilfsfunktion: Begrenzt einen Integer-Wert zwischen lo und hi
static int clamp_int(int x, int lo, int hi) {
    return x < lo ? lo : (x > hi ? hi : x);
}

// Überschriebene Implementierung von add_months
static void bounded_add_months_impl(YearMonth *self, int n) {
    // Schritt 1: Cast zu abgeleitetem Typ – sicher, da Layout passt.
    // Ohne diesen Cast könnten wir nicht auf min_year/max_year zugreifen.
    BoundedYearMonth *bym = (BoundedYearMonth *)self;

    // Schritt 2: Basisimplementierung wiederverwenden.
    // Wir rufen die static-Funktion direkt auf (nicht virtuell),
    // um Rekursion zu vermeiden. Das addiert die Monate und normalisiert,
    // als ob es ein normaler YearMonth wäre.
    add_months_default(&bym->base, n);

    // Schritt 3: Eigene Erweiterung – Anwenden der Grenzen.
    // Nur das Jahr wird geclamped; Monat bleibt unverändert.
    bym->base.year = clamp_int(bym->base.year, bym->min_year, bym->max_year);
}

// Statische VTable-Instanz für den abgeleiteten Typ
static const YearMonthVTable BOUNDED_YEAR_MONTH_VT = {
    .add_months = bounded_add_months_impl
};

// Initialisierungsfunktion: Setzt alles in logischer Reihenfolge
void BoundedYearMonth_init(BoundedYearMonth *bym, int year, int month,
                           int min_year, int max_year) {
    // 1. Eigene Mitglieder setzen (Grenzen)
    bym->min_year = min_year;
    bym->max_year = max_year;

    // 2. VTable des abgeleiteten Typs im Basisteil setzen (für Polymorphie)
    bym->base.vt = &BOUNDED_YEAR_MONTH_VT;

    // 3. Jahr und Monat setzen und normalisieren
    bym->base.year = year;
    bym->base.month = month;
    normalize(&bym->base);

    // 4. Startwert in Grenzen bringen (nach Normalisierung)
    bym->base.year = clamp_int(bym->base.year, min_year, max_year);
}

Die Reihenfolge in der Initialisierung ist entscheidend: Wir setzen die Grenzen zuerst, damit sie bei der Normalisierung und Begrenzung verfügbar sind. Die VTable muss vor virtuellen Aufrufen gesetzt sein, auch wenn hier Normalisierung nicht virtuell ist.

Polymorpher Aufruf in Aktion

Ein Zeiger auf bym.base kann überall als YearMonth * verwendet werden, da das Layout passt. Das ermöglicht Polymorphie: Der gleiche Code ruft je nach VTable unterschiedliche Implementierungen auf.

Hier ein Beispielprogramm, das beide Typen in einem Array speichert und polymorph aufruft:

static void print_ym(const char *label, const YearMonth *ym) {
    printf("%s: %04d-%02d\n", label, ym->year, ym->month);
}

int main() {
    YearMonth plain;
    YearMonth_init(&plain, 2025, 6);

    BoundedYearMonth bounded;
    BoundedYearMonth_init(&bounded, 2035, 1, 2020, 2030);

    // Array von Basiszeigern: Mischt Basistyp und abgeleiteten Typ
    YearMonth *arr[] = {
        &plain,          // Normale Instanz
        &bounded.base    // Basisteil der abgeleiteten Instanz
    };

    print_ym("plain start ", arr[0]);    // 📣 plain start : 2025-06
    print_ym("bounded start", arr[1]);   // 📣 bounded start: 2030-01

    // Polymorphe Aufrufe:
    // Gleicher Wrapper, aber unterschiedliche Implementierungen
    YearMonth_add_months(arr[0], 24);
    YearMonth_add_months(arr[1], 24);

    print_ym("plain +24 ", arr[0]);      // 📣 plain +24 : 2027-06
    print_ym("bounded +24", arr[1]);     // 📣 bounded +24: 2030-01
}

Erklärung des Verhaltens:

  • arr[0] (plain) verwendet die Standard-VTable: Addiert 24 Monate zu 2025-06 → 2027-06.

  • arr[1] (bounded.base) verwendet die abgeleitete VTable: Addiert 24 Monate zum initial geclampten 2030-01 → würde 2032-01 ergeben, aber geclampt auf 2030-01 (da max_year=2030).

Das zeigt Polymorphie: Der gleiche Aufruf YearMonth_add_months führt je nach VTable zu unterschiedlichem Verhalten.

Typtests

In einem polymorphen Kontext (etwa einem Array von YearMonth *) ist nur bekannt, dass ein Element mindestens ein YearMonth ist. Um festzustellen, ob es tatsächlich z. B.ein BoundedYearMonth ist, braucht es eine Laufzeitinformation über den konkreten Typ. Übliche objektorientierte Programmiersprachen stellen dafür eigene Sprachmittel zur Verfügung, doch da C keine eingebaute Unterstützung bietet, müssen wir das nachbilden.

Zwei einfache Ansätze.

Ansatz 1: Vergleich des VTable-Zeigers

Jeder konkrete Typ besitzt eine eigene VTable. Ein Objekt verweist auf die VTable seines dynamischen Typs. Ein Vergleich dieser Zeiger reicht aus, wenn nur der exakte Typ geprüft werden soll.

#define IS_INSTANCE_OF(obj, vt_ptr) ((obj)->vt == (vt_ptr))

static inline int is_BoundedYearMonth(const YearMonth *ym) {
    return IS_INSTANCE_OF(ym, &BOUNDED_YEAR_MONTH_VT);
}

Dieser Ansatz ist einfach und kostet wenig. Er eignet sich, solange deine Hierarchie flach bleibt und du nur auf einen bestimmten Typ prüfst.

Ansatz 2: Typ-ID im Basistyp

Bei tieferen Hierarchien reicht der Vergleich der VTable nicht aus, weil damit nur der exakte Typ erkennbar ist. In diesem Fall kann im Basistyp ein zusätzliches Typ-ID-Mitglied (z. B. enum oder int) ergänzt werden. Jeder abgeleitete Typ erhält eine eigene Kennung. Der Vergleich der Kennung ermöglicht dann auch Prüfungen auf Untertypen.

Dieser Ansatz verursacht etwas mehr Aufwand in der Initialisierung, ist aber flexibler für längere Ableitungsketten.

Aufgabe

  1. Füge der Basisstruktur wieder die zweite Funktion hinzu und vervollständige die Funktion, wo es nötig ist.
struct YearMonthVTable {
    void (*add_months)(YearMonth *self, int n);
    int  (*diff_months)(const YearMonth *self, const YearMonth *other);
};
  1. Integriere diese Struktur zusätzlich in unsere Verarbeitungsbeziehung und programmiere ein Demo.
// ---------------------------------------------------------------------------
// CyclicYearMonth
// ---------------------------------------------------------------------------

typedef struct {
    YearMonth base;
    int min_year;
    int max_year;
} CyclicYearMonth;

static void CyclicYearMonth_add_months_impl(YearMonth *self, int n) {
    CyclicYearMonth *cym = (CyclicYearMonth *)self;

    // normale Verschiebung
    YearMonth_add_months_default(&cym->base, n);

    int span_years   = cym->max_year - cym->min_year + 1;
    int span_months  = span_years * 12;

    // Offset relativ zu min_year
    int offset = (cym->base.year  - cym->min_year) * 12
               + (cym->base.month - 1);

    offset %= span_months;
    if (offset < 0) offset += span_months;

    cym->base.year  = cym->min_year + offset / 12;
    cym->base.month = (offset % 12) + 1;
}

static const YearMonthVTable CYCLIC_YEAR_MONTH_VT = {
    CyclicYearMonth_add_months_impl,
    YearMonth_diff_months_default
};

void CyclicYearMonth_init(CyclicYearMonth *cym,
                          int year, int month,
                          int min_year, int max_year) {
    cym->min_year   = min_year;
    cym->max_year   = max_year;
    cym->base.vt    = &CYCLIC_YEAR_MONTH_VT;
    cym->base.year  = year;
    cym->base.month = month;
    YearMonth_normalize(&cym->base);

    // Startwert in Bereich bringen
    if (cym->base.year < cym->min_year) cym->base.year = cym->min_year;
    if (cym->base.year > cym->max_year) cym->base.year = cym->max_year;
}

Opaque Pointer für feste Schnittstellen

Die bisher gezeigten Ansätze haben einen gemeinsamen Nachteil: Der aufrufende Code sieht die interne Struktur. Sobald ein Mitglied hinzugefügt oder die Reihenfolge geändert wird, muss alles neu kompiliert werden. Opaque Pointer lösen dieses Problem durch radikale Kapselung.

Das Prinzip ist einfach: Der Header zeigt nur einen unvollständigen Typ (etwa typedef struct YearMonth YearMonth;), während die Implementierung in der .c-Datei die vollständige Struktur kennt. Der aufrufende Code sieht keine Interna. Dadurch steigen Quell- und Binärkompatibilität. YearMonth kann außerhalb des Moduls nur als Zeiger existieren; sizeof(YearMonth) und Stack-Objekte sind nicht möglich. (Heap-Allokation ist die gängige Variante, aber nicht zwingend. Alternativ kann die API die benötigte Größe über eine Funktion bereitstellen, damit der Aufrufer eigenen Speicher bereitstellt, etwa über size_t YearMonth_size() { return sizeof(YearMonth); }.

Header: Nur unvollständiger Typ und API

Der Header deklariert den Typ, definiert ihn aber nicht. Das macht YearMonth zu einem incomplete type. Der Compiler kennt nur die Existenz, nicht die Größe oder die Mitglieder.

// year_month.h
#ifndef YEAR_MONTH_H
#define YEAR_MONTH_H

typedef struct YearMonth YearMonth;

// Lebenszyklus
YearMonth *YearMonth_create(int year, int month);
void       YearMonth_destroy(YearMonth *ym);

// Operationen
void YearMonth_add_months(YearMonth *ym, int n);
int  YearMonth_diff_months(const YearMonth *a, const YearMonth *b);

// Getter (da kein direkter Mitgliederzugriff möglich)
int YearMonth_get_year(const YearMonth *ym);
int YearMonth_get_month(const YearMonth *ym);

#endif

Der aufrufende Code kennt nur YearMonth* und die Funktionen. Kein Zugriff auf Mitglieder ist möglich, nicht einmal sizeof(YearMonth) funktioniert außerhalb der Implementierungsdatei.

Implementierung: Struktur bleibt intern

Die vollständige Strukturdefinition existiert ausschließlich in der .c-Datei. Hier lässt sich die interne Repräsentation beliebig ändern, ohne den Header oder den aufrufenden Code anzufassen.

// year_month.c */
#include "year_month.h"
#include <stdlib.h>

// Vollständige Definition: nur hier sichtbar
struct YearMonth {
    int year;
    int month; // 1–12
};

static void YearMonth_normalize(YearMonth *ym) {
    if (!ym) return;
    int total = ym->year * 12 + (ym->month - 1);
    ym->year  = total / 12;
    ym->month = total % 12 + 1;
}

YearMonth *YearMonth_create(int year, int month) {
    YearMonth *ym = malloc(sizeof(*ym));
    if (!ym) return nullptr;

    ym->year  = year;
    ym->month = month;
    YearMonth_normalize(ym);
    return ym;
}

void YearMonth_destroy(YearMonth *ym) {
    free(ym);
}

void YearMonth_add_months(YearMonth *ym, int n) {
    if (!ym) return;
    ym->month += n;
    YearMonth_normalize(ym);
}

int YearMonth_diff_months(const YearMonth *a, const YearMonth *b) {
    if (!a || !b) return 0;
    return (b->year  - a->year) * 12
         + (b->month - a->month);
}

int YearMonth_get_year(const YearMonth *ym) {
    return ym ? ym->year : 0;
}

int YearMonth_get_month(const YearMonth *ym) {
    return ym ? ym->month : 0;
}

Alle Funktionen prüfen auf nullptr, da Heap-Allokation fehlschlagen kann. Das ist defensives Programmieren für öffentliche APIs und verhindert undefiniertes Verhalten bei Speicherproblemen.

Nutzung: nur über Funktionen

Der aufrufende Code arbeitet ausschließlich mit Zeigern und der öffentlichen API. Es ist kein direkter Zugriff auf Mitglieder möglich und das erzwingt saubere Abstraktion.

// demo_year_month.c
#include <stdio.h>
#include "year_month.h"

int main() {
    YearMonth *a = YearMonth_create(2025, 6);
    YearMonth *b = YearMonth_create(2025, 6);

    if (!a || !b) {
        YearMonth_destroy(a);
        YearMonth_destroy(b);
        return 1;
    }

    YearMonth_add_months(b, 2);

    printf("a: %04d-%02d\n",
           YearMonth_get_year(a),
           YearMonth_get_month(a));
    printf("b: %04d-%02d\n",
           YearMonth_get_year(b),
           YearMonth_get_month(b));

    int diff = YearMonth_diff_months(a, b);
    printf("Diff: %d months\n", diff);

    YearMonth_destroy(a);
    YearMonth_destroy(b);
}

Die Ownership-Regel ist klar: Wer create aufruft, muss destroy aufrufen. Kein automatisches Cleanup wie bei Stack-Objekten.

Falls Kopien nötig sind, benötigt die API eine Copy-Funktion, weil der Aufrufer keine Struktur kopieren kann.

Fazit: Objektorientierte Konzepte im Kontext der Programmiersprache C

Auch wenn C selbst keine objektorientierte Sprache ist, stellt sich die Frage, ob und warum sich Personen, die mit C arbeiten, dennoch mit objektorientierten Prinzipien beschäftigen sollten. Objektorientierung (OO) ist in erster Linie eine Design- und Denkweise, nicht ausschließlich an bestimmte Sprachfeatures gebunden. Im Folgenden werden sowohl die Vorteile betrachtet, die für das Erlernen und Anwenden von OO-Konzepten in C sprechen, als auch die Nachteile und Grenzen, die dagegen angeführt werden.

Vorteile: Warum auch C-Programmierer von OO-Konzepten profitieren

Bevor die einzelnen Punkte erläutert werden, lohnt ein kurzer Blick darauf, warum sich OO-Konzepte auch in einer Sprache ohne direkte Unterstützung sinnvoll einsetzen lassen.

  • Paradigmenübergreifendes Denken: Objektorientierung ist ein sprachunabhängiges Programmierparadigma. Die Konzepte wie Kapselung, Modularisierung, Abstraktion und lose Kopplung können prinzipiell in jeder Sprache angewendet werden. Das Wissen um OO-Prinzipien fördert allgemeine Software-Architekturkompetenzen. Man kann z. B. in C Programme in autonome Module aufteilen, die jeweils klar umrissene Aufgaben erfüllen und nur definierte Schnittstellen nach außen bieten.

  • Bessere Struktur durch (Pseudo‑)Kapselung: Auch wenn C keine Schlüsselwörter wie public/private kennt, lässt sich informationelle Abschirmung erreichen. Durch Aufteilung in Header- und Implementierungsdateien kann man Strukturdefinitionen undurchsichtig (opaque) gestalten: Die interne Struktur eines Datentyps wird nur in der .c-Datei definiert, während in der .h-Datei nur ein Forward-Declaration (Zeiger auf struct) steht. Dieses Opaque-Pointer-Idiom ermöglicht eine Kapselung mit mehreren Instanzen. Die Nutzer des Moduls sehen nur die Schnittstellenfunktionen (ähnlich Methoden) und können nicht direkt auf interne Daten zugreifen, zumindest nicht ohne bewusste Umgehung. Dadurch werden globale Daten vermieden und der Code wird wartbarer, da Änderungen an der internen Darstellung eines ›Objekts‹ nicht zwingend andere Programmteile betreffen.

  • Polymorphie und flexible Schnittstellen: Dynamische Polymorphie kann in C manuell umgesetzt werden. Typischerweise geschieht das durch Funktionszeiger innerhalb von Strukturen, was einem virtuellen Methodenzeiger-Mitglied (Vtable) ähnlich ist. Solche Techniken wurden schon lange vor der Einführung von C++ in großen C-Projekten genutzt. Ein Objekt in C kann als struct definiert werden, die neben den Datenelementen auch Funktionszeiger enthält, welche auf die ›Methoden‹ zeigen. Dieses Muster erlaubt es, verschiedene Implementierungen hinter einer einheitlichen Schnittstelle zu verbergen (Beispiel: verschiedene Datei- oder Netzwerk-Streams mit identischen Funktionszeigern open/read/write/close). Viele größere C-Codebasen setzen Polymorphie auf diese Art ein, etwa das Linux-Kernel-Device-Driver-Modell oder die GObject-Bibliothek von GTK, die ein komplettes objektorientiertes Typensystem in C definiert. Große C-Projekte wie der Linux-Kernel, BSD-Kernel oder SQLite haben intern eigene objektorientierte Mechanismen entwickelt.

  • Wiederverwendbarkeit und Wartbarkeit: OO-Design fördert Code-Wiederverwendung durch klar definierte modulare Komponenten und vereinfachte Wartung. Indem man wiederverwendbare Strukturen und zugehörige Funktionen als Modul (›Klasse‹) definiert, kann derselbe Code in verschiedenen Projekten oder Kontexten eingesetzt werden. Das Denken in Objekten hilft, größere Softwareprojekte übersichtlich zu strukturieren. Viele gängige Entwurfsmuster (Design Patterns) aus der objektorientierten Welt (etwa Strategy, Observer, Iterator usw.) lassen sich in C implementieren. Wer mit C arbeitet und solche Muster kennt, entwirft leichter Architekturen, die über einfache prozedurale Abläufe hinausgehen.

  • Grundlagen für andere Sprachen und Interoperabilität: Wer die OO-Prinzipien verstanden hat, kann leichter in echten objektorientierten Sprachen (wie C++ oder Java) arbeiten. Auch wenn man primär in C entwickelt, kommt man oft mit Schnittstellen oder Bibliotheken in Berührung, die objektorientiert entworfen sind. Ein klassisches Beispiel sind C-Bibliotheken im Windows- oder GNOME-Umfeld, wo Handles oder Strukturen plus zugehörige Funktionen faktisch Objekte darstellen. Kenntnisse in OO helfen, solche APIs zu verstehen und korrekt anzuwenden. Zudem verbessert es die Kommunikation im Team, wenn Konzepte wie Klasse, Objekt, Methode, Vererbung, Polymorphie etc. allen geläufig sind, unabhängig von der verwendeten Sprache.

  • Machbarkeit und neue Perspektiven: Das Arbeiten mit OO-Ideen in C macht sichtbar, wo die Sprache flexible Lösungen erlaubt und wo ihre Grenzen liegen. Dabei entsteht ein genauer Blick auf typische Muster und Speicheraufbau. Verschiedene Beispiele aus realen Codebasen zeigen, dass sich zentrale OO-Elemente wie Kapselung, Vererbung und Polymorphie auch in C umsetzen lassen. Dieser Ansatz vermittelt ein besseres Verständnis dafür, wie Compiler und Laufzeit höhere Abstraktionen abbilden. Die gewonnenen Einsichten erweitern den eigenen Werkzeugkasten und unterstützen fundierte Entscheidungen im Softwareentwurf.

Nachteile und Grenzen: Wann man OOP in C lieber sein lassen sollte

Im Gegensatz dazu gibt es Gründe, die gegen den Einsatz solcher Konzepte in C sprechen.

  • Kein Sprachsupport, also hoher Boilerplate-Aufwand: C bietet keine nativen Sprachkonstrukte für Klassen, Vererbung oder polymorphe Methoden. Alles muss von Hand gebaut werden, was den Code schnell aufblähen und unübersichtlich machen kann. Um z. B. eine einfache Vererbungshierarchie nachzustellen, sind manuelle Schritte nötig (z. B. einen Basistyp als erstes struct-Mitglied einbetten und Typ-Casts nutzen). Polymorphie erfordert Funktionszeiger und oft zusätzliche Strukturen für Tabellen. Dieser Mehraufwand führt zu viel Boilerplate-Code, den eine Sprache wie C++ automatisch abnimmt. Oft ist das Nachbilden von OO-Features in C sehr umständlich und fehleranfällig, wodurch der Aufwand in vielen Fällen nicht im Verhältnis zum Ergebnis steht.

  • Fehlende echte Kapselung: Anders als Java oder C++ kennt C keine Sprachmechanismen, um Daten wirklich privat zu halten. Alle Mitglieder in einer öffentlich deklarierten Struktur sind von außen les- und schreibbar. Zwar kann durch opaque Strukturen oder Konventionen (Präfixe/Unterstriche bei privaten Variablen) eine Art informelle Kapselung erreicht werden, jedoch ist diese nicht vom Compiler erzwungen. Zuverlässige Kapselung basiert in C auf Disziplin beim Schreiben des Codes, nicht auf Sprachregeln. Ein typischer Workaround ist, private Mitglieder im Namen zu kennzeichnen (etwa mit _) und in Kommentaren darauf hinzuweisen, dass sie nicht von außen genutzt werden sollen. Solche Absprachen bieten aber keinen echten Schutz vor Fehlgebrauch. In größeren Teams oder langfristigen Projekten kann die fehlende Kapselung dazu führen, dass doch jemand direkt auf interne Details zugreift, was Wartungsprobleme nach sich zieht. Die Information Hiding-Garantie, die OO-Sprachen geben, fehlt in C: Man kann sie nur simulieren, aber niemals 100 % durchsetzen.

  • Keine Sprachunterstützung für Polymorphie/Vererbung: In C fehlen Mechanismen wie instanceof, automatische Upcasts/Downcasts oder virtuelle Methoden. Vererbung läuft über Komposition. Eine Struktur enthält eine andere Struktur als Basis. Es gibt aber keine Prüfung, ob eine abgeleitete Struktur wirklich alle Elemente liefert, die eine Basis-API erwartet. Versucht man Polymorphie durch Funktionszeiger in Strukturen umzusetzen, bewegt man sich außerhalb der statischen Typprüfung des Compilers. Ein häufiger Ansatz ist, vtable-Strukturen zu definieren, welche Funktionszeiger für jede ›Methode‹ enthalten. Hier ist es möglich, Fehler erst zur Laufzeit zu bemerken, z. B. wenn ein Funktionszeiger versehentlich nullptr bleibt und dann aufgerufen wird (was zu einem Absturz führt), während in C++ eine nicht implementierte virtuelle Methode schon beim Linken/Übersetzen erkennbar wäre. Das Implementieren von Vererbungs- oder Polymorphie-Konzepten in C erfordert viel Sorgfalt und Erfahrung. Einige Konstrukte (wie Mehrfachvererbung, abstrakte Basisklassen mit reinen virtuellen Methoden) sind nur mit erheblichem Aufwand oder gar nicht sinnvoll simulierbar, wodurch das Risiko steigt, dass der Code fehlerhaft oder schwer verständlich ist.

  • Wartbarkeit und Lesbarkeit leiden: Wird C-Code allzu sehr mit pseudo-objektorientierten Idiomen durchsetzt, kann dies die Lesbarkeit deutlich beeinträchtigen. Entwickler, die mit dem gewählten Muster nicht vertraut sind, haben Mühe, zu erkennen, was der Code tut. Beispielsweise kann eine exzessive Nutzung von void-Pointern und Casts (um generische Objekthierarchien abzubilden) den Code unübersichtlich machen. Für viele, die in C tätig sind, und eher einen klar prozeduralen Stil gewohnt sind, wirkt ein nachgeahmtes Klassen-Framework in C befremdlich. Die Teamproduktivität kann leiden, wenn alle zunächst das ›selbstgebaute OO-System‹ einer Codebasis verstehen müssen. Man läuft Gefahr, mit C einen ineffizienten Abklatsch einer OO-Sprache zu bauen, der weder so elegant wie echtes C++ noch so einfach wie idiomatisches C ist. Oft erhöht dieser Ansatz die Komplexität unnötig und erschwert die Wartung des Codes.

  • Verfügbarkeit fertiger Alternativen: Ein weiteres Gegenargument lautet pragmatisch: Warum das Rad neu erfinden? Wenn das Projekt stark von objektorientierten Mustern profitieren würde, steht mit C++ eine Erweiterung von C bereit, die genau diese Sprachelemente bietet. C++ wurde gerade als Antwort darauf geschaffen, C um Klassen, Vererbung, Polymorphie etc. zu ergänzen. Für viele Szenarien -- insbesondere in der Anwendungsentwicklung -- ist es naheliegend, direkt C++ (oder Java, C# etc.) einzusetzen, anstatt in C umständlich OO nachzuahmen. C hat seine Stärken hauptsächlich in niedrigschwelliger Systemprogrammierung und eingebetteten Systemen. In Bereichen, wo OO-Design entscheidend ist (z. B. komplexe GUI-Anwendungen, umfangreiche Business-Logik, Simulationen mit vielen Objekten), ist eine OO-Sprache meist die bessere Wahl. Zudem gibt es zahlreiche Frameworks und Bibliotheken in C++, die man nutzen kann, anstatt in C alles selbst bauen zu müssen. Selbst auf Mikrocontrollern wird C++ zunehmend unterstützt, sodass viele der klassischen Gründe gegen C++ (Speicherbedarf, fehlende Compiler) an Gewicht verloren haben. Daher sprechen praktische Erwägungen oft gegen eine aufwendige OO-Eigenimplementierung in C, wenn ein Wechsel der Sprache möglich ist.

Wer nach all den C-Varianten wissen will, wie dieselben Ideen ohne Handarbeit aussehen, kann sich anschauen, was C++ daraus macht. Die Sprache liefert Klassen, Vererbung und virtuelle Methoden direkt mit. Es gibt keine selbst gebauten VTables mehr, keine Casts auf eingebettete Basistypen und keine manuell gepflegten Funktionszeiger. Die Laufzeit kümmert sich um die nötige Dynamik, und der Compiler sorgt dafür, dass Überschreibungen und Signaturen stimmen.

Das folgende Programm zeigt die gleiche Struktur wie im letzten C-Beispiel: einen Basistyp mit Monatsarithmetik und einen abgeleiteten Typ, der Jahresgrenzen erzwingt. Nur ist hier nichts mehr zu sehen von den Mechanismen, die wir in C mühsam nachgestellt haben:

#include <iostream>

struct YearMonth {
    int year;
    int month; // 1–12

    YearMonth(int y, int m) : year(y), month(m) {
        normalize();
    }

    void normalize() {
        int total = year * 12 + (month - 1);
        year  = total / 12;
        month = total % 12 + 1;
    }

    virtual void add_months(int n) {
        month += n;
        normalize();
    }

    virtual int diff_months(const YearMonth& other) const {
        return (other.year  - year) * 12 + (other.month - month);
    }
};

struct BoundedYearMonth : YearMonth {
    int min_year;
    int max_year;

    BoundedYearMonth(int y, int m, int min_y, int max_y)
        : YearMonth(y, m), min_year(min_y), max_year(max_y) {
        clamp_year();
    }

    void clamp_year() {
        if (year < min_year) year = min_year;
        if (year > max_year) year = max_year;
    }

    void add_months(int n) override {
        YearMonth::add_months(n); // Basislogik wiederverwenden
        clamp_year();             // dann Grenzen anwenden
    }
};

void print_ym(const char* label, const YearMonth& ym) {
    std::cout << label << ": " << ym.year << "-";
    if (ym.month < 10) std::cout << '0';
    std::cout << ym.month << '\n';
}

int main() {
    YearMonth        plain{2025, 6};
    BoundedYearMonth bym{2029, 11, 2020, 2030};

    YearMonth* arr[] = {
        &plain, // Basisobjekt
        &bym    // abgeleitetes Objekt (Upcast über Zeiger)
    };

    print_ym("plain  start", *arr[0]);
    print_ym("bounded start", *arr[1]);

    arr[0]->add_months(24); // normal
    arr[1]->add_months(24); // mit clamp

    std::cout << "\n";
    print_ym("plain  +24", *arr[0]);
    print_ym("bounded +24", *arr[1]);

    std::cout << "\nDiff plain -> bounded: "
              << arr[0]->diff_months(*arr[1]) << " Monate\n";
}

Rückblick und Ausblick

OO-Prinzipien lassen sich in C nutzen, aber nur in einem begrenzten Rahmen. Einige Konzepte wie Modularisierung, klare Schnittstellen und einfache Formen von Polymorphie unterstützen die Struktur größerer Systeme. Gleichzeitig zeigt der Einsatz schnell die Grenzen der Sprache: Ohne eingebaute Mechanismen steigt der Aufwand, und Fehler lassen sich schwerer verhindern.

In diesem Text enden die Beispiele bewusst an einem Punkt, an dem VTables, einfache Vererbung per Einbettung und polymorphe Aufrufe gezeigt sind. Themen wie virtuelle Destruktoren in hierarchischen Typbäumen, explizite Kopiersemantik (zum Beispiel ein clone in der VTable), abstrakte Basistypen mit ›Pflichtmethoden‹ oder sichere Downcasts mit Laufzeitprüfung bleiben ausgeklammert. Sie wären eine direkte Fortsetzung der gezeigten Muster, würden den Code aber deutlich ausweiten und führen konzeptionell bereits in den Bereich, den C++ mit Klassen, virtuellen Methoden und dynamic_cast direkt anbietet.

Für überschaubare oder hardwarenahe Projekte bleibt ein prozeduraler Stil in C meist zweckmäßiger. In wachsenden Systemen kann der gezielte Einsatz einzelner OO-Ideen helfen, Ordnung zu halten, ohne ein vollständiges Objektmodell nachzubauen. Sobald der Aufwand für eigene Konstrukte steigt oder das Design komplexer wird, ist der Wechsel zu einer Sprache mit direkter OO-Unterstützung oft der pragmatischere Schritt.

OO-Konzepte zu kennen ist für Personen, die mit C arbeiten, dennoch sinnvoll. Sie erleichtern das Verständnis grundlegender Entwurfsprinzipien und unterstützen dabei, Software strukturiert aufzubauen, sowohl in C selbst als auch in Sprachen, die Objektorientierung direkt bereitstellen.