Pull to refresh
82
0
Егор Суворов @yeputons

User

Send message

Мне кажется, я просто за прошедшие сутки увлёкся написанием комментариев и потерял суть. Возможно, там и была какая-то мысль исходно, но сейчас перечитываю свои последние комментарии — не вижу. Прошу прощения.

И это принципиально разный подход.

Согласен, понимаете всё верно, да. А доклад мой, в чём, видимо и ирония :)

Найдётся все теоретически. На практике, конечно, фигня.

Это не вопрос веры, это вопрос данных

Данные есть. Что с ними дальше делать — вопрос веры. Писать ли на Rust или C++, так ли уж важно избавляться от ошибок работы с памятью в конкретных программах, так ли уж важно избавляться от UB...

Код с UB - не программа,

Вы прямо сейчас наверняка отвечали мне в браузере, написанном на C++, содержащем кучу UB. Не программа?

99% защита от ошибок все еще лучше, чем 10%.

А 0% думания про время жизни (C++) лучше, чем 2%. Потому что меньше мозг нагружается. И что? Вы всё ещё отталкиваетесь от мысли, что надо минимизировать количество ошибок управления памятью. Это де-факто не всегда так, бывают и другие приоритеты.

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

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

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

А так у меня речь шла про компанию уровня FAANG с офисами в десятках стран мира и десятками (если уже не сотнями) терабайт исходников. Куда уж "реалистичнее". Они что-то, наверное, про сборку большого количества кода с зависимостями понимают. Их решения не всем подойдут, мягко скажем, но работает отлично в рамках компании.

Но Rust предотвращает многие причины их возникновения, пусть и не все. И вот это уже и важно, и ценно

Вам ценно. Мне тоже нравится. Но не всем ценно.

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

То же самое могу сказать и про Rust: пробуем эффективно реализовать какую-нибудь нетривиальную структуру данных (тот же интрузивный список) — либо не получается, либо всплывает неэффективный Rc, либо пишем unsafe. Если вам на это хочется ответить, что, мол, надо использовать правильные абстракции и не писать интрузивные списки самостоятельно или изолировать их в отдельных крейтах или вся суть в том чтобы помечать такой код unsafe — то вы, кажется, не поняли претензию.

Вы смотрите на то, как люди пишут на C++, и ужасаетесь, что они это делают не так, как на Rust, и тратят время на другие проблемы. А они смотрят на то, на что они будут тратить время при написании кода на Rust и думают то же самое.

В языке очень много так называемых zero cost abstractions

А такие уж они zero cost? Вон в C++ внезапно оказалось, что std::unique_ptr ни разу не zero-cost, хотя казалось бы: обёртка над чистым указателем, которая вызывает delete в деструкторе. Ан нет — ABI мешается. Не стандарт, именно ABI. И, подозреваю, у Rust с ABI должны быть похожие ограничения.

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

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

Checking a.cpp ...
a.cpp:12:18: error: Reference to testVector that may be invalid. [invalidContainerReference]
    std::cout << vecRef << std::endl;
                 ^
a.cpp:6:23: note: Assigned to reference.
    const int &vecRef = testVector[0];
                      ^
