Programmieren Teil 3 – Variablen

Im letzten Artikel ging es um eins der primären Konzepte beim Programmieren, nämlich die Funktionen. Im aktuellen Artikel wollen wir uns mit einem zweiten fundamentalen Konzept beschäftigen: den Variablen.

In einem früheren Artikel gab es schon einmal eine abstraktere Einführung zu den Variablen, heute wollen wir uns etwas konkreter damit beschäftigen. Fangen wir zuerst wieder mit etwas technischem Hintergrund an!

Wie bereits im letzten Artikel geschrieben, befindet sich ein Programm während der Ausführung im Programmspeicher des Rechners, das heißt, die einzelnen Anweisungen sind in einzelne Speicherzellen geschrieben. Daneben benötigt ein Programm aber natürlich auch immer noch die Möglichkeit, Daten in irgendeiner Form verwalten zu können. Zu diesem Zweck gibt es noch den Datenspeicher; dieser ist wie der Programmspeicher aus einzelnen Zellen aufgebaut, die beliebige Inhalte (also: Zahlen) aufnehmen können und ebenfalls durch eine Adresse identifiziert werden.1 Diese Speicherzellen werden durch entsprechende Anweisungen modifiziert – sie repräsentieren (zusammen mit einigen anderen Informationen) den Programmzustand. Die Daten im Speicher sind wesentlicher Bestandteil der Programmabarbeitung, da sie einerseits den Programmablauf beeinflussen (wir werden noch sehen, wie) und andererseits natürlich die berechneten Ergebnisse des Programms repräsentieren.

1 Um Verwirrung zu vermeiden: Programmspeicher und Datenspeicher liegen – je nach Architektur des Rechnersystems – zusammen im Arbeitsspeicher (so ist das bei handelsüblichen Rechnern) oder können echt physikalisch getrennte Speicher sein. Wenn sie zusammen im Arbeitsspeicher liegen, sind es oft einfach zwei getrennte, durch das Betriebssystem vergebene Speicherbereiche.

Prinzipiell sind 2 verschiedene Datenspeicher zu unterscheiden: der Stack und der Heap. Mit Stack wird neben dem Datenspeicher auch eine bestimmte Datenstruktur beschrieben, bei der die Daten gestapelt (engl: to stack) werden. Konkret werden beim Stack-Speicher neue Daten “oben aufgelegt”; um ältere, tiefer gelegene Daten zu entfernen, müssen immer zuerst die weiter oben befindlichen entfernt werden (den Vorteil dieses Vorgehens erkläre ich später). Demgegenüber können Daten im Heap an (fast) beliebiger Stelle hinzugefügt und wieder entfernt werden.2 Beide Datenspeicher-Arten kommen in modernen Programmen vor und werden uns auch immer wieder begegnen; im Detail werden wir uns aber erst in den nächsten Artikeln damit beschäftigen.

2 Ebenso wie Programm- und Datenspeicher sind Stack und Heap in der Regel lediglich 2 getrennte Bereiche im gleichen Arbeitsspeicher.

Nun ist natürlich die große Frage: mit was für Anweisungen lassen sich die Speicherzellen der Daten modifizieren, sprich auslesen und beschreiben. Natürlich könnte man ein Programm direkt anweisen, in eine Speicherzelle mit einer bestimmten Adresse einen Wert zu schreiben oder daraus einen Wert zu lesen; das würde aber erstens zu ziemlich unleserlichen Programmen führen und zweitens mit der Arbeitsweise moderner Betriebssysteme fatal kollidieren. Wir benötigen also eine andere Möglichkeit, Daten anzusprechen.

Der Grundmechanismus hierfür sind die titelgebenden Variablen (ein Hinweis vorab, für mitlesende Mathematiker: Variablen sind in der Informatik prinzipiell denen der Mathematik ähnlich, unterscheiden sich aber in einigen fundamentalen Punkten, die im Folgenden klar werden sollten). Im Grunde ist eine Variable erst einmal nichts anderes als der Verweis auf eine Speicherzelle, versteckt hinter einem schönen Namen. Der Vorteil ist, dass erstens das Programm weitaus lesbarer wird, wenn man Namen statt Speicheradresse verwendet, und dass zweitens der Compiler beim Kompilieren eines Programms dafür sorgen kann, dass die Variablen mit vernünftigen Speicheradressen assoziiert werden (wobei hier keine festen, “absoluten”, sondern dynamische, “relative”, Adressen vergeben werden – mehr dazu vielleicht später). Diese Variablen können nun benutzt werden, um passende Werte in den Speicher zu schreiben.

Nehmen wir einmal an, wir hätten zwei Variablen x und y, welche natürliche Zahlen repräsentieren. Um den Wert 42 in die durch x repräsentierte Speicherzelle zu schreiben, würde man in C++ einfach die folgende Anweisung an passender Stelle einfügen – man spricht von einer Zuweisung:

x = 42;

Zum Lesen des Wertes von x reicht die Verwendung des Namens, etwa so (y speichert danach das Quadrat von x):

y = x * x;

Nehmen wir an, dass x und y zwei aufeinanderfolgende Speicherzellen an den Adressen 123456 und 123457 bezeichnen; beim Ausführen der Anweisungen passiert im Speicher das Folgende:

Vorher x = 42; y = x * x;
Adresse Inhalt
123456
123457
123458
Adresse Inhalt
123456 42
123457
123458
Adresse Inhalt
123456 42
123457 1764
123458

Wir sehen – keine große Magie! Variablen sind also lediglich Platzhalter für Adressen im Speicher, die unser Programm besser lesbar machen. Im Gegensatz zur Mathematik ist übrigens auch das folgende in der Programmierung erlaubt:

x = 42; y = x * x; x = 43; y = x + x;

x bekommt hier nacheinander 2 verschiedene Werte zugewiesen (in der Mathematik ist das natürlich unsinnig – hier liegt der große Unterschied im Verhalten von Variablen zur Informatik), die dann entsprechend für die Berechnung von y verwendet werden; dadurch spielt natürlich auch die Reihenfolge der Ausführung der Anweisungen eine Rolle. Im Speicher passiert das Folgende:

x = 42; y = x * x; x = 43; y = x + x;
Addr. Inhalt
123456 42
123457
123458
Addr. Inhalt
123456 42
123457 1764
123458
Addr. Inhalt
123456 43
123457 1764
123458
Addr. Inhalt
123456 43
123457 86
123458

Die Programmiersprache C++ bringt es mit sich, dass Variablen vor ihrer Verwendung immer deklariert werden müssen. Deklaration heißt, dass dem Compiler mitgeteilt wird, dass es von nun an eine Variable mit dem deklarierten Namen (dem Variablennamen) gibt; zusätzlich muss in C++ (im Gegensatz zu einigen anderen Sprachen) auch immer der Wertebereich der Variablen, der sogenannte Typ bzw. Datentyp, spezifiziert werden. Das hat den Vorteil, dass der Compiler gleich prüfen kann, ob man nur gültige Operationen ausführt – eine Zahl mit einer Zeichenkette zu addieren ist schließlich nicht sonderlich sinnvoll. Außerdem kann der Compiler über den Datentyp die passenden Speicheroperationen generieren, da es auf Prozessorebene ein Unterschied ist, ob man etwa eine natürliche Zahl oder eine reelle Zahl in den Speicher schreiben möchte.

Die Deklaration einer Variablen gestaltet sich in C++ etwa so; hier wird eine Variable x vom Typ int – das sind die ganzen Zahlen – deklariert:

int x;

Erst nachdem eine Variable deklariert wurde, kann sie auch benutzt (sprich gelesen und geschrieben) werden; was aber funktioniert, ist die kombinierte Deklaration und erste Zuweisung. Das sieht dann so aus:

int x = 42;

Bei der Zuweisung muss beachtet werden, dass nur Werte passender Typen einander zugewiesen werden, also etwa eine natürliche Zahl an eine Variable vom Typ der natürlichen Zahlen oder eine Zeichenkette an eine Variable vom Typ der Zeichenketten. Passend heißt hier zuweisungskompatibel; natürliche, ganze und reelle Zahlen sind etwa zuweisungskompatibel, natürliche und komplexe Zahlen natürlich nicht. Zu diesem Thema aber später noch etwas mehr. Auf der rechten Seite einer Zuweisung kann dabei ein beliebiger Ausdruck stehen, der ein Ergebnis vom passenden Datentyp liefert; nicht nur konstante Zahlen (etwa 42) oder simple Berechnungen (x * x) sind möglich, sondern auch komplexe Ausdrücke und Funktionsaufrufe – dazu gleich etwas.

Noch ein Fakt über Variablen, bevor wir uns ein kurzes Beispiel in konkretem Code ansehen. Jede Variable hat einen sogenannten Gültigkeitsbereich oder Scope. Dieser spezifiziert, in welchen Programmteilen eine Variable “bekannt” ist und damit verwendet werden kann. Für die meisten Variablen gilt, dass sie in dem Scope gültig sind, in welchem sie auch deklariert wurden. Typische Gültigkeitsbereiche sind etwa der globale Scope (das heißt, in der gesamten Datei – man spricht dann von globalen Variablen) oder innerhalb einer Funktion (das wären dann lokale Variablen); auch hier gilt: eine Variable kann in C++ erst benutzt werden, nachdem sie deklariert wurde. In jedem Scope kann auf der gleichen Scope-Ebene jeweils nur eine Variable mit einem bestimmten Namen deklariert werden; zwei globale Variablen x in der gleichen Datei sind etwa ungültig.

Eine letzte Information noch vor dem Beispiel, die zum Verständnis wichtig ist, mit Variablen aber nur teilweise etwas zu tun hat: die im letzten Artikel vorgestellte Funktion printf akzeptiert neben dem ersten Argument, welches die auszugebende Zeichenkette beschreibt, noch weitere Argumente. Diese enthalten Daten, die zusammen mit der Zeichenkette ausgegeben werden sollen, wobei die Zeichenkette die Informationen enthält, wie die Daten auszugeben sind; man spricht von der Formatierung der Daten (das f in printf steht für formatted). Über kurze Sonderzeichen in der Zeichenkette kann angegeben werden, in welchem Format die zusätzlichen Daten ausgegeben werden sollen; das Zeichen %i steht dabei etwa für ganze Zahlen. Die Reihenfolge der Formatierungs-Informationen entspricht dabei der Reihenfolge der übergebenen Argumente. Die folgende Anweisung gibt dementsprechend die Zeichenkette 1 + 2 = 3 auf dem Bildschirm aus (gern auch einmal im Programm vom letzten mal ausprobieren):

printf( "%i + %i = %i", 1, 2, 3 );

Nun aber genug der Theorie, hier kommt endlich ein kleines Beispiel.

#include <cstdio> int main() { int x = 42; int y = 2; int z = x + y; printf( "%i + %i = %i\n", x, y, z ); }

Zuerst werden 3 Variablen x, y und z deklariert und ihnen gleich Werte zugewiesen. Diese Werte werden anschließend genutzt, um eine passende Bildschirmausgabe – nämlich 42 + 2 = 44, zu generieren.

Das Beispiel ist natürlich denkbar simpel und man mag sich zurecht fragen, warum hier mit Variablen hantiert werden soll – man könnte schließlich auch die Werte direkt benutzen. Etwas anschaulicher wird es vielleicht mit diesem Beispiel:

#include <cstdio> int x = 42; int main() { printf( "%i * %i = %i\n", x, x, x * x ); }

Anstatt hier die Zahl 42 mehrmals in dem printf-Aufruf angeben zu müssen, reicht es, sie an einer Stelle zu notieren (in diesem Fall übrigens im globalen Scope – die Variable x wäre im gesamten weiteren Programm gültig) und dann mehrfach wiederzuverwenden. Das ist dann praktisch, wenn sie später einmal geändert werden soll, da sie lediglich an einer Stelle geändert werden muss. Noch verständlicher wird ihr Nutzen, wenn wir die im letzten Artikel erwähnte Möglichkeit berücksichtigen, dass Funktionen Werte als Ergebnis zurückliefern können. Schauen wir uns das folgende Beispiel an:

#include <cstdio> int f() { return 42; } int main() { int x = f(); printf( "%i * %i = %i\n", x, x, x * x ); }

Wir sehen hier eine Funktion f, welche den Werte 42 als Ergebnis zurückliefert. 42 ist hier natürlich nur ein Platzhalter für eine beliebig komplexe Berechnung. Das Ergebnis der Berechnung wird in der Funktion main in der Variablen x zwischengespeichert und dann für den printf-Aufruf in bekannter Manier wiederverwendet. Je komplexer eine Funktion wird, desto größer wird auch der Nutzen der Variablen, da sich ein Programm mit ihnen weitaus übersichtlicher gestalten lässt; zudem lassen sich Werte in Variablen zwischenspeichern, so dass eine erneute (unter Umständen aufwändige) Neuberechnung für jede Verwendung ausbleiben kann.

Abschließend noch 2 wichtige Bemerkungen zu Variablen.

Der globale Scope sollte nach Möglichkeit so selten wie möglich genutzt werden; für die meisten Variablen benötigt man ihn nicht und jede globale Variable macht ein Programm schwerer wartbar, da man die Übersicht über sie behalten muss. Ein übliches Problem mit globalen Variablen sind die Namenskonflikte. Zwar kann auf einer Scope-Ebene jeder Variablenname nur einmal vorkommen, aber in tiefer geschachtelten Scopes können die Namen mehrfach auftreten. Geschachtelte Scopes sind Scopes, die in einen anderen eingebettet sind; der Funktions-Scope ist etwa in den globalen Scope eingebettet. Damit kann sowohl eine globale als auch eine lokale Variable x in einer Funktion existieren, wobei immer die “lokalere” Variable Vorrang hat und die globalere überschreibt bzw. verdeckt. Bei längeren Funktionen kann es da durchaus einmal passieren, dass eine lokale Variable unabsichtlich eine globale verdeckt und das Programm damit ein unerwartetes Verhalten zeigt. Zudem gilt beim Programmieren weitgehend (nicht immer – dazu später auch noch etwas) der Grundsatz “Deklarationen so lokal wie möglich”, einfach, um die Übersichtlichkeit des Programmcodes zu erhalten. Wenn Variablen nur in der Nähe der Stellen deklariert werden, wo sie auch benutzt werden, lässt sich ein Programm viel leichter lesen, als wenn sämtliche Variablen global gehalten werden. Zudem lassen sich so Probleme bei der Rekursion vermeiden (dazu später mehr). Also, ganz wichtig: so wenige globale Variablen wie möglich (manchmal benötigt man welche, deswegen gibt es sie; das soll hier aber erst einmal nicht interessieren)!

Die zweite Anmerkung betrifft ebenfalls das Scoping von Variablen, und zwar eine Eigenschaft, die insbesondere bei Programmieranfängern häufig zu Verwirrung führt. Variablen unterschiedlicher Scopes haben nichts miteinander zu tun, auch wenn sie den gleichen Namen tragen. Ein Beispiel:

#include <cstdio> void f() { int x = 43; } int main() { int x = 42; f(); printf( "%i\n", x ); }

Dieses Programm wird natürlich die Zahl 42 auf dem Bildschirm ausgeben; zwar wird zwischen der Deklaration und Zuweisung von x = 42 und deren Verwendung in printf die Funktion f aufgerufen, in welcher ebenfalls eine Variable x existiert, welcher der Wert 43 zugewiesen wird, aber diese zweite Variable x hat nichts mit der ersten zu tun und bezeichnet einen vollkommen unabhängigen Speicherbereich. Diesen Punkt gilt es unbedingt zu beachten.

Damit wäre die Einführung zu den Variablen abgeschlossen; im nächsten Artikel wollen wir Funktionen und Variablen kombinieren und uns mit den Funktionsparametern beschäftigen. Außerdem sollen dem Stack noch ein paar Worte gewidmet werden.

Kommentare

  1. #1 Andreas
    April 16, 2013

    Sehr schön. Als Info-Dozent freue ich mich immer über die Möglichkeit, die eigene “Vermittlung” der Info-Inhalte vergleichen zu können. Deine Reihe hier ist wunderbar leicht zugänglich. Darf man Sie mit Quellenhinweis verlinken oder eventuell auch im Skript einbauen ?

  2. #2 Marcus Frenkel
    April 16, 2013

    @Andreas

    Klar, kein Problem. So lange die Quellen mit angegeben werden, kann das gern genutzt werden. Ich würde mich freuen, wenn mir noch gesagt wird, wo es genutzt wird, einfach damit ich weiß, wer so alles Interesse an den Inhalten hat und wo sie genutzt werden. :)

  3. #3 Doomtrain
    April 16, 2013

    Hm, ich glaub ich hab nen Fehler gefunden.
    Weiter oben steht, der Datenspeicher wird in Heap uns Stack unterteilt. Ich persöhnlich habe mal gelernt, dass sich die Daten im Buffer befinden und Heap die Bezeichnung für den Programmspeicher ist (daher auch der Bufferoverflow, Buffer läuft über, die Daten landen im Heap -> Supergau). Zudem würde ich den Stack auch eher dem Programmspeicher zuordnen.

    Ich kann mich natürlich auch irren, bin kein “echter” Informatiker.

  4. #4 Marcus Frenkel
    April 16, 2013

    @Doomtrain
    Heap und Stack sind beides Datenspeicher, da in beiden Speicherbereichen neue Speicherzellen reserviert und wieder freigegeben werden können (wobei sich nur der Mechanismus dafür in beiden unterscheidet). Der Programmspeicher ist (in der Regel) fest und ändert sich nicht während der Programmausführung.
    Mit Buffer ist im Allgemeinen nur ein zusammenhängender, eventuell erweiterbarer Speicherbereich gemeint; in der Regel sind Buffer im Heap reserviert (wenn man von der Datenstruktur des Buffers redet), können aber auch Stack-Bereiche bezeichnen.

  5. #5 Regina
    April 17, 2013

    42=Douglas Adams, die schönste aller Zahlen ;-)

  6. #6 Dr. Webbaer
    April 20, 2013

    Kleine Anmerkung noch hierzu:

    Der globale Scope sollte nach Möglichkeit so selten wie möglich genutzt werden; für die meisten Variablen benötigt man ihn nicht und jede globale Variable macht ein Programm schwerer wartbar, da man die Übersicht über sie behalten muss. Ein übliches Problem mit globalen Variablen sind die Namenskonflikte.

    Hat schon mal wer mit aus Quellcode-Sicht großen Programmen gearbeitet, die ausschließlich mit globalen Variablen arbeiten? – Das ist gar nicht so-o schlecht.

    MFG
    Dr. W (der allerdings nicht so weit gehen würde per GoTo adressierte Programmblöcke (die vielleicht noch in sogenannten Copy-Dateien liegen) Funktionen vorzuziehen ;-) )

  7. #7 Stefan W.
    http://demystifikation.wordpress.com
    April 24, 2013

    Ein übliches Problem mit globalen Variablen sind die Namenskonflikte.

    Das ist auch ein Problem, aber das kleinere. Das Problem an globalen Variablen ist, dass man nicht verfolgen kann, wer sie wann zuletzt geändert hat. Bei nebenläufigen Programmen kann jederzeit jemand anderes dazwischengefunkt haben, und der Wert hat sich seit dem letzten Zugriff, den man verfolgen kann, geändert.

    Nebenläufige Programme, bei denen zwei Instanzen von etwas die gleiche Variable manipulieren – der Horror.

    Das ist nicht nur so-o schlecht, das ist noch viel schlechter.

  8. #8 Dr. Webbaer
    April 25, 2013
    Ein übliches Problem mit globalen Variablen sind die Namenskonflikte.

    Das ist auch ein Problem, aber das kleinere. Das Problem an globalen Variablen ist, dass man nicht verfolgen kann, wer sie wann zuletzt geändert hat.

    Ischt eine Frage der Organisation, des Divide et Impera, der Verteilung der Logik. Man kann so oder so versuchen glücklich zu werden.
    Probleme der beschriebenen Art kann es immer geben.

    Ischt ein wenig so wie mit dem Automechaniker, der in einem Raum nur unter ganz bestimmten Bedingungen zwei Motoren gleichzeitig zerlegen würde, der zudem mehrere Taschen für seine Werkzeuge hat.

    HTH
    Dr. W