Java Standard: Zeichenketten


Auch wenn sich das Arbeiten mit Zeichenketten aus Sicht der Sprache Java nur in wenigen Aspekten vom Arbeiten mit anderen Objekten unterscheidet, ist es doch besonders für Anfänger wichtig, ein paar grundlegende Dinge zu wissen und Hinweise zu beachten, die in diesem Kapitel besprochen werden.

Allgemeine Benutzung von Strings

Bearbeiten

Im Gegensatz zu anderen Objekten müssen Strings nicht mit dem 'new'-Operator erzeugt werden. Stattdessen werden sie typischerweise einfach in Anführungszeichen in den Quellcode geschrieben. Das Arbeiten mit Strings erfolgt im Wesentlichen mit normalen Methoden wie bei anderen Objekten auch. Zusätzlich gibt es allerdings auch noch den Konkatenationsoperator '+', durch den mehrere Strings hintereinandergehängt werden können. Auch wenn dieser Operator üblicherweise für die arithmetische Additionsoperation benutzt wird, so kann er in Java eben auch benutzt werden, um mehrere Strings aneinanderzuhängen (zu 'konkatenieren').

Wenn der '+'-Operator auf zwei Argumente angewandt wird, von denen nur einer ein String ist, so wird der andere Operand ebenfalls in einen String umgewandelt, damit die Konkatenation durchgeführt werden kann. Im Falle der primitiven Datentypen bedeutet dies, dass einfach eine String-Repräsentation des aktuellen Wertes der jeweiligen Variable erstellt und verwendet wird (für den int-Wert 42 also der String "42", oder für die booleschen Werte "true" bzw. "false"). Handelt es sich bei dem Operator allerdings um ein Objekt, wird zur Generierung einer textuellen Beschreibung automatisch die toString()-Methode des Objektes aufgerufen. Da diese Methode bereits in der Klasse Object (der Wurzel der gesamten Klassenhierarchie) definiert ist, ist sie für jedes beliebige Objekt vorhanden. In der Standardversion gibt diese Methode einfach den Namen der Klasse des Objektes und eine Objektkennung aus. Sofern die Methode für die Klasse des jeweiligen Objekts jedoch überschrieben wurde, kann hier natürlich auch eine andere Beschreibung herauskommen.

Sonderzeichen in Strings

Bearbeiten

Neben ganz normalen Zeichen wie Ziffern, Buchstaben, Leerzeichen usw. können in Strings auch spezielle Zeichen eingebaut werden, die besondere Bedeutungen haben. So steht das Zeichen "\n" etwa für einen Zeilenumbruch (newline), also nicht für ein darstellbares Zeichen im üblichen Sinne. Dabei ist zu beachten, dass die Zeichenkette "\n" nur ein einziges Zeichen enthält und nicht zwei, wie man vielleicht annehmen könnte. Der umgekehrte Schrägstrich (Backslash) weist dem "n" hier lediglich eine besondere Bedeutung zu. Ein anderes Beispiel für ein solches Spezialzeichen ist der Tabulator, der durch "\t" dargestellt wird. Will man das Zeichen '\' selbst in einem String kodieren, so muss man das Zeichen doppelt (also '\\') angeben.

Diese Spezialzeichen sind übrigens nicht nur auf Strings beschränkt, sondern werden ebenso bei der Definition einzelner Zeichen des primitiven Typs char verwendet. Dies ist nicht weiter verwunderlich, da die Zeichenketten in String-Objekten intern letzlich durch Arrays des Typs char[] dargestellt werden. Das Zeichen '\n' stellt demnach ebenfalls das Newline-Zeichen dar, nur eben in Form des primitiven Typs char.

Veränderungen von Strings

Bearbeiten

Aus Effizienzgründen sollte bei der Arbeit mit Strings generell beachtet werden, dass diese in Java nicht veränderlich sind. Wenn ein Objekt des Typs String einmal angelegt wurde, kann es prinzipiell nie wieder verändert werden. Natürlich kann man jetzt denken, dass das ja nicht stimmt, denn schließlich kann man ja den folgenden Code schreiben, um einen String zu erzeugen:

 String s = "ein " + "String " + "aus " + "mehreren " + "Teilen";

Tatsächlich wird bei diesem Code aber nicht nur ein String-Objekt erzeugt, sondern derer gleich neun. Es wird nämlich für jeden literalen String in dieser Zeile ein Objekt erzeugt (also für "ein ", für "String " usw.), wodurch bereits fünf Objekte erzeugt werden müssen. Das Zusammenfügen dieser Strings erfolgt anschließend stückweise, wodurch auch noch die Strings "ein String ", "ein String aus ", "ein String aus mehreren " sowie "ein String aus mehreren Teilen" erzeugt werden. Letztlich wird jedoch nur ein einziger diese Strings tatsächlich benötigt, nämlich der am Ende resultierende. Die anderen acht Stücke werden anschließend nicht wieder benötigt und werden von der Garbage Collection entsorgt.

Eine Anmerkung am Rande: Es mag durchaus sein, dass die genannte Rechnung für das obige Beispiel nicht wirklich stimmt, da der Compiler diese Zeile bereits beim Übersetzen optimieren kann (da er ggf. erkennen kann, dass sich an den einzelnen Bestandteilen dieser Strings nichts ändern kann). Üblicherweise benutzt man in solchen Zeilen aber oftmals Variablen zur Konkatenation, deren konkreter Wert zur Zeit der Übersetzung noch nicht festliegt. In solchen (in der Praxis häufigen) Fällen würden dann auf jeden Fall derartig viele Objekte erzeugt werden müssen.

Auch wenn die Unveränderlichkeit von Strings ihre Vorteile hat, führt sie oft besonders bei Programmieranfängern unwissentlich zu Programmen oder Programmteilen, die sehr ineffizient arbeiten. Daher sollte man sich die folgende Regel gut einprägen: Wenn Strings in größerem Umfang verändert werden müssen, sollte statt der Klasse String immer die Klasse StringBuffer verwendet werden!

Die Klasse StringBuffer

Bearbeiten

Diese Klasse realisiert Objekte, die wie Strings ebenfalls Zeichenketten darstellen. Allerdings sind die Objekte dieser Klasse veränderlich, wodurch sie String-Manipulationen wesentlich effizienter durchführen können. Dadurch wird erreicht, dass weniger String-Objekte erzeugt werden müssen, um einen neuen String aus verschiedenen Teilen zusammenzusetzen. Objekte der Klasse StringBuffer besitzen z.B. verschiedene Varianten einer Methode append(), mit der ein gegebener Wert (ein String oder der ein Wert eines primitiven Typs) an den bisherigen Wert angehängt werden kann.

Wurde die gewünschte Zeichenkette dann erstellt, kann sie anschließend mit der toString()-Methode wieder in Form eines normalen String-Objekts umgewandelt werden.

Wenn man bereits vor der Erzeugung der gewünschten Zeichenkette etwa abschätzen kann, wie lange der String am Ende sein dürfte, kann man dies bereits im Konstruktor des StringBuffer-Objektes angeben. Dadurch wird für das Objekt Platz für entsprechend viele Zeichen reserviert. Wird der vorhandene Platz später dann doch überschritten, muss der interne Puffer vergrößert werden. Dies benötigt natürlich zusätzlichen Speicherplatz und auch Laufzeit, geschieht aber unbemerkt vom Benutzer des Objekts.

Nun zurück zum obigen Beispiel, diesmal mit einem StringBuffer realisiert. Der entsprechende Code könnte hier lauten:

 StringBuffer sb = new StringBuffer();
 sb.append("ein ");
 sb.append("String ");
 sb.append("aus ");
 sb.append("mehreren ");
 sb.append("Teilen");
 String s = sb.toString();

Auch mit dieser Variante müssen einige String-Objekte erzeugt werden, nämlich sechs Stück (Die fünf literalen Strings für die append()-Aufrufe sowie der letztendliche String s. Zudem wird jetzt natürlich auch noch das StringBuffer-Objekt benötigt, wodurch das Beispiel jetzt sieben statt der ursprünglichen neun Objekte beansprucht. Diese Differenz mag nicht besonders groß erscheinen, jedoch kann sie sich ganz erheblich in der Laufzeit niederschlagen, insbesondere wenn derartige Operationen sehr oft (z.B. in Schleifen) durchgeführt werden müssen.

Um dies zu verdeutlichen folgt hier nun ein vollständiges Programmbeispiel. Dabei wird für beide Varianten jeweils derselbe lange String in einer Schleife zusammengebaut. Zudem wird der Zeitverbrauch beider Varianten gemessen und anschließend ausgegeben. Hier das Programm:

 public class StringBufferTimeTest
 {
   public static void main(String args[])
   {
     final int LOOP_COUNT = 1000;
     
     
     // Variante nur mit String-Objekten
     long start = System.currentTimeMillis();
     String s = "Eine Rose ";
     for (int i = 0; i < LOOP_COUNT; i++)
     {
       s = s + "ist eine Rose ";
     }
     long end = System.currentTimeMillis();
     
     System.out.println("Zeit mit Strings [ms]: " + (end - start));
     
     
     // Variante mit einem StringBuffer-Objekt
     start = System.currentTimeMillis();
     StringBuffer sb = new StringBuffer("Eine Rose ");
     for (int i = 0; i < LOOP_COUNT; i++)
     {
       sb.append("ist eine Rose ");
     }
     String s2 = sb.toString();
     end = System.currentTimeMillis();
     
     System.out.println("Zeit mit StringBuffer [ms]: " + (end - start));
   }
 }

Erläuterung des Beispiel-Codes

Bearbeiten

Bevor auf die Ergebnisse des Programms eingegangen wird, erfolgt hier zunächst eine kurze Erklärung dieses Quelltextes. Die Konstante LOOP_COUNT (Zeile 5) legt fest, wie oft die Schleifen der beiden Varianten im Folgenden durchlaufen werden sollen. Hier wurde sie nur beispielhaft auf einen Wert von 1000 gesetzt.

Die Verarbeitung für die beiden Varianten beginnt jeweils damit, dass die aktuelle Zeit in der Variablen start (Zeile 9 und 21) gespeichert wird. Die statische Methode currentTimeMillis() der Klasse System liefert dazu die aktuelle Anzahl der verstrichenen Millisekunden seit dem 01.01.1970 zurück (diese Art der Zeitmessung ist in vielen Programmiersprachen bzw. Systemen üblich). Nach der Verarbeitung der Schleife wird erneut eine solche Zeitmessung durchgeführt und die Differenz jeweils mit einer Nachricht auf die Konsole geschrieben. Dadurch erhält man also die Anzahl der benötigten Millisekunden für den Programmcode, der sich dazwischen befindet.

Hier wird in beiden Varianten in der Schleife ein langer String zusammengesetzt, der letzlich den Inhalt "Eine Rose ist eine Rose ist eine Rose ist eine Rose ..." haben wird. Bei der Variante ohne StringBuffer wird an den bestehenden String s in jedem Schleifendurchlauf erneut der String "ist eine Rose " angehängt (Zeile 13). Aufgrund der Unveränderlichkeit von Strings führt das jedoch dazu, dass das alte String-Objekt in jedem Schleifendurchlauf verworfen wird und ein ganz neues Objekt angelegt werden muss. Das führt zu einem hohen Aufwand im Vergleich zur darauf folgenden Variante, die Gebrauch von einem StringBuffer macht. Hier wird der String in jedem Schleifendurchlauf in das existierende StringBuffer-Objekt eingefügt (Zeile 25), ohne dass ein neues Objekt erzeugt werden muss. Auch wenn der StringBuffer seinen internen Puffer hierbei gelegentlich vergrößern muss, ist diese Variante doch wesentlich effizienter, wie im Folgenden dargestellt wird.

Vorab noch eine Randbemerkung: Für den konstanten String "ist eine Rose " muss innerhalb der beiden Schleifen nicht bei jedem Schleifendurchlauf ein neues String-Objekt angelegt werden, auch wenn es auf den ersten Blick vielleicht so wirken mag. Auch hier kann der Compiler bereits zur Übersetzungszeit Optimierungen vornehmen, etwa indem dieser String aus der Schleife herausgezogen wird. Das ändert nichts an der Funktionalität des Programms, da sich der String ja nicht verändern kann.

Ergebnisse des Beispiels

Bearbeiten

Um nun zu untersuchen, wie stark sich die Laufzeit der beiden Varianten unterscheidet, wurde das Beispielprogramm mehrmals ausgeführt, wobei für die Konstante LOOP_COUNT jeweils verschiedene Werte genutzt wurden. Die folgende Tabelle zeigt die Laufzeiten (in Millisekunden), die sich bei verschiedenen Werten für die Schleifendurchläufe für die beiden Varianten ergeben haben. Ein Wert von 0 Millisekunden bedeutet dabei aber natürlich nicht, dass das Programm gar keine Zeit benötigt hätte, sondern heißt nur, dass die Zeit so gering war, dass sie von der Zeitmessung nicht mehr erfasst werden konnte. Der Vollständigkeit halber: Die Testläufe wurden auf einem PentiumM-Notebook mit 1,4 GHz durchgeführt. Natürlich kann der Leser mit obigem Programmcode auch selbst experimentieren, um diese Ergebnisse nachzuvollziehen.

10 100 1.000 10.000 20.000
String 0 10 71 55.670 218.264
StringBuffer 0 0 0 20 20

An der Tabelle kann man sehr deutlich erkennen, dass die Laufzeit für die Variante ohne StringBuffer für eine große Anzahl an Schleifendurchläufen geradezu explodiert. Bereits bei 10.000 Schleifendurchläufen benötigt diese Variante beinahe eine ganze Minute, während die Lösung mit StringBuffer noch weit von einer Laufzeit von einer Sekunde entfernt ist. Dieses Beispiel zeigt also sehr deutlich, wie stark die Art der Implementierung Einfluss auf die Laufzeit haben kann.