C++ Strings

A string is a sequence of characters used to represent text — names, sentences, file paths, anything made of letters, digits, and symbols. In C++ you almost always work with strings through the std::string class from the <string> header, which manages a resizable buffer of characters for you. This lesson covers how std::string works internally, how to create and manipulate strings, and the classic mistakes that trip up beginners moving from other languages or from raw C-style character arrays.

Overview / How Strings Work

C++ actually has two ways to represent text, and understanding the difference matters. A C-style string is a plain array of char values terminated by a special null byte ('\0') that marks the end. You manipulate it with pointer arithmetic and functions like strlen and strcpy from <cstring>. It is fast but dangerous: there is no bounds checking, no automatic memory management, and it is easy to write past the end of the buffer.

std::string, part of the C++ Standard Library, wraps that raw buffer in a safe, resizable object. Internally, a std::string stores a pointer to a heap-allocated character buffer, a length (how many characters are actually stored), and a capacity (how much space is currently allocated). When you append characters and the buffer runs out of room, the string allocates a larger buffer — typically by doubling capacity — copies the old characters over, and frees the old buffer. This happens automatically; you never call malloc or delete yourself.

Most modern implementations (including the one shipped with g++, libstdc++) also apply the Small String Optimization (SSO): short strings (commonly up to 15 characters on a 64-bit system) are stored directly inside the std::string object itself, with no heap allocation at all. This makes short strings extremely fast to create and copy. Once a string grows past that internal buffer, the implementation transparently switches to heap allocation. You don’t need to do anything special to benefit from this — it’s an implementation detail, but it explains why short strings are so cheap in C++.

Because std::string manages its own memory, it supports value semantics: copying a string copies its contents (or, since C++11, a move can transfer ownership without copying at all when the source is a temporary). This makes strings safe to pass around, store in containers like std::vector<std::string>, and return from functions, without the memory-management headaches of raw C-strings.

Syntax

Here is how you declare, initialize, and read strings:

#include <string>
using namespace std;

string s1;                  // empty string
string s2 = "Hello";        // initialize from a literal
string s3("World");         // constructor syntax
string s4(5, 'x');          // "xxxxx" - 5 copies of 'x'
string s5 = s2 + " " + s3;  // concatenation

cin >> s1;                  // reads one whitespace-delimited word
getline(cin, s1);           // reads an entire line, including spaces

Key members you will use constantly:

Expression Meaning
s.length() / s.size() Number of characters currently stored (identical, both provided for convenience).
s.empty() True if the string has zero characters.
s[i] Access character at index i. No bounds checking (undefined behavior if out of range).
s.at(i) Same as [] but throws std::out_of_range if i is invalid.
s.substr(pos, len) Returns a new string copying len characters starting at pos.
s.find(text) Returns the index of the first match, or string::npos if not found.
s.replace(pos, len, text) Replaces len characters starting at pos with text.
s.insert(pos, text) Inserts text before index pos.
s.erase(pos, len) Removes len characters starting at pos.
s.c_str() Returns a read-only, null-terminated C-string view of the data (for APIs that need const char*).
string::npos A special constant meaning “no position” / “until the end” — used to detect failed searches.

Examples

Example 1: Building and inspecting a string

#include <iostream>
#include <string>
using namespace std;

int main() {
    string first = "Ada";
    string last = "Lovelace";
    string full = first + " " + last;

    cout << "Full name: " << full << endl;
    cout << "Length: " << full.length() << endl;
    cout << "First character: " << full[0] << endl;
    cout << "Last character: " << full.back() << endl;

    full += "!";
    cout << "With exclamation: " << full << endl;

    return 0;
}

Output:

Full name: Ada Lovelace
Length: 12
First character: A
Last character: e
With exclamation: Ada Lovelace!

The + operator concatenates strings (and can mix in literals), length() counts characters, [0] grabs the first character, and back() returns the last one. += appends in place, growing the internal buffer if needed.

Example 2: Searching, extracting, and editing

#include <iostream>
#include <string>
using namespace std;

int main() {
    string sentence = "The quick brown fox jumps";

    size_t pos = sentence.find("brown");
    if (pos != string::npos) {
        cout << "'brown' found at index " << pos << endl;
    }

    string word = sentence.substr(pos, 5);
    cout << "Extracted word: " << word << endl;

    sentence.replace(pos, 5, "red");
    cout << "After replace: " << sentence << endl;

    sentence.insert(0, "Look! ");
    cout << "After insert: " << sentence << endl;

    sentence.erase(0, 6);
    cout << "After erase: " << sentence << endl;

    return 0;
}

Output:

'brown' found at index 10
Extracted word: brown
After replace: The quick red fox jumps
After insert: Look! The quick red fox jumps
After erase: The quick red fox jumps

find locates the substring’s starting index; always compare the result against string::npos before using it, since a failed search returns that special value rather than -1. substr, replace, insert, and erase all operate on positions and lengths, and each returns or mutates the string accordingly.

Example 3: Parsing structured text

#include <iostream>
#include <string>
#include <sstream>
#include <vector>
using namespace std;

int main() {
    string csvLine = "Alice,29,Engineer";
    stringstream ss(csvLine);
    string token;
    vector<string> fields;

    while (getline(ss, token, ',')) {
        fields.push_back(token);
    }

    cout << "Name: " << fields[0] << endl;
    cout << "Age: " << fields[1] << endl;
    cout << "Job: " << fields[2] << endl;

    string reversed(fields[0].rbegin(), fields[0].rend());
    cout << "Reversed name: " << reversed << endl;

    return 0;
}

Output:

Name: Alice
Age: 29
Job: Engineer
Reversed name: ecilA

A stringstream lets you treat a string as an input stream. The three-argument getline(stream, token, delimiter) overload splits on a custom character (here a comma) instead of a newline — a very common way to parse CSV-style data. The reversed-name line builds a new string from reverse iterators (rbegin()/rend()), a compact idiom for reversing without writing a loop.

Under the Hood

When you write s1 = s2, the string class allocates its own buffer and copies characters from s2 — the two strings are completely independent afterward. When you write s1 + s2, a brand-new string object is created large enough to hold both, and both operands are copied into it; chaining many + operations in a loop therefore creates and destroys many temporary strings, which is why repeated += (appending in place) is more efficient for building up long strings than repeated +.

Capacity growth is amortized: each time a string outgrows its buffer, the implementation typically allocates roughly double the previous capacity rather than exactly what’s needed right now. That means appending one character at a time is still efficient on average, because reallocations become rarer as the string grows. You can see (and control) this with s.capacity(), and pre-allocate space with s.reserve(n) if you know roughly how large a string will get, avoiding repeated reallocations.

Comparison operators (==, <, >, etc.) compare strings lexicographically — character by character, using each character’s underlying integer (ASCII/Unicode code point) value, the same way a dictionary orders words. This is different from comparing the strings as numbers, which is a frequent source of bugs (see Common Mistakes below).

Common Mistakes

Mistake 1: Assuming string comparison is numeric. Beginners often compare numeric-looking strings expecting numeric ordering, but std::string compares character by character.

#include <iostream>
#include <string>
using namespace std;

int main() {
    string a = "10";
    string b = "9";
    if (a < b) {
        cout << a << " is less than " << b << endl;
    } else {
        cout << a << " is not less than " << b << endl;
    }
    return 0;
}

Output:

10 is less than 9

This looks wrong at first glance, but it’s correct: comparing "10" and "9" compares '1' to '9' first, and '1' (ASCII 49) is less than '9' (ASCII 57), so "10" < "9" is true. If you actually want numeric comparison, convert first with stoi:

#include <iostream>
#include <string>
using namespace std;

int main() {
    string a = "10";
    string b = "9";
    if (stoi(a) < stoi(b)) {
        cout << a << " is less than " << b << endl;
    } else {
        cout << a << " is not less than " << b << endl;
    }
    return 0;
}

Output:

10 is not less than 9

Mistake 2: Using operator[] for out-of-range access. s[i] performs no bounds checking, so reading or writing past the end is undefined behavior — it might silently return garbage, corrupt memory, or crash unpredictably, and you can’t rely on any particular symptom to catch it. Use at() when the index isn’t guaranteed to be valid; it throws a catchable exception instead:

#include <iostream>
#include <string>
#include <stdexcept>
using namespace std;

int main() {
    string word = "Cat";
    try {
        cout << word.at(10) << endl;
    } catch (const out_of_range& e) {
        cout << "Caught exception: " << e.what() << endl;
    }
    return 0;
}

Output:

Caught exception: basic_string::at: __n (which is 10) >= this->size() (which is 3)

Reserve [] for cases where you have already verified the index is valid (for example, a loop bounded by s.length()), and prefer at() whenever the index comes from outside input or user-controlled data.

Best Practices

  • Prefer std::string over raw C-style char arrays for almost all text handling — it manages memory safely and provides far richer operations.
  • Always compare the result of find() against string::npos before using it as an index; ignoring this is one of the most common bugs in C++ string code.
  • Use getline(cin, s) instead of cin >> s whenever the input may contain spaces (full names, sentences, file paths).
  • Use reserve() before building up a very large string in a loop if you have a rough size estimate, to avoid repeated reallocations.
  • Use at() instead of [] when the index isn’t provably in range (e.g., derived from user input).
  • Pass strings by const string& in function parameters unless you need a modifiable copy, to avoid unnecessary copying.
  • Use c_str() only when interfacing with an API that specifically requires a const char*; otherwise stay in std::string.

Practice Exercises

Exercise 1: Write a program that reads a full sentence with getline and prints how many times the letter 'e' appears in it (case-insensitive).

Exercise 2: Given the string "2026-07-18", use substr and find (searching for '-') to print the year, month, and day separately, without hardcoding the positions of the dashes.

Exercise 3: Write a function-style snippet that takes a string and returns a new string with every word’s first letter capitalized (e.g., "hello world" becomes "Hello World"). Hint: walk the string and capitalize the character right after each space and at index 0.

Summary

  • std::string from <string> is the safe, resizable way to work with text in C++, in contrast to raw null-terminated C-style char arrays.
  • Internally, strings use heap allocation with amortized doubling for growth, plus a Small String Optimization that avoids heap allocation entirely for short strings.
  • Core operations include length(), substr(), find(), replace(), insert(), and erase(); always check find() results against string::npos.
  • Use getline() for whitespace-containing input and stringstream for parsing delimited data.
  • String comparisons are lexicographic, not numeric — convert with stoi/stod when you need numeric ordering.
  • Prefer at() over [] when index validity isn’t guaranteed, since [] gives undefined behavior on out-of-range access.