Skip to content

Latest commit

 

History

History
139 lines (92 loc) · 9.92 KB

null_terminated_string.md

File metadata and controls

139 lines (92 loc) · 9.92 KB

NULL-терминированные строки

В начале 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-терминатор? Только лишний байт в памяти!

С++ и std::string

C++ высокоуровневый язык! Уж повыше C, конечно. Стандартные строки в нем уже, учтя ошибку C, хранятся как размер + указатель на данные. Ура!

Но не совсем ура. Ведь огромное число C библиотек никуда не денется, и у большинства из них в интерфейсах используются NULL-терминированные строки. Поэтому std::string тоже обязательно NULL-терминированные. Поздравляю, мы храним один лишний байт ради совместимости. А еще мы его храним неявно: std::string::capacity() на самом деле всегда на 1 меньше действительно выделенного блока памяти.

C++ и std::string_view

"Используйте 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 без великой надобности. Экономия в функции на одном параметре, размером в указатель, редко когда оправдана.