В начале 70-х Кен Томпсон, Деннис Ритчи и Брайан Керниган, работая над первыми версиями C и Unix, приняли решение, которое отзывается болью, страданиями, багами и неэффективностью до сих пор, спустя 50 лет. Они решили, строки, как данные переменной длины, нужно представлять в виде последовательности, заканчивающейся терминирующим символом -- нулем. Так делали в ассемблере, а C ведь высокоуровневый ассемблер! Да и памяти у старенького PDP не много: лучше всего один байтик лишний на строку, чем 2, 4, а то и все 8 байтов для хранения размера в зависимости от платформы... Не, лучше байтик в конце! Но в других языках почему-то предпочли хранить размер и ссылку/указатель на данные...
Ну что ж, посмотрим, к чему это привело
Единственный способ узнать длину NULL-термирированной строки -- пройтись по ней и посчитать символы. Это требует линейного времени от длины строки.
const char* str = ...;
for (size_t i = 0 ; i < strlen(str); ++i) {
...
}
И вот уже этот простенький цикл требует не линейного числа операций, а квадратичного. Это известный пример. Про него даже известно, что умный компилятор способен вынести вычисление длины строки из цикла
const char* str = ...;
const size_t len = strlen(str);
for (size_t i =0 ; i < len; ++i) {
...
}
Но ведь пример может быть и посложнее. В коде одной там популярной игры про деньги, разборки мафии и угон автомобилей обнаружили занятный пример парсинга большого массива чисел из json-строки с помощью sscanf
Выглядел он примерно (его получили путем реверс-инженеринга конечного бинарного файла) так
const char* config = ...;
size_t N = ...;
for (size_t i =0 ; i < N; ++i) {
int value = 0;
size_t parsed = sscanf(config, "%d", &value);
if parsed > 0 {
config += parsed;
}
}
Прекрасный и замечательный цикл! Тело его выполняется всего N
раз, но на большинстве версий стандартной библиотеки C каждый раз требует strlen(config)
операций на интерацию. Ведь sscanf
должен посчитать длину строки, чтоб случайно не выйти за ее пределы! А строка NULL-терминированная.
Вычисление длины строки -- невероятно часто всречающаяся операция. И один из самых первых кандидатов на оптимизацию -- посчитать ее один раз и хранить со строкою... Но зачем тогда NULL-терминатор? Только лишний байт в памяти!
C++ высокоуровневый язык! Уж повыше C, конечно. Стандартные строки в нем уже, учтя ошибку C, хранятся как размер + указатель на данные. Ура!
Но не совсем ура. Ведь огромное число C библиотек никуда не денется, и у большинства из них в интерфейсах используются NULL-терминированные строки.
Поэтому std::string
тоже обязательно NULL-терминированные. Поздравляю, мы храним один лишний байт ради совместимости. А еще мы его храним неявно: std::string::capacity()
на самом деле всегда на 1 меньше действительно выделенного блока памяти.
"Используйте std::string_view
в своих API и вам не придется писать перегрузки для const char*
и const std::string&
чтобы избежать лишнего копирования!"
Ага, конечно.
std::string_view
это тоже указатель + длина строки. Но уже, в отличие от std::string
, указатель не обязательно на NULL-терминированную строку (Ура, мы можем использовать std::vector
и не хранить лишний байт!).
Но если вдруг за фасадом вашего удобного API со string_view
скрывается обращение к какой-нибудь сишной библиотеке, требующей NULL-терминированную строку...
// Эта маленькая программа весело и задорно выведет
// Hello
// Hello World
// Хотите вы этого или нет.
void print_me(std::string_view s) {
printf("%s\n", s.data());
}
int main() {
std::string_view hello = "Hello World";
std::string_view sub = hello.substr(0, 5);
std::cout << sub << "\n";
print_me(sub);
}
Чуть-чуть изменим аргументы
// Теперь эта маленькая программа весело и задорно выведет
// Hello
// Hello Worldnext (а может быть просто упадет с ошибкой сегментации)
// Хотите вы этого или нет.
void print_me(std::string_view s) {
printf("%s\n", s.data());
}
int main() {
char next[] = {'n','e','x','t'};
char hello[] = {'H','e','l','l','o', ' ', 'W','o','r','l','d'};
std::string_view sub(hello, 5);
std::cout << sub << "\n";
print_me(sub);
}
Функция не менялась, мы просто передали другие параметры и всё совсем сломалось! А это всего лишь print
. С какой-то другой функцией может случиться что-то совершенно немыслимое, когда она пойдет за границы диапазона, заданного в string_view
.
Что же делать?!
Нужно гарантировать NULL-терминированность. А для этого надо скопировать строку... Но ведь std::string_view
мы же специально использовали в API, чтобы не копировать?!
Увы. Как только вы сталкиваетесь со старыми C API, оборачивая их, вы либо вынуждены писать две имплементации -- с сырым char*
и c const std::string&
. Либо соглашаться на копирование на каком-то из уровней.
Никак.
NULL-терминированные строки -- унаследованная неэффективность и возможность для ошибок, от которых мы уже, вероятно, никогда не избавимся. В наших силах лишь постараться не продолжать плодить зло: В новых C-библиотеках стараться проектировать API, использующие пару указатель + длина, а не только лишь указатель на NULL-терминированную последовательность.
От этого наследия страдают программы на всех языках, вынужденные взаимодействовать с C API.
Rust, например, использует отдельные типы CStr
и CString
для подобных строк и переход к ним из нормального кода всегда сопровоздается мучительными тяжелыми преобразованиями.
Использование NULL-терминатора встречается не только для текстовых строк. Так, например, библиотека SRILM активно использует 0-терминированные последовательности числовых идентификаторов, создавая этим дополнительные проблемы. Семейство функций exec в Linux принимают NULL-терминированные последовательности указателей. EGL использует для инициализации списки атрибутов, оканчивающиеся нулем. И многие многие другие.
Не нужно дизайнить неудобные, уязвимые к ошибкам, API без великой надобности. Экономия в функции на одном параметре, размером в указатель, редко когда оправдана.