CodeGym /Kurse /C++ SELF /String-Aufteilung in Tokens mit Schleifen

String-Aufteilung in Tokens mit Schleifen

C++ SELF
Level 9, Lektion 3
Verfügbar

1. Einführung

Wenn man mit dem Programmieren anfängt, wirkt ein String oft wie ein Ganzes: „das ist der Text, das gebe ich aus, alles gut“. Sehr schnell stellt sich jedoch heraus, dass ein String ein Container von Zeichen ist und Bedeutung erst entsteht, wenn wir Teile extrahieren: Wörter, Befehle, Parameter. Tokenisierung ist wie Gemüse schneiden vor der Suppe: wenn man die Karotte ganz reinschmeißt, ist sie technisch „in der Suppe“, aber essen lässt sie sich schlecht (ähnlich wie Eingaben zu parsen, ohne sie aufzuteilen).

Es gibt auch einen praktischen Grund, warum wir das manuell machen. In der Standardbibliothek von C++ gibt es keine „eine magische Funktion split()“, die immer genau das tut, was man braucht, und zusätzlich für einen Einführungskurs bequem bleibt. Es gab Vorschläge zur Verbesserung der String-Splitting-Funktionalität im Standard, aber das wird nicht zur „einfachen Schaltfläche“ für Anfänger. Deshalb lernen wir heute die grundlegende Mechanik: wir nehmen einen String und extrahieren die Tokens selbst, mit Schleifen und substr().

Begriffe: Token, Trenner und Grenzen

Bevor wir Code schreiben, ist es nützlich, die Begriffe zu klären — sonst diskutieren wir später nicht über das Programm, sondern über die Bedeutung von Wörtern, was besonders ärgerlich ist, weil man sich auch über das Programm streiten könnte. Tokenisierung ist der Prozess, Tokens zu extrahieren; ein Token ist ein Stringfragment, und ein Trenner ist ein Zeichen (oder eine Menge von Zeichen), das Tokens voneinander trennt. Und ja: die häufigste Ursache für Bugs bei der Tokenisierung ist kein „komplizierter Algorithmus“, sondern einfacher Zugriff außerhalb der String-Grenzen.

Token — ein zusammenhängendes Stück String zwischen Trennern. Zum Beispiel sind in dem String add milk 2 die Tokens add, milk, 2.

Trenner — das, was Tokens trennt. Heute ist das meistens ein Leerzeichen ' ', manchmal ein Zeichen wie '=' im Format key=value.

String-Grenze — die Bedingung i < s.size(). Das ist Ihre „Lebenslinie“. Bricht man sie, fängt das Programm an, sich wie eine Katze zu verhalten, die „nur an der Kerze schnuppern wollte“.

Indizes im String und warum das fast immer std::size_t ist

Wenn man mit einer Schleife durch einen String läuft, hat man einen Index. Es ist verlockend, einfach int i = 0; zu schreiben — weil das „einfacher“ erscheint. Aber String-Methoden (size(), find()) arbeiten mit std::size_t — ein vorzeichenloser Ganzzahltyp, geeignet für Größen und Indizes. Deshalb bemühen wir uns in diesem Abschnitt, Indizes ebenfalls so zu deklarieren.

Kleiner Orientierungspunkt:

Was es ist Typ am häufigsten
Index eines Zeichens im String
std::size_t
Länge des Strings
std::size_t
„Anzahl von etwas“
std::size_t

Kurzbeispiel (nur um den Typ zu spüren):

#include <iostream>
#include <string>

int main() {
    std::string s = "abc";
    std::size_t n = s.size();
    std::cout << n << '\n'; // 3
}

substr(pos, count): der zweite Parameter ist die Länge

Hier stolpern Studenten regelmäßig über einen „stillen Bug“: man denkt, substr(i, j) bedeute „von i bis j“. In C++ ist das nicht so.

substr(pos, count) heißt: nimm count Zeichen, beginnend bei pos.

Wenn also die Token-Grenzen [i, j) sind (wie in der Mathematik und in den meisten Algorithmen), dann ist die Länge des Tokens j - i, und die korrekte Schreibweise lautet:

token = s.substr(i, j - i);

Kleines Beispiel:

#include <iostream>
#include <string>

int main() {
    std::string s = "hello world";
    std::cout << s.substr(0, 5) << '\n'; // hello
}

3. Grundlegender Tokenisierungsalgorithmus: „überspringen → erfassen → wiederholen“

Bei der manuellen Tokenisierung gibt es eine schöne Eigenschaft: sie läuft fast immer auf das gleiche Grundgerüst hinaus. Wir versuchen nicht, den ganzen Text auf einmal zu verstehen — wir gehen von links nach rechts und lösen bei jedem Schritt zwei Aufgaben. Erste: die Trenner überspringen. Zweite: die Grenze des Tokens finden. Am Ende schneiden wir das Token mit substr() aus und machen weiter. Das ist vergleichbar mit dem Lesen eines Textes mit den Augen: man versucht nicht, eine ganze Seite auf einmal zu erfassen, man springt von Wort zu Wort.

Die Schlüsselfrage ist, mit Grenzen zu arbeiten und s[i] nicht zu lesen, bevor man nicht geprüft hat, dass i < s.size().

Zwei Indizes: i und j

Das für Einsteiger geeignetste Modell sind zwei Indizes.

i — wo wir gerade sind.
j — wie weit das Token reicht (die erste Position nach dem Token).

Dann ist das Token der Bereich [i, j). Folglich:

  • Anfang des Tokens = i
  • Ende des Tokens (exklusiv) = j
  • Länge des Tokens = j - i
  • das Token selbst = s.substr(i, j - i)

Hier ist ein Mini-Skelett ohne Details:

std::size_t i = 0;
while (i < s.size()) {
    // 1) Trenner überspringen
    // 2) j finden
    // 3) token = substr(i, j - i)
    // 4) i = j
}

Mini-Flussdiagramm des Algorithmus

Manchmal ist es hilfreich, das einmal als Prozess zu sehen, statt als Codezeilen.

flowchart TD
    A[Start: i = 0] --> B{"i < s.size()?" }
    B -- nein --> Z[Ende]
    B -- ja --> C["Trenner überspringen: while s[i] == ' ' -> i++"]
    C --> D{" i >= s.size()? "}
    D -- ja --> Z
    D -- nein --> E["Finde Ende des Tokens: j = i; while j < size && s[j] != ' ' -> j++"]
    E --> F["Token ausschneiden: substr(i, j - i)"]
    F --> G[Setze i = j]
    G --> B

4. Aufteilung nach Leerzeichen: alle Tokens ausgeben

Fangen wir mit dem häufigsten Fall an: Tokens sind durch Leerzeichen getrennt. Wichtiges Abkommen: wir verlangen keinen „perfekten Input“. Der Nutzer kann drei Leerzeichen setzen, die Zeile mit einem Leerzeichen beginnen oder enden — das Programm soll deswegen keine Tragödie werden. Unsere Tokenisierung wird zuerst Leerzeichen überspringen, dann das Token lesen und so bis zum Ende fortfahren.

Das folgende Beispiel gibt jedes Token einzeln aus. Das ist nicht nur als Übung nützlich, sondern auch als Debug-Werkzeug: wenn Sie unsicher sind, ob Sie den Input richtig verstehen, drucken Sie erstmal die Tokens.

#include <iostream>
#include <string>

int main() {
    std::string s = "  add   milk   2  ";

    std::size_t i = 0;
    while (i < s.size()) {
        while (i < s.size() && s[i] == ' ') ++i; // Leerzeichen überspringen
        if (i >= s.size()) break;

        std::size_t j = i;
        while (j < s.size() && s[j] != ' ') ++j; // Ende des Tokens

        std::string token = s.substr(i, j - i);
        std::cout << "token=[" << token << "]\n";
        i = j;
    }
}

Erwartete Ausgabe:

token=[add]
token=[milk]
token=[2]

Beachten Sie die „doppelte Grenzprüfung“. Wir prüfen i < s.size() sowohl in der Skip-Schleife als auch in der Schleife zur Token-Ende-Suche. Das wirkt etwas pedantisch, aber so stürzt das Programm nicht bei einer leeren String-Eingabe oder einer Zeile nur mit Leerzeichen ab.

5. Mehrere Trenner: Leerzeichen und '='

Jetzt machen wir es etwas komplizierter, aber noch ohne „Streaming-Parsing“. Betrachten Sie einen Befehl wie: set user=alice.

Hier trennt das Leerzeichen die Teile des Befehls, und '=' trennt einen Token in Schlüssel und Wert. Das begegnet einem oft: Konfigurationen, Parameter, einfache DSL-Formate und sogar URLs (dort ist es etwas komplexer, aber die Idee bleibt).

Zuerst extrahieren wir Tokens nach Leerzeichen, und anschließend schneiden wir einen Token am '='. Wir beginnen mit dem zweiten Schritt — dem Zerlegen von user=alice — und machen das ebenfalls manuell, ohne find(), um die Technik zu festigen.

#include <iostream>
#include <string>

int main() {
    std::string pair = "user=alice";

    std::size_t k = 0;
    while (k < pair.size() && pair[k] != '=') ++k;

    if (k == pair.size()) {
        std::cout << "bad pair\n";
    } else {
        std::string key = pair.substr(0, k);
        std::string value = pair.substr(k + 1);
        std::cout << "key=[" << key << "], value=[" << value << "]\n";
        // key=[user], value=[alice]
    }
}

