После публикации предыдущей статьи на данную тему, некоторые читатели не обратили внимания, что данный проект, это не действующий инструмент, готовый для боевого применения в реальных проектах, а только доказательство работоспособости концепции использования плагинов компилятора для дополнительного семантического контроля исходного кода С++ во время компиляции. А в качестве примера реализации подобного плагина я взял концепцию безопасной работы с памятью из языка NewLang с минимальной адаптацией под C++.
То есть основная идея предыдущей статьи — продемонстрировать возможность использования плагина компилятора для дополнительного анализатора исходного текста, а не изучение функциональности реализованной библиотеки для работы с памятью (которая и не может быть в полном объеме портирована на С++ из-за архитектурных различий в этих языках программирования).
Тем не мене, большинство читателей все же уловило основную мысль и проявило интерес к возможному дальнейшему развитию подобного подхода к повышению безопасной разработки на С++ без нарушения обратной совместимости со старым кодом. Поэтому, чтобы не смущать читателей отсылкой к неизвестному для них новому языку, я начал адаптировать концепцию безопасной работы с памятью под чистый С++ для решения специфических для С++ проблем.
А пока идет доработка плагина и мне очень захотелось поделиться одним очень увлекательным квестом, которой показывает непреодолимые архитертурыне проблемы С++ на пути к безопасному программирования. И поводом для того стали итераторы.
Введение
Точнее, поводом стали не итераторы сами по себе, а реализация итераторов в С++ на основе адресной арифметики (а именно, использование итераторов как указателей на область памяти).
А так как указатели в С++ является фактически обычными числами, (т.е. у них отсутствует контекст — связь с переменными, которые находятся на стеке), то их использование легко может привести к циклическим ссылкам и утечкам памяти. А принимая во внимание возможность создания не инициализированных переменных и отсутствие контроля (Undefined Behaviour) при разименовании недействительного адреса (например, при доступе по нулевому указателю даже в некоторых шаблонных классах STL), то адресная арифметика — это зло вообще и итераторы в частности.
А началось все с очень простого примера:
std::vector<int> vec(100000, 0);
auto x = vec.begin();
auto y = vec.end();
vec = {};
vec.shrink_to_fit();
std::sort(x, y); // malloc(): unaligned tcache chunk detected
Совершено корректный код, который нормально скомпилируется и даже запустится. Ну а дальше как повезет :-(
Точнее, конкретно этот код скорее всего будет работать без проблем, если закомментировать строчку с vec.shrink_to_fit()
, которая возвращает неиспользуемую вектором память обратно в кучу, то как правило получаем ошибку, и хорошо, если это malloc(): unaligned tcache chunk detected, но обычно программа завершается из-за Segmentation fault при попытке обращения к недоступному для записи участку памяти.
Поэтому я решил, что прежде чем приступать к портированию основной библиотеки, потренироваться на кошках итераторах.
Кроличья нора
Имеем следующие сущности (имена переменных для этого конкретного примера кода):
- Исходную переменную с данным (в нашем случае переменная vect в строке 1)
- Один или несколько итераторов (x и y в строках 2 и 3)
- Операцию модификации данных в исходной переменной, которая может сделать недействительными ранее полученные итераторы. (строки 4 и 5)
- Использование ранее полученных итераторов и потенциальный креш, если итераторы стали недействительными.
Как видите, проблемы бы не было, если бы не реализация итераторов в С++ на основе адресной арифметики и отсутствие контроля при разименовании недействительного адреса в оперативной памяти, в том числе и из-за отсутствует контекста у указателей (зато не нужно платить за то, что не используется :-) ).
Осмысление задачи при первом подходе
Принимая во внимание, что решение подобных проблемы в лоб и с ходу не существует в принципе, в противном случае их бы давным-давно победили, тем не менее, с чего-то нужно было начинать. Поэтому я решил начать с самого простого анализа, который заключался следующих шагах:
- Определение переменной, в которую сохраняется итератор
- Сохранение пары связанных переменных: переменная-итератор и переменная, из которой итератор был получен
- Отслеживание обращения к исходной переменной на предмет возможной модификации данных
- Отслеживание использование итератора после возможной модификации данных в переменной с данными
Ошибка детектируется только в том случае, если используется итератор, а перед этим была модификация данных у исходной переменой.
Прототип плагина традиционно сделал под clang и при тестировании и вылезли ожидаемые нюансы (ни капель не сомневался, что именно так и будет), так как изменение данных в исходной переменной может быть в следующих случаях:
- При присвоении переменной с данными нового значения
- Передача адреса переменной или по ссылке в качестве не константного аргумента при вызове функции.
- При вызове любого метода, при котором изменяются внутренние данные переменой.
И если в присвоением нового значения переменной все как бы очевидно (на самом деле это зависит от типа объекта и размера его данных), то в общем случае значение у объекта может модифицироваться, а соответственно и изменяться итераторы, при вызове любого не константного метода. А если вспомнить про const_cast
, reinterpret_cast
или обычной С-style каст со скобочками, то данные в объекте могут изменяться при вызове вообще любого метода класса, включая даже методы для получение итераторов :-) ).
Переосмысление при втором подходе
Поэтому для второго похода к это задаче я решил конкретизировать список операций и обращение к методам у исходной переменной, которые могут приводит к инвалидации её итераторов следующим образом:
- Постулируется, что вызовы у константного объекта не изменяют его состояние и не инвалидируют полученные ранее итераторы. Но на самом деле это немого бессмысленно, так как если объект константный, то он по определению не должен изменять свое состояние (но помним про
const_cast
иmutable
, а поэтому лучше зафиксировать это в явном виде). - Постулируется, что методы получения итераторов не должны изменять состояние исходного объекта (по смыслу это почти очевидно, так как, например, метод
end()
возвращает итератор и может вызываться в множество раз цикле, но гарантий тут конечно нет и все в руках разработчика), поэтому это тоже фиксируется в явном виде. - Передача переменной или по ссылке в качестве константного аргумента при вызове функции… ну вы поняли (так же в явном виде договариваемся о неизменяемости объекта).
А вот с реализацией подобного подхода начались сложности. Как определить, какие методы меняют состояние объекта и приводят к инвалидации итераторов, а какие нет? В случае методов, которые возвращают итераторы (begin(), cbegin(), rbegin(), end() и т.д.), это еще можно сделать, если опираться на второе постулат и проверять тип возвращаемого значения, но как быть со всеми остальными методами? Ведь нельзя же считать, что все остальные методы будут модифицирующие внутренние данные объекта и обращение к ним будет приводить к недействительности полученных ранее итератороов.
Есть второй — обратный вариант, определить список методов объекта, которые изменяют его состояние (такие как clear(), operator=() и пр.), а все остальные методы считать не модифицирующие внутренние данные объекта и контролировать вызовы только данных методов.
Можно скомбинировать оба описанных выше способа, когда указывается как изменяющие, так и не изменяющие методы объекта. В этом случае, вызов модифицируещего метода будет приводить к последующей ошибке при обращении к итератору, а обращение к методу не указанному в списке константных, будет выдавать не ошибку, а всего лишь предупреждение. В этом случае, должна существовать возможность вручную проигнорировать (подавить предупреждение или сообщение об ошибке) при вызове любого метода у объекта, или когда сам объект будет передаваться в качестве не константного аргумента при вызове функции.
Но в любом случае, перечислять вручную "опасные" и "безопасные" методы для произвольного типа контейнеров — плохая идея. Наверно, это можно сделать вручную для типовых шаблонных классов, но в общем случае, любое ручное перечисление "опасных" и/или "безопасных" методов, это тупиковый путь. Если уж и анализировать вызовы методов у контейнера, то нужно делать это автоматически. А вот над алгоритмом анализа конечно придется подумать.
Второй вариант плагина был реализован с автоматической проверкой вызываемого метода по следующим правилам:
- если вызывается метод класса, который имеется в константой реализации, то его вызов будет безопасным даже для не константного объекта.
- если вызывается метод класса, который не имеет константой реализации, то его вызов всегда будет приводить к инвалидации всех полученных ранее итераторов.
- поведение при вызове методов, которые имею константный и не константный варианты реализации, определяется настройками плагина и по умолчанию выводится предупреждение при доступе к полученному ранее итератору. Такое поведение можно изменить в целом, так и только для отдельных методов или в одном конкретном случае использования.
Переосмысление для третьего подхода и мнение зала Хабра
Но кроличья нора на самом деле оказалась еще глубже, чем выглядела даже со второго взгляда. Данные в контейнере могут изменяться (а следовательно, могут изменяться и итераторы), не только при вызове методов у исходного объекта, но и при использовании других, полученных ранее итераторов.
Дело в том, что условные "начало" перед первым элементом и "конец" за последним элементом данных, это условные абстрактности, которые в общем случае являются обычными типизированными ссылками, а их реализация зависит не только от типа контейнера, но и от конкретной библиотеки и даже от внутреннего состояния самого контейнера.
Поэтому наверно единственный способ гарантировать безопасное использование итераторов, это пользоваться только их константными версиями, а вызовы методов класса считать безопасными, только если метод существует лишь в одной единственной версии для константных объектов (например, метод size() или операторы сравнения).
Итог
Но я так увлекся безопасностью итераторов, что на какое то время даже забыл о главной цели этой работы! А ведь меня интересуют не итераторы сами по себе, проблема безопасного использования С++ при работе с памятью! А раз реализация итераторов в С++ сделана на основе адресной арифметики, то это архитектурная особенность самого языка, которую невозможно исправить в принципе, так как это основа из основ для C++.
И наверно, единственным вариантом безопасного использования итераторов, будет какая нибудь надстройка, которая будет связывать итератор и исходный объект, не позволяя использовать значение итератора отдельно о самих данных (исходного контейнера). По сути, требуется связать между собой две сущности, итератор и данные, чтобы сохраненный итератор вызывался не в момент присвоения значения переменной, а в момент обращения к итератору.
Своего рода отложенный (ленивый) вызов метода класса, что-то вроде этого:
template <typename T, typename R>
class Depend {
T &variable;
union {
R(T::*m)();
R(T::*c)() const;
} method;
public:
Depend(T &var, R(T::*call)()) : m_var(var), method({.m = call}) {
}
Depend(T &var, R(T::*call)() const) : m_var(var), method({.c = call}) {
}
inline R operator*() {
return (m_var.*method.m)();
}
inline R operator*() const {
return (m_var.*method.c)();
}
};
#define DEPEND(variable, method) Depend<decltype(variable), decltype(std::declval<decltype(variable)>().method())>(variable, &decltype(variable):: method)
На скорую руку у меня не получилось сделать инициализацию итераторов без макроса. Может быть кто подскажет, как от него избавиться и сделать тоже самое только на шаблонах, так как мне хотелось сделать максимально простой синтаксис:
std::vector<int> vect(100000, 0);
auto x = DEPEND(vect, begin); // vec.begin();
auto y = DEPEND(vect, end); // vec.end();
vec = {};
vect.shrink_to_fit();
std::sort(*x, *y);
Вывод первый
Сейчас мне уже кажется, что для безопасного использования итераторов их вообще следует запретить для непосредственного использования (да, такой вот каламбур) и разрешить их применения только через прокси класс.
Подобное ограничение легко будет контролировать с помощью планига для компилятора (так как будут запрещены все итераторы без прокси-класса), а для старого кода оставить предыдущую реализацию проверок.
Вывод второй
Несмотря на текущую незавершенность задачи, она уже сейчас смогла принести ощутимую пользу основной задумке, так как помогла определиться с окончательной архитектурой плагина для clang и сделать окончательный выбор между AST Matcher vs. RecursiveASTVisitor в пользу последнего.
Несмотря на то, что поиск данных в AST с помощью matcher делается с помощью меньшего количества кода, но общее решение выходит довольно сложное в случае наличия взаимных зависимостей, которые формируются в разных сопоставителях. Ведь при поиске нужных узлов с помощью AST Matcher, MatchCallback вызываются для всех найденных данных, но создавать и очищать нужную контекстную информацию при каждом вызова очень накладно, поэтому нужно сохранять общего состояния плагина при анализе.
Именно поэтому после нескольких неудачных экспериментов с AST Matcher, я решил сделать плагин по старинке, как шаблон RecursiveASTVisitor. В этом случае обход AST выполняется последовательно и происходит сверху вниз через все уровни вложенности, что позволяет динамически создать и очищать уже не нужную контекстную информацию для функций и классов.
Кроме этого RecursiveASTVisitor позволяет прерывать, повторять или переключать обход отдельных ветвей AST в зависимости от параметров работы алгоритма анализа.
Но самое главное, Matcher обрабатывает AST только для конкретных указанных сопоставителей, запись которых происходит не самым понятным образом, и в случае появления нетривиальных условий поиска, некоторые из них могут быть просто пропущены, тогда как RecursiveASTVisitor последовательно обходит все узлы AST и это легко детектируется во время отладки и тестирования.
Комментарии (9)
Dooez
30.01.2025 20:36Насколько я понимаю Вы пробуете сделать нечто близкое к предлагаемому профилю memory safety?
Одна из основных критик данного профиля в невозможности реализации без аннотаций, хоть авторы и утверждают что аннотаций будет минимум. Я скорее разделяю мнение что без аннотаций это невозможно, но мне кажется что большинство из них должно быть в описании структур данных, то есть в библиотечном коде, что вполне допустимо. Поэтому в Вашем первом опросе я голосовал что безопасность только с плагином невозможна, так как потребует поддержки всей стандартной библиотеки.
Третий подход рассмотренный в статье мне кажется не очень правильным. Всё-таки получение итератора до функции меняющей состояние вектора - это ошибка программиста, и желательно ее детектировать и исправить. На мой взгляд второй подход может быть терпимым в отношении ложных срабатываний, если добавить список разрешенных методов для разных концептов контейнеров. Например, у contiguous контейнера операции доступа к элементам не должны инвалидировать память, а resize и reserve точно инвалидируют.
Ещё интересно, есть ли у вас привязка времени жизни слабой ссылки (например, внутри итератора) к времени жизни сильной ссылки?(Надеюсь я правильно применил термины которые вы используете для плагина) В memory safety профиле если не ошибаюсь благодаря такому слежению помимо предотвращения доступа после освобождения памяти этот механизм используется для контроля алиасинга. Это конечно снова требует аннотаций, но от этого не сбежать.
rsashka Автор
30.01.2025 20:36Без аннотаций это действительно невозможно и по моему мнению. Но для их применения достаточно уже текущих стандартов С++ https://github.com/rsashka/memsafe
Что же касается ошибок программистов при использования итераторов, то я как раз и решал задачу по поиску подобных ошибок.
Слабые ссылки внутри итератора никак не используются, так как сами итераторы реализуются на другом принципе, тогда как самим слабым ссылкам вообще не требуется никакого контроля (контроль с помощью плагина нужен только для сильных ссылок).
kozlyuk
Если алгоритм позволяет получать итераторы только в момент первого использования, то
DEPEND()
не нужен, вместо*x
достаточноvec.begin()
. Покажите полезный пример, где получать итераторы заранее необходимо, иDEPEND()
защищает от ошибок.Если запретить использовать итераторы без обертки, как реализовывать алгоритмы на итераторах? Мне нужен
pivot = std::partition(left, right)
для текущих значений итераторов, потому что потом я их поменяю.rsashka Автор
Пример с получением итераторов заранее показан в самом начале статьи. Может быть он и не очень "полезный", но это реальный код, в котором DEPEND() выполняет свою функцию.
Ваш второй пример с прямым использованием итератором std::partition(vec.begin(), vec.end()) в качестве аргументов функции не противоречит ограничениям на сохранение итераторов в переменные с помощью обертки DEPEND(), так как аргументы функции не создают переменных, которые могут протухнуть после из использования.
Разве что, может быть, если кроме итераторов диапазона в качестве аргумента передавать ещё и сам объект. Но тут уже я не могу придумать полезность подобной функции.
kozlyuk
В примере из статьи
x
иy
не используются до инвалидации. Под "полезным" примером я подразумеваю такой, где необходимо завести переменную-итератор, чтобы сохранить семантику кода. Например, работа с результатомstd::remove_if()
, которая не сводится только к передаче его вvec.erase()
.В моем примере аргументами
std::partition()
выступают итераторы, которые будут меняться, о чем я написал, и результат — тоже итератор, который куда-то пойдет. Типичный код из середины quicksort, которая потом пойдет обрабатывать[left; pivot)
и[pivot; right)
. Разумеется, я не хочу вызыватьstd::partition()
на каждое обращение кpivot
, как это делаетDEPEND()
.