In traditional C you manage string length yourself with raw char arrays, and a static array's size is fixed the moment you declare it. Modern C++ answers both with the standard library: std::string and std::vector. We will also look under them β€” at SSO (small string optimisation), at pointer invalidation, and at what you can actually do about performance.

1. std::string and small string optimisation (SSO)

std::string owns the memory holding its characters and grows the buffer for you when you append. No more strcpy or strcat and the buffer overflows they invite.

What SSO actually is

Allocating on the heap is expensive: the allocator has to go looking for a free block. Since short strings are overwhelmingly common in real programs, modern C++ standard libraries apply small string optimisation:

  • If the string is short enough, the characters are stored inside the std::string object itself β€” usually on the stack. Not a single heap allocation happens.
  • Past that threshold, std::string switches to a heap allocation and keeps the address in an internal pointer.
⚠️ "15 characters" is one library's number, not C++'s
You will read everywhere that the SSO threshold is 15 characters on a 64-bit system. That is true of libstdc++ (the standard library shipped with GCC). But the C++ standard specifies no threshold at all β€” it does not even require SSO to exist. libc++ (the default on macOS and with Clang) picks a different one.

So do not memorise a number, measure it on your own machine the way the code below does: compare the address of s.data() against the memory of the object s itself. Data inside the object means SSO is active; data outside it means the string went to the heap.
sso_probe.cpp β€” find your machine's SSO threshold
#include <cstdio>
#include <string>

int main() {
    printf("sizeof(std::string) = %zu bytes\n", sizeof(std::string));

    for (size_t n = 1; n <= 40; n++) {
        std::string s(n, 'x');
        // Du lieu con nam TRONG doi tuong s hay da nhay ra Heap?
        const void* data = s.data();
        bool onHeap = data < (const void*)&s || data >= (const void*)(&s + 1);
        if (onHeap) {
            printf("SSO: chuoi <= %zu ky tu nam trong object; tu %zu tro len moi cap phat Heap\n", n - 1, n);
            break;
        }
    }
    return 0;
}
What it actually prints β€” macOS, clang 21, libc++ 210106
sizeof(std::string) = 24 bytes
SSO: chuoi <= 22 ky tu nam trong object; tu 23 tro len moi cap phat Heap

22, not 15 β€” because this machine uses libc++. That number also shows libc++ fitting 22 characters plus a terminator into the object's 24 bytes, by sharing space with the fields the heap mode needs. Run the same program on Linux with GCC and you will see 15.

sso_demo.cpp
std::string s1 = "Hello";        // 5 chars -> SSO: stored inside the object
std::string s2 = "js-tools.org";  // 12 chars -> SSO: stored inside the object
std::string s3 = "Chao mung ban den voi series hoc C++"; // 37 chars -> too long for SSO: allocated on the heap

2. std::vector and the pointers underneath it

A std::vector is more than a growable array: at the memory level it is a structure holding exactly 3 pointers. Their names differ per library β€” below are libstdc++'s (GCC); libc++ calls them __begin_, __end_, __end_cap_ β€” but their roles are identical:

  • _M_start: the start of the buffer on the heap.
  • _M_finish: one past the last valid element (this is what size() reflects).
  • _M_end_of_storage: the end of the whole allocated buffer (this is what capacity() reflects).
std::vector in RAM:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Stack (3 pointers) β”‚          β”‚ Elem 0  β”‚ Elem 1  β”‚ Elem 2  β”‚ (empty) β”‚ (empty) β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ _M_start           β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€^ (start of the heap buffer)
β”‚ _M_finish          β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€^ (size = 3)
β”‚ _M_end_of_storage  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€^ (capacity = 5)
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Growing the capacity, and pointer invalidation

When you push_back() into a vector that is full (size == capacity):

  • The vector allocates a completely new region on the heap, typically twice the old capacity (Γ—2 on GCC/Clang, Γ—1.5 on MSVC).
  • It copies β€” or moves β€” every existing element into that new region.
  • It frees the old region.

Pointer invalidation: because the old region is gone, every pointer, reference and iterator that pointed into the vector becomes a dangling pointer at that instant. Using one is undefined behaviour, and it is miserable to debug.

reserve() as the fix

To stop the vector reallocating over and over β€” and invalidating pointers each time β€” use reserve(n). It asks for a buffer big enough for n elements up front:

vector_reserve.cpp
std::vector<int> vec;
vec.reserve(1000); // Allocate room for 1000 elements up front
// From here the next 1000 push_back calls reallocate ZERO times, so no
// pointer or iterator into the vector is ever invalidated.

What reserve() is worth β€” measured

Do not trust numbers someone hands you, this article's included. The program below counts how many times the vector reallocated, how much memory it asked for in total, and how long it took β€” run it on your machine and compare:

bench_reserve.cpp
#include <chrono>
#include <cstdio>
#include <vector>

int main() {
    const int N = 10000;

    {   // --- khong reserve
        std::vector<int> v;
        size_t reallocs = 0, totalBytes = 0, cap = 0;
        auto t0 = std::chrono::steady_clock::now();
        for (int i = 0; i < N; i++) {
            v.push_back(i);
            // capacity doi = vector vua cap phat lai va copy toan bo phan tu cu
            if (v.capacity() != cap) { cap = v.capacity(); reallocs++; totalBytes += cap * sizeof(int); }
        }
        auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
        printf("KHONG reserve: %.3f ms | reallocate %zu lan | tong cap phat %.1f KB\n",
               ms, reallocs, totalBytes / 1024.0);
    }

    {   // --- co reserve
        std::vector<int> v;
        v.reserve(N);
        size_t reallocs = 0, cap = v.capacity();
        auto t0 = std::chrono::steady_clock::now();
        for (int i = 0; i < N; i++) { v.push_back(i); if (v.capacity() != cap) { cap = v.capacity(); reallocs++; } }
        auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
        printf("CO   reserve: %.3f ms | reallocate %zu lan | tong cap phat %.1f KB\n",
               ms, reallocs, N * sizeof(int) / 1024.0);
    }
    return 0;
}
What it actually prints β€” Apple M-series, clang 21, libc++, -O2
$ g++ -std=c++17 -O2 bench_reserve.cpp -o bench && ./bench
KHONG reserve: 0.047 ms | reallocate 15 lan | tong cap phat 128.0 KB
CO   reserve: 0.012 ms | reallocate 0 lan | tong cap phat 39.1 KB

Three numbers, each read at its proper weight:

  • 15 reallocations down to 0. This is the one that matters most, because every reallocation turns every pointer and iterator into the vector into a dangling one.
  • 128 KB down to 39.1 KB. Without reserve the vector walks through capacities 1, 2, 4, 8 … 16384, asking for memory at each step β€” more than three times what it actually needed.
  • 0.047 ms down to 0.012 ms β€” around 4Γ— faster, but both are thousandths of a second. For 10,000 integers, speed is not why you use reserve(); the two points above are. The timing only becomes significant when the elements are heavy objects, so that each reallocation drags 10,000 copies or moves with it.
πŸ“Œ Your numbers will differ, and that is fine
Timing depends on the machine, the optimisation level and the standard library. The reallocation count depends on the implementation's growth factor: libstdc++ and libc++ double (Γ—2), MSVC uses 1.5. What does not change between machines is the shape: without reserve the number of reallocations grows with log(N); with reserve it is zero.

3. The std::string methods worth knowing

Beyond concatenation, std::string gives you plenty for searching, extracting substrings, replacing and comparing. These are the ones you will reach for daily:

Method What it does Example
append() Add to the end str.append(".cpp")
find() Find a substring's position str.find("tools")
substr() Extract a substring str.substr(0, 3)
replace() Replace a substring str.replace(0, 2, "JS")
compare() Compare two strings str.compare(other)
length() Length of the string str.length()
empty() Is it empty if (str.empty())
clear() Remove all characters str.clear()
at() Access a character (bounds checked) char c = str.at(0)
operator[] Access a character (unchecked, fast) char c = str[0]
rfind() Search backwards from the end str.rfind("tools")
c_str() Get a C-style string const char* p = str.c_str()

Those methods in practice

string_methods.cpp
#include <iostream>
#include <string>

int main() {
    std::string url = "js-tools.org";

    // append: add to the end
    url.append("/blog");
    std::cout << "URL: " << url << std::endl;  // js-tools.org/blog

    // find: locate a substring
    size_t pos = url.find("tools");
    std::cout << "Position of 'tools': " << pos << std::endl;  // 3

    // substr: extract a piece
    std::string domain = url.substr(0, 8);  // "js-tools"
    std::cout << "Domain: " << domain << std::endl;

    // replace: swap a piece out
    url.replace(0, 2, "JS");
    std::cout << "Sau replace: " << url << std::endl;  // JS-tools.org/blog

    // compare: compare two strings
    if (url.compare("JS-tools.org/blog") == 0) {
        std::cout << "URL matches!" << std::endl;
    }

    return 0;
}

4. Vector operations and ways to reach an element

Beyond push_back() and size(), a vector gives you plenty for accessing, inserting, erasing and traversing. Two distinctions matter: at() (checked but slower) versus operator[] (fast but unforgiving), and iterators.

Reaching an element: at() vs operator[]

at() does bounds checking and throws if the index is out of range; operator[] does not check β€” faster, and undefined behaviour when you are wrong:

vector_access.cpp
std::vector<int> nums = {10, 20, 30};

// operator[] - no bounds check (fast)
int x = nums[0];  // OK
// int y = nums[10];  // Undefined behaviour: may crash, may return garbage

// at() - bounds checked (safe)
try {
    int z = nums.at(0);    // OK
    int w = nums.at(10);   // Throws std::out_of_range
} catch (const std::out_of_range& e) {
    std::cout << "Error: " << e.what() << std::endl;
}

Iterators: safer than raw pointers

Iterators are the modern way to walk a vector instead of using indices. They insulate you from the low-level memory details and work the same way across every STL container:

vector_iterators.cpp
std::vector<int> nums = {100, 200, 300, 400};

// Walking with an iterator
for (auto it = nums.begin(); it != nums.end(); ++it) {
    std::cout << *it << " ";  // 100 200 300 400
}

// Range-based for loop (C++11, the simplest form)
for (int num : nums) {
    std::cout << num << " ";
}

// Walking backwards
for (auto it = nums.rbegin(); it != nums.rend(); ++it) {
    std::cout << *it << " ";  // 400 300 200 100
}

Inserting & erasing: insert() vs erase()

Careful here: insert() and erase() are O(n), because every element after the change has to shift:

vector_insert_erase.cpp
std::vector<int> data = {10, 20, 30, 40};

// insert: O(n), because every element after it has to shift
data.insert(data.begin() + 2, 25);  // {10, 20, 25, 30, 40}

// erase: remove at a position
data.erase(data.begin() + 1);       // {10, 25, 30, 40}

// erase a range, from begin+1 to end-1
data.erase(data.begin() + 1, data.end() - 1);  // {10, 40}

// pop_back: remove the last element, O(1)
data.pop_back();  // {10}

5. Move semantics: moving instead of copying

One of C++11's most important additions is move semantics. Rather than copying all of an object's data into another object β€” costly in both memory and time β€” we can transfer ownership of the data from the old object to the new one. It matters most for large containers like string and vector.

Lvalue vs rvalue

An lvalue is a variable with a stable memory address (the x in int x = 5;). An rvalue is a temporary about to be destroyed (the result of a function call, or of an expression).

lvalue_rvalue.cpp
std::string createMessage() {
    return "Hello from move semantics";  // An rvalue: a temporary about to die
}

int main() {
    std::string msg1 = "Lvalue";  // An lvalue: it has a stable address

    // COPY - slow: makes an independent duplicate
    std::string msg2 = msg1;  // Calls the copy constructor

    // MOVE - fast: takes ownership of the temporary's buffer
    std::string msg3 = createMessage();  // Calls the move constructor, no copy

    // std::move forces an lvalue to be treated as movable
    std::string msg4 = std::move(msg1);  // msg1 is now empty; msg4 took the buffer

    return 0;
}

Return value optimisation (RVO)

Modern compilers optimise returning large objects by never creating the intermediate copy at all β€” known as RVO, or NRVO for a named variable. It happens automatically, with nothing required from you:

rvo_demo.cpp
// Returning a large vector by value
std::vector<int> createLargeVector() {
    std::vector<int> result(1000000);
    for (int i = 0; i < result.size(); ++i) {
        result[i] = i * 2;
    }
    return result;  // RVO: the compiler builds it in place, no copy
}

int main() {
    // Thanks to RVO no copy happens here at all
    // The vector is constructed directly into `data`
    std::vector<int> data = createLargeVector();

    std::cout << "Vector size: " << data.size() << std::endl;

    return 0;
}

Moving a vector or a string

A vector is three pointers internally. Moving one does not copy the data (O(n)) β€” it just hands those three pointers over (O(1)):

move_vector.cpp
std::vector<int> vec1(10000);
// Fill vec1 with data...

// Copy - O(n):
std::vector<int> vec2 = vec1;  // Copies all 10,000 elements

// Move - O(1): only the 3 pointers change hands
std::vector<int> vec3 = std::move(vec1);
// vec1 is now empty; vec3 owns the original buffer

6. Putting it together: vector_string.cpp

The program below combines everything from this lesson: string methods, vector operations, iterators and move semantics:

vector_string.cpp
#include <iostream>
#include <string>
#include <vector>

int main() {
    // ===== std::string =====
    std::string siteName = "js-tools.org";
    std::string message = "Hoc C++ hien dai tai " + siteName;

    message.append(" - series hoan toan mien phi!");
    std::cout << message << std::endl;
    std::cout << "Length: " << message.length() << std::endl;

    // ===== std::vector with reserve() =====
    std::vector<std::string> tools;
    tools.reserve(5);  // Allocate up front: no reallocation, no invalidated pointers

    tools.push_back("Image Optimizer");
    tools.push_back("SnapCast");
    tools.push_back("ColorQuarium");

    std::cout << "\nTool list:" << std::endl;
    for (const auto& tool : tools) {
        std::cout << "- " << tool << std::endl;
    }

    // ===== insert() =====
    tools.insert(tools.begin() + 1, "QR Generator");

    std::cout << "\nAfter insert (size/capacity): "
              << tools.size() << "/" << tools.capacity() << std::endl;

    // ===== Move semantics =====
    std::vector<std::string> tools2 = std::move(tools);
    std::cout << "tools  size after move: " << tools.size() << std::endl;
    std::cout << "tools2 size after move: " << tools2.size() << std::endl;

    return 0;
}
What it actually prints
Hoc C++ hien dai tai js-tools.org - series hoan toan mien phi!
Length: 62

Tool list:
- Image Optimizer
- SnapCast
- ColorQuarium

After insert (size/capacity): 4/5
tools  size after move: 0
tools2 size after move: 4

The last two lines are the ones to look at closely: after std::move, tools holds 0 elements and tools2 holds 4. Not one element was copied β€” the three pointers inside tools simply moved to tools2, and tools was left in a valid empty state. That is the whole meaning of "move instead of copy" from section 5.

πŸ“ Check your understanding β€” Lesson 3
You call push_back() on a vector that is already full (capacity == size). What happens?

Download the lesson's sample source

You can download the complete sample C++ file for this lesson and practise with it directly on your own machine.

Download vector_string.cpp