Hallo und herzlich willkommen. Du, es gibt da was, über das wir reden sollten. Setz dich erstmal in Ruhe hin. Es ist so…

… Jetzt, da wir unsere Programme mit Funktionen in kleinere Einheiten aufteilen können, wird es Zeit, dass wir ein ernstes Wort über den Gültigkeitsbereich von Variablen miteinander sprechen. Es ist sehr wichtig zu wissen, wann eine Variable existiert, von wem sie gesehen oder verwendet werden kann und wo sie keine Gültigkeit besitzt.

Oft wird auch der englische Begriff Scope verwendet. Das wirst du spätestens bei deinem ersten out of scope Error merken.

Bisher haben wir das Thema unterschlagen. Dabei ist es extrem wichtig und gehört zum Fundament deiner Programmierkenntnisse. Wenn du nicht richtig auf den Gültigskeitsbereich achtest, kann das deine gesamte Anwendung unbrauchbar machen. Er ist etwas Komplex und wegen seinem abstrakten Charakter nicht so einfach zu beschreiben.

Trotzdem versuche ich den Gültigkeitsbereich möglichst greifbar zu veranschaulichen. Ich habe bis jetzt damit gewartet, da unser Wissensstand, vor allem über Pointer und Speicheradressen, uns nun den Scope leichter zu verstehen hilft.

Gültigkeitsbereich und Sichtbarkeit

Lange habe ich über den Scope von Variablen nachgedacht und versucht, ein klares sowie verständliches Bild davon zu erlangen. Doch er lässt sich nicht so leicht greifbar machen. Schaust du in verschiedener Literatur danach, dann wird er meist nur mit seinen Auswirkungen und Regeln umschrieben.

Der Gültigkeitsbreich einer Variablen beginnt mit der Deklaration und besteht nur innerhalb des Blocks, in dem sie deklariert wurde.

Weißt du noch, was ein Block ist? Das ist ein Bereich innerhalb des Programmcodes, der von geschweiften Klammern {} begrenzt wird. Denke dabei beispielsweise an den Block einer Schleife oder an den einer Funktion.

// listing1: scope block

{
  int anInt;
}

Außerhalb des Blocks ist die Variable nicht gültig. Dort ist sie für den Compiler eine nicht identifizierbare Entität und er erkennt sie nicht. Das ist die Sichtbarkeit (Visibility) der Variable.

Wir haben schon gesehen, dass Blöcke ineinander verschachtelt sein können. Eine deklarierte Variable ist auch für alle inneren Blöcke gültig. Doch umgekehrt geht das nicht. Denn die Gültigkeit der Variablen endet mit der schließenden geschweiften Klammer } des Blocks.

listing2 und listing3 zeigen die Sicht des Compilers auf eine Variable aus verschiedenen Bereichen.

// listing2: in scope

#include <iostream>

int main()
{
  {
    int anInt = 7;

    std::cout << "I can see anInt(" << anInt << ")" << std::endl;

    {
      std::cout << "I can still see anInt(" << anInt << ")" << std::endl;  
    }
  }

  return 0;
}
# Output

I can see anInt(7)
I can still see anInt(7)



// listing3: not in scope

#include <iostream>

int main()
{
  {
    int anInt = 7;
  }
  
  // will not compile; anInt is out of scope
  std::cout << "I can not see anInt(" << anInt << ")" << std::endl;

  return 0;
}
# Output

In function 'int main()':
error: 'anInt' was not declared in this scope
std::cout << "I can not see anInt(" << anInt << ")" << std::endl;

Lokale und globale Variablen

Die Variable anInt in den Beispielen ist eine Lokale Variable. Sie existiert nur in ihrem Gültigkeitsbereich und ist auch nur dort sichtbar. Außerhalb wird sie nicht gesehen. Anders verhält es sich bei Verschachtelungen. Der innere Block kann die lokalen Variablen seines umschließenden Blocks sehen (siehe listing2).

Doch Vorsicht! Der Name der Variable spielt bei der Sichtbarkeit eine entscheidende Rolle. Diese kann durch die Deklaration einer neuen Variable gleichen Namens im inneren Block eingeschränkt werden. Dann ist die äußere Variable unsichtbar.

