CodeGym /Kurse /C++ SELF /Non-copyable Typen: Szenarien zu unique ownership, mutex‑...

Non-copyable Typen: Szenarien zu unique ownership, mutex‑ähnliche

C++ SELF
Level 45, Lektion 2
Verfügbar

1. Ehrlicher Vertrag

Wenn Sie zum ersten Mal den Kompilierfehler „use of deleted function“ oder „copy constructor is not available“ sehen, scheint es, als würde der Compiler einfach bockig sein. Tatsächlich spielt der Compiler hier die Rolle eines strengen Wächters: er lässt Sie nicht dort weitermachen, wo sehr wahrscheinlich ein Bug lauert. Non-copyable-Typ bedeutet: für diesen Typ ist Kopieren verboten. Verboten ist es nicht, weil „der Autor faul war“, sondern weil eine Kopie den Sinn zerstört.

Die wichtigste Idee: das Kopierverbot ist Teil der Schnittstelle des Typs. Das ist ein Designelement, so wie „die Funktion hat zwei Parameter, nicht fünf“. Sie legen im Voraus fest: „dieses Objekt darf nicht ‚kopiert werden‘, mit ihm darf nur als einzigem Exemplar gearbeitet werden“.

Um die Begriffe nicht zu vermischen, machen wir eine kleine Tabelle. Sie handelt nicht vom Syntax, sondern vom Sinn.

Situation im Modell Was beim Kopieren passieren würde Deshalb macht der Typ…
Einzigartiges Eigentum an einer Ressource (Speicher, Descriptor, „Handle“) Zwei Besitzer glauben, die Ressource gehöre ihnen …non-copyable
Einzigartige Identität (eine „Zugangskarte“, eine „Session“) Es entstehen „zwei identische Identitäten“ …non-copyable
Mutex‑ähnliche Exklusivität (ein „Schloss“) Es entstehen zwei „Kopien des Schlosses“, die nicht dasselbe synchronisieren …non-copyable

Jetzt schauen wir uns drei typische „Real‑World“‑Szenarien an. Und ja: in allen drei Fällen hilft uns C++ dadurch, dass wir den Fehler beim Kompilieren bekommen, nicht „in zwei Tagen um 03:00 auf dem Server“.

2. Szenario Nr.1: unique ownership — „es darf nur einen Besitzer geben“

Wenn Sie jemals einen Wohnungsschlüssel in der Hand gehalten haben, verstehen Sie unique ownership: den Schlüssel können Sie an jemand anderen übergeben, aber wenn Sie einfach aus der Luft eine Kopie des Schlüssels erzeugen, ändert das die Spielregeln. In der Programmierung ist es genauso: manche Objekte sollen semantisch genau einen Besitzer haben.

In C++ ist das vertrauteste Beispiel für einzigartiges Eigentum std::unique_ptr. Es sagt genau: „ich besitze das Objekt, es kann keinen zweiten Besitzer geben“. Deshalb wird std::unique_ptr nicht kopiert — und wenn Sie es als Feld in einer Struktur ablegen, wird die Struktur ebenfalls automatisch non-copyable (das ist das Rule‑of‑Zero‑Prinzip: das Verhalten eines Typs folgt aus dem Verhalten seiner Felder).

Mini‑Beispiel: ein Besitzer für eine Zahl im Heap (ja, eine Zahl ist ein toy‑Resource, aber der Grund ist klar).

#include <memory>

struct IntOwner {
    std::unique_ptr<int> p;
};

int main() {
    IntOwner a{std::make_unique<int>(42)};
    // IntOwner b = a; // Kompilierfehler: Kopieren verboten
}

Hier ist nicht wichtig, dass wir ein int besitzen. Wichtig ist, dass wir etwas besitzen, das nicht sicher „standardmäßig vervielfältigt“ werden darf. Genau das drückt non-copyable aus.

Manchmal versucht ein Anfänger, das Verbot zu „umgehen“, weil „ich muss das Objekt ja in eine Funktion geben“. Hier kommt der erwachsene Gedanke: wenn ein Objekt Besitzer ist, muss die Funktion ehrlich sagen, was sie tut. Wenn die Funktion nur liest — nimmt sie const&. Wenn die Funktion den Zustand des Besitzers ändert — nimmt sie &. Wenn die Funktion versucht, den Besitzer per Wert zu übernehmen (also eine Kopie verlangen würde), passt das Design der Funktion nicht zum Besitzmodell.

Eine kleine Demonstration auf Signatur‑Ebene (ohne Tricks):

void observe(const IntOwner& o) {
    (void)o; // lesen, aber nicht kopieren
}

void modify(IntOwner& o) {
    (void)o; // verändern, aber nicht kopieren
}

int main() {
    IntOwner a{std::make_unique<int>(10)};
    observe(a); // OK
    modify(a);  // OK
}

Merken Sie sich die einfache Regel: wenn ein Typ non-copyable ist, nehmen Sie ihn per Referenz (oder per Pointer, wenn das Fehlen eines Werts erlaubt ist — das ist dann ein separater Designvertrag).

3. Szenario Nr.2: Dateitypen und Besitz von Descriptors

Eine Datei ist ein gutes Beispiel für eine Ressource, die „außerhalb“ Ihres Programms lebt. Wenn Sie eine Datei zum Schreiben öffnen, bekommen Sie ein Handle auf ein System‑Objekt. Und dann beginnt die OS‑Magie: Schreibcursor, Buffer, Zugriffsrechte, Locks, Fehler … Das ist kein Fall, in dem man gern versehentlich ein Objekt kopiert und zwei Entitäten hat, die glauben, sie würden dasselbe verwalten.

In der Standardbibliothek werden Dateistreams (std::ifstream, std::ofstream) nicht kopiert. Das macht Sinn: „eine Kopie des Streams“ wäre semantisch sehr zweifelhaft. Daher werden auch Ihre Typen, die einen Dateistream enthalten, üblicherweise automatisch non-copyable.

Binden wir das an unsere MiniNotes‑Übung. Angenommen, wir schreiben ein kleines Konsolenprogramm MiniNotes: es speichert Notizen im Speicher (als Vektor) und gibt sie aus. Heute fügen wir Logging in eine Datei hinzu: „add“, „list“, „remove“. Das Log ist wie eine „Blackbox“ — es muss einzigartig und echt sein.

Ein einfachster Logger, der ein std::ofstream besitzt, könnte so aussehen:

#include <fstream>
#include <string>

struct FileLogger {
    std::ofstream out;

    explicit FileLogger(const std::string& path) : out(path) {}

    void log(const std::string& msg) {
        out << msg << '\n';
    }
};

Der Code sieht harmlos aus, hat aber die wichtige Eigenschaft: FileLogger wird nicht kopiert, weil std::ofstream nicht kopierbar ist. Das ist gut: wir wollen nicht, dass jemand versehentlich einen zweiten Logger „auf dasselbe Handle“ erstellt, mit unklaren Zuständen.

So verwenden wir das in MiniNotes. Wir bauen nicht die ganze App, sondern zeigen den Kernpunkt: wir übergeben den Logger per Referenz.

#include <iostream>
#include <string>
#include <vector>

struct Note {
    int id{};
    std::string text;
};

void add_note(std::vector<Note>& notes, FileLogger& lg, const std::string& text) {
    int new_id = static_cast<int>(notes.size()) + 1;
    notes.push_back(Note{new_id, text});
    lg.log("add id=" + std::to_string(new_id)); // in die Datei geschrieben
}

Und ein Nutzungsbeispiel:

#include <iostream>
#include <vector>

int main() {
    FileLogger lg{"app.log"};
    std::vector<Note> notes;

    add_note(notes, lg, "Buy milk");
    std::cout << notes.size() << '\n'; // 1
}

Achten Sie auf den architektonischen Effekt: der Logger wird zur zentralen Ressource, die wir nicht kopieren oder verstreuen. Entweder speichern wir ihn als Feld im „Application Context“, oder wir geben ihn dort hin, wo er gebraucht wird, per Referenz.

Falls Sie jetzt denken: „Was, wenn ich zwei Logger brauche?“ — das ist völlig in Ordnung. Aber zwei Logger müssen zwei bewusst erzeugte, unterschiedliche Objekte sein: z. B. einer schreibt in "app.log", der andere in "error.log". Das ist keine Kopie, das sind zwei verschiedene Ressourcen.

Manchmal ist es sinnvoll, das Kopierverbot explizit zu machen, auch wenn es schon implizit durch die Felder gilt. Das macht die Fehlermeldung klarer und den Typvertrag offensichtlicher:

#include <fstream>
#include <string>

struct FileLogger {
    std::ofstream out;

    explicit FileLogger(const std::string& path) : out(path) {}

    FileLogger(const FileLogger&) = delete;
    FileLogger& operator=(const FileLogger&) = delete;
};

Ja, das ist leicht redundant, aber es hilft Anfängern oft: man sieht förmlich „kopieren verboten“.

4. Szenario Nr.3: mutex‑ähnliche Typen — „Exklusivität darf nicht kopiert werden“

Das Wort „mutex“ im Titel kann einschüchternd klingen, wenn Sie noch keine Nebenläufigkeit studiert haben. Das ist ok: wir reden jetzt nicht über Threads und Synchronisation als Thema. Wir brauchen nur das Bild: es gibt ein Objekt, das Exklusivität garantiert. Exklusivität heißt: nur einer kann „hinein“.

Auch im Single‑Thread‑Kontext begegnet einem das häufig. Zum Beispiel haben Sie eine „Zugangskarte“ für einen Serverraum. Es gibt nur eine Karte. Wenn Sie sie kopieren, verliert das Konzept Sinn: plötzlich gibt es zwei Karten, und jemand kann rein, wo er nicht sollte. In der Programmierung sieht so eine „Zugangskarte“ oft wie ein Objekt aus, das ein Recht repräsentiert.

Wir modellieren so ein Objekt als einfachen Typ AccessCard. Es ist selbst billig (nur ein int), aber semantisch darf es nicht kopiert werden, weil die ID einzigartig sein und einzig bleiben soll.

struct AccessCard {
    int id{};

    explicit AccessCard(int v) : id(v) {}

    AccessCard(const AccessCard&) = delete;
    AccessCard& operator=(const AccessCard&) = delete;
};

Nun machen wir einen „Raum“, den man nur mit Karte betreten kann. Und wir definieren bewusst den Parameter als Referenz, damit niemand versehentlich eine Kopie übergibt.

#include <iostream>

void enter_server_room(const AccessCard& card) {
    std::cout << "Entered with card #" << card.id << '\n'; // Entered with card #7
}

Beispiel für die Nutzung:

int main() {
    AccessCard card{7};
    enter_server_room(card);

    // AccessCard copy = card; // Fehler: Kopieren verboten
}

Es geht hier nicht darum, dass int ein „schlechter Typ“ wäre. Es geht darum, dass wir eine Geschäftsregel mit Sprachmitteln ausdrücken: „die Karte darf nicht kopiert werden“. Und der Compiler wird so zu einem Verbündeten: er verhindert, dass man versehentlich Code schreibt, der das Sicherheitsmodell bricht.

Wenn Sie in der Standardbibliothek Typen sehen, die wie „Schlösser“ oder „Synchronisierer“ aussehen, sind diese sehr oft ebenfalls non-copyable. Aus gutem Grund: eine „Kopie des Schlosses“ macht nicht automatisch ein zweites Schloss, das dasselbe schützt; meist ist das einfach sinnlos.

5. Non-copyable in der Anwendung: Kontext, Referenzen und Besitzgrenzen

Wenn in einem Projekt ein non-copyable Objekt auftaucht, hat ein Anfänger meist zwei Fragen. Erstens: „Wie gebe ich das jetzt zwischen Funktionen weiter?“ Zweitens: „Wo speichere ich das?“ Beide Fragen lösen sich mit einer Idee: ein non-copyable Resource sollte einen klaren Besitzer haben, und der Rest des Codes sollte über Referenzen damit arbeiten.

Zurück zu MiniNotes: wir bauen einen „Application Context“, der Daten und Ressourcen zusammenhält. Das ist praktisch: im main erzeugen Sie alles „auf einmal“ und geben dann das eine Context‑Objekt per Referenz weiter.

#include <vector>

struct AppContext {
    FileLogger logger;
    std::vector<Note> notes;

    explicit AppContext(const std::string& log_path)
        : logger(log_path) {}
};

Jetzt operieren Funktionen auf AppContext&:

#include <string>

void add_note(AppContext& app, const std::string& text) {
    int new_id = static_cast<int>(app.notes.size()) + 1;
    app.notes.push_back(Note{new_id, text});
    app.logger.log("add id=" + std::to_string(new_id));
}

Und main:

int main() {
    AppContext app{"app.log"};
    add_note(app, "Read C++ book");
}

Was bringt diese Organisation?

Sie macht „resource‑volle“ Objekte (Logger, Connection, exklusiver Token) zu einem expliziten „Wurzelbesitzer“. Diese Wurzel wird in main erzeugt und lebt bis zum Programmende. Referenzen auf ihre Felder sind daher innerhalb eines sinnvollen Umfangs sicher.

Wichtig ist dabei, nicht zu versuchen, non-copyable Typen mit Hacks zu „reparieren“. Zum Beispiel ist der Gedanke „kopieren verboten, also speichere ich überall Zeiger“ trügerisch. Ein Zeiger kopiert sich zwar, aber damit haben Sie das Problem „wer ist Besitzer?“ und „wann wird das Objekt zerstört?“ nicht gelöst. Häufig enden Programme dann mit Dangling‑Pointer‑Situationen. Für Einsteiger ist die freundlichste Strategie: klarer Besitzer + Referenzen.

6. Kompilierfehler bezüglich deleted copy

Kompilierfehler sind wie Nachrichten von einem sehr ehrlichen Freund. Sie sind nicht immer höflich und nicht immer kurz, aber meist sagen sie die Wahrheit. Wenn ein Typ non-copyable ist, sehen Sie meist eine von zwei Situationen.

Situation eins — Sie haben das Kopieren selbst mittels = delete verboten, und der Compiler meldet, dass Sie versuchen, eine gelöschte Funktion zu benutzen. Das sieht dann etwa so aus (Sinn, nicht exakter Text): „use of deleted function AccessCard::AccessCard(const AccessCard&)“.

Situation zwei — Sie haben das Kopieren nicht manuell verboten, aber es wurde implizit verboten, weil eines der Felder nicht kopierbar ist (z. B. std::ofstream oder std::unique_ptr). Dann lautet die Meldung etwa: „copy constructor is implicitly deleted“ und der Compiler zeigt, welches Feld schuld ist. Das ist sehr nützlich: der Compiler sagt quasi „schau, du versuchst, einen Ressourcen‑Besitzer zu kopieren“.

Fast immer ist der Fix derselbe: prüfen Sie die Stelle, an der Sie ein Objekt per Wert übergeben/returnen, und ersetzen Sie das durch Übergabe per Referenz.

Zum Beispiel so zu schreiben ist nicht gut (weil der Parameter per Wert eine Kopie benötigt):

void bad(FileLogger lg) {
    (void)lg;
}

Und so ist es richtig:

void good(FileLogger& lg) {
    lg.log("hello");
}

Hier lohnt eine philosophische Pause: non-copyable Typen zwingen Sie zu ehrlichere Signaturen. Wenn eine Funktion per Wert verlangt, verspricht sie damit meist, dass sie eine unabhängige Kopie benötigt. Wenn eine Kopie unmöglich ist, war das Versprechen falsch.

7. Typische Fehler

Fehler Nr.1: Kopieren nur „teilweise“ verbieten (nur Konstruktor oder nur Zuweisung).
Manche schreiben T(const T&) = delete;, vergessen aber operator=(const T&). Am Ende ist ein Pfad des Kopierens verboten, der andere formal möglich bleibt (oder beim Versuch seltsame Meldungen erzeugt). Für Einsteiger die verlässlichste Gewohnheit: beide Operationen verbieten, damit der Typ konsistent non-copyable ist.

Fehler Nr.2: non-copyable Typen weiterhin per Wert entgegennehmen „aus Gewohnheit“.
Es ist leicht, automatisch void f(T t) zu schreiben, weil man das bei int oder std::string so tut. Aber bei non-copyable Typen ist das nicht nur „teuer“, das ist unmöglich. Der richtige Schritt ist, innezuhalten und den Vertrag zu wählen: const T& zum Lesen, T& zum Modifizieren. Das macht den Code nicht nur kompilierbar, sondern auch verständlicher.

Fehler Nr.3: versuchen, das Verbot mit rohen Zeigern zu umgehen.
Wenn Kopieren verboten ist, ist die Versuchung groß, überall T* zu speichern, „weil Pointer kopiert werden“. Ja, die Adresse kopiert sich — aber Sie haben damit nicht geklärt, wer der Besitzer ist und wann das Objekt zerstört wird. Häufig entstehen so Situationen, in denen ein Objekt bereits zerstört ist, aber Pointer noch im Programm herumschwirren. Für das heutige Thema ist die bessere Strategie: ein Besitzer + Referenzen.

Fehler Nr.4: einen Typ ohne Grund non-copyable machen und sich wundern, warum es „unpraktisch“ wird.
Non-copyable ist eine starke Einschränkung und sollte durch das Modell gerechtfertigt sein. Wenn ein Objekt logisch ein Wert ist (wie Point, User, Report aus früheren Vorlesungen), erschwert ein Kopierverbot das Leben: überall Referenzen, Lebenszeitfragen, mehr kognitive Last. Verbieten Sie Kopieren dort, wo eine Kopie wirklich den Sinn bricht: einzigartiges Eigentum, einzigartige Identität, Exklusivität.

Fehler Nr.5: Verwechseln von = delete (gelöschte Funktion) und delete (Speicherfreigabe).
Wegen des gleichen Wortes verwischen Anfänger manchmal die Begriffe. T(const T&) = delete; befreit keinen Speicher und „löscht kein Objekt“ — es verbietet lediglich den Aufruf der Funktion auf Kompilierzeit. Und delete p; ist die Operation zur Freigabe von Speicher (und wir haben bereits besprochen, warum manuelle Speicherverwaltung gefährlich sein kann). Das sind zwei verschiedene Konzepte mit demselben Schild.

1
Aufgabe
C++ SELF, Level 45, Lektion 2
Gesperrt
Zugangskarte
Zugangskarte
1
Aufgabe
C++ SELF, Level 45, Lektion 2
Gesperrt
Der Besitzer einer Zahl
Der Besitzer einer Zahl
1
Aufgabe
C++ SELF, Level 45, Lektion 2
Gesperrt
Rabatt-Token
Rabatt-Token
1
Aufgabe
C++ SELF, Level 45, Lektion 2
Gesperrt
Notizkontext
Notizkontext
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION