C_Runtime_Javalike: : Grundsätzliches zur Objektorientierung in C


 

 

C ist zwar keine objektorientierte Sprache, aber man kann in C (wie auch beispielsweise in Assembler) objektorientiert programmieren.

Was ist unter Objektorientierung zu verstehen

Folgende Ausführungen beziehen sich auf die objektorientierte Merkmale der Programmiersprache. Die Methoden der Objektorientierung als Softwaretechnologie werden hierbei nicht erfasst. Aus deren Sicht ist Objektorienrierung weit umfangreicher zu interpretieren.

(=>Wikipedia), bezogen auf Anwendungen in C:

Bindung von Subroutinen (= Methoden) an Daten

Das Wesentlichste der Objektorientierung ist zuerst die Bindung von Subroutinen (in C 'Funktionen' genannt) an Daten. In den alten, funktionalen Programmiersprachen gab es entweder die "lokalen" Daten, die im Stackbereich liegen, oder die "globalen" Daten, bei den ganz alten Programmiersprachen (Standard-BASIC) gab es nur globale Daten. Die Objektorientierung setzt die Klassendaten hinzu. Eine Klasse ist eine Zusammenfassung von Datenanordnungen mit den zugehörigen Subroutinen, die hier "Methoden" (manchmal auch "Operationen") genannt werden.

Eine Zusammenfassung von Datenanordnungen gibt es auch bei C, das struct{...} -Konstrukt. In C++ unterscheidet sich das struct-Konstrukt nicht bedeutend vom class-Konstrukt.

Um diesbezüglich objektorientiert zu programmieren,

In C++ oder Java sind die Klassenmethoden zur Klasse zugehörig zu notieren und werden gerufen nach dem Schema:

object.method(arguments);

wobei object diejenige Instanz der Daten ist, auf die sich der Methodenaufruf bezieht. In C geht das nicht. Eine Möglichkeit in C ist folgende:

method_Typ(&object, arguments);

Hier wird der Zeiger auf das zugehörige Object als erster Parameter übergeben. Die Methode muss einen längeren Namen bekommen, weil sonst gleichnamige Methode verschiedener Klassen kollodieren. Das Namensschema ist hier: Anhängen des zugehörigen Typs des Objektes. In C++ geht die Übergabe der Referenz auf das Objekt auf Maschinenniveau nicht viel anders. Der hierbei implizit über den Stack übergebene Zeiger auf die Daten heißt this. Der Zugriff auf die Daten erfolgt bei C++ oder Java implizit, wenn die Daten nicht lokal definiert sind aber als Klassendaten bekannt sind:

Typ::method()      //Klassenmethode in C++
{ int a = b;       //b ist als Klassenvariable definiert.
}

Es ist aber auch this->b schreibbar, und zur verdeutlichung, dass b eine Klassenvariable ist (Attribut genannt) auch sinnvoll zu schreiben. In C muss man dieses in jedem Fall tun:

method_Typ(Typ* ythis)    //Klassenmethode in C
{ int a = ythis->b;       //b ist als Klassenvariable definiert.
}

Der Zeiger ythis ist in Anlehnung aus dem this, was in C++ oder Java an gleicher Stelle für die Klassendaten verwendet wird, benannt. Die direkte Verwendung von this führt dann zu Problemen, wenn man diese C-Code-Part mit einem C++-Compiler übersetzt. Daher sollte this nirgends verwendet werden. Die Compilierung von C-Quellen mit einem C++-Compiler ist sinnvoll, beispielsweise auch deshalb, weil der C++-Compiler Fehler schärfer kontrolliert. Grundsätzlich sollten alle C-Quellen so geschrieben werden, dass sie mit einem beliebigem Compiler, nicht nur den für das momentane Zielsystem, compilierbar sind. Das fördert auch Simulationsmöglichkeiten von C-Algorithmen am PC.

Kapselung von Daten

Das zweite wichtige Prinzip der Objektorientierung ist die Kapselung von Daten. Wogegen in nichtobjektorientierter Software man nie wußte - wer benutzt wann welche Variable, und noch schlimmer, wer ändert sie? - sollen die Daten einer Klasse in der Regel nur den klasseneigenen Methoden zugänglich sein. In C++ und Java gibt es das Schlüsselwort private für diesen Zweck, die Verwendung von public sollte die Ausnahme sein.

Der Vorteil: Man kann Datenstrukturen ändern aus der Sicht der Klasse, nur dieses modulare Teil einer Gesamtsoftware muss dabei berücksichtigt werden, ohne Nebenwirkungen und ohne die andere große Software des Gesamtsystems beachten zu müssen.

Der Nachteil: Alle Zugriffe auf die Daten einer Klasse werden über Methoden ausgeführt. Das ist aber nur ein Schreibarbeitsnachteil:

