Most C++ developers use "std::string" every day. We concatenate strings, pass them to functions, compare them, and print them without giving much thought to what's happening internally.
But std::string is far more than just an array of characters. It's a class that balances performance, memory usage, and ease of use.
Let's look under the abstraction and see what happens under the hood.
What is std::string?
std::string is simply an alias for:
std::basic_string<char>The C++ Standard Library defines a template called std::basic_string, and std::string specializes it for ordinary char characters.
Other common variants include:
std::wstring
std::u8string
std::u16string
std::u32stringEach stores a different character type, but internally they all follow the same general design.
Internal Representation
Although the C++ Standard doesn't dictate the exact layout, most implementations contain something similar to:
// Just a simplified example
class string {
char* data_;
size_t size_;
size_t capacity_;
};Where:
data_points to the character buffer.size_stores the number of characters.capacity_stores how much memory has been allocated.
Size vs Capacity
One of the most misunderstood concepts is the difference between size and capacity.
std::string s = "Hello";
size() // = 5
capacity() // = maybe 15The string contains only five characters.
However, extra memory is already allocated to avoid reallocating every time another character is appended.
When you do:
s += " World";No allocation happens if the new text still fits inside the existing capacity.
Dynamic Memory
Eventually, the string runs out of space.
For example:
std::string s = "Hello";
for (int i = 0; i < 100; i++)
s += '!';Eventually:
capacity = 15, but it needs 16 characters. It allocates a larger buffer, copies the existing data, destroy the old buffer and then continue writing.
Growing gradually instead of allocating every append keeps complexity close to O(n) instead of O(n²).
Most implementations (not all) double the capacity when growing.
Example:
15
30
60
120
240
The exact way it grows depends on the library implementation.
Small String Optimization (SSO)
One of the coolest optimizations is Small String Optimization.
Very small strings usually don't allocate memory at all.
Instead, the characters are stored directly inside the object itself.
Conceptually:
std::string
+-----------------------------------+
| inline buffer (15 chars) |
| size |
+-----------------------------------+
Instead of std::string being a pointer to a heap allocation
So this:
std::string s = "Hello";often performs zero heap allocations.
This makes small strings incredibly fast.
Different standard libraries use different inline sizes:
- 15 characters
- 23 charachters
- Or other implementation specific
The C++ Standard does not require SSO, but nearly every modern implementation uses it.
Why Store Capacity?
Suppose capacity didn't exist.
Appending would look like:
A
↓
AB
↓
ABC
↓
ABCD
↓
ABCDE
Every append would require:
- Allocate new memory
- Copy everything
- Free old memory
That would be extremely slow.
Capacity prevents this by allocating additional room ahead of time.
Why a Null Terminator?
Even though std::string knows its length, C libraries expect null-terminated strings.
For example:
printf("%s\n", s.c_str());The c_str() function returns a pointer ending in \0'.
Without that terminator, many C APIs wouldn't work correctly.
Copying a String
std::string a = "Hello";
std::string b = a;This performs a deep copy, each string own its memory
Modern C++ abandoned Copy-On-Write because it caused thread-safety and performance issues.
Move Semantics
One of C++11's biggest improvements was move semantics.
std::string make()
{
return "Hello";
}
std::string s = make();Instead of copying every character, the internal buffer can simply be transferred.
Conceptually:
Before:
Temporary -> Heap -> Copy -> New Heap
After moving:
Temporary -> Heap -> Ownership transferred -> No copy
Moving a large string is often constant time.
Reserving Memory
If you know approximately how large a string will become, you can reserve memory in advance.
std::string text;
text.reserve(1000);Now many appends happen without repeated reallocations.
This is especially useful when reading files or generating large outputs.
Common Mistakes
Assuming capacity equals size
std::string s = "Hello";
size() != capacity()
Capacity is often larger
Holding onto pointers
const char* ptr = s.c_str();
s += " World";The append may reallocate memory, making ptr invalid.
Always reacquire the pointer after modifying the string.
Forgetting to reserve
Building very large strings without reserve() can trigger many unnecessary reallocations.
Is std::string Contiguous?
Yes.
Since C++11, the characters are guaranteed to be stored contiguously.
This means:
char* p = s.data();is safe for direct access to the underlying character array
Final Thoughts
std::string may seem simple, but it's cleverly engineered:
- Automatic memory management
- Contiguous storage
- Small String Optimization (SSO)
- Capacity management to reduce reallocations
- Move semantics for efficient transfers
- Compatibility with legacy C APIs through null termination
Most of these optimizations are invisible to the programmer, which is exactly the point. You get an easy-to-use, high-performance string type without worrying about manual memory management.
Understanding how std::string works internally can help you write more efficient code, avoid common mistakes, and appreciate the sophisticated design decisions behind one of the most frequently used classes in modern C++.
Thanks for stopping by.