Comments 145
да и вообще эти null-terminated string это вещь про которую можно сказать что если бы её никогда не существовало то всем на белом свете было бы легче жить)
Помню они меня так достали в C++ что я написал класс обёртку c операторами приведения во все разумные типы чтобы вообще никогда их не использовать... а потом пол года ещё дебажил сам этот класс)
Там речь про nullable значения, а не про null-terminated строки
А вот с этим как раз не соглашусь. NPE это сигнал что программа работает не так как должна. И чем раньше это выяснится тем лучше. А вот если NPE будет както замаскированно или автоисправлено или ещё чтото подобное и программа с ошибкой "на борту" похромает кудато дальше то кончиться всё это может хуже чем просто поиск ошибки в коде.
Я тут смешал две больших проблемы (которые существовали, кажется, ещё до моего рождения:) ): как обозначить, где кончается строка, и как обозначить, что указатель никуда не указывает.
Для решения обеих чаще всего используется 0 (или null).
И чем раньше это выяснится тем лучше.
Раньше — это во время компиляции. NPE, вывалившийся в рантайме на проде — это слишком поздно. (И ещё хорошо если в принципе вывалившийся, а не по-тихому портящий память...)
Конечно на этапе компиляции это ещё лучше чем при выполнении. Но разговор то про то что сама концепция вредоносна. Вон например в Котлине есть возможность завести типы которые не могут быть null то есть вынуждают заполнять неинициализированные ссылки ссылками на какой нибудь dumb а потом какой нибудь модуль может решить что этот dumb вполне себе "живой" объект и начать с ним работать ну и пошло поехало...
кстати ну вот не припомню чтобы null поганил память если можно обрисуйте механику...
У котлина null safety из коробки, в нём такой проблемы изначально нет. Ну а если кто-то принципиально игнорирует null safety и лепит dumb объекты — ну штош, защита от дурака не спасает от долб слишком изобретательных дураков ¯\_(ツ)_/¯
типы которые не могут быть null то есть вынуждают заполнять неинициализированные ссылки ссылками на какой нибудь dumb
Почему обязательно ссылки и обязательно на dumb? Деталей Вы не указали, но я бы к архитектуре вопросы поднял, мне такой подход странным кажется.
кстати ну вот не припомню чтобы null поганил память если можно обрисуйте механику...
В каких-нибудь embedded адрес 0x00 вполне себе легальный адрес.
И вот еще, статья 10 летней давности на тему разыменования null.
upd. В той статье выводов мало, но вот есть еще: https://habr.com/ru/companies/contentai/articles/205070/
В целом, есть варианты, когда null пролезет и притворится валидным указателем.
Не соглашусь. Это очень изящное и экономичное решение для множества задач (т.е. кроме текстовых редакторов). А ещё переход всего FOSS на UTF-8 не дался бы так легко с другими более сложными типами строк (см винду и трудности с её UTC32, _w -функциями в API и т.д.).
null-terminated string появились во времена, когда даже 2 байт на длину строки было многовато по памяти.
А олды помнят еще и $-терминированные строки. Как по мне вопрос не в размере, вопрос в однобайтовой ASCII кодировке. В те времена, когда она была достаточной этого хватало. Сегодня... Сегодня сложно. Местами давно надо заменить, но сказать проще чем сделать.
Мне кажется кодировка тут ортогональна.
Различия просто в том как делать: length-value или null-terminated.
Да, когда памяти кот наплакал - хочется сэкономить. Но, как и с nil-ами, кроилово ведёт к попадалову. Вместо того, чтобы сделать заголовок из пары байт - получили вот это, с нулём в конце... и все проблемы с этим связанные.
Различия просто в том как делать: length-value или null-terminated.
Наверное, length-value... если мы уверены, что эта length будет подсчитываться правильно. Сколько, говорите, байтов занимает одна буква?
Или все-таки null-terminated... если мы уверены, что не забыли поставить этот null в нужном месте. Сколько, говорите, байтов занимает одна буква?
С нулем прелесть в том, что некоторые функции гарантируют 0 в конце буфера, некоторые нет, некоторые забивают нулями буфер до конца. Походу проектировались они разными людьми.
Сколько, говорите, байтов занимает одна буква?
str* работают с последовательностями байтов, кодировки на следующем уровне абстракции.
В Си никаких уникодов нет и не было, особенно когда эти null-terminated изобретались.
А сейчас да, до сих пор умудряются проблемы не этом получать. К примеру в Расте есть у String (который хранит UTF-8) безобидный метод truncate(n), который работает не по кодовым точкам, а блин по байтам (чтобы быть O(1)) и если ему указать байт посреди кодовой точки - просто паникует.
Хотя могли бы сделать вот что-нибудь такое и оно было бы тоже O(1):
pub fn truncate(s: &str, n: usize) -> &str {
let n = s.len().min(n);
let m = (0..=n)
.rfind(|m| s.is_char_boundary(*m))
.unwrap_or_default();
&s[..m]
}Резало бы по ближайшей кодовой точке.
Да, а потом появляется черный кот, 🐈⬛, разрезанный по яйцам по черный / "плюс" / кот, и осталось не пойми что. Но, хотя бы не посреди кодовой точки. ... unicode...
Ну, графемы порежутся, да.
Но, очень часто, на это пофиг - хочется, например, просто обрезать длинную строку текста какой-нибудь ошибки до Х байт, чтобы логгировать например. И, чтобы при этой простой операции ничего не взрывалось :)
Юникот, сэр!
Подозреваю, что дело не только в паре байт для указания длины.
null-terminated строки позволяли делать разбор текста (например, когда его вычитали из файла в память) не тратясь на дополнительное копирование, прямо на живом буфере. Натыкал нулей вместо разделителей - и вот тебе отдельные строки (привет, strtok). В те далёкие времена (да и сейчас, в общем-то) такая экономия была существенной.
Если же строки будут с полем длины, то такой финт уже не провернёшь и придётся копировать-перекладывать много-много данных.
(По правильному, ) один из хороших вариантов: для строки хранить два указателя (типа, как структура): начало и символ-за-концом. Но что-то "не взлетает".
Но что-то "не взлетает".
В каком смысле, если так и устроены вектры в С++?
нет, скорее всего упрощение программирования - проверка не нуль одна из базовых функций АЛУ, поэтому есть везде. вот и реализуема. попробуй на другой символ-терминатор - заклюют и заплюют.
Не критично на самом деле. Писал как-то парсер. Надо было максимально быстро, ибо объём большой. Как раз вот так расставлял нули по тексту, но всё равно был отдельный массив с индексами начала токенов. Ничего не мешает туда писать и длинну тоже. На момент отметки она уже известна.
Если мы используем length-value мы имеем максимальный размер строки. null-terminated же позволяет иметь строки не ограниченной длинны.
null-terminated же позволяет иметь строки не ограниченной длинны.
Вот только память, падла, не позволяет.
Понятно, что правильный тип length - это size_t или подобное.
а если длина больше чем 2**size_t-1?
Тогда такую строку невозможно аллоцировать, не говоря уж обработке.
Можно, просто по частям, можно даже диск задействовать, если ОЗУ маловато будет (через системный своп или ручками). Оно сложновато, мягко говоря, но вполне реализуемо. Естественно, тут ни нуль-терминатором, ни просто длиной не ограничишься, придется расписывать в прилагаемой к строке структуре все особенности хранения.
size_t на 128 битах будет 8 байт, на каждую строку. Даже если она 2 байта. И тут можно дойти до идеи коротких и длинных строк.
Про влияние оверхеда на хранение длины на выбор null-terminated vs length-based здесь в коментах уже больше десятка раз написали )
А так то для коротких строк в плюсах давно уже SSO придумали, оно и лишнюю аллокацию в куче убирает.
Прямо как в Delphi. Есть ShortString с однобайтовой длиной (совместимо с Pascal и старыми версиями самого Delphi), есть AnsiString с четырехбайтовой длиной (впрочем, там в хранимой по указателю структуре вроде не только длина есть).
До всего этого в результате всё равно доходят. В том же Расте раз строки в std на хипе - появляются всякие smallvec с векторами и строками на стеке, например (не совсем то же самое, но тем не менее).
Ну и Vec там, вокруг которого строятся и строки, один фиг имеет поле длины usize, что 64 бита обычно. Для всех практических целей этого достаточно.
pub struct Vec<T, A: Allocator = Global> {
buf: RawVec<T, A>,
len: usize,
}Я сейчас не вспомню, почти 30 лет назад, но имхо у DEC VAX/VMS штатным типом строки было 2 байта префикса (длина) и потом строка. И, скорее всего, в PDP11 осях также, оттуда скорее всего пошло. так что в принципе, не такой и большой оверхед. это просто проще программировать - проверка на 0 в АЛУ - базовая, и есть во всех ассемблерах.
C null-terminated можно дёшево работать с подстроками - для обработки хвоста можно просто передать указатель на первый символ посередине оригинала, в случаях посложнее можно временно занулить какой-нибудь символ. С явной длиной это подороже.
Опять же это благодаря условию если не ноль в алу.
Не сильно. Если глядеть на весь оверхеэд с этими гигантскими библиотеками-фреймворками о всём и ничем, из которых используется пара функций, некоторые ранее просто замещались макросом в инклюде… То не сильно. В принципе в любом решении можно найти как плюсы так минусы. Так и выбор подстроки не до конца в твоём предложении дает неоднозначный эффект, а в случае с префиксом длины унифицированный вариант. Код проще и быстрее. Просто например в с++ вызывается конструктор, в него по принципу CoW передается указатель на начало строки, и заполняется префикс=если длина старой - позиция < длины подстроки ? Разница : длина подстроки. И всё
И, скорее всего, в PDP11 осях также
Нет, не так. PDP-11 MACRO-11 Language Reference Manual , стр. 6-21, параграф 6.3.5
А олды помнят еще и $-терминированные строки
"Я не вспоминал int 21h N дней."
Сбрасываю счетчик на ноль. Снова 20-30 лет его копить буду....
Сейчас мы расплачиваемся за эту экономию бесконечными buffer overflow уязвимостями. Длина в начале строки решила бы кучу проблем с безопасностью
Стек адресов возврата, отделённый от стека данных, решил бы кучу проблем с безопасностью...
Это вы про Эльбрус? ;)
Я "Эльбрус" живьём только в музее Яндекса грязными руками трогал, но другого железа с раздельными стеками физически вроде не видел.
Мысль в том, что огромное количество эксплоитов заточено за то, чтобы переполнить какую-то строку или массив специально подобранными данными, чтобы она вылезли за пределы отведённого на них буфера на стеке и затерли ранее запихнутые туда адреса возврата — и когда придет время возвращаться из функции, управление пойдёт совсем не туда, откуда ее вызывали — а туда, куда надо взломщику.
Так вот, если временные переменные и адреса возврата будут на разных стеках, то такой финт ушами не проканает.
Это понятно, мысль не новая - но с реализациями в железе негусто. На iapx432 (1981 год) вроде тоже такое было - но это скорее экземпляр для кунсткамеры.
А зачем обязательно в железе? «Стек данных» можно и программно двигать.
Без аппаратного SP уныло будет
А в чём проблема-то? Заводим ячейку, назваем её SPd; обычно для выделения места под временные переменные добавляем требуемый объём к SP — а тут будем добавлять к SPd, всего-то. Да, будет работать чуть-чуть медленнее. Зато управление не будет передаваться куда не положено.
Возможность испортить данные в другом фрейме никуда не денется.
Да, будет работать чуть-чуть медленнее
В этом и проблема, и не факт что чуть чуть (если какая то функция вызывается каждую тысячу-другую тактов).
если какая то функция вызывается каждую тысячу-другую тактов
...то у вас есть две проблемы.
С работающим branch prediction и прочими оптимизациями в микроархитектуре вызов функции почти бесплатен (инлайнинг даёт выигрыш в первую очередь из-за совместной оптимизации кода нескольких функций, а не из-за избавления от call/ret). Явная работа с альтернативным стеком на этом фоне может быть заметна.
Да, но надо слишком многое переделывать, в первую очередь компиляторы. На дековских можно было любой регистр использовать косвенный с автоинкрементом/декрементом, А здесь вместо push/pop городить конструкции
Да ладно… Любой процессор Гарвардской архитектуры. Микроконтроллеры Pic например. Куда их только не засовывали.
Куда их только не засовывали.
«Господа гусары, ВСЕМ МОЛЧАТЬ!!!» ©
Там код и данные разделяются, насчёт стеков не уверен - не писал под них.
Ходят легенды про язык тех времен, где в строке первый байт определял ее размер.
Я думаю что это реально неплохое решение по сравнению с null terminated, сразу точно знаешь сколько памяти выделено.
256 байтов тогда всем хватало ;)
Зато любая операция со строками == неочевидная пляска с выделением памяти под капотом. Или как там оно внутри устроено?
Паскаль? Так и сейчас в Delphi размер перед данными строки но по отрицательному смещению + магия компилятора чтобы всю эту кухню скрыть с глаз долой.
Это "паскалевские строки".
Последние 8 лет работаю с языком, где два типа строк - char и varchar.
char - обычный буфер заданной длины. И больше туда не впихнуть
dcl-s s1 char(5);
dcl-s s2 char(10);
s2 = '0123456789';
s1 = s2; // s1 = '01234' - что не влезло, то не влезло
s2 = s1; // s2 = '01234 ' - остаток забивается пробеламиПри все желании за границу не вылезешь.
Но...
%len(s1); // всегда 5
%len(s2); // всегда 10а чтобы узнать реальную длину (без хвостовых пробелов) надо
%len(%trimr(s2));varchar - фактически структура
dcl-s vs varchar(10);в С будет представлено как
struct t_varchar {
unsugned short len;
char data[10];
} vs;и тут уже %len вернет реальное значение поля vs.len
И все это достаточно безопасно. Особенно с учетом того, что здесь не бывает неинициализированных переменных - компилятор автоматически инициализирует любую объявленную переменную дефолтным для ее типа значением (если явно не указано иное значение) - для char это "пустая" (заполненная пробелами) строка, для varchar это строка с len = 0, для числовых типов 0.
Очень сильно похоже на pl/sql или любой встроенный в БД язык.
Это специфический для платформы IBM i (AS/400) язык RPG. Он не то чтобы "встроен в БД" - на этой платформе БД (DB2) является частью ОС ну и языки освновные (С/С++, RPG, COBOL, CL) тоже "встроены" (компилаторы являются частью ОС) в систему.
Язык типа COBOL по назначению - основное в нем работа с БД и коммерческие вычисления.
И да, в синтаксисе есть "привкус" PL-I который IBM одно время активно пыталась развивать. В виде всех этих dcl-... в объявлениях.
В Delphi оно и сейчас так. Пользуйте ShortString, и радуйтесь.
null-terminated string появились во времена, когда даже 2 байт на длину строки было многовато по памяти.
В общем да, хотя в 80-е был Паскаль и там уже были строки (хотя в оригинальном Паскале семидесятых их не было), а потом Ansi появились, в девяностых вроде, я как раз тогда Си учил после Паскаля, и сишные нуль-термиированные строки вызвали некоторое недоумение, было дело. Потом я на Дельфи работал. В общем дело привычки и аккуратности.
Че-то прошли мимо того факта, что strncpy работает с байтами, а не с UTF-8 и поэтому может разрезать символ посредине. Хотя да, по сравнению с отсутствием нуля это мелочи. Ужасная функция :)
Не думаю, что в 95% кода всего ядра нужно что-то кроме ascii
Драйвер ФС может работать с utf8.
Кодировки для FS в ядре - это костыль для FAT и подобного, чтобы вместо CP866 показывать KO8-R. В нативных FS имена файлов - просто последовательности байтов, скажем никто не мешает использовать невалидную utf-8 последовательность.
В linux драйвер ФС работает с байтами. Разрешены любые байты кроме 0x00 и 0x2F (Слеш /)
Название файла может даже не быть корректной кодировкой. Все преобразования уже userspace происходят.

