CodeGym /Kurse /C++ SELF /Tiefer einsteigen in die Vergleichsfalle: i < size()

Tiefer einsteigen in die Vergleichsfalle: i < size()

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

1. Woher kommt die Falle bei i < size()

Gehen wir das Problem „Vergleichsfalle“ noch einmal durch. Die Macher der Standardbibliothek std meinten es gut. Wie man so sagt: „gut gemeint, meist doch nicht so gut gemacht“.

Denken Sie selbst: „i mit size() vergleichen — das ist doch die harmloseste Sache im Programmieren!“ — so denkt ein normaler Mensch. Und C++ — nun ja, C++ ist wie C++: auch normal, nur mit eigener Normalität.

Das Problem entsteht nicht, weil der Vergleich schlecht ist, und nicht, weil if plötzlich hinterhältig geworden wäre. Das Problem entsteht durch implizite Typumwandlungen: wenn in einem Ausdruck verschiedene Ganzzahltypen auftauchen, versucht der Compiler, sie auf einen „gemeinsamen Nenner“ zu bringen. Manchmal ist dieser Nenner unsigned — und dann wird eine negative Zahl plötzlich… zu einer sehr positiven Zahl.

Im Titel verwenden wir size() als die übliche Schreibweise für Standardtypen. Heute halten wir die Beispiele an std::string, weil die Zeichenkette bereits bekannt ist, aber die Idee gilt genauso für jedes Objekt, das size() hat (inklusive zukünftiger Container).

size() und der Typ std::size_t

Erinnern wir uns zuerst an eine wichtige Tatsache: die Länge eines Strings kann nicht negativ sein. Selbst wenn der String „das Leben eines Programmierers in Deadlines“ heißt — er hat trotzdem eine Länge >= 0. Deshalb gibt die Standardbibliothek die Länge mit einem Typ zurück, der keine negativen Werte speichern kann: meist ist das std::size_t.

std::size_t ist ein unsigned-Ganzzahltyp „für Größen und Indizes“. Und hier beginnt das kleine Drama: sehr oft wird der Index bei Anfängern in einem int gehalten (weil „es ist ja einfach eine Zahl“), während die Größe als std::size_t kommt (weil die Bibliothek das so vorgibt). Wenn sie im Vergleich aufeinandertreffen — schaltet die Typkonvertierungs-Magie ein.

Kleine Tabelle, um den Sinn festzuhalten, nicht um Namen auswendig zu lernen:

Entität Typischer Typ Kann negativ sein? Beispielbedeutung
Index „Position im String“ int (häufig bei Anfängern) Ja „Benutzer hat -1 eingegeben“
Stringlänge std::size_t Nein „Stringlänge ist 5“

Wenn Sie wissen wollen, warum der Standard das überhaupt so macht (und warum die Diskussion über signed/unsigned keine pedantische Haarspalterei, sondern eine Sicherheitsfrage ist), dann liegt das direkt daran, dass die Sprache signed- und unsigned-Ganzzahlen getrennt behandelt.

2. Wie implizite Umwandlungen den Vergleich zerstören

Stellen wir uns eine typische Aufgabe vor: der Benutzer gibt einen Index ein, wir wollen prüfen, ob der Index innerhalb des Strings liegt.

Der naive Code sieht sehr logisch aus:

#include <iostream>
#include <string>

int main() {
    std::string s = "abcd";
    int i = -1;

    if (i < s.size()) {
        std::cout << "Index is inside\n";
    } else {
        std::cout << "Index is outside\n";
    }
}

Der Mensch liest das so: „-1 ist kleiner als 4? Ja. Also ist er innerhalb.“ Aber ein Index -1 existiert nicht im String — er müsste sofort verworfen werden.

Warum kann die Bedingung sich merkwürdig verhalten? Weil s.size() ein std::size_t (unsigned) ist, und i ein int (signed). Beim Vergleich versucht der Compiler, beide Operanden auf einen gemeinsamen Typ zu bringen. Sehr oft passiert Folgendes: das int wird zu std::size_t.

Und static_cast<std::size_t>(-1) ist nicht „-1“, sondern eine riesige Zahl (im Wesentlichen die Bit-Interpretation modulo 2N). Am Ende wird der Vergleich in etwa so: „riesige Zahl < 4?“ — und die Antwort ist false.

Damit das nicht wie Magie wirkt, zeichnen wir ein Mini-Diagramm dessen, was im Kopf des Compilers passiert:

flowchart TD
    A["Ausdruck: i < s.size()"] --> B["Typ von i: int (signed)"]
    A --> C["Typ von s.size(): size_t (unsigned)"]
    B --> D["Müssen auf gemeinsamen Typ gebracht werden"]
    C --> D
    D --> E["i wird implizit unsigned"]
    E --> F["Wenn i -1 war, wird es eine große Zahl"]
    F --> G["Vergleich ergibt unerwartetes Resultat"]

Mini-Demo: wie „-1“ zu einer „Überraschung“ wird

Machen wir ein kleines Experiment: wandeln wir -1 manuell in std::size_t um und geben es aus.

#include <cstddef>
#include <iostream>

int main() {
    int i = -1;
    std::size_t u = static_cast<std::size_t>(i);

    std::cout << "i = " << i << '\n'; // i = -1
    std::cout << "u = " << u << '\n'; // u = ... (sehr grosse Zahl)
}

Die konkrete „sehr grosse Zahl“ hängt davon ab, wie viele Bits std::size_t auf Ihrer Plattform hat, aber die Bedeutung ist stabil: ein negativer Wert wird in einem unsigned-Typ nicht als negativ gespeichert, er wird als großes Positivwert neu interpretiert.

Die Kernidee ist einfach: das Problem liegt nicht an size(), nicht an Strings und nicht am Vergleich an sich. Das Problem ist, dass Sie Zahlen aus verschiedenen „Welten“ vergleichen: die signed-Welt und die unsigned-Welt. Und an der Grenze steht der Compiler-Übersetzer, der manchmal so übersetzt, dass sich die Bedeutung ändert.

4. Index sicher prüfen

Jetzt der praktischste Teil: was schreibt man statt des gefährlichen i < s.size().

Wenn der Index in einem int liegt, ist die sichere Prüfung fast immer zweistufig:

1) zuerst sicherstellen, dass der Index nicht negativ ist;
2) dann mit size() vergleichen, bereits in der Welt von std::size_t.

Sieht so aus:

#include <cstddef>
#include <iostream>
#include <string>

int main() {
    std::string s = "abcd";
    int idx = -1;

    if (idx >= 0 && static_cast<std::size_t>(idx) < s.size()) {
        std::cout << "ok: " << s[static_cast<std::size_t>(idx)] << '\n';
    } else {
        std::cout << "bad index\n"; // bad index
    }
}

Achten Sie auf die Reihenfolge bei &&. Sie ist hier nicht „aus Optik“: zuerst die Prüfung idx >= 0, und erst danach führen wir den static_cast zu std::size_t aus. Das ist wichtig, weil eine Umwandlung eines Negativwerts zu size_t zwar „sicher“ im Sinne „das Programm stürzt nicht“, aber nicht sicher im Sinne „die Logik bleibt korrekt“.

Falls Sie fragen wollen: „Warum nicht einfach static_cast<std::size_t>(idx) < s.size() machen und fertig?“ — dann: weil bei idx = -1 Sie eine riesige Zahl erhalten und die Prüfung sinnlos wird.

Praktisches Beispiel: kleine CharAt-App

Bauen wir ein kleines Stück einer Lehranwendung. Die App soll einen String und einen Index lesen und dann entweder das Zeichen ausgeben oder einen Fehler melden. Das ist eine einfache Aufgabe, zeigt aber perfekt, wo die Falle liegt.

Naive Version

#include <iostream>
#include <string>

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

    int idx = 0;
    std::cin >> idx;

    if (idx < s.size()) {
        std::cout << s[idx] << '\n';
    } else {
        std::cout << "bad index\n";
    }
}

Diese Version kompiliert, funktioniert oft sogar bei normalen Eingaben, und genau deshalb ist sie gefährlich: der Bug versteckt sich bis zum ersten negativen Index.

Außerdem können negative Zahlen auch als Zwischenresultat auftauchen, selbst wenn Sie sie nicht bewusst eingeben: zum Beispiel Subtraktion, Offset, „Index minus 1“.

Sichere Version

#include <cstddef>
#include <iostream>
#include <string>

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

    int idx = 0;
    std::cin >> idx;

    if (idx >= 0 && static_cast<std::size_t>(idx) < s.size()) {
        std::cout << s[static_cast<std::size_t>(idx)] << '\n';
    } else {
        std::cout << "bad index\n"; // bad index
    }
}

Ja, es ist etwas mehr Code. Dafür ist das Verhalten jetzt robust: negative Indizes werden ausgeschlossen, zu große Indizes werden ausgeschlossen, und vor allem — der Vergleich hängt nicht mehr von der „Magie“ signed/unsigned ab.

Häufiger Pseudo-Fix: „machen wir den Index zu size_t“

An dieser Stelle machen viele den naheliegenden Schritt: „Da size() ein size_t ist, machen wir auch den Index zu size_t“. Manchmal ist das tatsächlich sinnvoll — zum Beispiel wenn der Index innerhalb des Programms entsteht und per Definition nicht negativ sein kann.

Aber wenn der Index vom Benutzer kommt, gibt es einen Haken: der Nutzer kann -1 eingeben. Beim Einlesen in ein size_t erhalten Sie dann dieselbe große Zahl, nur ohne expliziten static_cast: alles passiert „von selbst“.

Schauen wir:

#include <cstddef>
#include <iostream>

int main() {
    std::size_t idx = 0;
    std::cin >> idx;

    std::cout << idx << '\n'; // wenn -1 eingegeben wird, sieht man eine grosse Zahl
}

Sie haben das Problem der negativen Werte also nicht beseitigt — Sie haben nur die Möglichkeit verloren, sie als negativ zu erkennen. Daher die praktische Regel: wenn der Benutzer negativ eingeben kann — lesen Sie den Index als int, prüfen idx >= 0, und wandeln erst danach in size_t um.

5. Index-Loops und Typwahl

Wir kommen jetzt zu dem Punkt, an dem das Thema im Alltag meist auftaucht: der klassische Index-Loop.

Viele schreiben so:

#include <iostream>
#include <string>

int main() {
    std::string s = "abcd";

    for (int i = 0; i < s.size(); ++i) {
        std::cout << s[i] << '\n';
    }
}

In der Praxis funktioniert das fast immer, weil i bei 0 startet und nicht negativ wird. Aber der Compiler könnte eine Warnung wegen signed/unsigned-Vergleich ausgeben. Und der Compiler ist hier kein Nörgler — eher ein Freund, der sagt: „Sieh zu, im Moment ist alles ok, aber die Konstruktion ist potentiell gefährlich. Bist du sicher, dass hier nie ein Minus auftreten kann?“

Eine konsistentere Variante ist, i vom gleichen Typ wie size() zu machen:

#include <cstddef>
#include <iostream>
#include <string>

int main() {
    std::string s = "abcd";

    for (std::size_t i = 0; i < s.size(); ++i) {
        std::cout << s[i] << '\n';
    }
}

Das ist eine gute Variante genau für das Vorhaben „von 0 aufwärts“. Aber denken Sie daran, dass std::size_t unsigned ist: wenn Sie rückwärts laufen wollen und Bedingungen wie i >= 0 schreiben, geraten Sie in eine andere Falle.

Kurz-Checkliste für Vergleiche mit size()

Wenn in Ihrem Vergleich size() (eines Strings oder eines Containers) vorkommt, behalten Sie im Kopf, dass das sehr wahrscheinlich ein unsigned-Typ ist. Wenn der andere Operand ein int ist, können implizite Umwandlungen auftreten und negative Werte sich in große positive verwandeln.

Deshalb: wenn der Index von außen kommt oder durch Berechnung entstehen kann, wo ein Minus möglich ist, prüfen Sie zuerst >= 0, und vergleichen Sie danach mit size() erst nach explizitem static_cast zu std::size_t. Das ist etwas wortreicher, macht aber die Absicht klar und schützt vor Bugs an den Rändern.

6. Typische Fehler

Fehler Nr.1: nur idx < s.size() prüfen und idx >= 0 vergessen.
Das ist das häufigste Problem: die Bedingung sieht mathematisch plausibel aus, aber durch signed/unsigned-Konversionen beginnt sie, ein Eigenleben zu führen. In der Folge kann ein negativer Index entweder die Prüfung passieren oder aus „seltsamen Gründen“ nicht — beides ist schlecht, weil das Programm nicht mehr vorhersehbar ist.

Fehler Nr.2: static_cast<std::size_t>(idx) ohne vorherige Prüfung idx >= 0.
Solch ein Cast wird manchmal geschrieben, um eine Compiler-Warnung „zum Schweigen“ zu bringen. Aber wenn idx negativ sein kann, löst der Cast das Problem nicht, sondern versteckt es: -1 wird zu einer riesigen Zahl und der Vergleich verliert seinen Sinn. Explizite Umwandlung sollte die Absicht ausdrücken, nicht eine zweifelhafte Stelle kaschieren.

Fehler Nr.3: den Index auf std::size_t ändern, wenn er vom Benutzer kommt.
Es scheint „richtiger“, weil size() auch size_t ist. Aber bei Eingabe von -1 bekommen Sie nicht „minus eins“, sondern eine große Zahl, und das Programm kann dann versuchen, damit wie mit einem normalen Index zu arbeiten. Wenn Benutzereingaben Fehler erlauben, ist es praktischer, sie in einem int zu halten, damit negative Werte sauber aussortiert werden können.

Fehler Nr.4: int im Index-Loop verwenden und Compiler-Warnungen wegen signed/unsigned ignorieren.
Die Schleife for (int i = 0; i < s.size(); ++i) funktioniert gewöhnlich, aber sie schafft eine „Rutschbahn“ für spätere Änderungen: heute ist i immer ab 0, morgen setzt jemand i = start - 1, und plötzlich wird das Verhalten seltsam. Es ist ruhiger, wenn der Index-Typ mit dem Typ von size() übereinstimmt — zumindest dort, wo ein Minus logisch ausgeschlossen ist.

1
Aufgabe
C++ SELF, Level 8, Lektion 3
Gesperrt
Gültiger Cursor
Gültiger Cursor
1
Aufgabe
C++ SELF, Level 8, Lektion 3
Gesperrt
Zeichen an der Position
Zeichen an der Position
1
Aufgabe
C++ SELF, Level 8, Lektion 3
Gesperrt
Fragmentfenster
Fragmentfenster
1
Aufgabe
C++ SELF, Level 8, Lektion 3
Gesperrt
Zeichenabfragen
Zeichenabfragen
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION