После публикации предыдущей статьи на данную тему, некоторые читатели не обратили внимания, что данный проект, это не действующий инструмент, готовый для боевого применения в реальных проектах, а только доказательство работоспособости концепции использования плагинов компилятора для дополнительного семантического контроля исходного кода С++ во время компиляции. А в качестве примера реализации подобного плагина я взял концепцию безопасной работы с памятью из языка 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 и это легко детектируется во время отладки и тестирования.
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()
.