Автор оригинальной статьи @saugustu. Ссылка на оригинальную статью:

saugustu42/cool-story

Тимофей Федорович начал свой рассказ с того, что Си – низкоуровневый язык. Язык «опасный». Что несмотря на желание писать безопасные программы, на Си в полной мере сделать этого невозможно, потому что в нём нет соответствующих инструментов. В нём нет безопасности по доступу к переменным. Если программа в процессе выполнения, выйдет за границы массива или за границы аллоцированной памяти, она может например повредить данные переменной, используемой этой же самой программой (например счетчик цикла). И дальнейшее поведение программы предсказать невозможно. В си нет типа данных string. То что мы называем строкой, по сути является тупо адресом, и по этому адресу аллоцировано сколько-то памяти. И неизвестно, сколько памяти там аллоцировано, это нигде не записано. Мы должны сами хранить такие данные в специально созданных переменных. А в более навороченных C++ с этих проблем нет, потому что в них есть std::string, который сам по себе следит за количеством аллоцированной памяти. Говоря языком ООП, std::string – класс, а в терминах си, по сути своей – структура. В стд стринг помимо таких переменных, как например количество аллоцированной памяти, инкапсулирована куча полезных методов и функций и механизмы реаллокации. std::string многое умеет, и делает это безопасно. А работать с памятью на си (через чары как в наших mem функциях) – это всё равно что ехать на танке. Едешь и сносишь всё на своем пути. Поэтому работать с памятью таким образом необходимо очень аккуратно, точно зная что ты делаешь. В си ты лёгким движением руки можешь реинтерпретировать память как какой хочешь тип и записать куда угодно что угодно или считать что угодно. Кроме той ситуации, когда операционная система бьёт тебя за это по рукам. Когда операционка через прерывание процесса через контроллер памяти, отследив что программа полезла «не в свою» память, выдаёт Segmentation fault. Есть места, в которых undefined behavior известно и ожидаемо. Выход за границы аллоцированной памяти, выход за границы массива..

Но также, ub встречается и в несколько неожиданных местах. В качестве примера, лектор привёл код:

x = x++ + ++x

И сказал что так писать нельзя.

После этого примера, а также после того как я спросил «является ли поведение строковой функции после подачи NULL поинтера на вход, неопределенным поведением?», Тимофей Федорович ответил «не всегда». Я понял, что тема неопределенного поведения много обширней чем мне казалось. Что на те вопросы, которые я изначально ему сформулировал, лектору не удастся ответить тезисно ввиду объёмности темы. Далее мы обсудили тему защиты функций. Функция, работающая со строками, не будет вести себя неопределённо, когда программист предполагал, что NULL-импут может поступить на вход и добавил в функцию проверку входящих данных. Лектор поведал, что NULL поинтер – это ещё не самое худшее что мы можем пихнуть в функцию. А вот если ей дать какой-нибудь неопределённый указатель, то это уже намного хуже. NULL поинтер отличается тем, что ты можешь его проверить. NULL это штука известная, это «определённое никуда».

Как альтернатива проверке в функции принимаемых аргументов, есть такая вещь как «контракт». То есть когда ты пишешь какую-то функцию, есть набор входных данных и есть выходные данные. Для языка си есть оговорка, что мы можем return только что-то одно. Поэтому иногда выходные данные помещаются по адресу, на который указывает указатель, поданный тебе на вход. Но в любом случае, когда есть контракт, функция должна иметь право на отказ выполнять работу. Суть в том, что интерфейс входных и выходных данных должен быть формально зафиксирован, то есть должны быть чёткие требования, какие инпуты должны придти на вход функции чтобы она начала работу, а в каком случае функция имеет право отказать в выполнении своей деятельности в зависимости от значений поданных параметров. Типы проверятся компилятором автоматически. В контракте функции может быть написано, что адрес не имеет права быть NULL, он обязан быть валидным. И, например, дополнительно мы можем наложить требования, что по этому адресу должно быть аллоцировано не менее чем (сколько-то байт). Я позволил себе перебить повествование Тимофея Федоровича, напомнив, что в нашей методике обучения присутствует большое количество запретов: функций, макросов.. одна норма чего стоит. И что учитывая эти ограничения, я не уверен, что есть возможность запилить такой контракт в наши учебные проекты (по крайней мере начальные). Учитель продемонстрировал знание факта о наших ограничениях и дал свой комментарий, что эти ограничения – это одна из фишек нашего учебного трека. Продолжив, он отметил, что необязательно эти контракты писать в теле функций. Документацию к функции (в формате комментария) можно написать над функцией.

/*
** norminette really allows us
** to write comments like this
*/

Если эта функция описывается в твоей самописной библиотеке, тогда её описание должно находиться в заголовочном файле. В любом случае, есть такой механизм под названием doxygen, который умеет из сишных / плюсовых / и некоторых других файлов, вытаскивать данные и автоматически генерировать документацию. Самое главное, что пока твои комментарии находятся прямо в исходном коде, они остаются более менее актуальными. Потому что программист, изменяющий содержимое функции, должен обязательно обновить документацию. Иначе, если её не обновлять, то уже документация станет невалидной и весь смысл этих заявлений и требований контракта теряется.


От себя добавлю интересный пост от Sky про наши ограничения, написанный в телеге в начале ноября:

“Сабжекты сабжектами, но полезно понимать, как в боевых проектах код пишут. И почему. И разделять всякий трэш, который мы делаем в рамках школьных проектов (потому что так в сабджекте написано) и то, как это всё используется в жизни. Фильтровать, и ни в коем случае не воспринимать школу как однозначный источник правильных практик и привычек.”

Но это уже следующий уровень, ребят. Хочется верить, что норму разрабатывали не дураки, и давайте на первых порах следовать ей, успокаивая себя, что в этом есть польза. А что нам ещё остаётся? )


Так вот, Тимофей Федорович сказал, что вне зависимости от учебной программы ecole42, глобально, в работе программиста:

В функции самое главное, чтобы её контракт был понятен. Как с ней можно, как с ней нельзя. Потому что undefined behavior возникает в первую очередь от недопонимания программистов, использующих функцию. Когда человек пытается воспользоваться функцией не так, как надо, и он ожидает от неё какого-то другого поведения.

С неопределенным поведением, помимо очевидных кейсов его возникновения, бывают и подлые моменты, про которые просто надо знать. В частности, когда мы работаем с сишными строками, то нам приходится иногда эмулировать поведение std::string из плюсов хотя бы в том, что за каждой строкой нужно по-хорошему тащить её длину. Мы можем сделать в си структуру struct my_str, где лежит указатель на строковый буфер char *buf, аллоцированный размер этого буфера, а также длину строки. Передавать длину строки нужно для того чтобы не тратить ресурсы на её перевычисление.

Соответственно, возникает следующий вопрос: а что если нам передадут «разломанный» экземпляр этой структуры? Например в структуре указано что длина строки 100, аллоцированная память 1000000, а буфер будет NULL ptr, или вообще битый указатель? Возникает вопрос, а что нам в этом случае делать? В первом варианте функция начинает быть параноиком. Принимая аргументы, она начинает их перепроверять. Каждый раз, когда она принимает на вход структуру, она всё перепроверяет: например пересчитывает длину строки. При этом функции могут подать на вход невалидный указатель buf в структуре. Функция хотя бы может сделать проверку является ли буфер NULL ptr. Но если buf – это вообще невалидный указатель, то это плохо и от segmentation fault функция никак не убережется. При этом, на эту паранойю мы потратим время, прямо пропорциональное длине строки. То есть для длинных строк, там где возникали асимптотические проблемы – там эти проблемы останутся. Из-за того что каждое действие со строкой требует перепрохождения её от начала до конца, из-за этого многие проблемы возникали в windows, когда ты корзину открываешь, а там 10000 файлов. И идёт strcat много раз для каждого файла и тормозит. Потому что по сути там требовалось квадратичное время от количества файлов, чтобы открыть папку в какой-то винде. Сейчас это наверняка исправили. Такое было не только в винде: много где встретились с этой проблемой с нультерминированными строками. Проблема связана со скоростью, с асимптотикой.

Так вот, дело в том, что вот эта структура данных, которую ты создашь на чистом си, окажется в потенциально невалидном состоянии. И ты либо будешь каждый раз параноиком и будешь каждый раз перепроверять, всё ли там соответствует правде. (Но некоторые вещи ты и не сможешь проверить). Либо ты должен требовать гарантированной целостности такой структуры. А как её можно требовать? Например в документации. По сути в комментарии к функции. Но проблема вот в чём: «а как программист, который будет использовать эту написанную тобой функцию, будет её использовать? Станет ли читать документацию? Не всегда».