Не может, байты продолжения в UTF8 всегда с 10хххххх начинаются
Ну ок.
А если буфер src закончился посреди символа?
Каким образом эта функция учтет UTF-8?
https://github.com/torvalds/linux/commit/079a028d6327e68cfa5d38b36123637b321c19a7#diff-caf1d936b395dcac087bd2b6d8585de0e06695cfe00c899d9299dc9cfec2a118L91
char *strncpy(char *dest, const char *src, size_t count)
{
char *tmp = dest;
while (count) {
if ((*tmp = *src) != 0)
src++;
tmp++;
count--;
}
return dest;
}если закончится, то коррапченый символ в utf-8 это меньшая проблема :-)
Все же потенциальное отсутствие '\0' - явно задокументированная особенность и с ней умеют работать через конструкцию вида strncpy(buf, ..., sizeof(buf)-1).
Как здесь например:
https://github.com/torvalds/linux/commit/340ff3216799a947fe0b07bed8f0409ffc716be9#diff-81db6161fb0345ecabeac4f346089871ab4d62d9e8ee1fdb04c73757b8e8bbb8L133
А вот фигня с UTF-8 - более тонкая, неочевидная, и непредсказуемая.
Может. 10 байт могут попасть в середину символа.
Хоронили strncpy, порвали два баяна
Там же вроде ядро линукса на раст переписывают? Эта проблема сама бы не ушла, если использовать не С-строки?
Ядро не равно модули ядра. Да и модули как я помню не переписываются, а пишутся новые под новое железо или специфические кейсы
Ничего там не переписывается. Добавили тулчейн чтобы можно было писать что-то в ядре на Расте да и всё.
Переписать всё ядро - понадобится, небось, миллион человеко-лет.
Или один Claude Code и несколько месяцев.
...но есть нюанс...
Угу, сжечь токенов но 100 миллионов долларов и получить в конце кусок говна :)
Тут одни уже Bun переписывали на Расте...
Проблема Си-строк никуда не уйдет, пока жив сишный ABI. Любой язык, который общается с ядром, вынужден подстраиваться под эти нули в конце
Ядро не будут переписывать на rust, только драйвера
Для тех кто хочет переписать на Rust есть https://github.com/asterinas/asterinas
то
strncpyзапишет ровноnбайт и не поставит завершающий\0. Дальше любой...уходит читать за буфер
О, спасибо, что экскурс в историю описали, как он появился. Всегда когда ещё писал на древнем до-ANSI С поражался этой разнице с strcpy. Даже был какой-то паттерн: использовать объявленные константы заданного размера и в них делать strncpy - дикое уродство, но типа безопаснее указателя с strcpy.
Это не про строки вообще.
Замена — это не «найти и поменять на безопасный аналог».
Это не баг в трекере — это код, который надо перечитать.
Постить ИИ текст — это не про качественный постинг.
Зато он избавил нас от цыган strncpy ! (С придыханием) (ц) ;)
Ну, функция стремная, да. Но она протестирована вдоль и поперек? Почему бы не оставить как есть и просто обьявить deprecated ? Она же и в glibc, и в куче программ продолжает торчать. 6 лет переписывали то что и так работает.
по нынешним временам - неизвестно работает или нет. Там могли быть скрытые неочевидные баги, если автор кода не до конца понимал все крайние случаи. Видимо решили, что функция - очевидный code smell, который нужно искоренять.
Deprecated не мешает людям копипастить старый код. Единственный способ избавиться от проблемы - физически удалить ее источник
Self-Protection Project Киса Кука,
СОВПАДЕНИЕ? НЕ ДУМАЮ! /s
Как всё запущено.
Проблема strncpy не только в терминаторе, она еще и кэш убивает своим забиванием нулями хвоста буфера, если строка короткая..
Зачем вообще кто-то придумал строки, заканчивающиеся нулём? Не лучше ли просто хранить длину строки? А ещё хорошо бы и размер буфера хранить. И тогда внезапно все эти переполнения буфера, донимающие нас уже пол-века, были бы не страшны. Не говоря уже про strlen(), выполняющийся за О(1).
А что ещё Вы хотите похранить на машине с 16 КБ памяти?
И что, теперь куча старого софта тупо перестала компилироваться?
Нет, функцию убрали из кода ядра. В libc она никуда не делась.
В те времена память была дорого́й, экономили как могли. Видимо, создатели языка много работали с текстом (компиляцыя кода?), и там это удобно: што-то делаеш в любом месте текста и просто сохраняеш указатель на это место.
С интересом наблюдаю за изобретающими Pascal-строки...
На тех граблях уже попрыгали, и перешли на нормальные null-terminated. Но нет, надо попрыгать снова, потому что кому-то лень думать о том, что делает функция, за него компилятор думать должен, и защищать от мошенников выхода за пределы буфера.
Зачем тратить лишние когнитивные ресурсы и оставлять риск человеческого фактора?
Затем, что если не думать над архитектурой (а даже несколько возможных вариантов решения какой-нибудь сортировки -тоже архитектура), и просто тупо лепить один на один кирпичики - получается большой, тормозной и глючный Франкенштейн, который развалится не от переполнения буфера, а из-за своей кривизны и непродуманности.
Автор не разобрался в теме, не понял почему и где баг - проблема этой функции не в том, что, есть в конце '\0' или нет, на это есть документация, а в UTF-8. Размер символа сегодня может быть 1,2,3,4 байта, и эта функция разрезает последний символ на части. А если не разрезать, а копировать полностью то получается переполнение буфера при копировании. Как то так выходит на самом деле.
То, что strncpy может не поставить завершающий \0 гораздо хуже, чем если \0 разрежет UTF8-символ. Хотя и в разрезании символа нету ничего хорошего
Говно еще в том, что strncpy принятно использовать без проверки на ошибки.
Было переполнение или нет - коду пофиг, он молотит дальше.
Когда-то были дыры из-за того, что например PHP видит строку "evil\0innocent" и она проходит проверку, а нативный код видит "evil\0".
Даже если отдельно порешать проблему с '\0', расширив буфер, проблема с неоднозначностью строк из-за обрезки остается.
Я даже подозреваю, что если хорошенько поиграться со шрифтами с UTF-8, можно из двух неудачно обрезанных последовательностей собрать UTF-8-франкенштейна с новыми символами.
Короче, strncpy - непредсказуемое говно.
дак на что поменяли? strncpy_sнадеюсь?
Зашли обсудить один коммит в ядре Linux, а в итоге судим Кернигана и Ритчи за решения 1972 года
Тут скорее камень в огород тех, кто никак не может закопать стюардессу. И продолжает на фундаменте 72-го года пытаться строить hi-tech небоскреб.
Ммм, эти длинные типографские тире, этот стиль письма с тремя аргументами, эти «не просто…, а …»
А так же AI детектор говорящий что 55% вероятности что статью писала моделька, а не человек.
Статья частями подозрительно похожа на https://chessman7.substack.com/p/linux-finally-killed-strncpy-it-took


Из ядра Linux выпилили strncpy: шесть лет, 362 коммита, одна функция