C# String Performanz – Macht Optimierung Sinn?

Hi,

Worum geht es hier?

String sind Zeichenketten in Programmiersprachen. Ein wichtiger Aspekt bei C# (und einigen anderen Sprachen) ist, das Strings immutable (unveränderlich) sind.

Was kann man sich darunter vorstellen?

Ein String ist eine Zeichenkette, z.B. „Hallo“. Intern wird der String in einem zusammenhängendem Datenbereich, einem Array, als einzelne Zeichen gespeichert. Hallo ist also [‚H‘,’a‘,’l‘,’l‘,’o‘]. Möchte man einen String mit einem anderen verknüpften, so kann dies nicht im gleichen Array passieren (es ist ja schon sozusagen „voll“). Je nach Technik wird im Normalfall das Array in ein neues Array umkopiert, welches genug Platz für den neuen String bietet.

Sagen wir, wir möchten „Hallo Welt“ ausgegeben.
Die einfachste Methode, Strings aneinanderzuhängen ist der +-Operator:

string einNeuerString = "Hallo" + " Welt";

Intern passiert nun das folgende:
[‚H‘,’a‘,’l‘,’l‘,’o‘], [‚ ‚,’W‘,’e‘,’l‘,’t‘] sind die beiden Eingänge.
Schritt 1:
Anlegen von einem neuen Array
[“,“,“,“,“,“,“,“,“,“]
Kopieren der Zeichen aus [‚H‘,’a‘,’l‘,’l‘,’o‘],
[‚H‘,’a‘,’l‘,’l‘,’o‘,“,“,“,“,“]
Kopieren der Zeichen aus [‚ ‚,’W‘,’e‘,’l‘,’t‘]
Ergebnis: [‚H‘,’a‘,’l‘,’l‘,’o‘,“,’W‘,’e‘,’l‘,’t‘]

Zusammenfügen von Strings passiert relativ häufig, z.B. wenn ein Benutzer einer Webseite oder eines Newsletter mit Namen angesprochen werden soll. Es gibt verschiedene Wege, verschiedene String zusammenzufügen, welche unterschiedlich schnell sind.

In Foren liest man häufig, dass man einen StringBuilder verwenden soll. Das ist manchmal richtig, manchmal wenn man auf Geschwindigkeit sieht falsch. Ich zeige das hier an 5 Kommandos um Strings in C# zusammenzufügen.

+-Operator:

string c = a + b;

concat:

string c = String.Concat(a, b);

Format:

string c = String.Format("{0}{1}", a, b);

String Interpolation:

string c = $"{a}{b}";

StringBuilder:

StringBuilder stringBuilder = new StringBuilder(a); 
stringBuilder.Append(b);

Wenn im Internet Messungen veröffentlich sind, wird häufig einfach einige tausend bis Millionen mal ein String an andere drangehangen. Folgendes Konstrukt nutze ich, um die Geschwindigkeit zu messen:

MeasureAndPrint nimmt eine andere Methode. Diese wird zuerst in WArmUp einmal ausgeführt (ein Vorladen, um Kompilieren während der Laufzeit zu verhindern (bei komplexeren Sachen nötig)). C# verwaltet den Speicher automatisch. Um zu verhindern, dass auf Werte im Speicher zurückgegriffen wird, wird die Aufräumfunktion der GarbageCollectors genutzt. Danach führt MeasureAndPrint die Einzelmehtode aus, die 40000 mal einfach eine Zahl an den Eingangsstring anhängt.

using System;
using System.Diagnostics;
using System.Text;

namespace SpeedOfStrings
{
    class Program
    {
        static int iterations = 1;
        static int repeats = 40000;
        static string initString = "Init";

        static void Main(string[] args)
        {
            Func<string> plus = StringAppendUsingPlus;
            Func<string> concat = StringAppendUsingConcat;
            Func<string> format = StringAppendUsingFormat;
            Func<string> interpolation = StringAppendUsingInterpolation;
            Func<string> builder = StringAppendUsingStringBuilder;

            iterations = repeats;

            MeasureAndPrint(plus);
            MeasureAndPrint(concat);
            MeasureAndPrint(format);
            MeasureAndPrint(interpolation);
            MeasureAndPrint(builder);
            Console.ReadLine();
        }

        private static void MeasureAndPrint(Func<string> func)
        {
            WarmUp(func);

            iterations = repeats;

            Stopwatch stopWatch = new Stopwatch();
            stopWatch.Start();
            func();
            stopWatch.Stop();
            Console.WriteLine("{0} braucht {1} ms", func.Method.Name, stopWatch.ElapsedMilliseconds);
        }

        private static void WarmUp(Func<string> func)
        {
            iterations = 1;
            func();
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
        }
/* zu messende Methoden */

    }
}

Die Methoden die gemessen werden sollen sind einfach wie oben beschrieben +, Concat, String.Format, StringInterpolation und StringBuilder.

Zu + gibt es eigentlich nichts zu sagen:

        private static string StringAppendUsingPlus()
        {
            string temp = initString;
            for (int i = 0; i < iterations; ++i)
                temp += i.ToString();
            return temp;
        }

+ und Concat sind intern ähnlich. Sollte also ähnliche Werte liefern.

        private static string StringAppendUsingConcat()
        {
            string temp = initString;
            for (int i = 0; i < iterations; ++i)
                temp = String.Concat(temp, i);
            return temp;
        }

String.Format ist eine Möglichkeit um einfach Text zu formatieren

String.Format("Hallo {0}, wie geht's", name);

setzt einfach einen vorher definierten Namen an die Stelle {0}. Mit + müsste man dies so schrieben:

"Hallo " + name + ", wie geht's";

(+ würde also zweimal aufgerufen werden).

        private static string StringAppendUsingFormat()
        {
            string temp = initString;
            for (int i = 0; i < iterations; ++i)
                temp = String.Format("{0}{1}", temp, i);
            return temp;
        }

StringInterpolation ist einfach eine neue Art, Formatbefehle zu vereinfachen.

$"Hallo {name}, wie geht's";

Hier sollte also die Geschwindigkeit vergleichbar mit String.Format sein.

        private static string StringAppendUsingInterpolation()
        {
            string temp = initString;
            for (int i = 0; i < iterations; ++i)
                temp = $"{temp}{i}";
            return temp;
        }

StringBuilder ist ein extra Objekt, welches Strings schnell zusammenfügen soll:

        private static string StringAppendUsingStringBuilder()
        {
            StringBuilder stringBuilder = new StringBuilder(initString);
            for (int i = 0; i < iterations; ++i)
                stringBuilder.Append(i);
            return stringBuilder.ToString();
        }

Wenn obiges als Programm läuft, kommt man auf meinem betagten i5-3337u zu folgendem Ergebnis:

StringAppendUsingPlus braucht 2449 ms
StringAppendUsingConcat braucht 1926 ms
StringAppendUsingFormat braucht 4674 ms
StringAppendUsingInterpolation braucht 4915 ms
StringAppendUsingStringBuilder braucht 6 ms

+ und Concat liegen tatsächlich in ähnlichen Regionen und sind grob doppelt so schnell wie Format oder StringInterpolation. StringBuilder ist wahnsinnig viel schneller. Nur leider macht dieses Messen in der Praxis nicht so viel Sinn. Was haben wir oben gemacht? Wir haben einen String gebildet, an den 40000 mal etwas angehangen wurde, also ein Riesenteil.

Machen wir es doch mal etwas anders. Wir rufen für „Hallo Welt“, „Hallo Wolfgang“, „Hallo xyz“,… ja eigentlich nur einmal den Operator auf, aber haben zig verschiedene Aufrufe. Messen wir also das: 40000 Mal wird ein einzelnes Zusammenhängen getestet, statt einmal 40000 Zusammenhänge.

using System;
using System.Diagnostics;
using System.Text;

namespace SpeedOfStrings
{
    class Program
    {
        static int iterations = 1;
        static int repeats = 40000;
        static string initString = "Init";

        static void Main(string[] args)
        {
            Func<string> plus = StringAppendUsingPlus;
            Func<string> concat = StringAppendUsingConcat;
            Func<string> format = StringAppendUsingFormat;
            Func<string> interpolation = StringAppendUsingInterpolation;
            Func<string> builder = StringAppendUsingStringBuilder;

            iterations = 1;

            MeasureNTimes(plus, repeats);
            MeasureNTimes(concat, repeats);
            MeasureNTimes(format, repeats);
            MeasureNTimes(interpolation, repeats);
            MeasureNTimes(builder, repeats);
            Console.ReadLine();
        }

        private static void MeasureNTimes(Func<string> func, int n)
        {
            WarmUp(func);

            iterations = 1;

            Stopwatch stopWatch = new Stopwatch();
            stopWatch.Start();
            for (int i = 0; i < n; i++)
                func();
            stopWatch.Stop();
            Console.WriteLine("{0} braucht {1} ms", func.Method.Name, stopWatch.ElapsedMilliseconds);
        }

        /* zu messende Methoden */
    }
}

Unterscheidet sich das Ergebnis? Ja, extrem:

StringAppendUsingPlus braucht 8 ms
StringAppendUsingConcat braucht 9 ms
StringAppendUsingFormat braucht 15 ms
StringAppendUsingInterpolation braucht 20 ms
StringAppendUsingStringBuilder braucht 17 ms

Statt fast 5000 Sekunden benötigt die langsamste Art nur noch 20 ms. + und Concat sind immer noch flotter, aber StringBuilder ist auf einmal relativ langsam. Das Problem an der Sache ist, dass das Erzeugen des StringBuilder Objekts aufwendig ist. StringBuilder lohnen sich also erst dann, wenn man viele Werte auf einmal zusammenhängen will. Bei der Messung ist auch jeweils der Methodenaufruf in der Zeit enthalten, dieser sollte aber bei den verschiedenen Methoden gleich lang brauchen. Die Werte dienen daher nur dem Verhältnis der Methoden untereinander.

Lohnt es sich jetzt also, immer im normalen Umgang + zu verwenden?

Ich denke nicht. In dem hier angegebenen Fall sind es 40.000 Wiederholungen. Eine Einzeloperation ist also im Zeitbereich von 225 Nanosekunden bei + gegen 500 Nanosekunden bei Interpolation (Eine Nanosekunde = 1/1000000 Sekunde). Also wenn man nicht hauptsächlich Strings zusammenhängt eine vernachlässigbar kleine Zeit.

Was ich hiermit daher zeigen wollte: Es ist relativ egal was man nutzt. Ich finde es wichtiger, dass man es einigermassen durchzieht, damit Quellcode lesbar bleibt. String.Format und StringInterpolation finde ich dabei am lesbarsten.

Nehmen wir an, wir wollen dem Benutzer sagen: „Guten Tag Young, heute ist Dienstag“

"Guten Tag " + name + " heute ist " + wochentag;

– je länger das wird, desto mehr wird es zu einer +-Wüste.

String.Format("Guten Tag {0} heute ist {1}", name, wochentag);

trennt Code von Formatierung.

String.Format hatte immer den Nachteil, dass Änderungen eher blöd waren. Beispiel
Bei einer Änderung zu Vorname und Zuname verschieben sich die ganzen Angaben:

String.Format("Guten Tag {0} {1} heute ist {2}", vorname, name, wochentag);

Das hintere {1} zu {2} ist fehlerträchtig. Und je mehr Variablen man nutzt, desto unübersichtlicher wird es.

Interpolation:

$"Guten Tag {name} heute ist {wochentag}";

wird einfach zu

$"Guten Tag {Vorname} {name} heute ist {wochentag}";

Das neue StringInterpolation Feature von C# finde ich bietet da am meisten Übersicht. (Man bastele mal einen langen SQL-String zusammen um zu sehen, was ich meine ;-))

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.