Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C++  Module 5 – Arrays, vectors and aggregate types

C-style strings and why std::string replaced them

How null-terminated C strings work, why strcpy overflows and == misleads, how to use strlen, strcmp and snprintf safely, and how to convert to std::string.

  • Beginner
  • 22 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free: 22 minutes

What you will learn

  • Explain how null-terminated strings work
  • Use strlen, strcmp and snprintf correctly
  • Convert safely between C strings and std::string

Before you start

A C-style string is an array of char whose end is marked by the null character '\0', a byte with the value 0. Nothing else records its length: std::strlen finds it by walking through the characters until it meets that byte. The functions that copy and join such strings, std::strcpy and std::strcat, never check whether the destination is big enough, and == compares where two strings are in memory, not what they say. Decades of C programs have crashed, or been broken into, through exactly these gaps.

std::string replaced them because it closes every gap: it stores its length, owns and grows its memory, compares text with ==, and joins strings with +. C strings have not gone away, though. Every string literal is one, main receives its arguments as C strings, and C libraries and operating-system calls take them. This lesson shows how they work, the three classic mistakes with recordings from Apple clang and GCC 16, and how to move safely between the two kinds.

A C string is characters followed by ‘\0’

char city[] = "Pune" is five bytes ending in '\0'; strlen counts the four before it, while a std::string stores its size.char city[] = "Pune": 5 bytesstd::strlen(city) walks from [0] and stopsat the first '\0': 4 charactersstd::string city = "Pune"[0]'P'[1]'u'[2]'n'[3]'e'[4]'\0'size 4(stored)P u n e \0(characters)a std::string never has to search

How a C string marks its end, and how std::string knows its length

Text description of the diagram

The diagram compares two ways of holding the text "Pune".

  1. char city[] = "Pune" is an array of five chars at indexes 0 to 4: 'P', 'u', 'n', 'e' and the null character '\0', whose value is 0. Nothing else records how long the text is.
  2. std::strlen(city) finds the length by walking from index 0 until it meets the first '\0', so it counts 4 characters. It has to do that walk every time it is called, and it never stops if the '\0' is missing.
  3. A std::string holding "Pune" stores its size, 4, beside the characters, so size() needs no search. It still keeps a '\0' after the characters, so that c_str() can hand them to a function that expects a C string.
layout.cpp C++ · layout.cpp
// A C string is an array of char that ends with the null character '\0'.
#include <cstddef>
#include <cstring>
#include <print>

int main() {
    char city[] = "Pune";               // five chars: 'P', 'u', 'n', 'e' and the terminator '\0'
    std::println("sizeof(city) = {}, std::strlen(city) = {}", sizeof(city), std::strlen(city));
    for (std::size_t i = 0; i < sizeof(city); ++i) {
        if (city[i] == '\0') std::println("city[{}] = {:3}  the terminator", i, static_cast<int>(city[i]));
        else std::println("city[{}] = {:3}  '{}'", i, static_cast<int>(city[i]), city[i]);
    }

    city[2] = '\0';                     // a '\0' in the middle ends the string there
    const char* text = city;
    std::println("after city[2] = '\\0': std::strlen = {}, the string is \"{}\"", std::strlen(text), text);

    std::println("std::strlen(\"café\") = {}: it counts bytes, and é takes two in UTF-8", std::strlen("café"));
}
The bytes of a C string Python · layout_session.py
from session import run

run("""
clang++ -std=c++23 -Wall -Wextra layout.cpp -o layout
./layout
g++-16 -std=c++23 -Wall -Wextra layout.cpp -o layout-gcc
./layout-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra layout.cpp -o layout
$ ./layout
sizeof(city) = 5, std::strlen(city) = 4
city[0] =  80  'P'
city[1] = 117  'u'
city[2] = 110  'n'
city[3] = 101  'e'
city[4] =   0  the terminator
after city[2] = '\0': std::strlen = 2, the string is "Pu"
std::strlen("café") = 5: it counts bytes, and é takes two in UTF-8
$ g++-16 -std=c++23 -Wall -Wextra layout.cpp -o layout-gcc
$ ./layout-gcc
sizeof(city) = 5, std::strlen(city) = 4
city[0] =  80  'P'
city[1] = 117  'u'
city[2] = 110  'n'
city[3] = 101  'e'
city[4] =   0  the terminator
after city[2] = '\0': std::strlen = 2, the string is "Pu"
std::strlen("café") = 5: it counts bytes, and é takes two in UTF-8

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 layout_session.py

  • char city[] = "Pune"; copies the literal into an array of five char: the four letters and the terminator. So sizeof(city) is 5 while std::strlen(city) is 4.
  • Each char holds a number: 80 is 'P' in ASCII, and the terminator is 0.
  • Writing a '\0' into the middle ends the string there: std::strlen stops at the first one it meets, and so does every function that prints or copies the string.
  • std::strlen counts bytes, not letters: "café" is 5 bytes, because UTF-8 stores é in two.
  • Because the length is never stored, each std::strlen call walks the whole string again. A loop such as for (i = 0; i < strlen(s); ++i) walks it once per step.
Unicode Character Inspector Paste any text to see each character with its UTF-8 bytes: the number std::strlen counts.

String literals are read-only

A string literal such as "pune" is an array of const char, created before the program starts and kept for its whole run, and the standard leaves the effect of changing one undefined. Since C++11, the standard no longer lets a literal convert to a plain char*, although compilers still accept it, with a warning, for the sake of old code:

literal.cpp C++ · literal.cpp
// A string literal is an array of const char, kept in memory that the program may not change.
#include <cstdio>
#include <print>

int main() {
    char copy[] = "pune";        // an array initialised with a copy of the literal: yours to change
    copy[0] = 'P';
    std::println("{}", copy);
    std::fflush(stdout);         // print the line above now, before the crash below

    char* p = "pune";            // not allowed since C++11: the literal is const
    p[0] = 'P';                  // writing into the literal: undefined behaviour
    std::println("{}", p);
}
Changing a copy, then the literal itself Python · literal_session.py
from session import run

# Both compilers warn about char* p = "pune"; writing through p then crashes, because literals are read-only.
run("""
clang++ -std=c++23 -Wall -Wextra literal.cpp -o literal
./literal
g++-16 -std=c++23 -Wall -Wextra literal.cpp -o literal-gcc
./literal-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra literal.cpp -o literal
literal.cpp:11:15: warning: ISO C++11 does not allow conversion from string literal to 'char *' [-Wwritable-strings]
   11 |     char* p = "pune";            // not allowed since C++11: the literal is const
      |               ^
1 warning generated.
$ ./literal
Pune
[stopped by SIGBUS: a shell reports exit status 138]
$ g++-16 -std=c++23 -Wall -Wextra literal.cpp -o literal-gcc
literal.cpp: In function 'int main()':
literal.cpp:11:15: warning: ISO C++ forbids converting a string constant to 'char*' [-Wwrite-strings]
   11 |     char* p = "pune";            // not allowed since C++11: the literal is const
      |               ^~~~~~
$ ./literal-gcc
Pune
[stopped by SIGBUS: a shell reports exit status 138]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 literal_session.py

Both compilers warn about char* p = "pune"; (-Wwritable-strings in Clang, -Wwrite-strings in GCC), and both programs crashed on the write with SIGBUS: the literal sits in memory that the program may only read. The copy in char copy[] = "pune"; is an ordinary array, so changing it is fine. Point to literals with const char*, and copy them into an array or a std::string when you need to change the text. The std::fflush(stdout) is there because output that is still waiting in a buffer is lost when a program crashes.

Hex Viewer & File Inspector Open a compiled program and search for a string literal: its bytes end with 00, the terminator.

Comparing: std::strcmp, not ==

compare.cpp, and == between two arrays

C++ (compare.cpp) · compare.cpp

// Two arrays that hold the same text: == on pointers compares addresses, std::strcmp compares the characters.
#include <cstring>
#include <print>

int main() {
    char typed[] = "pune";
    char stored[] = "pune";
    const char* a = typed;
    const char* b = stored;
    std::println("a == b: {}", a == b);                                  // two arrays, two addresses
    std::println("std::strcmp(a, b) == 0: {}", std::strcmp(a, b) == 0);   // the same characters
    std::println("std::strcmp(\"apple\", \"banana\") < 0: {}", std::strcmp("apple", "banana") < 0);
    std::println("std::strcmp(\"Zoo\", \"apple\") < 0: {}", std::strcmp("Zoo", "apple") < 0);   // 'Z' is 90, 'a' is 97
}

C++ (compare_arrays.cpp) · compare_arrays.cpp

// == between two arrays: deprecated since C++20, an error in C++26.
#include <print>

int main() {
    char typed[] = "pune";
    char stored[] = "pune";
    std::println("{}", typed == stored);
}
== and strcmp, then == between two arrays Python · compare_session.py
from session import run

# compare.cpp with both compilers, then == between two arrays in C++23 and in C++26 mode (-std=c++2c).
run("""
clang++ -std=c++23 -Wall -Wextra compare.cpp -o compare
./compare
g++-16 -std=c++23 -Wall -Wextra compare.cpp -o compare-gcc
./compare-gcc
clang++ -std=c++23 -Wall -Wextra compare_arrays.cpp -o compare_arrays 2>&1 | grep -E "warning|error"
g++-16 -std=c++23 -Wall -Wextra compare_arrays.cpp -o compare_arrays-gcc 2>&1 | grep -E "warning|error"
clang++ -std=c++2c -Wall -Wextra compare_arrays.cpp -o compare_arrays 2>&1 | grep -E "warning|error"
g++-16 -std=c++2c -Wall -Wextra compare_arrays.cpp -o compare_arrays-gcc 2>&1 | grep -E "warning|error"
""")

Output

$ clang++ -std=c++23 -Wall -Wextra compare.cpp -o compare
$ ./compare
a == b: false
std::strcmp(a, b) == 0: true
std::strcmp("apple", "banana") < 0: true
std::strcmp("Zoo", "apple") < 0: true
$ g++-16 -std=c++23 -Wall -Wextra compare.cpp -o compare-gcc
$ ./compare-gcc
a == b: false
std::strcmp(a, b) == 0: true
std::strcmp("apple", "banana") < 0: true
std::strcmp("Zoo", "apple") < 0: true
$ clang++ -std=c++23 -Wall -Wextra compare_arrays.cpp -o compare_arrays 2>&1 | grep -E "warning|error"
compare_arrays.cpp:7:30: warning: comparison between two arrays is deprecated; to compare array addresses, use unary '+' to decay operands to pointers [-Wdeprecated-array-compare]
compare_arrays.cpp:7:30: warning: array comparison always evaluates to false [-Wtautological-compare]
2 warnings generated.
$ g++-16 -std=c++23 -Wall -Wextra compare_arrays.cpp -o compare_arrays-gcc 2>&1 | grep -E "warning|error"
compare_arrays.cpp:7:30: warning: comparison between two arrays is deprecated in C++20 [-Warray-compare]
$ clang++ -std=c++2c -Wall -Wextra compare_arrays.cpp -o compare_arrays 2>&1 | grep -E "warning|error"
compare_arrays.cpp:7:30: error: comparison between two arrays is ill-formed in C++26; to compare array addresses, use unary '+' to decay operands to pointers [-Warray-compare-cxx26]
compare_arrays.cpp:7:30: warning: array comparison always evaluates to false [-Wtautological-compare]
1 warning and 1 error generated.
$ g++-16 -std=c++2c -Wall -Wextra compare_arrays.cpp -o compare_arrays-gcc 2>&1 | grep -E "warning|error"
compare_arrays.cpp:7:30: error: comparison between two arrays is not allowed in C++26 [-Warray-compare]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 compare_session.py

  • a == b on two const char* compares two addresses. The two arrays hold the same text in different places, so the answer is false.
  • std::strcmp(a, b) compares the characters one by one and returns 0 when they are all equal, a negative number when a comes first and a positive number when b does. It compares the byte values, so every uppercase ASCII letter comes before every lowercase one: "Zoo" sorts before "apple".
  • Comparing two arrays directly, typed == stored, has been deprecated since C++20, and both compilers warn. C++26 removes it: with -std=c++2c (C++26 mode), Clang and GCC both refuse to compile it.
  • With string literals the result of == is not even predictable: the standard leaves it unspecified whether two identical literals are the same object, so "pune" == "pune" may be true or false.

Copying and joining: where buffer overflows come from

std::strcpy(dest, src) copies characters until it has copied the '\0', and std::strcat(dest, src) appends the same way. Neither knows how big dest is:

overflow.cpp C++ · overflow.cpp
// std::strcat appends until it has copied the '\0', however small the destination is.
#include <cstring>
#include <print>

void greet(const char* name) {
    char buffer[16];
    std::strcpy(buffer, "Hello, ");     // 7 characters and '\0': fits
    std::strcat(buffer, name);          // appends name: nothing checks that it fits
    std::println("{}", buffer);
}

int main(int argc, char* argv[]) {
    greet(argc > 1 ? argv[1] : "Asha");
}
A name that fits, and one that does not Python · overflow_session.py
from session import run

# A short name fits; a 14-letter name overflows the 16-byte buffer. Without a checker the result depends on the
# compiler: Clang's build dies inside println, GCC's -O2 build seems to work. AddressSanitizer names the bug (grep
# keeps the key lines of its report, sed hides the addresses).
run("""
clang++ -std=c++23 -Wall -Wextra overflow.cpp -o overflow
./overflow Ravi
./overflow Venkataramanan
g++-16 -std=c++23 -O2 -Wall -Wextra overflow.cpp -o overflow-gcc
./overflow-gcc Venkataramanan
clang++ -std=c++23 -g -fsanitize=address overflow.cpp -o overflow-asan
ASAN_OPTIONS=symbolize=0 ./overflow-asan Venkataramanan 2>&1 | grep -E "ERROR|of size|overflows" | sed -E 's/==[0-9]+==//; s/0x[0-9a-f]+/0x…/g'
""")

Output

$ clang++ -std=c++23 -Wall -Wextra overflow.cpp -o overflow
$ ./overflow Ravi
Hello, Ravi
$ ./overflow Venkataramanan
libc++abi: terminating due to uncaught exception of type std::bad_alloc: std::bad_alloc
[stopped by SIGABRT: a shell reports exit status 134]
$ g++-16 -std=c++23 -O2 -Wall -Wextra overflow.cpp -o overflow-gcc
$ ./overflow-gcc Venkataramanan
Hello, Venkataramanan
$ clang++ -std=c++23 -g -fsanitize=address overflow.cpp -o overflow-asan
$ ASAN_OPTIONS=symbolize=0 ./overflow-asan Venkataramanan 2>&1 | grep -E "ERROR|of size|overflows" | sed -E 's/==[0-9]+==//; s/0x[0-9a-f]+/0x…/g'
ERROR: AddressSanitizer: stack-buffer-overflow on address 0x… at pc 0x… bp 0x… sp 0x…
WRITE of size 15 at 0x… thread T0
    [32, 48) 'buffer' (line 6) <== Memory access at offset 48 overflows this variable

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 overflow_session.py

"Hello, Ravi" fits into 16 bytes. "Hello, Venkataramanan" needs 22, so strcat wrote 6 bytes past the end of buffer, over whatever the compiler had placed there. The Clang build then died with a std::bad_alloc exception thrown inside std::println, a message that says nothing about the real cause. The GCC -O2 build printed the whole greeting and seemed fine: the overflow was still there, it just damaged nothing that this short program used afterwards. AddressSanitizer names the bug, a stack-buffer-overflow: a write of 15 bytes, the name and its terminator, that runs past the 16 bytes of buffer. Overflows like this one are also a classic security hole: input chosen by an attacker can overwrite data the program relies on, such as the place a function returns to.

When you must fill a fixed buffer, use a function that takes its size. std::snprintf writes at most the size you give it, always ends the text with '\0', and returns the length the whole text would have had:

bounded.cpp C++ · bounded.cpp
// std::snprintf writes at most the size it is given, always ends with '\0',
// and returns the length the whole text would have had.
#include <cstddef>
#include <cstdio>
#include <print>

int main() {
    char line[12];
    int needed = std::snprintf(line, sizeof line, "%s scored %d", "Asha", 74);
    std::println("needed {}, room for {} plus '\\0': \"{}\"", needed, sizeof line - 1, line);
    if (needed >= static_cast<int>(sizeof line)) std::println("the text was cut short");

    char wide[32];
    needed = std::snprintf(wide, sizeof wide, "%s scored %d", "Asha", 74);
    std::println("with 32 bytes: needed {}, \"{}\"", needed, wide);
}
snprintf cuts the text instead of overflowing Python · bounded_session.py
from session import run

# GCC sees the truncation while compiling (-Wformat-truncation, part of -Wall); both programs print the same.
run("""
clang++ -std=c++23 -Wall -Wextra bounded.cpp -o bounded
./bounded
g++-16 -std=c++23 -Wall -Wextra bounded.cpp -o bounded-gcc
./bounded-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra bounded.cpp -o bounded
$ ./bounded
needed 14, room for 11 plus '\0': "Asha scored"
the text was cut short
with 32 bytes: needed 14, "Asha scored 74"
$ g++-16 -std=c++23 -Wall -Wextra bounded.cpp -o bounded-gcc
bounded.cpp: In function 'int main()':
bounded.cpp:9:31: warning: '%d' directive output truncated writing 2 bytes into a region of size 0 [-Wformat-truncation=]
    9 |     int needed = std::snprintf(line, sizeof line, "%s scored %d", "Asha", 74);
      |                  ~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
bounded.cpp:9:31: note: 'snprintf' output 15 bytes into a destination of size 12
$ ./bounded-gcc
needed 14, room for 11 plus '\0': "Asha scored"
the text was cut short
with 32 bytes: needed 14, "Asha scored 74"

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bounded_session.py

The text needed 14 characters and the buffer had room for 11, so snprintf stored "Asha scored" and returned 14: a return value that is not smaller than the buffer’s size means the text was cut short. GCC even noticed the cut while compiling, with its -Wformat-truncation warning. Avoid std::strncpy for this job: when the source is too long it fills the destination without a terminator, so the next strlen runs off its end.

C string or std::string?
Criterion C string (char array)std::string
Length Found by walking to '\0', every timeStored: size() takes one step
Memory A fixed buffer that you size yourselfGrows by itself and frees itself
Copy and join strcpy and strcat, unchecked= and +, which never overflow
Compare text strcmp(a, b) == 0a == b
A '\0' inside the text Ends the stringAn ordinary character
When to choose Talking to C functions, and string literalsEvery string your own code builds, keeps or changes

Converting between C strings and std::string

Going from a C string to a std::string is easy and safe, because the string copies the characters. Going back needs care:

convert.cpp, and a std::string made from nullptr

C++ (convert.cpp) · convert.cpp

// From C strings to std::string and back.
#include <cstddef>
#include <cstring>
#include <print>
#include <string>
#include <string_view>

std::size_t c_api_length(const char* s) { return std::strlen(s); }   // stands for any function that takes a C string

int main() {
    const char* from_c = "Chennai";
    std::string s = from_c;                      // copies the characters up to the '\0'
    s += " Central";                             // s owns its memory and grows as needed
    std::println("{} ({} characters)", s, s.size());
    std::println("a C function sees {} characters", c_api_length(s.c_str()));   // c_str(): a '\0'-terminated copy-free view

    std::string_view view = from_c;              // no copy: a pointer and a length, found with strlen once
    std::println("view {} has {} characters", view, view.size());

    std::string with_zero("ab\0cd", 5);          // a std::string can hold '\0' as an ordinary character
    std::println("size {}, but std::strlen(c_str()) is {}", with_zero.size(), std::strlen(with_zero.c_str()));
}

C++ (from_null.cpp) · from_null.cpp

// A std::string made from a null pointer: undefined behaviour before C++23, a compile error since.
#include <string>

int main() {
    std::string s = nullptr;
    return static_cast<int>(s.size());
}
Both directions, and std::string from nullptr Python · convert_session.py
from session import run

# convert.cpp with both compilers, then a std::string made from nullptr, which C++23 refuses (grep keeps the error lines).
run("""
clang++ -std=c++23 -Wall -Wextra convert.cpp -o convert
./convert
g++-16 -std=c++23 -Wall -Wextra convert.cpp -o convert-gcc
./convert-gcc
clang++ -std=c++23 from_null.cpp -o from_null 2>&1 | grep "error:"
g++-16 -std=c++23 from_null.cpp -o from_null-gcc 2>&1 | grep "error:"
""")

Output

$ clang++ -std=c++23 -Wall -Wextra convert.cpp -o convert
$ ./convert
Chennai Central (15 characters)
a C function sees 15 characters
view Chennai has 7 characters
size 5, but std::strlen(c_str()) is 2
$ g++-16 -std=c++23 -Wall -Wextra convert.cpp -o convert-gcc
$ ./convert-gcc
Chennai Central (15 characters)
a C function sees 15 characters
view Chennai has 7 characters
size 5, but std::strlen(c_str()) is 2
$ clang++ -std=c++23 from_null.cpp -o from_null 2>&1 | grep "error:"
from_null.cpp:5:17: error: conversion function from 'std::nullptr_t' to 'std::string' (aka 'basic_string<char>') invokes a deleted function
$ g++-16 -std=c++23 from_null.cpp -o from_null-gcc 2>&1 | grep "error:"
from_null.cpp:5:21: error: use of deleted function 'std::__cxx11::basic_string<_CharT, _Traits, _Alloc>::basic_string(std::nullptr_t) [with _CharT = char; _Traits = std::char_traits<char>; _Alloc = std::allocator<char>; std::nullptr_t = std::nullptr_t]'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 convert_session.py

  • std::string s = from_c; copies the characters up to the '\0'; after that, s owns its own memory.
  • s.c_str() returns a pointer to the string’s characters followed by a '\0', ready for a function that expects a C string. It is not a copy, so it stays valid only while s exists and is not changed.
  • A std::string_view built from a C string runs strlen once and then remembers the length. Like a C string pointer, it does not own the characters.
  • A std::string may contain '\0' as an ordinary character: with_zero has 5 characters, but a C function given its c_str() sees only the 2 before the first terminator.
  • Making a std::string from a null pointer used to compile, with undefined behaviour, usually a crash, at run time. Since C++23 the standard deletes that constructor, so std::string s = nullptr; is a compile error in both compilers.

The pointer from c_str() is the one to watch. Here it outlives the string it points into:

lifetime.cpp C++ · lifetime.cpp
// c_str() points into the string's own memory: valid only while the string lives and is not changed.
#include <cstring>
#include <print>
#include <string>

std::string full_name(const char* first, const char* last) {
    return std::string(first) + " " + last;
}

int main() {
    std::string city = "Bengaluru";
    const char* p = city.c_str();       // fine: city is alive and unchanged
    std::println("{} has {} characters", p, std::strlen(p));

    const char* name = full_name("Venkata", "Ramanan Subramaniam").c_str();
    // The string returned by full_name was a temporary: it was destroyed at the end of the line above.
    std::println("{}", name);
}
A pointer into a destroyed string Python · lifetime_session.py
from session import run

# Clang warns while compiling; AddressSanitizer catches the read of the freed memory (key lines, addresses hidden).
run("""
clang++ -std=c++23 -g -Wall -Wextra -fsanitize=address lifetime.cpp -o lifetime
ASAN_OPTIONS=symbolize=0 ./lifetime 2>&1 | grep -E "ERROR|of size|located|freed by|allocated by" | sed -E 's/==[0-9]+==//; s/0x[0-9a-f]+/0x…/g'
""")

Output

$ clang++ -std=c++23 -g -Wall -Wextra -fsanitize=address lifetime.cpp -o lifetime
lifetime.cpp:15:24: warning: object backing the pointer will be destroyed at the end of the full-expression [-Wdangling-gsl]
   15 |     const char* name = full_name("Venkata", "Ramanan Subramaniam").c_str();
      |                        ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1 warning generated.
$ ASAN_OPTIONS=symbolize=0 ./lifetime 2>&1 | grep -E "ERROR|of size|located|freed by|allocated by" | sed -E 's/==[0-9]+==//; s/0x[0-9a-f]+/0x…/g'
ERROR: AddressSanitizer: heap-use-after-free on address 0x… at pc 0x… bp 0x… sp 0x…
READ of size 2 at 0x… thread T0
0x… is located 0 bytes inside of 48-byte region [0x…,0x…)
freed by thread T0 here:
previously allocated by thread T0 here:

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lifetime_session.py

full_name returns a temporary std::string, which is destroyed at the end of the line that called it, together with its buffer. Clang warns while compiling (-Wdangling-gsl), and AddressSanitizer reports the later read as a heap-use-after-free in a 48-byte region, the freed buffer of the 27-character name. GCC compiles the same file without a warning, so do not rely on one. Keep the std::string in a variable for as long as you use its c_str(), and call c_str() again after any change to the string.

Your turn: write strlen, strcmp and a bounded copy

Writing these three functions yourself is the quickest way to see the rules of this lesson from the inside: the walk to '\0', the comparison of byte values and the terminator that must always be there.

cstring_exercise.cpp C++ · cstring_exercise.cpp
// Exercise: three C-string functions. Write the bodies marked "your code here", then build and run this file:
//   clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined cstring_exercise.cpp -o cstring_exercise
//   ./cstring_exercise
// Every check must say "ok", and the sanitizers must report nothing. Do not call the <cstring> functions in your
// answers: the checks compare your functions with them.
#include <cstddef>
#include <cstring>
#include <print>

// The number of characters before the terminating '\0', like std::strlen.
std::size_t my_strlen(const char* s) {
    return 0;   // your code here
}

// Compares two C strings like std::strcmp: negative if a comes first, 0 if they are equal, positive if b comes first.
// Compare the characters as unsigned char values, as std::strcmp does.
int my_strcmp(const char* a, const char* b) {
    return 0;   // your code here
}

// Copies src into dest, which has room for size chars, and always ends dest with '\0': it copies at most size - 1
// characters, and with size 0 it writes nothing. Returns my_strlen(src), so a result >= size means the copy was cut.
std::size_t bounded_copy(char* dest, std::size_t size, const char* src) {
    return 0;   // your code here
}

int failures = 0;

void check(bool passed, const char* what) {
    std::println("{}  {}", passed ? "ok    " : "FAILED", what);
    if (!passed) ++failures;
}

int sign(int x) { return (x > 0) - (x < 0); }

int main() {
    const char* words[]{"", "a", "Pune", "Thiruvananthapuram"};
    bool lengths_ok = true;
    for (const char* w : words) lengths_ok = lengths_ok && my_strlen(w) == std::strlen(w);
    check(lengths_ok, "my_strlen agrees with std::strlen on \"\", \"a\", \"Pune\" and a long name");

    const char* pairs[][2]{{"apple", "banana"}, {"pune", "pune"}, {"abc", "ab"}, {"Zoo", "apple"}, {"", "x"}};
    bool order_ok = true;
    for (auto& p : pairs) order_ok = order_ok && sign(my_strcmp(p[0], p[1])) == sign(std::strcmp(p[0], p[1]));
    check(order_ok, "my_strcmp has the same sign as std::strcmp on five pairs");
    check(my_strcmp("caf\xc3\xa9", "cafe") > 0, "bytes above 127 compare as unsigned: \"café\" after \"cafe\"");

    char dest[8];
    std::size_t n = bounded_copy(dest, sizeof dest, "Bengaluru");
    check(n == 9 && std::strcmp(dest, "Bengalu") == 0, "\"Bengaluru\" into 8 bytes: \"Bengalu\", returns 9");
    n = bounded_copy(dest, sizeof dest, "Pune");
    check(n == 4 && std::strcmp(dest, "Pune") == 0, "\"Pune\" fits: copied whole, returns 4");
    char one[1]{'x'};
    n = bounded_copy(one, sizeof one, "Agra");
    check(n == 4 && one[0] == '\0', "size 1: only the terminator is written");
    char untouched[4]{'q', 'q', 'q', 'q'};
    n = bounded_copy(untouched, 0, "Agra");
    check(n == 4 && untouched[0] == 'q', "size 0: nothing is written");

    std::println("{} of 7 checks failed", failures);
    return failures == 0 ? 0 : 1;
}

As it starts, every function returns 0, so every check fails:

The starting point Python · exercise_session.py
from session import run

# The exercise as it starts: the functions return 0, so most checks fail.
run("""
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined cstring_exercise.cpp -o cstring_exercise
./cstring_exercise
""")

Output

$ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined cstring_exercise.cpp -o cstring_exercise
cstring_exercise.cpp:11:35: warning: unused parameter 's' [-Wunused-parameter]
   11 | std::size_t my_strlen(const char* s) {
      |                                   ^
cstring_exercise.cpp:17:27: warning: unused parameter 'a' [-Wunused-parameter]
   17 | int my_strcmp(const char* a, const char* b) {
      |                           ^
cstring_exercise.cpp:17:42: warning: unused parameter 'b' [-Wunused-parameter]
   17 | int my_strcmp(const char* a, const char* b) {
      |                                          ^
cstring_exercise.cpp:23:32: warning: unused parameter 'dest' [-Wunused-parameter]
   23 | std::size_t bounded_copy(char* dest, std::size_t size, const char* src) {
      |                                ^
cstring_exercise.cpp:23:50: warning: unused parameter 'size' [-Wunused-parameter]
   23 | std::size_t bounded_copy(char* dest, std::size_t size, const char* src) {
      |                                                  ^
cstring_exercise.cpp:23:68: warning: unused parameter 'src' [-Wunused-parameter]
   23 | std::size_t bounded_copy(char* dest, std::size_t size, const char* src) {
      |                                                                    ^
6 warnings generated.
$ ./cstring_exercise
FAILED  my_strlen agrees with std::strlen on "", "a", "Pune" and a long name
FAILED  my_strcmp has the same sign as std::strcmp on five pairs
FAILED  bytes above 127 compare as unsigned: "café" after "cafe"
FAILED  "Bengaluru" into 8 bytes: "Bengalu", returns 9
FAILED  "Pune" fits: copied whole, returns 4
FAILED  size 1: only the terminator is written
FAILED  size 0: nothing is written
7 of 7 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 exercise_session.py

You are done when it prints 0 of 7 checks failed and the sanitizers report nothing. Hints: in my_strcmp, stop at the first position where the characters differ or where a ends, then subtract the two characters converted to unsigned char; in bounded_copy, handle size == 0 first, and write the terminator after the last character you copy.

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.

  1. Question 1 of 5 char code[] = "IN-560001"; What is sizeof(code)?

    Type a number.

    Show the answer to question 1

    Answer: 10

    The literal has 9 characters, and the array also holds the terminating '\0', so it is 10 bytes. std::strlen(code) is 9: it counts the characters before the terminator.

  2. Question 2 of 5 const char* a = typed; and const char* b = stored; point to two arrays that both hold "pune". Which test is true exactly when they hold the same text?

    Choose one answer.

    Show the answer to question 2

    Answer: std::strcmp(a, b) == 0

    a == b compares two addresses, which differ for two arrays. *a == *b compares only the first characters, and equal lengths say nothing about the characters. std::strcmp compares character by character and returns 0 when the texts are the same.

  3. Question 3 of 5 char buf[6]; int n = std::snprintf(buf, sizeof buf, "%d runs", 120); The full text would be 120 runs. What is n?

    Type a number.

    Show the answer to question 3

    Answer: 8

    snprintf returns the length the whole text would have had, 8 characters, whatever the size of the buffer. It wrote only 120 r and the terminator. A return value that is not smaller than the buffer size means the text was cut short.

  4. Question 4 of 5 When does the pointer returned by s.c_str() stop being safe to use?

    Choose one answer.

    Show the answer to question 4

    Answer: When s is destroyed or changed by a call that can change its characters or its size

    c_str() returns a pointer into the string's own buffer, not a copy. Destroying the string frees that buffer, and changing the string can move its characters to a new buffer. The lesson's example used a pointer into a temporary string after the end of its line, and AddressSanitizer reported a heap-use-after-free.

  5. Question 5 of 5 What does C++ say about char* p = const_cast<char*>("pune"); p[0] = 'P';?

    Choose one answer.

    Show the answer to question 5

    Answer: Writing into a string literal is undefined behaviour

    A string literal is an array of const char that the program may not change; on this Mac the write crashed with SIGBUS because the literal sits in read-only memory. const_cast only silences the compiler. Copy the literal into an array, char copy[] = "pune";, or use std::string when you need to change the text.

Interview questions

Fresher · Why does strlen(“hello”) return 5 while sizeof(“hello”) is 6?

A string literal is an array of const char that holds its characters and then the terminating null character, '\0'. "hello" is therefore an array of 6 chars, and sizeof measures the whole array: 6 bytes. std::strlen does not know about arrays at all. It receives a pointer to the first character and counts characters until it meets the first '\0', which gives 5. The two also differ after a change: write a '\0' into the middle of a char array and strlen gets shorter, while sizeof stays the size of the array. And inside a function that receives the string as a const char*, sizeof gives the size of the pointer, 8 on a 64-bit computer, which is why only strlen can measure a C string there.

Key takeaways

  • A C string is chars followed by '\0'. std::strlen walks to that byte every time it is called, and it counts bytes: "café" is 5.
  • String literals are arrays of const char in read-only memory: point to them with const char*, and writing into one crashed both builds.
  • Compare text with std::strcmp(a, b) == 0. == compares addresses, and == between two arrays is an error in C++26.
  • std::strcpy and std::strcat never check the destination’s size; a 22-byte greeting in a 16-byte buffer gave a crash with Clang, silent damage with GCC and a stack-buffer-overflow report with AddressSanitizer.
  • std::snprintf never overflows, always terminates the text and returns the length it needed.
  • std::string stores its length, manages its memory and compares with ==; c_str() lends a C string that dies with the string or changes with it.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.