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::stringobject itself β usually on the stack. Not a single heap allocation happens. -
Past that threshold,
std::stringswitches to a heap allocation and keeps the address in an internal pointer.
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.
#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;
}
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.
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 whatsize()reflects). -
_M_end_of_storage: the end of the whole allocated buffer (this is whatcapacity()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:
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:
#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;
}
$ 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.
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
#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:
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:
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:
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).
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:
// 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)):
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:
#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;
}
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.
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
Comments