a.cpp:8:23: note: Assuming condition is true.
    for (int i = 4; i < 10000; i++) {
                      ^
a.cpp:8:23: note: Assuming condition is true.
    for (int i = 4; i < 10000; i++) {
                      ^
a.cpp:9:20: note: After calling 'push_back', iterators or references to the container's data may be invalid .
        testVector.push_back(i);
                   ^
a.cpp:12:18: note: Reference to testVector that may be invalid.
    std::cout << vecRef << std::endl;

Ну а если заниматься ещё и динамическим анализом вроде Address Sanitizer/Valgrind, то найдётся абсолютно всё, тут теорема Райса неприменима.

Не то чтобы я не согласен, но это скорее вопрос веры: считаем ли мы класс ошибок X настолько критичным, чтобы заставлять программиста доказывать их отсутствие в программе компилятору. С учётом того что и в Rust бывают баги и в компиляторе, и в borrow checker, и в стандартной библиотеке.

то это тоже говорит об удобстве разработки на языке

Вы считаете, что удобство разработки — это отсутствие UB. Опять же, не то чтобы я не согласен, но есть и другая точка зрения: удобство разработки — это когда язык не мешается под ногами. Например, была тут серия статей про разработку игр на Rust — там хочется писать много-много некорректного кода для прототипирования, а не рефакторить, чтобы удовлетворить компилятор. К баттлу C++ vs Python, кстати, тоже относится: много кому нравится именно Python за возможность быстро нафигачить кода и не думать толком про типы данных и времена жизни. Кстати, по этой же причине Python может нравится и больше Rust.

Safe Rust гарантирует невозможность

Если нет багов в unsafe-библиотеках, компиляторе, borrow checker... Что определённо хорошо и я доволен, но не всех убеждает. Мол, вопрос, рисков.

И да, как уже упоминали, даже unsafe Rust не значит "как в С", там все еще полно проверок, просто они ослаблены

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

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

Опять же, вопрос веры.

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

В некоторых компаниях ровно так и происходит, никакого "реального мира". И платят хорошо. Довольно удобно в них работать было, кстати, никакой Rust и близко не подобрался по уровню тулинга, который там был для C++.

Можно сделать гадости, как на любом языке. Но они будут сделаны намеренно.

Буквально позавчера видел баг на Rust, когда кодом деньги списывались, а нужное действие не совершалось. Сделано было случайно.

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

Можно. А много кто и не страдает! В этом отличие. Люди пишут на Си и C++ с удовольствием и не парятся про какое-то там UB, несоответствие стандарту и прочая, прочая, прочая. Вот тут-то стычка и происходит.

И многие старые разработчики на C++ это тоже понимают и уходят

Да и c Си тоже, вот пример доклада, как контрибьюторы Postgres начали Rust использовать в своём проекте: https://www.youtube.com/watch?v=kAQeout-mh8

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

Не поэтому, а потому что Rust ставит программисту очень много ограничений ради той самой безопасности. Нужны ли эти ограничения — вот тут расхождение мнений.

Всё верно, останется поведение одной платформы, а на другой будут лишние инструкции.

Не всегда продлевает: const auto &x = std::min(2, 3);это UB. Но это уже совсем не к этому примеру, конечно.

Clang справился и это приятные новости. Конкретно этот пример был из проекта, который компилировался GCC и Visual Studio.

Здесь параметров у функции нет, а возвращается std::string, ваша рекомендация выполняется.

Могла бы спасти рекомендация "следите за всем переменными типа std::string_view и не записывайте в них временный std::string при помощи неявной конвертации". Но она не такая известная.

А ещё такой код легко можно получить уже постфактум: сначала функция возвращала, скажем, const char*, потом её модернизировали в соответствии с вашей рекомендацией. И раньше код работал, а потом перестал.

Правильно понимаю, что бы вылечить С++ от UB, нужно править ядро языка, что повлечёт нарушение обратной совместимости, в том числе и с С?

Не совсем. Если мы каким-то чудом определим всё неопределённое поведение, то это будет полностью обратно совместимо: раньше программа X могла делать что угодно, теперь она обязана делать Y.

Будут технические проблемы из-за того что никто никогда не ставил цели "формально определить поведение". Из-за этого что является корректным разыменованием указателя по стандарту — довольно странная конструкция. Например, может оказаться, что если есть два int* с одинаковым битовым значением, то один разыменовывать можно, а другой нельзя, потому что они были получены разными способами. Были две прекрасные статьи на тему: раз (перевод на русский) и два (на английском).

Предположим даже, что наука продвинулась достаточно, что формально определить что является UB.

Дальше будет самое страшное — уговорить весь зоопарк компиляторов делать что-то одно. Даже сейчас пропихнуть что-то в стандарт — дело непростое, и поведение компиляторов отличается. А если это что-то настолько радикальное, как потребовать, скажем, при переполнении массива кидать исключение — туши свет. Исключения есть не везде. Если потребуем ронять программу или выводить на экран стектрейс — неясно, что делать на микроконтроллерах. И как-то удовлетворить все группы пользователей C++ лично мне кажется невероятным.

Бывают ситуации ещё сложнее: пусть мы передали в std::sort некорректный компаратор. Сейчас поведение не определено; реализации могут падать, сортировать как-то, или даже навечно зависать из-за нарушения внутренних инвариантов. Что такое "потребовать обнаружение некорректного компаратора" — большой вопрос. В Java поведение, если не ошибаюсь, тоже не определено, но в гораздо более сильно смысле: программе всё ещё запрещено портить память. А зациклиться или выкинуть случайное исключение или получить мусор в выходном массиве вроде бы можно.

Так что, мне кажется, гораздо проще сделать с нуля язык без UB, хотя бы относящегося к памяти, чем пытаться определить вообще всё поведение в C++. Rust, Carbon, Nim и прочие товарищи чем-то таким и занимаются.

Я так понимаю, речь про -fanalyzer. Но он же полную фигню написал. Там нет неинициализированных значений, там просто use-after-free. Ладно, у нас там UB и предположим, что это просто сообщение кривое.

Но так-то стало хуже: если заменить string_view на string, то сообщение остаётся, а вот UB полностью уходит:

<source>:3:29: error: use of uninitialized value '<unknown>' [CWE-457] [-Werror=analyzer-use-of-uninitialized-value]
    3 | std::string read() { return "foo"; }
      |                             ^~~~~
  'int main()': events 1-2
    |
    |    4 | int main() {
    |      |     ^~~~
    |      |     |
    |      |     (1) entry to 'main'
    |    5 |     std::string s = read();
    |      |                          ~
    |      |                          |
    |      |                          (2) calling 'read' from 'main'
    |
    +--> 'std::string read()': events 3-6
           |
           |    3 | std::string read() { return "foo"; }
           |      | ~~~         ^~~~            ~~~~~
           |      | |           |               |
           |      | |           |               (6) use of uninitialized value '<unknown>' here
           |      | |           (3) entry to 'read'
           |      | (4) region created on stack here
           |      | (5) capacity: 8 bytes
           |

Итого имеем статический анализатор, который выдаёт одинаковое (неверное) сообщение и на корректной программе, и на некорректной. Довольно бесполезно. Можно баг зарепортить.

Автору, мне кажется, сильно повезло работать с каким-то небольшим подмножеством C++ на своих проектах. В крупных проектах ситуация отличается.

Всё упирается в то, можно ли написать большой полезный проект большой группой людей с полным соблюдением стандарта C++. Я утверждаю, что это невозможно, особенно если есть код старше 5-10-20 лет, см. ниже.

Да, это хорошо и большое количество С++ компиляторов обусловлено временем появления С и С++, это примерно 40-50 лет назад

А чем конкретно хорошо большое количество компиляторов? Я не увидел аргументов дальше в статье. Поддержка большого количества платформ (в том числе старых) — это, безусловно, преимущество, но само по себе разнообразие ничем не хорошо.

Кроме компиляторов С++ есть такое понятие как стандарт

У меня про это целый доклад был: "Санитайзеры и стандарт не спасут":

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

  • Пользователи постоянно нарушают стандарт, обычно случайно. Например, до 2007 года не было никаких проблем с обнаружением переполнения конструкцией a > a + 100, но внезапно появились. Стандарт не менялся. Просто компиляторы начали чуть больше оптимизировать.

  • Стандарт довольно сильно ограничен. В C++17 и раньше технически невозможно написать std::vector с соблюдением стандарта. По факту все, конечно, пишут, и проблем не возникает. Пока. Посмотрим, как будут вести себя компиляторы через двадцать лет на сегодняшнем коде.

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

Недавний пример с работы: одна и та же программа, скомпилированная одним и тем же GCC на одной машине в 32-битном и 64-битном режиме выдавала разные результаты вычислений. При этом всё полностью по стандарту. Просто в одном случае excess precision компилятор добавил, а в другом не добавил.

Есть огромное количество совершенно неожиданного UB: https://github.com/Nekrolm/ubbook

И знаете как мне это удалось? Я просто использовал конкретный стандарт языка, в первом случае С++ 98, во втором скажем так общий стандарт С++ до шаблонов. Ну не прелесть ли? Что вы сейчас думаете о совместимости С++?

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

Кто что подразумевает под словами "C++" — отдельный вопрос.

С++ никогда не ломает обратную совместимость

C++ ломает обратную совместимость в каждом, каждом выпущенном стандарте. Даже есть отдельный раздел "что сломали на этот раз". А если вам примеры оттуда кажутся академическими — так это потому что они минимизированы до предела. В реальном коде каждый такой пример был бы размазан между трёмя файлами и парой сотен строк.

вы просто берете и собираете код компилятором с поддержкой нового стандарта

И у вас старый код перестаёт компилироваться в лучшем случае, а в худшем — просто меняет своё поведение. Я и на работе это иногда вижу, и был про отличный доклад: "Как обновить компилятор и не тронуться". Бонусные баллы, если у вас код не писался исходно с расчётом на совместимость между компиляторами.

У нас не компилится проект, по причине того, что компилятор считает программиста, обезьяной с клавиатурой в руках. Прям как в Rust'е.

Повысить уровень предупреждений можно, но до Rust ещё очень, очень, очень далеко. Снова упрощённый пример с работы:

#include <iostream>
#include <string>
std::string read() { return "foo"; }
int main() {
    std::string_view s = read();
	std::cout << s << "\n";
}

Компилируется без предупреждений, запускается, выводит foo. А UB есть. И даже выстрелит, если строчку сделать подлиннее. А если не делать — пройдёт любое тестирование. Конечно, опытный программист на C++ сразу заметит подвох, но мы же статический анализ обсуждаем. Address Sanitizer тоже может помочь, но только если весь код будет скомпилирован сразу с ним, а не размазан между зависимостями, которые задолбаться перекомпилировать.

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

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

Это всё не помогает избежать UB. Есть ODR, есть порядок инициализации, есть желание некоторых программистов нарушить strict aliasing "потому что так же эффективнее", есть неявное взятие чистых ссылок и указателей (пример кода выше со string_view)... И сошлюсь ещё раз на UBBook с бытовыми примерами: https://github.com/Nekrolm/ubbook/

Вам всё равно нужно обрабатывать ошибки.

Разумеется, тут соглашусь. Rust не решает всех проблем. Как и статическая типизация не решает всех проблем, но многие (не все) почему-то предпочитают писать крупные длинные проекты на языках, где опечатка в имени переменной выявляется заранее, а не во время тестирования.

Новый код так и не пишут. А старый такой код есть. Если нужен стоящий пример — берём redis, пытаемся собрать плюсовым компилятором, не получается на втором же файле:

adlist.c: In function 'list* listCreate()':
adlist.c:25:24: error: invalid conversion from 'void*' to 'list*' [-fpermissive]
   25 |     if ((list = zmalloc(sizeof(*list))) == NULL)
      |                 ~~~~~~~^~~~~~~~~~~~~~~
      |                        |
      |                        void*
adlist.c: In function 'list* listAddNodeHead(list*, void*)':
adlist.c:74:24: error: invalid conversion from 'void*' to 'listNode*' [-fpermissive]
   74 |     if ((node = zmalloc(sizeof(*node))) == NULL)
      |                 ~~~~~~~^~~~~~~~~~~~~~~
      |                        |
      |                        void*
adlist.c: In function 'list* listAddNodeTail(list*, void*)':
adlist.c:107:24: error: invalid conversion from 'void*' to 'listNode*' [-fpermissive]
  107 |     if ((node = zmalloc(sizeof(*node))) == NULL)
      |                 ~~~~~~~^~~~~~~~~~~~~~~
      |                        |
      |                        void*
adlist.c: In function 'list* listInsertNode(list*, listNode*, void*, int)':
adlist.c:133:24: error: invalid conversion from 'void*' to 'listNode*' [-fpermissive]
  133 |     if ((node = zmalloc(sizeof(*node))) == NULL)
      |                 ~~~~~~~^~~~~~~~~~~~~~~
      |                        |
      |                        void*
adlist.c: In function 'listIter* listGetIterator(list*, int)':
adlist.c:197:24: error: invalid conversion from 'void*' to 'listIter*' [-fpermissive]
  197 |     if ((iter = zmalloc(sizeof(*iter))) == NULL) return NULL;
      |                 ~~~~~~~^~~~~~~~~~~~~~~
      |                        |
      |                        void*

Если продолжить собирать дальше, можно наткнуться на restrict, переменную int delete, параметр функции const char *template, это даже никаким -fpermissive не убирается. И это у меня больше половины файлов не начали компилироваться, потому что Lua не установлен.

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

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

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

Не то чтобы нормально, но эту битву пользователи компиляторов проиграли почти двадцать лет назад, когда GCC начал убирать наивные проверки на переполнение вроде if (a + 100 < a): https://gcc.gnu.org/bugzilla/show_bug.cgi?id=30475#c4

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

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

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

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

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

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

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

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

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

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

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

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

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

1
23 ...

Information

Rating
Does not participate
Location
Санкт-Петербург, Санкт-Петербург и область, Россия
Date of birth
Registered
Activity