Как стать автором
Обновить

Комментарии 23

Последние годы пишу в основном на Java и на C++ поддерживаю только legacy код.
Это такое счастье забыть про core файлы и gdb и «все пропало… сделай что ни будь что бы обработчик не падал, но ничего кроме core файла нет».

Прочитал статью и порадовался, что не приходится это снова писать. Каждый кто писал на C++ эти и подобные «обертки» пытался делать.

Мы за вас тоже рады, но кто-то должен писать на C++ нативные вещи, чтобы вы могли потом использовать их из своей уютной Java, не так ли?

НЛО прилетело и опубликовало эту надпись здесь
Отличная работа. C++ и понимание UB на первом курсе это круто.
Покритикую оформление, примеры в гифках это очень плохая идея. За них очень легко цепляться при беглом чтении, но результаты висят меньше секунды, прочитать ничего невозможно. Продублируйте статическим текстом, будет значительно лучше.
Для записи консоли — отличная штука asciinema
Извините, немного не понял нужность теста на переполнение при суммировании. Если нам нужно, чтобы никогда в рантайме не переполнялся результат, мы можем пойти нескольким путями:
1. Проверять флаг переполнения после операции. Непереносимо, но быстро и дубово
2. Приводить перед сложением переменные на тип «выше», складывать, сравнивать с соответствующим INT_MAX, при успехе кастовать обратно
3. Проверять как Вы проверяете
4. Собираться с ключом -ftrapv и обрабатывать SIGABRT.
Но в большинстве случаев для передаваемых снаружи библиотеки/функции целых мы пишем "+", потому что Int большой, его скорее всего хватит, а нас устраивают риски
Немного не понял, про какие именно флаги Вы говорите. Если Вы имеете в виду флаги из <cerrno>, <cmath> или <fenv.h>, то на обычном сложении int-ов ни один из них не сработает: если проверять их постфактум, то уже случилось UB, и дальнейшие предположения о поведении программы невозможны; если приводить к float или double — то, очевидно, будут проблемы с диапазонами значений (у int-а он немного другой, чем у типов с плавающей точкой).
Про использование флагов непосредственно процессора и -ftrapv: есть обсуждение на стеке.
Насчет рисков — риски нас не устраивают. Задачей было написать программу, которая может давать определенные гарантии пользователю. Поэтому необходимо было обработать все случаи, в том числе и такие:
  • тип long long (или, если угодно, __int128), который не получится полностью покрыть каким-либо другим типом;
  • переполнения в беззнаковых типах, которые по факту UB не являются, но сообщить о них пользователю обязательно.
Я имел в виду именно флаг процессора и постфактум. Всё-таки
если проверять их постфактум, то уже случилось UB, и дальнейшие предположения о поведении программы невозможны
я не могу вспомнить ни одного компилятора, который в этом случае «ломает» что-то сверх операции суммирования, если с помощью ключей мы убедились, что компилятор не считает переполнение невозможны (стандартное поведение gcc с 2008, проверял в последний раз в 2016м). И да, проверять флаг процессора проще всего на ассемблере. Альтернатива — builtins gcc или clang
если приводить к float или double
Да не дай Шворц. К long или long long, если паранойя, всё-таки стандарт гарантирует int <= long <= long long. Сейчас чаще всего int 4байтовый, long long 8 байт.

Если проверить на входе уже длинный тип, то введение собственного «ещё более длинного» типа не вариант, тут действительно надо проверять заранее. Конечно, можно упороться (см.комменты), но принцип ровно тот же, что у вас в статье.
переполнения в беззнаковых типах, которые по факту UB не являются, но сообщить о них пользователю обязательно
А разве ключами компилятора это сделать нельзя? Интересно, кто-нибудь помнит все ключи gcc?..
Да, теперь Вас понял.
Встроенные чекеры в gcc и clang и вправду работают гарантированно, поэтому в случае именно с этим компиляторами, возможно, стоило использовать именно их. Спасибо!

Нужных ключей, по крайней мере у gcc, увы, я не нашел (и поискав слово overflow по странице, и перебрав все руками).
Встроенные чекеры в gcc и clang и вправду работают гарантированно
Наш любимый третий компилятор похоже до сих пор не умеет работать так с signed

Да, погуглил, unsigned wraparound gcc действительно не ловит. Clang ловит с ключом -fsanitize=unsigned-integer-overflow

А в чем именно проблема с несколькими правилами применяемыми к одному и тому же месту? Вроде как если идти восходящим поиском и менять AST то проблем быть не должно, или нет?
И почему макросы а не функции? Как такой код будет обработан?


arr[foo(1,2)] = 42;

Действительно, на стадии разработки идеи, мы думали над тем, чтобы изменять AST. Но возникает несколько проблем. Во-первых, после поисков информации, оказалось, что clang не особо предусматривает такой функционал. То есть, скорее всего, это не невозможно сделать, но и не просто). Также, если как-то удастся менять AST, изменение одного узла, должно как-то повлиять на позиции символов в строке, в файле. Это кажется совсем нетривиальным, так как AST мало чего понимает о том как узлы дерева связаны с позициями в файле.