In diesem Beispiel behandeln wir explizit den Fall „kein Trenner gefunden“. Das ist eine wichtige Fertigkeit: selbst wenn das Format „eigentlich korrekt sein sollte“, muss der Code einen Plan B haben, sonst finden Nutzer einen Weg, unerwartete Eingaben zu machen. Nutzer sind wie Wasser — statt Ritzen suchen sie Bugs.

6. Mini-Anwendung TaskShell: Befehle verstehen

Verbinden wir die Tokenisierung zu einer nachvollziehbaren Geschichte. Wir bauen eine Konsolenanwendung TaskShell, die Befehle in einer Zeile annimmt. Vorläufig speichert sie noch keine Tasks (Container wie std::vector kommen später), aber sie kann bereits „verstehen“, was von ihr verlangt wird, und Parameter sauber zerlegen. Das ist eine ehrliche Entwicklungsphase: zuerst bringt man dem Programm bei, Befehle zu verstehen, dann fügt man „Speicher“ hinzu.

Wir implementieren die Befehle:

  • echo ... — gibt die Token-Argumente aus
  • set key=value — gibt aus, dass Schlüssel und Wert erkannt wurden
  • exit — Beenden

Die ganze Eingabezeile lesen

Um die Kommandozeile zu tokenisieren, muss man sie komplett einlesen. Üblich ist dafür std::getline.

#include <iostream>
#include <string>

int main() {
    std::string line;
    std::getline(std::cin, line);

    std::cout << "line=[" << line << "]\n";
}

Wenn der Nutzer eingibt:

echo hello world

dann gibt das Programm aus:

line=[echo hello world]

Erstes Token — der Befehl, die übrigen — Argumente

Fügen wir nun die Zerlegung hinzu. Wichtig: wir speichern die Argumente nicht in einem Array (Container kommen später). Wir gehen sie einfach durch und geben sie aus.

#include <iostream>
#include <string>

int main() {
    std::string line = "  echo   hello   world  ";

    std::size_t i = 0;
    while (i < line.size() && line[i] == ' ') ++i;

    std::size_t j = i;
    while (j < line.size() && line[j] != ' ') ++j;

    std::string cmd = (i < line.size()) ? line.substr(i, j - i) : "";
    std::cout << "cmd=[" << cmd << "]\n"; // cmd=[echo]
}

Jetzt haben wir nur den Befehl extrahiert. Der nächste Schritt sind die Argumente. Die Logik: nachdem wir cmd gelesen haben, setzen wir die Tokenisierung an der Position i = j fort.

Unten die Version, die Befehl und alle folgenden Tokens ausgibt:

#include <iostream>
#include <string>

int main() {
    std::string line = "  echo   hello   world  ";

    std::size_t i = 0;
    int token_index = 0;

    while (i < line.size()) {
        while (i < line.size() && line[i] == ' ') ++i;
        if (i >= line.size()) break;

        std::size_t j = i;
        while (j < line.size() && line[j] != ' ') ++j;

        std::string token = line.substr(i, j - i);
        std::cout << token_index << ": [" << token << "]\n";
        // 0: [echo], 1: [hello], 2: [world]
        ++token_index;
        i = j;
    }
}

Das ist praktisch ein „universeller Token-Printer“. Behalten Sie ihn als minimale Prüfung: wenn die Tokens korrekt ausgegeben werden, haben Sie schon die halbe Arbeit des Parsers erledigt.

Ein-Durchlauf-Parsing von set key=value

Jetzt bauen wir für TaskShell die Logik, set zu erkennen und das nächste Token als key=value zu behandeln. Wir machen das, ohne Argumente zu speichern, indem wir das erste und das zweite Token direkt lesen.

#include <iostream>
#include <string>

int main() {
    std::string line = "set user=alice";

    std::size_t i = 0;
    while (i < line.size() && line[i] == ' ') ++i;

    std::size_t j = i;
    while (j < line.size() && line[j] != ' ') ++j;

    std::string cmd = line.substr(i, j - i);
    i = j;

    while (i < line.size() && line[i] == ' ') ++i;
    j = i;
    while (j < line.size() && line[j] != ' ') ++j;

    std::string arg = (i < line.size()) ? line.substr(i, j - i) : "";

    std::cout << "cmd=[" << cmd << "], arg=[" << arg << "]\n";
    // cmd=[set], arg=[user=alice]
}

Und nun der zweite Schritt: arg an '=' zerlegen:

#include <iostream>
#include <string>

int main() {
    std::string arg = "user=alice";

    std::size_t eq = 0;
    while (eq < arg.size() && arg[eq] != '=') ++eq;

    if (eq == arg.size()) {
        std::cout << "bad format\n";
    } else {
        std::string key = arg.substr(0, eq);
        std::string value = arg.substr(eq + 1);
        std::cout << "set " << key << " to " << value << '\n';
        // set user to alice
    }
}

Ja, der Code ist länger geworden als „im Traum“. Aber das ist der ehrliche Preis für Verständnis: Sie machen per Hand, was später fortgeschrittene Werkzeuge übernehmen.

Wie man Tokenisierung robust macht

Anfänger schreiben Tokenisierung oft für „perfekten Input“. So nach dem Motto: „es ist doch genau ein Leerzeichen, wozu Checks“. Dann kommt aber Input wie " set user=alice " oder ganz leere Zeilen, und alles bricht zusammen.

Robustheit erreicht man nicht durch Magie, sondern durch Disziplin: immer Trenner in einer eigenen Schleife überspringen, immer i < size prüfen und niemals line[i] aufrufen, wenn man sich nicht sicher ist, dass i im String liegt. Selbst wenn Sie später trimmen und Mehrfach-Leerzeichen zusammenfassen, bleibt „sichere Tokenisierung“ nützlich: sie schützt vor ungeplanten Fällen und erleichtert Debugging.

Noch ein wichtiger Punkt: Tokenisierung heißt nicht unbedingt „Liste von Tokens erstellen“. Häufig tokenisieren wir on the fly, als Pipeline: Token erhalten → sofort verarbeiten → weitermachen. Das ist besonders praktisch in frühen Kursphasen, solange wir noch keine Container zur Speicherung haben.

7. Typische Fehler bei manueller Tokenisierung

Fehler Nr. 1: Zugriff auf s[i] ohne Prüfung i < s.size().
Das ist der häufigste Weg, undefiniertes Verhalten und sehr merkwürdige Symptome zu erzeugen. Bei der Tokenisierung bewegt man i oft, und in der letzten Iteration fährt man leicht über das Ende hinaus. Die Regel: jeder Zugriff auf s[i] darf nur nach Grenzprüfung erfolgen, auch wenn man sicher glaubt, dass dort ein Zeichen ist.

Fehler Nr. 2: Vergessen, Trenner vor dem Lesen eines Tokens zu überspringen.
Wenn Sie den Schritt „Leerzeichen überspringen“ nicht machen, erhalten Sie bei mehreren aufeinanderfolgenden Leerzeichen entweder leere Tokens, eine Endlosschleife oder Sie schneiden immer wieder substr(i, 0) aus. Ein korrektes Muster hat immer eine eigene Schleife, die i über Leerzeichen bewegt.

Fehler Nr. 3: Verwirrung bei substr(i, j - i) und der Versuch, substr(i, j) zu schreiben.
Das ist eine logische Falle: j steht in der Tokenisierung meist für „Position nach dem letzten Token-Zeichen“. substr() erwartet aber eine „Länge“, nicht die Endposition. Daher ist das richtige Ausdruck fast immer j - i. Wenn Sie substr(i, j) schreiben, kann der String bei kurzen Tests „fast richtig“ aussehen, aber bei echten Daten brechen.

Fehler Nr. 4: i = j + 1 setzen und hoffen, dass immer genau ein Leerzeichen da ist.
Dieser Code funktioniert nur bis zur ersten Eingabe mit zwei aufeinanderfolgenden Leerzeichen oder einem Leerzeichen am Ende. Viel robuster ist i = j zu setzen und Leerzeichen in einer eigenen Schleife zu überspringen. Dann spielt die Anzahl der Leerzeichen keine Rolle mehr.

Fehler Nr. 5: Den Fall „kein Trenner“ bei key=value nicht behandeln.
Wenn Sie key=value parsen, aber nicht prüfen, ob '=' wirklich gefunden wurde, kann substr(eq + 1) dazu führen, dass Sie „nach dem Ende des Strings abschneiden“. Manchmal ergibt das eine leere Zeichenkette und einen stillen Fehler, manchmal unangenehmere Effekte. Richtig ist: wenn eq == arg.size(), dann ist das Format fehlerhaft und muss explizit behandelt werden.

1
Aufgabe
C++ SELF, Level 9, Lektion 3
Gesperrt
Wortliste
Wortliste
1
Aufgabe
C++ SELF, Level 9, Lektion 3
Gesperrt
Rechteck-Befehl
Rechteck-Befehl
1
Aufgabe
C++ SELF, Level 9, Lektion 3
Gesperrt
Anfrageparameter
Anfrageparameter
1
Aufgabe
C++ SELF, Level 9, Lektion 3
Gesperrt
Einzeilen-Kalkulator
Einzeilen-Kalkulator
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION