Строки в C++: std::string и C-строки

Строки в C++: std::string и C-строки Полезное

Строки в C++ — это последовательности символов, но хранить их можно двумя принципиально разными способами: низкоуровневая C-строка (массив char[], который заканчивается нулевым байтом '\0') и высокоуровневый класс std::string из заголовка <string>, который сам управляет памятью и хранит длину явно, отдельным числом. В статье разберем, чем они отличаются на практике, как создавать и менять строки через std::string, и на какой типичной ошибке с длиной чаще всего спотыкаются новички.

Два вида строк: C-строка и std::string

C-строка пришла в C++ по наследству от языка C: область памяти типа char, где символы идут подряд, а конец помечен байтом '\0' (нуль-терминатор). Длину такой строки каждый раз вычисляет функция strlen() из <cstring>, идя по байтам до '\0'.

std::string устроен иначе: класс хранит указатель на буфер И текущую длину отдельным числом, поэтому size() не пересчитывает строку заново. Память под буфер std::string выделяет и освобождает сам (RAII) — вручную new/delete вызывать не нужно.

Термин Что это Как узнается длина Управление памятью
C-строка char[] или char*, обязателен '\0' в конце strlen(), поиск до первого '\0' вручную (стек или ручной new/delete)
std::string класс из <string>, буфер + сохраненная длина size()/length(), мгновенно автоматическое (RAII)

Строка с нулевым байтом внутри данных, а не только в конце, ведет себя по-разному в двух мирах — ниже это видно на конкретном примере.

Создание строк: конструкторы std::string

У std::string несколько полезных конструкторов — пустая строка, строка из литерала, строка из n одинаковых символов, строка как часть другой строки.

#include <iostream>
#include <string>

int main() {
    std::string s1;                  // пустая строка, s1.size() == 0
    std::string s2 = "hello";        // из строкового литерала (C-строки), 5 символов
    std::string s3(10, 'z');         // явный вызов конструктора: 10 символов 'z'
    std::string s4(s2, 1, 3);        // копия части s2: с индекса 1, длиной 3 символа

    std::cout << "s1='" << s1 << "' size=" << s1.size() << "\n";
    std::cout << "s2='" << s2 << "' size=" << s2.size() << "\n";
    std::cout << "s3='" << s3 << "'\n";
    std::cout << "s4='" << s4 << "'\n";
}

Вывод:

s1='' size=0
s2='hello' size=5
s3='zzzzzzzzzz'
s4='ell'

s4 — это часть s2 («hello»): начиная с индекса 1 (буква ‘e’) и длиной 3 символа получаем «ell».

Длина и доступ к символам: size() против strlen()

К отдельному символу строки можно обратиться по индексу, как в массиве: s[0] — первый символ (нумерация с нуля), s[s.size() - 1] — последний. У std::string есть и безопасный вариант s.at(i), который бросает исключение std::out_of_range при выходе за границы, тогда как s[i] при неверном индексе — неопределенное поведение (UB), программа может не упасть сразу, а тихо прочитать чужую память.

Разница между size() и strlen() не только в скорости, но и в смысле — покажем это на строке со встроенным нулевым байтом:

#include <cstring>
#include <iostream>
#include <string>

int main() {
    const char* c_str = "AB\0CD";      // C-строка: strlen остановится на первом '\0'
    std::string cpp_str("AB\0CD", 5);  // std::string: явно хранит 5 байт, включая '\0' внутри

    std::cout << "strlen(c_str) = " << std::strlen(c_str) << "\n";
    std::cout << "cpp_str.size() = " << cpp_str.size() << "\n";
}

Вывод:

strlen(c_str) = 2
cpp_str.size() = 5

Для C-строки нулевой байт внутри данных — конец строки, все после него strlen() не видит («AB» — 2 символа). Для std::string нулевой байт — просто один из символов, длина задана явно и равна 5. Вывод: если в данных возможны нулевые байты (например, бинарные данные), std::string подходит, а обычная C-строка — нет.

Беззнаковая длина: типичная ошибка в цикле

size() возвращает значение беззнакового типа std::size_t (как и все методы, возвращающие длину или индекс). Это создает ловушку при вычитании из пустой или короткой строки.

#include <iostream>
#include <string>

int main() {
    std::string s;                    // пустая строка, s.size() == 0
    std::size_t bad = s.size() - 1;   // беззнаковое вычитание: 0 - 1 не "-1", а переполнение

    std::cout << "s.size() = " << s.size() << "\n";
    std::cout << "s.size() - 1 = " << bad << "\n";
}

Вывод (для 64-битного std::size_t, платформозависимо):

s.size() = 0
s.size() - 1 = 18446744073709551615

В цикле for (std::size_t i = 0; i < s.size() - 1; ++i) для пустой строки условие не станет ложным сразу: цикл попытается пройти почти по всему адресному пространству и обратится к памяти за пределами строки (UB). Исправление — не вычитать из размера, а прибавлять к индексу: for (std::size_t i = 0; i + 1 < s.size(); ++i) — для пустой строки такое условие сразу ложно.

Ввод и вывод строк

Вывод строки в поток работает как для чисел, через <<. Для ввода есть два инструмента с разным поведением: >> читает одно «слово» до пробела, а getline() — всю строку целиком, включая пробелы внутри.

#include <iostream>
#include <string>

int main() {
    std::string word;
    std::string line;

    std::cin >> word;               // читает до первого пробельного символа
    std::cin.ignore();              // пропускаем один оставшийся разделитель
    std::getline(std::cin, line);   // читает всю оставшуюся строку до конца строки

    std::cout << "word='" << word << "'\n";
    std::cout << "line='" << line << "'\n";
}

Для ввода Hello world from Otus (одна строка, Enter в конце) программа выведет:

word='Hello'
line='world from Otus'

cin >> word останавливается на пробеле, cin.ignore() убирает этот пробел, а getline() дочитывает остаток целиком. Связка нужна, когда после слова или числа нужен остаток строки без потери пробелов.

Сравнение и конкатенация: операторы

std::string перегружает привычные операторы, поэтому со строками можно работать почти как с числами.

Оператор Что делает
= присваивание значения
+= добавление в конец символа, C-строки или другой std::string
+ конкатенация (склеивание) двух строк или строки и символа
==, != посимвольное сравнение на равенство
<, >, <=, >= лексикографическое сравнение (по кодам символов, как в словаре)

Например, для std::string a = "abc" и b = "abd": a == b дает false, a < b дает true (строки совпадают до третьего символа, а 'c' идет раньше 'd'), а a + b дает "abcabd" — склеивание, а не арифметическое сложение.

Изменение строки: resize, clear, empty, push_back, append

std::string можно менять на месте, без пересоздания.

Метод Что делает
resize(n) меняет длину на n: обрезает лишнее или дополняет символами по умолчанию
resize(n, c) то же, но новые позиции при увеличении заполняются символом c
clear() делает строку пустой
empty() true, если строка пустая
push_back(c) добавляет один символ char в конец
append(n, c) добавляет n одинаковых символов c
append(t) добавляет содержимое строки t (C-строки или std::string)
append(t, pos, count) добавляет часть t с индекса pos, длиной count символов
#include <iostream>
#include <string>

int main() {
    std::string s = "cpp";
    s.push_back('!');            // один символ в конец
    s.append(3, '?');            // три одинаковых символа в конец
    std::cout << "1) " << s << " (size=" << s.size() << ")\n";

    s.resize(4);                  // обрезали строку до 4 символов
    std::cout << "2) " << s << "\n";

    s.resize(7, '_');             // увеличили строку, новые символы '_'
    std::cout << "3) " << s << "\n";

    std::cout << std::boolalpha;
    std::cout << "4) empty() = " << s.empty() << "\n";
    s.clear();
    std::cout << "5) empty() = " << s.empty() << "\n";
}

Вывод:

1) cpp!??? (size=7)
2) cpp!
3) cpp!___
4) empty() = false
5) empty() = true

Совместимость с C: метод c_str()

Многие функции C ждут указатель const char*, а не объект std::string. Для этого есть метод c_str(), отдающий указатель на внутренний буфер с гарантированным '\0' в конце.

#include <cstring>
#include <iostream>
#include <string>

void print_c_string(const char* text) {   // старая C-функция, ждет char*
    std::cout << "C-функция получила: " << text << " (strlen=" << std::strlen(text) << ")\n";
}

int main() {
    std::string s = "Otus";
    print_c_string(s.c_str());   // c_str() отдает буфер, заканчивающийся '\0'
}

Вывод:

C-функция получила: Otus (strlen=4)

Граница: указатель от c_str() действителен только до следующего изменения строки (resize, append и подобные методы могут переместить буфер). Сохранять его «про запас» и использовать после изменения строки — ошибка.

Выводы

  • C-строка (char[], заканчивается '\0') и std::string (буфер + явная длина) — разные сущности, особенно при нулевых байтах внутри данных.
  • size()/length() возвращают сохраненную длину мгновенно, strlen() у C-строки каждый раз идет по байтам до '\0'.
  • Длина строки — беззнаковый тип; вычитание из size() при короткой строке может дать переполнение вместо отрицательного числа.
  • cin >> s читает слово до пробела, getline() — строку целиком; c_str() дает совместимость с C, но указатель живет только до следующего изменения строки.

Где применяется / связь с практикой

Освойте тему на практике

Работа со строками — база почти любой программы на C++: разбор ввода, форматирование вывода, обработка файлов и логов. На курсе C++ Developer эти темы разбирают на практике — от конструкторов std::string до ошибок с памятью и границами из примеров выше.

Освойте тему на практике

Если пока не готовы к полному курсу, можно начать с открытых уроков Otus — там разбирают практические задачи по C++.

FAQ

Можно ли сравнивать std::string и C-строку (char*) напрямую?
Да. У std::string операторы ==, < и другие перегружены и для const char*: C-строка автоматически участвует в сравнении по содержимому, отдельно приводить типы не нужно.

Что быстрее — std::string или char* (C-строка)?
Однозначно не ответить: современные реализации std::string для коротких строк используют small string optimization и не обращаются к куче, поэтому разница в реальных программах часто мала. Точный выигрыш зависит от компилятора и сценария — решать профилированием, а не общим правилом.

Как убрать пробелы в начале и конце строки?
Готового метода «обрезать пробелы» у std::string в стандартной библиотеке C++ до C++23 нет. Обычно ищут первый и последний непробельный символ через find_first_not_of/find_last_not_of и вырезают часть методом substr().

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)