Hallo und herzlich willkommen. Na, hast du dich seit letztem Mal gut erholt und alles ordentlich reflektiert?

Sehr gut, denn wir knüpfen nahtlos daran an. Wir hatten uns mit Pointern befasst und dabei eine Sache erwähnt, diese aber noch nicht so richtig in Zusammenhang gesetzt: Referenzen.

Jetzt wollen wir es genauer wissen und uns den Unterschied sowie die Abgrenzung zu Pointern anschauen.

Was ist eine Referenz?

Ohne Umwege und direkt in einem Satz zusammengefasst:

Eine Referenz ist ein Pseudonym für eine Variable.

Mit einer Referenz kannst du einer Variablen also einen weiteren Namen geben. Unter diesem ist sie nach Deklaration der Referenz im Programm ebenfalls bekannt.

Hast du einen Spitznamen, mit dem deine Freunde dich rufen? Das lässt sich gut vergleichen. Egal, ob deine Mutter dich mit deinem Geburtsnamen ruft oder deine Freunde deinen Spitznamen verwenden. Jedes Mal bist du gemeint.

Wenn du eine Referenz deklarierst, dann musst du sie sofort mit einer Variablen initialisieren. Du hast keine Wahl und kannst sie nicht erst später miteinander verbinden. Es entsteht eine Referenz auf diese Variable und eine weitere Möglichkeit, auf ihre Daten zuzugreifen.

Bei der Deklaration wird der Referenz Operator & dem Datentypen angefügt. Damit ist der darauffolgende Name als Bezeichnung einer Referenz gekennzeichnet.

Lass uns zum besseren Verständnis mit einer Variablen und zwei Referenzen spielen (siehe listing1).

// listing1: reference declaration

#include <iostream>

int main()
{
  int anInt{15};
  std::cout << "anInt = " << anInt << std::endl;
  std::cout << "memory address of anInt = " << &anInt << std::endl;
  
  int& refToInt = anInt;
  std::cout << "reference to anInt = " << refToInt << std::endl;
  std::cout << "memory address of reference to anInt = " << &refToInt << std::endl;
  
  int& anotherRef = refToInt;
  std::cout << "anotherRef = " << anotherRef << std::endl;
  std::cout << "anotherRef is at address: " << &anotherRef << std::endl;
  
  return 0;
}
# Output

anInt = 15
memory address of anInt = 0x7ffe5620f9f4
reference to anInt = 15
memory address of reference to anInt = 0x7ffe5620f9f4
anotherRef = 15
anotherRef is at address: 0x7ffe5620f9f4

listing1 zeigt dir sehr schön, dass eine Referenz nur einen neuen Namen für die selbe Speicheradresse und die dahinter liegenden Daten einführt.

Die Ausgaben auf der Konsole demonstrieren dir, dass alle Referenzen auf die selben Daten zeigen. Dabei spielt es keine Rolle, ob sie auf die ursprüngliche Variable oder nur auf eine andere Referenz initialisiert wurden. Es wird immer die selbe Speicheradresse referenziert.

Warum sind Referenzen nützlich?

Referenzen lassen dich also mit ihrem initialisierten Speicher arbeiten. Damit kannst du sie gut in Kombination mit Funktionen verwenden. Aus eps1.15_Funktionen weißt du, wie eine Funktion typischerweise deklariert wird.

// listing2: declaring a function

// returnType FunctionName (type parameterName)
int toSquare(int number);

In listing2 siehst du ein typisches Beispiel. Rufst du diese Funktion auf, verlangt diese ein Argument als Parameter. Dieses Argument wird in den Parameter kopiert und belegt einen Block im Speicher. Die Funktion arbeitet anschließend mit der Kopie. Schließlich gibt die Funktion noch einen Wert zurück. Dazu wird ihr Ergebnis ebenfalls kopiert. Diesmal in den Return Value.

Die Kopiervorgänge erhöhen den Speicherkonsum des Programms. Besonders, wenn das Argument sehr groß ist.

Da stellt sich doch die Frage: 

„Muss die Kopiererei unbedingt sein?“

Wäre es nicht viel besser, wenn das Kopieren wegfallen würde? Stattdessen könnte die Funktion doch direkt mit den Daten des Arguments arbeiten.

Genau dafür sind Referenzen nützlich.

Schau dir listing3 an. Dort findest du unsere Beispielfunktion int toSquare(int number) und wie diese mit Referenzen gestaltet werden kann.

Bei void toSquareRef(int& number) fallen direkt zwei Unterschiede auf. Die Funktion hat den Return Type void. Sie liefert somit keinen Wert, der kopiert werden würde, zurück. Das Argument wird als Referenz übergeben. Es muss also auch nicht mehr kopiert werden. Der Parameter wird so zu einem Alias für das Argument, mit dem die Funktion Zugriff auf die Daten bekommt. Für dieses Vorgehen kannst du dir das Schlagwort Pass-by-reference merken.

// listing3: reference as function parameter

#include <iostream>

int toSquare(int number)
{
    std::cout << "(memory address of argument = " << &number << ")" << std::endl;

    number *= number;
    return number;
}

void toSquareRef(int& number)
{
    std::cout << "(memory address of argument = " << &number << ")" << std::endl;
    number *= number;
}

int main()
{
    int number = 10;
    std::cout << "number = " << number << std::endl;
    std::cout << "memory address of number = " << &number << std::endl;

    std::cout << "The square of " << number << std::endl;
    std::cout << "is: " << toSquare(number) << std::endl;

    std::cout << "The square of " << number << std::endl;
    std::cout << "is: ";
    toSquareRef(number);
    std::cout << number << std::endl;

    return 0;
}
# Output

number = 10
memory address of number = 0x7ffff58c4da4
The square of 10
is: (memory address of argument = 0x7ffff58c4d8c)
100
The square of 10
is: (memory address of argument = 0x7ffff58c4da4)
100

Die beiden Funktionen verfolgen denselben Zweck. Sie sollen eine Integerzahl quadrieren. Während bei int toSquare(int number) eine Kopie des Ergebnisses an die aufrufende main Funktion zurückgegeben wird, überschreibt void toSquareRef(int& number) den Adressbereich mit den neuen Daten.

Wenn du bei der Deklaration vergisst, den Funktionsparameter mit dem & Operator als Referenz zu kennzeichnen, führt die Funktion zwar ihre Operation aus. Doch das Ergebnis ist für main() nicht sichtbar. Der Wert der Variablen verändert sich nicht und der Speicherblock bleibt unangetastet.

In diesem Fall wäre das Ergebnis eine lokale Kopie innerhalb der Funktion, die bei Verlassen der Funktion wieder gelöscht wird.

Du solltest aber beachten, dass der ursprüngliche Wert der Variable number in der Variante mit Referenzen verloren geht. Benötigst du allerdings den Ausgangswert und das Ergebnis, dann kannst du eine zusätzliche Variable und Speicherplatz nicht vermeiden.

Das Schlüsselwort const und Referenzen

Referenzen bringen also den Vorteil, dass Funktionen keine Kopie ihrer Parameter erstellen, sondern direkt mit den Argumenten arbeiten. Und so erhöht sich das Leistungsvermögen deiner Applikation merklich.

Wie du bestimmt schon bemerkt hast, bringt diese Fähigkeit auch mehr Verantwortung für dich. Denn du bekommst mit einer Referenz den vollen Zugriff auf die Daten einer Speicheradresse und kannst sie verändern oder sogar löschen.

Möchtest du die Werte aber nicht manipulieren, sondern nur weiterverwenden, dann solltest du darauf achten, dass das nicht ungewollt an irgendeiner Stelle geschieht.

Dabei helfen dir Referenzen, die mit dem Schlüsselwort const deklariert wurden. Wie eine Konstante kann der initialisierte Wert der Referenz nicht verändert werden oder als l-value etwas zugewiesen bekommen. Das wird sofort zu einer Fehlermeldung während des Kompiliervorgangs führen.

// listing4: const reference

#include <iostream>

void toSquareRef(const int& number, int& result)
{
    result = number*number;
    // number *= number;
}

int main()
{
    int number = 0;
    int result = 0;

    std::cout << "Enter a number: ";
    std::cin >> number;

    toSquareRef(number, result);

    std::cout << "The square of " << number << std::endl;
    std::cout << "is: " << result << std::endl;
    
    return 0;
}
# Output

Enter a number: 5
The square of 5
is: 25

Anders als im vorherigen Beispiel hat die Funktion in listing4 zwei Parameter. Einer bringt die Zahl, die quadriert werden soll, und die andere das Ergebnis der Operation.

Um sicher zu gehen, dass die ursprüngliche Zahl nicht verändert wird, wurde die Referenz mit const gekennzeichnet.

Glaubst du mir nicht? Das ist auch gut so! Bleibe kritisch und prüfe, was dir als neue Erkenntnis oder Wissen verkauft wird. Sonst fällst du leicht auf Betrüger und Selbstdarsteller rein.

Also, was passiert, wenn du nun der konstanten Referenz einen neuen Wert zuweist? Probier es einfach aus und kommentiere die alte Operation der Funktion wieder ein. Was hält der Compiler davon? Bestimmt beschwert er sich, dass du versuchst, einem read-only Parameter einen neuen Wert zuzuweisen.

Vielleicht klingt das im ersten Moment etwas übervorsichtig. Doch spätestens in Projekten, in denen andere Programmierer deinen Code weiterverwenden, wird es die Aussagekraft und die Qualität deiner Zeilen steigern.

Wo liegt der Unterschied zwischen Pointern und Referenzen?

Du kannst nun zu Recht die Frage stellen:

„Und wozu gibt es Referenzen? Ich habe doch schon Pointer!“

Und diese Frage ist berechtigt. Referenzen und Pointer sind sich in ihren Eigenschaften sehr ähnlich.

Pointer stammen aus der Programmiersprache C, aus der C++ hervorging. Sie sind in beiden Sprachen eine Möglichkeit, sich indirekt auf Objekte zu beziehen.

Allerdings bietet C++ zusätzlich Referenzen als alternativen Mechanismus für die grundsätzlich gleiche Aufgabe. In manchen Situationen besteht C++ darauf, dass du Pointer verwendest. Manchmal ist es sinnvoll, Referenzen zu nutzen. Meistens wird es dir selbst überlassen, dich für eine Notation zu entscheiden. Das macht es zu einer Geschmacksfrage.

Wenn du dich dadurch verunsichert fühlst, dann bleibe trotzdem gelassen. Du bist nicht allein. Auch wenn sie es nicht zugeben wollen, viele C++-Programmierer wissen nicht genau, wann welche Notation besser wäre.

Lass uns die Unterschiede aufstellen.

Fangen wir mit der Deklaration an. Auf den ersten Blick sehen beide sich sehr ähnlich. Eine Referenz nutzt allerdings den Referenz-Operator & als Anhang zum Datentypen und wird mit einer Variablen initialisiert. Ein Pointer hingegen ist durch den Dereferenz-Operator * gekennzeichnet und muss nicht sofort initialisiert werden. Ihm kann auch erst im späteren Verlauf des Programms die Referenz einer Variablen zugewiesen werden (siehe listing5).

// listing5: declarations

#include <iostream>

int main()
{
    int anInt{15};          // variable initialization
    int& refToInt = anInt;  // reference intialization
    int* pointToInt;        // pointer declaration
    pointToInt = &anInt;    // pointer assignment

    std::cout << "Value of variable = " << anInt << std::endl;
    std::cout << "Address of variable = " << &anInt << std::endl;

    std::cout << "Value of reference = " << refToInt << std::endl;
    std::cout << "Address of reference = " << &refToInt << std::endl;
    
    std::cout << "Value of pointer = " << *pointToInt << std::endl;
    std::cout << "Address of pointer = " << pointToInt << std::endl;
}
# Output

Value of variable = 15
Address of variable = 0x7ffccb621f34
Value of reference = 15
Address of reference = 0x7ffccb621f34
Value of pointer = 15
Address of pointer = 0x7ffccb621f34

Initialisieren einer Referenz mit einem Objekt wird häufig auch Binding genannt, da die beiden nun miteinander verbunden sind.

Den größten Unterschied ist in lisitng5 sehr gut zu erkennen. Um einen Pointer zu dereferenzieren und an die Daten der Speicheradresse zu gelangen, musst du explizit den Dereferenz-Operator * nutzen. Nach der Initialisierung zeigt der dereferenzierte Ausdruck *pointToInt des Pointers pointToInt auf den Wert anInt und gibt nicht die Adresse zurück.

Bei Referenzen ist dies nicht nötig. refToInt verweist ohne einen Operator auf den Wert von anInt.

Allerdings kannst du nicht alles referenzieren. Der spezielle Speicherbereich von nullptr wird von einer Referenz nicht akzeptiert. Natürlich kannst du das mit Biegen und Brechen irgendwie erreichen. Doch wozu? Es führt nur zu Fehlern und nicht definiertem Verhalten in deinem Code.

Diese Eigenschaft macht deinen Code nicht unbedingt sicherer, macht dich vielleicht auf mögliche Mängel aufmerksam.

Wann zeigen und wann verweisen

Wir wissen nun, dass Pointer und Referenzen viele Eigenschaften teilen. Obwohl Pointer alleine ausreichend Möglichkeiten bieten (siehe Programmiersparche C), entwickelt sich mit der Zeit ein gewisses Gespür dafür, wann Referenzen eine sinnvolle Ergänzung sind. Auch wenn es immer wieder Situationen gibt, in denen die Wahl nicht so eindeutig ist. Wichtig ist, dass du eine einigermaßen konsistente Philosophie für die Verwendung von Referenzen entwickelst.

Bei deiner Entscheidungsfindung hilft anhand der wichtigsten Merkmale abzuwägen. Referenzen können über ihre Lebensdauer nur mit einer Variablen verbunden werden. Sie verweisen direkt auf den Wert, geben nicht die Adresse zurück und können keine Null-Referenz sein.

Typischerweise brauchen Funktionen nicht mehr von ihrem Argument, um ihre Aufgabe zu erfüllen. Also nur dort Pointer verwenden, wenn deren Eigenschaften auch tatsächlich nötig sind.

Das bleibt hängen

Referenzen sind Aliasnamen und stellen eine sinnvolle Alternative zu Pointern bei der Übergabe von Argumenten an Funktionen dar. Sie müssen initialisiert werden, dürfen keinen nullptr enthalten und garantieren so gültige Werte.

Da keine Kopie des Arguments erstellt wird, ist die Übergabe per Referenz effizient, selbst wenn sie mit großen Strukturen oder Klassen verwendet wird.

Wenn es nützlich ist, kann die Funktion den Wert des Arguments direkt ändern. Ansonst haben wir über const-correctness gesprochen und wie mit der richtigen Verwendung von const sichergestellt wird, dass der Wert hinter der Referenz sich nicht verändert. So ist das Argument quasi read-only.

Auch wenn Pointer und Referenzen nicht ganz einfach zu verstehen sind, erweitern diese unseren Werkzeugkasten mit mächtiger Funktionalität. Mit ihnen sind wir gut ausgerüstet, um unseren Code schneller, mit optimaler Speichernutzung und sicherer gestalten zu können.

Ich wünsche dir maximalen Erfolg!


Quellen

  • [1] B. Stroustrup, A Tour of C++. Pearson Education, 2. Auflage, 29. Juni 2018.
  • [2] B. Stroustrup, Programming: Principles and Practice Using C++. Addison Wesley, 2. Auflage, 15. Mai 2014.
  • [3] U. Breymann, Der C++ Programmierer. C++ lernen – professionell anwenden – Lösungen nutzen. Aktuell zu C++17. München: Carl Hanser Verlag GmbH & Co. KG; 5. Auflage, 6. November 2017