Маросы для того, чтобы получить номер строки и название файла из кода. Фукнции такого не умеет, только если очень сильно извратиться. То есть наши макросы устроены примерно так:
ASSERT_WHATEVER (PARAMS) checkWhatever(PARAMS, __FILE__, __LINE__)

Приведенный код будет обработан следующим образом:
ASSERT_IOB(arr, foo(1, 2)) = 42;
Внутри макроса завернута функция сохраняющая категорию значения, то есть присвоение произойдет корректно.
Учитывая что clang-format основан на clang и изменение AST и привязка к строкам вполне реализуема, возможно вам будет интересно посмотреть как они это делают.

С макросом в принципе всё понятно (хотя если заниматься реконструкцией AST назад в код то строки и файлы сразу доступны), но есть традиционный comma operator который всегда макросы портит. К примеру вот вам валидный код который умрёт от прямой замены:
int a[2] = {};
a[0,1] = 4;

Классическое решение — заворачивать аргумент макроса в скобки.
Да, Вы абсолютно правы насчет макросов, мы об этом забыли… Обязательно поправим в проекте! В статье решили не поправлять, так как сильно упадет читаемость из-за большого количества скобок.
Спасибо за проявленное внимание к проекту!

Поправьте пожалуйста в статье в первом куске кода с принтф'ами и изображением дерева ветки ветвления then и else. Они перепутаны.

На всякий случай — кажите, а вы в курсе о существовании clang.llvm.org/docs/UndefinedBehaviorSanitizer.html?

Вроде как он уже все вышеописанное (и много чего еще) делает.
Конечно, в курсе, и про sanitizer, и про valgrind, и статический clang-check — мы их упоминаем в свертываемом элементе в начале статьи. Но у нас не стояло цели создать им конкуренцию, о чем мы говорим в заключении :)

Кстати, конкретно -fsanitize=undefined не найдет в следующем коде UB даже при вводе «5»:
  int a, b; // b is uninit
  std::cin >> a;
  if (a != 5)
    b = 5;
  std::cout << b << '\n'; // b is still uninit if a == 5, UB

А наш анализатор найдет. Конечно, глупо сравнивать его и sanitizer, но тем не менее, у нас даже есть некоторое преимущество!

Вы упомянули о clang static analyzer а это не санитайзер. А в "динамические valgrind и sanitizer" не совсем очевидно, что речь именно о -fsanitize в clang/gcc. Будем считать что упомянули. :-)


Для вашего примера санитайзер не нужен. Clang такое ловит сам с -Wall -Werror во врямя компиляции:
https://godbolt.org/z/Edc4rq


source:5:7: error: variable 'b' is used uninitialized whenever 'if' condition is false [-Werror,-Wsometimes-uninitialized]
  if (a != 5) b = 5;
      ^~~~~~
source:6:16: note: uninitialized use occurs here
  std::cout << b << '\n';  // b is still uninit if a == 5, UB
               ^
source:5:3: note: remove the 'if' if its condition is always true
  if (a != 5) b = 5;
  ^~~~~~~~~~~~
source:3:11: note: initialize the variable 'b' to silence this warning
  int a, b;  // b is uninit
          ^
           = 0
1 error generated.

Конкретно для такой штуки, небось, memory sanitizer справится. С ним, правда, другие тонкости есть.

Как курсовая — отлично, как способ разобраться в clang — великолепно, как штука для практического применения — ненужно.
Попробую конструктивно покритиковать.
1) время компиляции увеличивается кратно.
2) код раздувается, его оптимизация ломается.
3) время выполнения кода увеличивается (об этом упомянуто).
4) ошибки будут найдены либо случайно (если дойдет ветвление, если подберутся условия ub), либо их нужно искать целенаправлено (читай — писать тесты). Т.е. добавление проверок никак не помогает обнаружить ошибку, но помогает ее быстрее локализовать в ходе случайного или целенаправленого возникновения.
5) согласно описанию, анализ и птсинг кода слишком примитивен. Для большинства мест в коде, можно доказать отсутствие ub, но, похоже, алгоритм все равно вставит патч. Например, for(auto i = 0; i < array.size(); i++) array[I];

Вы абсолютно правы насчет применения и пунктов 1-4 :) Что касается пункта 5, то Вы тоже правы, но тут есть, что добавить. Доказать отсутствие UB — отнюдь не тривиальная задача. Например, тот кусок кода, который Вы привели, в определенном контексте вполне может его вызывать:
struct Foo {
  long long size() { return 1ll << 60; }

  int operator[](signed int& i) {
    if (i == 5)
      i = __INT_MAX__; // на следующем шаге цикла знаковое переполнение - UB
    return i;
  }
};

int main() {
  Foo array;
  for (auto i = 0; i < array.size(); i++)
    array[i]; // i обязано иметь тип signed int, чтобы можно было взять ссылку
}

Вероятно, если проверить, что array — это std::vector или другая структура из STL, то тогда этот код будет действительно безопасен, но данный пример хорошо иллюстрирует, что доказательство даже для такого простого случая не будет столь же простым. Возможно, это тема для отдельной курсовой работы…
Зарегистрируйтесь на Хабре, чтобы оставить комментарий