Der Grund dafür liegt an der Art und Weise, wie lokale Variablen im Speicher verarbeitet werden. Beim Betreten eines Gültigkeitsbereichs werden alle lokalen Daten auf einem besonderen Speicherbereich, den sogenannten Stack, angelegt. Dieser arbeitet nach dem last in- first out Prinzip. Das bedeutet, die zuletzt auf den Stack abgelegten Daten werden zuerst abgerufen. Beim Verlassen des Gültigkeitsbereichs werden alle lokalen Daten auf dem Stack wieder freigegeben. Somit existiert eine lokale Variable nur innerhalb seines Blocks.

Das bringt den Vorteil, dass wir uns nicht um die Speicherverwaltung bei Blockbeginn und Blockende kümmern müssen. Führt aber die Eigenschaft mit sich, dass Variablen in verschiedenen Gültigkeitsbereichen mit gleichem Namen deklariert werden können und nur dort sichtbar sind. Hat ein eingenesteter Block und sein umschließender Block den Namen verwendet, ist für jeden nur seine eigene Variable gültig. Das siehst du gut in listing4.

// listing4: variable name in scope

#include <iostream>

int main()
{
  int anInt = 3;

  {
    std::cout << "I can see anInt(" << anInt << ")" << std::endl;

    int anInt = 7;

    std::cout << "I can still see anInt(" << anInt << "), right?" << std::endl;
  }

  std::cout << "I am sure anInt(" << anInt << ") is still the same" << std::endl;

  return 0;
}
# Output

I can see anInt(3)
I can still see anInt(7), right?
I am sure anInt(3) is still the same

Jetzt weißt du, worauf du bei deinen lokalen Variablen achten musst, damit sie in dem aktuellen Block auch sichtbar sind. Hast du allerdings einen Wert, den du gerne in verschiedenen Gültigkeitsbereichen immer wieder verwenden möchtest, wäre es doch einfacher, wenn die Variable von überall zu sehen ist. Also nicht nur lokal, sondern global sichtbar.

Dazu gibt es in C++ die Möglichkeit, Globale Variablen anzulegen. Diese müssen von dir außerhalb deiner main Funktion deklariert werden und können anschließend von überall gesehen werden.

// listing5: global variable

#include <iostream>

int globalInt = 3;

int main()
{
  {
    int localInt = 5;

    std::cout << "I can see globalInt(" << globalInt << ")" << std::endl;
    std::cout << "I can see localInt("  << localInt  << ")" << std::endl;
 
    int globalInt = 7;

    std::cout << "I can still see globalInt(" << globalInt << "), right?" << std::endl;
  }
  
  std::cout << "I can see globalInt(" << globalInt << ")" << std::endl;
  // std::cout << "I can not see localInt("  << localInt  << ")" << std::endl;
  
  return 0;
}
# Output

I can see globalInt(3)
I can see localInt(5)
I can still see globalInt(7), right?
I can see globalInt(3)

Globale Variablen können also von jedem Ort aus gelesen und ihnen Werte zugewiesen werden.

Sehr praktisch. Warum sollte ich sie nicht häufiger verwenden? Spart doch Zeit, kann alle Variablen gebündelt an einem Ort anlegen und es geht beim Verlassen von Blöcken keine Information mehr verloren.

Klingt im ersten Moment verlockend und wirst du in älterem Code auch manchmal sehen, doch Globalität hat auch seine Nachteile: Variablen können von überall für jeden geändert werden. Du bist also nie absolut sicher, dass die globale Variable den erwarteten Wert trägt, oder ob sie unwissentlich an einer anderen Stelle verändert wurde. Somit haben globale Variablen eine Unvorhersehbarkeit, und tragen ein gewisses Risiko, welches bei größeren Projekten, mit mehreren Programmieren oder Multithreading (parallele Ausführung von Funktionen) steigt.

Mehr sehen dank Namespaces

Auch unterliegen globale Variablen dem Stack und sie werden unsichtbar, wenn eine lokale Variable denselben Namen verwendet. Dem kannst du allerdings mit Namespaces entgegenwirken.

Kennengelernt haben wir schon den Namensraum std aus der C++-Standardbibliothek. Jetzt erstellen wir unseren eigenen Namespace.

Ein Namespace kann nur außerhalb deiner main Funktion definiert werden. Unter Verwendung des Schlüsselworts namespace mit darauffolgendem Namespace Specifier kannst du einen Block erstellen {} in den alle Variablen und Funktionen hineinkommen, die den Specifier tragen sollen.

In listing6 siehst du sehr schön, wie die Sichtbarkeit von globalInt erhöht ist. Vergleiche es ruhig mit listing5.

// listing6: namespace variable

#include <iostream>

namespace myNamespace{
  int globalInt = 5;
}

int main()
{
  {
    int localInt = 5;

    std::cout << "I can see globalInt(" << myNamespace::globalInt << ")" << std::endl;
    std::cout << "I can see localInt("  << localInt  << ")" << std::endl;

    int globalInt = 7;

    std::cout << "And I can see another globalInt(" << globalInt << "), declared in this scope" << std::endl;
    std::cout << "Meanwhile, I can still see globalInt(" << myNamespace::globalInt << ") with its namespace" << std::endl;
  }

  return 0;
}
# Output

I can see globalInt(5)
I can see localInt(5)
And I can see another globalInt(7), declared in this scope
Meanwhile, I can still see globalInt(5) with its namespace

Besonders interessant werden Namespaces, wenn du deinen Code anderen zur Verfügung stellst. Da passiert es schnell, dass Namen mehrfach vorkommen, doch dein Code bleibt, dank des Namensraums, in jedem Scope stets sichtbar.

Funktionen, Referenzen, Pointer und ihr Scope

Nun ist es an der Zeit, dein Wissen über Funktionen, Referenzen und Pointer auszupacken. Wir schauen uns jetzt den Gültigkeitsbereich bei Funktionen genauer an.

Eine Funktion hat einen eigenen Scope, begrenzt von geschweiften Klammern {}, besitzt lokale Variablen und hat Zugriff auf globale Variablen. So weit nichts Neues. Hinzukommen die Argumente aus der Signatur der Funktion, die abhängig von ihrer Form ein anderes Verhalten haben.

// listing7: function scope

#include <iostream>

int globalInt = 3;

int myFunction(int arg)
{
  int localInt = 5;
  int localSum = localInt + globalInt + arg;

  return localSum;
}

int main()
{
  int value = 5;
  std::cout << "Function returns: " << myFunction(value) << std::endl;
  
  return 0;
}
# Output

Function returns: 13

Die Funktion myFunction in listing7 hat insgesamt die drei lokalen Variablen localInt, localSum und arg. Das Besondere an dem Argument arg ist, dass es eine lokale Kopie des übergebenen Wertes ist. Umgekehrt verhält es sich mit localSum. Die return Anweisung macht eine Kopie der lokalen Variablen und stellt diese als Rückgabewert bereit. So können Werte in eine Funktion gegeben und von dort erhalten werden. Der Gültigkeitsbereich ist, wie wir es erwarten.

Sind die Argumente allerdings Referenzen oder Pointer, ist plötzlich nicht mehr so einfach zu erkennen, wer alles die Variable sehen kann. Lass uns drei Funktionen definieren. Eine bekommt das Argument als Kopie, die zweite bekommt das Argument als Referenz und in der dritten Funktion ist das Argument ein Pointer (siehe listing 8).

// listing8: different types of function arguments

#include <iostream>

// function argument as copy
int myFunction(int argument)
{
  // cannot see local variable of main function
  // std::cout << "Value of argument in main: << value;
  argument = 7; // can only see local copy of it, passed as argument

  std::cout << "Value of argument in my function: "
            << argument << std::endl;
  std::cout << "Address of argument in my function: "
            << &argument << std::endl;

  int localInt = 5;
  int localSum = localInt + argument;

  return localSum;
}

// function argument as reference
int myFunctionReference(int &argument)
{
  // cannot see local variable of main function
  // std::cout << "Value of argument in main: << value;
  argument = 7; // can only see the reference to it, passed as argument
  // But I can change the value of it as I have access to the memory address

  std::cout << "Value of argument in my function as reference: "
            << argument << std::endl;
  std::cout << "Address of argument in my function as reference: "
            << &argument << std::endl;

  int localInt = 5;
  int localSum = localInt + argument;

  return localSum;
}

// function argument as pointer
int myFunctionPointer(int *argument)
{
  // cannot see local variable of main function
  // std::cout << "Value of argument in main: << value;
  *argument = 5; // can only see the a pointer to it, passed as argument
  // But I can change the value of it as I have access to the memory address

  std::cout << "Value of argument in my function as pointer: "
            << *argument << std::endl;
  std::cout << "Address of argument in my function as pointer: "
            << argument << std::endl;

  int localInt = 5;
  int localSum = localInt + *argument;

  return localSum;
}

int main()
{
  int value = 5;

  std::cout << "Value of argument in main: " << value << std::endl;
  std::cout << "Address of argument in main: "<< &value << std::endl;

  int returnValue = myFunction(value);
  std::cout << "My function returns " << returnValue << std::endl << std::endl;

  std::cout << "Value of argument in main: " << value << std::endl;
  std::cout << "Address of argument in main: "<< &value << std::endl;

  returnValue = myFunctionReference(value);
  std::cout << "My function returns " << returnValue << std::endl << std::endl;

  std::cout << "Value of argument in main: " << value << std::endl;
  std::cout << "Address of argument in main: "<< &value << std::endl;

  returnValue = myFunctionPointer(&value);
  std::cout << "My function returns " << returnValue << std::endl << std::endl;
  std::cout << "Value of argument in main: " << value << std::endl;

  return 0;
}
# Output

Value of argument in main: 5
Address of argument in main: 0x7fff93758140
Value of argument in my function: 7
Address of argument in my function: 0x7fff9375811c
My function returns 12

Value of argument in main: 5
Address of argument in main: 0x7fff93758140
Value of argument in my function as reference: 7
Address of argument in my function as reference: 0x7fff93758140
My function returns 12

Value of argument in main: 7
Address of argument in main: 0x7fff93758140
Value of argument in my function as pointer: 5
Address of argument in my function as pointer: 0x7fff93758140
My function returns 10

Value of argument in main: 5

Du siehst, dass keine der drei Funktionen die lokale Variable value aus dem Gültigkeitsbereich der main Funktion sehen kann. Jedoch können Referenzen und Pointer auf ihre Speicheradresse zugreifen und den Wert trotzdem verändern. Möchtest du das nicht, hast du die Möglichkeit, mit const &argument die Referenz unveränderlich zu machen. Bei Pointern kannst du das Schreiben in den Speicher nicht verhindern.

Das bleibt hängen

Diesmal haben wir uns den Gültigskeitsbereich (Scope) und die Lebenszeit von Variablen genauer angeschaut. Damit wir in jeder Situation die Sichtbarkeit und den Zugriff auf unsere Daten richtig bestimmen können, haben wir verschiedene Arten von Geltungsbereichen und ihre Regeln kennengelernt.

Mit der Deklaration führen wir eine Variable in ihren Geltungsbereich ein. Eine Lokale Variable wird innerhalb eines Blocks oder einer Funktion deklariert. So erstreckt sich ihr Geltungsbereich von da an bis zum Ende des Blocks. Einen Block erkennst du an dem Paar geschweifter Klammern {}, die ihn umschließen. Die Argumente einer Funktion verhalten sich ebenfalls wie lokale Variablen.

Außerhalb der main Funktion deklarierte Variablen sind von überall sichtbar und werden Globale Variablen genannt. Da sie von jedem Ort geändert werden können, sind wir nie ganz sicher über ihren Zustand. Und somit steigern sie das Fehlerrisiko in unserem Code.

Ein Name kann eindeutig identifiziert werden, wenn er in einem namespace {} außerhalb von deinen Blöcken definiert ist. Sein Geltungsbereich erstreckt sich zwar auch vom Punkt der Deklaration bis zum Ende seines Blocks, kann aber unter Verwendung des Namespace Specifier als Prefix myNamespace::myVar auch an anderer Stelle verwendet werden.

Referenzen und Pointer erhöhen zwar nicht die Sichtbarkeit deiner Variablen, können aber auf die Speicheradresse zugreifen und so trotzdem den Wert verändern.

Generell kannst du dir für alle Variablen merken, dass sie initialisiert und alle Funktionen definiert werden müssen, bevor du sie verwenden kannst. Und dass sie am Ende des Gültigskeitsbereichs wieder zerstört werden. Bei globalen Objekten ist der Zeitpunkt der Zerstörung das Ende des Programms. Pointer Variablen, die mit new erzeugt wurden, leben allerdings, bis du sie mit delete eingehändig zerstörst.

Du siehst, es gibt einiges, worauf du beim Erzeugen deiner Variablen und der Verwendung deiner Funktionen achten musst, damit sie dir auch genau dann zur Verfügung stehen, wenn du sie benötigst.

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