int a = global.ichgreifeDirketZu;
entgegen
int a = getData_FromObject(theObject);

Um absolut rechenzeit-optimal in C zu arbeiten, ist es auch möglich, diese Art des Zugriffes in ein define zu verwandeln und letztlich im Maschinencode doch wieder den optimalen direkten Zugriff zu haben:

#define getData_FromObject(OBJECT) ((OBJECT).ichgreifeDirektZu)

Das ist eine Lösung für Singletons (nur einmal instanziierte Klassen). OBJECT muss hier das Singleton-Objekt selbst sein. Eine adäquate Lösung für mehrfachinstanziierte Daten ist

#define getData_FromObject(OBJECT) ((OBJECT)->ichgreifeDirektZu)

Hier muss OBJECT ein Zeiger (Referenz) auf ein Objekt (Instanz) dieses Typs (dieser Klasse) sein.

Vorteil der Kapselung: Man kann sich in der Kapsel überlegen, wie die Daten zusammengebaut werden. Gegebenenfalls können sie auch nicht direkt in der angesprochenen Struktur liegen, sondern nochmals indirekt. Folgendes Beispiel zeigt, wie mit einem define Daten komplex zusammengesucht werden können. Vorteil für den Anwender: Er braucht diese Details nicht zu wissen, er kann keine Programmierfehler bei diesem Zugriff zu machen, der Zugriff ist in einer anderen Quelldatei formuliert und kann vom Anwender unabhängig korrigiert werden.

#define getData_FromObject(OBJECT) ( (OBJECT)->ctrlValue==1 ? (OBJECT)->itsPartner->a : (OBJECT)->b + (OBJECT)->b))

Wenns komplexer wird, dann sollte man aber doch eine Methode schreiben:

int getData_FromObject(Typ* ythis)
{ if(ythis->ctrlValue==1)
  { return ythis->itsPartner->a;
  }
  else
  { int value = ythis->a + ythis->b;
    return value;
  }
}

Die Methode ist meist die bessere Wahl, wenn es nicht auf die Mikrosekunde der Rechenzeit ankommt.

Vererbung

Was ist Vererbung? Meist falschverstanden als: "Ich nehme etwas, tue noch was dazu, und erhalte das was ich brauche". Typisches Fehlerbeispiel: Eine Datenstruktur beschreibt ein Quadrat. Ich brauche ein Rechteck. Dazu brauche ich noch eine zusätzliche Seitenlänge, die setze ich hinzu. Das ist es nicht. In diesem Fehlbeispiel würde nämlich die Aussage gelten: Ein Rechteck ist ein Quadrat.

Vererbung wird dann verwendet, wenn man ein Objekt unter seiner Grundeigenschaft kennen muss, auch wenn es spezifische Ausprägungen hat (die an anderer Stelle gegebenfalls wichtig sind).

Die Vererbung ist in C damit realisierbar, dass die Daten der Basisklasse als erstes in den Daten der abgeleiteten Klasse angeordnet werden, damit hat man mit dem abgeleiteten Objekt auch gleichzeitig die Daten der Basisklasse:

typedef struct Data_Base_t
{ int a, float b;
}Data_Base;

 
typedef struct MySpecialData_t
{ Data_Base super;
  int myAdditionalData;
}MySpecialData;


mySpecialData data;
...

callMethod_DataBase(data.super);  //Zugriff auf die Basisklasse

Bezüglich der Vererbung ist das dynamische Binden wichtig, siehe folgend.

Dynamisches Binden

Wenn man ein Objekt unter seiner Grundeigenschaft kennt, dann möchte man mit Methoden der Grundeigenschaft eigentlich die tatsächlichen Daten in der Art des tatsächlichen Objektes verändern. Typisches Lehrbeispiel dazu: Ein Lohnbüro kennt alle Mitglieder ohne Unterschied ihrer Stellungen (Lohnempfänger, Gehaltsempfänger). Ein Aufruf der Methode calcSalary() muss zu verschiedenen Berechnungen führen, obwohl der Mitarbeiter des Lohnbüros, der diese Berechnung anweißt (die Methode ruft), die Details nicht kennt. Derjenige, der die Berechnung ausführt (die Methode selbst), muss die Details natürlich kennen.

Das dynamische Binden wird in C++ mit virtuellen Methoden (Schlüsselwort virtual) ausgeführt. In Java ist es immer präsent, ohne extra Kennzeichnung. Auch in C ist ein solches dynamisches Binden möglich, und an verschiedenen Stellen auch sinnvoll. Programme nach diesem Schema werden letztlich einfacher im Vergleich zu einem handgeschriebenem Code, wenn man ein solches Problem denn hat. Siehe dazu den Hauptabschnitt Realisierung des dynamischen Bindens (virtuelle Methoden) in C