C++ / STANDARD CONTAINERS
std::string and the operations you reach for daily
Search, slice, and rebuild std::string values with find, substr, replace and append, and handle std::string::npos without getting bitten.
What you will learn
- Compare find/rfind results against std::string::npos, never against 0 or -1
- Slice with substr(pos, count), computing count as end - start
- Build strings with += or append; chained operator+ copies through temporaries
- Use at() for checked indexing; front()/back() on an empty string are undefined
Understanding std::string and the operations you reach for daily
std::string is a container of char that owns its bytes: it keeps track of a buffer, a length and a capacity, and copying a string copies the characters. Because the length is stored rather than discovered by scanning, size() is O(1) and an embedded '\0' is just another byte. Short values usually live inside the string object itself (the small-string optimisation), which is why a 12-character string typically costs no heap allocation while a 100-character one does.
Everything you search with returns a position, not an iterator: find, rfind, find_first_of and find_last_not_of all hand back a std::size_t index, and they signal failure by returning std::string::npos, which is the largest representable size_t. That one design choice causes most string bugs, because npos compares greater than every real index, so a test like if (s.find(c) > 0) is true even when nothing matched. Those positions then feed the slicing operations, and every one of them takes a (pos, count) pair: substr(4, 3) means three bytes starting at index four. When you only want a yes/no answer, C++20 adds starts_with, ends_with and contains so you can skip the npos comparison entirely.
Mutation happens in place: +=, append, insert, erase and replace modify the existing object, and reserve lets you allocate the room up front before a loop starts filling it. Chained operator+ is a different animal, since each + materialises a temporary string, so assembling a line from eight pieces with + moves far more bytes than eight += calls. Remember that all of these count bytes rather than characters: size() of the UTF-8 text 'naïve' is 6, and indexing into the middle of a multi-byte sequence hands you half a character.
<cstddef>
<iostream>
<string>
int main() {
std::string path = "/var/log/app.log";
std::size_t slash = path.rfind('/');
std::string file = path.substr(slash + 1);
std::size_t dot = file.rfind('.');
std::string stem = file.substr(0, dot);
std::string ext = file.substr(dot + 1);
std::cout << "file: " << file << '\n';
std::cout << "stem: " << stem << ", ext: " << ext << '\n';
std::cout << "size: " << file.size() << ", first: " << file.front()
<< ", last: " << file.back() << '\n';
std::string rotated = path;
rotated.replace(rotated.find(".log"), 4, ".1.log");
std::cout << "rotated: " << rotated << '\n';
if (path.find(".txt") == std::string::npos)
std::cout << "no .txt in path\n";
}Every std::string operation is built around byte positions, with std::string::npos as the only 'not found' marker, in a buffer the string itself owns.
Worked examples
Building and editing a string in place
Shows append, insert, erase, replace and resize mutating one buffer instead of producing new strings.
<cstddef>
<iostream>
<string>
int main() {
std::string s;
s.reserve(32);
for (int i = 1; i <= 3; ++i) {
s += "id";
s += std::to_string(i);
s += ',';
}
s.pop_back();
std::cout << s << " (size " << s.size()
<< ", capacity >= 32: " << (s.capacity() >= 32u) << ")\n";
s.insert(0, "keys=");
std::cout << s << '\n';
std::size_t comma = s.find(',');
s.erase(comma, 1);
std::cout << s << '\n';
s.replace(0, 4, "list");
s.resize(18, '.');
std::cout << s << '\n';
}Example explained
Line 1s += appends into the existing buffer, while s = s + "id" + ... would copy the whole current value on every iteration.
Line 2reserve(32) provides room before the loop, and appending never shrinks capacity, so the check prints 1.
Line 3insert(0, "keys=") shifts every existing byte to the right, which is why repeatedly prepending is O(size) each time.
Line 4resize(18, '.') pads with the fill character; resizing downwards just truncates and ignores the second argument.
Splitting on a delimiter with find and substr
Uses npos as the loop terminator and turns index pairs into fields, then parses one of them.
<cstddef>
<iostream>
<string>
<vector>
std::vector<std::string> split(const std::string& s, char sep) {
std::vector<std::string> out;
std::size_t start = 0;
for (;;) {
std::size_t hit = s.find(sep, start);
if (hit == std::string::npos) {
out.push_back(s.substr(start));
return out;
}
out.push_back(s.substr(start, hit - start));
start = hit + 1;
}
}
int main() {
std::string record = "42px,,7,city";
std::vector<std::string> fields = split(record, ',');
for (const std::string& f : fields)
std::cout << '[' << f << ']';
std::cout << '\n';
std::cout << "fields: " << fields.size() << '\n';
std::cout << "first as int + 1: " << std::stoi(fields[0]) + 1 << '\n';
}Example explained
Line 1find(sep, start) resumes the scan at start, so each pass only looks at the tail that has not been consumed.
Line 2hit - start is the field length, because substr wants a count and not an end index.
Line 3The empty field between the two commas survives as "" since substr(start, 0) returns an empty string rather than being skipped.
Line 4std::stoi("42px") returns 42: it converts the longest numeric prefix and throws std::invalid_argument only when there is no leading number.
Comparison, checked access, and copies
Demonstrates byte-wise ordering, the readable terminator at index size(), at() throwing, and value semantics.
<cctype>
<iostream>
<stdexcept>
<string>
int main() {
std::string a = "Zebra", b = "apple";
std::cout << (a < b) << ' ' << (a == "Zebra") << '\n';
std::string s = "abc";
std::cout << (s[s.size()] == '\0') << '\n';
try {
std::cout << s.at(3);
} catch (const std::out_of_range&) {
std::cout << "at(3) threw out_of_range\n";
}
std::string big = s;
for (char& c : big)
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
std::cout << s << " -> " << big << '\n';
}Example explained
Line 1a < b is 1 because comparison is byte-by-byte, and 'Z' (0x5A) sorts before 'a' (0x61); it is not alphabetical or case-insensitive.
Line 2s[s.size()] is defined since C++11 and yields the stored null terminator, which is why c_str() never has to copy anything.
Line 3s.at(3) validates the index and throws, so the pending << never inserts a character.
Line 4Uppercasing big leaves s alone because the copy owns its own bytes, and the unsigned char cast keeps std::toupper defined for byte values above 127.
Important notes
Since C++11 the buffer is contiguous and always null-terminated, so c_str() and data() cost nothing and s[s.size()] reads a valid '\0'; writing anything other than '\0' to that position is undefined.
front(), back() and pop_back() on an empty string are undefined, so guard them with empty() even though s[s.size()] itself is legal.
Common mistakes
Testing a search with >= 0 or != -1: npos is size_t(-1), so >= 0 is always true and the npos value flows onward, where substr(npos) throws std::out_of_range or substr(npos + 1) silently returns the entire string.
Reading substr's second argument as an end index: s.substr(2, 5) is five bytes starting at index 2, not indices 2 through 5, so the extracted text quietly includes extra characters.
Keeping a pointer from c_str() or data() and then appending to the string: the append can reallocate the buffer, and the saved pointer is left dangling for the C function that receives it.
Try it yourself
Change, predict, then run
Write a function that takes a std::string such as "timeout=30" and returns only the value after the '=', returning an empty string when there is no '='. Check it against "timeout=30", "flag=" and "broken".
Open the C++ workspaceCheck your understanding
A std::string p holds "report.txt" with no directory part. What does p.substr(p.rfind('/') + 1) evaluate to?
- An empty string, because substr clamps an out-of-range position to the end
- It throws std::out_of_range, because rfind returned a position past the end
- "report.txt", because rfind returns npos and npos + 1 wraps around to 0
- "eport.txt", because rfind returns 0 when the character is not found
Show answer
rfind reports failure with npos, the largest size_t, and unsigned arithmetic makes npos + 1 equal 0, so substr(0) copies the whole string and the idiom happens to give the answer you wanted. The throw option is tempting because substr does throw when pos > size(), but the wraparound puts pos at 0, well inside the string; the flip side is that a missing separator is never reported, so if you actually need to detect it you must compare against npos yourself.