Chris Codeblog

Tipps, Tricks & Tutorials rund ums Programmieren

[C++] std::vector und die Speicherverwaltung an ein paar Beispielen

Vor nicht allzu langer Zeit habe ich mich etwas mit std::uniuqe_ptr in Kombinationn mit std::vector beschäftigt und bin dabei selbst auf ein paar Dinge „gestoßen“, die wohl auch für ein paar andere Interessant sein dürften. Oftmals ist es so, dass man bestimmte Techniken/Klassen/etc. einfach verwendet, ohne sich groß Gedanken zu machen wie das ganze im Hintergrund funktioniert (zumindest hoffe ich, dass es nicht nur mir so geht).

std::vector.push_back – simpler Datentyp

Der „einfachste“ und wohl bekannteste Weg Daten in einen Vector zu bekommen ist std::vector.push_back zu benutzen. Ein kleines Beispiel:

void VectorGroesseOhneReserve() {
    // Vector erzeugen
    std::vector<int> vec;

    // Vectorgroesse + Kapazitaet
    PrintVectorGroesse(vec);

    // 10 Objekte im Vector ablegen
    for(int nAnzahl = 0; nAnzahl < 6; ++nAnzahl) {
        vec.push_back(nAnzahl);
        
        std::cout << "Kapazität: " << vec.capacity() << " (" << &vec << "): ";

        for(const auto &n: vec) {
            std::cout << &n << ";";
        }
        std::cout << std::endl;
    }
}

Was passiert hier? Als erstes legen wir einen std::vector an, der als Datentyp einen inthält. Danach fügen wir dem Vector nach und nach Daten hinzu und geben dazu Informationen aus. Das Ergebnis nachfolgend aufgelistet:

Anzahl: 0, Kapazität: 0 (0x16f4bb578)
Kapazität: 1 (0x16f4bb578): 0x600001320030;
Kapazität: 2 (0x16f4bb578): 0x600001320040;0x600001320044;
Kapazität: 4 (0x16f4bb578): 0x600001320030;0x600001320034;0x600001320038;
Kapazität: 4 (0x16f4bb578): 0x600001320030;0x600001320034;0x600001320038;0x60000132003c;
Kapazität: 8 (0x16f4bb578): 0x600001125180;0x600001125184;0x600001125188;0x60000112518c;0x600001125190;
Kapazität: 8 (0x16f4bb578): 0x600001125180;0x600001125184;0x600001125188;0x60000112518c;0x600001125190;0x600001125194;

In jeder Zeile wird die Kapazität des Vectors und die Adressen der hinterlegten Objekte angezeigt. Wie man sehen kann, ändern sich die Adressen der hinterlegten Objekt immer dann, wenn sich die Kapazität erhöht. Aber warum ist das so? Eins nach dem anderen. Sehen wir uns erstmal an, was passiert.

Zeile 1: Nach dem initialisieren hat der enthält der Vector 0 Elemente und kann kann auch keine Elemente aufnehmen.

Zeile 2: Die Kapazität des Vectors wird auf 1 erhöht, das Objekt wird abgelegt

Zeile 3: Ein Objekt wird zum Vector hinzugefügt. Da die Kapazität nicht mehr reicht, wird ein neuer Speicherbereich erzeugt, (aktuelle Größe x 2), dann werden die bestehenden Objekte des ursprünglichen Vectors in den neuen Speicher kopiert und der alte Speicher wird freigegeben.

Zeile 4 und 6: Hier passiert das gleiche wie bei Zeile 3

Zeile 5 und 7: Da noch genügend freier Speicher im Vector vorhanden ist, können die neuen Objekte einfach im Vector abgelegt werden, ohne dass Speicher angefordert/freigegeben und Objekte kopiert werden müssen.

Übrigens ist der Multiplikator (x2) nicht fix, sondern kann sich von Compiler zu Compiler unterscheiden.

std::vector.push_back + reserve – simpler Datentyp

Schauen wir uns ein weiteres Beispiel an. Dieses mal verwenden wir aber std::vector.reserve um den benötigten Speicherplatz bereits am Anfang zu reservieren:

void VectorGroesseMitReserve() {
    // Vector erzeugen
    std::vector<int> vec;

    // Speicher reservieren
    vec.reserve(6);

    // Vectorgroesse + Kapazitaet
    PrintVectorGroesse(vec);

    // 6 Objekte im Vector ablegen
    for(int nAnzahl = 0; nAnzahl < 6; ++nAnzahl) {
        vec.push_back(nAnzahl);

        std::cout << "Kapazität: " << vec.capacity() << " (" << &vec << "): ";

        for(const auto &n: vec) {
            std::cout << &n << ";";
        }
        std::cout << std::endl;
    }
}

Die Änderung ist in Zeile 6 zu sehen. Wenn wir schon zu beginn wissen, wie viele Daten im Vector abgelegt werden, können wir den Speicher direkt mit std::vector.reserve reservieren und sparen uns den späteren Verwaltungsaufwand. In diesem Fall sieht die Ausgabe wie folgt aus:

Anzahl: 0, Kapazität: 6 (0x16dcb3578)
Kapazität: 6 (0x16dcb3578): 0x60000085c000;
Kapazität: 6 (0x16dcb3578): 0x60000085c000;0x60000085c004;
Kapazität: 6 (0x16dcb3578): 0x60000085c000;0x60000085c004;0x60000085c008;
Kapazität: 6 (0x16dcb3578): 0x60000085c000;0x60000085c004;0x60000085c008;0x60000085c00c;
Kapazität: 6 (0x16dcb3578): 0x60000085c000;0x60000085c004;0x60000085c008;0x60000085c00c;0x60000085c010;
Kapazität: 6 (0x16dcb3578): 0x60000085c000;0x60000085c004;0x60000085c008;0x60000085c00c;0x60000085c010;0x60000085c014;

Nach dem reserve aufgerufen wurde hat der Vector gleich die angegebene Kapazität. Solange das hinzugefügte Objekt noch in den reservierten Bereich des Vectors passt, wird kein neuer Speicher angefordert und das Objekt nicht auf einen neuen Speicherbereich kopiert. Wird die Anzahl der reservierten Plätze überschritten geht das ganze allerdings wieder von vorne los.

std::vector.push_back – komplexer Datentyp

Soweit, sogut. Sehen wir uns doch mal an, wie das ganze mit einem komplexen Datentyp aussieht. Mit diesem können wir uns Meldungen des Konstruktor, Kopierkonstruktors und Destruktors ausgeben lassen und es wird nochmal etwas ersichtlicher, was überhaupt passiert, wenn ein Objekt zu einem Vector hinzugefügt wird.

Dazu legen wir uns erstmal eine kleine Klasse an. In meinem Beispiel gibt es eine Klasse Person, die wie folgt aussieht:

//
// Created by Chris on 10.11.22.
//

#ifndef VECTOR_PERSON_H
#define VECTOR_PERSON_H

#include <string>


class Person {
private:
    std::string m_strVorname;
    std::string m_strNachname;

public:
    Person(const std::string &strVorname, const std::string &strNachname);
    Person(const Person &person);
    virtual ~Person();
};


#endif //VECTOR_PERSON_H
//
// Created by Chris on 10.11.22.
//

#include "Person.h"
#include <iostream>

Person::Person(const std::string &strVorname, const std::string &strNachname)
    : m_strVorname(strVorname),
    m_strNachname(strNachname) {
    std::cout << "Person " << this->m_strVorname << " " << this->m_strNachname << " (" << this << ") erzeugt" << std::endl;
}

Person::Person(const Person &person)
    : m_strVorname(person.m_strVorname),
    m_strNachname(person.m_strNachname) {
    std::cout << "Person " << this->m_strVorname << " " << this->m_strNachname << " (" << this << ") kopiert" << std::endl;
}

Person::~Person() {
    std::cout << "Person " << this->m_strVorname << " " << this->m_strNachname << " (" << this << ") freigegeben" << std::endl;
}

Die Klasse besitzt einen Konstruktor, einen Kopierkonstruktor und den Destruktor. Zu jeder Funktion wird eine Meldung ausgegeben. Jetzt machen wir das gleiche, was wir schon beim Vector mit den Integern gemacht haben. Wir legen einen Vector an und fügen 2 Personen hinzu:

void VectorPersonPushBackOhneReserve() {
    std::vector<Person> vec;

    for(int nAnzahl = 0; nAnzahl < 2; ++nAnzahl) {
        std::cout << "-----------------------------------------" << std::endl;
        vec.push_back(Person("Max", "Mustermann " + std::to_string(nAnzahl)));
    }
}

Die Ausgabe sieht wie folgt aus:

-----------------------------------------
Person Max Mustermann 0 (0x16afab528) erzeugt
Person Max Mustermann 0 (0x600000cb4000) kopiert
Person Max Mustermann 0 (0x16afab528) freigegeben
-----------------------------------------
Person Max Mustermann 1 (0x16afab528) erzeugt
Person Max Mustermann 1 (0x6000032b0038) kopiert
Person Max Mustermann 0 (0x6000032b0000) kopiert
Person Max Mustermann 0 (0x600000cb4000) freigegeben
Person Max Mustermann 1 (0x16afab528) freigegeben
Person Max Mustermann 1 (0x6000032b0038) freigegeben
Person Max Mustermann 0 (0x6000032b0000) freigegeben

Puh. Was passiert denn hier nun?

Zeile 2: Die erste Person wird erzeugt

Zeile 3: Die erste Person wird in den Vector kopiert

Zeile 4: Da die als erstes erzeugte Person out of scope geht (Ende der for-Schleife), wird sie freigegeben

Zeile 6: Die zweite Person wird erzeugt

Zeile 7: Die zweite Person wird in den Vector kopiert

Zeile 8: Die erste Person wird wieder kopiert

Zeile 9: Die erste (ursprüngliche im Vector abgelegte Person) wird freigegeben

Zeile 10: Die zweite Person wird freigegeben weil die for-Schleife wieder am Ende angekommen ist

Zeile 11 und 12: Die zwei Personen, die im Vector sind, werden wieder freigegeben, weil wir das Ende der Funktion selbst erreicht haben

Das Interessante passiert in Zeile 8 und 9. Die Person, die im ersten Schleifendurchgang erzeugt wurde, wird kopiert und wieder freigegeben. Warum? Der Vector hat nach dem ersten push_back eine Größe von 1. Beim zweiten push_backsteht fest, dass der Speicher nicht reicht. Also muss neuer Speicher angefordert/initialisiert werden (diesmal mit einer Größe von 2). Anschließend wird die Person aus dem ursprünglichen Vector an die neue Speicheradresse kopiert.

std::vector.push_back + reserve – komplexer Datentyp

Nächster Versuch. Dieses mal verwenden wir aber wieder die Funktion reservedes Vectors und reservieren uns damit gleich mal den benötigten Speicher für unsere zwei Objekte:

void VectorPersonPushBackMitReserve() {
    std::vector<Person> vec;
    vec.reserve(2);

    for(int nAnzahl = 0; nAnzahl < 2; ++nAnzahl) {
        std::cout << "-----------------------------------------" << std::endl;
        vec.push_back(Person("Max", "Mustermann " + std::to_string(nAnzahl)));
    }
}

Ausgabe:

-----------------------------------------
Person Max Mustermann 0 (0x16afab530) erzeugt
Person Max Mustermann 0 (0x6000032b4000) kopiert
Person Max Mustermann 0 (0x16afab530) freigegeben
-----------------------------------------
Person Max Mustermann 1 (0x16afab530) erzeugt
Person Max Mustermann 1 (0x6000032b4038) kopiert
Person Max Mustermann 1 (0x16afab530) freigegeben
Person Max Mustermann 1 (0x6000032b4038) freigegeben
Person Max Mustermann 0 (0x6000032b4000) freigegeben

Nun sieht das ganze schon etwas logischer aus oder? Die erste Person wird erzeugt, in den Vector kopiert, die Person auf dem Stack wird wieder freigegeben. Das gleiche passiert mit der zweiten Person. Zum Schluss werden in Zeile 9 und 10 wieder die beiden Personen im Vector freigegeben.

std::vector.emplace_back – komplexer Datentyp

Geht das ganze nicht noch etwas effizienter? Ja, das geht! Hier kommt emplace_backins Spiel. Hier wird das Objekt, das dem Vector hinzugefügt wird, direkt auf dem entsprechenden Speicherbereich erzeugt. Damit sparen wir uns die Kopieroperation in den Vector.

void VectorPersonEmplaceBackOhneReserve() {
    std::cout << "Emplace Back ohne Reserve" << std::endl;
    std::vector<Person> vec;

    for(int nAnzahl = 0; nAnzahl < 2; ++nAnzahl) {
        std::cout << "-----------------------------------------" << std::endl;
        vec.emplace_back("Max", "Mustermann " + std::to_string(nAnzahl));
    }
}

Und die Ausgabe:

-----------------------------------------
Person Max Mustermann 0 (0x600000cb8000) erzeugt
-----------------------------------------
Person Max Mustermann 1 (0x6000032b8038) erzeugt
Person Max Mustermann 0 (0x6000032b8000) kopiert
Person Max Mustermann 0 (0x600000cb8000) freigegeben
Person Max Mustermann 1 (0x6000032b8038) freigegeben
Person Max Mustermann 0 (0x6000032b8000) freigegeben

Wir sehen, die erste Person wird erzeugt. Die zweite ebenfalls und dann wird die erste wieder kopiert. Hier haben wir wieder das gleiche Problem wie vorher: Der Vector hat nach dem ersten push_back nur eine Größe von eins. Also muss wieder neuer Speicher angefordert werden und die Daten werden in den neuen Speicherbereich kopiert. Zeile 7 und 8 ist wieder die normale Speicherfreigabe am Ende der Funktion.

std::vector.emplace_back + reserve – komplexer Datentyp

Zum Schluss natürlich noch die Version mit reservieren des benötigten Speichers:

void VectorPersonEmplaceBackMitReserve() {
    std::vector<Person> vec;
    vec.reserve(2);

    for(int nAnzahl = 0; nAnzahl < 2; ++nAnzahl) {
        std::cout << "-----------------------------------------" << std::endl;
        vec.emplace_back("Max", "Mustermann " + std::to_string(nAnzahl));
    }
}

Ausgabe:

-----------------------------------------
Person Max Mustermann 0 (0x6000032b0000) erzeugt
-----------------------------------------
Person Max Mustermann 1 (0x6000032b0038) erzeugt
Person Max Mustermann 1 (0x6000032b0038) freigegeben
Person Max Mustermann 0 (0x6000032b0000) freigegeben

Und damit haben wir eigentlich auch die ursprünglich erwartete Ausgabe oder? Zuerst wird die erste Person erzeugt, dann die zweite und dazwischen sind keine Kopieroperationen. Am Ende der Funktion werden beide Personen wieder freigegeben.

[C++] std::vector und die Speicherverwaltung an ein paar Beispielen

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Nach oben scrollen