Zusammengesetzte Wahrheiten
Spätestens beim „char“ Datentyp, der ja „nur“ ein Zeichen speichern kann, sollte jedem klar geworden sein, dass wir mit den primitiven Datentypen auf Dauer nicht auskommen werden. Wir brauchen erweiterte Strukturen, um beispielsweise Text abspeichern zu können. Auch größere Zahlenkolonnen müssen wir in einer Art Listen ablegen können, damit eine einfache Verarbeitung ermöglicht wird. Man muss also in der Lage sein, mehrere primitive Informationsträger zu einer neuen Einheit zusammenzufassen. Gehen wir mal davon aus, dass wir die Arbeitsstunden einer Woche in einem Computer ablegen wollen. Eine – wenn auch keine sehr elegante – Möglichkeit wäre, einfach die Stunden pro Tag in einer eigenen Variablen abzulegen:
Listing 1: Einzelbelegung von mehreren Zahlenwerten
Das funktioniert zwar, allerdings würde nun die Aufsummierung dieser Zahlen recht umständlich sein:
Listing 2: Aufsummierung von Einzelwerten
Wenn wir das Beispiel nun von Wochentagen auf Monatstage erweitern würden, wäre die Summierung äußerst unangenehm zu realisieren. Dieses Vorgehen können wir uns so vorstellen, dass wir fünf einzelne Zettel hätten, auf denen jeweils die Arbeitszeit dieses Tages stehen würde. Niemand mit Ordnungssinn würde auf die Idee kommen, seine Stunden auf solchen einzelnen Zetteln zu notieren, sondern stattdessen einen Zettel nehmen und dort eine Art Tabelle aufmalen:
Unstrukturierte vs. strukturierte Datensammlung
Abb.: 1: Unstrukturierte vs. strukturierte Datensammlung
Top
Arrays in C
Für Programme gilt das gleiche. Wir wollen viele zusammengehörige gleichartige Informationen auch in einem einzelnen Konstrukt abspeichern. Dadurch können wir sie auch einheitlich (beispielsweise mit Wiederholungsschleifen) verarbeiten. Hierfür nutzen wir sogenannte „Arrays“. Einem Array müssen wir nun zwei Dinge mitgeben. Erstens, welche Art von gleichartigen Daten wollen wir ablegen und zweitens, wie viele davon. Genau wie bei den primitiven Datentypen auch, müssen wir Arrays in einer Variablen ablegen. Sehen wir uns das Ganze mal im Code an. Da wir jetzt in speziellere Sphären der Computersprachen abtauchen, müssen wir auch etwas genauer die Unterschiede zwischen den Programmiersprachen beachten. Doch zuerst noch ein kleiner Hinweis – wir bewegen uns für die nächsten Erklärungen im Bereich der kompilierten, statisch typisierten(1) Programmiersprachen. Hierbei konzentriere ich mich jetzt auf die Programmiersprachen C (C++(2)), C# und Java. Skriptsprachen, welche meist dynamisch typisieren, zeigen bei genauerer Betrachtung ein anderes Verhalten bezüglich Variablen – doch davon später. Beginnen wir zuerst mit der reinen Deklaration eines int Arrays mit 5 möglichen Einträgen in der Sprache C:
(1) „Statisch typisiert“ bedeutet, dass beim Schreiben des Codes der Datentyp festgelegt wird.
(2) Da C++ „lediglich“ eine objektorientierte Erweiterung von C ist, verhält sich C++ bezüglich Arrays wie C.
Listing 3: Deklaration eines int Arrays in C
Die Variable zahlenArray beinhaltet jetzt das Array, welches wieder irgendwelche Zufallsdaten aus dem Speicher enthält. Wichtig ist bei den kompilierten Programmiersprachen, dass wenn die Größe (in unserem Fall 5) des Arrays einmal festgelegt wurde, diese nicht mehr verändert werden kann – man kann es nicht „dynamisch“ vergrößern oder verkleinern. Wir müssen also vor Erzeugung des Arrays wissen, was wir alles damit anstellen möchten. Das liegt daran, dass der Platz, den das Array belegt, reserviert – sprich freigehalten – werden muss. Wir werden gleich noch andere Konstrukte kennenlernen, welche dieses Manko umgehen – bspw. „Listen“. Sehen wir uns jetzt aber erstmal mal an, wie wir uns die Ablage eines Arrays im Speicher vorstellen können. Wie immer ist die Realität etwas komplizierter, aber wir wollen ja nur die wesentlichen Aspekte verstehen, die wir für unsere Programmiertätigkeiten benötigen. Bei einem Array von fünf int Werten wissen wir, wie viel Speicherplatz benötigt wird, nämlich 5 x 4 Byte. Im Speicher können wir es uns nun wie folgt vorstellen:
Speicherbedarf eines int[5] Arrays
Abb.: 2: Speicherbedarf eines int[5] Arrays
Ich habe in dem Bild den Speicher zweidimensional dargestellt, so dass die erste Hex-Stelle der Adresse als Zeile und die zweite als Spalte auftritt (siehe beispielhaft die Adresse 0x13). In den Zellen stehen die (zufälligen) Werte der Speicherzellen. Wenn nun ein Array 5 int Werte a 4 Byte benötigt, so liegt das Array in einem zusammenhängenden Speicherbereich. Weiterhin können wir auch die Adresse des Arrays festhalten. Das ist die erste Zelle des vom Array belegten Bereichs – in unserem Fall die 0x74. Das ist nun insofern praktisch, als dass sich der Rechner eigentlich nur drei Dinge merken muss: Wo ist die Startadresse, welcher Datentyp ist in einem Feld und wie viele Felder gibt es? In unserem Fall wäre dies Startadresse 0x74, Datentyp int und 5 Felder. Wie bei den „normalen“ Variablen auch, finden wir in den einzelnen Feldern des Arrays wieder die Werte, welche zufällig im Speicher vorhanden waren. Sehen wir uns mal an, wie wir auf Daten schreibend und lesend in einem Array zugreifen können:
Listing 4: Arraynutzung in C
Kurze Erklärung der Codezeilen: Bei der Initialisierung kann man in C dem gesamten Array auch gleich feste Werte zuordnen, damit wir keine Zufallswerte mehr vorfinden. Diese stehen immer in geschweiften Klammern. Der Zugriff auf ein einzelnes Element in Arrays erfolgt in eckigen Klammern – sowohl lesend als auch schreibend. i[3] = -4000; bedeutet, dass im Array auf Position 3 der Wert -4000 geschrieben wird. In den beiden printf Aufrufen greifen wir lesend auf das Array zu, um den Wert auf der Konsole auszugeben. i[0] ist dabei der erste Wert eines Arrays, wodurch i[4] der letzte ist – nicht wie viele anfangs fälschlicherweise meinen i[5]!
Die Konsolenausgabe dieses Codes lautet somit:
Soweit zu den Arraymerkmalen, wie sie nicht nur in C, sondern auch in vielen anderen Programmiersprachen vorzufinden sind. Wenn wir noch tiefer in C einsteigen erkennen wir aber, warum diese Programmiersprache für den Einstieg oftmals nicht die erste Wahl ist. Im Vergleich zu „moderneren“ im Sinne von „jüngeren“ Programmiersprachen, hat C bei der Umsetzung von Arrays ein paar Besonderheiten. C nennt man gerne „hardwarenah“, was bedeutet, wir haben einen relativ transparenten Zugriff auf die Ressourcen des Computers. Dies kann man sich zunutze machen, um den inneren Aufbau von Arrays in C näher zu untersuchen. Hierfür müssen wir aber noch einen kleinen „Schlenker“ über das Konzept der Zeiger (englisch „pointer“) machen. In C wird dieses Konzept sehr intensiv genutzt. In den neueren Programmiersprachen haben sie jedoch an Bedeutung verloren. Ich werde später noch etwas genauer auf Pointer und dem Gedanken von Adressen eingehen, hier genügt erstmal eine grobe Erklärung. Wenn wir uns die Abbildung 2 nochmal ansehen, dann erkennen wir für jede Speicherzelle erstmal zwei Informationen. Der Wert der Speicherzelle und die Adresse. Mit den Werten haben wir ja bereits Bekanntschaft gemacht – die Adresse blieb bis jetzt für uns verborgen. Mit einem einfachen Code in C können wir aber die Adresse einer Variablen herausfinden:
Listing 5: Zugriff auf Variablenadresse in C
Kurze Erklärung der Codezeilen: Wir deklarieren und initialisieren eine Variable zahlVariable. Anschließend erzeugen wir eine neue Variable, welche mit Hilfe des „*“ als „pointer“ deklariert wird. Dadurch sind wir in der Lage, „Adressarithmetik“ zu betreiben – sprich mit Hilfe von Rechenoperationen den Adresswert auf andere Adressen zeigen lassen, was wir im nächsten Codebeispiel (Listing 6)ausprobieren werden. Im Code oben schreiben in diese Pointervariable nun die Adresse der zahlVariable, was mit Hilfe des „&“ Operators erfolgt. Nun greifen wir auf die einzelnen Variablen zu. In der ersten Ausgabe schreiben wir einfach nur den Wert der zahlVariable auf die Konsole. In der zweiten Ausgabe schreiben wir die Adresse der Variablen, die wir ja in der Pointervarialben zahlAdresse gespeichert haben. Und in der letzten Ausgabe geben wir den Inhalt der Speicherzelle aus, auf die die Adresse von zahlAdresse zeigt.
Die Ausgabe sieht dann wie folgt aus:
Die Adresse der Variablen ist also 6.422.292, wobei dies bei mehrmaligem Ausführen der Software unterschiedlich sein kann – je nachdem welchen Speicherbereich der Computer für diese Variable „aussucht“. Wenn wir nun den Inhalt der Speicherzelle mit der Adresse 6.422.292 ausgeben, dann erhalten wir natürlich wieder den Wert 10. Dies ist übrigens auch einer der Gründe, warum wir bei der Erzeugung der Pointervariablen angeben müssen, auf was für einen Datentyp der Pointer zeigt, damit der Computer die Ausgabe richtig interpretieren kann – doch davon später mehr.
Nun wenden wir dieses Wissen auf unser Array an:
Listing 6: Zugriff auf Variablenadresse in C mit Adressänderung
Wenn wir den Code ausführen, gibt es zwar eine Warnung, die ignorieren wir aber einfach.
Kurze Erklärung der Codezeilen: Wir deklarieren und initialisieren wieder ein Array. Danach schreiben wir die Adresse des Arrays in den Pointer adr. Hier wird die Warnung erzeugt, da der Pointer für int ausgelegt ist und nicht für int[]. Da das genau das ist, was wir machen wollen, werden wir die Warnung ignorieren. Nun geben wir die Adresse und den Inhalt der adressierten Speicherzelle aus. Dies ist erwartungsgemäß der erste Wert im Array, nämlich die 1. Nun erhöhen wir den Adresswert um 1 mit dem ++ Operator. Interessanterweise erhöht sich der tatsächliche Adresswert um 4; was daran liegt, dass der Computer bei einem Zeiger auf int die 4 Bytes, welche ein int Wert belegt, bei Erhöhung um 1 auf die aktuelle Adresse hinzufügt. Nun geben wir den Inhalt dieser Speicherzelle aus (was wir bei Pointervariablen mit einem Vorangestellten “*“ machen müssen) und erhalten als Wert die 20, was der zweite Wert unseres Arrays ist.
Hier der Vollständigkeit halber nochmal die Ausgabe aus meinem Testlauf des Programms:
Top
Zweidimensionale Arrays in C
Wir sehen also, dass C die Arraydaten sequenziell im Speicher ablegt. Dies erklärt, warum der Zugriff auf Arrays sehr schnell vonstattengeht – die Adressarithmetik ist relativ unkompliziert. Arrays befinden sich in der Hierarchie der Programmierelemente relativ weit unten – sie werden neben den primitiven Datentypen auch als einer der Grundbausteine gesehen (auch wenn sie streng genommen eine Ebene höher liegen, da sie ja aus primitiven Datentypen zusammengesetzt sind). Unser Array aus dem vorigen Beispiel war jetzt eine Liste von 5 Werten. Was ist aber, wenn wir keine eindimensionale Liste benötigen, sondern eine Matrix – sprich eine zweidimensionale Liste? Es gibt viele Einsatzmöglichkeiten für solche Konstrukte. Gehen wir beispielsweise davon aus, wir wollen einem Grafikprogramm die Informationen für ein Rechteck mitgeben:
Rechteck im Pixel-Koordinatensystem
Abb.: 3: Rechteck im Pixel-Koordinatensystem
Die vier Punkte liegen auf 200/200, 600/200, 600/400 und 200/400 Pixel. Diese Informationen können wir sehr einfach in einer zweidimensionalen Liste ablegen:
Zweidimensionale Liste
Abb.: 4: Zweidimensionale Liste
Und genau solch eine zweidimensionale Liste würden wir in unseren Programmen auch in einem zweidimensionalen Array ablegen. Der zugehörige C Code hierzu würde wie folgt aussehen:
Listing 7: Zweidimensionales Array in C
Kurze Erklärung der Codezeilen: Das Array erzeugen wir diesmal mit der Angabe der Höhe und Breite des zweidimensionalen Feldes, jeweils in einer eigenen eckigen Klammer. Die Zeilenanzahl beträgt 4 und die Spaltenanzahl 2. Auch hier können wir gleich das ganze Array mit Werten belegen, diesmal müssen wir ein äußeres Klammernpaar für alle Zeilen vorsehen und pro Zeile je ein inneres Klammernpaar, in das wir die beiden Koordinatenwerte je Punkt eintragen. Der Zugriff auf einzelne Elemente erfolgt dann konsequenterweise wieder über je eine eckige Klammer für Zeilen- und Spaltenposition
Die Frage ist nun, wie wird das Array jetzt im Speicher abgelegt? Nun, das können wir wieder mit einem einfachen C Programm herausfinden:
Listing 8: Adressanalyse eines zweidimensionalen Arrays in C
Da der Code nichts Neues enthält, spare ich mir hier eine weitere Erklärung und wir sehen uns gleich die Ausgabe an:
Die Daten liegen also sequenziell im Speicher. Wenn wir das in unserem gedachten Speichermodell mal ansehen, können wir uns das wie folgt vorstellen:
Ablage eines zweidimensionalen Arrays in C
Abb.: 5: Ablage eines zweidimensionalen Arrays in C
Die Adressrechnung, welche der Computer durchführen muss, ist auch hier recht simpel. Da wir wissen, wie viele Spalten unsere Matrix hat (in unserem Fall 2), ist die Adresse für jeden Punkt in unserem Array wie folgt zu ermitteln: Adresse für punkte[Zeile][Spalte] ist Anfangsadresse des Arrays + 2*4*Zeile + 4*Spalte. Versuchen wir mal die Adresse unseres Punktes „punkte[3][1]“ (das ist der letzte Eintrag unseres Arrays) herauszufinden: Spalte = 1, Zeile = 3 und die Anfangsadresse ist 2816 oder im Dezimalsystem 40. Dadurch ist die Adresse unseres Punktes 40 + 2*4*3 + 1*4 = 68 oder 4416. Das ist genau die Position des letzten int Wertes im Speicher. Das Ganze funktioniert aber nicht nur bei ein- oder zweidimensionalen Arrays, sondern auch bei noch höheren Dimensionen. Einzige Einschränkung ist hier, dass das Array „symmetrisch“ sein muss, sprich im zweidimensionalen Fall müssen die Anzahl der Spalten für jede Zeile gleich sein. Wir werden gleich noch sehen, dass man das in anderen Programmiersprachen durchaus anders gestalten kann. Doch zuvor möchte ich noch auf eine kleine Besonderheit von C eingehen, welche mit ein Grund für die sinkende Popularität von C erklärt. Sehen wir uns den folgenden Code genauer an:
Listing 9: Fehlerhafter Arrayzugriff in C
Die Ausgabe dieses Codes (zumindest beim ersten Durchlauf auf meinem Rechner) ist:
Was ist hier passiert? Nun, wir haben ja auf die Arrayposition 4/0 zugegriffen. Nachdem die erste Arrayposition die „0“ ist, muss die letzte entsprechend „3“ sein. Wir greifen aber auf „4“ zu, was bedeutet, wir „schießen über das Ziel hinaus“. Wir greifen also auf Werte zu, die nicht Teil des Arrays sind und wir somit einen „zufälligen“ Wert auf dem Speicher lesen. Dem nicht genug – wir könnten theoretisch auch einen Schreibzugriff an dieser Stelle durchführen! Wenn nun zufällig eine andere, wichtige Information genau auf dieser Speicherzelle liegt, könnten wir unser Programm hiermit zum Absturz bringen. Wir greifen also auf „fremde“ Speicherbereiche zu, was bei Arrays auch als Überlauf bezeichnet wird. C verhindert solche Zugriffe erstmal nicht! Dies bedeutet, dass ein C-Programmierer sehr genau auf seinen Code achten muss. Das Problem ist, dass solche Abstürze auftreten können, aber nicht müssen... je nachdem welche Information „zufällig“ direkt nach dem Array abgelegt wurde. Es ist also möglich, dass solche Abstürze im Test nicht entdeckt werden und dann irgendwann mal auf dem Userrechner zu einem Fehlverhalten führen. Dieses Problem tritt insbesondere auch dann zutage, wenn wir direkt mit Pointern und der dazugehörigen Adressarithmetik arbeiten.
Die Frage ist nun, ob C auch so etwas wie dynamische Arrays anbietet und man ahnt es schon – nein! Man muss es mal wieder selbst durchführen oder eine externe Bibliothek nutzen, in der sich schon jemand diese Mühe gemacht hat. Eine Lösung wäre das für Java/C# genutzte Prinzip der dynamischen Listen basierend auf Arrays selbst zu programmieren. Eine andere Möglichkeit, welche tatsächlich gerne genommen wird, sind die verketteten Listen. Eine solche Liste werde ich später nochmal vorstellen.
Top
Strings in C
Das Verständnis von Arrays ist in C nun auch eine Grundvoraussetzung, Texte zu speichern. Anders als C# oder Java speichert C Texte – meist auch „Strings“ genannt – in Arrays ab. Das liegt insofern nahe, als dass Strings ja eine Aneinanderreihung von Zeichen sind. Wenn wir also ein Zeichen in einer char Variable speichern können, dann müsste ein char Array ja einen String ablegen können. Probieren wir es aus:
Listing 10: Versuch, Strings in C als char Array abzubilden
Die Ausgabeformatierung ist nun nicht mehr %d, sondern %s für eine Interpretation als String. Sehen wir uns das Ergebnis an:
Unser Text „hallo welt“ ist zwar zu sehen, aber es kommen noch weitere „merkwürdige“ Zeichen ans Licht. Was ist hier also passiert? Wir haben ja bereits festgestellt, dass in C für ein Array der Platz reserviert ist. Dass wir beim Zugriff nicht „über das Ziel hinausschießen“ sind aber wir zuständig. Wir sollten also nicht auf den Speicherbereich nach dem Array zugreifen, können es aber. Und genau das ist hier passiert. Der Computer hat einfach munter die Speicherzellen nach dem reservierten Speicherbereich für das Array ausgelesen. Und dort standen nun irgendwelche zufälligen Daten, die eben zu zufälligen Zeichenausgaben geführt haben. Wir müssen also dem Rechner mitteilen, wo der String endet. Dazu fügen wir den sogenannten „Stringterminierer“ oder englisch „string terminator“ ein. Das ist das Zeichen mit der ID „0“ in unserer Zeichentabelle. Diese ID können wir direkt als Zahl eingeben, wodurch sich unser Programm wie folgt verändert.
Listing 11: Versuch, Strings in C als char Array abzubilden
Wenn wir den Code ausführen, sehen wir den erwarteten Text:
Jetzt möchte man meinen, das ist doch sehr umständlich. Eine Vereinfachung der Initialisierung wäre es, den Text in einer Stringkonstanten zu hinterlegen. Diese werden bei den meisten kompilierten Programmiersprachen mit doppelten Anführungsstrichen eingegeben (im Gegensatz zu dem einen Anführungsstrich bei char).
Listing 12: Stringzuweisung in C
Hier scheint die 0 nicht notwendig zu sein. Wenn man den String jedoch wieder als Array untersuchen würde, dann findet man an der Indexposition 10 doch wieder die 0 – sie wurde einfach automatisch erstellt. Warum ist das nun so umständlich? Nun – bei näherer Betrachtung haben wir ein wichtiges Problem noch nicht auf dem Schirm! Gehen wir mal davon aus, dass wir eine Konsoleneingabe in einer Variablen ablegen möchten. C bietet verschiedene Ansätze für die Konsoleneingabe – ich verwende hier die einfachste, wohlwissend, dass das Leerzeichen nicht 1:1 eingelesen wird und vor allem es bezüglich des Überlaufs meiner Variablen gefährlich werden kann. Wir werden gleich sehen, was damit gemeint ist. Das Hauptproblem bei diesem Anwendungsfall „Usereingabe“ ist, dass wir vorab nicht wissen können, wie lang der String sein wird, den der User eingibt. Insofern können wir eigentlich die Arraylänge unserer Variablen nicht festlegen. Wir haben also immer, wenn die Stringlänge nicht vorab feststeht oder gar während der Ausführung des Programms geändert werden kann, ein Dimensionierungsproblem. Eine – zugegebenermaßen nicht sehr elegante und nicht sichere – Lösung ist, das Array erstmal sehr groß anzulegen. Sehen wir uns trotzdem mal einen Beispielcode hierfür an, der ganz nebenbei nur für Demonstrationszwecke genutzt werden sollte(3)
(3) In „realen“ Programmen würden wir dies natürlich besser lösen, bspw. durch zeichenweises Einlesen oder „fgets“
Listing 13: Speicherung einer Konsoleneingabe in C
Kurze Erklärung der Codezeilen: Wir erzeugen ein char Array der Länge 100 in der Annahme, dass der User nie die 100 Zeichen benötigen wird. Danach erfolgt scanf, was eine Konsoleneingabe vom User einfordert. Diese wird bis zum nächsten „Whitespace“ (bspw. dem Leerzeichen) oder zur Betätigung der Enter Taste gelesen. scanf benötigt darüber hinaus noch die Info, wie die Eingabe interpretiert werden soll (bei uns als String: %s) und die Adresse des char Arrays, in der die Eingabe abgelegt werden soll: &eingabe. Danach geben wir den Inhalt der Variablen eingabe wie gewohnt aus.
Bei Starten können wir nicht über den CodeRunner „Play Button“ gehen, sondern müssen über das „Run and Debug“ Debug Button VSCode Werkzeug am linken Rand gehen. Hier müssen wir den „gcc“ bzw. „GND“ Compiler auswählen. Dadurch startet der Code so, dass wir auch eine Eingabe in der Konsole durchführen können. Wenn wir nun den Text „hallo“ eingeben („hallo welt“ geht nicht, da ja ein Leerzeichen enthalten ist), dann sehen wir auf der Konsole tatsächlich „hallo“ und nicht das Wort „hallo“, gefolgt von 95 zufälligen Zeichen, die durch Zufallszahlen im Speicher erzeugt wurden. Der Rechner scheint also den Terminator eingefügt zu haben. Das können wir sehr einfach feststellen, indem wir einen Text mit nur 5 Zeichen einzugeben und den Wert der 6. Indexposition des Arrays (also die Nummer 5, da die 0 ja mitzählt) als Zahl ausgeben lassen:
Listing 14: Speicherung einer Konsoleneingabe in C mit Ausgabe einzelner Char Codes
Bei Eingabe von „hallo“ ist die Ausgabe des Codes ist entsprechend:
Der Stringterminierer ist also ein Indikator, wo der String aufhört, damit wir nicht vorab 100% exakt wissen müssen, wie viele Zeichen wir reservieren müssen. Dies hat nun ein paar Implikationen, die allerdings nur bei der Stringverarbeitung in C gelten:
  • Ein String benötigt immer ein Zeichen mehr, als effektiv genutzt werden kann – den Stringterminierer.
  • Wir müssen vorab wissen, wie viel maximal in einem String abgelegt werden soll, damit die Größe des char Arrays ausreicht und kein Überlauf während der Laufzeit auftritt.
Als letzten Punkt zum Thema Strings in C sehen wir uns der Vollständigkeit halber nochmal folgenden Code an – und erinnern uns an den Begriff „Pointer“:
Listing 15: String Initialisierung als Konstantwert
Kurze Erklärung der Codezeilen: Wir erzeugen nun kein char Array mehr, sondern eine Pointervariable, welche auf ein char zeigt. In der gleichen Zeile weisen wir „hallo welt“ dieser Variablen zu. Was wir aber eigentlich hier machen ist, dass wir die Adresse der Stringkonstanten „hallo welt“ auf den Pointer „stringVar“ kopieren. Dadurch zeigt diese Variable auf die gleiche Adresse wie die Stringkonstante, weshalb wir sie ausgeben können. Das Problem hierbei ist, dass die Stringkonstante im Speicher so abgelegt ist, dass sie nicht änderbar ist (deshalb der Name „Konstante“). Wenn wir beispielsweise versuchen stringVar[0] = 'H' zu setzen, würden wir einen Fehler bei der Ausführung erhalten.
Die Nutzung von char Pointern als Strings und der Initialisierung mit Konstanten ist nur dann sinnvoll, wenn wir den Inhalt der Strings nicht mehr ändern wollen. Ansonsten müssen wir über Arrays oder dynamisch erzeugte Speicherbereiche gehen, da hier bei einer Zuweisung C die Stringkonstante Zeichen für Zeichen in das Array hineinkopiert.
Top
Arrays - Objektorientiert
So viel zu Arrays (und Strings) in C. Sehen wir uns das Ganze mal in Java und C# an. Die beiden Programmiersprachen verfolgen jeweils ähnliche Ansätze – sie sind objektorientiert und versuchen die Konzepte der Adressarithmetik wie in C weitestgehend zu „verstecken“. Wir werden die Ideen hinter der Objektorientierung in einem eigenen Abschnitt dieses Buches noch näher kennen lernen. Für den Moment reicht uns folgende kurze Erklärung: Ein Objekt ist ein Zusammenschluss von Variablen und Funktionalitäten, der jeweils in einem eigenen Konstrukt – dem Objekt – zu finden ist. Die Konsequenz hieraus ist, dass im inneren der Objekte solche Situationen wie ein Arrayüberlauf bei Strings von vorneherein abfangen und Fehler generiert werden können, da wir vor jedem Zugriff auf eine Variable eine Funktionalität setzen können. Zusammengehörige Daten und Funktionalitäten werden „gekapselt“, was die strukturierte Programmierung erheblich fördert. Um die Begrifflichkeit etwas eindeutiger zu machen, hat man bei den Objekten die Funktionalitäten „Methoden“ und die Variablen „Eigenschaften“ genannt. Sehen wir uns nun mal die Erzeugung eines Arrays in C# an (der Code für die Erzeugung ist in Java identisch, lediglich die Ausgabefunktionalität würde mit System.out.println() erfolgen):
Listing 16: Arraydeklaration und Ausgabe in C#
Kurze Erklärung der Codezeilen: Wir erkennen bei der Erzeugung des Arrays gewisse Parallelen zu C. Arrays werden in C# und auch in Java mit eckigen Klammern verwaltet. Der Unterschied hier ist, dass wir die eckigen Klammern gleich beim Datentyp eintragen, womit die Variable zahlArray zum Typ int[](integer Array) wird. Die eigentliche Erzeugung des Arrays wird mit dem Schlüsselwort new durchgeführt, womit ein innerer Prozess angestoßen wird, der das Array erzeugt. Dadurch haben C# und Java nicht nur die Möglichkeit das Array zu erstellen, sondern sich auch gleich darum zu kümmern, die initialen Werte im Array auf 0 zu setzen. Dies können wir dann bei der Konsolenausgabe beobachten. Die Zugriffe auf die einzelnen Arrayposition läuft wiederum exakt wie in C.
Bei der Ausgabe des Codes sehen wir schließlich auch die definierten Startwerte des Arrays (hier beispielhaft für die Indexposition 0):
Nun versuchen wir einen Überlauf im Array, indem wir bei der Ausgabe nicht auf zahlArray[0], sondern auf zahlArray[5] zugreifen:
Der Computer stoppt mit einer Exception das Programm mit einer Fehlerausgabe. Warum er es stoppt, bzw. was eine „Exception“ ist werden wir später noch behandeln, soviel sei hier aber gesagt: Wenn der Computer eine für ihn nicht sinnvoll ausführbare Konstellation findet, wird die Ausführung unterbrochen, das Programm „stürzt ab“. Der Vollständigkeit halber zeige ich das gleiche Phänomen nochmal in Java:
Listing 17: Arraydeklaration und fehlerhafter Zugriff in Java
Die Java Ausgabe der Exception sieht dann ähnlich wie bei C# aus:
Dadurch, dass wir in C# und Java über Objekte verfügen und diese die Zugriffe kontrollieren können (wir erinnern uns – Objekte haben auch eigene Funktionalitäten, in denen wir solche Kontrollen einbauen können), verhindert das System einen unerlaubten Zugriff auf eine nicht reservierte Speicherzelle. Dieses Verhalten hat so manche massiven Systemabstürze verhindert, da die Chance, dass während des Tests dieses Problem entdeckt wird im Vergleich zu C und C++ sehr viel höher ist. Gehen wir nun noch einen Schritt weiter und sehen uns an, wie C# und Java Arrays intern behandeln. Auch hier bedienen wir uns einer modellhaften Vorstellung die ausreicht, das Verhalten von Arrays zu verstehen und somit vorherzusehen.
Modellhafte Speicherbelegung eines C# bzw. Java Arrays
Abb.: 6: Modellhafte Speicherbelegung eines C# bzw. Java Arrays
Die Variable „zahlArray“ existiert als eigene Speicherzelle. Diese hat zwar eine Adresse, welche aber beim Programmieren faktisch keine große Rolle mehr spielt. In dieser Speicherzelle befindet sich nun eine Adresse – man spricht hier von einer „Referenz“ – auf das eigentliche Array Objekt. Im Bild sind „nur“ die Daten dargestellt, in Wirklichkeit liegt hier sehr viel mehr drin, nämlich die gesamte Funktionalität des Objektes und eventuell notwendige zusätzliche Daten, was für unsere Vorstellung jetzt erstmal nicht sonderlich wichtig ist. Genauso wie die Tatsache, dass diese Ablagestruktur nur virtuell in der Laufzeitumgebung („virtual machine“) von Java bzw. C# vorliegt. Entscheidend ist eben die Trennung des eigentlichen Wertes der Variable zahlArray und dem tatsächlichen Objekt. Durch die Objektorientierung unseres Arrays können wir nun beispielsweise die Länge des Arrays einfach abfragen. Das Arrayobjekt ist sich seiner Länge also bewusst, was nicht verwundert, da es ja den Zugriff außerhalb der definierten Länge kontrolliert und blockiert, was ohne dieses Wissen nicht möglich wäre. Sehen wir uns an, was der folgende Code bewirkt:
Listing 18: Längenbestimmung eines Arrays in C#
Kurze Erklärung der Codezeilen: Die initiale Belegung eines Arrays mit Werten erfolgt in C# und Java genauso, wie in C mit einer geschweiften Klammer (dies gilt übrigens auch für mehrdimensionale Arrays). In der Ausgabe wiederum rufen wir eine Eigenschaft des Arrayobjektes auf. Diese Aufrufe von objektinternen Eigenschaften und Methoden erfolgt mit dem Puntkoperator, gefolgt von der entsprechenden Eigenschaft bzw. der Methode. In unserem Fall wollen wir die Eigenschaft „Länge“ des Objektes wissen, weshalb wir zahlArray.Length ausgeben.
Die Ausgabe liefert uns:
Diese Information hilft uns schon mal in unserem Code, unzulässige Zugriffe auf unser Array von vorneherein zu verhindern, da wir die Länge nun prüfen können. Welche Eigenschaften und Funktionen die jeweiligen Objekte besitzen, muss im Zweifelsfall in der API-Dokumentation(4) der jeweils genutzten Elemente nachgelesen werden. Soweit schon mal ein kleiner Vorteil des Objektansatzes von Java und C#. Nun gehen wir mal auf die nächste Ebene und sehen uns den Aufbau von mehrdimensionalen Arrays an, welche in C# und Java nicht als Matrix wie in C, sondern als „Arrays von Arrays“ realisiert werden kann:
(4) API: Application Programming Interface ist die Sammlung der zur Verfügung stehenden Elemente der Programmiersprache
Listing 19: Erzeugung eines zweidimensionalen Arrays in Java
Soweit sieht das jetzt erstmal „unverdächtig“ aus, da die Gedanken aus dem eindimensionalen Array sinnvoll um die zweite Dimension (und wer möchte auch viel höhere Dimensionen) erweitert wurde. Wir werden später noch sehen, dass in C# hier eine weitere Syntaxvariante existiert (die eigentlich den „alten“ C Gedanken einer Matrix wiederbelebt). Insofern bleiben wir jetzt erstmal bei Java und sehen uns deshalb mal folgenden Code an:
Listing 20: Initialisierung mehrdimensionales Array in Java
Kurze Erklärung der Codezeilen: Der Datentyp, welcher vor zahlArray steht, ist jetzt mit int[][] als „Array von Array“ gekennzeichnet – was ein zweidimensionales Array darstellt. Wie in C auch, können wir mit vordefinierten Werten initialisieren. Nun geben wir die Länge des Arrays aus (in Java wird hier length mit kleinem „l“ geschrieben). Danach sehen wir uns die Breite der 0. Zeile an.
Die Ausgabe des Programms:
Hier haben wir schon den ersten Indikator dafür, dass der „Array von Array“ Ansatz recht praktisch sein kann. Wir können also nicht nur die Länge, sondern auch die Breite unseres Arrays feststellen. Nun ergänzen wir unseren Code wie folgt:
Listing 21: Asymmetrisches Array in Java
Die zweite Zeile ist jetzt also breiter als die erste und letzte Zeile. Sehen wir uns dies in der Ausgabe an:
Wir haben also ein asymmetrisches zweidimensionales Array geschaffen. Das Ganze geht auch flexibler, wie folgender Code beweist:
Listing 22: Dynamische Erzeugung eines asymmetrischen Arrays in Java
Kurze Erklärung der Codezeilen: Wir erzeugen nun im Speicher ein Array von Arrays und sagen, dass das äußere Array in Summe 3 innere Arrays aufnehmen kann. Danach erzeugen wir für jede Position des äußeren Arrays jeweils ein individuelles inneres Array. Zuerst eins der Länge 4, dann eins der Länge 3 und am Schluss eins der Länge 5. Anschließend lassen wir uns die Längen der jeweiligen Arrays wieder ausgeben.
Die Ausgabe dürfte nun niemanden mehr überraschen:
Trotzdem sehen wir uns nochmal schnell die Belegung in unserem vereinfachten Speichermodell an:
Speichermodell von 2-dimensionalem Array als Objekte
Abb.: 7: Speichermodell von 2-dimensionalem Array als Objekte
Die Variable zahlArray liegt im Speicher und trägt die Adresse des Arrays der 1. Dimension. Dieses hat drei Einträge. In jedem dieser Einträge finden wir wieder eine Adresse, und zwar die des 0., 1. und 2. Arrays der Längen 4, 3 und 5. Wo diese Arrayobjekte liegen, ist vollkommen irrelevant – im Gegensatz zu C, wo die Arrays in einem Block abgelegt werden. Wichtig ist nur, dass die Adressen jeweils im Array der 1. Dimension abgelegt wurden. In Java (und auch C#) finden wir in einer Variablen welche ein Objekt enthält, ja lediglich die Adresse, unter der das Objekt im Speicher zu finden ist (Achtung – dies ist bei primitiven Datentypen nicht der Fall!). Wenn wir diesen Gedanken jetzt näher analysieren, kommt uns hier noch ein weiterer Punkt in die Quere. Sehen wir uns das folgende Listing an:
Listing 23: Dynamische Erzeugung eines asymmetrischen Arrays in Java ohne Belegung der 2. Dimension
Kurze Erklärung der Codezeilen: Wie im vorausgegangenen Code erzeugen wir eine Variable für ein zweidimensionales Array, legen aber nur das Array der 1. Dimension fest. In die drei Felder des Arrays legen wir aber kein neues Array hinein – die Felder bleiben ohne Initialisierung. Bei der Ausgabe lassen wir uns wieder die Länge des Arrays der 1. Dimension anzeigen. Anschließend versuchen wir den direkten Wert aus dem Feld der 0. Indexposition auszugeben, also dort, wo wir eigentlich nichts hineingelegt haben.
Die Ausgabe zeigt uns jetzt einen neuen, in der Programmierung wichtigen Begriff, die „null“:
Wir sind hier auf ein Phänomen bei Objekten gestoßen, welches wir bei den einfachen Datentypen so nicht kannten. Was ist passiert? Nun, bei einfachen Datentypen, bspw. bei int haben wir festgestellt, dass jede der 216 Bitkombinationen einer tatsächlichen Zahl zugeordnet wurde. Es gibt also keine Bitkombination, die keine „Bedeutung“ hat. Bei Objektvariablen ist dies anders. Hier steht ja die Adresse des Objektes in der Variablen. Wenn dort aber eine Adresse steht, die nicht existiert (meist als Adresse „0“ referenziert), dann kann der Computer mit diesem Objekt auch nicht arbeiten. Da ein Array in Java bei der Erzeugung automatisch initialisiert wird (Java setzt ja beispielsweise bei einem int Array alles auf 0), trägt Java bei Objekten eine Bitkombination für „null“ ein – sprich eine nicht existente Adresse. „null“ bedeutet also „kein Objekt vorhanden“. Die Variable existiert zwar, zeigt aber auf „nichts“, was ja mit „Null“ übersetzbar wäre (weshalb man auch vom „Null Pointer“ spricht). Sehen wir uns mal an, was passiert, wenn wir versuchen die Länge des nicht vorhandenen Arrays auf der Indexposition 0 festzustellen:
Listing 24: Versuch des Zugriffs auf ein nicht vorhandenes Objekt in Java
Die Ausgabe verwundert nun nicht – schließlich haben wir etwas Unmögliches verlangt: die Eigenschaft von etwas auszulesen, das gar nicht existiert:
Diese NullPointerException ist vermutlich die häufigste Fehlersituation, mit der man als Programmierer von objektorientierten Sprachen zu tun haben wird. So viel zu den Arraykonzepten in Java. Widmen wir uns nun kurz den Besonderheiten von C# in diesem Zusammenhang. C# würde die dynamische Generierung von Arrays in Arrays genauso unterstützen wie Java. Der folgende Code unterscheidet sich kaum vom Listing 21:
Listing 25: Dynamische Erzeugung eines asymmetrischen Arrays in C#
Die Ausgabe ist exakt die Gleiche, wie bei Java. Allerdings haben sich die C# Macher wohl an das Konzept aus C erinnert, dass man auch symmetrische Arrays ganz gut gebrauchen kann. Nun ist es so, dass mit dem Java Konstrukt int[][] zahlArray = new int[3][4] auch ein symmetrisches Array erzeugt werden würde, aber es ist nun eben intern ein Array von Arrays und nicht ein Array, in dem wir mit einer schlauen Adressarithmetik auf die einzelnen Zeilen uns Spalten zugreifen können. Nun haben die C# Entwickler jedoch beschlossen, die Java Konstruktion so nicht umzusetzen, sondern sie fordern immer den Weg aus Listing 24, also zuerst Erzeugung des äußeren Arrays und dann die explizite Erzeugung der inneren. Um nun doch wieder in einer Zeile ein leeres mehrdimensionale Array zu erzeugen gibt es in C# die Option, die Matrixschreibweise anzuwenden. Sehen wir uns das mal im Code an:
Listing 26: Zweidimensionales Array als Matrix in C#
Kurze Erklärung der Codezeilen: Die Variable wird nun als zweidimensionales Matrixarray mit [,] erzeugt. Dreidimensional wäre dann entsprechend [,,]. Die Initialisierung wurde hier mit vordefinierten Werten mit Hilfe der geschweiften Klammern vorgenommen. Achtung – hier müssen nun die Anzahl der Spalten pro Zeile immer identisch sein, sonst gibt es einen Compilerfehler. Wenn man keine vordefinierten Werte haben möchte, so kann man new int[3,2] schreiben – C# würde dann überall die 0 eintragen werden. Bei der Ausgabe verlangen wir die Länge des Arrays, wobei dies die Gesamtanzahl der Elemente ist, wodurch es konsequenterweise mit der .Length Eigenschaft nicht Möglich ist die Breite auszulesen. Dies erfolgt mit der Methode GetLength(), welche als Parameter die Dimension benötigt. Der Zugriff erfolgt sinnigerweise auch über die eckige Klammer und den Indizes über Komma getrennt. C# wirft hier allerdings auch eine IndexOutOfRangeException, wenn wir außerhalb des gültigen Bereiches auslesen.
Die Ausgabe ist nun wieder entsprechend. Es sei hier nochmal darauf hingewiesen, dass die Länge nun die Gesamtanzahl der Elemente aufzeigt – also 6 und nicht die Anzahl der Zeilen.
Die „Macher“ von C# standen nun wohl vor der Frage, wie die Initialbelegung mit Werten eines mehrdimensionalen Arrays zu interpretieren ist. Der Ausdruck aus dem oberen Listing {{11, 12}, {21, 22}, {31, 32}}; musste nun entweder für die Matrix, oder dem mehrdimensionalen Array festgelegt werden. Dabei haben sie sich für die Matrix entschieden. In Java gibt es keine Matrix in dieser Form, weshalb dieser Code dort als Initialisierung eines mehrdimensionalen Arrays gewertet wird wie wir im Listing 20 gesehen haben.
Der ein oder andere kann an diesen „Matrix-“ Arrays erkennen, dass der Verzicht von Java auf dieses Konstrukt nun kein großer Verlust ist, da der „Array von Array“ Ansatz auch die symmetrischen Konstrukte abbildet und die Feststellung der Breite und Länge relativ eingängig ist. Eingefleischte C# Programmierer wiederum würden vermutlich nicht auf dieses Konstrukt der Matrix verzichten wollen. Was auf jeden Fall aber ersichtlich werden sollte ist, dass der C-Ansatz sehr viel simpler gestrickt ist und somit sehr viel mehr Aufmerksamkeit vom Programmierer erfordert. Auf der anderen Seite ist C die grundlegendere Sprache. Beispielsweise wurde die Java Virtuelle Maschine ursprünglich in C geschrieben. Allein deshalb ist es meines Erachtens nicht sinnvoll, die eine Programmiersprache über die andere zu stellen. Jede Sprache hat irgendwo seine Daseinsberechtigung. Der Vorteil der objektorientierten Sprachen wiederum ist, dass mit dem Punktoperator die internen Methoden von Arrays genutzt werden können, wo einiges an potentiellen Fehlern bereits abgefangen wird. In den einzelnen objektorientierten Programmiersprachen gibt es darüber hinaus noch „Utilitiy Klassen“, welche den Umgang mit Arrays vereinfachen. In Java wäre dies beispielsweise die Klasse java.util.Arrays, in C# ArrayUtil und in C++ utils::ArrayUtils. Die Utilities muss man meist aus externen Bibliotheken importieren oder gleich selbst anfertigen. Prozedurale Ansätze wie in C sind wiederum gegenüber den objektorientierten Sprachen meist performanter.
Top
Dynamische Arrays
Lösen wir nun unser Problem mit der statischen Arraygröße. Gehen wir mal davon aus, wir wollen Usereingaben in einem Array speichern. Immer wenn die Informationen bzw. Inhalte für unsere Arrays von außen kommen, wissen wir nicht wie viele Einträge wir haben werden – sprich wir können die Arraygröße nicht von vorneherein festlegen. In den objektorientierten Programmiersprachen gibt es hierfür im Regelfall eigene Klassen (zum Teil sogar mehrere), welche dieses Problem in den Griff bekommen. Der Überbegriff von solchen Konstrukten lautet „dynamische Arrays“. Eine Internetsuche mit „dynamic array java“ bringt uns beispielsweise relativ schnell zu dem vermutlich am häufigsten genutzten Java Konstrukt für diese Aufgabe, der ArrayList. Sehen wir uns mal ein Beispiel an, in der eine ArrayList mit Werten gefüllt wird. Danach wird ein Wert ausgegeben, mittig einen Wert hinzugefügt, der vorletzte Wert gelöscht und der letzte ausgetauscht. Am Schluss werden alle Werte nochmal per Schleife ausgegeben:
Listing 27: ArrayList als dynamisches Array in Java
Kurze Erklärung der Codezeilen: Die ArrayList befindet sich in java.util, weshalb wir diese Bibliothek importieren müssen. Danach wird eine leere ArrayList erzeugt, indem der „Konstruktor“ new ArrayList() aufgerufen wird. Damit das Objekt weiß welche Daten es verarbeiten soll, wird über einen Typenparameter mitgeteilt, dass es sich um „Strings“ kümmern muss. Dieses Konzept nennt man „Generics“, was so viel bedeutet, dass die Klasse zwar generisch agiert (sprich mit allen Klassen arbeiten kann), über den Parameter aber typisiert wird, damit beim Zugriff kein Typecast auf „Object“ notwendig ist. Wem das nun zu „kompliziert“ vorkommt, soll sich noch ein wenig auf das Kapitel „Objektorientierung“ warten, wo das Konzept nochmal etwas deutlicher gemacht wird. Für jetzt müssen wir uns lediglich merken, dass eine ArrayList in Java zwischen den <> Zeichen einfach die Klasse haben möchte, welche wir mit der ArrayList verarbeiten wollen – in unserem Fall eben String. Da wir nun mit einem Objekt der Klasse ArrayList arbeiten, gehen wir beim Zugriff immer über Methoden - also "Funktionalitäten", welche mit dem Punktoperator "." nach dem Variablennamen des ArrayList Objektes angesprochen werden, bspw. myList.get(i).
Die wichtigsten Methoden der Java ArrayList finden sich weiter unten in Tabelle 1. Wenn wir unseren Code nun ausführen, sehen wir:
Zwei Dinge müssen wir bei der ArrayList nun noch klären. ArrayListen können in Java nur Objekte verwalten. Das ist in Bezug auf die primitiven Datentypen eine erhebliche Einschränkung. Warum die Java Autoren die ArrayList so gestalten mussten, werden wir im Abschnitt zur Objektorientierung noch sehen. Die Frage ist nun, wie bekommen wir eine ArrayList von Zahlen, wie bspw. „int“ realisiert? Hier kommen wieder die Wrapperklassen ins Spiel. Der Umgang mit Variablen vom Typ dieser Wrapperklassen funktioniert genauso wie mit den primitiven Datentypen. Dadurch können wir eine ArrayList() erzeugen und bspw. mit „add(123)“ eine ganze Zahl anhängen.
Das zweite wichtige Detail ist der innere Aufbau von solchen Konstrukten. Sehr einfach ausgedrückt handelt es sich um ein Objekt, in dem sich ein Array befindet, welches über Manipulationsmethoden befüllt und verwaltet wird:
Vereinfachter innerer Aufbau einer ArrayList in Java
Abb.: 8: Vereinfachter innerer Aufbau einer ArrayList in Java
Wenn wir also get() aufrufen, wird lediglich auf das innere Array zugegriffen und der Wert zurückgegeben. Das ist nun auch deshalb „vereinfacht“, weil ich das ganze Thema Typisierung hier der Einfachheit halber mal weggelassen habe. Die wichtige Frage ist, was passiert nun, wenn ich das innere Array mit zusätzlichen Einträgen bestücken möchte. Bis zum 10. Element ist dies ja kein Problem, da ich das innere Array mit der Größe „10“ angenommen habe. Was passiert aber beim 11. Element? Hier muss ein neues Array erzeugt und die Werte vom alten Array übernommen werden:
Erweiterung der inneren Datenhaltung von ArrayListen in Java
Abb.: 9: Erweiterung der inneren Datenhaltung von ArrayListen in Java
Man kann sich vorstellen, dass dieser Prozess nicht in null Zeit durchgeführt werden kann – aber schließlich zahlt man für jede Annehmlichkeit einen Preis – in diesem Fall zahlt man für die Flexibilität der ArrayList den Preis der Performance. Wenn ich jedoch von vorneherein bereits weiß, dass ich – sagen wir 500 – Werte eintragen muss, so würde es sinnvoll sein, das innere Array bereits mit dieser Größe anzulegen. Und genau dafür gibt es einen weiteren Konstruktor, welcher einen Parameter akzeptiert: new ArrayList(500).
Das Ganze funktioniert auch in C# - nur dass die Klasse hier List heißt und in der Bibliothek System.Collections.Generic zu finden ist:
Listing 28: List als dynamisches Array in C#
Bemerkenswert gegenüber Java ist, dass der Zugriff hier wie bei normalen Arrays funktioniert. Die C# Autoren haben zugunsten der einfacheren Nutzbarkeit auf die strikte Umsetzung der objektorientierten Idee, alle Funktionalitäten über Methoden zu realisieren, verzichtet.
Sehen wir uns noch eine Möglichkeit für dynamische Array an, die uns C++ bietet, der vector.
Listing 29: Vector als dynamisches Array in C++
Ansonsten sind die Implementierungen in Java, C# und C++ sehr ähnlich, was der Vergleich der wichtigsten Methoden zeigt:
Java: C#: C++: Funktion:
new ArrayList<class>() new List<class>() std::vector<std::class> Konstruktor – erzeugt ein leeres dyn. Array, welches Objekte der Klasse „class“ verarbeitet
new ArrayList<class>(cap) new List<class>(cap) existiert nicht Konstruktor – erzeugt eine leeres dyn. Array, welches Objekte der Klasse „class“ verarbeitet, mit einer initialen Kapazität von „cap“.
.add(object) .Add(object) .push_back(object) Hängt „object“ am Ende an.
.add(object) .Add(object) insert(v.begin()+pos, object)(5) Fügt „object“ an der Position „pos“ ein.
.get(pos) [pos] [pos] Gibt den Eintrag an der Position „pos“ zurück.
.remove(pos) .RemoveAt(pos) .erase(pos) Löscht den Eintrag an der Position „pos“.
.set(pos, object) [pos] = object [pos] = object Ersetzt den Eintrag an der Position „pos“ durch „object“.
.size() .Count() .size() Gibt die Anzahl der Elemente aus.
Tabelle 1: Wichtige Methoden der Java ArrayList, C# List und C++ vector
(5) „v“ steht für das Vektorobjekt selbst. v.begin() liefert einen Iterator, mit dessen Hilfe der C++ Compiler die Adressarithmetik durchführen kann.
Nun ist noch ein Hinweis sinnvoll, der im Zusammenhang mit dynamischen Listen wichtig ist. Sehen wir uns das am Beispiel eines Java Codes mal an:
Listing 30: Indexverschiebung durch Ergänzung in Java
Die Ausgabe ist nun nicht sehr verwunderlich:
Die hier wichtige Erkenntnis ist jedoch, dass wir uns bei dynamischen Arrays nicht darauf verlassen können, dass ein Wert, den ich zum Zeitpunkt 1 auf einer Indexposition finde, zu einem späteren Zeitpunkt 2 hier zwingend auch wieder vorfinde. In unserem Beispiel ist der Text „einkaufen“ von Indexposition 2 auf 3 gerutscht. Solch ein Verhalten werden wir bei Arrays nie vorfinden, es sei denn ich schreibe explizit auf Indexposition 2 einen anderen Wert. Das ist nun auf den ersten Blick trivial und scheinbar von keiner großen Bedeutung, jedoch sollten wir vor diesem Hintergrund wirklich nur dann dynamische Arrays nutzen, wenn wir wirklich die Dynamik brauchen. Wenn wir bspw. Konfigurationsdaten in ein Array verpacken, dann erwarten wir, dass die einzelnen Konfigurationsinformationen immer auf festen Plätzen zu finden sind. Beispielsweise packen wir in ein Array auf Indexposition 0 die IP Adresse, auf Indexposition 1 den Port und auf 2 das genutzte RDBMS. In solch einer Situation wäre es sinnvoll, die Werte 0, 1 und 2 als Konstanten zu erstellen:
Listing 31: Nutzung als Konstanten für Arrayzugriffe in Java
Kurze Erklärung der Codezeilen: Wir wollen die drei Werte IP-Adresse, Port und Datenbanktyp in einem Array speichern. Damit wir uns die Indexpositionswerte nicht merken müssen, erzeugen wir mit dem Schlüsselwort final jeweils eine int Variable IP, PORT und DB_TYPE (Konstanten schreibt man üblicherweise in Großbuchstaben, um den Code lesbarer zu machen). Somit können wir auf die jeweiligen Arraypositionen über die Konstanten zugreifen – also anstatt cfgValues[1] schreiben wir nun cfgValues[PORT], was einfacher lesbar ist.
Die Ausgabe dieses Codes wäre somit:
Dadurch, dass wir zu 100% wissen, dass sich die Positionen der Elemente nicht verschieben können, ist die Nutzung von Konstanten für die Positionen entsprechend problemlos möglich. Gerade für Informationen, wie Konfigurationen oder technisch wichtige Daten, greift man gerne auf solche Konstrukte zurück. Bei dynamischen Arrays wäre dieser Ansatz fehleranfällig, da wir die Position der Elemente ja im Nachhinein ändern können.
Top
Assoziative Arrays / Dictionaries
Und in diesem Zusammenhang kommen wir nun zu dem letzten wichtigen Konstrukt bei Arrays, den „assoziativen Arrays“; also Arrays, bei denen wir nicht über die Indexpositionen zugreifen, sondern über eindeutige Werte. Jeder Eintrag in solchen Arrays erhält ein eindeutiges Label, oder auch Schlüssel (engl. „key“) genannt – ähnlich wie ein eindeutiger Name des Eintrags. Wenn man also einen neuen Eintrag einfügen möchte, muss man neben dem Eintrag auch einen Schlüssel mitgeben. Beim Auslesen der Einträge wiederum, benötigen wir nur den Schlüssel. Im Regelfall sind diese Konstrukte ungeordnet, was so viel bedeutet, als dass die Reihenfolge des Einfügens keine Rolle für die Ausgabe spielt – sie existiert schlichtweg nicht. Intern existiert natürlich eine Struktur, welche für das möglichst schnelle Auffinden der richtigen Elemente optimiert ist, meist eine Baumstruktur. Beginnen wir hier auch mit einem Java Beispiel der Hashtable:
Listing 32: Asoziatives Array am Beispiel einer Hashtable in Java
Kurze Erklärung der Codezeilen: Die Hashtable benötigt nun die Typinformation für zwei Klassen – der Klasse für den „Key“ und der für den eigentlichen Wert, dem „Value“. Der Key wird üblicherweise mit einem String oder einem ganzzahligen Datentyp hinterlegt, es ist aber im Prinzip jede Klasse(6) möglich. Die Daten werden mit put eingefügt und mit get wieder ausgelesen.
(6) Die gewisse Eigenschaften erfüllen muss – Details finden sich in der API Dokumentation von Java
Starten wir die Software erhalten wir erwartungsgemäß:
In Java gibt es neben der Hashtable noch weitere Implementierungen dieses Prinzips, wie beispielsweise die Hashmap. In C# existiert zwar auch eine Hashtable, gebräuchlicher ist jedoch Dictionary:
Listing 33: Asoziatives Array am Beispiel eines Dictionaries in C#
Und das Ganze nochmal in C++:
Listing 34: Assoziatives Array am Beispiel einer Map in C++
Wie immer, gleichen sich die Ansätze in gewissen Grenzen. Auch hier der Vollständigkeit halber noch die wichtigsten Befehle der assoziativen Arrays in den drei Sprachen:
Java: C#: C++: Funktion:
new Hashtable<classK, classV>() new Dictionary<classK, classV>() std::vector<std::classK, classV> Konstruktor – erzeugt ein leeres assoziatives Array, welches Objekte der Klasse „classK“ als Key und „classV“ als Value verarbeitet
new Hashtable<classK, classV>(cap) new Dictionary<classK, classV>(cap) existiert nicht Konstruktor – erzeugt ein leeres assoziatives Array, welches Objekte der Klasse „classK“ als Key und „classV“ als Value verarbeitet, mit einer initialen Kapazität von „cap“.
.put(objK, objV) .Add(objK, objV) .insert(std::make_pair(objK, objV)) Fügt „objV“ mit dem Key „objK“ ein.
.get(pos) [pos] [pos] Liest das Objekt mit dem Schlüssel „objK“ aus.
.remove(pos) .RemoveAt(pos) .erase(pos) Löscht den Eintrag mit dem Schlüssel „objK“.
.size() .Count() .size() Gibt die Anzahl der Elemente aus.
Tabelle 2: Wichtige Methoden der Java Hashtable, C# Dictionary und C++ map
Ein letzter Hinweis noch für die assoziativen Arrays. Da wir ja nur noch über Keys unsere eingetragenen Werte herauslesen können, gibt es keine einfache Schleife mehr, mit der wir alle Elemente auslesen können. Wir müssen hierzu immer auch alle Keys wissen. Sowohl in Java, C# als auch für C++ gibt es jedoch Ansätze, alle Keys auszulesen. Hierfür lässt man „Iteratoren“ oder „Enumeratoren“ erzeugen – sogenannte „Aufzählungstypen“, mit deren Hilfe wir uns sequenziell alle Keys ausgeben lassen können. Wer hier tiefer einsteigen möchte oder muss, der findet mit Suchanfragen wie bspw. „get all keys from Hashtable in Java“ relativ schnell gute Hinweise.
Top
Strings - Objektorientiert
Doch gehen wir mit unseren zusammengesetzten Datentypen weiter voran. Wir wissen jetzt, wie die Arrays in Sprachen wie C# oder Java behandelt werden. Kümmern wir uns nun um Texte – oder besser gesagt „Strings“. Wir haben ja in den vorausgegangenen Codebeispielen schon Strings genutzt, aber noch nicht intensiver darüber gesprochen. Wie die Arrays auch, werden in den objektorientierten Programmiersprachen die Strings als Objekte gehandhabt, was die ganze Sache erheblich vereinfacht. Die gesamte Mechanik der Prüfung, ob ein String in ein char Array reinpasst oder nicht, entfällt hier. Die Objekte kümmern sich selbstständig darum. Sehen wir uns mal die Stringnutzung in Java an:
Listing 35: Stringnutzung in Java
Wie wir sehen müssen wir nicht vorher festlegen, wie groß der Text sein soll, der in der Stringvariablen einzutragen ist. Die erste Codezeile würde übrigens in C# genauso aussehen. Wie das Array auch, haben die Stringobjekte Eigenschaften und Methoden, welche den Umgang mit Strings erleichtern. Je nach Programmiersprache gibt es unterschiedliche Hilfsmethoden, die wir in der API-Beschreibung nachlesen müssen. Sehen wir uns beispielhaft mal ein paar an. Gehen wir davon aus, dass wir aus einem String einen Teilstring extrahieren wollen. Um den Vorteil der Objektorientierung zu sehen, blicken wir zuerst nochmal auf C. Hier könnten wir beispielsweise folgenden – zugegebenermaßen nicht sehr performanten, aber funktionierenden – Code nutzen, der die ersten 5 Zeichen aus einem gegebenen String schneidet(7):
(7) Wer es performanter haben möchte, sollte sich „memcpy()“ mal ansehen.
Listing 36: Einfache Substringerstellung in C
Kurze Erklärung der Codezeilen: Wir beginnen mit einer für uns neuen Initialisierung eines Strings. stringVar[] übernimmt einfach „nur“ die Adresse des Konstantwertes „hallo welt“ und muss somit nicht mehr weiter definiert werden. Danach erzeugen wir eine Variable für die Aufnahme eines 5-stelligen Strings mit abschließendem Terminatorzeichen. In der nächsten Zeile finden wir eine Standardzählschleife, welche von 0 bis kleiner 5 zählt (Zählschleifen werden wir im Rahmen der Kontrollstrukturen noch näher beleuchten). Die Variable „i“ hat also im ersten Durchlauf die 0, dann die 1 usw. bis zur 4. Im Rumpf der Schleife (also zwischen den geschweiften Klammern) übernehmen wir nun Zeichen für die Werte in die Variable subStringVar. Nach der Schleife setzen wir den Stringterminator explizit (auf 0) und geben am Ende den Wert aus.
Die Ausgabe liefert uns somit den String „hallo“. Sehen wir uns exakt die gleiche Logik mal in Java an:
Listing 37: Substringermittlung über Arrays in Java
Dadurch, dass wir die nicht objektorientierte Methodik dem objektorientierten Code „überstülpen“, ist das Ergebnis unnötigerweise verkompliziert worden. Wir sehen uns gleich eine einfachere Möglichkeit an. Gehen wir aber vorher durch den Code durch:
Kurze Erklärung der Codezeilen: Wir erzeugen und initialisieren wieder eine Stringvariable für den ganzen Text. Danach zerlegen wir den Text in ein char Array, um den gleichen Algorithmus wie in C zu ermöglichen. Nun bereiten wir das Zielarray mit 5 Einträgen vor. Wir benötigen in Java keinen Stringterminator, da wir aus dem Array gleich einen „normalen“ String erzeugen und dort kein Terminator benötigt wird. Danach folgt wieder eine Zählschleife, welche exakt so wie in C funktioniert. Nach der Schleife erzeugen wir aus dem Array mit new String(subStringArray) den benötigten String und geben ihn schließlich aus.
Die Java Mechanik erzeugt nun die gleiche Ausgabe „hallo“ wie unser C Code – nur eben umständlicher. Nun kommen die Hilfsmethoden der Klasse String ins Spiel. Streng genommen sind sie uns ja schon vorher begegnet, da der Code stringVar.toCharArray() ja bereits eine Hilfsmethode darstellt. Die Beschreibung der Java API für das String Objekt liefert uns unter anderem die Methode substring(beginIndex, endIndex). Sie liefert also den Substring vom angegeben Startindex bis (exklusive) dem Endindex. Der Code reduziert sich also auf den folgenden:
Listing 38: Objektorientierte Substring Ermittlung in Java
Das ausgegebene Ergebnis ist wieder „hallo“. Die gesamte Mechanik ist also in der Hilfsmethode substring() vorab geschrieben worden. Das ist so weit recht praktisch – nur muss man eben wissen, welche Hilfsmethoden existieren, weshalb man für die gerade genutzte Programmiersprache immer wissen muss, wo man nachsehen kann. Im Abschnitt über die Objektorientierung werden wir noch sehen, wie wir eigene Hilfsmethoden generieren können. Sehen wir uns der Vollständigkeit halber auch nochmal den Code in C# an:
Listing 39: Objektorientierte Substring Ermittlung in C#
Soweit nichts neues – wir haben ja schon öfter gesehen, dass in den einzelnen objektorientierten Sprachen das Rad nicht immer neu erfunden wurde, zumal C# einige der recht erfolgreichen Ansätze von Java übernommen hat. Etwas anders sieht es da in C++ aus, welche ja aus C hervorgegangen ist und als objektorientierte Erweiterung von C gehandelt wird. Aus diesem Grund funktioniert der Code aus Listing 35 1:1 auch so in C++. Es ist aber auch möglich, in C++ einen objektorientierten Ansatz des Codes umzusetzen:
Listing 40: Objektorientierte Substring Ermittlung in C++
Mal abgesehen von ein paar unterschiedlichen Syntaxregeln ist der allgemeine Ansatz der Objektorientierung wieder zu erkennen. Ist damit C dem Untergang geweiht, weil es „zu umständlich“ ist? Nun, die Antwort ist „ja und nein“. Auf der einen Seite liefern uns die C-Bibliotheken auch hilfreiche Funktionen für den Umgang mit Strings. Diese kann ich bei Bedarf auch noch erweitern. Darüber hinaus ist C aufgrund des geringeren Overheads im Regelfall performanter als objektorientierter Code. Auf der anderen Seite ist Entwicklungszeit oft bares Geld, weshalb man bei der Perfomance mitunter zugunsten schnellerer Entwicklungszyklen Abstriche macht. Der eigentliche „Problemfall“ von C ist aber die Angst vor fatalen Fehlern in Code bezüglich der Adressarithmetik und der Speicherverwaltung, über die wir später auch noch mal blicken müssen.
Bevor wir das Thema „Strings“ abschließen, müssen wir aber noch über ein oft unterschätztes Thema bei Strings sprechen – den Stringvergleichen. Nun möchte man denken, dass der folgende Java Code relativ klare Ergebnisse liefern sollte:
Listing 41: Vergleich von verketteten Strings in Java
Kurze Erklärung der Codezeilen: Zuerst erzeugen wir drei Stringvariablen und initialisieren sie mit drei verschiedenen Werten. Danach erzeugen wir drei weitere, indem wir die ersten drei mit „+“ Verketten. Die Variable string1 erhält dadurch den Wert „hallo welt“, string2 auch „hallo welt“ und string3 „hallo mars“. Danach geben wir die Variablenvergleiche, welche in Java mit „==“ erfolgen, auf der Konsole aus. Wir vergleichen also zuerst string1 mit string2 und dann string1 mit string3.
Sehen wir uns das auf den ersten Blick überraschende Ergebnis an:
Java sagt mir also allen Ernstes, dass sich sowohl string2 als auch string3 von string1 unterscheiden, obwohl string1 und string2 die gleichen Inhalte aufweisen sollten, nämlich „hallo welt“! Nun, um dieses Verhalten zu verstehen, müssen wir uns nochmal in unseren Modellspeicher begeben.
Ablage zweier identischer Stringobjekte in Java
Abb.: 10: Ablage zweier identischer Stringobjekte in Java
Wie wir sehen, ist der Inhalt der Variablen string1 nicht der Text „hallo welt“, sondern die Adresse, unter der dieser Text zu finden ist. string2 beinhaltet eine andere Adresse – die zwar auch auf ein Stringobjekt mit dem Inhalt „hallo welt“ zeigt, aber eben unter einer anderen Adresse. Dies bedeutet, dass in Java der Vergleich von zwei Objektvariablen zum Vergleich der Adressen führt. Dies ist übrigens auch der Grund, warum ich string1 und string2 so „umständlich“ durch Verkettung aufbaue. Hätte ich den Code wie folgt geschrieben:
Listing 42: Belegung zweier Stringvariablen mit ein und demselben Objekt
hätte Java die Gleichheit der beiden Stringkonstanten bemerkt und sie nur einmal angelegt, wodurch beide die gleiche Adresse erhalten hätten.
Die Frage ist nun, ob das ein sinnvolles Verhalten ist, die Adressen zu vergleichen und nicht die Inhalte. Die Antwort ist wie so oft „ja und nein“. Beginnen wir mit der „ja“ Antwort. Wenn wir den Code „string1 == string2“ als Frage auf „ist es ein und dasselbe?“ interpretieren, ist die Antwort darauf in der Tat „falsch“, da es sich ja um zwei verschiedene Objekte handelt. Die Tatsache, dass sich im (Listing 42) tatsächlich nur ein Objekt verbirgt wäre ohne diesen Ansatz nicht überprüfbar. Darüber hinaus ist die Frage, „was ist eigentlich gleicher Inhalt?“ nicht so einfach beantwortbar. Ist beispielsweise der String „hallo welt“ der gleiche, wie „Hallo Welt“? Diejenigen, welche die Java Sprache entwickelt haben konnten diese Frage wohl auch nicht beantworten, weshalb sie für den Vergleich zwei Hilfsmethoden erstellt haben – und zwar „equals()“ und „equalsIgnoreCase()“. Sehen wir uns das im Code an:
Listing 43: Case sensitiver Vergleich von Strings in Java
Die Ausgabe ist nunmehr erwartbar, da der erste Vergleich "case sensitive" - also unter Berücksichtigung von Groß- und Kleinschreibung durchgeführt wurde:
In Java ist der Objektvergleich mit „==“ immer der Vergleich auf ein und dasselbe Objekt. Anderweitige Vergleiche müssen stets mit Hilfsmethoden durchgeführt werden, da „Gleichheit“ vom Anwendungsfall abhängt. Wie sieht das Ganze nun in C# aus? Nun hier hat man sich für einen anderen Weg entschieden. Man geht davon aus, dass ein Vergleich immer auf die Inhalte abzielt.
Listing 44: Vergleich von verketteten Strings in C#
Die Ausgabe hier ist anders als in Java:
C# vergleicht also nicht die Adressen! Der Grund liegt darin, dass die Entwickler von C# den „==“ Operator mit einer anderen Funktionalität „überladen“ haben. Intern wird „string1 == string2“ mit der gleichen Routine verarbeitet wie „string1.Equals(string2)“. Das heißt, der Compiler setzt intern die beiden Codevarianten in die gleiche Funktionalität um. Diese vergleicht dann die beiden Strings case sensitive. Wenn wir case insensitive vergleichen wollen, wird eine andere Form von „Equals“ benötigt, welche allerdings nicht mit „==“ ersetzbar ist:
Listing 45: Case insensitiver Stringvergleich in C#
Wenn man es dann noch genauer nehmen möchte, kann man noch kulturelle Eigenheiten berücksichtigen lassen, wobei dies den Rahmen hier sprengen würde und für den Anfang unserer Programmiererkarriere auch keine große Rolle spielt. Details können wie immer aus der API-Beschreibung entnommen werden. Widmen wir uns nun noch kurz der C++ Variante und sehen, wie es hier gelöst wird:
Listing 46: Vergleich von verketteten Strings in C++
Auch hier erstmal keine besonderen Neuigkeiten. Lediglich bei der Ausgabe ist für C# und Java Programmierer aufgrund der internen Verarbeitung von bool in C++ eher ungewohnt:
Wir erinnern uns: C++ übernimmt 1 als true und 0 als false. Ansonsten zeigt C++ das gleiche Verhalten wie C# in Puncto Stringvergleiche mit „==“. C++ bietet übrigens in der String Klasse keine direkte Hilfsmethode für einen case insensitive Stringvergleich. Diese müssen entweder selbst geschrieben oder eine entsprechende Bibliothek für einen solchen Vergleich eingebunden werden.
Für die wichtigsten Stringfunktionalitäten in Java, C#, C++ und C habe ich hier ein paar Methoden und Funktionen zusammengetragen, wobei „s1“ und „s2“ jeweils für einen beliebigen String steht:
Funktion: Java: C#: C++: C:
Vergleich (case sens.) s1.equals(s2) s1==s2 s1==s2 strcmp(s1,s2)
Substring s1.substring(…) s1.Substring(…) s1.substr(…) -
Teilstring suchen s1.indexOf(s2) s1.IndexOf(s2) s1.find(s2) -
Länge des Strings s1.length() s1.Length s1.length() -
Alles zu Großbuchst. s1.toUpperCase() s1.ToUpper() - -
Alles zu Kleinbuchst. s1.toLowerCase() s1.ToLower() -/td> -
Tabelle 3: Ausgewählte String Funktionalitäten
Wie man sieht, ist bei der Programmiersprache C die Spalte kaum gefüllt – man muss sich hier die Funktionalitäten meist selbst erstellen oder entsprechende Bibliotheken einbinden, welche die gewünschten Funktionen mitliefern. Die objektorientierten Programmiersprachen sind da besser bestückt und ähneln sich durchaus in der Funktionalität. Insofern wird ein Java Programmierer keine großen Probleme haben, sich mit C# zurechtzufinden und umgekehrt.
Einen letzten Punkt bei den beiden objektorientierten Programmiersprachen Java und C# möchte ich aber noch anbringen. Die Strings dieser beiden Programmiersprachen sind „immutable“, sprich unveränderbar. Das bedeutet, dass wenn ein String einmal erzeugt wurde, man diesen nicht verändern kann. Dies kann man mit folgendem Code – erstmal in C++, wo Strings nicht immutable sind, veranschaulichen:
Listing 47: Stringergänzung in C++
Kurze Erklärung der Codezeilen: In die Stringvariable stringVar schreiben wir einen Text „hallo “ und hängen danach „welt“ hinten dran. Am Schluss geben wir stringVar wieder aus.
Nun versuchen wir das Ganze mit Java (in C# ist die einzige Möglichkeit etwas anzuhängen der „+“ Operator):
Listing 48: Stringergänzung in Java
Sehen wir uns auch hier das Ergebnis an:
Das „welt“ wurde also nicht angehängt. Der String stringVar hat sich also nicht verändert. Nun, was soll jetzt aber die Methode „concat“ eigentlich bewirken. Die Lösung hierzu sehen wir im folgenden Code:
Listing 49: Stringergänzung in Java – jetzt richtig
Und jetzt haben wir erst das erwartete Ergebnis:
concat“ hängt schon etwas an den String „stringVar“ dran, ändert ihn aber nicht. Stattdessen wird ein neuer String erzeugt und diesen speichern wir in der Variablen „stringVar2“. Zugegebenermaßen nutzen wir concat eher nicht, sondern gehen mit dem „+“ Operator vor, wie in C# - aber hier lässt sich das unterschiedliche Verhalten von C#/Java und C++ am besten Zeigen. Dieses Verhalten hat nun aber eine recht unschöne Nebenwirkung. Sehen wir uns das mal im Detail in Java an (da hier die interne Adressierung relativ einfach auszulesen ist):
Listing 50: Unveränderbarkeit von Strings in Java
Kurze Erklärung der Codezeilen: Zuerst wird stringVar mit einem Stringwert initialisiert. Danach lassen wir mit der Methode identityHashCode() die interne ID des Objektes ausgeben, mit deren Hilfe Java die einzelnen Objekte innerhalb der Virtuellen Maschine adressiert. Danach hängen wir einen leeren String an. „+=“ bedeutet soviel wie „nehme den String auf der linken Seite und hänge den String auf der rechten Seite hinten dran“. Dadurch ändert sich der Inhalt von stringVar nicht. Wenn wir nun die interne ID wieder ausgeben, sehen wir, ob intern ein neues Objekt erstellt wurde, oder nicht.
Mit anderen Worten: in Java (und C#) wird bei der Änderung eines Strings immer ein neuer String erzeugt! Wenn wir bei den Filezugriffen sind sehen wir, dass dies beim zeichenweisen Einlesen von großen Files ein Problem für das Speichermanagement sein wird, da der Computer die vielen „alten“ Stringobjekte ja wieder los werden muss. So viel vorab – es gibt mit Hilfe von Stringbuffern die Möglichkeit dieses Problem zu umgehen. Doch davon später mehr.
Stellen wir uns für den nächsten Punkt in Sachen Strings folgende Situation vor. Wir haben eine Eingabemaske für Informationen – sagen wir das Alter soll eingegeben werden. Wir wollen nun prüfen, ob der User volljährig ist – sprich mindestens 18, oder minderjährig, also unter 18. In Java können wir bspw. eine sehr einfache Eingabemechanik für Usereingaben verwenden, das JOptionPane.showInputDialog(). Hier geben wir einen beliebigen Text ein und können diesen Text in einer Variablen speichern. Da wir aber nicht ahnen können, ob der User nun Zahlen oder einen beliebigen Text eingibt, ist der Rückgabetyp dieser Funktionalität „String“. Einen String können wir aber bezüglich des Größenvergleiches nicht wie eine Zahl behandeln, weshalb wir den String in einer Zahl „umwandeln“ müssen. Dies ist aber kein Typecast, da wir den String analysieren müssen und dann die Zahl daraus „interpretieren“. Diesen Vorgang der Stringanalyse und der anschließenden Extraktion von Informationen nennt man „Parsen“. Sehen wir uns den entsprechenden Code hierzu an:
Listing 51: Beispiel für Stringparsing
Kurze Erklärung der Codezeilen: Da die JOptionPane nicht im Standardkontext vorhanden ist, müssen wir den Ort javax.swing.* über den import bekannt machen. Mit JOptionPane.showInputDialog("Eingabe Alter:") wird ein kleiner Popup Dialog erstellt, der die Eingabe einfordert. Was immer der User eingibt und mit „OK“ bestätigt, wird durch die Zuweisung in sEingabe übergeben – und zwar als String. Nun wandeln wir den String über Integer.parseInt() in eine Zahl um. Wenn der User etwas eingibt, was keine int Zahl ist, würde das Programm hier abstürzen – was aber für unseren Test OK ist. Nun können wir den geparsten Wert in iAlter ablegen und prüfen.
Wenn wir das Programm starten, dann sehen wir die Eingabemaske. Geben wir nun eine Zahl kleiner 18 ein, erscheint „Minderjährig“, bei einer Zahl größer oder gleich 18 entsprechend „Volljährig“. In C# wäre die Parsefunktion unter „Int32.Parse(sEingabe);“ zu finden, unter C++ gibt es die Funktion „std::stoi(sEingabe);“ oder bei char* Zeichenketten den C-Weg über „atoi(sEingabe)“.
Top
Structs
Die letzte Notwendigkeit für „zusammengesetzte“ Daten machen wir uns am folgenden Beispiel klar. Gehen wir davon aus, dass wir die Position von Personen – sagen wir Spieler in einem Spielfeld – tracken möchten. Hierzu benötigen wir für jeden Spieler drei Informationen: X-Koordinate, Y-Koordinate und den Namen des Spielers.
Kombinierte Informationen pro Element
Abb.: 11: Kombinierte Informationen pro Element
Diese drei Informationen möchten wir jetzt in einem Array halten – allerdings wollen wir pro Array Eintrag alle drei Informationen in einer Einheit verwalten. Eine recht einfache Möglichkeit wäre, die drei Informationen in einem zweidimensionalen Array zu speichern:
Datensätze in zweidimensionalen Array
Abb.: 12: Datensätze in zweidimensionalen Array
Diese Vorgehensweise hat aber einen entscheidenden Nachteil. Die Namen müssen als Strings abgelegt werden – es gibt hier keine Alternative(8). Da in einem zweidimensionalen Array die Datentypen in allen Spalten aber gleich sein müssen sind wir gezwungen, auch die X und Y Koordinaten als Strings abzulegen. Das ist sehr unpraktisch, da wir mit Strings nicht rechnen können. Das heißt, wir können nicht ohne weiteres die Abstände zwischen den einzelnen Spielern berechnen, sondern müssten bei jedem Zugriff erst den String in einen int Wert parsen. Wir benötigen also eine andere Möglichkeit, diese drei Informationen zu bündeln. In der Programmiertechnik finden wir hier zwei unterschiedliche Konzepte, je nachdem ob wir objektorientiert oder strukturiert/prozedural programmieren. Beginnen wir mit dem Ansatz für nicht objektorientierte Programmiersprachen und greifen uns hier wieder C heraus. C erlaubt es uns, sogenannte „Strukturen“ zu erstellen. Dies sind Elemente, welche eine Klammer um die verschiedensten Variablen bilden. Sehen wir uns den Code für unser Datenproblem an:
(8) Zumindest in den statisch typisierten Programmiersprachen
Nutzung eines struct in C
Kurze Erklärung der Codezeilen: Mit „typedef“ kann man einem Datentyp oder einer Struktur einen eigenen Namen geben – in unserem Fall wollen wir ein neues „struct“ benennen. In den geschweiften Klammern reihen wir die einzelnen Teilinformationen mit entsprechenden Datentypen auf. Dies können alle bis dahin bekannten Datentypen sein (also auch structs, welche vorher definiert wurden). Beim Namen setzen wir einfach einen char Pointer, da wir den Namen später nicht ändern wollen. Danach erzeugen wir eine Variable „p1“ vom Typ „player“, was ja unser neu erzeugtes struct ist. Der Zugriff auf die inneren Elemente des structs erfolgt mit dem „Punktoperator“. Insofern können wir die Elemente mit Werten belegen und auch wieder ausgeben.
Die Ausgabe sollte dadurch nachvollziehbar sein:
Wir können die Spieler somit auch in ein Array eintragen – hier beispielhaft mit den ersten beiden:
Listing 53: Array of struct in C
Es ist zugegebenermaßen in dem Programm kein großer Vorteil darin zu erkennen, Arrays zu nutzen. Wenn wir uns aber später mit Schleifen beschäftigen, werden wir sehen, dass wir die Zugriffe auf unsere Arrays sehr viel kompakter programmieren können.
Jetzt, da wir das struct verstanden haben, muss ich noch auf ein in C relativ häufig genutztes Konzept eingehen, den verketteten Listen. Die Idee ist hier, dass wir ein struct realisieren, welches einen Zeiger auf ein weiteres struct beinhaltet. Hierdurch können wir eine Kette bilden:
Verkettete Liste
Abb.: 13: Verkettete Liste
Diese verkettete Liste (oder engl. „linked list“) benötigt also ein struct, in dem die Referenz auf das nächste Element als Zeiger abgelegt werden kann und darüber hinaus die eigentlichen Daten, die als Liste abgelegt werden sollen („Payload“). Sehen wir uns ein einfaches Beispiel hierzu mal an:
Listing 54: Verkettete Listen in C
Kurze Erklärung der Codezeilen: Das struct muss einen Namen bekommen, damit wir intern wieder darauf verweisen können (elmt). Unsere Payload soll ein Text sein und next ein Zeiger auf ein „struct namens elmt“. Damit ich später bei der Erzeugung auf „struct namens elmt“ verzichten kann, mache ich mit typedef einen Datentyp namens „element“ daraus. Nun erzeuge ich in der main Funktion einen Zeiger auf ein element in der Variable el0, was unser nulltes Element darstellt (mitunter auch „Kopf“ „root“ oder „header“ genannt). Nun muss ich C noch explizit sagen, dass ich einen Speicherplatz für dieses Element benötige, was ich mit malloc() mache (weshalb auch stdlib.h benötigt wird). Damit der Rechner aber weiß, wie viel Speicherplatz benötigt wird, nutze ich hierfür die Funktion sizeof(), welche als Parameter entweder ein Datentypkonstrukt oder ein Zeiger für die Ermittlung des belegten Speicherplatzes erwartet. Nach der Speicherplatzreservierung kann ich nun Werte eintragen. Der next Wert sollte immer definiert sein, weshalb ich immer gleich NULL eintrage – es wird also nicht auf ein nächstes Element gezeigt. Nun erzeuge ich auf die gleiche Weise ein weiteres Element el1. Jetzt kann ich den Zeiger von el0->next auf das neue Element el1 zeigen lassen (der Zugriff auf Zeiger in structs wird über „->“ realisiert). Danach wird ein weiteres Element angehängt. Für die Ausgabe erzeuge ich eine Variable für die Verarbeitung myEl und lasse sie auf das nullte Element zeigen. Solange nun in myEl keine NULL steht, kann ich die Payload ausgeben. Nach der Ausgabe setze ich den Zeiger von myEl auf das nächste Element – eben bis hier NULL steht.
Die Ausgabe des Codes lautet somit:
Structs sind in C also ein wesentliches Element, um überhaupt sinnvoll und wie der Name schon sagt „strukturiert“ zu arbeiten. Wie sieht das Ganze denn nun in objektorientierten Sprachen aus? C++ und C# kennen das Konzept von struct auch. Man kann also dort den „klassischen“ Weg über struct gehen oder über den Objektweg in Form von Klassen. Bevor ich nun auf den objektorientierten Weg eingehe, noch ein kurzer Hinweis für C++ und C#, wann ein struct sinnvoller ist und wann eine Klasse. Klassen sind „komfortabler“ in der Nutzung, da wir beim Belegen von Werten auch Funktionalitäten hinterlegen können, sogenannte „Setter“ – was wir im Teil „Objektorientierung“ näher betrachten werden. Structs hingegen haben eine sehr viel einfachere Speicherstruktur, weshalb sie in puncto Performance schneller sind als Klassen und Objekte. Sehen wir uns nun mal eine objektorientierte Lösung für unsere Spielerdaten an, wobei ich nur dort auf das Kapitel über die Objektorientierung vorgreife, wo es für die Erklärungen hier notwendig ist. Zuerst erstellen wir uns eine Klasse für die Spieler:
Listing 55: Klasse als Datencontainer in Java
Kurze Erklärung der Codezeilen: Das ganze Konstrukt ist eine sogenannte „Klasse“, weshalb wir die Schlüsselwörter public class benötigen. Innerhalb der Klasse reihen wir wieder – wie im struct auch – die einzelnen Datentypen nacheinander.
Diese Klasse würden wir in ein eigenes File auslagern. Die Nutzung der Klasse sieht dann wie folgt aus:
Listing 56: Nutzung einer Klasse als Datencontainer in Java
Kurze Erklärung der Codezeilen: Das einzig wirklich neue ist „new Player()“. Mit diesem Befehl erzeugen wir im Speicher ein neues Objekt aus der Klasse „Player“. Die Nutzung ist auf den ersten Blick genauso wie die struct Nutzung in C.
Mit Ausnahme der Ausgabe ist der Code in Java und C# identisch. Wie in C auch, können wir jetzt in Java oder C# auch ein Array aus diesen Objekten bilden:
Listing 57: Array of Object in Java
Kurze Erklärung der Codezeilen: Auch hier erkennen wir die Parallelen zu C. Das Einzige, worauf man hier wirklich achten muss ist, dass wir für jeden Arrayeintrag mit new Player() das Objekt wirklich erzeugen müssen. Bei der Erzeugung des allPlayer Arrays wird in Java und in C# das Array mit null Werten initialisiert, wie wir es schon bei den „Arrays von Arrays“ gesehen haben.
So weit haben wir nun die wichtigsten Möglichkeiten gesehen, Daten strukturiert im Speicher abzulegen, womit wir einen großen Schritt in Richtung Programmierung gegangen sind.
Top
CC Lizenz (BY NC SA)
CC Lizenz (BY NC SA)