C++ Part I · Module 9 · Arrays, Strings, and Aggregate Data
std::string: Construction, Access and Modification
Chapter 9.4 ended by attributing both of its problems to one cause. A C string's length has to be rediscovered by scanning because it is not stored anywhere, and its terminator can be absent because nothing maintains it. Both follow from the text and its length not being kept together. This chapter is the type that keeps them together, and the consequences are almost boringly good: it knows its own size without looking, it maintains a terminator for you so that handing it to a C interface is safe, and it grows when you append rather than overflowing. What makes the chapter worth reading carefully is the word own. A std::string owns its characters, which means it is responsible for storage, which means two things you can measure. Its characters are not always where you would guess — a short string keeps them inside the object itself, and you can watch them move the moment it outgrows that — and copying one copies the text, with a real allocation. That second property is the debt Chapter 6.5 ran up when it argued for const references on the strength of a copy cost it had no type available to demonstrate.
Foundation17 min readC++Part IFundamentalsStrings
Part I · Module 9 · Chapter 9.5 · std::string: Construction, Access and Modification
1. The Type That Stores Both
Chapter 9.4 §1 established that a C string is a char array plus a convention. The convention exists because a bare address has to be enough, and everything that went wrong in that chapter followed from the length not being stored.
#include <string>
std::string s = "idle";A
std::stringis an object that owns a sequence of characters and knows how many there are.
Three consequences, immediately:
It knows its size without scanning. s.size() is a stored number, not a search. Chapter 9.4 §3's O(n) strlen becomes O(1).
It cannot lose its terminator, because it maintains one rather than relying on you — §7.
It grows. Appending to it is a normal operation rather than a buffer overflow waiting to happen.
The cost of all three is in the word owns, and §4 to §6 are that cost.
2. Construction and Access
#include <iostream>
#include <string>
int main()
{
std::string s = "idle";
std::cout << "s = \"" << s << "\" size = " << s.size()
<< " capacity = " << s.capacity() << '\n';
std::cout << "s[0] = " << s[0] << " s.back() = " << s.back() << '\n';
std::cout << "c_str() is null-terminated? "
<< std::boolalpha << (s.c_str()[s.size()] == '\0') << '\n';
std::cout << "sizeof(std::string) = " << sizeof(std::string) << " bytes (the object, not the text)\n";
return 0;
}Measured output:
s = "idle" size = 4 capacity = 22
s[0] = i s.back() = e
c_str() is null-terminated? true
sizeof(std::string) = 24 bytes (the object, not the text)Read the four lines.
size() is 4, which is Chapter 9.4's strlen answer rather than its sizeof answer. The terminator is maintained and is not counted — the size is the number of characters you put in.
s[0] and s.back() work and return chars. Indexing is unchecked, exactly as for an array — Chapter 9.1 §6's situation, unchanged. There is also a checked accessor, s.at(0), which throws if the index is out of range — measured: s.at(9) on a four-character string threw std::out_of_range. Exceptions are Module 22's subject, and the point here is only that a choice exists where an array offers none.
c_str() is null-terminated — true. §7.
sizeof(std::string) is 24 — and that is the size of the object, not of the text. A std::string holding a megabyte is still 24 bytes on the stack. What those 24 bytes contain is §4's subject, and it is more interesting than it sounds.
Capacity 22 for a 4-character string is the thing to notice and not yet explain. §4 explains it.
3. Modification
std::string s = "idle";
s += " state"; // append -- s is now "idle state"
s.push_back('!'); // one character
s.append("..."); // another string
s[0] = 'I'; // modify in place
s.clear(); // size 0None of these can overflow a buffer, because there is no fixed buffer — the string arranges storage as needed, which is §5.
Two things worth noting against Chapter 9.4:
+= is the whole of strncpy's job, done safely. Chapter 9.4 §6 measured strncpy leaving a buffer unterminated. Appending to a std::string cannot do that; there is no size you could get wrong.
Comparison works. s == "idle" compares the text, and < orders lexicographically. Chapter 9.4's C strings cannot do this — == on two const char* compares the two addresses. Measured, with two char arrays both holding "idle":
std::string: a == b : true
&a[0] == &b[0] : false (different objects)
const char*: p1 == p2 : false <- compares ADDRESSESTwo strings with identical text compared equal; two pointers to identical text compared unequal. That is one of the more common beginner bugs in C, and strcmp is the function that exists because of it.
4. Where the Characters Actually Live
§2 raised two puzzles: sizeof(std::string) is 24, and a 4-character string reported a capacity of 22. Both have the same answer.
The object's 24 bytes do not hold a megabyte of text, so for a long string they must hold a pointer to the characters plus the size and the capacity. But for a short string, 24 bytes is more than enough to hold the characters themselves — and implementations exploit that.
Here it is, measured, by appending ten characters at a time and printing where the characters are:
std::cout << "growth, and where the characters live:\n";
const void* last = s.data();
std::cout << " size " << s.size() << " capacity " << s.capacity()
<< " data at " << static_cast<const void*>(s.data()) << '\n';
for (int i = 0; i < 5; ++i) {
s += "0123456789";
const void* now = s.data();
std::cout << " size " << s.size() << " capacity " << s.capacity()
<< " data at " << now << (now == last ? "" : " <- MOVED") << '\n';
last = now;
}Measured output:
size 4 capacity 22 data at 0x16d43e880
size 14 capacity 22 data at 0x16d43e880
size 24 capacity 47 data at 0x102edd940 <- MOVED
size 34 capacity 47 data at 0x102edd940
size 44 capacity 47 data at 0x102edd940
size 54 capacity 95 data at 0x102edd970 <- MOVED againTwo separate findings in that output.
The characters start inside the object. At size 4 and size 14 they are at 0x16d43e880, which does not move when the string grows to 14. A second program makes the claim exact rather than inferred from address regions:
an automatic int at 0x16dbc6888
the string OBJECT at 0x16dbc6870
short string chars at 0x16dbc6870 capacity 22
long string chars at 0x10297df30 capacity 2007The short string's characters are at the object's own address — 0x16dbc6870 on both lines. Not merely in the same region; the same byte. And the object sits next to an ordinary automatic int, which is Chapter 7.3 §3's automatic-storage region. That is why capacity was 22: it is however much of the 24-byte object is available for characters.
The long string's characters are somewhere else entirely, with a capacity that tracks its size.
Then they move out. At size 24 the capacity jumps to 47 and the data address becomes 0x102edd940 — a different region entirely, which Chapter 7.3 §7 measured as where dynamically obtained storage went, and which matches the long string's 0x10297df30 above. The string has stopped keeping its characters inline and has obtained storage for them.
This is called the small-string optimization, and the honest framing matters: it is an implementation technique, not a standard guarantee. The standard says a std::string manages its characters; it does not say where. Capacity 22 is this library's number; another implementation will give a different one, or none at all. Chapter 1.3 §5's distinction applies — this is a measurement of one implementation, and the useful part is knowing the technique exists so that a 24-byte object holding text is not a contradiction.
5. Growth, and the Pattern You Have Seen Before
Look at the capacity column again: 22, 22, 47, 47, 47, 95.
It roughly doubles, and the data address changes exactly when it does. Between those two moments the address is stable.
That is Chapter 8.8 §5's measurement, for text instead of for a std::vector:
| Chapter 8.8 §5 | here | |
|---|---|---|
| capacity sequence | 4 → 8 → 16 | 22 → 47 → 95 |
| address moved at | 2 of 10 insertions | 2 of 5 appends |
| stable in between | 3 insertions | 3 appends |
So the same hazard applies. A pointer or reference into a std::string's characters — anything from s.data(), &s[0], or s.c_str() — is valid only until the next modification. Chapter 8.8 §9's rule holds unchanged: use an index if you need it across a change.
Growing by doubling rather than by one is what makes appending cheap on average: the number of reallocations grows with the logarithm of the final size rather than with the size. The precise strategy is an implementation choice; that it is geometric rather than linear is the part you can rely on in practice.
6. The Copy Cost — Chapter 6.5's Debt
Chapter 6.5 §7 argued for const& parameters partly on the grounds that copying costs something, and admitted it could not show that: "every type available at this point in the curriculum is a fundamental type… copying one of those costs nothing worth measuring." It named this chapter as where the demonstration would arrive.
Because a std::string owns its characters, copying one copies the text:
text size = 2000 data at 0x1012a5fb0
a copy's data at 0x1012a67b0 <- differentDifferent addresses. A 2000-character string copied to a new object produced characters at a different place, which means storage was obtained and 2000 bytes were copied. That is the deterministic evidence, and it is the same kind Chapter 6.2 §5 used for the 16 KB aggregate.
The timing, at -O2, 300,000 calls each:
| run | by value | by const& |
|---|---|---|
| 1 | 27 ms | 0 ms |
| 2 | 16 ms | 0 ms |
| 3 | 13 ms | 0 ms |
The const& column is below this timer's resolution, which is a millisecond. That is what the measurement says and it is all it says — it does not say the cost is zero, and quoting a ratio against it would be arithmetic on a number I do not have. The by-value figure settled at 13–16 ms after a 27 ms cold first run, the same artefact Chapter 6.2 §6 reported.
So the honest statement of the debt, paid:
Passing a
std::stringby value allocates and copies on every call. Passing it byconst&does not. Measured: 13–16 ms against something too small to register, for the same 300,000 calls.
Chapter 6.5 §6's conclusion stands and now has evidence: const& is chosen because it makes the true statement about what the function does, and avoiding this cost comes along with it.
One thing deliberately not explained here: how the copy happens. A std::string has a copy constructor, which is Chapter 13.2's subject, and there is a way to transfer the characters instead of duplicating them, which is Chapter 13.8's. This chapter measures the cost; Module 13 explains the machinery and how to avoid it when a copy is genuinely unnecessary.
7. Conversion to a C String
Chapter 9.4 §8 said every operating system and vendor interface takes a const char*. This is the boundary.
std::string path = "/tmp/trace.log";
std::FILE* f = std::fopen(path.c_str(), "r"); // c_str() for a C interfacec_str() returns a const char* that is null-terminated — measured true in §2. The std::string maintains that terminator specifically so this conversion is free and correct.
data() returns the same pointer and, since C++11, is also null-terminated. The two are interchangeable for reading; c_str() is the one to write when the purpose is a C interface, because it says so.
Three rules, and all three are Chapter 8.8's property applied to text:
The pointer is valid until the string is modified. §5's measurement is why. Calling c_str(), then appending, then using the saved pointer is a use-after-free.
The pointer is valid only while the string exists. some_c_function(make_path().c_str()) is safe — the temporary lives to the end of the full expression, Chapter 6.5 §5's rule — but saving that pointer is not.
Do not write through it. It is const, and Chapter 8.7 §6's note applies: the const is a promise about that access path, and the string is entitled to assume its characters are not changed behind it.
Going the other way is a constructor:
const char* from_c = get_name(); // a C interface hands you this
std::string owned = from_c; // copies the characters -- now you own themThat copy is the point. The const char* you were handed may be a static string, a buffer about to be reused, or something with a lifetime you do not control. Copying into a std::string at the boundary converts a lifetime question into a non-question, and Chapter 9.4's whole catalogue of problems stays on the other side of it.
8. What to Actually Do
Use std::string for text you own. It is the default; Chapter 9.4's arrays are for reading other people's interfaces.
Take const std::string& to read it — Chapter 6.5's rule, now with measured evidence.
Use size(), not strlen. It is stored, not scanned.
Append with += or append and stop thinking about buffer sizes.
Use == to compare text. It compares characters, unlike two const char*s.
Use c_str() at a C boundary, and do not save the pointer past the next modification.
Copy a const char* into a std::string at the boundary, to convert a lifetime question into none.
Treat any pointer into a string as valid until the next change — §5, and Chapter 8.8 §9's rule.
Do not reason about where the characters live. The small-string optimization is an implementation detail; capacity 22 is this library's number.
9. Mistakes Worth Making Once
Comparing C strings with ==. It compares addresses; std::string's == compares text.
Calling strlen on a std::string's c_str() to get its length. size() is stored.
Saving c_str() and then appending. Measured in §5: the address moves.
Expecting sizeof(std::string) to relate to the text's length. It is 24 for any length.
Being surprised by capacity 22 for four characters. The characters are inside the object — §4.
Treating the small-string optimization as guaranteed. It is this implementation's choice.
Passing std::string by value to read it. Measured: 13–16 ms against unmeasurable, for 300,000 calls.
Writing through c_str(). It is const, and the string assumes its characters are stable.
10. Interview Relevance
"Why std::string rather than char*?" The complete answer is ownership: it stores its length, maintains its terminator, resizes itself, and compares by value. Listing the four as consequences of one property is better than listing them as four features.
"How big is a std::string?" The object is a fixed small size — 24 bytes here — regardless of the text. If you can add the small-string optimization, and that capacity 22 for a short string is the evidence, you are describing something you have looked at.
"Is passing std::string by value expensive?" Yes, it allocates and copies. The measurement to have ready is the pair of addresses, which is deterministic, rather than a timing ratio.
"When does c_str() stop being valid?" On the next modification, and when the string is destroyed. This is the question that tells an interviewer whether you have met Chapter 8.8's bug class.
11. What You Should Be Able to Say Now
- A
std::stringowns its characters and knows how many there are — which removes all four of Chapter 9.4's problems at once. size()is stored, so it is O(1) wherestrlenis O(n).- Measured:
size4 for"idle", andc_str()is null-terminated —true. - Indexing is unchecked, as for an array;
at()is the checked alternative (exceptions are Module 22). ==compares the text, where==on twoconst char*compares addresses.- Measured:
sizeof(std::string)is 24 — the object, not the text. - Measured: a 4-character string reported capacity 22 with its characters at
0x16d4…, the automatic-storage region — inside the object. - Measured: crossing that capacity moved the characters to
0x102e…, a different region. That is the small-string optimization, an implementation detail and not a guarantee. - Measured: capacity went 22 → 47 → 95, with the address changing exactly at those two points and stable for three appends in between.
- So a pointer into a string is valid only until the next modification — Chapter 8.8 §5's measurement, for text.
- Chapter 6.5's debt, paid: a 2000-character copy produced characters at a different address, and 300,000 by-value calls took 13–16 ms against a
const&figure below the timer's resolution. - That is reported as "did not register", not as zero and not as a ratio.
c_str()gives a null-terminatedconst char*for a C interface, valid until the next modification and while the string lives.- Copy a
const char*into astd::stringat the boundary — it converts a lifetime question into none, and ownership is the question Chapter 10.9 is named after.
12. Exercises
Exercise 1 — two lengths, revisited. For std::string s = "reset"; give s.size(), what std::strlen(s.c_str()) returns, and which of the two is a stored value.
Exercise 2 — predict the output. For std::string s = "ab"; give s.size() and say what you expect of s.capacity() and of s.data()'s region, with reference to §4.
Exercise 3 — when is it stale? For each, say whether the saved pointer is still valid: (a) auto* p = s.c_str(); std::cout << p; (b) auto* p = s.c_str(); s += "x"; std::cout << p; (c) auto* p = s.c_str(); s[0] = 'z'; std::cout << p; (d) f(make_name().c_str()); (e) auto* p = make_name().c_str(); f(p);.
Exercise 4 — the four problems. Name Chapter 9.4's four problems and, for each, the single property of std::string that removes it.
Exercise 5 — read the measurement. §6 reports 13–16 ms by value and 0 ms by const&. State precisely what that establishes, what it does not, and why no ratio is given.
Exercise 6 — cross the boundary. You are handed const char* name by a C library and must store it for later use. Give the code, and say what problem the copy solves.
Exercise 7 — choose the parameter. For each, give the parameter type and the reason: (a) read some text (b) append to the caller's text (c) take ownership of text the caller is finished with (d) read text the caller only has as a const char*.
13. Summary
- A
std::stringowns its characters and stores its length — which is why Chapter 9.4's four problems all disappear together. - Measured:
size()stored and O(1);c_str()null-terminated;sizeof24 regardless of the text. - Measured: capacity 22 for four characters, with the characters inside the object at the automatic-storage address.
- Measured: crossing it moved them to the dynamic region — the small-string optimization, an implementation detail.
- Measured: capacity 22 → 47 → 95, the address moving exactly there — Chapter 8.8 §5 again, for text.
- So a pointer into a string is valid only until the next modification.
- Chapter 6.5's debt paid: different addresses after a copy, and 13–16 ms by value against a
const&figure that did not register. - No ratio is quoted, because the second number is below the timer's resolution.
c_str()at a C boundary; copy aconst char*in at the boundary.- Ownership is the recurring question — Chapter 10.9's title, Module 14's subject, Chapter 9.11's test.
The parameter rule this chapter confirmed has a gap in it, and the gap is the next chapter. Taking const std::string& says "I will read this text and not own it", which is almost always the true statement — but it still requires the caller to have a std::string. A caller holding a const char* from Chapter 9.4's world, or a literal, or a portion of a larger string, has to construct one first, and that construction is the allocation this chapter just measured. The next chapter is a type that is nothing but a pointer and a length travelling together: sixteen bytes, no allocation, no ownership, and exactly one rule you have to respect — which is Chapter 8.8's rule again, in the one place the compiler actually warns about it.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
