Articles

C++-Zeiger: Warum wir sie brauchen, wann wir sie verwenden, wie sie sich vom Zugriff auf das Objekt selbst unterscheiden

Auch wenn die meisten Programmierer verstehen, was der Unterschied zwischen Objekten und Zeigern auf sie ist, ist es manchmal nicht ganz klar, welche der Möglichkeiten des Zugriffs auf das Objekt zu wählen ist. Wir haben versucht, diese Frage im Folgenden zu beantworten.

Frage

Ich komme aus einem Java-Hintergrund und habe angefangen, mit Objekten in C++ zu arbeiten. Aber eine Sache, die mir aufgefallen ist, ist, dass die Leute oft Zeiger auf Objekte verwenden, anstatt die Objekte selbst, zum Beispiel diese Deklaration:

Object *myObject = new Object;

anstatt:

Object myObject;

oder anstatt eine Funktion, sagen wir testFunc(), wie diese zu verwenden:

myObject.testFunc();

müssen wir schreiben:

myObject->testFunc();

Aber ich kann nicht herausfinden, warum wir es auf diese Weise tun sollten. Ich würde vermuten, dass es mit Effizienz und Geschwindigkeit zu tun hat, da wir direkten Zugriff auf die Speicheradresse erhalten. Liege ich da richtig?

Antwort

Übrigens sollten wir beachten, dass Zeiger in Java nicht explizit verwendet werden, d.h. ein Programmierer kann nicht auf ein Objekt im Code durch einen Zeiger darauf zugreifen. In Java werden jedoch alle Typen außer Base referenziert: Der Zugriff auf sie erfolgt über den Link, obwohl man den Parameter nicht explizit per Link übergeben kann. Außerdem sind new in C++ und Java oder C# unterschiedliche Dinge.

Um eine Vorstellung von den Zeigern in C++ zu geben, geben wir zwei ähnliche Codefragmente an:

Java:

Object object1 = new Object(); //A new object is allocated by JavaObject object2 = new Object(); //Another new object is allocated by Javaobject1 = object2; //object1 now points to the object originally allocated for object2//The object originally allocated for object1 is now "dead" – //nothing points to it, so it//will be reclaimed by the Garbage Collector.//If either object1 or object2 is changed, //the change will be reflected to the other

Das nächste Äquivalent zu diesem ist:

C++:

Object * object1 = new Object(); //A new object is allocated on the heapObject * object2 = new Object(); //Another new object is allocated on the heapdelete object1;//Since C++ does not have a garbage collector, //if we don't do that, the next line would //cause a "memory leak", i.e. a piece of claimed memory that //the app cannot use //and that we have no way to reclaim...object1 = object2; //Same as Java, object1 points to object2.

Schauen wir uns den alternativen Weg in C++ an:

Object object1; //A new object is allocated on the STACKObject object2; //Another new object is allocated on the STACKobject1 = object2;//!!!! This is different! //The CONTENTS of object2 are COPIED onto object1,//using the "copy assignment operator", the definition of operator =.//But, the two objects are still different. //Change one, the other remains unchanged.//Also, the objects get automatically destroyed //once the function returns...

Erhält man einen Geschwindigkeitsgewinn, wenn man direkt auf den Speicher zugreift?

Eigentlich überhaupt nicht. Für den Zugriff auf den Heap werden in der Regel Zeiger verwendet, während sich die Objekte im Stack befinden – dies ist eine einfachere und schnellere Struktur. Wenn Sie ein Anfänger sind, haben wir für Sie einige Materialien, in denen wir im Detail erklären, was ein Stack und ein Heap ist.

Streng genommen verbindet diese Frage zwei verschiedene Themen. Erstens: Wann verwenden wir die dynamische Speicherzuweisung? Zweitens: Wann ist es besser, Zeiger zu verwenden? Sicher, wir werden nicht auf die allgemeinen Worte verzichten, dass man immer das am besten geeignete Werkzeug für die Aufgabe wählen muss. Fast immer gibt es eine bessere Umsetzung als die manuelle dynamische Zuweisung (dynamische Allokation) und/oder rohe Zeiger zu verwenden.

Es ist sehr bedauerlich, dass man die dynamische Zuweisung so oft sieht. Das zeigt nur, wie viele schlechte C++-Programmierer es gibt.

In gewissem Sinne haben Sie zwei Fragen in einer gebündelt. Die erste ist, wann sollten wir dynamische Zuweisung (mit new) verwenden? Die zweite lautet: Wann sollten wir Zeiger verwenden?

Die wichtige Botschaft, die man mitnehmen kann, ist, dass man immer das richtige Werkzeug für die jeweilige Aufgabe verwenden sollte. In fast allen Situationen gibt es etwas Angemesseneres und Sichereres als die manuelle dynamische Zuweisung und/oder die Verwendung von rohen Zeigern.

Dynamische Zuweisung

In Ihrer Frage haben Sie zwei Möglichkeiten zur Erstellung eines Objekts aufgezeigt. Der Hauptunterschied ist die Speicherdauer des Objekts. Wenn Sie Object myObject; innerhalb eines Blocks ausführen, wird das Objekt mit automatischer Speicherdauer erstellt, was bedeutet, dass es automatisch zerstört wird, wenn es den Anwendungsbereich verlässt. Wenn Sie new Object() ausführen, hat das Objekt eine dynamische Speicherdauer, d. h. es bleibt am Leben, bis Sie es explizit löschen. Sie sollten die dynamische Speicherdauer nur verwenden, wenn Sie sie brauchen. Das heißt, Sie sollten es immer vorziehen, Objekte mit automatischer Speicherdauer zu erstellen, wenn Sie es können.

Die beiden wichtigsten Situationen, in denen Sie eine dynamische Zuweisung benötigen könnten:

  1. Sie brauchen das Objekt, um den aktuellen Bereich zu überleben – dieses spezifische Objekt an dieser spezifischen Speicherposition, nicht eine Kopie davon. Wenn Sie mit dem Kopieren/Verschieben des Objekts einverstanden sind (was in den meisten Fällen der Fall sein sollte), sollten Sie ein automatisches Objekt bevorzugen.
  2. Sie müssen eine Menge Speicher zuweisen, was leicht den Stack füllen kann. Es wäre schön, wenn wir uns darüber keine Gedanken machen müssten (die meiste Zeit sollte man das auch nicht müssen), da es wirklich außerhalb des Aufgabenbereichs von C++ liegt, aber leider müssen wir uns mit der Realität der Systeme auseinandersetzen, für die wir entwickeln.
  3. Sie kennen die Array-Größe nicht genau, die Sie verwenden müssen. Wie Sie wissen, ist in C++ die Größe der Arrays festgelegt. Das kann zu Problemen führen, zum Beispiel beim Lesen von Benutzereingaben. Der Zeiger definiert nur den Abschnitt des Speichers, in den der Anfang eines Arrays geschrieben wird, ohne seine Größe zu begrenzen.

Wenn die Verwendung einer dynamischen Zuweisung notwendig ist, sollten Sie sie mit einem intelligenten Zeiger oder einem anderen Typ kapseln, der das Idiom „Ressourcenerwerb ist Initialisierung“ unterstützt (Standardcontainer unterstützen es – es ist ein Idiom, nach dem die Ressource: ein Speicherblock, eine Datei, eine Netzwerkverbindung usw. – initialisiert werden, während sie in den Konstruktor gelangen, und dann vorsichtig durch den Destruktor zerstört werden). Intelligente Zeiger sind z.B. std::unique_ptr und std::shared_ptr

Zeiger

Es gibt jedoch noch andere, allgemeinere Verwendungszwecke für rohe Zeiger jenseits der dynamischen Zuweisung, aber die meisten haben Alternativen, die man vorziehen sollte. Wie zuvor, bevorzugen Sie immer die Alternativen, es sei denn, Sie brauchen Zeiger wirklich.

  1. Sie brauchen Referenzsemantik. Manchmal möchte man ein Objekt mit einem Zeiger übergeben (unabhängig davon, wie es alloziert wurde), weil man möchte, dass die Funktion, an die man es übergibt, Zugriff auf dieses spezielle Objekt hat (nicht auf eine Kopie davon). In den meisten Situationen sollten Sie jedoch Referenztypen gegenüber Zeigern bevorzugen, da sie genau dafür gedacht sind. Beachten Sie, dass es hier nicht unbedingt darum geht, die Lebensdauer des Objekts über den aktuellen Bereich hinaus zu verlängern, wie in Situation 1 oben. Wie zuvor, wenn Sie damit einverstanden sind, eine Kopie des Objekts zu übergeben, brauchen Sie keine Referenzsemantik.
  2. Sie brauchen Polymorphismus. Sie können Funktionen nur polymorph (d.h. entsprechend dem dynamischen Typ eines Objekts) über einen Zeiger oder eine Referenz auf das Objekt aufrufen. Wenn das das Verhalten ist, das Sie brauchen, dann müssen Sie Zeiger oder Referenzen verwenden. Auch hier sollten Referenzen bevorzugt werden.
  3. Sie wollen darstellen, dass ein Objekt optional ist, indem Sie erlauben, dass eine nullptr übergeben wird, wenn das Objekt weggelassen wird. Wenn es sich um ein Argument handelt, sollten Sie lieber Standardargumente oder Funktionsüberladungen verwenden. Andernfalls sollten Sie lieber einen Typ verwenden, der dieses Verhalten kapselt, wie z.B. std::optional (eingeführt in C++17 – bei früheren C++-Standards verwenden Sie boost::optional).
  4. Sie möchten Kompilierungseinheiten entkoppeln, um die Kompilierungszeit zu verbessern. Die nützliche Eigenschaft eines Zeigers ist, dass Sie nur eine Vorwärtsdeklaration des Typs, auf den gezeigt wird, benötigen (um das Objekt tatsächlich zu verwenden, benötigen Sie eine Definition). Dies ermöglicht es Ihnen, Teile Ihres Kompilierungsprozesses zu entkoppeln, was die Kompilierungszeit erheblich verbessern kann. Siehe das Pimpl-Idiom.
  5. Sie benötigen eine Schnittstelle zu einer C-Bibliothek oder einer C-ähnlichen Bibliothek. An diesem Punkt sind Sie gezwungen, rohe Zeiger zu verwenden. Das Beste, was Sie tun können, ist sicherzustellen, dass Sie Ihre rohen Zeiger erst im letztmöglichen Moment loslassen. Sie können einen Rohzeiger zum Beispiel von einem intelligenten Zeiger erhalten, indem Sie dessen get-Funktion verwenden. Wenn eine Bibliothek eine Zuweisung für Sie durchführt, von der sie erwartet, dass Sie sie über ein Handle wieder freigeben, können Sie das Handle oft in einen Smart Pointer mit einem benutzerdefinierten Deleter verpacken, der das Objekt entsprechend freigibt.