C++ Module 2 – Types, values and expressions
std::string and std::string_view
Build, change and search text with std::string, pass it cheaply as std::string_view, and catch the views that outlive their text before they print garbage.
What you will learn
- Create, modify and search strings
- Pass read-only text efficiently with std::string_view
- Avoid string_views that outlive the text they point to
Before you start
What you’ll learn · Before you start
What you will learn
- Create, modify and search strings
- Pass read-only text efficiently with std::string_view
- Avoid string_views that outlive the text they point to
Before you start
On this page
std::string owns a sequence of characters: it allocates the memory, grows it as you append, and frees it when
the string is destroyed, so it is the type for text you build, change or keep. std::string_view (C++17) owns
nothing: it is a pointer and a length that refer to characters someone else owns, so making one, or taking a piece
of one, copies nothing. That makes std::string_view the usual type for a function parameter that only reads text:
it accepts a std::string, a string literal or a part of either without a copy. The price is lifetime. A view that
outlives its text points at freed memory, and the compiler usually says nothing. This lesson shows the operations of
both types, measures what the view saves, records how short strings are stored by the two standard libraries, and
reproduces the dangling view under AddressSanitizer, with Apple clang 21 and GCC 16.
Building and changing a std::string
A std::string behaves like a value: copying it copies the characters, and comparing two strings compares their
characters. The common operations, one per line:
// Building and changing a std::string: the string owns its characters and grows as needed.
#include <print>
#include <stdexcept>
#include <string>
int main() {
std::string order = "masala dosa";
order += ", filter coffee"; // append
order.insert(0, "2 x "); // insert at a position
std::println("{} ({} characters)", order, order.size());
order.replace(0, 1, "3"); // replace 1 character at position 0
order.erase(order.find(',')); // erase from the comma to the end
std::println("{}", order);
const std::string item = order.substr(4, 6); // a new string: 6 characters from position 4
std::println("item = {}, first letter = {}, last letter = {}", item, item[0], item.back());
try {
std::println("{}", item.at(10)); // at() checks the position; item[10] would not
} catch (const std::out_of_range&) {
std::println("at(10) threw std::out_of_range");
}
const bool apple_first = std::string("apple") < std::string("banana");
std::println("{}", apple_first ? "apple sorts first" : "banana sorts first");
} from session import run
run("""
clang++ --version | head -n 1
clang++ -std=c++23 -Wall -Wextra editing.cpp -o editing && ./editing
g++-16 --version | head -n 1
g++-16 -std=c++23 -Wall -Wextra editing.cpp -o editing && ./editing
""") Output
$ clang++ --version | head -n 1 Apple clang version 21.0.0 (clang-2100.3.34.2) $ clang++ -std=c++23 -Wall -Wextra editing.cpp -o editing && ./editing 2 x masala dosa, filter coffee (30 characters) 3 x masala dosa item = masala, first letter = m, last letter = a at(10) threw std::out_of_range apple sorts first $ g++-16 --version | head -n 1 g++-16 (Homebrew GCC 16.2.0) 16.2.0 $ g++-16 -std=c++23 -Wall -Wextra editing.cpp -o editing && ./editing 2 x masala dosa, filter coffee (30 characters) 3 x masala dosa item = masala, first letter = m, last letter = a at(10) threw std::out_of_range apple sorts first
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 editing_session.py
+=appends,insert(pos, text)inserts before positionpos,replace(pos, count, text)replacescountcharacters, anderase(pos)removes everything fromposto the end.findreturns the position of the first match (here the comma), which is howeraseknew where to start.substr(pos, count)returns a new string with a copy of those characters.s[i]does not checki: a position past the end is undefined behaviour.s.at(i)checks and throwsstd::out_of_range, as the output shows.front()andback()give the first and last character.<,==and the other comparisons compare character by character, so"apple"sorts before"banana".
The program also shows that size() counts characters of type char, 30 for the order; the next lesson explains why
that is a count of bytes, not of letters, once text is not plain English.
A name normaliser
Real input arrives with stray spaces and mixed case. normalise trims both ends, turns ASCII letters into lower case
and collapses each run of spaces and tabs into one space:
// A name normaliser: trims the ends, lower-cases ASCII letters
// and collapses runs of spaces into one.
#include <cctype>
#include <print>
#include <string>
#include <string_view>
std::string normalise(std::string_view text) {
std::string out;
out.reserve(text.size());
bool pending_space = false;
for (char c : text) {
// <cctype> functions need an unsigned char value:
// passing a negative char is undefined behaviour.
const auto u = static_cast<unsigned char>(c);
if (std::isspace(u)) {
pending_space = !out.empty(); // remember a gap, but never at the start
continue;
}
if (pending_space) out += ' ';
pending_space = false;
out += static_cast<char>(std::tolower(u));
}
return out;
}
int main() {
for (std::string_view raw : {" Aditi RAO ", "MEERA\tnair", "x", " "}) {
std::println("[{}] -> [{}]", raw, normalise(raw));
}
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra normalise.cpp -o normalise && ./normalise
g++-16 -std=c++23 -Wall -Wextra normalise.cpp -o normalise && ./normalise
""") Output
$ clang++ -std=c++23 -Wall -Wextra normalise.cpp -o normalise && ./normalise [ Aditi RAO ] -> [aditi rao] [MEERA nair] -> [meera nair] [x] -> [x] [ ] -> [] $ g++-16 -std=c++23 -Wall -Wextra normalise.cpp -o normalise && ./normalise [ Aditi RAO ] -> [aditi rao] [MEERA nair] -> [meera nair] [x] -> [x] [ ] -> []
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 normalise_session.py
std::isspace and std::tolower come from C, and they take their argument as an int that must be a value of
unsigned char (or EOF); anything else is undefined behaviour, says section 7.4 of the C standard. Every byte of a
non-ASCII UTF-8 letter is above 127, and where char is signed, as it is on this Mac, such a byte is stored as a
negative char, so the code converts each char to unsigned char first. out.reserve(text.size()) allocates once, since the result is
never longer than the input.
Searching: find, contains, starts_with and ends_with
find returns the position of the first match, rfind of the last, and both return std::string::npos, the
largest value of std::size_t, when there is no match. C++20 and C++23 added three functions that answer the usual
yes-or-no questions directly:
// Searching a string: find gives a position or npos;
// contains, starts_with and ends_with give a bool.
#include <print>
#include <string>
int main() {
const std::string file = "report-q3-final.pdf";
const auto dash = file.find('-');
const auto zip = file.find(".zip");
std::println("first '-' at {}, last '-' at {}", dash, file.rfind('-'));
std::println("\".zip\" {}", zip == std::string::npos ? "not found (npos)" : "found");
std::println("contains(\"final\"): {}", file.contains("final")); // C++23
std::println("starts_with(\"report\"): {}", file.starts_with("report")); // C++20
// C++20, and case matters
std::println("ends_with(\".PDF\"): {}", file.ends_with(".PDF"));
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra search.cpp -o search && ./search
g++-16 -std=c++23 -Wall -Wextra search.cpp -o search && ./search
""") Output
$ clang++ -std=c++23 -Wall -Wextra search.cpp -o search && ./search
first '-' at 6, last '-' at 9
".zip" not found (npos)
contains("final"): true
starts_with("report"): true
ends_with(".PDF"): false
$ g++-16 -std=c++23 -Wall -Wextra search.cpp -o search && ./search
first '-' at 6, last '-' at 9
".zip" not found (npos)
contains("final"): true
starts_with("report"): true
ends_with(".PDF"): false
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 search_session.py
contains (C++23) replaces s.find(x) != std::string::npos, and starts_with and ends_with (C++20) replace
comparisons with substr. All of them compare exactly, so ".PDF" does not match ".pdf".
std::string_view: reading text without copying it
A std::string_view holds two things: a pointer to the first character and a length. A function that takes one can
be called with a string literal, a std::string or a view of part of either, and nothing is copied. substr on a
view makes another view, so splitting a line into fields allocates nothing. The program below proves it by counting
every heap allocation, through its own operator new:
// std::string_view refers to characters that someone else owns:
// making a view, or a piece of one, copies nothing.
// This program counts heap allocations to show it.
#include <cstdlib>
#include <new>
#include <print>
#include <string>
#include <string_view>
#include <vector>
static int allocations = 0;
// Every new and delete in the program goes through these two functions, so they can count.
void* operator new(std::size_t size) {
++allocations;
if (void* p = std::malloc(size == 0 ? 1 : size)) return p;
throw std::bad_alloc{};
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
// The same splitter twice: the first returns copies, the second views into the original text.
std::vector<std::string> split_copies(const std::string& text, char sep) {
std::vector<std::string> out;
out.reserve(8);
for (std::size_t start = 0;;) {
const auto end = text.find(sep, start);
out.push_back(text.substr(start, end - start)); // substr allocates when the piece is long
if (end == std::string::npos) return out;
start = end + 1;
}
}
std::vector<std::string_view> split_views(std::string_view text, char sep) {
std::vector<std::string_view> out;
out.reserve(8);
for (std::size_t start = 0;;) {
const auto end = text.find(sep, start);
out.push_back(text.substr(start, end - start)); // a string_view's substr never allocates
if (end == std::string_view::npos) return out;
start = end + 1;
}
}
int main() {
const std::string line = "Mumbai Central to New Delhi,Rajdhani Express number twelve,departs in the evening";
allocations = 0;
const auto copies = split_copies(line, ',');
std::println("{} fields as std::string: allocations = {}", copies.size(), allocations);
allocations = 0;
const auto views = split_views(line, ',');
std::println("{} fields as std::string_view: allocations = {}", views.size(), allocations);
std::println("first field: {}", views[0]);
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra views.cpp -o views && ./views
g++-16 -std=c++23 -Wall -Wextra views.cpp -o views && ./views
""") Output
$ clang++ -std=c++23 -Wall -Wextra views.cpp -o views && ./views 3 fields as std::string: allocations = 3 3 fields as std::string_view: allocations = 1 first field: Mumbai Central to New Delhi $ g++-16 -std=c++23 -Wall -Wextra views.cpp -o views && ./views 3 fields as std::string: allocations = 4 3 fields as std::string_view: allocations = 1 first field: Mumbai Central to New Delhi
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 views_session.py
In both runs one allocation is the vector’s own storage (reserve(8)). With std::string, each field that is too
long for the string’s inline buffer allocated too: two with libc++ (Apple clang) and three with libstdc++ (GCC),
because “departs in the evening” has 22 characters, which fit inline in libc++ but not in libstdc++. With
std::string_view, the fields cost nothing. The C++ Core Guidelines give the same advice (rule SL.str.2): refer to
characters you do not own with std::string_view.
A std::string owns its characters; a std::string_view only points at them
Text description of the diagram
The diagram has four boxes.
- At the top right is std::string text, which holds a pointer, its size, 37, and its capacity. An arrow labelled owns goes from it to the characters.
- At the top left is std::string_view word, which holds only a pointer and a size, 8. An arrow labelled points into goes from it to the same characters.
- In the middle are the characters on the heap: "Namaste, and welcome to the C++ track". The string allocated them and frees them in its destructor.
- A dashed arrow leads down to the last box: once text is destroyed, word still points at the same place, which is now freed memory.
A view is not a string, and three differences matter:
- It is read-only: there is no
+=,insertorerase. - It need not end with a
'\0'.view.data()points into someone else’s characters, so passing it to a C function that expects a terminated string can read past the end; the standard says so in a note todata(). Convert withstd::string(view)when you need a terminated copy. - It does not keep the characters alive. That is the next section.
Small strings live inside the object
The standard does not say how a std::string stores its characters, but both libraries keep short strings inside
the std::string object itself, which avoids an allocation; longer strings go on the heap. The limit differs:
// Short strings are stored inside the std::string object itself; longer ones on the heap.
// How short is short depends on the standard library.
#include <cstdint>
#include <print>
#include <string>
// Whether the characters of s sit inside the bytes of the object s.
bool stored_inside(const std::string& s) {
const auto object = reinterpret_cast<std::uintptr_t>(&s);
const auto characters = reinterpret_cast<std::uintptr_t>(s.data());
return characters >= object && characters < object + sizeof s;
}
int main() {
std::println("sizeof(std::string) = {}, capacity of an empty string = {}", sizeof(std::string),
std::string{}.capacity());
for (std::size_t length : {15, 16, 22, 23}) {
const std::string s(length, 'x');
std::println("{} characters: {}", length, stored_inside(s) ? "inside the object" : "on the heap");
}
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra sso.cpp -o sso && ./sso
g++-16 -std=c++23 -Wall -Wextra sso.cpp -o sso && ./sso
""") Output
$ clang++ -std=c++23 -Wall -Wextra sso.cpp -o sso && ./sso sizeof(std::string) = 24, capacity of an empty string = 22 15 characters: inside the object 16 characters: inside the object 22 characters: inside the object 23 characters: on the heap $ g++-16 -std=c++23 -Wall -Wextra sso.cpp -o sso && ./sso sizeof(std::string) = 32, capacity of an empty string = 15 15 characters: inside the object 16 characters: on the heap 22 characters: on the heap 23 characters: on the heap
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sso_session.py
| Standard library | sizeof(std::string) |
Characters stored inside |
|---|---|---|
| libc++ (Apple clang 21) | 24 bytes | up to 22 |
| libstdc++ (GCC 16) | 32 bytes | up to 15 |
Both are 64-bit builds on the same Mac. This explains the different allocation counts above, and it matters for the next example: a dangling view into a short string may still find the old characters in place, so a test with short text can hide the bug, while text longer than the inline buffer lives in heap memory that AddressSanitizer watches.
A string_view that outlives its string
Here are the two classic ways to create a dangling view. greeting() returns a view into its local string, which
is destroyed when the function returns, and temporary.cpp keeps a view of a temporary string, which is destroyed at
the end of the statement that made it:
dangling.cpp
// A std::string_view must not outlive the string it looks at. Both mistakes below compile.
#include <print>
#include <string>
#include <string_view>
std::string_view first_word(std::string_view text) {
return text.substr(0, text.find(' ')); // fine: a view into the caller's text
}
std::string_view greeting() {
// longer than the small-string buffer
const std::string local = "Namaste, and welcome to the C++ track";
return first_word(local); // a view into local, which is destroyed when greeting() returns
}
int main() {
const std::string kept = "Vanakkam everyone";
std::println("{}", first_word(kept)); // fine: kept is still alive
const std::string_view word = greeting();
std::println("{}", word); // reads the memory of a destroyed string
} temporary.cpp
// A view of a temporary string: the temporary is destroyed at the end of the line that made it.
#include <print>
#include <string>
#include <string_view>
int main() {
const std::string city = "Thiruvananthapuram";
std::string_view shout = city + " Central"; // city + " Central" is a temporary std::string
std::println("{}", shout);
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra -g -fsanitize=address,undefined dangling.cpp -o dangling && ./dangling
g++-16 -std=c++23 -Wall -Wextra -g -fsanitize=address,undefined dangling.cpp -o dangling && ./dangling
clang++ -std=c++23 -Wall -Wextra -g -fsanitize=address,undefined temporary.cpp -o temporary && ./temporary
g++-16 -std=c++23 -Wall -Wextra -g -fsanitize=address,undefined temporary.cpp -o temporary && ./temporary
""") Output
$ clang++ -std=c++23 -Wall -Wextra -g -fsanitize=address,undefined dangling.cpp -o dangling && ./dangling
Vanakkam
=================================================================
==…==ERROR: AddressSanitizer: heap-use-after-free on address 0x…
READ of size 8 at 0x… thread T0
#28 in main dangling.cpp:21
0x… is located 0 bytes inside of 40-byte region [0x…,0x…)
freed by thread T0 here:
#2 in greeting() dangling.cpp:14
#3 in main dangling.cpp:20
previously allocated by thread T0 here:
#4 in greeting() dangling.cpp:12
#5 in main dangling.cpp:20
SUMMARY: AddressSanitizer: heap-use-after-free
[73 more lines of the report left out]
[exit status 134]
$ g++-16 -std=c++23 -Wall -Wextra -g -fsanitize=address,undefined dangling.cpp -o dangling && ./dangling
Vanakkam
=================================================================
==…==ERROR: AddressSanitizer: heap-use-after-free on address 0x…
READ of size 8 at 0x… thread T0
#8 in main dangling.cpp:21
0x… is located 0 bytes inside of 38-byte region [0x…,0x…)
freed by thread T0 here:
#5 in greeting() dangling.cpp:14
#6 in main dangling.cpp:20
previously allocated by thread T0 here:
#6 in greeting() dangling.cpp:12
#7 in main dangling.cpp:20
SUMMARY: AddressSanitizer: heap-use-after-free
[58 more lines of the report left out]
[exit status 134]
$ clang++ -std=c++23 -Wall -Wextra -g -fsanitize=address,undefined temporary.cpp -o temporary && ./temporary
temporary.cpp:8:30: warning: object backing the pointer will be destroyed at the end of the full-expression [-Wdangling-gsl]
8 | std::string_view shout = city + " Central"; // city + " Central" is a temporary std::string
| ^~~~~~~~~~~~~~~~~
1 warning generated.
=================================================================
==…==ERROR: AddressSanitizer: heap-use-after-free on address 0x…
READ of size 26 at 0x… thread T0
#28 in main temporary.cpp:9
0x… is located 0 bytes inside of 32-byte region [0x…,0x…)
freed by thread T0 here:
#2 in main temporary.cpp:8
previously allocated by thread T0 here:
#12 in main temporary.cpp:8
SUMMARY: AddressSanitizer: heap-use-after-free
[81 more lines of the report left out]
[exit status 134]
$ g++-16 -std=c++23 -Wall -Wextra -g -fsanitize=address,undefined temporary.cpp -o temporary && ./temporary
=================================================================
==…==ERROR: AddressSanitizer: heap-use-after-free on address 0x…
READ of size 26 at 0x… thread T0
#8 in main temporary.cpp:9
0x… is located 0 bytes inside of 31-byte region [0x…,0x…)
freed by thread T0 here:
#5 in main temporary.cpp:8
previously allocated by thread T0 here:
#7 in main temporary.cpp:8
SUMMARY: AddressSanitizer: heap-use-after-free
[59 more lines of the report left out]
[exit status 134]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dangling_session.py
AddressSanitizer stops all four runs with a heap-use-after-free report that names the line that read the memory, the line that freed it and the line that allocated it (the sessions keep those lines of each report and write the addresses, which change on every run, as 0x…). The compilers are less helpful:
- Clang warns about the temporary (
-Wdangling-gsl, on by default); GCC 16 says nothing. - Neither compiler warns about the view returned from
greeting(), because it passes throughfirst_word, a function that is fine on its own. Clang does warn (-Wreturn-stack-address) when a function returns its local string directly as a view,return local;, but one function call in between is enough to hide it.
So the rules are about lifetimes, not syntax. Return a std::string from a function that builds text; take a
std::string_view parameter only for text you read during the call; store a std::string in a variable or a member
that must outlive the call; and run tests with -fsanitize=address, using text longer than the inline buffer.
Exercise: split and join
Write split, which cuts a std::string_view at every separator and returns the pieces as views into the original
text (keeping empty pieces), and join, which glues pieces together with a separator between them. The checks in
main must all print ok; the last one checks that split copies nothing.
// Exercise: write split and join. Build and run this file: every check must say "ok".
// split("a,b,c", ',') gives the views {"a", "b", "c"}; empty fields are kept.
// join({"a", "b", "c"}, ", ") gives the string "a, b, c".
#include <print>
#include <string>
#include <string_view>
#include <vector>
std::vector<std::string_view> split(std::string_view text, char separator) {
(void)text;
(void)separator;
return {}; // replace this
}
std::string join(const std::vector<std::string_view>& parts, std::string_view separator) {
(void)parts;
(void)separator;
return {}; // replace this
}
int failures = 0;
void check(bool passed, std::string_view what) {
std::println("{} {}", passed ? "ok " : "FAILED", what);
if (!passed) ++failures;
}
int main() {
using Parts = std::vector<std::string_view>;
check(split("idli,dosa,vada", ',') == Parts{"idli", "dosa", "vada"}, "three fields");
check(split("chai", ',') == Parts{"chai"}, "no separator: one field");
check(split("a,,b", ',') == Parts{"a", "", "b"}, "an empty field in the middle is kept");
check(join({"idli", "dosa", "vada"}, ", ") == "idli, dosa, vada", "join with a separator");
check(join({"chai"}, " + ") == "chai", "one part: no separator");
const std::string line = "Pune;Nashik;Nagpur";
const auto parts = split(line, ';');
check(!parts.empty() && parts[0].data() == line.data(),
"the views point into the original text (no copies)");
std::println("{} of 6 checks failed", failures);
return failures == 0 ? 0 : 1;
} As given, every check fails:
from session import run
run("""
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined split_join.cpp -o split_join && ./split_join
""") Output
$ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined split_join.cpp -o split_join && ./split_join FAILED three fields FAILED no separator: one field FAILED an empty field in the middle is kept FAILED join with a separator FAILED one part: no separator FAILED the views point into the original text (no copies) 6 of 6 checks failed [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 split_join_session.py
First hint: loop with text.find(separator, start), and stop after the piece for which find returns npos.
text.substr(start, end - start) gives the right piece even then, because substr stops at the end of the view.
Self-check
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
When should a function take std::string_view instead of const std::string&? When it only reads the text
during the call. A std::string_view parameter accepts string literals, std::string objects and parts of either
without creating a temporary std::string, so a call such as f("literal") allocates nothing, and taking a piece
costs nothing. Take const std::string& (or a std::string by value) when the function needs a terminated string
for a C API, or keeps the text after it returns.
Key takeaways
std::stringowns its characters;std::string_viewis a pointer and a length into characters owned elsewhere.substron astd::stringcopies; on astd::string_viewit copies nothing, as the allocation counts show.at()checks positions,[]does not;contains(C++23),starts_withandends_with(C++20) replacefindcomparisons.- Pass
unsigned charvalues tostd::isspace,std::tolowerand the other<cctype>functions. - Short strings live inside the object: up to 22 characters with libc++, 15 with libstdc++.
- A view must not outlive its text: never return a view of a local string or keep a view of a temporary; Clang warns about some cases, AddressSanitizer catches them at run time.
size()countscharvalues, which are bytes: the next lesson is about what that means for Unicode text.
References
- Working Draft, Programming Languages: C++ (N4950), 23.3 String view classes, 23.4 String classes, 6.7.7 Temporary objects (ISO/IEC JTC1/SC22/WG21)
- P0254R2: Integrating std::string_view and std::string (ISO/IEC JTC1/SC22/WG21)
- P0457R2: String Prefix and Suffix Checking (ISO/IEC JTC1/SC22/WG21)
- P1679R3: string contains function (ISO/IEC JTC1/SC22/WG21)
- ISO/IEC 9899:2024 working draft (N3220), 7.4 Character handling <ctype.h> (ISO/IEC JTC1/SC22/WG14)
- C++ Core Guidelines: SL.str.2 Use std::string_view or gsl::span<char> to refer to character sequences (Standard C++ Foundation)
- AddressSanitizer (LLVM Project)
- Diagnostic flags in Clang: -Wdangling-gsl (LLVM Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress