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

  1. SSA форма

  2. Доминирование

  3. Неопределённое поведение

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

Наверное, многие слышали, что неопределённое поведение (undefined behavior, UB) — постоянный источник разнообразных багов, иногда очень забавных, иногда довольно жутких. Тема также неоднократно освещалась и на Хабре, навскидку раз, два, три (и даже целый тег есть). Однако чаще всего статьи по данной теме посвящены тому, как можно отстрелить себе ногу, голову или случайно сжечь свой жёсткий диск, исполнив какой-нибудь опасный код. Я же намерен сделать акцент на том, зачем авторы языков программирования надобавляли всей этой красоты, и как оптимизатор может её эксплуатировать, чтобы делать из неё перфоманс. Всё будет проиллюстрировано наглядными примерами из LLVM и присыпано байками из собственного опыта, так что наливайте себе чай, располагайтесь поудобнее, и погнали.

Коротко о предмете

Если вы совсем не знакомы с тем, что такое undefined behavior, то есть много источников, которые можно прочитать для ознакомления. Начать можно со статьи в Википедии, а дальше гуглить по ключевым словам и наслаждаться десятками историй на тему "я не понимаю, какого чёрта моя программа работает не так, как мне хочется" или "1001 способ отстрелить себе ногу для чайников". Некоторые довольно забавны, рекомендую.

Для нас имеет значение то, что в языках программирования (и особо этим славен С++ и ему подобные) в стандарте встречаются фразы типа

When signed integer arithmetic operation overflows (the result does not fit in the result type), the behavior is undefined

В этом случае может произойти абсолютно всё что угодно: может быть, вам повезёт, и при переполнении получится результат по модулю 2n, может быть повезёт чуть меньше, и получится какой-нибудь 0 или SINT_MAX, а может быть, ваша программа удалит корневой каталог, а жёсткий диск сгорит синим пламенем просто потому, что вы сложили два числа.

Вот краткий список ситуаций, когда мы можем столкнуться с неопределённым поведением:

  • Переполнения знаковой арифметики

  • Деление (или взятие остатка) на 0

  • Разыменование (т.е. чтение или запись) nullptr или указателя на невыделенную память (например, за границы массива)

  • Использование неинициализированных переменных

  • Бесконечный цикл без сайд-эффектов

  • Гонка при записи неволатильного поля разными потоками

  • и т.п.

Благими намерениями

В нашей среде существует локальный мем "demon optimizer" — это такой гипотетический оптимизирующий компилятор, который будет специально находить любой формальный повод, по которому может случиться UB, и в этой ситуации всегда стараться наносить наибольший возможный урон (например, вставлять "rm -rf /" везде, когда переполняется хоть какая-то знаковая арифметика). Понятно, что в реальности компилятор не ставит себе задачу при малейшей возможности нагадить программисту. В реальности всё хуже: он может нагадить ему, искренне стараясь делать добро.

Не так давно в интернетах дивились примерно следующей ситуации: компилятор clang умудрился вызвать функцию never_called

#include <iostream>

int main() {
    int cnt = 0;
    while (cnt++ != -1) {}
    return 0;
}

void never_called() {
    std::cout << "You are screwed" << std::endl;
}

Вместо принта можно вставить какой-нибудь "rm -rf /", и этот код также исполнится. Почему так происходит, нетрудно понять, если заглянуть в godbolt:

main:                                   # @main
never_called():                      # @never_called()
        push    rbx
        mov     rbx, qword ptr [rip + std::cout@GOTPCREL]
        lea     rsi, [rip + .L.str]
        mov     edx, 15
        mov     rdi, rbx
        call    std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)@PLT
        mov     rax, qword ptr [rbx]
        mov     rax, qword ptr [rax - 24]
        mov     rbx, qword ptr [rbx + rax + 240]
        test    rbx, rbx
        je      .LBB1_5
        cmp     byte ptr [rbx + 56], 0
        je      .LBB1_3
        movzx   eax, byte ptr [rbx + 67]
        jmp     .LBB1_4
.LBB1_3:
        mov     rdi, rbx
        call    std::ctype<char>::_M_widen_init() const@PLT
        mov     rax, qword ptr [rbx]
        mov     rdi, rbx
        mov     esi, 10
        call    qword ptr [rax + 48]
.LBB1_4:
        movsx   esi, al
        mov     rdi, qword ptr [rip + std::cout@GOTPCREL]
        call    std::basic_ostream<char, std::char_traits<char> >::put(char)@PLT
        mov     rdi, rax
        pop     rbx
        jmp     std::basic_ostream<char, std::char_traits<char> >::flush()@PLT               # TAILCALL
.LBB1_5:
        call    std::__throw_bad_cast()@PLT
.L.str:
        .asciz  "You are screwed"

Как видим, прямо за меткой main идёт метка начала функции never_called, и если передать ей управление, то процессор пойдёт исполнять его дальше сплошняком. Давайте примерно разберём схему того, как это случилось.

  1. Согласно стандарту С++, знаковое переполнение — это UB. Поэтому можно считать, что cnt, которая изначально было равно нулю и шло вверх, всегда будет неотрицательным. Отрицательным оно может стать только при переполнении, но поскольку это UB, компилятору на это наплевать.

  2. Сравнение cnt != -1 можно превратить в true, поскольку cnt — неотрицательное, а -1 — отрицательное, и можно считать, что они никогда не равны.

  3. Цикл while (true) {} — это бесконечный цикл без сайд-эффектов, то есть UB. Компилятор считает, что если мы сюда пришли, то делать можно всё что угодно. Например... Ничего. Вообще. Можно просто удалить цикл и весь последующий код вместе с return'ом.

Оба раза компилятор сделал нечто умное и полезное. Удаление сравнения двух чисел и замена его на константу — это благо. Вам не придётся тратить на это процессорный такт. Что касается удаления бесконечного цикла, то тут о благостности можно поспорить, но с другой стороны, представьте, что кто-то бы после while (true) {} написал целую гору кода. Например. по ошибке. Компилятору бы пришлось её оптимизировать и тащить до самого конца, тратя на этот код своё время, притом, что он никогда не исполнится. Учитывая это, его удаление — это тоже полезно, по крайней мере для ускорения самой компиляции. Выходит, компилятор пытался сделать только добро, а сделал какую-то опасную ерунду.

UB - свойство времени исполнения

Многие задаются вопросом: ну хорошо, раз UB — это, потенциально, очень страшно и может привести к ужасным последствиям, почему бы компилятором просто не выдавать warning в тех ситуациях, когда вы написали опасный код? Мы могли бы с помощью опций сделать его ошибкой и вообще избавиться от этой проблемы.

Дело в том, что в языках вроде С++ очень многие конструкции потенциально могут иметь неопределённое поведение при определённых условиях. Деление ведёт к UB только если знаменатель равен нулю. Вряд ли кто-то специально написал там константу 0 (такое бывает, но это скорее сделано в рамках какого-то учебного опыта, а не практической задачи), скорее всего написано <выражение 1> / <выражение 2>, и чему равно выражение 2 — будет известно только во время исполнения. Аналогичным образом, бесконечным может оказаться не только цикл while (true), но и цикл с каким-то сложным, не анализируемым условием, которое, тем не менее, всегда истинно. Также сложение x + y может переполниться при передаче одних параметров (и породить UB), а может и не переполниться.

Если бы мы захотели иметь такой warning, он бы вылетал примерно на каждой первой строчке вашей программы. Очень редко когда значения выражений можно оценить и проанализировать на предмет переполнения/инвариантной истинности/нулёвости во время компиляции. Строго говоря, это можно сделать только для констант, constexpr'ов и их производных, да и то не всегда (иногда это слишком долго, чтобы компилятор занимался этим на практике). Поэтому чаще всего то, будет ли ваша программа иметь неопределённое поведение или нет, определяется входными данными во время исполнения.

Приведём пример:

int foo(int x) {
  int sum = 0;
  for (int i = 0; i < 1000; i++)
    if (cond(i))
      sum += i / x;
  return sum;
}

Данная функция будет иметь неопределённое поведение при одновременном соблюдении двух условий:

  • Функция будет вызвана с x = 0

  • cond(i) вернёт true хотя бы один раз

В этом случае произойдёт деление на 0, и программа имеет право закрашиться, повиснуть, вернуть какую-нибудь ерунду и т.д. Однако сам факт наличия в ней деления ещё не означает, что в программе всегда присутствует UB.

Неизбежное UB путешествует во времени

Интересно то, что если при каком-то сценарии неопределённое поведение в программе присутствует, то не имеет никакого значения, где именно и когда оно проявится. Приведём пример:

void foo(int *ptr) {
  printf("Before the loop\n");
  for (int i = 0; i < 1000; i++) {
    printf("Before check\n");
    if (i == 17)
      *ptr = 1;
    printf("After check\n");
  }
  printf("After the loop the loop\n");
}

Человеку наивному может показаться, что при вызове с ptr = nullptr поведение программы должно быть следующим:

  • Программа точно напечатает "Before the loop";

  • Программа точно 17 раз напечатает пару "Before check"/"After check";

  • Потом для i = 17 ещё раз точно напечатается "Before check", а вот дальше может произойти краш/зависание/либо программа продолжит работать, но сделает что-нибудь странное.

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

  • Условие i == 17 станет истинным (причём только 1 раз) в ходе выполнения данного цикла;

  • Поэтому запись единицы в *ptr обязательно произойдёт;

  • Чтобы не делать лишнюю проверку в цикле, компилятор имеет право сделать любое из следующих преобразований:

void foo(int *ptr) {
  *ptr = 1;
  printf("Before the loop\n");
  for (int i = 0; i < 1000; i++) {
    printf("Before check\n");
    printf("After check\n");
  }
  printf("After the loop the loop\n");
}

void foo(int *ptr) {
  printf("Before the loop\n");
  for (int i = 0; i < 1000; i++) {
    printf("Before check\n");
    printf("After check\n");
  }
  printf("After the loop the loop\n");
  *ptr = 1;
}

void foo(int *ptr) {
  printf("Before the loop\n");
  for (int i = 0; i < 17; i++) {
    printf("Before check\n");
    printf("After check\n");
  }
  *ptr = 1;
  for (int i = 17; i < 1000; i++) {
    printf("Before check\n");
    printf("After check\n");
  }
  printf("After the loop the loop\n");
}

Наверное, можно придумать и другие варианты, но довольно и этих. UB - свойство динамическое. Если ptr != nullptr, то его в этой программе вообще нет, и установка единицы в эту память может происходить вообще независимо от принтов, и внешних различий в поведении вы не увидите. Однако если ptr == nullptr, то компилятор рассуждает примерно так: раз UB неизбежно случится в этой программе (а это так, потому что i == 17 точно станет true рано или поздно), то всё равно, когда оно произойдёт. Вся эта программа динамически имеет неопределённое поведение . Поэтому если краш (или другой неожиданный эффект) случится до или после цикла и принтов, то это абсолютно нормально. UB — свойство не конкретной операции, а всей программы, в которой эта операция выполнится.

Таким образом, если программа закрашится до того, как будет напечатан первый принт, или после того, как будет напечатан последний — и то, и другое будет совершенно правильно. Данный эффект объясняет, почему баги, связанные с UB, так сложно диагностировать, обкладывая принтами, в оптимизирующем режиме. Лучше для этого использовать -O0. Там всё ещё нет железных гарантий, но есть надежда, что это сработает.

Безопасно ли выносить инварианты из циклов?

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

bool cond(int idx);

int example_add(int x, int y) {
  int sum = 0;
  for (int i = 0; i < 1000; i++)
    if (cond(i))
      sum += x + y;
  return sum;
}

Понятно, что если cond() вернёт true хотя бы раз, а лучше — много раз, то имеет смысл вот такая оптимизация:

bool cond(int idx);

int example_add(int x, int y) {
  int sum = 0;
  int tmp = x + y;
  for (int i = 0; i < 1000; i++)
    if (cond(i))
      sum += tmp;
  return sum;
}

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

bool cond(int idx);

int example_div(int x, int y) {
  int sum = 0;
  for (int i = 0; i < 1000; i++)
    if (cond(i))
      sum += x / y;
  return sum;
}

Можем ли мы предвычислить данное выражение, сделав что-то вроде

bool cond(int idx);

int example_div(int x, int y) {
  int sum = 0;
  int tmp = x / y;
  for (int i = 0; i < 1000; i++)
    if (cond(i))
      sum += tmp;
  return sum;
}

Казалось бы, всё то же самое. Но погодите-ка. А что если cond() всегда возвращает нам false, а y = 0? В этом случае изначальная функция никогда не выполняет операцию деления на 0, которая ведёт к неопределённому поведению, а оптимизированная - разделит на 0, после чего программа может повиснуть или покрашиться. Выходит, такое преобразование вот так в лоб делать нельзя.

Но что у нас там было со сложением? Ведь там-то всё безопасно? Ведь так?

Мы ведь можем выносить сложение из цикла, верно?
Мы ведь можем выносить сложение из цикла, верно?

Ну чисто формально, с точки зрения С++ и демон-оптимайзера — нет. Вспоминаем, что в С++ знаковые переполнения — это UB. В ситуации, когда x + y переполняется, а cond() всегда возвращает false, исходная программа не имела UB, а новая — имеет. Злобный демон-оптимайзер мог бы воспользоваться этим фактом и вставить rm -rf / в случае переполнения в оптимизированном коде. Неужели оптимизация невалидна?

Poison в LLVM

Может показаться, что по причинам, описанным выше, в С++ вообще ничего нельзя спекулировать. Малейший чих — и вы получите UB в месте, где его не было, и оптимизатор, из самых лучших побуждений, вас после этого похоронит вместе с программой и жёстким диском. К счастью, на самом деле всё не так плохо. В реальности clang сложение всё-таки выносит. Но как он это делает? Разве ему не страшно?

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

Для тех, кто не читал первые две части, сейчас самое время прочитать, ссылки есть в шапке.

На уровне IR-инструкций, существует 2 типа неопределённого поведения:

  • Немедленное UB, которое происходит в момент исполнения соответствующей проблемной инструкции;

  • Отложенное UB, выражаемое отравленным (poison) значением, и которое может при определённых условиях произойти после выполнения проблемной инструкции (а может и не произойти).

Там раньше было (и всё ещё остаётся) такая вещь, как undef, но она является более слабой формой poison'а, и о ней мы говорить сегодня не будем. Почему от неё отказались, пишет тот же Джон Рейгр. Poison не является никаким числом и обладает следующими удивительными свойствами:

  • Любая инструкция, операндом которой является poison, либо ведёт к неопределённому поведению, либо также производит poison

    • Исключения: финода становится poison только если мы реально приходим из блока, откуда он приходит; select не становится poison, если он динамически не выбран, и есть ещё специальная freeze-инструкция. О ней мы поговорим как-нибудь в другой раз, желающие могут ознакомиться в LangRef.

  • Таким образом, poison как бы распространяется по поддеревьям выражений, "отравляя" их, а если яд дотечёт до какой-то опасной инструкции и она исполнится, то случится непосредственное UB.

  • В любой момент компилятор имеет право превратить poison в любое значение, какое ему нравится.

Чтобы понять, что это такое, давайте рассмотрим другой очень известный пример: в С++ программа

void foo(int x) {
  if (x + 1 > x)
    printf("true");
  else
    printf("false");
}

может быть соптимизирована просто в

void foo(int x) {
  printf("true");
}

При этом true напечатается даже при x = SINT_MAX, когда наивные люди могли бы ожидать, что x + 1 переполнится и сравнение даст false. На самом деле, происходит следующее:

...
  %x.plus.1 = add nsw i32 %x, 1
  %cmp = icmp sgt i32 %x.plus.1, %x
  br i1 %cmp, label %if.true, label %if.false
...

Тут стоит флаг nsw (no sign wrap), гарантирующий отсутствие знакового переполнения. Если оно всё же произойдёт (функция будет вызвана с x = SINT_MAX), то результатом этой инструкции будет poison. Компилятор же размышляет примерно так:

  • У меня есть гарантия того, что при вычислении %x.plus.1 не будет переполнения.

    • Если это в самом деле так, то %x + 1 > %x, и я естественным образом могу заменить %cmp на true

    • Если мою гарантию нарушили, то результатом %x.plus.1 станет poison. Поскольку у инструкции %cmp есть операнд poison, то её результат также poison. Мне выгодно заменить этот poison на true.

  • Поэтому всегда валидно заменить %cmp на true

На практике, nsw и им подобные флаги (есть ещё nuw у интов, всякие nnan, ninf и т.п. у флотов, а также метаданные диапазонов (!range) на лоадах, и многое другое) говорят: соответствующих ситуаций (переполнений, нанов, бесконечностей, значений вне диапазона) тут просто быть не может. Оптимизируйте так, будто бы их нет. А если они всё же будут, то там всё превратится в poison, который потом может привести к реальному UB, а может и не привести.

Отложенное UB и правильный вынос инвариантов

Для того, чтобы понять, как poison реализует отложенное UB, перепишем функции example_add и example_div на LLVM IR.

define noundef i32 @example_add(i32 noundef %x, i32 noundef %y) {
entry:
  br label %loop

loop:                                             ; preds = %backedge, %entry
  %sum = phi i32 [ 0, %entry ], [ %sum.merged, %backedge ]
  %iv = phi i32 [ 0, %entry ], [ %iv.next, %backedge ]
  %loop.exit.cond = icmp slt i32 %iv, 1000
  br i1 %loop.exit.cond, label %check_cond, label %exit

exit:                                             ; preds = %loop
  ret i32 %sum

check_cond:                                       ; preds = %loop
  %cond = call zeroext i1 @cond(i32 %iv)
  br i1 %cond, label %update_sum, label %backedge

update_sum:                                       ; preds = %check_cond
  %x.plus.y = add nsw i32 %x, %y
  %sum.updated = add nsw i32 %sum, %x.plus.y
  br label %backedge

backedge:                                         ; preds = %update_sum, %check_cond
  %sum.merged = phi i32 [ %sum.updated, %update_sum ], [ %sum, %check_cond ]
  %iv.next = add nsw i32 %iv, 1
  br label %loop
}

define noundef i32 @example_div(i32 noundef %x, i32 noundef %y) {
entry:
  br label %loop

loop:                                             ; preds = %backedge, %entry
  %sum = phi i32 [ 0, %entry ], [ %sum.merged, %backedge ]
  %iv = phi i32 [ 0, %entry ], [ %iv.next, %backedge ]
  %loop.exit.cond = icmp slt i32 %iv, 1000
  br i1 %loop.exit.cond, label %check_cond, label %exit

exit:                                             ; preds = %loop
  ret i32 %sum

check_cond:                                       ; preds = %loop
  %cond = call zeroext i1 @cond(i32 %iv)
  br i1 %cond, label %update_sum, label %backedge

update_sum:                                       ; preds = %check_cond
  %x.div.y = sdiv i32 %x, %y
  %sum.updated = add nsw i32 %sum, %x.div.y
  br label %backedge

backedge:                                         ; preds = %update_sum, %check_cond
  %sum.merged = phi i32 [ %sum.updated, %update_sum ], [ %sum, %check_cond ]
  %iv.next = add nsw i32 %iv, 1
  br label %loop
}

declare i1 @cond(i32)

На первый взгляд разницы никакой нет. Тут одинаковый CFG и плюс-минус одинаковые вычисления, отличается только инструкция в блоке update_sum (в одном случае add nsw, а в другом — sdiv). Однако различие между ними несколько больше, чем кажется на первый взгляд.

Дело в том, что sdiv представляет пример инструкции, которая имеет непосредственное неопределённое поведение при исполнении. Поэтому передвинуть её из блока update_sum в блок entry — нельзя, потому что в сценарии, когда cond() всегда возвращает false, в изначальной программы UB динамически не исполняется, а после переноса оно бы исполнялось.

Однако add nsw не ведёт к непосредственному UB. Он в случае переполнения произведёт poison, что само по себе не опасно. Однако poison имеет свойство отравлять другие инструкции, и в данном случае цепочка отравлений выглядит следующим образом:

  • %x.plus.ypoison при переполнении

  • %sum.updated — poison, если %x.plus.ypoison

  • %sum.mergedpoison, если %sum.updatedpoison и мы пришли туда из блока %update_sum

  • %sumpoison, если %sum.merged — poison, и мы пришли туда из блока %backedge

  • ret i32 %sum — непосредственное UB, если %sumpoison (потому что функция помечена как noundef)

То есть, для того, чтобы тут случилось непосредственное UB, нужно чтобы одновременно:

  • Poison возник в результате переполнения x + y

  • Мы прошли по пути check_cond update_sum backedge

В противном случае poison может и возникнуть, но непосредственного UB не будет! Именно это и даёт полное основание выполнить инструкцию add спекулятивно в блоке entry. Это, например, может сделать оптимизация LICM.

Заметьте, что несмотря на то, что даже если x + y реально переполняется, но cond() всегда возвращает false, то мы не пойдём в блок update_sum, и, соответственно, не отравим всю эту цепочку вплоть до return'а.

Вместо заключения

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

  • Неопределённое поведение — динамическое свойство программ, которое может реализоваться или не реализоваться во время исполнения;

  • Программа, в которой исполняется непосредственное UB — "закоррапчена" вся вместе, и UB в ней может произойти в самых неожиданных местах, а не только там, где это бы было при наивном пошаговом исполнении программы;

  • Компиляторы могут опираться на гарантии вида "такая ситуация не произойдёт", оптимизировать с учётом этих гарантий, а если ситуация всё же происходит — это и есть UB;

  • В LLVM существует непосредственное и отложенное UB. Последнее реализуется через poison, и появление poison не обязательно приведёт к UB (для этого могут потребоваться некоторые другие условия).

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


  1. ruomserg
    14.06.2023 17:41
    +7

    Мне кажется, что из-за несовершенства человеческого языка, которым пишутся стандарты — у нас произошло огромное недопонимание между авторами компиляторов и всеми остальными пользователями.


    Когда умные люди делали языки (Керниган, Ричи, Страуструп) — они понимали, что не могут предусмотреть заранее все возможные архитектуры на которых будут реализованы компиляторы и рантайм языка. Чтобы не связывать по рукам и ногам будущих разработчиков ЭВМ, компилятора и рантайма — они сознательно отказались покрывать спецификацией "странные пограничные случаи". Так родилось понятие "UB" — которое означало в те времена, что результат определяется архитектурой ЭВМ и особенностями компилятора. То есть язык вам не гарантирует что инструкция закрытая под if (x+1>x) выполнится при определенном значении X. Но в те времена — это не означало что соответствующее условие вместе с веткой кода можно просто выкинуть. Соответствующий код генерировался — но узнать, как он себя поведет — можно было только запустив исполняемый файл на вашей конкретной ЭВМ. Например, если аппаратура ЭВМ имела аппаратное детектирование переполнений, то код под веткой if мог действительно не выполнится — но не потому что компилятор его обнулил, а потому что с точки зрения языка — такая ситуация не определена. Однако, на конкретной платформе — поведение программы с UB было вполне определенным: она либо выполняла соответствующий кусок кода если происходило переполнение регистра, либо падала в ОС с диагностикой.


    Потом авторы компиляторов решили толковать "что угодно" расширительно. Это позволяло конкурировать в синтетических тестах с другими авторами компиляторов и применять оптимизации все ближе и ближе к грани фола. Апофеозом стало темплейтное метапрограммирование на C++, имеющее тенденцию порождать кучу специализаций шаблона (SFINAE) которые никогда не будут использованы, и как следствие — резкий уклон компиляторов C++ (а следом и C — потому что обычно это один и тот же компилятор под капотом c разными настройками) в попытки доказать что некий код — unreachable и его можно вырезать.


    Грубо говоря — если раньше компилятор воздерживался от оптимизации в случаях когда не мог с уверенностью доказать что программа будет эквивалентна тому что написал программист — то теперь ситуация обратная: это вы должны доказать компилятору что он не может из вашей программы сделать нечто иное — чего вы не писали.


    Да, понятно что с другой моделью оптимизации C++ — не жилец. Да, понятно, что мы сейчас начнем ныть что разработчики приходят с низкой квалификацией и нельзя от них ожидать чтобы они думали о ручной оптимизации (выносы инвариантов и проч). Но когда по статистике больше 70% проектов на C++ содержат хотя бы одно UB (и только вопрос времени, после какой очередной версии компилятора оно стрельнет!) — и если программа на C++ ведет себя не так — мы вынуждены лезть в ассемблер чтобы понять, в какую сторону ее вывернул компилятор, и обратным ходом искать UB которое ему удалось использовать… Я не знаю, считать ли это прогрессом, или наоборот...


    P.S. В результате, я лично перешел для большинства задач на Java (где UB гораздо меньше), а C++ волевым решением ограничил до относительно безопасного подмножества включающего "С с классами" и шаблоны для быстрой генерации специализаций классов (и предотвращения копипасты), но исключая темплейтное мета-программирование.


    1. slonopotamus
      14.06.2023 17:41
      +3

      результат определяется архитектурой ЭВМ и особенностями компилятора

      Вы путаете undefined behavior и implementation-defined behavior.

      С остальным комментарием в целом согласен, программирование на C++ - это хождение по минному полю.


      1. ruomserg
        14.06.2023 17:41
        +6

        Я все-таки буду настаивать на своем. implementation-defined behavior — это когда авторы компилятора и рантайма обязаны выбрать определенное поведение для целевой платформы и гарантировать его. UB — это когда компилятор генерирует код, но виртуально пожимает плечами: "хрен знает, зачем вы это делаете — смотрите сами что получится когда вы это запустите". Технически, результат может быть даже разный при разных опциях оптимизации (а в случае implementation-defined behavior — вообще говоря должен быть какой-то один). Однако, изначальное понимание UB было: "мы вам генерируем код как вы сказали, но стандарт языка это не покрывает, так что рассчитывайте только на то что вы лучше нас знаете свое обрудование и операционную систему". Теперь UB понимается в лучших традициях школоты: "… училка заболела, айда гулять с уроков!".


        1. domix32
          14.06.2023 17:41

          Есть два UB - undefined behavior и unspecified behaviour. Ваш пример с умными указателями - это как раз unspecified behavior, то есть имеется высокоуровневое описание поведения, но нет формального описания внутрненнего поведения, которое бы разрешило спорные моменты конкретных реализаций. Undefined это когда конструкция вполне легальна, но поведение в особых случаях никак не определяется вовсе и зависит от конкретной реализации.


          1. morijndael
            14.06.2023 17:41

            Конструкция легальна, но при этом компилятор будет предполагать, что программист абсолютно точно всё проверил, и Undefined Behaviour в программе отсутствует. А если нет проверок в коде, значит гарантирует, что входные данные проверены. То, что программист мог просто ошибиться, компилятор не предполагает :D

            И как раз эти предположения приводят к очень...интересным изворотам программы


    1. xortator Автор
      14.06.2023 17:41

      P.S. В результате, я лично перешел для большинства задач на Java (где UB гораздо меньше)

      Строго говоря, в джаве UB нет вообще, и даже в JLS такое слово не встречается. :) Дело в том, что "потенциально опасные" инструкции в джаве всё равно могут быть. Например, если вы компилируете джаву в LLVM (как делает компилятор Falcon), у вас всё равно в какой-то момент могут появиться всякие nsw флаги. Другое дело, что (если только в компиляторе нет багов) в этом случае реальное, непосредственное UB никогда не должно выполниться. Однако poison вполне себе генерится и протекает.


  1. kovserg
    14.06.2023 17:41
    -1

    Цикл while (true) {} — это бесконечный цикл без сайд-эффектов, то есть UB.… Можно просто удалить весь последующий код вместе с return'ом.

    Вообще когда смотрю на это то кажется что стандарт пишут наркоманы эффективные менеджеры, а не инженеры.


    Переполнения знаковой арифметики

    Какие проблемы сделать модификаторы типов для определения поведения? И типы длинных чисел.


    Деление (или взятие остатка) на 0

    То есть явно указать поведения тоже невозможно средствами языка. Опять что-то мешает?


    ..(например, за границы массива)

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


    Использование не инициализированных переменных

    Что мешает инициализировать переменные нулями. И если кому явно надо не инициализированные данные то это указывать явно как в zig


    Бесконечный цикл без сайд-эффектов

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


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


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


    1. domix32
      14.06.2023 17:41
      +2

      Вообще когда смотрю на это то кажется что стандарт пишут наркоманы эффективные менеджеры, а не инженеры.

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

      Какие проблемы сделать модификаторы типов для определения поведения?

      Первый кандидат на проблемы с совместимостью с Си.

      Что мешает инициализировать переменные нулями.

      Второй кандидат. Во-первых - это медленно. Во-второых именно для этого в C++11 как раз завезли агрегатную инциализацию, но писать две лишних скобки очень сложно приучить, опять же учитывая совместимость. Проверки на unititialized вроде в ворнинги не так давно только добавили, а то и вовсе только во внешних анализаторах кода живут.

      Что мешает сделать проверки в отладочных билдах

      В стандартных контейнерах так всё и сделано. Но многие все ещё тяготеют в пользу простых сишных массивов. Отсутвие span в стандартной библиотеке также играет свою роль. Всякие view-like механизмы только с C++17 стали появляться.

      Вот кроме как вредительством это назвать нельзя.

      А вы видимо умеете заглядывать на пол века вперёд, чтобы предсказать как ваш код будут использовать? Раньше плюсы были вообще препроцессором над Си, который превращал плюсовый код в Си, а потом уже начинал его компилировать (гуглить cppfront). И только спустя 15 лет появился некоторый стандарт. Хорошо говорить с колокольни, когда потрогал уже несколько десятков языков. А так да, спонсор этого вашего вредительства последние сорок лет.


      1. equeim
        14.06.2023 17:41

        Отсутвие span в стандартной библиотеке также играет свою роль.

        У span как раз-таки, в отличие от контейнеров, проверок выхода за границы нет - функция at() отсутствует.


        1. domix32
          14.06.2023 17:41

          проверок выхода за границы нет

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


      1. kovserg
        14.06.2023 17:41
        -1

        Первый кандидат на проблемы с совместимостью с Си.

        C++ не совместимо с C. И потом что модификаторы могут испортить?


        typedef int __module_arithmetic__ int_m;
        typedef int __overflow_checking__ int_o;
        typedef int __modification_callback__( invariant1_validation_fn ) int_inv1;

        инициализировать переменные нулями… Во-первых — это медленно.

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


        bool question() { bool tobe; return tobe || !tobe; }

        В c++ bool имеет минимум 3 значения true=1,false=0 и UB=не представима в коде целевой программы. То есть за значением переменных таскаются пачки метаданных которые активно используются оптимизатором, но при некоторых преобразования могут отваливаться. Что вызывает новые UB.


        В стандартных контейнерах так всё и сделано

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


        А вы видимо умеете заглядывать на пол века вперёд...

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


        1. domix32
          14.06.2023 17:41
          +2

           c++ bool имеет минимум 3

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

          А не делать чудные оптимизации

          вы просили быстро - вам сделали быстро. Это значит всё лишнее и ненужное выкинуто. Даже пруф Великой Теоремы Ферма. Какие вопросы?

           Если код раньше использовал модульную арифметику на int.

          В том-то и суть, что зоопарк архитектур примерно никак не гарантировал поведение при переполнении. А т.к. изначально оно генерировалось в Си и под каждую архитектуру люди изобретали свои отдельные компиляторы под каждую же архитектуру имелось определенное количество багов тех компиляторов. И вот это ваше "раньше использовал" работало не всегда и не так прекрасно как вам хотелось бы верить. Это сейчас всё относительно изоморфно c х86_х64 en masse. Это уже не говоря про то что современный школьник сейчас получает более полные и обобщенные знания информатики, чем многие программисты того времени могли представить, раз они нынче такие умные, что видят ляпы в языках программирования.


    1. WASD1
      14.06.2023 17:41

      Цикл while (true) {} — это бесконечный цикл без сайд-эффектов, то есть
      UB.… Можно просто удалить весь последующий код вместе с return'ом.

      Вообще конкретно это UB довольно логично и служит для помощи программисту.
      А вот компилятор, так зловредно его эксплуатирующий... ну выпускнику MIT надо в резюме воткнуть строчку "мой коммит в LLVM ускорил specperf_*** на 0.01%.

      Логика тут примерно такая:
      1. Любой код без side-effects можно (и нужно в целях производительности) удалять.
      2. Кроме бесконечного цикла - который сам по себе side effect.
      3. Но если мы не можем доказать про цикл, что он конечный/бесконечный (и цикл без side-effects) - давайте его удалим. Пользы намного больше чем вреда.
      3.1 при этом явно скажем программисту, что бесконечные циклы без других side-effects запрещены

      ============ а вот дальше идёт довольно-таки плохая логика
      4. а давайте удалять те циклы, которые без side-effects даже явно бесконечные, формально ссылаясь на то, что это UB и его быть в коде не должно.
      4.1 В оправдание п.4 - c развинием компиляторов многие циклы про которые раньше было непонятно finite\infinite стало можно сделать выводы. Поэтому если не принимать "4" - то для некоторых других существующих программ с обновлнием компилятора получим изменение поведения. В общем как не крути всё плохо.

      ПС
      > для бесконечного цикла надо давать команду ожидания окончания потока.
      В Rust (примерно) так и сделано.
      Цикл loop {} именно бесконечный с гарантией что не удалится бэкэндом.
      В С \ С++ часто сложно сделать что-то разумное не поламав обратную совместимость.


    1. xortator Автор
      14.06.2023 17:41
      +2

      Вообще когда смотрю на это то кажется что стандарт пишут наркоманы эффективные менеджеры, а не инженеры.

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

      while (true) {
        <килотонна кода>
        cond = ...
        if (cond) break;
        <килотонна кода>
      }

      Представьте, что компилятор доказал, что cond начиная с итерации 10 -- инвариант. И переписал цикл в духе

      int fake_cnt = 0;
      while (true) {
        <килотонна кода>
        cond = ...
        if (cond && cnt++ < 10) break;
        <килотонна кода>
      }
      cond_10 = ...
      while (true) {
        <килотонна кода>
        
        if (cond_10) break;
        <килотонна кода>
      }

      Теперь у вас во втором цикле инвариантное условие, и его можно переписать как

      cond_10 = ...
      if (cond_10) {
        while (true) {
          <килотонна кода>
          if (true) break;
          <килотонна кода>
        }
      } else {
        while (true) {
          <килотонна кода>
          if (false) break;
          <килотонна кода>
        }
      }

      Допустим, cond_10 в реальности всегда true, но доказать это невозможно. Так бывает. Теперь у вас второй while -- бесконечный цикл с горой кода, который ничего не делает. Если вы туда зайдёте, ваша программа зависнет и не сделает ничего и никогда. Вычищение такого кода -- экономия компайл тайма.

      Какие проблемы сделать модификаторы типов для определения поведения? И типы длинных чисел.

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

      То есть явно указать поведения тоже невозможно средствами языка. Опять что-то мешает?

      То есть, вы предлагаете перед каждым делением вставлять (на уровне компиляторного фронта) чек типа

      if (denom == 0) {
        // делай то, чего вы там хотите
      }

      ? Ну тогда будет Java, пишите на ней. В С++ это убьёт перфоманс.

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

      А что будет, если вы предположили, а они не выполняются? :)

      Что мешает инициализировать переменные нулями.

      Помимо того, что дорого, так ещё и убьёт кучу оптимизаций. Ну например:

      int x;
      if (smth) {
        x = 10;
      }
      print(x);

      Вы тут не сможете заменить на print(10), придётся честно проверять.

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

      Уточните -- кому надо? :)

      Вообще там огромное количество преобразований которые не основываются на логике и здравом смысле.

      Если вы не понимаете, зачем это делается, это не значит, что в этом нет логики и здравого смысла.

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

      Верно, вы ничего не понимаете в компиляторах. Вас это удивляет? :)


      1. kovserg
        14.06.2023 17:41
        -1

        Представьте такой код

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


        Вы, надеюсь, понимаете, чем сложение в длинных числах отличается от сложения в типах

        Вы надеюсь издеваетесь. Вы когда со строками работаете вас это не останавливает.
        Тут тоже самое надо быстро int надо не без заморочек number.


        Если вы не понимаете, зачем это делается, это не значит, что в этом нет логики и здравого смысла.

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


        1. xortator Автор
          14.06.2023 17:41
          +1

          Вы вообще не в ту сторону смотрите. Я не хочу представлять код. Я хочу иметь возможность работы с кодом как и с другими типами данных. Что бы были селекторы, фильтры и возможность выполнять явные преобразования, что бы его можно не только генерировать но и анализировать и итерационно модифицировать. 

          Понял. Пишите на лиспе.


          1. kovserg
            14.06.2023 17:41

            Вы что-то имеете против лиспа (кроме скобок)?


  1. buldo
    14.06.2023 17:41
    +4

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

    А как писать на C++ без UB?


    1. sv_911
      14.06.2023 17:41

      А как писать на C++ без UB?

      Просто смириться, что ub - таже ошибка, что и не ub. На какой-нибудь яве можно случайно базу стереть неправильным sql запросом. Врядли ub прям сильно страшнее


    1. domix32
      14.06.2023 17:41

      Знать что они существуют, следовать гайдлайнам и делать хорошо и не делать плохо.


    1. aamonster
      14.06.2023 17:41

      Собственно, писать без UB :-)

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

      ЗЫ: а авторам компиляторов и статических анализаторов – развивать диагностику. Довольно часто можно определить, что программист не учёл или неправильно обработал возможность UB. Классический пример – проверка if (this), встречавшаяся раньше в коде от M$ (и которую clang просто выкидывает, выдавая warning).


      1. buldo
        14.06.2023 17:41

        То есть для того, кто работает с плюсами только время от времени - обложиться анализаторами по полной?


        1. aamonster
          14.06.2023 17:41

          Ну, это полезно, но я не об этом. "Не допускай переполнения", "не выходи за границы массива", "не вызывай функции объекта, если он может быть nullptr" и всё такое. Если по простому – ставьте всюду явные проверки, и убирайте их, только когда сами точно знаете, что всё Ok (кстати, есть шанс, что их за вас уберёт компилятор).


      1. morijndael
        14.06.2023 17:41

        Собственно, писать без UB :-)

        То есть, чтобы писать без UB, надо писать без UB? Получается какая-то бесконечная рекурсия...

        ...поведение которой тоже неопределено :DDDD


        1. aamonster
          14.06.2023 17:41

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


      1. kovserg
        14.06.2023 17:41

        Собственно, писать без UB :-)

        Вы давно по минному полю ходили. Надо просто не наступать на мины. А если вы не один, а целая стая?


        1. aamonster
          14.06.2023 17:41

          Дык возникающее UB – это, как правило, ошибка в программе (переполнение, разыменование нулевого указателя и т.п.). Просто мы привыкли, что компилятор нас "прощает" и закладываемся на наши представления о поведении компилятора в этих случаях. Надо отвыкать.

          Причём, как правило, мы эти UB видим. Просто действуем по принципу "и так сойдёт". Ну типа переполнение – ничего страшного, просто получим неверный результат, потом обработаем. Вот и в статье – все UB видны сразу (кроме разве что бесконечного цикла).

          А для "стаи" – писать guidelines и бить палкой за их нарушение. К примеру, требовать писать контракты функций и соблюдать их "внутри и снаружи" (функция на допустимых данных должна корректно отработать [exception, описанный в доке на функцию – тоже корректно], а вызывающий код должен предоставить корректные данные [если пришли снаружи – проверить и обработать ситуацию некорректных]).


    1. xortator Автор
      14.06.2023 17:41
      +2

      Можете почитать статью Джона Рейгра Undefined Behavior != Unsafe Programming. Это не поможет писать без UB (это невозможно), но может, вы станете к этому относиться проще. :)

      https://blog.regehr.org/archives/1467


    1. kovserg
      14.06.2023 17:41
      +1

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

      Если ваша программа на C++ работает без ошибок. Обратитесь к разработчику компилятора, он исправит ошибки в компиляторе.


    1. Nansch
      14.06.2023 17:41

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


  1. WASD1
    14.06.2023 17:41

    Спасибо.

    Один интересный вопрос остался не покрытым.
    В С и С++ разные наборы UB - как бэкэнд LLVM это разруливает?


    1. morijndael
      14.06.2023 17:41

      Скорее всего это разруливает фронтенд компилятора, генерируя для LLVM подсказки: вот тут можно соптимизировать, а вот тут нельзя


    1. xortator Автор
      14.06.2023 17:41

      Это разруливает фронтэнд. LLVM IR имеет чётко прописанное поведение каждой своей инструкции (когда там poison и когда там UB), а задача компилятора С или С++ -- перевести конструкции этих языков в LLVM IR таким образом, чтобы они работали так, как требуют стандарты этих языков.


      1. aamonster
        14.06.2023 17:41

        Именно каждой? Т.е., грубо говоря, чтобы избежать UB в конкретной инструкции – достаточно прямо перед ней вставить (автоматически) проверку аргументов и бросать exception, если они некорректны? (Причём изрядную долю этих проверок оптимизатор выпилит).

        Т.е. можно задёшево и независимо от языка сделать код для тестов на UB?


        1. xortator Автор
          14.06.2023 17:41

          Вы можете генерить такой LLVM IR, в котором вообще не будет UB. Например, так, как вы описываете (плюс не навешивать никуда флагов типа nsw). Даже там, где язык это позволяет, он не обязывает делать именно UB. Но оптимизатору с этим будет жить тяжелее, поэтому на практике всегда, когда язык позволяет UB, стараются сгенерить такой IR, который ведёт себя так же.

          Т.е. можно задёшево и независимо от языка сделать код для тестов на UB?

          За очень-очень-очень дорого, но можно. Можете померить скорость работы джавы с отключенным Tier 2 компилятором и сравнить с аналогичными программами на С++. Будет примерно то же самое (ожидаю в среднем разницу раз в 5-10, в терминальных случаях в сотни и тысячи).


          1. aamonster
            14.06.2023 17:41

            "Задёшево" – в смысле усилий программиста. Прогнать тестирование на 10% производительности (особенно если есть возможность для тестов взять железо попроизводительнее) – приемлемо.

            Ну грубо говоря как когда-то в Turbo Pascal можно было собрать прогу с проверкой выхода за границы массива и т.п. – для отладки собирали так, а деплой уже без этого.


            1. xortator Автор
              14.06.2023 17:41
              +1

              В смысле усилий программиста дешевле UB sanitizer. Он эти чеки сам вставляет. Другое дело, что так можно отловить сильно не всё. Вручную, впрочем, тоже. Банальный пример: если у вас есть только int*, вы никак не можете проверить, вылезаете вы за границы выделенной памяти или нет. Для этого где-то дополнительно придётся хранить длину, и это будет работать только если указатель всегда только на начало массива, а не куда-то в середину.


  1. Tsimur_S
    14.06.2023 17:41
    +1

    Можно просто удалить цикл и весь последующий код вместе с return'ом.

    Почему тогда компилятор этого не сделал а оставил код от never_called()? Разве неиспользуемый код не удаляется компилятором при оптимизациях?


    1. WASD1
      14.06.2023 17:41

      never_called не была помечена как static.
      А значит обязана остаться в единице компиляции.

      ПС
      Но вообще требовать от компилятора удалить неиспользуемый код довольно странно.


      1. Tsimur_S
        14.06.2023 17:41

        Но вообще требовать от компилятора удалить неиспользуемый код довольно странно.

        Я на С++ не писал, поэтому возможно мои вопросы выглядят странно или глупо.

        Но для меня "удивительно" что компилятор выкинув код перешел по сути к пустому main() без кода, ведь по утверждению автора компилятор удалил оттуда код. Но изначально пустой main компилируется в нечто совершенно другое(с вызовом ret).

        Какое именно поведение тут пытался сохранить компилятор?


        1. gurux13
          14.06.2023 17:41
          +2

          С точки зрения компилятора такого mainа "не может быть", потому что бесконечный цикл не "можно удалить", а "приводит к неизвестным последствиям". То есть, компилятор считает себя уверенным в том, что main() никогда не вызовется. Поэтому весь его код можно удалить.

          Если бы main была while(false), компилятор бы (скорее всего) соптимизировал main() в return;

          При этом и main и never_called - экспортируемые метки объектного файла, поэтому должны существовать. И так случилось, что main == never_called. При этом последняя - нормальная функция, и её компилятор компилирует как надо.


        1. WASD1
          14.06.2023 17:41

          Какое именно поведение тут пытался сохранить компилятор?

          Спасибо за вопрос. Он отличный.
          Всё, что сделал компилятор - он сделал чисто технически боле-менее корректно:
          1. Подкорректировал main (удаление return - несомненно malevolent действие) из-за UB, право имел.
          2. Сохранил never_called (она не статик => её чисто теоретически мог вызывать кто-то другой извне).
          3. Сложил 2 функции в машинных кодах в исполняемый файл, легли подряд.

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

          Сейчас эту проблему осознали. В новых языках, даже низкоуровневых UB нет. Например Rust - даже по производительности он не медленнии С\С++.

          Проблема со старыми языками. Там от UB избавитсья крайне сложно.
          Малой кровью - надо было бы запретить return в этм месте. Но тут надо копать кишки LLVM.


          1. Tsimur_S
            14.06.2023 17:41

            Спасибо за объяснение. В общем то пункт 2 понятен, пункт 3 скорее следствие. Но пункт 1 по прежнему загадка.

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

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

            Выкидывание ret из main точно так же нарушает стандарт c++, где в случае отсутствия return statement стандарт требует  implicitly return the value 0 .


          1. morijndael
            14.06.2023 17:41

            Говоря про раст, надо уточнять, про какой именно. UB отсутствует в safe-подмножестве, во всяком случае это цель, и обратное считается багом

            Однако при использовании unsafe-подмножества UB все ещё присутствует, и если нарушить инварианты (пишутся в документации отдельным заголовком Safety), то ноге будет больна


        1. xortator Автор
          14.06.2023 17:41

          Посмотрите на ассемблер внимательно. Там две разные функции, просто записанные подряд. Тело одной начинается с лейбла main:, а тело другой -- с never_called():. Просто так получилось, что весь код после лейбла main: выкосился, и поэтому при передаче туда управления немедленно начинает исполняться код другой функции, который лежит дальше.


  1. jcmvbkbc
    14.06.2023 17:41

    void foo(int *ptr) {
      printf("Before the loop\n");
      for (int i = 0; i < 1000; i++) {
        printf("Before check\n");
        if (i == 17)
          *ptr = 1;
        printf("After check\n");
      }
      printf("After the loop the loop\n");
    }

    * Условие i == 17 станет истинным (причём только 1 раз) в ходе выполнения данного цикла;

    * Поэтому запись единицы в *ptr обязательно произойдёт;

    * Чтобы не делать лишнюю проверку в цикле, компилятор имеет право сделать любое из следующих преобразований:

    void foo(int *ptr) {
      *ptr = 1;
      printf("Before the loop\n");
      for (int i = 0; i < 1000; i++) {
        printf("Before check\n");
        printf("After check\n");
      }
      printf("After the loop the loop\n");
    }

    Это какое-то слишком сильное упрощение. Не имея определения функции printf компилятор не может этого сделать, как минимум по двум причинам:

    • у printf может быть внутреннее состояние, которое заставит её сделать нелокальный переход на какой-нибудь итерации цикла в функции foo. Если это случится до 17й итерации, то присваивание *ptr до начала выполнения цикла может изменить видимое поведение программы.

    • printf может обращаться к объекту, на который указывает ptr переданный в foo. Если значение объекта изменится не ровно между двумя вызовами printf на 17й итерации, то это может изменить видимое поведение программы.


    1. xortator Автор
      14.06.2023 17:41

      Да, хорошее замечание, но я опустил эти подробности, имея в виду, что читатель все-таки знает, что такое printf. На самом деле достаточно вывести willreturn для этой функции (тогда не будет зависания или передачи управления неизвестно куда) и noalias для ptr. Полное определение тут можно не иметь, хватит атрибутов.


  1. playermet
    14.06.2023 17:41

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


    1. Panzerschrek
      14.06.2023 17:41

      Я тоже такого мнения.
      Вон, в Rust, к примеру, UB без unsafe нету. Там догадались не делать переполнение операций с целыми числами UB, как это делает C++. А это значительный процент случаев с UB. Есть ещё деление, но оно в Rust всегда с проверкой на 0 (что несколько медленно). Ну а всякие UB при чтении/записи памяти вообще устранены механизмами языка.