Sökresultat för

En djupdykning i C++ mest populära container

10 minuter i lästid
Jens Riboe
Jens Riboe
Senior/Expert Software Developer
En djupdykning i C++ mest populära container

Det finns ett flertal containertyper i C++, men den absolut populäraste är std::vector, vilken är ämnet för denna artikel. Den består av ett dynamiskt allokerat minnesblock, som omallokeras allt efter behov, helt automatiskt.

#include <print>
#include <vector>

int main() { 
    auto numbers = std::vector<int>{1, 2, 3, 4, 5}; 
    for (auto x : numbers)  std::print("{} ", x);
}
// prints: 1 2 3 4 5 

Populering

Det finns flera olika sätt att populera en vektor. Här nedan visar jag det vanligaste varianterna:

// compile-time initialization
auto numbers = std::vector<int>{1, 2, 3, 4, 5}; 
// [1, 2, 3, 4, 5]

// run-time initialization
auto numbers = std::vector<int>{};
for (auto k=1; k<=5; ++k) numbers.push_back(k);
// [1, 2, 3, 4, 5]

// based on another container
auto s = std::set<int>{5,3,1,4,5,1,1,3,2};
auto numbers = std::vector<int>{s.begin(), s.end()};
// [1, 2, 3, 4, 5]

// based on an array
int arr[] = {1,2,3,4,5};
auto numbers = std::vector<int>{std::begin(arr), std::end(arr)};
// [1, 2, 3, 4, 5]

// initialization with a count and a value
auto numbers = std::vector<int>(5, 42); 
// [42, 42, 42, 42, 42]

// initialization with a count and the default value of the element type
auto numbers = std::vector<int>(5); 
// [0, 0, 0, 0, 0]

Compiler Explorer Try it yourself on Compiler Explorer

I de två sista alternativen är det viktigt att använda parenteser och inte klamrar. Med klamrar i de två sista, initieras vektorn med två respektive ett värde. Det här är exempel på subtiliteter i språkets syntax, man dessvärre behöver ha koll på.

CTAD

När man initierar med statiska värden, såsom det första alternativet visar, kan kompilatorn klura ut vilken elementtyp som används, utan att man behöver ange det själv. Det här kallas Class Template Argument Deduction, förkortat CTAD och infördes i C++17.

int main() {
    {
        auto numbers = std::vector{1,2,3,4,5};
        std::println("ints   : {}", numbers);
    }
    {
        auto numbers = std::vector{1.1,2.2,3.3,4.4,5.5};
        std::println("doubles: {}", numbers);
    }
    {
        using namespace std::string_literals;
        auto numbers = std::vector{"hej"s, "hopp"s, "tjabba"s};
        std::println("strings: {}", numbers);
    }
}
ints   : [1, 2, 3, 4, 5]
doubles: [1.1, 2.2, 3.3, 4.4, 5.5]
strings: ["hej", "hopp", "tjabba"]

Anropa först reserve()

Låt oss se hur minneshanteringen fungerar när man använder push_back.

auto operator new(size_t bytes) -> void* {
    std::println("[new] bytes={}", bytes);
    return ::malloc(bytes);
}
int main() {
    auto const N = 20;
    auto numbers = std::vector<int>{};
    for (auto k = 1; k <= N; ++k) numbers.push_back(k);
    std::println("w/o reserve: {}", numbers);
}
[new] bytes=4
[new] bytes=8
[new] bytes=16
[new] bytes=32
[new] bytes=64
[new] bytes=128
w/o reserve: [1, 2, 3, 4, 5, ...]

Här kan du tydligt se hur vektorn dubblerar sin interna array i takt med att värden sätts in. Det här är givetvis ett minnesslöseri, vilket motiverar att du skall alltid först anropa metoden reserve(), som förallokerar ett tillräckligt stort minnesblock.

auto numbers = std::vector<int>{};
numbers.reserve(N);
for (auto k = 1; k <= N; ++k) numbers.push_back(k);
std::println("w reserve: {}", numbers);
[new] bytes=80
w reserve: [1, 2, 3, 4, 5, ...]

Denna gång allokerades bara ett minnesblock, med plats för 20 heltal om 4 bytes vardera. Om du kör detta exempel i Compiler Explorer, så ser du också att std::println allokerar minnesblock, om 85 respektive 83 bytes.

Compiler Explorer Try it yourself on Compiler Explorer

Sätt inte in element i början

Vektorn är konstruerad för insättning i slutet, men inte för insättning i början. Så det finns metoden push_back() men inte push_front(). Emellertid, finns metoden insert() som kan sätta in ett element på godtycklig plats i en vektor. Låt oss kika på hur det går till.

struct Dummy {
    inline static int next = 1;
    int value = next++;
    Dummy() { std::println("Dummy({})", value); }
    Dummy(Dummy const& rhs) { 
        std::println("Dummy(Dummy const& {})", rhs.value); 
    }
};

int main() {
    std::println("-- initialization of 3 elements --");
    auto v = std::vector<Dummy>(3);
    
    std::println("-- insertion at front --");
    auto x = Dummy{};
    v.insert(v.begin(), x);
    for (auto& d : v) std::print("{} ", d.value);
}
-- initialization of 3 elements --
Dummy(1)
Dummy(2)
Dummy(3)
-- insertion at front --
Dummy(4)
Dummy(Dummy const& 4)
Dummy(Dummy const& 1)
Dummy(Dummy const& 2)
Dummy(Dummy const& 3)
4 1 2 3 

Compiler Explorer Try it yourself on Compiler Explorer

Så, vad händer här egentligen? Jo, de tre elementen skiftas ett steg bakåt med hjälp av dess copy-constructor. Tänk på hur mycket extra-arbete detta skulle vara för en vektor med betydligt fler element.

Åtkomst av element

Du kan komma åt enstaka element via två olika metoder. Låt oss kika på dem både och se hur de skiljer sig åt. Den första är index-operatorn operator[](IndexType) och den andra är metoden at(IndexType). Både returnerar referensen till elementet ifråga, så man använder samma metod både för läsning respektive skrivning.

struct Dummy {
    inline static int next = 1;
    int value = next++;
    Dummy() { std::println("Dummy({})", value); }
    Dummy(Dummy const&) { std::println("Dummy(Dummy const& {})", value); }
};

int main() {
    auto v = std::vector<Dummy>(3);
    std::println("v[1]: {}", v[1].value);
    v[1].value = 42;
    std::println("v[1]: {}", v[1].value);

    v[6] = Dummy{};
    std::println("v[6]: {}", v[6].value);
    std::println("v.size={}, v.capacity={}", v.size(), v.capacity());

    try { v.at(5) = Dummy{}; }
    catch (std::exception& x) { std::println("ERR: {}", x.what()); }
}
Dummy(1)
Dummy(2)
Dummy(3)
v[1]: 2
v[1]: 42
Dummy(4)
v[6]: 4   // 😮 outside its boundary
v.size=3, v.capacity=3
Dummy(5)
ERR: vector::_M_range_check: __n (which is 5) >= this->size() (which is 3)

Compiler Explorer Try it yourself on Compiler Explorer

Som du ser här ovan, så finns det ingen indexkontroll för operator[], medan metoden at() kastar en exception om man försöker gå utanför indexgränsen. Så använd index-operatorn bara när du är helt säker på att du håller dig innanför gränserna, såsom kontroll mot size().

for (auto k = 0; k < v.size(); ++k) std::print("{} ", v[k]);

Extraktion av element

Man extraherar element i slutet av en vektor, med hjälp av två metoder. Först back(), som returnerar det sista värdet och sen pop_back(), som tar bort elementet.

struct Dummy {
    inline static int next = 1;
    int value = next++;
    Dummy() { std::println("Dummy({})", value); }
    Dummy(Dummy const& rhs) { 
        std::println("Dummy(Dummy const& {})", rhs.value); 
        value = rhs.value;
    }
};

int main() {
    auto v = std::vector<Dummy>(4);
    auto last = v.back();
    std::println("last: {}, size={}, capacity={}", last.value, v.size(), v.capacity());

    v.pop_back();
    std::println("last: {}, size={}, capacity={}", last.value, v.size(), v.capacity());

    while (not v.empty()) {
        auto last = v.back(); v.pop_back();
        std::println("last: {}, size={}, capacity={}", last.value, v.size(), v.capacity());
    }
}
Dummy(1)
Dummy(2)
Dummy(3)
Dummy(4)
Dummy(Dummy const& 4)
last: 4, size=4, capacity=4     //v.back();
last: 4, size=3, capacity=4     //v.pop_back();
Dummy(Dummy const& 3)
last: 3, size=2, capacity=4
Dummy(Dummy const& 2)
last: 2, size=1, capacity=4
Dummy(Dummy const& 1)
last: 1, size=0, capacity=4

Compiler Explorer Try it yourself on Compiler Explorer

Om du behöver radera fler element på en gång, och inte "behöver" dessa, är det effektivare att använda metoden erase(). Här kommer ett illustrativt exempel.

struct Dummy {
    inline static int next = 1;
    int value = next++;
    Dummy() { std::println("{}", to_string("CREATE  "s)); }
    ~Dummy() { std::println("{}", to_string("DISPOSE "s)); }
    //...
    auto to_string(std::string const& op = ""s) const -> std::string {
        if (op.empty())
            return std::format("Dummy({}) @ {}", value, reinterpret_cast<size_t>(this));
        return std::format("{} Dummy({}) @ {}", op, value, reinterpret_cast<size_t>(this));
    }
};

int main() {
    auto v = std::vector<Dummy>(5);
    for (auto&& d : v) std::println("{} ", d.to_string());

    std::println("-- erase --");
    v.erase(v.begin() + 2, v.end());
    std::println("-- done --");
}
CREATE   Dummy(1) @ 168821424
CREATE   Dummy(2) @ 168821428
CREATE   Dummy(3) @ 168821432
CREATE   Dummy(4) @ 168821436
CREATE   Dummy(5) @ 168821440
Dummy(1) @ 168821424 
Dummy(2) @ 168821428 
Dummy(3) @ 168821432 
Dummy(4) @ 168821436 
Dummy(5) @ 168821440 
-- erase --
DISPOSE  Dummy(3) @ 168821432
DISPOSE  Dummy(4) @ 168821436
DISPOSE  Dummy(5) @ 168821440
-- done --
DISPOSE  Dummy(1) @ 168821424
DISPOSE  Dummy(2) @ 168821428

Compiler Explorer Try it yourself on Compiler Explorer

Användning av emplace_back()

Förutom push_back() finns det också en snarlik metod som heter emplace_back(), vad skiljer dessa åt? Här kommer ett illustrerande exempel.

struct Person {
    std::string name{};
    unsigned age{};
    float height{};
    Person() { std::println("Person()"); }
    ~Person() { std::println("~Person({}, {}, {})", name, age, height); }
    Person(std::string n, unsigned a, float h)
        : name{std::move(n)}, age{a}, height{h} 
    {
        std::println("Person({}, {}, {})", name, age, height);
    }
    Person(Person const& rhs) 
        : name{std::move(rhs.name)}, age{rhs.age}, height{rhs.height} 
    {
        std::println("Person const&({}, {}, {})", name, age, height);
    }
    Person(Person&& rhs) noexcept
        : name{std::move(rhs.name)}, age{rhs.age}, height{rhs.height} 
    {
        std::println("Person&&({}, {}, {})", name, age, height);
        rhs.age=0, rhs.height=0;
    }
};

int main() {
    auto v = std::vector<Person>{};
    v.reserve(3);
    {
        std::println("-- copy --");
        auto p = Person{"Anna"s, 42U, 179.5F};
        v.push_back(p);
    }
    {
        std::println("-- move --");
        v.push_back( Person{"Berit"s, 32U, 175.5F} );
    }
    {
        std::println("-- emplace --");
        v.emplace_back( "Carin"s, 52U, 177.5F );
    }
    std::println("-- print --");
    for (auto&& p : v) std::println("Person({}, {}, {})", p.name, p.age, p.height);
    std::println("-- done --");
}
-- copy --
Person(Anna, 42, 179.5)
Person const&(Anna, 42, 179.5)
~Person(Anna, 42, 179.5)
-- move --
Person(Berit, 32, 175.5)
Person&&(Berit, 32, 175.5)
~Person(, 0, 0)
-- emplace --
Person(Carin, 52, 177.5)
-- print --
Person(Anna, 42, 179.5)
Person(Berit, 32, 175.5)
Person(Carin, 52, 177.5)
-- done --
~Person(Anna, 42, 179.5)
~Person(Berit, 32, 175.5)
~Person(Carin, 52, 177.5)

Compiler Explorer Try it yourself on Compiler Explorer

Som du kan se här ovan, tar emplace_back() konstruktor-parametrar för element-typen Person och initierar objektet direkt på plats i den interna array:en hos std::vector objektet. I det två första fallen skapas ett extra objekt på stacken, som sen destrueras. Jämför du dessa två alternativ, ser du att har man ett namngivet objekt så anropas copy-constructor, men skickar man ett anonymt objekt som argument, så anropas move-constructor.

Vitsen med resize()

Jag har tidigare i denna artikel beskrivit metoden reserve() och dess betydelse för minnesanvändningen. Men det finns en snarlik metod som heter resize(), och hur fungerar den och när är den användbar? Låt oss kika på ett exempel, som använder en STL algoritm-funktion.

struct Person {
    std::string name{};
    unsigned age{};
    float height{};
    inline static int next_id = 1;
    Person() { 
        name = "person-"s + std::to_string(next_id++);
        std::println("Person({})", name); 
    }
    //...
};

int main() {
    auto const N = 3;
    {
        std::println("-- reserve --");
        auto v = std::vector<Person>{};
        v.reserve(N);
        std::for_each(v.begin(), v.end(), [](auto&& p){
            std::println("Person({}, {}, {})", p.name, p.age, p.height);
        });
    }
    {
        std::println("-- resize --");
        auto v = std::vector<Person>{};
        v.resize(N);
        std::for_each(v.begin(), v.end(), [](auto&& p){
            std::println("Person({}, {}, {})", p.name, p.age, p.height);
        });
    }
}
-- reserve --
-- resize --
Person(person-1)
Person(person-2)
Person(person-3)
Person(person-1, 0, 0)
Person(person-2, 0, 0)
Person(person-3, 0, 0)
~Person(person-1, 0, 0)
~Person(person-2, 0, 0)
~Person(person-3, 0, 0)

Compiler Explorer Try it yourself on Compiler Explorer

Reserve allokerar ett internt minnesblock, men det påverkar bara capacity men inte size. Medan resize gör detta plus initierar samtliga element med default constructor. Det är därför vi inte ser några utskrifter i första fallet, eftersom vektorn är tom.