C++ / STANDARD CONTAINERS
C-style strings and why std::string replaced them
Explain what a C-style string really is, why its NUL-terminated convention leaks bugs, and how std::string replaces it while still talking to C APIs.
What you will learn
- Explain why sizeof gives 6 and strlen gives 5 for the C string "hello"
- Recognise that == on two char* compares addresses, and use strcmp or std::string
- Rewrite strcpy/strcat buffer juggling as std::string += with no size arithmetic
- Hand a std::string to a C API with c_str() and know when that pointer goes stale
Understanding C-style strings and why std::string replaced them
C has no string type. A C-style string is an array of char whose logical end is marked by a byte with value zero, and C++ inherits it in two shapes: string literals, whose type is const char[N] with the terminator counted in N, and arrays you declare yourself. The moment you pass one to a function it decays to a pointer to its first element and the bound vanishes from the type, which is why the length then has to be recovered by walking memory until the zero byte shows up.
Almost every rough edge follows from the length not being stored. strlen is a loop, so asking how long the text is costs time proportional to its size, and a loop that calls strcat repeatedly rescans everything it already wrote, turning concatenation into quadratic work. strcpy receives a destination pointer with no idea how much room sits behind it, so the caller computes strlen(src) + 1 by hand and is one arithmetic slip from writing outside the buffer. And since a char* is only an address, == compares addresses, not characters.
std::string keeps three things together: a pointer to the characters, the current size, and the allocated capacity, and it owns that buffer and frees it in its destructor. That one change makes size() constant time, makes appending amortised constant time with automatic growth, makes copying and returning value operations instead of pointer sharing, and makes == compare content. The old convention is still reachable rather than abolished: c_str() produces a NUL-terminated const char* on demand, so std::string is what you hold and the raw pointer is only what you pass across a boundary to legacy code.
<cstring>
<iostream>
<string>
<type_traits>
void inspect(const char text[10]) {
std::cout << "parameter type is const char*: " << std::boolalpha
<< std::is_same_v<decltype(text), const char*> << '\n';
}
int main() {
const char cstr[] = "hello"; // 5 characters plus a hidden '\0'
std::string str = "hello";
std::cout << "sizeof cstr = " << sizeof cstr << '\n';
std::cout << "strlen(cstr) = " << std::strlen(cstr) << '\n';
std::cout << "str.size() = " << str.size() << '\n';
inspect(cstr);
const char* lit = "abc";
char buf[] = "abc";
std::cout << "lit == buf: " << (lit == buf) << '\n';
std::cout << "strcmp(lit, buf) == 0: " << (std::strcmp(lit, buf) == 0) << '\n';
std::cout << "std::string(lit) == buf: " << (std::string(lit) == buf) << '\n';
}
A C-style string is a convention over raw bytes (find the '\0' yourself, own the memory yourself) rather than a type, and std::string replaced it by storing the length and owning the buffer.
Worked examples
strncpy does not promise a terminator
Truncating text with the C library leaves you responsible for the '\0', while substr does the bookkeeping.
<cstring>
<iostream>
<string>
int main() {
const char* src = "abcdefgh";
char dst[6];
std::strncpy(dst, src, sizeof dst); // writes 6 chars, no terminator
dst[sizeof dst - 1] = '\0'; // you must add it yourself
std::cout << "dst = " << dst << " (" << std::strlen(dst) << ")\n";
std::string s = src;
std::string cut = s.substr(0, 5); // length is tracked, not searched
std::cout << "cut = " << cut << " (" << cut.size() << ")\n";
}
Example explained
Line 1strncpy copies exactly sizeof dst bytes; because src is longer than 6, it writes no '\0' at all.
Line 2The manual dst[sizeof dst - 1] = '\0' is not tidiness: without it, printing dst reads past the array.
Line 3s.substr(0, 5) returns a new std::string that allocates and terminates its own storage, so no byte counting happens in your code.
Line 4Both lines produce the same five characters, but only one of them required you to reason about where the end is.
Copying a pointer is not copying text
Assigning a char* shares one buffer, while assigning a std::string duplicates the characters.
<iostream>
<string>
int main() {
char raw[6] = "cat";
char* alias = raw; // copies the address, not the characters
alias[0] = 'b';
std::cout << "raw = " << raw << '\n';
std::string owner = "cat";
std::string copy = owner; // copies the characters
copy[0] = 'b';
std::cout << "owner = " << owner << '\n';
std::cout << "copy = " << copy << '\n';
}
Example explained
Line 1char* alias = raw creates a second name for one array, so the write through alias is visible through raw.
Line 2std::string copy = owner performs a deep copy, so owner still reads "cat" after copy is modified.
Line 3This is why a function can safely return a std::string by value; returning raw would hand back the address of a dead local array.
A stored length can hold a zero byte
std::string can contain an embedded '\0' that a const char* interface cannot represent.
<cstring>
<iostream>
<string>
void legacy(const char* text) { // C interface: length is implied
std::cout << "legacy sees " << std::strlen(text) << " chars: " << text << '\n';
}
int main() {
std::string s = "hi";
s.push_back('\0');
s += "there";
std::cout << "s.size() = " << s.size() << '\n';
std::cout << "s[3] = " << s[3] << '\n';
legacy(s.c_str());
}
Example explained
Line 1s.size() is 8 because the size is a stored member and '\0' is just an ordinary byte inside the buffer.
Line 2s[3] is 't', the character after the embedded zero, reached by index arithmetic rather than by scanning.
Line 3c_str() points at those same bytes, so strlen stops at index 2 and the legacy function only ever sees "hi".
Line 4Any data that may contain a zero byte therefore cannot round-trip through a const char* API.
Important notes
A string literal has type const char[N] and typically lives in read-only memory; char* p = "hi"; is ill-formed since C++11, and writing through such a pointer is undefined behaviour.
c_str() is guaranteed to be NUL-terminated, but the returned pointer is invalidated by any later modification of the string, so pass it and forget it rather than storing it.
Common mistakes
Allocating strlen(src) bytes instead of strlen(src) + 1: strcpy then writes the terminator one byte past the end, corrupting whatever follows or aborting under a sanitizer.
Using sizeof on a char* function parameter to get the length; the parameter has already decayed to a pointer, so you get 8 on a 64-bit build no matter how long the text is, and the loop reads the wrong number of bytes.
Writing if (name == "admin") where name is a char*: that compares two addresses, is essentially always false, and the branch silently never runs.
Try it yourself
Change, predict, then run
Write std::string shout(const char* text) that returns the text uppercased, then print shout("hello") together with sizeof(text) printed from inside the function, and explain why that number is not 6.
Open the C++ workspaceCheck your understanding
A loop appends a short piece of text to a char buffer with strcat 1000 times. Why is the same loop written with std::string and += asymptotically faster?
- strcat must scan the destination from the beginning to find the '\0' on every call, so the total work is quadratic, while std::string already knows its size
- strcat copies the entire destination buffer on every call, while std::string only moves it
- std::string stores its characters in a hash table, which makes appending constant time
- strcat allocates a fresh buffer on every call, while std::string allocates only once
Show answer
The cost is the repeated search for the terminator: call n walks everything the previous n-1 calls wrote, so 1000 appends do about 500,000 byte reads, whereas std::string appends at a stored offset. Option 4 is tempting but backwards: strcat never allocates at all, it writes into memory you already supplied, which is exactly why it can overflow; std::string does reallocate occasionally, but geometric growth keeps each append amortised constant.