Unterprogramm
Ein Unterprogramm oder eine Subroutine, ist ein Teil eines Computerprogramms, der aus gegebenenfalls mehreren anderen Programmteilen oder Programmen heraus aufgerufen werden kann und nach Abschluss der Abarbeitung wieder in das aufrufende (Teil-) Programm zurückkehrt.
Je nach Kontext werden auch Bezeichnungen wie Prozedur, Funktion, Methode, Modul, Routine/Subroutine, Operation etc. in weitgehend identischem Sinn benutzt. Die verschiedenen Bezeichnungen sind teils historisch entstanden, teils im Umfeld verschiedener Programmiersprachen, betonen jeweils einen bestimmten Aspekt und/oder unterscheiden sich in Details.
Unterprogramme können hinsichtlich ihrer Zugehörigkeit zu den aufrufenden Programmen unterschiedlich implementiert sein:
- Als logisch in sich geschlossener Funktionsteil im Quelltext eines Programms, das nach dem Prinzip der modularen Programmierung erstellt wurde. Hinsichtlich der administrativen Verwaltung (z. B. bei Änderungen) und der technischen Ausführung auf einem Computer gehören solche, z. T. ebenfalls „Unterprogramm“ genannte Quelltextabschnitte fest zum Gesamtprogramm. Je nach Programmiersprache (z. B. bei Cobol) und Programmierstil hat das Unterprogramm Zugriff auf alle im Quelltext definierten Daten und Funktionen.
- Als administrativ und zur Ausführung eigenständige Programme. Sie werden erst zur Ausführungszeit („dynamisch“) geladen und über Parameter mit dem oder den aufrufenden Programmen verbunden.
- Eine Mischform aus beidem sind Unterprogramme mit ebenfalls eigenständigem Quelltext und getrennter Kompilierung. Ihr Maschinencode wird jedoch zusammen mit dem Code sie aufrufender Programme zu einer ausführbaren Datei „statisch gebunden“.
Gründe für das Aufteilen von Programmen in Unterprogramme
Aus der ursprünglichen Sicht der Assemblerprogrammierung war der Grund der Aufteilung die mehrfache Verwendung der gleichen Befehlsfolge, damit Einsparung von Speicherplatz und die Vermeidung von Codewiederholungen.
In modernen Technologien des Softwareengineering ist ein weiterer wichtiger Grund allerdings die Strukturierung des Softwaredesigns. Ein Unterprogramm sollte eine in sich abgeschlossene und gut beschreibbare Teilaufgabe erledigen. Das ist ebenfalls das Konzept der Methoden in der objektorientierten Programmierung. Unterprogramme sind heute zu Gunsten der besseren Wartbarkeit und Programmierfehlerbehebung vorzugsweise kurz und übersichtlich, da der rechnerinterne Zeit- und Verwaltungsaufwand zum Aufruf von Unterprogrammen auf modernen Rechenmaschinen praktisch keine Rolle mehr spielt.
Beispiele zu Unterprogrammen
Die Beispiele in den Programmiersprachen C, C++ und Java zeigen Details zur Programmierung mit Unterprogrammen.
Beispiel Java
float parabel(float x, float a, float b, float c) { float y = a * x * x + b * x + c; return y; }
Diese quadratische Funktion wird der Bezeichnung Funktion im mathematischen Sinne gerecht: x wird auf y abgebildet, die Funktion benutzt keine externen Daten. Man könnte x als Argument bezeichnen, a, b und c dagegen als Parameter. Die Bezeichnung y, die mathematisch das Ergebnis bezeichnet, kommt nur in der lokalen Variablen zum tragen.
int setNextValue(float value) { if (index < 0 || index > (maxIndex - 1)) { throw new OutOfBoundsException(); } dataArray[index] = value; return index; }
Dieses Java-Beispiel stellt ein Unterprogramm dar, das eine Funktion im allgemeinsprachlichem Sinne ausführt. Der Rückgabewert ist lediglich der Folgeindex beziehungsweise die Anzahl der Daten. In diesem Unterprogramm werden Daten verwendet, die außerhalb deklariert sind. Wäre das ein C-Beispiel, dann kann es sich nur um globale Variablen handeln. In C++ könnten es sinnvollerweise Klassenvariablen sein, in Java sind es jedenfalls Klassenvariablen.
Beispiel Assembler
.GLOBAL _set_floatExtend; ;float set_floatExtend(_floatExtend* dst, float nVal); _set_floatExtend: I4=R4; ;_floatExtend* dst PX=F8; ;float nVal dm(0,I4)=PX1; ;store 40 bit in 2 32 bit memory locations dm(1,I4)=PX2; ! FUNCTION EPILOGUE: i12=dm(-1,i6); jump (m14,i12) (DB);!.return F0=F8; ;return value nVal to R0/F0 RFRAME; !FUNCTION END
Dieses Beispiel stellt ein in Assembler geschriebenes Unterprogramm für die Signalprozessorfamilie ADSP-21xx von Analog Devices dar. Dieses Beispiel wird weiter unten bezüglich Übergabe von Argumenten und Rückgabewert diskutiert. Die Kommentierungen zeigen, wie dieses Unterprogramm in einer C- oder C++-Umgebung verwendet werden soll.
Beispiel Großrechner IBM-Welt
Das Beispiel zeigt, beispielhaft und Cobol-ähnlich angedeutet, den Code eines aufrufenden und eines Unterprogramms, und zeigt, wie der Unterprogrammaufruf auf Systemen der Serie IBM System/390 verläuft. Technische Details wie etwa Register sichern und rückladen sind nicht dargestellt.
- Code des rufenden Programms:
* Daten:
A.
>Struktur von A, z.B.:
Ax >Format und Längen
Ay ...
B.
B1 (Definitionen, ggf. weitere Teilfelder)
B2 (Definitionen)
B3 (Definitionen)
* Funktionscode:
A-ROUTINE.
A-1. >Befehle-1
Call UPRO Using A, B2.
A-2. >Befehle-2, z. B. Auswerten und Verarbeiten Rückgabewert(e)
A-ROUTINE Exit.
>beliebige weitere Routinen/Befehle
* Programm-Ende
- Code des Unterprogramms, ggf. in einer anderen Programmiersprache:
* Datendefinitionen:
A-DAT >Format und Sub-Struktur wie in A; andere Bezeichner möglich!
B-2 dto.
C-x z.B. eigene Definitionen von UPRO
* Funktionscode:
Entry UPRO Using A-DAT, B-2.
>Feldbezeichnungen von Using ggf. abweichend, Reihenfolge identisch zu 'Call'!
>Weitere Befehle des Unterprogramms:
>Mit vollem Zugriff (auch ändernd) auf die Struktur (Einzelfelder) von A-Daten und B2.
Ggf. setzen Returncode, z.B. in B-2 (= B2)
Exit = UPRO-Ende
- Ablauf des Unterprogramm-Aufrufs (Call und Return):
- (von den Compilern generierte Funktionen)
* Call im rufenden Programm: Setzt in einer Adressliste mit 2 Einträgen die Adresse von A und von B-2 Setzt einen Zeiger (Register) auf die Adressliste Setzt einen Zeiger (Register) auf die Rückkehradresse A-2. Verzweigt zum Entry-Point von UPRO (ggf. nach dem Laden von UPRO) Wird 'HIER' anschließend fortgesetzt * Entry im Unterprogramm: >Übernehmen der übergebenen Adressen: Adresse(A-DAT) = aus Adressliste(1), Adr(B-2) = aus Adressliste(2) >Verarbeitung – mit Zugriff auf übergebene und eigene Daten * Rücksprung ins rufende Programm: Exit: Rückkehr zu Adresse A-2 im rufenden Programm
Parameter/Argumente
Unterprogramme verarbeiten Daten und liefern Werte zurück. Dazu haben Unterprogramme in ihrer Abbildung in höheren Programmiersprachen eine sogenannte formale Parameterliste. Dieser Ausdruck wurde bereits in den 1960er Jahren für die damals als Lehrbeispiel entstandene Sprache ALGOL benutzt und ist noch heute üblich. Die Begriffe Parameter oder Argument werden in diesem Kontext oft synonym verwendet, wobei sich „Parameter“ genau genommen auf die Funktionsdefinition bezieht, „Argument“ hingegen auf den tatsächlichen Aufruf. Den Unterprogrammen wird beim Aufruf über die tatsächliche Parameterliste (genauer: Argumentliste) bestimmte Werte übergeben, mit denen sie arbeiten können. Die Unterprogramme liefern in der Regel Rückgabewerte zurück.
Alle genannten Werte können auch Referenzen, Zeiger oder Adressen auf Speicherbereiche sein. Die genannten Begriffe sind ebenfalls synonym. Im C++-Jargon wird allerdings oft streng zwischen Referenz und Zeiger unterschieden, mit Referenz wird die mit Type& deklarierte Variante bezeichnet, Zeiger dagegen mit Type*. Der Unterschied besteht darin, das eine Referenz im Gegensatz zum Zeiger nicht uninitialisiert oder leer sein darf. Das bedeutet, bei Verwendung einer Referenz muss immer ein gültiges Objekt des entsprechenden Typs übergeben werden. Referenzen auf Grunddatentypen sind ebenfalls erlaubt (z. B. int&).
Übergabe der Parameter/Argumente über den Stack
Für ein Verständnis der Funktionsweise von Unterprogrammen ist folgendes Basiswissen notwendig:
Grundsätzlich ist der Speicher von Prozessoren unterteilt in
- Programmspeicher: Dort steht der Maschinencode, der als Befehle abgearbeitet wird.
- Dynamischer Speicher (Heap): Dort sind Daten abgespeichert.
- Stapelspeicher (Stack, Aufrufstapel): Das ist ein besonderer Datenbereich, dessen Verwendung insbesondere bei Unterprogrammen eine Rolle spielt.
Jeder Thread hat seinen eigenen Stapelspeicher. In diesem werden gespeichert:
- Die Rücksprungadressen für die Fortsetzung der Programmbearbeitung nach Abarbeitung des Unterprogramms
- Die tatsächlichen Parameter
- Alle Daten, die lokal in einer Prozedur vereinbart werden
- Rückgabewerte
Der Stack wird bei der Programmabarbeitung im Maschinencode über ein spezielles Adressregister adressiert, den Stackpointer oder Stapelzeiger. Dieser adressiert immer das untere Ende des als Stack benutzen Speicherbereiches. Hinzu kommt zumeist ein Basepointer, der eine Basisadresse der Variablen und tatsächlichen Parameter innerhalb des Stacks adressiert. Der Begriff Stack ist im Deutschen als „Stapel“ übersetzbar, auch der Begriff „Kellerspeicher“ wird benutzt. Im Stack werden Informationen gestapelt und nach dem Prinzip Last In – First Out (LIFO) gespeichert und wieder ausgelesen. Allerdings kann der Zugriff auch auf beliebige Adressen innerhalb des Stacks erfolgen.
Die Parameterübergabe erfolgt über den Stack. Jeder tatsächliche Parameter wird in der Reihenfolge der Abarbeitung, üblicherweise von links nach rechts (gemäß einer strikt festgelegten Aufrufkonvention), auf den Stack gelegt. Dabei erfolgt, falls notwendig, eine Konvertierung auf das Format, das vom Unterprogramm benötigt wird.
Bei Aufruf des Unterprogramms wird dann der sogenannte Basepointer auf die nunmehr erreichte Adresse des Stacks gesetzt. Damit sind die Parameter des Unterprogramms relativ über die Adresse, die im Basepointer gespeichert ist, erreichbar, auch wenn der Stack für weitere Speicherungen benutzt wird.
Werte oder Referenzen/Zeiger als Parameter
Werden in der tatsächlichen Parameterliste nicht nur elementare Datentypen wie int oder float angegeben, sondern komplette Datenstrukturen, dann werden im Stack meist nicht die Werte der Datenstruktur selbst, sondern Referenzen (Adressen) auf die Datenstrukturen übergeben. Das hängt allerdings vom Aufruf und der Gestaltung der tatsächlichen Parameterliste ab. In C und C++ ergeben sich folgende Verhältnisse:
void function(type* data) // Funktionskopf, formale Parameterliste … struct { int a, float b } data; // Datendefinition function(&data); // Funktionsaufruf, tatsächliche Parameterliste
In diesem Fall erfolgt beim Aufruf die explizite Angabe der Adresse der Daten, ausgedrückt mit dem & als Referenzierungsoperator. Beim Aufruf ist data ein Zeiger (engl. pointer) auf die Daten. Allgemein ausgedrückt kann von Referenz auf die Daten gesprochen werden.
Der Aufruf function(data) ohne den Referenzierungsoperator & führt zu einem Syntaxfehler. In C allerdings nur, wenn der Prototyp der gerufenen Funktion bekannt ist.
In C++ kann der Funktionskopf im gleichen Beispiel mit void function(type& data) geschrieben werden. Dann ist der Aufruf mit function(data) zu gestalten. Der Übersetzer erkennt automatisch aufgrund des in C++ notwendigerweise bekannten Funktionsprototyps, dass die Funktion laut formaler Parameterliste eine Referenz erwartet und kompiliert im Maschinencode das Ablegen der Adresse der Daten auf den Stack. Das entlastet den Programmierer von Denkarbeit, der Aufruf ist einfacher. Allerdings ist beim Aufruf nicht ersichtlich, ob die Daten selbst (call by value) oder die Adresse der Daten übergeben wird.
In C oder C++ ist es auch möglich, anstelle der meist sinnvollen und gebräuchlichen Referenzübergabe eine Wertübergabe zu programmieren. Das sieht wie folgt aus:
void function(type data) // Funktionskopf, formale Parameterliste … struct { int a, float b } data; // Datendefinition function(data); // Funktionsaufruf, tatsächliche Parameterliste
Beim Aufruf wird der Inhalt der Struktur insgesamt auf den Stack kopiert. Das kann sehr viel sein, wenn die Struktur umfangreich ist. Dadurch kann es zum Absturz des gesamten Ablaufes kommen, wenn die Stackgrenzen überschritten werden und dies in der Laufzeitumgebung nicht erkannt wird. Eine Wertübergabe ist allerdings sinnvoll in folgenden Fällen:
- Übergabe einer kleinen Struktur
- Einkalkulierung der Tatsache, dass der Inhalt der originalen Struktur während der Abarbeitung verändert wird. Die Inhalte der Struktur beim Aufrufer bleiben unverändert, da eine Kopie der Daten angelegt und übergeben wird.
Rückgabe von Ergebnissen
Rückgabe von Einzelwerten
Ein Unterprogramm kann in den meisten Programmiersprachen einen einfachen Wert als Rückgabewert zurückgeben, beispielsweise als Integer oder Wahrheitswert. Dieser einfache Wert passt immer in ein CPU-Register, damit ist dies für die meisten Übersetzer/Laufzeitsysteme die richtige Wahl. Der Wert im Register wird entweder innerhalb eines Ausdruckes unmittelbar weiterverarbeitet (z. B. … + function(parameter) * …) oder er wird gespeichert (z. B. variable = function(parameter)).
Wirkung und funktionale Programmierung: Das Unterprogramm selbst kann dabei ohne Wirkung auf irgendwelche anderen Daten sein, oder im Unterprogramm können weitere Daten beeinflusst werden. Im ersten Fall entspricht das Unterprogramm dem Ansatz der Funktionalen Programmierung.
Rückgabewertübergabe in Form einer kompletten Struktur von Daten
In fast allen modernen prozedualen Programmiersprachen ist es möglich, nicht nur einfache skalare Werte wie ganze Zahlen (zum Beispiel int) oder Gleitkommazahlen (zum Beispiel float) als Rückgabewert zurückzugeben, sondern auch Datenstrukturen oder Datenverbunde (zum Beispiel struct).
struct DataType { int a; float b }; DataType function() { DataType data; // Daten werden hier angelegt, data.a = 5; // und belegt data.b = 27.2; return data; // und nach außen kopiert. } DataType data2 = function(); // Aufruf
Die Struktur wird in diesem Beispiel in der Programmiersprache C zunächst lokal auf dem Stapelspeicher (Stack) angelegt. Mit dem Aufruf von return werden die Daten der Struktur in einen vom Compiler reservierten Zwischenbereich im Stapelspeicher kopiert, der mit dem Verlassen der Funktion nicht freigegeben wird sondern zur Aufrufumgebung gehört. Bei der nachfolgenden Zuweisung werden dann die Daten in die Zielstruktur kopiert. Diese Variante der Datenübergabe ist etwas aufwändiger. Besteht die Struktur allerdings aus wenigen Daten, kann der Compiler diese auch über CPU-Register und damit optimal zurückgegeben. Der Zwischenbereich wird dann nach dem Programmblock (an der schließenden geschweiften Klammer), der den Aufruf enthält, wieder freigegeben.
Folgende Struktur wird von den meisten C++-Übersetzern mindestens mit einer „Warnung“ bewertet, ist in dieser Programmiersprache zwar zulässig, jedoch programmiertechnisch unsinnig:
DataType* function() { DataType data; // Daten werden hier angelegt, data.a = 5; // und belegt data.b = 27.2; return &data; // und nach außen als Referenz bekanntgegeben. } DataType* data2 = function(); // Aufruf
Der Fehler besteht darin, dass die Daten im Stapelspeicher angelegt werden und eine Referenz (Zeiger) auf den Stapelbereich zurückgegeben wird, der Stapelbereich aber dann für anderweitige Verwendung freigegeben wird. Es kommt auf die weitere Stapelnutzung an, ob der Bereich tatsächlich überschrieben wird, so dass ein solcher Fehler zunächst gegebenenfalls gar nicht auffällt. Insbesondere, wenn über den Zeiger schreibend auf die Daten zugegriffen wird, kann es im weiteren Verlauf zum Überschreiben wichtiger Stapelspeicher-Strukturdaten über Zeigervariablen oder Rücksprungadressen kommen, was einen schwer nachvollziehbaren Absturz des Gesamtprogrammablaufes erzeugt.
Strukturierte Programmiersprachen erlauben daher keine statischen Datenstrukturen als Rückgabewerte und erfordern grundsätzlich die Erzeugung von lokalen Datenstrukturen mit Referenzvariablen dauerhaft und mit dem dazugehörigen Datentyp im dynamischen Speicher (Heap), so dass die Daten nicht versehentlich überschrieben oder falsch interpretiert werden können.
Die Möglichkeit, das Problem auch in der Programmiersprachenfamilie von C richtig zu lösen, besteht ebenfalls in der Nutzung von dynamischem Speicher:
DataType* function() { DataType* data = new DataType; // Daten werden stattdessen im Heap angelegt, data->a = 5; // und belegt data->b = 27.2; return data; // und nach außen als Referenz bekanntgegeben. }
In C muss malloc anstatt new verwendet werden, alles andere ist identisch. In C und in C++ muss dabei geklärt werden, wer für das Freigeben der Daten verantwortlich ist. Ansonsten bleibt Speichermüll stehen, was bei längerer Laufzeit zum Absturz des Systems führen kann. In objektorientierten Programmiersprachen mit dynamischen Laufzeitsystemen sorgt deswegen eine obligatorische, automatische Speicherbereinigung für das korrekte Freigeben des Speichers, wenn dieser nicht mehr verwendet wird. Hier können die Datenobjekte beziehungsweise Instanzen sogar völlig unabhängig vom erzeugenden Programm weiterexistieren, so dass der Programmierer gar keinen Einfluss mehr auf die Freigabe des Speichers nehmen kann.
Rückschreiben über referenzierte Daten
In vielen Programmiersprachen ist ein Rückschreiben von Ergebnissen auch über Referenzen, die als Parameter des Unterprogramms übergeben wurden, möglich, beispielsweise in C und C++:
void function(Type* data) { data->a = data->b * 2; // Wert in data->a wird veraendert. }
Das gilt gleichermaßen für Java. Das Rückschreiben kann ungewollt sein, weil Nebenwirkungen (Nebeneffekte) verhindert werden sollen. Ein Unterprogramm soll die Werte von bestimmten Datenstrukturen nur lesend verarbeiten und wirkungsfrei darauf sein. In C++ (bzw. in C) ist es möglich, zu formulieren:
void function(Type const* data) { data->a = data->b * 2; // Hier meldet der Übersetzer einen Syntaxfehler. }
Die hier verwendete Schreibweise mit dem Schlüsselwort const soll deutlich machen, dass der gezeigerte (referenzierte) Bereich als konstant zu betrachten ist. Möglicherweise wird const Type* geschrieben, was syntaktisch und semantisch identisch ist. Nur in diesem Fall ist es möglich, einen als konstant deklarierten Speicherbereich überhaupt zu übergeben. Die Konstruktion
const struct Type { int a, float b } data = { 5, 27.2 }; function(Type* data) { … } // Funktionsdefinition function(&data); // Aufruf
führt in C++ zu einem Syntaxfehler, weil es nicht gestattet ist, als konstant bezeichnete Daten an eine nicht konstante Referenz zu übergeben. In C werden Zeigertypen nicht so genau getestet, so dass dieses Beispiel – abhängig vom verwendeten Übersetzer – in solchen Fällen möglicherweise lediglich eine Warnung auslösen würde.
Allerdings ist es in C und C++ möglich, innerhalb der Funktion den Typ des Zeigers zu wandeln und dann dennoch schreibend auf den Speicherbereich zuzugreifen. Eine solche Programmierung sollte nur in Sonderfällen verwendet werden und sollte nach außen entsprechend dokumentiert werden.
In Java gibt es die Möglichkeit der const-Auszeichnung in einem Referenzparameter zur Unterscheidung des schreibenden oder nicht schreibenden Zugriff auf eine Instanz nicht. Das Konzept sieht stattdessen vor, den Zugriffsschutz über private-Kapselung zu realisieren. Insbesondere können spezifische interface-Referenzen verwendet werden. Mit dieser Methodik ist es möglich, von außen zu kontrollieren, was eine Subroutine an den übergebenen Daten ändern kann, ohne die Subroutine im Detail zu kennen.
In der objektorientierten Programmierung in Java und C++ wird die Referenz auf die Klassendaten implizit mit dem this-Zeiger übergeben. Für Klassenmethoden ist also das Schreiben auf die eigenen Daten immer möglich.
Überladen und dynamisches Binden von Unterprogrammen
Das Wort „überladen“ ist eine direkte Übersetzung aus dem englischen overload und nicht unbedingt aus sich heraus verständlich. Mit Überladen wird hier das Umdefinieren des Bezeichners (engl. name mangling) eines Unterprogramms in Abhängigkeit von der Parameterauswahl verstanden. Eine besser verständliche Bezeichnung wäre „Parametersensibilität“, die sich aber in Fachkreisen bis heute nicht durchsetzen konnte. Die nachstehenden Beispiele sind nur in C++ oder Java möglich, nicht aber in reinem C, wo die Überladung von Funktionen nicht vorgesehen ist und der Versuch, eine solche zu realisieren, einen Übersetzungsfehler auslösen würde.
Die Funktion void function(int x); ist eine gänzlich andere als void function(float x);. Beide Funktionen haben verschiedene Implementierungen, verschiedene Bezeichnungen in der Objektdatei und haben nichts weiter miteinander zu tun, als dass sie den gleichen Namen tragen. Überladen ist also nur der Funktionsname.
Problematisch für das Verständnis und für den Übersetzer sind Aufrufe folgender Art:
short y; function(y);
Hier muss der Übersetzer entscheiden, ob er eine Typumwandlung (cast) nach int oder nach float durchführt und die entsprechende Variante der Funktion aufruft. Naheliegend wäre der erste Fall, dennoch hängt hier einiges vom verwendeten Übersetzer ab; der Programmierer ahnt nicht, was sich im Untergrund des Maschinencodes tut. Einige Übersetzer wählen in solchen Fällen das mutmaßlich Richtige (was im konkreten Fall falsch sein kann), andere Übersetzer, beispielsweise GNU, neigen eher dazu, einen Fehler auszugeben, um vom Anwender eine Entscheidung zu verlangen. Er muss die Auswahl dann mit einer Schreibweise wie function((float)(y)); per Typumwandlung festlegen.
Im Allgemeinen ist es besser, die Möglichkeit des Überladens nicht zu frei zu nutzen, sondern nur für deutliche Unterschiede wie Varianten von Unterprogrammen mit unterschiedlicher Parameteranzahl. Aber auch hier führt die Kombination mit Parametern mit default-Argumenten zu Irritationen. Als sicher kann ein parametersensitiver Funktionsaufruf mit Zeigern verschiedenen Types, die nicht über Basisklassen (Vererbung) ableitbar sind, bezeichnet werden. Hier prüft der Übersetzer jedenfalls die Zeigertyprichtigkeit und meldet entweder einen Fehler oder verwendet genau das passende Unterprogramm:
class ClassA; class ClassB; function(class A*); // Ist deutlich unterschieden von function(class B*);
wenn ClassA und ClassB in keiner Weise voneinander abgeleitet (vererbt) sind.
Mit dem vom „Überladen“ zu unterscheidenden „Überschreiben“ wird das dynamische Binden bezeichnet. Hier wird tatsächlich eine Methode (ein Unterprogramm) einer Basisklasse von der gleichnamigen und gleich parametrisierten Methode der abgeleiteten Klasse überdeckt. Zur Laufzeit wird diejenige Methode gerufen, die der Instanz der Daten entspricht. Das wird vermittelt durch die Tabelle virtueller Methoden, ein Grundkonzept der objektorientierten Programmierung.
Umsetzung auf Maschinenebene
Das Konzept des Stack wurde weiter oben im Abschnitt „Übergabe der Parameter/Argumente über den Stack“ bereits erläutert.
Für Unterprogramme auf Maschinensprachniveau (Assembler) ist es an sich gleichgültig beziehungsweise liegt in der Hand des Programmierers, wie er die Parameterübergabe und die Rücksprungadresse verwaltet. Möglich ist auch die Übergabe und Speicherung ausschließlich in Prozessorregistern. Allerdings ist bei der Verwaltung der Rücksprungadresse die Notwendigkeit eines geschachtelten Aufrufs mehrerer (typisch verschiedener) Unterprogramme ineinander zu beachten. Nur bei ganz einfachen Aufgaben ist eine Beschränkung auf wenige oder nur eine Ebene sinnvoll. Es gibt aber tatsächlich bei zugeschnittenen Prozessoren und Aufgabenstellungen auch solche Konzepte.
- Die Rücksprungadresse, das ist die Folgeadresse nach dem Aufruf der Unterprogramme für die Fortsetzung des aufrufenden Programmes, wird auf den Stack gelegt.
- Zuvor werden die Aufrufparameter auf den Stack gelegt.
- Noch zuvor wird ein gegebenenfalls notwendiger Speicherplatz für Rückgabewerte auf dem Stack reserviert, wenn notwendig.
- Der Basepointer wird auf den Stack gelegt.
- Danach wird das Unterprogramm aufgerufen, das heißt, der Befehlszähler wird geändert auf die Startadresse des Unterprogramms.
- Am Beginn des Unterprogramms wird der Basepointer auf den Wert des Stackpointers gesetzt als Adress-Bezug der Lage der Parameter, des Rücksprunges und der lokalen Variablen.
- Der Stackpointer wird gegebenenfalls weiter dekrementiert, wenn das Unterprogramm lokale Variablen benötigt. Diese liegen auf dem Stack.
- Am Ende des Unterprogramms wird der ursprüngliche Wert des Basepointer aus dem Stack geholt und damit rekonstruiert.
- Dann wird die Rücksprungadresse aus dem Stack geholt und der Instruction Pointer damit wieder restauriert.
- Der Stackpointer wird inkrementiert um den Wert, um den vorher dekrementiert wurde.
- Damit wird das aufrufende Programm fortgesetzt.
In Assembler muss man diese Dinge alle richtig selbst programmieren. In den Programmiersprachen C++ und C übernimmt das der Übersetzer. In Java erfolgt innerhalb der Speicherbereiche der Virtuellen Maschine das Gleiche, organisiert vom Bytecode (erzeugt vom Java-Übersetzer) und dem Maschinencode in der virtuellen Maschine.
Als Illustration sei hier der erzeugte Assembler-Code (80x86-Assembler) der folgenden einfachen Funktion gezeigt:
float parabel(float x) { return x * x; }
Als Compiler wurde Microsoft Visual Studio 6 auf einem PC verwendet. Der Assemblercode ist in dieser IDE sichtbar, beispielsweise beim Debuggen in Maschinenebene, aber auch wenn Listingfiles mit setzen der entsprechenden Compileroptionen erzeugt werden.
Maschinencode für den Aufruf: float y = parabel(2.0F);
push 40000000h ; Der Wert 2.0 wird in den Stack gelegt. call parabel ; Aufruf des Unterprogramms; ; call legt den Instructionpointer in den Stack add esp, 4 ; Addieren von 4, das ist Byteanzahl des Parameters fst dword ptr [ebp - 4] ; Abspeichern des Ergebnisses in y
Maschinencode des Unterprogramms:
parabel: push ebp ; Der Basepointer wird im Stack gespeichert. mov ebp, esp ; Der Basepointer wird mit dem Wert des Stackpointer geladen. sub esp, 40h ; 64 Byte Stack werden reserviert. push ebx ; CPU-Register, die hier verwendet = geändert werden, push esi ; werden im Stack zwischengespeichert. push edi fld dword ptr [ebp + 8] ; Der Wert des Parameters x wird relativ zum Basepointer geladen fmul dword ptr [ebp + 8] ; und in der floating-point-unit mit selbigem multipliziert. pop edi ; Register werden restauriert. pop esi pop ebx mov esp, ebp ; Der Stackpointer wird genau auf den Stand wie beim Aufruf ; des Unterprogramms gebracht. pop ebp ; Der Basepointer wird aus dem Stack restauriert. ret ; Der Instruction pointer wird aus dem Stack restauriert ; und damit wird nach dem call (oben) fortgesetzt.
Folgendes Beispiel zeigt einen handgeschriebenen Assemblercode für den Signalprozessor ADSP-216x von Analog Devices für folgende aus C zu rufende Funktion:
float set_floatExtend(_floatExtend* dst, float nVal);
Dabei handelt es sich um eine Funktion, die einen in nVal stehenden Wert auf der Adresse dst speichern soll. Das Besondere hierbei ist, dass der Fließkommawert 40 Bit umfasst und auf zwei 32-bittige Speicheradressen aufgeteilt werden muss.
.GLOBAL _set_floatExtend; ; Sprunglabel global sichtbar _set_floatExtend: ; Sprunglabel angeben, das ist der Name des Unterprogramms, ; aus C ohne Unterstrich anzugeben. I4 = R4; ; Im Register R4 wird der erste Parameter _floatExtend* dst übergeben. ; Da es eine Adresse ist, wird diese in das Adressregister I4 umgeladen. PX = F8; ; Der zweite Parameter float nVal wird aus F8 in das Register PX geladen. dm(0,I4) = PX1; ; Ein Teil des Inhaltes von PX, in PX1 sichtbar, wird auf ; der Adresse gespeichert, die von I4 gezeigert wird. dm(1,I4) = PX2; ; Speicherung des zweiten Teils auf der Folgeadresse ! FUNCTION EPILOGUE: ; Standard-Abschluss des Unterprogramms: i12 = dm(-1,i6); ; Das Adressregister i12 wird aus einer Adresse relativ zum Basepointer ; (hier i6) geladen. Das ist die Rücksprungadresse. jump (m14,i12) (DB) ; Das ist der Rücksprung unter Nutzung des Registers i12. F0 = F8; ; nach dem Rücksprung werden die noch im cashe stehenden Befehl verarbeitet, ; hier wird der Wert in F8 nach dem Register R0 geladen, für return. RFRAME; ; dieser Befehl korrigiert den Basepointer i6 und Stackpointer i7.
Sammlungen von Unterprogrammen
Unterprogramme werden oft vorübersetzt und zu Programmbibliotheken zusammengefasst.