CodeGym /Kurse /C++ SELF /Operatoren vs. Comparatoren

Operatoren vs. Comparatoren

C++ SELF
Level 50, Lektion 1
Verfügbar

1. Warum die Wahl entsteht

Wenn man anfängt, operator== und <=> zu implementieren, hat man zunächst das angenehme Gefühl: „mein Typ ist fast wie ein int“. Doch recht schnell schlägt die Realität zu: dasselbe Objekt kann in unterschiedlichen Aufgaben verschieden verglichen werden. An einer Stelle ist uns die id wichtig, an anderer „zuerst Priorität, dann Name“, an dritter Stelle „zuerst erledigte nach unten“. Und genau hier ist es wichtig, die Operatoren nicht in ein „Schweizer Messer, das nebenbei auch Suppe kocht“, zu verwandeln.

Überladene Operatoren sind Defaults für einen Typ. Wenn Sie a < b definieren, sagen Sie implizit: „in den meisten Fällen, wenn Leute Task sehen und sortieren wollen, erwarten sie genau diese Ordnung“. Ein Comparator (oft eine Lambda) ist dagegen eine „lokale Regel“: genau jetzt, in diesem konkreten std::sort, sortieren wir so, weil die Aufgabe das verlangt.

Um das nicht theoretisch zu belassen, setzen wir unsere kleine Konsolenanwendung fort: ein Mini-Aufgaben-Tracker (eine bedingte TODO-Liste), bei dem wir Aufgaben in einem std::vector speichern und sie ausgeben und sortieren können.

2. Mini-Modell: TaskId, Priority, Task

Bevor wir über "Operatoren vs. Comparatoren" streiten, brauchen wir ein Objekt, das überhaupt verglichen werden kann. Wir bauen ein kleines Aufgabenmodell, das für Einsteiger verständlich ist und zugleich genügend "reichhaltig", damit verschiedene Sortier- und Suchkriterien entstehen können. Wichtig: das Modell muss nicht perfekt sein — es soll lehrreich und vorhersehbar sein, damit die Folgen Ihrer Entscheidungen sichtbar werden.

Beginnen wir mit einer kleinen TaskId (um "eine Zahl als Ganzes" und "Aufgaben-Identifier" nicht zu verwechseln) und einer Priorität:

#include <compare>

class TaskId {
public:
    explicit TaskId(int v) : v_(v) {}

    int value() const { return v_; }

    auto operator<=>(const TaskId&) const = default; // Ordnung nach der Zahl
    bool operator==(const TaskId&) const = default;

private:
    int v_{};
};

enum class Priority {
    Low = 0,
    Normal = 1,
    High = 2
};

Nun die Aufgabe selbst. Sie hat id, title, priority und ein Flag done. Ausgabe in einen Stream und Vergleiche haben wir bereits in früheren Vorlesungen behandelt.

#include <string>
#include <utility>

struct Task {
    TaskId id;
    std::string title;
    Priority priority{Priority::Normal};
    bool done{false};

    bool operator==(const Task&) const = default;
};

Beachten Sie: hier bedeutet operator== = default "gleich, wenn alle Felder gleich sind". Das ist nicht immer die richtige Bedeutung von Gleichheit, aber ein sehr bequemer Ausgangspunkt für die Diskussion. In ein paar Abschnitten sehen wir, wann das zu einem Designfehler wird.

Fügen wir noch einen Stream-Ausgabeoperator hinzu, damit in Sortierbeispielen etwas gedruckt werden kann:

#include <iostream>

std::ostream& operator<<(std::ostream& os, const Task& t) {
    os << "Task{id=" << t.id.value()
       << ", title=\\"" << t.title << "\\""
       << ", done=" << (t.done ? "true" : "false")
       << "}";
    return os;
}

3. Wann ein Vergleich Teil des Typs sein sollte

Wenn Sie einen Operator innerhalb des Typs (oder als freie Funktion daneben) definieren, fügen Sie nicht nur syntaktischen Zucker hinzu. Sie publizieren eine Regel, die von anderen Teilen des Programms genutzt wird: Algorithmen, Container, Ihre zukünftigen Funktionen. Das ist vergleichbar damit, ein Feld price zu nennen und zu hoffen, dass alle "Preis in Dollar" verstehen — und später plötzlich Cent dort zu speichern. Formal möglich. Glück bringt das nicht.

Das ehrlichste Kriterium: ein Operator ist dort angebracht, wo der Typ eine natürliche, für die meisten Leser offensichtliche Bedeutung des Vergleichs hat. Bei TaskId ist das einfach: zwei TaskId sind gleich, wenn die Zahlen identisch sind. Bei Task ist das nicht so offensichtlich: eine Aufgabe kann "dasselbe" Objekt nach id sein, auch wenn sich der title geändert hat (Umbenennung). Oder umgekehrt: in einem System ohne id ist "Gleichheit aller Felder" die Norm.

Eine praktische Regel, die Nerven rettet: der Operator == sollte die Frage beantworten "ist das dasselbe Objekt im Sinne meiner Fachdomäne?". Wenn ja — gut. Wenn Sie das Gefühl haben, die Antwort hängt von der Situation ab, dann ist es wahrscheinlich zu früh, == als die einzige Bedeutung von Gleichheit zu definieren, oder er sollte sich an einem stabileren Schlüssel orientieren.

Zum Beispiel, wenn id die Identität ist, kann Gleichheit "nach allen Feldern" falsch sein. Dann besser so:

struct Task {
    TaskId id;
    std::string title;
    Priority priority{Priority::Normal};
    bool done{false};

    bool operator==(const Task& other) const {
        return id == other.id; // Identität nach id
    }
};

Ein solcher operator== macht die Aufgabe "dasselbe Objekt", selbst wenn Titel oder Priorität geändert wurden. Und das entspricht häufig der Realität: die Aufgabe ist eine Entität, die Felder sind ihr Zustand.

4. Standard-Ordnung: nützlich, aber gefährlich

Eine Ordnung (<, >, <=>) ist eine noch rutschigere Angelegenheit als Gleichheit. Gleichheit lässt sich meistens irgendwo vernünftig begründen. Eine Ordnung hingegen beginnt oft, sich als "natürlich" auszugeben, obwohl sie eigentlich nur für den Entwickler praktisch ist.

Wenn Sie <=> für Task hinzufügen, definieren Sie eine Default-Sortierung. Fragen Sie ehrlicherweise: "Hat die Aufgabe eine einzige natürliche Ordnung, die überall erwartet wird?". Wenn Sie eine starke Antwort haben, z. B. "wir sortieren überall nach id", dann kann man das tun:

#include <compare>

struct Task {
    TaskId id;
    std::string title;
    Priority priority{Priority::Normal};
    bool done{false};

    auto operator<=>(const Task& other) const {
        return id <=> other.id; // Ordnung nach Identifier
    }

    bool operator==(const Task& other) const {
        return id == other.id; // und Gleichheit stimmt mit der Ordnung überein
    }
};

Wichtig hier: wir machen == absichtlich konsistent zur Ordnung, um keinen "kognitiven Dissonanz" zu erzeugen. Wenn a == b, erwartet man üblicherweise, dass a < b und b < a falsch sind.

Wenn Sie sich aber nicht sicher sind, ob es eine "natürliche" Ordnung gibt — fügen Sie sie nicht in den Typ ein. Das ist keine Schwäche, das ist Vorsicht. Ein Typ ohne < wird nicht schlechter als Entität; er sagt nur ehrlich: "die Reihenfolge hängt von der Aufgabe ab, definieren Sie einen Comparator".

5. Comparator: Sortierung für eine konkrete Aufgabe

Wenn wir einen std::vector<Task> sortieren, tun wir das fast immer für die Anzeige, für einen Bericht oder für die Auswahl durch den Nutzer — nicht, weil Aufgaben eine philosophische "natürliche Ordnung" besitzen. Deshalb ist std::sort mit einem Comparator eine völlig normale, erwachsene Praxis.

Stellen Sie sich vor, die App hat den Befehl "Aufgaben anzeigen: zuerst unerledigte, dann erledigte; innerhalb dessen nach Priorität; und erst danach nach id". Das ist keine "Ordnung der Entität", das ist eine "Darstellungsordnung".

So sieht das als Comparator-Lambda aus:

#include <algorithm>
#include <vector>

int priority_rank(Priority p) {
    return static_cast<int>(p);
}

void sort_for_ui(std::vector<Task>& tasks) {
    std::sort(tasks.begin(), tasks.end(),
              [](const Task& a, const Task& b) {
                  if (a.done != b.done) return a.done < b.done; // false kommt vor true
                  if (a.priority != b.priority) {
                      return priority_rank(a.priority) > priority_rank(b.priority); // High hat höheren Wert
                  }
                  return a.id < b.id;
              });
}

Beachten Sie den Stil: wir sagen explizit "wenn sie sich hier unterscheiden — entscheide danach, sonst weiter". Solcher Code liest sich wie eine Reihe von Sortierregeln, nicht wie ein Rätsel. Ja, das sind mehr Zeilen als = default, aber der Sinn steht direkt dort, wo Sie ihn verwenden.

Und das Wichtigste: Sie mussten die Bedeutung von Task im gesamten Programm nicht ändern. Sie haben einfach nur für die jeweilige Ansicht sortiert.

6. Suche: Operatoren und Prädikate

Das Wort "Suche" wird oft mit "Sortierung" verwechselt. In Lernkopfen ist das meist dasselbe: "ich suche — also vergleiche ich". In der STL sieht Suche aber häufiger so aus: "Finde ein Element, das eine Bedingung erfüllt". Diese Bedingung ist eine gewöhnliche Funktion bool(const T&), und meist schreibt man sie als Lambda. Hier brauchen wir nicht unbedingt eine Ordnung, und oft nicht einmal operator==.

Beginnen wir mit dem klassischen std::find. Er sucht ein "genau gleiches Element". Daher benötigt er operator==:

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks = {
        {TaskId{1}, "Buy milk", Priority::Normal, false},
        {TaskId{2}, "Fix bug", Priority::High, false}
    };

    auto it = std::find(tasks.begin(), tasks.end(),
                        Task{TaskId{2}, "", Priority::Low, true});

    std::cout << (it != tasks.end() ? it->title : "not found") << '\n';
    // Fix bug
}

Dieses Beispiel wirkt seltsam (wir haben ein "Vorlagen-Task" mit leerem Titel erstellt), aber es zeigt sehr gut, dass std::find mittels == vergleicht. Wenn Ihr == nur die id vergleicht, funktioniert das. Wenn Ihr == alle Felder vergleicht, lässt sich so kein "Suche nach id" realisieren — Sie müssten eine exakte Kopie des Tasks bauen oder den Ansatz ändern.

Und hier kommt std::find_if ins Spiel: Suche per Bedingung. Er braucht kein operator==, sondern ein Prädikat.

#include <algorithm>

Task* find_by_id(std::vector<Task>& tasks, TaskId id) {
    auto it = std::find_if(tasks.begin(), tasks.end(),
                           [&](const Task& t) { return t.id == id; });
    return (it == tasks.end()) ? nullptr : &*it;
}

Dieser Stil ist oft besser, selbst wenn ein == existiert, weil die Suchbedingung direkt im Code sichtbar ist: wir suchen nach id, Punkt. Keine Rätselraten, "was vergleicht dort operator==".

7. Nützliche Feinheiten

Spickzettel: was wohin gehört

Wenn der Kopf raucht, ist es hilfreich, eine einfache Entscheidungslandkarte vor Augen zu haben. Es gibt hier keine "einzig richtige Antwort", aber praktische Heuristiken, die vor zufälligen API-Fehlern und seltsamen Operatoren schützen.

Situation Was besser ist Warum
Der Typ hat eine offensichtliche Bedeutung von "Gleichheit" (z. B. TaskId) operator== innerhalb des Typs Das ist Teil der Entität und wird überall erwartet
Der Typ hat eine offensichtliche "natürliche Ordnung" (selten, aber möglich) operator<=> oder operator< innerhalb des Typs Bequem für std::sort als Default und für Container, die eine Ordnung brauchen
Der Typ hat viele sinnvolle Sortierungen (nach Name, nach Datum, nach Priorität) Comparator in std::sort Die Sortierregel ist lokal und explizit, der Typ wird nicht "umprogrammiert"
Suche nach komplexer Bedingung (Substring, Flag, Bereich) std::find_if mit Lambda So lässt sich die Bedingung leichter ausdrücken, als sie in operator== zu quetschen
Suche per Schlüssel (z. B. id  -> Task) und das ist eine häufige Operation Separater Index (z. B. std::unordered_map<TaskId, ...>) Die Suche wird direkt statt "vector durchlaufen"

Beachten Sie: die letzte Zeile betrifft nicht direkt Operatoren, ist aber sehr praktisch. Manchmal diskutieren wir "wie vergleichen?", obwohl die richtige Antwort wäre: "speichern wir die Daten so, dass wir nicht linear suchen müssen".

Comparator und korrekte Vergleichsdefinition

Es ist sehr leicht, einen Comparator zu schreiben, der plausibel aussieht, aber die Erwartungen von std::sort verletzt. Wir gehen hier nicht tief in die Theorie, aber die grundlegende Intuition sollte sitzen: ein Comparator muss eine sinnvolle, widerspruchsfreie Ordnung definieren. Wenn ein Comparator in einem Fall sagt "a ist kleiner als b" und an einer anderen Stelle anders entscheidet (oder vom veränderlichen externen Zustand abhängt), kann die Sortierung merkwürdige Ergebnisse liefern.

Zum Beispiel ist ein Comparator, der Zufälligkeit oder einen globalen Aufrufzähler verwendet, eine schlechte Idee. Ja, das klingt wie ein Witz, aber manchmal machen Leute fast dasselbe, wenn sie in der Lambda eine Referenz auf eine Variable einfangen, die sich während der Sortierung ändert.

Gute Comparatoren haben diese Eigenschaft: sie sind "pur". Sie erhalten zwei Objekte als Eingabe und liefern true/false als Ausgabe, und die Entscheidung hängt nur von diesen Objekten (und maximal von konstanten Regeln wie einer Prioritätstabelle) ab.

8. Mehrere Sortierungen ohne Modell zu brechen

Eine sehr typische Situation in realen Projekten: der Nutzer möchte einen Umschalter für die Sortierung. Heute "nach Priorität", morgen "nach Titel". Hier ist es besonders wichtig, nicht zu versuchen, operator<=> "zur Laufzeit umzuschreiben" (was glücklicherweise nicht möglich ist) und nicht, den Operator von einer globalen Variable currentSortMode abhängig zu machen. Das mag als Idee erscheinen, riecht aber nach Bug.

Sauberer: eine separate Funktion, die den Modus annimmt und std::sort mit dem passenden Comparator aufruft. Der Modus sei vorerst ein gewöhnliches enum class.

#include <algorithm>

enum class SortMode {
    ById,
    ByTitle,
    ByPriority
};

void sort_tasks(std::vector<Task>& tasks, SortMode mode) {
    if (mode == SortMode::ById) {
        std::sort(tasks.begin(), tasks.end(),
                  [](const Task& a, const Task& b) { return a.id < b.id; });
        return;
    }

    if (mode == SortMode::ByTitle) {
        std::sort(tasks.begin(), tasks.end(),
                  [](const Task& a, const Task& b) { return a.title < b.title; });
        return;
    }

    std::sort(tasks.begin(), tasks.end(),
              [](const Task& a, const Task& b) {
                  return static_cast<int>(a.priority) > static_cast<int>(b.priority);
              });
}

Beachten Sie den "technischen Trick": wir bauen keine komplizierten Templates, verwenden keine fortgeschrittenen Themen, sondern wählen einfach den passenden Comparator aus. Ja, hier gibt es ein if. Und das ist in Ordnung. Manchmal ist das lesbarste Design gerade das, das sich nicht "zu schlau" anstellt.

9. Typische Fehler

Fehler Nr. 1: versuchen, alle Sortier-Varianten in operator<=> zu quetschen.
Anfänger denken oft: "ich kann doch eine Ordnung definieren, dann ist Sortierung überall bequem". Später zeigt sich, dass es einen bequemen Standard gibt, aber fünf Nutzungsszenarien. In der Folge wird operator<=> entweder umstritten oder schädlich, weil er eine Ordnung aufzwingt, wo keine universelle erwartet wird.

Fehler Nr. 2: operator== mit = default übernehmen, ohne über die Bedeutung von Identität nachzudenken.
= default vergleicht alle Felder, und das ist oft überraschend. Wenn die Aufgabe "dasselbe Objekt" nach id ist, Sie aber title geändert haben, wird plötzlich a == b false und std::find findet nicht mehr die "gleiche" Aufgabe. Bevor Sie == defaulten, formulieren Sie: "Was bedeutet 'gleich' für meinen Typ?"

Fehler Nr. 3: std::find verwenden, wo eine Suche nach einer Bedingung nötig ist.
Wenn Sie nach einer Aufgabe per Substring im Titel oder nach dem Flag done suchen, quälen Sie nicht operator== und bauen keinen "Vorlagen-Task" für den Vergleich. std::find_if mit Lambda ist meist einfacher, klarer und ehrlicher — die Suchbedingung ist direkt am Aufruf sichtbar.

Fehler Nr. 4: Comparator hängt von externem veränderlichem Zustand ab.
Sortierung ruft den Comparator oft und in nicht offenkundiger Reihenfolge auf. Wenn Ihr Comparator eine Variable liest, die parallel geändert wird (oder sich innerhalb des Comparators ändert), erhalten Sie einen "schwankenden" Vergleich. Das Ergebnis ist eine merkwürdige Reihenfolge oder, im schlimmsten Fall, sehr schwer erklärbares Verhalten.

Fehler Nr. 5: Gleichheit und "Ähnlichkeit" vermischen.
Manchmal möchte man operator== so überladen, dass Aufgaben als gleich gelten, wenn sie ähnliche Titel oder die gleiche Priorität haben. Das ist jedoch keine Gleichheit, sondern "Ähnlichkeit". Für Ähnlichkeit ist eine separate Funktion oder ein Prädikat in find_if besser — sonst brechen Sie die Erwartungen an == im ganzen Programm.

1
Aufgabe
C++ SELF, Level 50, Lektion 1
Gesperrt
Zutrittsliste
Zutrittsliste
1
Aufgabe
C++ SELF, Level 50, Lektion 1
Gesperrt
Kontosuche
Kontosuche
1
Aufgabe
C++ SELF, Level 50, Lektion 1
Gesperrt
Bildschirm des Auftragsdisponenten
Bildschirm des Auftragsdisponenten
1
Aufgabe
C++ SELF, Level 50, Lektion 1
Gesperrt
Schaufenster-Modus
Schaufenster-Modus
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION