Module 3 · string: Text That Knows Its Own Length
When to Use string and When Not To
In this lesson
- Choose between
std::string, a char array,const char*,string_viewandvector<char>by asking four questions about your text. - Pass text to a function by
const&or bystring_view, and explain from a measurement why a by-value parameter in a loop is slow. - Use a
string_viewsafely: say what it holds, and spot a view that outlives the text it looks at.
Kenji wrote a helper that finds where a word ends, and called it once for every word of a 60,000-character text. The answers were right, and the program crawled. He started rewriting the loop, as he does. Amara read the helper's first line, size_t word_end(const string text, size_t start), and added one character: &. The program became well over a hundred times faster. This lesson is about that one character, and about the other times a string is not the tool.
Four questions about your text
A string is the right default for text, but not for every job. Four questions settle almost every case.
| Question | Answer that points to string | Answer that points elsewhere |
|---|---|---|
| 1. One character, or many? | many: a name, a line, a file's text | one: a grade, a separator, a key pressed: char |
| 2. Text people read, or raw bytes? | text | bytes of an image, a sound or a network packet: vector<unsigned char> |
| 3. Who owns the characters? | your program keeps them | someone else, and you only read them: const string& or string_view; a fixed literal in the code: constexpr string_view |
| 4. Does it change, and how? | it is read, built or edited | built from very many pieces: still a string, with reserve and +=, or an ostringstream |
To own characters means to hold them in memory you are responsible for, and to free that memory when you are done. A string owns its characters. A view, which this lesson meets below, only looks at characters that something else owns. Kenji's answers: many characters, text, and owned by the caller, because his helper only reads them. Question 3 sends him away from a by-value string parameter. So the questions name the type before you write the line.
The comparison chart
Here are the five types side by side. A char array is C's char name[20]; a const char* is a pointer to the first character of text that ends with '\0', like a string literal. The cells follow each type's page on cppreference. n is the number of characters.
Read the string column top to bottom: it owns, knows its length, grows, and still hands C a '\0'-ended text through c_str(). Its one price is the copy, O(n). The two columns that copy in O(1), const char* and string_view, pay with the last row. They hold no characters of their own, so when the owner goes, they point at nothing. A vector<char> owns and grows like a string but promises no '\0' and offers no text functions such as find. So the cheap copies are the unsafe ones, and the safe ones copy.
The decision flowchart
The same questions as a flowchart, in the order to ask them. Start at the top and follow your answers.
Kenji's walk: many characters, text, and owned by the caller, so the third box sends him right, to const string& or string_view. The last line is the honest one about letters. Lesson 01 showed that Maria's name in Bangla has size 21 for 7 code points. And one letter on screen can be more than one code point. Counting what a reader sees as letters needs Unicode rules. ICU (International Components for Unicode) is the library that holds them; Chromium and Android both ship it. This track does not teach it; it is the name to search for when you need it.
Passing text to a function: by value, const& or string_view
How a function takes its text says what it may do with it, and what each call costs.
| Parameter | The function may | Cost of each call |
|---|---|---|
string s or const string s | change its own copy; the caller sees nothing | O(n): every character is copied |
const string& s | read the caller's string | O(1): a second name, no copy |
string_view s (C++17) | read any text: a string, a literal, a char array, or a piece of one | O(1): a pointer and a length are copied |
string& s | change the caller's string | O(1) |
Here is Kenji's helper written three ways, each called once per word of the same text. word_end returns the position of the next space after start, or the text's size when there is none. The program times each loop with chrono::steady_clock, a clock that only moves forward.
#include <chrono>
#include <iostream>
#include <string>
#include <string_view>
using namespace std;
size_t word_end_by_value(const string text, size_t start) {
size_t space = text.find(' ', start);
return space == string::npos ? text.size() : space;
}
size_t word_end_by_ref(const string& text, size_t start) {
size_t space = text.find(' ', start);
return space == string::npos ? text.size() : space;
}
size_t word_end_by_view(string_view text, size_t start) {
size_t space = text.find(' ', start);
return space == string_view::npos ? text.size() : space;
}
int main() {
string text;
for (int k = 0; k < 3000; k++) {
text += "Bob fixes bugs fast ";
}
auto t0 = chrono::steady_clock::now();
size_t a = 0;
for (size_t i = 0; i < text.size();) {
size_t end = word_end_by_value(text, i);
a += end - i;
i = end + 1;
}
auto t1 = chrono::steady_clock::now();
size_t b = 0;
for (size_t i = 0; i < text.size();) {
size_t end = word_end_by_ref(text, i);
b += end - i;
i = end + 1;
}
auto t2 = chrono::steady_clock::now();
size_t c = 0;
for (size_t i = 0; i < text.size();) {
size_t end = word_end_by_view(text, i);
c += end - i;
i = end + 1;
}
auto t3 = chrono::steady_clock::now();
chrono::duration<double, milli> va = t1 - t0, vb = t2 - t1, vc = t3 - t2;
cout << text.size() << " characters, 12000 calls each\n";
cout << "by value: " << a << " letters, " << va.count() << " ms\n";
cout << "by const&: " << b << " letters, " << vb.count() << " ms\n";
cout << "by string_view: " << c << " letters, " << vc.count() << " ms\n";
return 0;
}
60000 characters, 12000 calls each
by value: 48000 letters, 16.6773 ms
by const&: 48000 letters, 0.087271 ms
by string_view: 48000 letters, 0.09158 ms
One run on Compiler Explorer, GCC 12 at -O2 -std=c++17, the Playground's flags. In eleven runs of the same program, by value took 15.8 to 32.6 ms and const& 0.07 to 0.11 ms, so by value was between 152 and 405 times slower in each run. string_view matched const& in ten runs and took 0.94 ms in one. Every by-value call copied all 60,000 characters into a new buffer and freed it again: 12,000 calls, about 720 million bytes. A run that counted allocations saw 12,000 for the 12,000 calls, so the compiler skipped none of the copies.
The const in const string text did not help, because it only stops the function from changing its own copy. The copy is made anyway. So a read-only parameter is const string& or string_view, and here the two cost the same.
string_view: a pointer and a length
A string_view, from <string_view> in C++17, is a view: it holds a pointer to someone else's characters and how many of them to look at. Making one costs O(1), copying one costs O(1), and so does its substr, which only moves the pointer and changes the length. It has most of a string's reading functions, size, [], find and substr among them, and nothing that changes the characters.
#include <iostream>
#include <string>
#include <string_view>
using namespace std;
int main() {
string text = "Maria asks why, every time";
string_view all = text;
string_view who = all.substr(0, 5);
string_view what = all.substr(6, 8);
cout << "sizeof(string_view): " << sizeof(string_view) << " bytes\n";
cout << "who: [" << who << "] starts " << who.data() - text.data() << " bytes into text\n";
cout << "what: [" << what << "] starts " << what.data() - text.data() << " bytes into text\n";
text[0] = 'm';
cout << "after text[0] = 'm', who is [" << who << "]\n";
return 0;
}
sizeof(string_view): 16 bytes
who: [Maria] starts 0 bytes into text
what: [asks why] starts 6 bytes into text
after text[0] = 'm', who is [maria]
On GCC 12 for a 64-bit machine a view is 16 bytes, an 8-byte pointer and an 8-byte length, however long the text. data() gives the view's pointer, and both views point inside text, at 0 and at 6. When text changed, who showed the change, because it never had characters of its own. So a view is a window onto the text, not a copy of it.
The trap: a view that outlives its text
A window is only useful while the house is standing. If the characters a view points at are destroyed, the view still has its pointer and its length, and nothing tells it. Using it is undefined behaviour. The easiest way to get there is a temporary: a value with no name, destroyed at the end of the statement that made it. string::substr returns a new string, so it makes one.
#include <iostream>
#include <string>
#include <string_view>
using namespace std;
int main() {
string record = "Amara Okafor, room 12, the north building";
string_view place = record.substr(14, 27);
cout << "place: [" << place << "]\n";
return 0;
}
GCC 12 compiled it with no message, at the Playground's flags and with -Wall -Wextra too. One run on Compiler Explorer printed place: [, then 16 bytes of unreadable garbage, then th building]. The 27-character temporary was freed at the semicolon, and the memory allocator had already written over its first 16 bytes. Another run printed different garbage. The fix is to keep the characters: string place = record.substr(14, 27);, or view the original with string_view(record).substr(14, 27). So a view is safe exactly as long as its owner lives, and the compiler will not check that for you.
In C++20
string_view gained starts_with and ends_with, the same pair lesson 02 showed on string. A view helps most when you parse: test a prefix, then cut it off with remove_prefix, and no character is ever copied.
#include <iostream>
#include <string_view>
using namespace std;
int main() {
string_view files[] = {"test_parser.cpp", "main.cpp", "test_report.cpp", "notes.txt"};
for (string_view name : files) {
if (name.starts_with("test_") && name.ends_with(".cpp")) {
name.remove_prefix(5);
name.remove_suffix(4);
cout << "test: " << name << '\n';
} else {
cout << "skip: " << name << '\n';
}
}
return 0;
}
test: parser
skip: main.cpp
test: report
skip: notes.txt
remove_prefix(5) moves the view's start 5 characters on; remove_suffix(4) shortens it by 4. The literals live for the whole program, so these views never dangle. The Run button opens it at C++20.
Amara's count_words takes a string_view, so a literal, a string, a C char array and a piece of a string all go in without a copy.
#include <iostream>
#include <string>
#include <string_view>
using namespace std;
int count_words(string_view text) {
int words = 0;
bool in_word = false;
for (char c : text) {
if (c == ' ') {
in_word = false;
} else if (!in_word) {
in_word = true;
words++;
}
}
return words;
}
int main() {
string line = "Amara writes the cleanest code";
char old_style[] = "Bob rushes";
cout << count_words("a literal works too") << '\n';
cout << count_words(line) << '\n';
cout << count_words(old_style) << '\n';
cout << count_words(string_view(line).substr(6, 10)) << '\n';
return 0;
}
4
5
2
2
The last call counts "writes the", ten characters of line starting at 6. With a const string& parameter, the literal and the char array would each be copied into a temporary string first.
Every PNG image file starts with the same eight bytes. They are numbers from 0 to 255, not letters, so they go in a vector<unsigned char>. A C string would also stop at the first zero byte.
#include <cstring>
#include <iostream>
#include <vector>
using namespace std;
int main() {
// The first eight bytes of every PNG image file.
vector<unsigned char> header{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A};
cout << "bytes:";
for (unsigned char b : header) {
cout << ' ' << (int)b;
}
cout << "\nsize(): " << header.size() << '\n';
char packet[] = {'H', 'I', 0, 'Z', 0};
cout << "a packet of " << sizeof(packet) << " bytes, strlen says " << strlen(packet) << '\n';
return 0;
}
bytes: 137 80 78 71 13 10 26 10
size(): 8
a packet of 5 bytes, strlen says 2
The (int) cast prints each byte as a number; without it cout would print 137 and 13 as odd symbols. strlen stopped at the zero byte in the middle, so 3 of the packet's 5 bytes were invisible to it.
David marks five papers. One grade is one character, so grade returns a char. Many grades together are text, so they are collected in a string with +=.
#include <iostream>
#include <string>
using namespace std;
char grade(int mark) {
if (mark >= 80) {
return 'A';
}
if (mark >= 65) {
return 'B';
}
if (mark >= 50) {
return 'C';
}
return 'F';
}
int main() {
int marks[] = {84, 55, 91, 47, 70};
string grades;
for (int m : marks) {
grades += grade(m);
}
cout << "grades: " << grades << " (" << grades.size() << " papers)\n";
return 0;
}
grades: ACAFB (5 papers)
A char is one byte with no heap block behind it. A string of one character would work, and would carry a length and a buffer for no reason.
Where this is used
- The C++ Core Guidelines. Rule SL.str.1 is "Use
std::stringto own character sequences", because a string handles allocation, ownership, copying and growth for you. Rule SL.str.2 is "Usestd::string_vieworgsl::span<char>to refer to character sequences", because a view reads text however it is stored. They are this lesson's question 3, as two rules. - Chromium. Google's browser had its own view type,
base::StringPiece, years before C++17. Oncestd::string_viewarrived,StringPiecebecame another name for it, and the code base has been replacing its uses withstd::string_viewitself.
Common mistakes
1. Printing a view's data().
string line = "Maria asks why, every time";
string_view who = string_view(line).substr(0, 5);
cout << who << '\n';
cout << who.data() << '\n';
No message at any command line. The first line printed Maria, the second Maria asks why, every time. data() is a plain const char*, and cout prints a const char* up to the next '\0', ignoring the view's length. Print the view itself, and when a C function needs a '\0'-ended text, make a string from the view and pass its c_str(). You will do it because data() worked on a string.
2. Comparing two C texts with ==.
char answer[] = "yes";
if (answer == "yes") {
cout << "same\n";
} else {
cout << "different\n";
}
The Playground printed different with no message. With -Wall, GCC 12 warns: warning: comparison with string literal results in unspecified behavior [-Waddress]. == on two arrays compares their addresses, never their characters. Make one side a string, or use strcmp(answer, "yes") == 0. You will write it because == on strings compares characters, and the two look the same.
3. Returning a view of a local string.
string_view room_label(int n) {
string label = "room number " + to_string(n) + ", north building";
return label;
}
GCC 12 said nothing, even with -Wall -Wextra. One run on Compiler Explorer of a caller that printed room_label(12) in brackets gave unreadable bytes, then north building]. label is destroyed when the function returns, and the view pointed into it. Return string by value; it is moved out, not copied. You will write the view because "views are cheap" sounds like advice for every return type.
Write string_view first_word(string_view line), which returns a view of the characters before the first space, or the whole line if it has none. It must make no copy.
Input. Lines until the end of the input. No line starts with a space.
Output. For each line, its first word on a line of its own.
Constraints. At most 1000 lines, each 1 to 100 printable ASCII characters.
Sample. Input Zara tests edge cases first, hello and Kenji optimises early gives Zara, hello and Kenji.
#include <iostream>
#include <string>
#include <string_view>
using namespace std;
// Return a view of the first word of line: the characters before the
// first space, or the whole line if it has no space. Make no copy.
string_view first_word(string_view line) {
return line;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string line;
while (getline(cin, line)) {
cout << first_word(line) << '\n';
}
return 0;
}
Not graded on its own. The view you return points into line, which lives until the next getline, so printing it at once is safe.
David describes some text in three words, and you answer with the flowchart's type. The words are: how many characters (one or many), what kind (text or bytes), and who owns them (literal, borrowed or owned).
Input. A line with q, then q lines of three words.
Output. For each line, one type: char if it is one character; else vector<unsigned char> for bytes; else string_view for a literal or borrowed text; else string.
Constraints. 1 <= q <= 100. The words are exactly as listed.
Sample. Input 4, one text owned, many bytes owned, many text borrowed and many text owned gives char, vector<unsigned char>, string_view and string.
#include <iostream>
#include <string>
using namespace std;
int main() {
int q;
cin >> q;
for (int i = 0; i < q; i++) {
string count, kind, owner;
cin >> count >> kind >> owner;
// Ask the flowchart's questions in its order, and print one type.
}
return 0;
}
Not graded on its own. The order is the point: one bytes literal is still char.
Kenji counts how often a short pattern appears in a long text, overlaps included. His helper gives the right count and is far too slow. Change only starts_at, never main, until a text of 200,000 characters runs well under a second.
Input. Line 1: the text. Line 2: the pattern.
Output. The number of positions where the pattern starts in the text.
Constraints. The text has 1 to 200000 characters, the pattern 1 to 10, printable ASCII.
Sample. Input abababa and aba gives 3: the pattern starts at 0, 2 and 4.
#include <iostream>
#include <string>
using namespace std;
// Kenji's helper: does pattern appear in text, starting at index i?
// It gives the right answer, and it is far too slow on a long text.
// Change only this function, never main.
bool starts_at(string text, string pattern, size_t i) {
return text.substr(i, pattern.size()) == pattern;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string text, pattern;
getline(cin, text);
getline(cin, pattern);
long long count = 0;
for (size_t i = 0; i + pattern.size() <= text.size(); i++) {
if (starts_at(text, pattern, i)) {
count++;
}
}
cout << count << '\n';
return 0;
}
Not graded on its own. Time it on the Playground before and after. There are two copies to find, and one hides inside the function's body.
Run in CompilerCommon doubts
Should every text parameter be a
string_viewnow?For a function that only reads, it is a fine default. If the function keeps the text after it returns, take a
string, because a view would dangle. If it hands the text to a C function, takeconst string&, because a view promises no'\0'.Is a char array ever the right choice?
Rarely, in C++ you write yourself. It fits when a C library wants a buffer of a fixed size to fill. Even then, copy the result into a
stringas soon as you have it.Why not take
const char*parameters, like C?It does not know its length, so every
sizeis astrlenwalk, and astringcaller has to writec_str(). Astring_viewparameter accepts both, and knows its length.Does returning a
stringfrom a function copy it?No. Since C++11 the result is moved, which hands over the buffer in
O(1), and the compiler often builds it in place.
Key takeaways
- Ask four questions: one character or many, text or bytes, who owns it, and how it changes.
- A
stringowns, knows its length and grows;const char*andstring_viewonly look at text that someone else keeps alive. - A read-only parameter is
const string&orstring_view; a by-valuestringcopies every character on every call. - A
string_viewis a pointer and a length:O(1)to make, copy andsubstr, and broken once its text is gone. - Bytes belong in
vector<unsigned char>, one character in achar, and counting letters in Bangla or emoji needs a library such as ICU. - Go deeper: Under the Hood, the small string buffer and the cost of + (Pro).
That is the end of this module's free lessons. Next come the problems page and the module test. For Pro readers, lesson 05 is "Under the Hood: The Small String Buffer, Growth and the Cost of +" (Pro). Lesson 06 is "CP and Interview Pack: string Patterns, Questions and the Bug Gallery" (Pro).
End of lesson 4
Mark it done, and your progress moves with you.
Next: Under the Hood: The Small String Buffer, Growth and the Cost of +