Pull to refresh

Comments 157

Следовать лучшим практикам разработки

А ссылка на эти эзотерические практики будет?

Без статического анализатора, который проверяет следование этим правилам, толку от них довольно мало.

Разрабатывать на C++ в целом сравнительно хлопотное занятие. Как минимум нужно:

  • Включить все предупреждения

  • Превратить их в ошибки компиляции

  • Добавить флаги вроде (-pedantic)

  • Использовать статические анализаторы

  • Использоваться санитайзеры

  • Использовать фаззинг

  • Опционально использовать как минимум три разных компилятора

И это мы ещё не дошли до сборки проекта и управления зависимостями ;) Там отдельный прекрасный мир, даже учитывая наличие conan/vcpkg и CMake.

И даже это всё не помогает даже на уровне учебных задач для студентов. Я несколько лет требовал всё это (кроме фаззинга, а зря) в автоматическом режиме для получения ненулевых баллов по домашним заданиям, и всё равно возникали проблемы с UB. В итоге получился доклад: https://cppconf.ru/talks/dd008542beaf4076be59f6a4a2064df9/

Основной тезис — есть куча багов, на которые может наткнуться даже студент.

  • Бывают баги в комбинациях (компилятор, стандартная библиотека, динамический анализатор): показывает UB там, где его нет. Было минимум два раза.

  • Бывает много интересного UB, которое не ловится санитайзерами: вызов std::exit() в деструкторе глобальной переменной, например.

  • Бывают баги в компиляторах: в MinGW очень сильно сломан thread_local.

  • В сторонних библиотеках (даже Boost) регулярно встречается UB, которое ловят санитайзеры, но по факту пока ещё не рушит код. Хотим санитайзеры — надо сразу править у себя зависимости.

  • Санитайзеры умеют находить далеко не все простые выходы за границу массива.

  • Даже в статическом анализаторе компилятора бывают баги: говорит, что есть очевидный use-after-free, а его на самом деле нет.

  • По стандарту C++ есть много странных мест. Например, конструкция for (int element : some_vector) в C++03 (который не поддерживает range-based-for) имеет полное право компилироваться и рушить программу.

  • По факту есть некоторые запрещённые вещи, не указанные ни в стандарте, ни в документации. Просто знать надо. Например, нельзя создать глобальную переменнуюint read; и слинковать с ключом -static. Даже под виндой.

Честно говоря, каждый раз сталкиваясь с CMake, я в шоке от того что он стал индустриальным стандартом. Абсолютно упоротая, неинтуитивная жесть

Да, CMake ужасен. Знаю людей, которые делегируют написание кода на нём ChatGPT :)

Да, потрясающая дрянь. Но тут "чем хуже, тем лучше", видимо.

Это так ровно до того момента как вам понадобиться править древний и большой проект на autotools. Там полюбишь хоть черта лысого.

Не поймите неправильно, autotools я увадаю, но когда там начинают километровыми m4-макросами бросать направо и налево - хочется выйти в окно :-(

Третий пример не сильно то и про c++. Писать и читать массив из разных потоков одновременно - будет плохо в любом языке.

Понимаю, что проблема синхронизации потоков имеет место во многих языках. C/C++ интересны тем, что их стандарты вводят определение Undefined Behavior. То есть стандарт прямо говорит, что в этой ситуации может произойти что угодно. В моем случае метод поиска в таблице не работал. Это выглядело интересно

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

Нельзя вот так просто взять и синхронизировать через глобальную перененную...

Ну разве что, это переменная примитивного типа (индекс в буфере), которую меняют потокобезопасно - через InterlockedIncrement/InterlockedExchange.

Это работает только потому что эти функции создают барьеры. Атомарное изменение переменной индекса тут "постольку поскольку". Можно положить thread в память и честно атомарно инкрементировать индекс, и чтец его целым прочитает. Только вот thread в память ещё не доехал...

Может в Interlocked какие-то барьеры (не знаю деталей; это же из WinAPI функции), но вот для атомика можно инкремент/декремент с std::memory_order_relaxed. Т.е. барьера (переупорядочивания) операций не будет.

На самом деле, не совсем так. На x86 memory order не будут работать так, как описаны в стандарте. Архитектурно не предусмотрено. Любое атомарное действие автоматически синхронизирует кэши безо всякого переупорядочивания. По сути, любой из предложенных memory order-ов будет seq_cst.

А вот всякие армы такое умеют.

Переупорядочивание же и на уровне компиляции может быть.

Барьер говорит, что ни-ни, ни компилятор не переложит инструкции через барьер (в целях оптимизации конвейера, например), ни процессор не исполнят что-нибудь заранее. Поэтому если на уровне железа атомик на x86 всегда seq, это вовсе не означает, что на уровне компилятора он должен перестать быть zero-cost, если так можно.

Спасибо. Про влияние компилятора я не подумал. Буду теперь в курсе.

В любом языке в многопоточности будет полное UB и может ломаться внутренний метод и вести себя неожиданно. За исключением разве что языков, где выполнение всегда гарантированно однопоточное, вроде JavaScript и Python.

То есть стандарт прямо говорит, что в этой ситуации может произойти что угодно. В моем случае метод поиска в таблице не работал. Это выглядело интересно

Это выглядит совершенно нормально. Стандарт ни разу не обещал многопоточную целостность для STL контейнеров.

Зачем вы вообще создаёте лишний поток, который создаёт потоки? Ничего кроме лишнего геморроя это не принесёт.

Создание потоков вообще не лучшая практика. Нынче принятно всё лопатить в thread pools. Либо используйте более высокоуровневые примитивы типа future/promise. Они как минимум берут на себя все эти проблемы многопоточной синхронизации.

Стандарт ни разу не обещал многопоточную целостность для STL контейнеров.

Там тоньше - не обещал многопоточную целостность на изменение, но обещал целостность на чтение. Можно же представить себе кеширующие структуры, которые на чтение будут изменять своё внутреннее состояние.

Да не в любом.
Язык же может и "соломки подложить". Окружить мьютексом, например.
Тут важно именно zero-cost abstraction. Если ты полез в массив из разных потоков одновременно - значит, ты ЗНАЕШЬ, что делаешь. Язык с zero-cost НЕ БУДЕТ тебе подкладывать мьютекс или ещё как-то помогать (потому что мьютекс - уже не zero-cost) За счёт этого ты можешь применять разные трюки с lock-free и прочими низкоуровневыми вещами, ориентированными на конкретное железо.

Мои навыки программиста распространяются только на два языка: c++ и java. Я просто не в курсе, как обстоят дела с другими языками, может и ошибусь сейчас. Так вот, мне кажется, что никакой язык, который позволяет программисту писать многопоточный код, не "подложит мьютекс" автоматически. Ну то есть программист в любом случае должен как-то намекнуть компилятору/интерпретатору, что обращение к массиву будет из разных потоков. Повторюсь, возможно ошибаюсь, и такой язык существует. Ну а если нет, то и c++ и java вполне себе умеют "соломки подкладывать".

Rust? Не мьютекс, но есть гарантии владения объекта потоком, а программист уже вынужден как-то реализовать захват.

Если ты полез в массив из разных потоков одновременно - значит, ты ЗНАЕШЬ, что делаешь.

Или, что гораздо вероятнее, ту переписывал однопоточный код на многопоточный и упустил все места, где доступ осуществляется из нескольких потоков.

теги что надо(

ощущаются будто C++ это синоним abnormal programming

Пишешь на плюсах неаккуратно - получаешь "ненормальное программирование" :)

Наоборот, аккуратно писать на плюсах - "ненормальное программирование", потому что выходит за пределы возможностей обычного человека.

Не выучил наизусть 1500 страничную документацию - не мужик.

В первом примере по-моему вы ссылаетесь не на то место в стандарте, и время жизни временной переменной чуть больше. Временный объект - это результат вызова GetBoard(), так как он передается по константной ссылке - то его время жизни расширяется до конца полного выражения. Конструктор named_arg получает уже ссылку, и никак на время жизни объекта не влияет. Временный объект живет до конца всего выражения. Но вот использовать полученный auto args уже нельзя; как только это выражение закончится временный объект уничтожится.

В документации fmt про это написано, и даже дан пример:

class fmt::basic_format_args
A view of a collection of formatting arguments. To avoid lifetime issues it should only be used as a parameter type in type-erased functions such as vformat:

void vlog(string_view format_str, format_args args); // OK
format_args args = make_format_args(); // Error: dangling reference

То есть если бы вы не сохраняли результат make_format_args а сразу передали бы его дальше - всё было бы хорошо.

У меня на работе fmt версии 9. Там функция make_format_args выглядит так:

template <typename Context = format_context, typename... Args>
constexpr auto make_format_args(Args&&... args)
    -> format_arg_store<Context, remove_cvref_t<Args>...> {
  return {std::forward<Args>(args)...};
}

То есть она принимает forwarding reference, что позволяет передавать ей в качестве аргументов временные объекты. В моем случае - результат вызова fmt::arg. Это значит, что результат вызова make_format_arg можно сохранить и позже использовать, но только в том случае, если аргумент fmt::arg - l-value reference.

Но в остальном вы правы. Здесь несколько иная проблема, чем то, что описано в статье. Я поправлю. Спасибо

Ну да, проблема только со временным объектом - строкой - результатом GetBoard(). Он передается по константной ссылке, и этого достаточно чтобы его время его жизни расширилось на весь statement. С остальными временными объектами все в порядке, они копируются или "двигаются", но так как содержат только ссылки то никаких проблем. Всё висит на аргументе fmt::arg, если он временный объект то результат сохранять нельзя, иначе можно.

Мне казалось, что наличие константной ссылки на временный объект продлевает время его жизни (процесс, известный как lifetime extension).

Вроде бы это работает только тогда, когда временный объект сохраняется в локальную ссылочную переменную.

На C++ впринципе нельзя писать безопасный код.

Расскажи это стандарту misra, чет кроме c и c++ альтернатив то и не предвидится в местах где эта безопасность реально нужна и где ошибки в коде это потенциальные жизни, а не упавший сервер с картинками

Я обычно говорю в таких случаях - разруха не в клозетах, а в головах, рулят не языки, а практики, а "безопасных языков", которые позволят "каждому кретину" писать безошибочный и безопасный код, не существует.

Rust относительно безопасен для low-level штук, которые обычно пишут на С/C++

Это типичное заблуждение. Low-level штуки, которые обычно пишут на C/C++ на расте - это unsafe (пусть даже через некую красивую обертку), а unsafe в расте - это еще более забористая по части всяких UB штука, чем C/C++. На самом деле никто точно не знает, что там является UB, а что - нет :)

Unsafe в Расте изолирован и его довольно таки немного, весь остальной код просто полагается на то, что unsafe написан правильно, и вот эта часть статически верифицируется.

То есть, в итоге это все равно намного лучше, чем когда весь вообще код может быть unsafe.

Unsafe в Расте изолирован

Он изолирован только визуально. UB в unsafe коде может затронуть поведение абсолютно любого куска программы, ибо это UB.

и вот эта часть статически верифицируется

За счет наличия чудовищных ограничений, для обхода которых и нужен unsafe. Чем более сложная логика и структуры данных нужны, тем больше будет unsafe, неважно - сами ли вы будете писать unsafe код или использовать готовый со стороны (тоже неизвестно кем и как написанный). В общем, ладно, уже многократно обсуждалось по кругу, каждый все равно остается при своем мнении :)

Это далеко не только визуально, а ограничения вполне себе адекватные, по большей части.

Как можно заметить, большая часть кода выложенная на crates.io она таки safe, причем даже в embedded. О чем, собственно, и разговор. Куда проще отследить и доказать безопасность небольшого подмножества кода, чем вообще всего, потому что unsafe операции могут где угодно встретиться.

Это однозначно лучше, чем когда весь код вообще unsafe.

В Rust народ очень любит фигачить unwrap куда ни попадя. Не то, чтобы это UB, но до безопасности тут чудовищно далеко.

Ну так можно и пароли в логи печатать и множество других логических ошибок делать. Но блин, это много безопаснее, чем UB, которое может стрелять очень далеко от места возникновения.

Но блин, это много безопаснее, чем UB

Безусловно, это много лучше хотя бы тем, что unwrap легко найти. Но, как говорится, можно вывезти девушку из деревни, но вот деревню из девушки... В смысле, на кой чёрт нужны все эти чудесные Result, если код может просто обвалиться в каком-то бинарном парсере.

MISRA это не про безопасный код, а про compliance. Исследования результатов реального применения MISRA довольно печальные - есть несколько нормальных правил, но большинство либо слишком шумные, либо ухудшают надёжность кода.

Можно писать безопасный код на чём угодно, и небезопасный тоже можно на чём угодно написать. Вопрос лишь в том, насколько просто или сложно это сделать на том или ином языке.

Можно. Просто нужно ограничить использование сложных для понимания фишек + статический анализатор использовать.

Любой статический анализатор, например PC Lint, нашёл бы в этом коде сразу все проблемы.

2 пример, классический пример, того, что должно быть чётко прописано в стандарте, да и вообще не надо так делать на любом языке. Лучше передать мьютекс как зависимость из вне.

3 пример тоже как делать в принципе не надо, а не только в С++.

Конечно, для написания безопасного кода на любом языке нужно писать как можно проще и делать это стандартом. Для c++ есть санитайзеры, это хорошо. А то есть примеры когда написанный и хорошо протестированный код с++ для безопасности переписывают на С. Мало того что для микроконтроллера это ведёт к увеличению flash памяти, так и в процессе рефакторинга с++ можно на С тоже накосячить. Для справки по микроконтроллерам: ведущие производители интеллектуальных датчиков используют C++ для стандарта (SIL2 и больше) безопасных приборов. Процесс разработки на всех этапах начиная с архитектуры и стандарта кодирования жёстко контролируется сертификацией на данный стандарт, как это делается например в сертификации на стандарт по взрывозащите по ATEX.

Ну для SIL оба языка и С и С++, стоят как рекомендованные только вместе со статический анализатором. Не рекомендованы динамическое выделение памяти, указатели и так далее. Их использование должно быть минимизировано. На Си это конечно сделать намного труднее, если вообще невозможно.

Вот Ada там стоит как рекомендованно без ограничений :).

А так да конечно, практически безопасность обеспечивается правильным процессом, если есть детальные требования к критически важным частям, дизайн, юнит тесты, ревью, анализатор кода, смок тесты, системные тесты, стандарт кодирования и вся команда чётко этому следует вероятность ошибок в коде падает кратно.

Вот я смотрю на эти примеры кода и не понимаю этот синтаксис. Я пишу на С/С++ под микроконтроллеры, у меня и библиотеки и модульность и переиспользование. А тут накручено такого синтаксиса, что может по этому и нужно обмазываться кучей анализаторов и санитайзеров, чтоб не нарваться на УБ? Может писать проще и анализировать будет проще?

Но тогда не будет красиво, модно, молодежно. Народ начнет ныть как все устарело, а комитет вернётся к преподаванию вместо написания новых стандартов. Помогал переводить легаси проект с 98х недавно, тот ещё ужас, хотя и написано как вы говорите проще

Пробовали переписать все на раст, но вылезло очень много проблем и ошибок, по итогу получилось пятая часть на расте, процентов 10 осталось Легаси, потому что не смогли перенести, остальное на семнадцатом стандарте

Почему выбрали раст? И почему не оставили эту затею, когда полезли проблемы?

Так хотел заказчик изначально - модно, молодёжно :) но когда сделали первые изменения и выкатили смету и оценку времени на перевод - уже перехотел. В итоге сошлись на компромисе, что растовая часть уйдет в либу, кор часть (которая если честно была написана на чистом С и просто притворялась плюсами) останется на 98х, а обвес передеет на 17 стандарт.

Если у вас С++, у вас тоже UB везде по коду, просто вы про это не знаете. Точно же используете преобразование целого в указатель и обратно, ну как минимум для обращения к регистрам, а это UB.

union используете для преобразования из одного типа в другой или обращения к разным типам, а это UB.

То, что писать надо проще это факт, но что такое проще, непонятно, switch это проще? Нет, потому что это ветвление, а код должен быть линейный, любое ветвление, это гемор с юнит тестами, тестами и потенциальная ошибка.

Поэтому я бы сказал, что код должен быть выразительнее. Т. Е. небольшие линейные функции. Ну и понятнее.

На сколько я понимаю про УБ, то оно возникает как следствие оптимизации кода, когда компилятор решает сам как должно быть лучше. А без оптимизации я даже примерно представляю, что должно получиться в ассемблере и там всё норм, поведение определено архитектурой.

На сколько я понимаю про УБ, то оно возникает как следствие оптимизации кода, когда компилятор решает сам как должно быть лучше.

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

А без оптимизации я даже примерно представляю, что должно получиться в ассемблере и там всё норм, поведение определено архитектурой.

Полагаться на везение не есть хорошо. Но смотря что пишем, конечно.

UB не возникает как следствие оптимизации кода.

Оно реализуется в следствии оптимизации кода. Есть такая чудесная статья What every compiler writer should know about programmers or “Optimization” based on undefined behaviour hurts performance, в ней как раз рассказывается, что в древние времена UB позволяло учесть особенности компилятора и платформы за счёт неуниверсальности программы. Например, страница 0 на POWER/AIX сплошь заполнена нулями.

То есть, UB сейчас и в древние времена трактуется по-разному. Раньше это была возможность подточить язык C для эффективного использования на конкретной платформе, а сейчас это запрещённый приём для программиста.

Полагаться на везение не есть хорошо

Или на компилятор. Компилятор может и не быть плюсовым с точки зрения формального следования стандарту. Тогда какие-то вещи, которые UB в C++ в этом C++' могут быть well-defined.

Да, уже посмотрел в стандарте:

C++17 §8.2.10/5 [expr.reinterpret.cast]

A value of integral type or enumeration type can be explicitly converted to a pointer. A pointer converted to an integer of sufficient size (if any such exists on the implementation) and back to the same pointer type will have its original value; mappings between pointers and integers are otherwise implementation-defined. [ Note: Except as described in 6.7.4.3, the result of such a conversion will not be a safely-derived pointer value. — end note ]

Но вот аскажем укзатель одного типа в указатель другого уже точно UB

int i = 0; short* ptr = (short*) &i;

тут чтоно уже strict aliasing

почему УБ? Из-за порядка байтов в слове?

Компилятору разрешено не отслеживать указатели разных типов, полагая что участки куда они ссылаются разные, раз типы разные. А раз так, он может, например, взять и переставить местами инструкции записи и последующего чтения, казалось бы, одного и того же участка памяти.

Странно это, компилятору разрешено не отслеживать и делать предположения вместо того, чтобы сравнить адреса. Ведь компилятор в курсе про адреса, которые он накомпилировал, ведь в курсе? Похоже надо вводить в код директивы отмены оптимизации, чтобы компилятор на критических участках не навыдумывал себе перестановок записи и чтения.

Если доступ осуществляется через указатели, компилятор в общем случае не может знать, чему они равны.

В общем случае это так, действительно. Но гонять по массиву-буферу двумя указателями, одним для записи двойных слов, а другим для чтения одинарный слов - совершенно рабочий подход, как мне думается.

Если компилятор полезет менять последовательность операций, то это же будет фиаско.

Поэтому и написали в стандарте, что по умолчанию указатели на несовместимые типы не равны. И это верно в 99% случаев.

Если компилятор полезет менять последовательность операций, то это же будет фиаско.

Строго говоря, это нарушение фон Неймановской архитектуры. На такие махинации ещё можно закрывать глаза когда комилятор точно знает что он делает и его манипуляции остаются незамеченными. А вот так, типа это ваша программиста забота сделать так чтобы я компилятор не переупорядочил поток ваших команд - это перебор уже.

Проблема в любой попытке "отменить оптимизации" одна: а какое поведение "правильное" или "не соптимимзированное"? Это либо надо чётко прописывать (в тот же стандарт, что безумно муторно), либо надеяться, что ваша интуиция совпадёт с интуицией разработчиков компиляторов. Оба варианта мне кажутся малореалистичными.

Например, кому-то очевидно, что в 32-битных программах для вещественных чисел надо использовать сопроцессор x87 с 80-битными числами (потому что вдруг мы компилируем под что-то безумно древнее), а кому-то очевидно, что надо использовать SSE с 64-битными числами если доступно. А это отличие влияет на excess precision и добавляет/убирает некоторую недетерминированность вычислений с вещественными числами.

Неоптимизированным нужно считать поведение соответствующее логике написанного кода. И тут не нужно стандарт дописывать. И даже если нам нужно использовать SSE, то в неоптимизированном коде SSE будет использоваться в том самом месте, где происходят вычисления его (SSE) требующие. Точность вычислений будет меняться, но логика их обработки, что самое главное, останется прежней. Детерминированность вычислений можно задавать погрешностью.

Так логика зависит от точности

if (delta < eps) return;

Ну так и надо задавать ошибку с запасом, чтобы логика от точности не зависила.

Это если в момент написания кода держать в уме все "запасы".
А бывает проект на 10 млн. строк, пишущийся с лохматого года. Кто ж тогда знал, что x87 не вечный...

Постойте, а разве вычисления с лохматых годов потеряли в точности? Или float-ты усохли?

А, понял, выбор разрядности типа отдали на откуп компилятору....

соответствующее логике написанного кода

Так в этом и проблема. Если в коде написано x * 2, а компилятор заменяет на x << 1 — это соответствует логике? А замена x - x на 0? А замена -x на ~x + 1? Стандарт говорит, что да, потому что он говорит про арифметические операции, а как там числа представляются — внутреннее дело компилятора.

И вместе с тем стандарт не определяет абсолютно все операции с целыми числами. Например результат -x определён только если это значение помещается в int, а если не помещается — стандарт ничего не обещает. Вот все такие штуки надо строго определить, чтобы говорить про "логику кода",

а как там числа представляются — внутреннее дело компилятора.

Начиная с c++20 это не так, two's complement теперь обязателен. §6.8.1/3

Для хранения бит — да, но выражение -INT_MIN всё ещё может быть не равно самому себе: https://godbolt.org/z/aM3nTc35P

Удивительно, но добавив const перед int, я добился, чтобы компилятор посчитал, что выражения равны ))

Удивительно, но добавив const перед int, я добился, чтобы компилятор посчитал, что выражения равны ))

UB такое UB. гг.

Это нормально умножить на 2 сдвигом, это один из вычислительных методов, выражение упрощать тоже норм. А если результат операции не помещается в тип, то остаётся то, что помещается, а остальное отваливается и не надо ничего обещать по стандарту. Зачем при переполнении по коленям стрелять?

Но это же другие инструкции. Например, флаг CR не всегда будет установлен сдвигом, в отличие от умножения. Где граница между "очевидный вычислительный метод" и "привязка к конкретному процессору"?

	mov ecx, 0x50000000
	shl ecx, 3  ; CR = 0
	
	mov eax, 0x50000000
	mov ebx, 8
	imul ebx    ; CR = 1

Единственное промежуточное состояние между полным UB и "привязаться к конкретному процессору" (как сделали в Java), мне кажется — это заставить компилятор чётко документировать под каждый процессор всё поведение.

Во первых вы не тот флаг смотрите. Для переполнение есть OF. А CF это флаг переноса.

Что бы была граница надо явно указывать что вы ожидаете. Или быструю модульную арифметику, или с проверками на переполнения или если надо то арифметику с насыщением или же иные представления чисел. Кстати тоже самое касается и чисел с плавающей точкой. Так что или специальные типы вводите или конструкции языка, которые явно это указывают иначе будет бардак, как сейчас.

Так ведь можно же, вон в <cstddef> придумали байт и написали к нему операции.

enum class byte : unsigned char {};

> А без оптимизации я даже примерно представляю, что должно получиться в ассемблере и там всё норм, поведение определено архитектурой.

Я бы не был так уверен, потому что граница между "оптимизация" и "не оптимизация" весьма тонка. Даже с -O0 компилятор может "наоптимизировать". Из тривиального — арифметика будет заменена на побитовые операции или будут сокращены слагаемые или c == -c окажется верным даже для INT_MIN.

Из чуть менее тривиального: вот такая функция может упасть с сегфолтом в некоторой программе, которая скомпилирована без оптимизаций. Вы сразу понимаете, как так ( спойлер)?

void print(bool x) {
    const char *names[] = {"false", "true"};
    printf(names[x]);
}

Передача неинициализированных переменных это UB. Такая программа может, как водится, и rm -rf дёрнуть, даже если скомпилирована с оптимизациями.

Такая программа максимум дернет левый адрес с мусором или из запрещённой области. Но вызывать что попало, это же call надо сгенерировать. С чего вдруг то?!

С того, что стандарт не запрещает. UB это не когда происходит что-то наиболее вероятное, это когда произойти моежт что угодно вообще. И, к слову, за примерами вызовов компилятором не вызываемых явно функций далеко ходить не надо: https://godbolt.org/z/7TMeETYnc

И вы считаете, что оптимизация с рандомным вызовом функции это нормально для современного компилятора? Это же вредительство какое-то!

Не пишите код с UB. Не можете убедиться, что в коде нет UB — перепишите так чтобы можно было убедиться. Всё равно не можете — переходите на язык, где таких проблем не будет.

Это довольно всрато, но таков плюсовый путь.

Да мне нравятся плюсы, писал последний раз для STM32. Никаких УБ, всё чётко и исполняется именно так как задумано изначально.

Не то чтобы нормально, но эту битву пользователи компиляторов проиграли почти двадцать лет назад

Не "почти", а гораздо больше 20 лет назад, просто, как пишет там пациент, "in earlier gcc versions this only happened if the optimizer was on" (ну так себе аргумент), а так это "has not changed since at least January 24, 1994 (when 2.5.8 was released)", так что там все 30 лет будут, хехе.

Конкретно эта программа с каким-то определённым компилятором вряд ли сделает вызов, но чуть другой пример

int branch(bool x) {
    return x ? yes() : no();
}

компилятор может реализовать через вызов по таблице адресов с индексом 'x'

С т.з. архитектуры INT_MIN как раз равен -INT_MIN. Если бинарно рассмотреть операцию отрицания (~a + 1), то: 1000... --> 0111... --> 1000...

Но с т.з. стандарта последняя операция заканчивается переполнением, а это UB и компилятор волен делать что вздумается, да.

А это действительно так, что такое событие как совершенно обычное переполнение, которое влияет только на флаг процессора, дает кард бланш компилятору на генерацию рандомной дичи? Откуда это вообще пошло?

Всё верно, даёт. Вот тут историческая справка с матами на английском (и перевод на русский). Исходно вроде как пошло из соображений "на разных процессорах естественное поведение может отличаться". Дальше комитет не верил что всё можно описать или даже потребовать от разработчиков компиляторов задокументировать поведение (и сейчас-то документацию никто не пишет). Дальше придумали формулировку "поведение не определено", а в начале двухтысячных разработчики компиляторов начали использовать её в том числе для оптимизации кода. В том числе при отключённых флагах оптимизации, вероятно, чтобы совсем простые оптимизации делать сразу при чтении кода.

Ну а то что такой соптимизированный код соответствует лишь стандарту C++, но не интуиции под конкретный процессор — упс.

И самое главное, а откуда компилятору знать про переполнение в общем случае?

Разве что только программист подсунет ему выражение с константами и компилятор его посчитает, увидит переполнение и инкрементирует себе счетчик UB, чтобы с чистым сердцем накосячить столько же раз в самых необычных местах.

И самое главное, а откуда компилятору знать про переполнение в общем случае?

В общем случае он и не будет об этом знать. Но, например, "наивные" проверки на переполнение типа if (n + 1 < n) {...} со знаковыми n могут не работать, потому что компилятор вправе считать, что такого не может быть никогда. А вот с беззнаковыми - пожалуйста.

И возвращаясь с этим примеров в контест обсуждения UB, то независимо от типа, компилятор не в праве rm -rf делать, а только соптимимизировать ветвление до if (true), либо посчитать с переполнением для INT_MAX неправильно, но это "неправильно" будет определено архитектурно.

rm -rf это утрированный пример. Хотя вдруг у вас в коде есть такая готовая ветка и она неожиданно сработает?

либо посчитать с переполнением для INT_MAX неправильно

