The C++ Textbook

Part 4 · The standard library

std::string and text

Text handling, encodings, and the difference between a character and a byte.

By the end of this chapter you can

  • Use std::string and std::string_view appropriately
  • Explain why a string_view can dangle
  • Describe what a char actually holds in a UTF-8 world

std::string is the first standard container most people meet, and it is a good one to meet first: it owns its memory, grows on demand, copies deeply, and moves cheaply. Everything Part 3 said about ownership, it does correctly and invisibly.

What it does not do is understand text. It is a sequence of bytes, and the gap between “byte” and “character” is where the interesting problems live.

A container that happens to hold characters

The operations you will use daily
#include <iostream>
#include <string>

int main() {
    std::string name = "Ada";

    name += " Lovelace";                    // append
    std::cout << name << '\n';
    std::cout << "size:      " << name.size() << '\n';
    std::cout << "first:     " << name.front() << '\n';
    std::cout << "substr:    " << name.substr(4, 8) << '\n';
    std::cout << "find:      " << name.find("Love") << '\n';
    std::cout << "starts:    " << std::boolalpha << name.starts_with("Ada") << '\n';

    for (char c : name.substr(0, 3)) std::cout << c << '.';
    std::cout << '\n';
}

starts_with and ends_with arrived in C++20. contains looks like it should be their sibling but is C++23 — a distinction worth knowing, because reaching for it while compiling as C++20 produces 'std::string' has no member named 'contains', which reads like a missing header rather than a missing standard. Until you are on C++23, the idiom is find(...) != std::string::npos.

npos is worth knowing because it is how find reports failure — a std::string::size_type of all-ones, the largest representable value:

npos, and the comparison to write
#include <iostream>
#include <string>

int main() {
    std::string text = "hello";

    std::cout << "find(\"ll\")  = " << text.find("ll") << '\n';
    std::cout << "find(\"zz\")  = " << text.find("zz") << '\n';
    std::cout << "npos        = " << std::string::npos << '\n';

    if (text.find("zz") == std::string::npos) {
        std::cout << "not found — compare against npos, never against -1\n";
    }
}

It owns its bytes

Copying a std::string copies the characters. Moving it transfers the buffer. Both were the subject of Part 3; here is the same story in the type you will actually use:

Copy duplicates, move transfers
#include <iostream>
#include <string>
#include <utility>

int main() {
    std::string source(1000, 'x');

    std::string copied = source;
    std::cout << "after copy: source has " << source.size()
              << ", copy has " << copied.size() << '\n';

    std::string moved = std::move(source);
    std::cout << "after move: source has " << source.size()
              << ", moved has " << moved.size() << '\n';
}
Where a short string lives
#include <iostream>
#include <string>

int main() {
    std::string small = "short";
    std::string large(100, 'x');

    std::cout << "sizeof(std::string) = " << sizeof(std::string) << '\n';
    std::cout << "small capacity      = " << small.capacity() << '\n';
    std::cout << "large capacity      = " << large.capacity() << '\n';

    // A string's buffer is inside the object when it is small enough.
    const void* object_start = &small;
    const void* buffer_start = small.data();
    std::cout << "small buffer inside the object? " << std::boolalpha
              << (buffer_start >= object_start &&
                  buffer_start < static_cast<const char*>(object_start) + sizeof(std::string))
              << '\n';
}

string_view: a non-owning window

A great deal of string code only reads. Taking a const std::string& handles that, but it forces every caller to have an actual std::string — so passing a literal or a char array constructs a temporary, allocating and copying to do nothing but read.

std::string_view is a pointer and a length. It owns nothing, copies in two machine words, and binds to anything contiguous:

One function, every kind of caller
#include <iostream>
#include <string>
#include <string_view>

// No allocation, whatever the caller holds.
std::size_t count_vowels(std::string_view text) {
    std::size_t n = 0;
    for (char c : text) {
        if (std::string_view{"aeiouAEIOU"}.find(c) != std::string_view::npos) ++n;
    }
    return n;
}

int main() {
    std::string owned = "the quick brown fox";
    const char* c_style = "hello world";
    char buffer[] = "raw array";

    std::cout << count_vowels(owned)    << '\n';
    std::cout << count_vowels(c_style)  << '\n';
    std::cout << count_vowels(buffer)   << '\n';
    std::cout << count_vowels("literal") << '\n';
    std::cout << count_vowels(owned.substr(4, 5)) << "  (a temporary, still fine here)\n";
}

Substrings are where it pays best. s.substr(...) allocates and copies; std::string_view{s}.substr(...) just moves two numbers:

Slicing without copying
#include <chrono>
#include <iostream>
#include <string>
#include <string_view>

int main() {
    const std::string text(100'000, 'x');
    constexpr int rounds = 100'000;
    using clock = std::chrono::steady_clock;
    using ms = std::chrono::milliseconds;

    auto start = clock::now();
    std::size_t sink = 0;
    for (int i = 0; i < rounds; ++i) sink += text.substr(10, 5000).size();
    auto mid = clock::now();

    std::string_view view{text};
    for (int i = 0; i < rounds; ++i) sink += view.substr(10, 5000).size();
    auto finish = clock::now();

    std::cout << "string::substr:      " << std::chrono::duration_cast<ms>(mid - start).count() << " ms\n";
    std::cout << "string_view::substr: " << std::chrono::duration_cast<ms>(finish - mid).count() << " ms\n";
    std::cout << "(checksum " << sink << ")\n";
}

The dangling problem

A string_view does not keep its bytes alive. That is the entire cost of it being free, and it is the same lifetime rule from Chapter 2.4 in a new costume:

A view of something that is gone
#include <iostream>
#include <string>
#include <string_view>

std::string build() { return "a temporary string"; }

int main() {
    // The temporary dies at the end of this statement. The view outlives it.
    std::string_view view = build();

    std::cout << "reading through the view: " << view << '\n';
}

AddressSanitizer catches that one. The version that catches people is subtler — a function returning a view of its own parameter:

A view of a parameter that has gone home
#include <iostream>
#include <string>
#include <string_view>

std::string_view first_word(const std::string& text) {
    return std::string_view{text}.substr(0, text.find(' '));
}

int main() {
    // The argument is a temporary: it dies at the end of the full expression,
    // taking the returned view's bytes with it.
    std::string_view word = first_word(std::string{"hello world"});

    std::cout << "first word: " << word << '\n';
}

It is not null-terminated

A string_view may point into the middle of a buffer, so there is no guarantee of a '\0' after its last character. C APIs need one:

Crossing into C
#include <cstdio>
#include <iostream>
#include <string>
#include <string_view>

void legacy_print(const char* text) { std::printf("  C says: %s\n", text); }

int main() {
    std::string owner = "hello world";
    std::string_view view = std::string_view{owner}.substr(0, 5);

    std::cout << "the view prints correctly: " << view << '\n';

    // legacy_print(view.data());     // WRONG: prints "hello world", not "hello"
    legacy_print(std::string{view}.c_str());   // allocate, and be correct
}

std::string::c_str() is guaranteed null-terminated. string_view::data() is not, and passing it to a C function reads until it happens to find a zero byte.

A char is a byte, not a character

This is the part that surprises people, and it is not a C++ quirk — it is how text works.

Counting what, exactly?
#include <iostream>
#include <string>

int main() {
    std::string ascii = "hello";
    std::string accented = "café";
    std::string emoji = "hi 👋";

    std::cout << "\"hello\" size " << ascii.size() << '\n';
    std::cout << "\"café\"  size " << accented.size() << "   <- 4 characters\n";
    std::cout << "\"hi 👋\"  size " << emoji.size() << "   <- 4 characters\n";
}

café is four characters and five bytes: é is encoded in UTF-8 as two bytes. The waving hand is four bytes. size() counts bytes, operator[] indexes bytes, and substr slices bytes — so slicing in the middle of a multi-byte character produces invalid text:

Slicing through a character
#include <iostream>
#include <string>

int main() {
    std::string text = "café";

    std::cout << "whole:            " << text << '\n';
    std::cout << "substr(0, 4):     " << text.substr(0, 4) << "  <- cut mid-character\n";
    std::cout << "substr(0, 5):     " << text.substr(0, 5) << "  <- whole thing\n";

    std::cout << "bytes: ";
    for (unsigned char c : text) std::cout << static_cast<int>(c) << ' ';
    std::cout << '\n';
}

What the standard does give you is enough to pass text through safely. Concatenating, comparing for equality, searching for a substring, and writing to a stream are all byte operations that work correctly on UTF-8 without knowing anything about it.

Building strings efficiently

Appending in a loop reallocates as it grows. If you know roughly how big the result will be, say so:

reserve, and what it saves
#include <chrono>
#include <iostream>
#include <string>

int main() {
    constexpr int n = 400'000;
    using clock = std::chrono::steady_clock;
    using ms = std::chrono::milliseconds;

    auto start = clock::now();
    std::string grown;
    for (int i = 0; i < n; ++i) grown += 'x';
    auto mid = clock::now();

    std::string reserved;
    reserved.reserve(n);
    for (int i = 0; i < n; ++i) reserved += 'x';
    auto finish = clock::now();

    std::cout << "no reserve: " << std::chrono::duration_cast<ms>(mid - start).count() << " ms\n";
    std::cout << "reserve:    " << std::chrono::duration_cast<ms>(finish - mid).count() << " ms\n";
    std::cout << "(sizes " << grown.size() << ", " << reserved.size() << ")\n";
}

The difference is smaller than people expect, because std::string grows geometrically — doubling, so the total copying is bounded. reserve still helps, and matters more for large strings, but “concatenation in a loop is quadratic” is a myth inherited from languages with immutable strings.

For assembling values of mixed types, std::format (C++20) is clearer than a chain of +:

std::format
#include <format>
#include <iostream>
#include <string>

int main() {
    std::string name = "Ada";
    int year = 1843;
    double share = 0.6666;

    std::cout << std::format("{} published in {}\n", name, year);
    std::cout << std::format("{:>10} | {:<8} | {:.1f}%\n", name, year, share * 100);
    std::cout << std::format("{0} and {0} again\n", name);
}

Check yourself

Practice