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

Комментарии (3)


  1. kozlyuk
    30.01.2025 20:36

    1. Если алгоритм позволяет получать итераторы только в момент первого использования, то DEPEND() не нужен, вместо *x достаточно vec.begin(). Покажите полезный пример, где получать итераторы заранее необходимо, и DEPEND() защищает от ошибок.

    2. Если запретить использовать итераторы без обертки, как реализовывать алгоритмы на итераторах? Мне нужен pivot = std::partition(left, right) для текущих значений итераторов, потому что потом я их поменяю.


    1. rsashka Автор
      30.01.2025 20:36

      Пример с получением итераторов заранее показан в самом начале статьи. Может быть он и не очень "полезный", но это реальный код, в котором DEPEND() выполняет свою функцию.

      Ваш второй пример с прямым использованием итератором std::partition(vec.begin(), vec.end()) в качестве аргументов функции не противоречит ограничениям на сохранение итераторов в переменные с помощью обертки DEPEND(), так как аргументы функции не создают переменных, которые могут протухнуть после из использования.

      Разве что, может быть, если кроме итераторов диапазона в качестве аргумента передавать ещё и сам объект. Но тут уже я не могу придумать полезность подобной функции.


      1. kozlyuk
        30.01.2025 20:36

        В примере из статьи x и y не используются до инвалидации. Под "полезным" примером я подразумеваю такой, где необходимо завести переменную-итератор, чтобы сохранить семантику кода. Например, работа с результатом std::remove_if(), которая не сводится только к передаче его в vec.erase().

        В моем примере аргументами std::partition() выступают итераторы, которые будут меняться, о чем я написал, и результат — тоже итератор, который куда-то пойдет. Типичный код из середины quicksort, которая потом пойдет обрабатывать [left; pivot) и [pivot; right). Разумеется, я не хочу вызывать std::partition() на каждое обращение к pivot, как это делает DEPEND().