Строго говоря, такого числа -INT_MIN не существует. Компилятор мог бы на самых дальних подступах не пропускать такую дичь.

По моему все эти рассуждения для бедных.

  1. Есть модульная арифметика там нет переполнений

  2. При желании все переполнения можно отслеживать

Вот что мешает сделать отдельные типы данных для модульных целых и для целых с проверкой переполнения? Или любовь к UB значительно выше? Да и вместо poison делать явное указание pofig что значение переменной не важно, оно может быть любым НО! только из допустимых бинарным представлением.

ps: По поводу INT_MIN это очень удобное значение для NO_VALUE константы.

Вот что мешает сделать отдельные типы данных для модульных целых и для целых с проверкой переполнения?

Так делайте (через типы/шаблоны/операторы, все языковые средства вам предоставили).

В стандарт это не тащат, потому что не на всех архитектурах отрицательные числа представлены в дополнительном коде, и поэтому "модульные целые" не будут zero-cost.

потому что не на всех архитектурах отрицательные числа представлены в дополнительном коде

Ну, скажем так, таких архитектур все меньше и меньше, зато архитектуры, где вычисления идут в режиме насыщения (т.е. где INT_MAX + 1 == INT_MAX), используются довольно широко, всякие DSP так любят считать. Там, естественно, эмуляция модульной арифметики будет во-первых конечно далеко не zero-cost, а во-вторых зачастую просто-напросто вредна.

Хочется матом выругаться. Как windows 7 устарел и не поддерживается это норм, а как оставить гавна мамота с доисторическими архитектурами так все за (всеми шестью лапками).

Согласен. Поросшее мхом легаси как правило всё равно поддерживают старыми стандартами и компиляторами. А если вдруг припирает на новые переезжать, там и без этого полную ревизию приходится делать. Толку от этого стремления к обратной совместимости на практике не особо много.

И самое главное, а откуда компилятору знать про переполнение в общем случае?

А никто не говорит что он точно знает. Но если узнает - пеняйте на себя. :) Поэтому и называют - UB.

Сама функция валидная. Компилятор реально не инициализирует входную переменную согласно коду и получает сегфолтъ. А мог бы помочь программисту и вставить лишний mov до вызова.

Но это не UB rm -rf, чего все так боятся, а UB при вызове с мусором на входе.

преобразование целого в указатель и обратно

Это давно не UB и за это написано в reinterpret_cast. Но там опять "но", если указатель это что-то "необычное" тогда это запрещается. "Зависит от реализации", короче говоря.

Полагаю подобный код не будет работать на штуках типа DSP, где слово может быть 18битным

Неизвестно почему тут вообще должно быть UB. reinterpret_cast явно оставляет биты на месте, лишь говоря компилятору интерпретировать их по-другому. Если исходные и конечные сущности бит в бит одинаковы размером, то какие проблемы. А если нет, то компилятор сам проинформирует о несоответствии. Другое дело всякие static_cast и C-style приведение типов. Это явное создание новой сущности, и в этом процессе пространство для самодеятельности конечно огромное.

В самом по себе преобразовании целого в указатель через reinterpret_cast и обратно никакого UB нет, это вполне легитимная операция. UB может начаться если по такому рукотворному указателю начать обращаться без старта лайфтайма соответствующего объекта (без использования placement new или std::start_lifetime_as из C++23).

Если по указанному адресу такого объекта никогда не было, это уже никакое не UB. Там в принципе ничего работать не должно. Через интегральные типы передают указатели когда по-другому этого не сделать. Например в WPARAM/LPARAM.

Насколько я знаю кастовать в void*/char* и обращаться к нему можно, а вот кастовать в любой другой тип, это уже UB и вот тогда надо всей этой страшной магие заниматься.

Именно за каст void*/uintptr_t и говорят строчки в стандарте что "всё ок, но иногда не ок", а из других строк узнаём что любой указатель можно кастануть в char*, раунд!

Именно за каст void*/uintptr_t и говорят строчки в стандарте что "всё ок, но иногда не ок"

Указатель не обязан быть просто числом размером в машинное слово. Это только во flat модели так. Ещё есть сегментные/страничные системы адресации.

Ну почему, ты можешь, например, взять T*, преобразовать его через reinterpret_cast в любой целочисленный тип, который достаточно велик, чтобы этот указатель в него поместился, затем передать получившееся целочисленное значение в какое-то другое место, там преобразовать обратно в тот же самый тип T* и свободно обращаться по этому указателю, это абсолютно легитимно.

Да, прочитал в стандарте 17 еть такое:

C++17 §8.2.10/5 [expr.reinterpret.cast]

A value of integral type or enumeration type can be explicitly converted to a pointer. A pointer converted to an integer of sufficient size (if any such exists on the implementation) and back to the same pointer type will have its original value; mappings between pointers and integers are otherwise implementation-defined. [ Note: Except as described in 6.7.4.3, the result of such a conversion will not be a safely-derived pointer value. — end note ]

А что именно вам здесь незнакомо с точки зрения синтаксиса? Пространства имён? Лямбды? Auto? Чтобы их читать, необязательно даже С++ знать. Код как код.

Вот это:

MyClass::MyClass()
  : m_worker{std::thread{[this]{
        std::lock_guard l{m_mutex};
        ...
      }}} {}

с кучей скобок с непонятными для меня целями.

Ах да, вот ещё пространства имён через :: меня тоже напрягает, но уже чисто зрительно, имена сливаются в одну длинную колбасу.

Это одно поле (a) класса инициализированное другим (b) полем класса: A::A() : a{b}.

Э не. Поле (а) инициализируется потоком из временной лямбы. Возможно даже std::thread не нужен, лень проверять. Сама лямбда имеет доступ к головному классу и его полю (b).

ПС, пасаны, я вам по секрету скажу, тут это самое, оно - УБ.

Ну низя так просто взять и написать зис в плюсах.

Внимательно смотрите за руками:

  • этот зис он в капче лямбды

  • а лямбда создаётся где? Правильно в конструкторе объекта на которой этот зис указывает

  • так погодите, это же не просто тело конструктора, а список инициализации. Это значит что? А то что по определению не все мемберы проинициализированны

  • можно спорить УБ это или нет если вы в теле конструктора такое пишете, но т.к. это список инициализации то это 100% УБ

Я поясню, использование зис в самом конструкторе не УБ, а вот передача зис внешнему коду (в данном случае лямбде) и использование этого указателя ДО начала жизни объекта уже УБ. А если лямбда сразу вызывается? Все равно УБ. Но почему? А потому что в лямбде я могу использовать весь объект целиком, а не только вот те кусочки которые уже проинициализированны. А как же тогда правильно писать? Передавать ссылку(и) на отдельные мемберы которые уже проинициализированны.

Так что хоть несмотря на то, что и кажется что беда в УБ с доступом к мемберу из 2 потоков, но на самом деле УБ происходит ещё раньше. Нельзя использовать зис за пределами конструктора включая и однополосный код. Такие дела.

А как же тогда правильно писать?

Правильно вообще так не писать. Конструкторы они для постройки внутренней инфраструктуры объекта. Все внешние операции должны запускаться отдельными методами после того как объект полностью готов к работе. Но это вопрос архитектуры. И очень дискуссионный.

Так что хоть несмотря на то, что и кажется что беда в УБ с доступом к мемберу из 2 потоков

Это не беда 2 потоков. Благодаря второму потоку удавалось на время замаскировать проблему. Если бы лямбда синхронно запускалась там же, проблема неинициализированного мембера всплыла сразу же.

Что-то Вы часть круглых скобок в фигурные превратили.

Если писать код в нормальной среде разработки, она сама покажет место, где неправильное количество скобок, уже в процессе набора. А вообще, это дело привычки - после некоторого опыта написания все это становится понятным с первого взгляда. При освоении любого языка программирования поначалу есть сложности с пониманием.

Первые пример - базовый учебник С++, второй - невнимательное чтение документации, третий - уже высказались выше.
Извините, а причем здесь C++?

C++ все это позволяет в отличие от, например, Rust.
Если вы писали промышленный код на C++, то согласитесь со мной, что внимательное чтение учебников и документации все равно не спасает от подобных багов

C++ все это позволяет в отличие от, например, Rust.

Это не бага, это фича C++.

Фича для написания багов, ага

Фича для написания багов, ага

Значит никакой С++ вам в данном проекте и не нужен. Пишите на Жаве или С#, там вся эта низкоуровневая байда решена за вас под капотом.

внимательное чтение учебников и документации все равно не спасает от подобных багов

Я бы не сказал, что я способен одинаково внимательно прочесть стандарт С++ и стандарт какого-нибудь R5RS.

Rust, кстати, в этом отношении стремительно приближается к С++.

Понимаете, когда у вас язык SML, где полная документация базового языка - 40 страниц, её прочесть внимательно значительно проще, чем читать документацию С++.

MyClass::MyClass()
: m_worker{std::thread{[this]{
std::lock_guard l{m_mutex};
...
}}} {}

А что вы ожидали, когда пытались запустить в потоке лямбду с захваченным this, который не сконструирован?

Запусти вы поток внутри тела конструктора, без проблем.

Насколько я помню, такой подход был выбран из-за возможности избежать лишнего перемещения, когда сконструированный по-умолчанию объект класса std::thread заменяется только что созданным в теле конструктора. Но, конечно, вы правы, тут только такой подход позволил бы избежать UB

Меня gcc замучил предупреждениями что объекты инициализируются не в порядка объявления в классе. Хоспаде какая тебе разница как я запишу m_count(0), m_ptr(NULL). А тут видимо другой компилятор или версия поновее и когда это действительно нужно предупреждения нет.

Некоторые ошибки (примеры 2 и 3) можно было отловить используя code review, третий случай дак точно, он сразу в глаза бросается. Насколько опытные с++ разработчики писали этот код? Не хочу никого задеть, просто любопытно. А так конечно это хорошая практика использовать всевозможные анализаторы.

Зачастую разного рода ошибки попадают в продакшн код, проходя ревью действительно опытных разработчиков. Наличие самого процесса код ревью, как и наличие тестов в коде, не гарантирует отсутствие ошибок в нем

В моем конкретном случае это значит, что время жизни объекта std::string не продлевается внутри вызова fmt::arg, потому что данный объект перестает существовать после того, как отработает конструктор detail::named_arg.

Ты понял это неверно: потому что данный объект перестает существовать после того, как отработает конструктор - конкретно здесь. Конструктор не получал временный объект в его области видимости внутри fmt::arg, конструктор получил аргумент функции. Временный объект привязался к параметру функции и продолжает существовать до конца вызова fmt::arg.

В первом примере дважды допускается одна и та же ошибка - возврат ссылки на локальную переменную функции. Упрощенно https://godbolt.org/z/Ybcn3n1va. Сначала параметр v конструктора возвращаемого объекта ссылается на локальную переменную arg функции arg. Затем поле value ссылается на локальную переменную v конструктора named_arg.

В третьем примере можно было использовать vector::reserve(), чтобы гарантировать отсутствие реалокаций, и индексы вместо итераторов для корректного определения начала и конца вектора.

В третьем примере можно было использовать vector::reserve(), чтобы гарантировать отсутствие реалокаций, и индексы вместо итераторов для корректного определения начала и конца вектора.

Нельзя так делать. Никакие индексы не помогут, просто потому что без специальных плясок синхронность памяти для потоков на разных ядрах и процах не гарантируется. И даже в пределах одного ядра проц может вам сначала обновить индексы, а только потом контент куда эти индексы показывают. Да и просто компилятор возьмёт да и переставит эти две операции местами - это не проблема в рамках одного потока, а про другие потоки ему думать не указывали.

Это ребус про memory_order получается?

Можно ли рисовать барьеры памяти без использования атомиков?

С memory_order здесь нет ребусов. По умолчанию этоmemory_order_seq_cst.

Пример кода https://godbolt.org/z/q7xj4x1zo,

#include <thread>
#include <vector>
using namespace std;

void func() {
    for (int i = 0; i < 100; ++i);
}

int main()
{   
    const int nworkers = 5; 
    vector<thread> threads;
    threads.reserve(nworkers + 1);    
    threads.emplace_back([&threads, nworkers]{
        for (int i = 0; i < nworkers; ++i)
            threads.emplace_back(func);        
    });
    for (int i = 0; i < nworkers + 1; ++i)
        threads[i].join();
    return 0;
}

Можно. threads[0] добавляется в main. Основной поток с main остановится на первом join(), пока не завершится threads[0], к завершению которого вектор будет построен.

, к завершению которого вектор будет построен.

Построен он полюбому будет только для  threads[0], т.к. он его и строит. А main этого может и не увидеть даже когда выйдет из спячки. Ну, OK, там может быть неявная синхронизация на стыке "поток threads[0] завершился, .join() отпустило". Это имеется ввиду?

к завершению которого вектор будет построен

Построен для main, но не факт, что все данные будут записаны в память. Например, capacity/size компилятор может держать где-то в регистрах для оптимизации и выгрузить в память перед циклом с join, но поток threads[0] уже начнёт работу.

UPD тут даже интереснее. Сначала конструируется объект thread, а потом вызывается emplace_back. То есть, vector::size принимает значение 1 из-за первого emplace_back гарантировано после инициализации потока.

upd: Спасибо @LeetCode_Monkey и @qw1 за то, что отметили ошибку в моем комментарии. Не обратил внимание, что в третьем примере гонка, а значит ub, возникает уже при вставке первого элемента в main. То есть недостаточно делать reserve и использовать индексы. Из головы вылетело простое правило, что std::vector не является потокобезопасным, поэтому изменение vector в нескольких потоках без синхронизации - ub.

В третьем примере vector следует полностью строить в main, т.к. функция в конечном итоге ждет завершения всех потоков.

С++ видится мне огромным франкенштейном

Франкенштейн это ученый. А у монстра Фаракенштейна имени нет. Или вы видите огромного ученого?)

Мне кажется, в плюсы давно пора вводить safe / unsafe секции - вот тогда заживем...

Надо по аналогии с флагом /Wall вводить флаг компиляции для UB -> error/warning что бы компилятор показывал где он творит дичь, а не делал это втихаря.

что бы компилятор показывал где он творит дичь, а не делал это втихаря

В какой-то из статей, посвященных UB, разбирался этот вариант и говорилось, что в современных компиляторах во многих случаях при применении оптимизаций, опирающихся на отсутствие UB, уже тупо нет информации о том, какие именно строки в исходнике попали под действие этих оптимизаций (тупо потому, что до этой стадии уже было применено несколько других оптимизаций).

Так что хотелка, безусловно, хорошая. Но вряд ли реализуемая.

Ну вот у вас есть strict aliasing. Огромное поле для всяких увеселительных UB. Включено по умолчанию.

Исходя из того (если выключить), что указатели любых типов могут писать в любую область памяти (даже в другие типы) приходится вечно load-ить значения при каждом обращении, а не использовать "вчерашнее" из регистра, а то вдруг ваша безобидная запись в массив - на самом деле запись в индекс?

UB однако. Бумага говорит UB, практика говорит UB, ассемблер говорит UB, а все равно, конечный пользователь норовит мимо C++ memory model покрутить перф без замеров.

Можете его конечно выключить, но удар по перфу.

Да последние версии С++ требуют соблюдения обрядов и церемоний. Причем чем дальше тем их больше и они всё более забористые. Например идея введения благословения union во избежание UB: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p0593r3.html#type-punning

Если программисты доверяют оптимизатору выкидывать одни куски кода, а другие заменять константами, то какого фига программисты эти куски вообще писали?!

Потому что у человека, в общем случае, нет знаний чтобы написать оптимальный код самому. Есть старый пример с заменой компилятором (то ли кланг, то ли гцц) какого-то вида математического выражения на одну операцию над аргументом и рассчитываемой им константой. В качестве обоснования в комментарии к коду оптимизатора есть ссылочка на главу справочника по математике, в которой доказывается теорема об эквивалентности такой замены.

В более частном, у человека не хватает ширины контекстного окна чтобы заметить возможность схлопывания "чего-то длинного с кучей проверок", например, во "что-то покороче с меньшим числом проверок". Или просто нет никакого разумного времени для анализа некоторых оптимизаций: прикиньте, сколько времени у вас займёт оптимизировать все рекурсии, развернуть все циклы (только нужные), и заинлайнить все функции (только нужные) в обычном проекте хотя бы на сотни тысяч строк.

Там же, где знания есть, очень даже пишут руками без помощи компилятора. За примерами далеко ходить не надо: https://github.com/xiph/rav1e. Обвзяка на расте, а реальное числодробление на ассемблере.

Весь STL на этом построен, что компилятор выкинет лишнее, которое в данном конкретном случае не требуется (move-конструкторы и т.п.). Опять же, assert-ы, if (config == Release) и т.п.

О, влезли в конетейнеры. Влезли в шаблоны. А потом "оно не работает". Парень, иди учи Шарп, если уж на остальное не хватило. Боюсь, асм и машкоды тебы и вовсе раздавят, пусть Сяху и и осилишь (без расширений для процессора). Короче, не выставляй себя полным нубом.

Sign up to leave a comment.

Articles