Learn C++ STL

Lesson 4 of 9 · string: Text That Knows Its Own Length

Module 3 · string: Text That Knows Its Own Length

When to Use string and When Not To

FreeReading

In this lesson

  • Choose between std::string, a char array, const char*, string_view and vector<char> by asking four questions about your text.
  • Pass text to a function by const& or by string_view, and explain from a measurement why a by-value parameter in a loop is slow.
  • Use a string_view safely: 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.

QuestionAnswer that points to stringAnswer that points elsewhere
1. One character, or many?many: a name, a line, a file's textone: a grade, a separator, a key pressed: char
2. Text people read, or raw bytes?textbytes of an image, a sound or a network packet: vector<unsigned char>
3. Who owns the characters?your program keeps themsomeone 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 editedbuilt 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.

string against a char array, const char*, string_view and vector<char> Property string char[N] const char* string_view vector<char> owns its characters yes yes, a fixed N no no yes knows its length yes no, strlen no, strlen yes yes can grow yes no no no yes ends with '\0' yes, c_str() if you put one expected not promised no cost of a copy O(n) O(n), strcpy O(1) O(1) O(n) cost of size O(1) O(n) O(n) O(1) O(1) safe after its source is gone yes, own copy yes, own copy no no yes, own copy n is the number of characters. strlen walks the text to the '\0', one step per character.

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.

Which text type: the decision flowchart 1. One character, or many? one char many 2. Text people read, or raw bytes? bytes vector<unsigned char> text 3. Who owns the characters? a literal constexpr string_view someone else, read only const string& or string_view your program 4. Built from many pieces? no string yes string with reserve and +=, or an ostringstream Counted in letters, like Bangla or emoji? Keep a string, and count with a library such as ICU.

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.

ParameterThe function mayCost of each call
string s or const string schange its own copy; the caller sees nothingO(n): every character is copied
const string& sread the caller's stringO(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 oneO(1): a pointer and a length are copied
string& schange the caller's stringO(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.

Run in Compiler
Example 1: one function for every kind of text

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.

Run in Compiler
Example 2: bytes are not text

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.

Run in Compiler
Example 3: one character is a char

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.

Run in Compiler

Where this is used

  • The C++ Core Guidelines. Rule SL.str.1 is "Use std::string to own character sequences", because a string handles allocation, ownership, copying and growth for you. Rule SL.str.2 is "Use std::string_view or gsl::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. Once std::string_view arrived, StringPiece became another name for it, and the code base has been replacing its uses with std::string_view itself.

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.

Brain teaser

Two views, both of the text abc, made in two ways.

string_view a = "abc";
string_view b = string("abc");

One of them may be used for the rest of the program, as long as you like. The other is broken from the next line on, even if a test run happens to print abc. Which is which, and why?

Ask where each "abc" lives, and when each one stops living. A string literal is not a temporary.

Exercise 1Easy

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.

Run in Compiler
Exercise 2Medium

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.

Run in Compiler
Exercise 3Hard

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 Compiler

Common doubts

  • Should every text parameter be a string_view now?

    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, take const 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 string as soon as you have it.

  • Why not take const char* parameters, like C?

    It does not know its length, so every size is a strlen walk, and a string caller has to write c_str(). A string_view parameter accepts both, and knows its length.

  • Does returning a string from 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 string owns, knows its length and grows; const char* and string_view only look at text that someone else keeps alive.
  • A read-only parameter is const string& or string_view; a by-value string copies every character on every call.
  • A string_view is a pointer and a length: O(1) to make, copy and substr, and broken once its text is gone.
  • Bytes belong in vector<unsigned char>, one character in a char, 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 +