Рассогласованный std::string

С классом std::string знакомы наверное все программисты C++. Но далеко не все знают, что он может находиться в рассогласованном состоянии. Под ним я понимаю такое состояние, при котором различные способы определения длины одной и той же строки (например, метод size и функция strlen) дают разный результат. Возникать оно может по разным причинам. Но, как правило, основная причина — NULL символ в неожиданном месте.

Сегодня я хотел бы обсудить именно такие «нездоровые» состояния std::string. Мы посмотрим, как они возникают, и попытаемся понять, почему они выглядят именно так.

Дополнительно мы обсудим один весьма спорный момент, связанный с методом std::string::reserve.

Метод data

Начиная с C++17 метод data [1] может возвращать неконстантный указатель на внутренний буфер. С помощью этого указателя мы можем писать напрямую в буфер. Но к чему это может привести?

Ниже приводится пример неправильного использования этой возможности.

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

int main()
{
    constexpr char in_str[] = "Hello";
    const auto n_char = strlen(in_str) + 1;

    std::string out_str;
    out_str.reserve(n_char);

    std::strncpy(out_str.data(), in_str, n_char);

    std::cout << "out_str: \"" << out_str << "\"\n"
        << "size: " << out_str.size() << '\n'
        << "data: \"" << out_str.data() << "\"\n"
        << "c_str: \"" << out_str.c_str() << "\"\n"
        << "strlen: " << std::strlen(out_str.c_str()) << '\n';

    return 0;
}

// Output
// out_str: ""
// size: 0
// data: "Hello"
// c_str: "Hello"
// strlen: 5

Возникает два вопроса.

  1. Почему размер строки out_str равен нулю, хотя данные в ней есть?
  2. Почему при выводе out_str в std::cout отображается пустая строка, но при выводе этой же строки через указатель, она отображается правильно?

Ответ на первый (и частично второй) вопрос кроется во внутреннем устройстве класса std::string. Его суть можно записать так.

class string
{
public:
   using size_type = //...

   const char* c_str() const { return m_p_buffer; }
 
   char* data() { return m_p_buffer; }

   size_type size() const { return m_size; }

private:
   char *m_p_buffer;
   size_type m_size;
   size_type m_capacity; 
};

На самом деле std::string — это алиас для std::basic_string<> [2]. Но речь сейчас не об этом.

Главное здесь в том, что размер строки хранится в отдельном поле m_size (все имена полей условны, в реальности они могут называться иначе). Когда в нашем примере мы пишем в буфер m_p_buffer, мы меняем содержимое буфера, а размер строки (значение поля m_size) при этом не меняется. Поэтому мы и получаем такое рассогласованное состояние.

Вообще говоря, хранение размера строки в отдельном поле необязательно. Стандарт не требует именно такого устройства std::string. Но он требует, чтобы метод size выполнялся за константное время [3]. То есть метод не должен зависеть от длины строки. Добиться этого можно разными способами, но все они требуют использования дополнительных полей.

Например, вместо размера строки можно хранить указатель на её конец, тогда длина строки будет определяться как разность указателей. Но даже при такой реализации std::string изменение содержимого буфера не влечет за собой изменения указателя на конец строки.

Метод reserve

Возможно Вы заметили, что в примере выше был вызван метод reserve, и в него была передана длина строки плюс 1. Почему было так сделано? На самом деле это перестраховка с моей стороны. В описании метода reserve [4] говорится:

If new_cap is greater than the current capacity(), new storage is allocated, and capacity() is made equal or greater than new_cap.

Или в моём переводе:

Если новая ёмкость больше текущей ёмкости, выделяется новое хранилище, и ёмкость становится равной или больше заданной.

Формулировка «равной или больше» («equal or greater«) и порождает вопросы. Мы же помним, что строка должна дополнительно хранить NULL символ. И здесь непонятно, reserve резервирует под него место или нет.

В обсуждении этого вопроса [5] приводятся доводы с обеих сторон.

Доводы в пользу того, что метод резервирует место под NULL символ.

  • Во-первых, начиная с C++11 метод data должен возвращать указатель на буфер, заканчивающийся NULL символом («The returned array is null-terminated«) [1]. Во-вторых, этот же метод должен работать за константное время. Добиться этого, по мнению участников обсуждения, можно путём автоматического резервирования места под NULL символ.
  • Текущие (на момент написания комментариев в обсуждении) реализации GNU C++ library и MSVC2013 автоматически добавляют единицу к запрашиваемой ёмкости.

Главная причина сомнений — это неоднозначная формулировка стандарта («equal or greater«). Почему в нём не написано: «strictly greater» (строго больше)?

Что я думаю по этому поводу?

  • Да, буфер, указатель на который возвращает метод data, должен завершаться NULL символом. Но нигде не говорится о том, кто именно должен беспокоиться об этом символе. При традиционных способах записи в этот буфер за наличие NULL символа отвечают соответствующие методы записи (assign, append и др.). Они должны гарантировать, что результирующая строка будет завершаться NULL символом.
  • Я легко могу представить реализацию std::string, в которой метод reserve не выделяет дополнительное место под NULL символ, но при этом метод data работает за константное время и возвращает указатель на буфер, завершающийся NULL символом. Да, это будет не самая лучшая реализация, но, тем не менее, она будет соответствовать стандарту.
  • То, что в приведенных реализациях стандартной библиотеки место под NULL символ резервируется, нельзя считать убедительным доводом. Во-первых, существуют другие реализации. И не факт, что в них сделано точно также. Во-вторых, реализация может измениться со временем. Об этом напоминает спор felix-gcc и Andrew Pinski, приведённый в статье «Си должен умереть» [6].

Исходя из вышесказанного, я предполагаю наименее благоприятный для нас вариант реализации: метод reserve не выделяет место под NULL символ. Возможно, я дую на воду. Но, как по мне, лучше иметь чуть более сложный код и использовать лишний байт памяти, чем однажды нарваться на переполнение буфера.

Метод resize

Метод resize [7] позволяет изменять значение условного поля m_size. Но его использование тоже может приводить к неочевидным результатам.

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

int main()
{
    std::string str = "123";
    str.resize(10);

    std::cout << "out_str: \"" << str << "\"\n"
        << "size: " << str.size() << '\n'
        << "data: \"" << str.data() << "\"\n"
        << "c_str: \"" << str.c_str() << "\"\n"
        << "strlen: " << std::strlen(str.c_str()) << '\n';

    return 0;
}

// Output
// out_str: "123"
// size: 10
// data: "123"
// c_str: "123"
// strlen: 3

Обратите внимание: одна и та же строка имеет различный размер в зависимости от того, как его измерять. Метод size возвращает длину 10 символов, а вывод самой строки и функция strlen говорят о том, что длина строки всего 3 символа. Кому верить?

Подвох заключается в том, что метод resize, если не задан его второй параметр, дополняет строку до нужного размера NULL символами.

Метод size возвращает значение условного поля m_size, которое действительно равно 10. Функция strlen, в свою очередь, считает количество символов до первого NULL символа. И получает результат 3.

Вывод в std::cout, судя по приведённым примерам, работает так.

  1. Определяется длина выводимой строки. Для этого используется метод size.
  2. Если размер равен нулю — ничего не выводится (ответ на второй вопрос из раздела «метод data»).
  3. Если размер отличен от нуля — выводятся все символы кроме NULL символов (см. раздел «NULL символ в середине строки» ниже).

Получается, функцию вывода строки в std::cout можно записать так (грубая реализация).

std::ostream& operator << (std::ostream &os, const std::string &str)
{
    for(int i = 0; i < str.size(); ++i)
        if(str[i] != '\0')
            os << str[i];    

    return os;
}

Интересно посмотреть, как работает метод resize в случае уменьшения размера строки.

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

int main()
{
    std::string str = "123456789";
    str.resize(3);

    std::cout << "str: \"" << str << "\"\n"
        << "size: " << str.size() << '\n'
        << "data: \"" << str.data() << "\"\n"
        << "c_str: \"" << str.c_str() << "\"\n"
        << "strlen: " << std::strlen(str.c_str()) << '\n'
        << "data+4: \"" << str.data()+4 << "\"\n";

    return 0;
}

// Output
// str: "123"
// size: 3
// data: "123"
// c_str: "123"
// strlen: 3
// data+4: "56789"

Из вывода этого примера видно, что метод resize сделал всего две вещи: изменил значение внутреннего поля m_size и записал NULL символ в конец новой усеченной строки.

Правильная запись в буфер

Вернёмся к нашей задаче с прямой записью в строковый буфер. Как сделать это правильно? Как добиться согласованного состояния строки? Ниже приводится два варианта решения этой проблемы.

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

int main()
{
    constexpr char in_str[] = "Hello";
    const auto n_char = strlen(in_str);

    //variant 1:
    std::string out_str(n_char, '\0');

    //variant 2:
    //std::string out_str;
    //out_str.resize(n_char);

    std::strncpy(out_str.data(), in_str, n_char);

    std::cout << "out_str: \"" << out_str << "\"\n"
        << "size: " << out_str.size() << '\n'
        << "data: \"" << out_str.data() << "\"\n"
        << "c_str: \"" << out_str.c_str() << "\"\n"
        << "strlen: " << std::strlen(out_str.c_str()) << '\n';

    return 0;
}

// Output
// out_str: "Hello"
// size: 5
// data: "Hello"
// c_str: "Hello"
// strlen: 5

Первый вариант основан на использовании специального конструктора класса std::string [8]. Он принимает символ и количество копий этого символа, которые должны сформировать строку. Второй вариант основан на использовании метода resize, и, по сути, аналогичен первому.

Обратите внимание, мы используем только количество символов в строке, без его увеличения на 1 для NULL символа. Объясняется это тем, что конструктор и метод resize формируют строку, которая по стандарту должна завершаться NULL символом. Рассматривавшийся ранее метод reserve не формирует строку, он только выделяет под неё память.

Для устранения сомнений, в качестве символа-наполнителя можно использовать, например, символ 'A'. В этом случае out_str до вызова strncpy будет содержать строку "AAAAA" с NULL символом на конце.

NULL символ в середине строки

Рассмотрим вот такой пример.

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

int main()
{
    std::string str = "Hello";

    str[2] = '\0';

    std::cout << "str: \"" << str << "\"\n"
        << "size: " << str.size() << '\n'
        << "data: \"" << str.data() << "\"\n"
        << "c_str: \"" << str.c_str() << "\"\n"
        << "strlen: " << std::strlen(str.c_str()) << '\n'
        << "data+3: \"" << str.data()+3 << "\"\n";

    return 0;
}

// Output
// str: "Helo"
// size: 5
// data: "He"
// c_str: "He"
// strlen: 2
// data+3: "lo"

В этом примере мы записали NULL символ в середину строки. С учетом всего вышесказанного, результат работы этой программы должен быть для Вас ожидаемым.

UTF-8

Напоследок напомню, что метод size и функция strlen считают количество однобайтовых символов. Поэтому их применение к многобайтовым кодировкам может приводить к неожиданным результатам.

Ниже приводится как раз такой пример.

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

int main()
{
    std::string str = u8"Привет";

    std::cout << "str: \"" << str << "\"\n"
        << "size: " << str.size() << '\n'
        << "data: \"" << str.data() << "\"\n"
        << "c_str: \"" << str.c_str() << "\"\n"
        << "strlen: " << std::strlen(str.c_str()) << '\n';

    return 0;
}

// Output
// str: "Привет"
// size: 12
// data: "Привет"
// c_str: "Привет"
// strlen: 12

Вывод этой программы станет понятным, если вспомнить, что каждому кириллическому символу в кодировке UTF-8 соответствует два байта.

Кстати, начиная с C++20 пример выше не скомпилируется. Объясняется это тем, что до C++20 префикс u8 приводил литерал к типу const char[N], а начиная с C++20 — к типу const char8_t[N] [9].

Заключение

Работа со строками. С ней сталкиваются все программисты, начиная с самых первых дней погружения в эту профессию. В языках C/C++ она всегда сопровождалась болью и страданиями.

Строки в языке C требовали (и требуют) большой аккуратности со стороны программиста. Ему необходимо внимательно следить и за размером буфера, в котором располагается строка, и за NULL символом на конце строки.

В C++ появился std::string. Он должен был спасти программиста от головной боли, связанной со строками. И частично ему удалось это сделать. В большинстве случаев программисту теперь не нужно переживать о размере буфера и NULL символе. Но…

К сожалению std::string не стал панацеей. В нём могут возникать рассогласованные состояния, которые могут сбить с толку даже опытного программиста. Что уж говорить про новичков?

Сегодня я описал несколько ситуаций, приводящих к рассогласованному состоянию std::string. Мы посмотрели, когда и почему они возникают.

Все примеры проверялись на трёх компиляторах (с использованием godbolt): GCC, clang и MSVC . На всех получался один и тот же результат. Но нужно помнить, что в ряде случаев такое поведение не гарантируется стандартом, поэтому может отличаться на других компиляторах или даже на этих же компиляторах, но других версий.

Надеюсь, теперь классу std::string будет сложнее удивить Вас.

Ссылки

  1. Метод data: https://en.cppreference.com/w/cpp/string/basic_string/data.html
  2. std::string и std::basic_string: https://en.cppreference.com/w/cpp/string/basic_string.html
  3. Метод size: https://en.cppreference.com/w/cpp/string/basic_string/size.html
  4. Метод reserve: https://en.cppreference.com/w/cpp/string/basic_string/reserve.html
  5. Обсуждение вопроса о резервировании методом reserve места под NULL символ: https://stackoverflow.com/questions/30111288/stdstringreserve-and-end-of-string-0
  6. Статья Никиты Орлова «Си должен умереть» (спор felix-gcc и Andrew Pinski приводится в конце этой статьи): https://habr.com/ru/articles/592233/
  7. Метод resize: https://en.cppreference.com/w/cpp/string/basic_string/resize.html
  8. Конструкторы класса std::string: https://en.cppreference.com/w/cpp/string/basic_string/basic_string.html
  9. Префиксы строковых литералов: https://en.cppreference.com/w/cpp/language/string_literal.html

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *