Скачай PVS-Studio и найди ошибки в С, С++,C# коде
291,98
рейтинг
5 февраля в 14:06

Разработка → Undefined behavior ближе, чем вы думаете

Ад ближе чем кажетсяМногие считают, что неопределённое поведение программы возникает из-за грубых ошибок (например, запись за границы массива) или на неадекватных конструкциях (например, i = i++ + ++i). Поэтому для многих является неожиданностью, когда неопределенное поведение вдруг проявляет себя во вполне привычном и ничем не настораживающем коде. Рассмотрим один из таких примеров. Программируя на C/C++ никогда нельзя терять бдительность. Ад ближе чем кажется.



Описание ошибки


Я давненько не поднимал тему 64-битных ошибок. Тряхну стариной. В данном случае неопределённое проведение будет проявлять себя в 64-битной программе.

Рассмотрим некорректный синтетический пример кода.
size_t Count = 1024*1024*1024; // 1 Gb
if (is64bit)
  Count *= 5; // 5 Gb
char *array = (char *)malloc(Count);
memset(array, 0, Count);

int index = 0;
for (size_t i = 0; i != Count; i++)
  array[index++] = char(i) | 1;

if (array[Count - 1] == 0)
  printf("The last array element contains 0.\n");

free(array);

Этот код корректно работает, если собрать 32-битную версию программы. А вот если собрать 64-битный вариант программы, всё намного интересней.

64-битная программа выделяет массив байт размеров в 5 гигабайт и заполняет его нулями. Затем в цикле массив заполняется какими-то случайными числами, неравными нулю. Чтобы числа не были равны 0, используется "| 1".

Попробуйте угадать, как поведёт себя эта программа, собранная в режиме x64 с помощью компилятора, входящего в состав Visual Studio 2015. Заготовили ответ? Если да, то продолжим.

Если вы запустите отладочную версию этой программы, то она упадёт из-за выхода за границу массива. В какой-то момент переменная index переполнится и её значение станет равно ?2147483648 (INT_MIN).

Логичное объяснение? Ничего подобного! Это неопределённое поведение и произойти может всё что угодно.

Дополнительные ссылки:
Когда я или кто-то ещё говорит, что это неопределённое поведение, люди начинают ворчать. Я не знаю почему, но люди уверены, что точно знают, как работают вычисления в C/C++ и как ведут себя компиляторы.

Но на самом деле они этого не знают. Если бы знали, они бы не говорили всякие глупости. Обычно глупости выглядят как-то так (собирательный образ):

Вы несете теоретический бред. Ну да, формально переполнение 'int' приводит к неопределенному повреждению. Но это не более чем болтовня. На практике, всегда можно сказать что получится. Если к INT_MAX прибавить 1, мы получим INT_MIN. Быть может и есть какие-то экзотические архитектуры, где это не так, но мой компилятор Visual C++ / GCC выдают корректный результат.

Так вот, сейчас я без всякой магии на простом примере продемонстрирую неопределённое поведение и не на какой-то волшебной архитектуре, а в Win64-программе.

Достаточно собрать приведённый выше пример в режиме Release x64 и запустить его. Программа перестанет падать, а сообщение «the last array element contains 0» выдано не будет.

Неопределенное поведение здесь проявило себя следующим образом. Массив будет полностью заполнен, не смотря, на то, что тип 'int' недостаточен для индексации всех элементов массива. Для тех, кто не верит, предлагаю взглянуть на ассемблерный код:
  int index = 0;
  for (size_t i = 0; i != Count; i++)
000000013F6D102D  xor         ecx,ecx  
000000013F6D102F  nop  
    array[index++] = char(i) | 1;
000000013F6D1030  movzx       edx,cl  
000000013F6D1033  or          dl,1  
000000013F6D1036  mov         byte ptr [rcx+rbx],dl  
000000013F6D1039  inc         rcx  
000000013F6D103C  cmp         rcx,rdi  
000000013F6D103F  jne         main+30h (013F6D1030h)

Вот оно проявление неопределенного поведения! И никаких экзотических компиляторов. Это VS2015.

Если заменить 'int' на 'unsigned' неопределённое поведение исчезнет. Массив будет заполнен только частично и в конце будет выдано сообщение «the last array element contains 0».

Ассемблерный код, когда используется 'unsigned':
  unsigned index = 0;
000000013F07102D  xor         r9d,r9d  
  for (size_t i = 0; i != Count; i++)
000000013F071030  mov         ecx,r9d  
000000013F071033  nop         dword ptr [rax]  
000000013F071037  nop         word ptr [rax+rax]  
    array[index++] = char(i) | 1;
000000013F071040  movzx       r8d,cl  
000000013F071044  mov         edx,r9d  
000000013F071047  or          r8b,1  
000000013F07104B  inc         r9d  
000000013F07104E  inc         rcx  
000000013F071051  mov         byte ptr [rdx+rbx],r8b  
000000013F071055  cmp         rcx,rdi  
000000013F071058  jne         main+40h (013F071040h)

Примечание про PVS-Studio


Анализатор PVS-Studio напрямую не диагностирует переполнение знаковых переменных. Это неблагодарное занятие. Почти невозможно предсказать, какие значения будут иметь те или иные переменные и произойдет переполнение или нет. Однако, он может заметить в этом коде ошибочные паттерны, которые он связывает с «64-битными ошибками».

На самом деле никаких 64-битных ошибок нет. Есть просто ошибки, например, неопределённое поведение. Просто эти ошибки спят в 32-битном коде и проявляют себя в 64-битном. Но если говорить про неопределённое поведение, то это не интересно, и никто покупать анализатор не будет. Да ещё и не поверят, что могут быть какие-то проблемы. А вот если анализатор говорит, что переменная может переполниться в цикле, и что это ошибка «64-битная», то совсем другое дело. Profit.

Приведенный выше код PVS-Studio считает ошибочным и выдаёт предупреждения, относящиеся к группе 64-битных диагностик. Логика следующая: в Win32 переменные типа size_t являются 32-битными, массив на 5 гигабайт выделить нельзя и всё корректно работает. В Win64 стало много памяти, и мы захотели работать с большим массивом. Но код отказал и даёт сбой. Т.е. 32-битный код работает, а 64-битный нет. В рамках PVS-Studio это называется 64-битной ошибкой.

Вот диагностические сообщения, которые выдаст PVS-Studio на код приведённый в начале:
  • V127 An overflow of the 32-bit 'index' variable is possible inside a long cycle which utilizes a memsize-type loop counter. consoleapplication1.cpp 16
  • V108 Incorrect index type: array[not a memsize-type]. Use memsize type instead. consoleapplication1.cpp 16

Подробнее на тему 64-битных ловушек предлагаю познакомиться со следующими статьями:

Корректный код


Чтобы всё работало хорошо, надо использовать подходящие типы данных. Если вы собираетесь обрабатывать большие массивы, то забудьте про int и unsigned. Для этого есть типы ptrdiff_t, intptr_t, size_t, DWORD_PTR, std::vector::size_type и так далее. В данном случае пусть будет size_t:
size_t index = 0;
for (size_t i = 0; i != Count; i++)
  array[index++] = char(i) | 1;

Вывод


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

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

Не будьте в их числе. То, что программа сейчас работает, ещё ничего не значит. Как проявит UB предсказать невозможно. Ожидаемое поведение программы — это всего лишь один из вариантов UB.
Автор: @Andrey2008
PVS-Studio
рейтинг 291,98
Скачай PVS-Studio и найди ошибки в С, С++,C# коде

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

  • 0
    Интересный подход. А как это так компилятор int (который, емнимс, на винде = 32бита) легко положил в 64битный регистр и сравнивает напрямую не ругаясь?!
    Это обычные оптимизации, или мегажесть?
    • +7
      Это неопределённое поведение, возникшее из-за переполнения signed integer. Проявляется в Release. Собственно, про это статья и написана :)
      • 0
        gcc -O2 a.cpp
        a.cpp: In function ‘int main()’:
        a.cpp:16:18: warning: iteration 2147483647ul invokes undefined behavior [-Waggressive-loop-optimizations]
        array[index++] = char(i) | 1;
        ^
        a.cpp:15:3: note: containing loop
        for (size_t i = 0; i != Count; i++)
        ^

        При этом результат тот же в релизе с -O2
        .L3:
        movl %edx, %ecx # i, tmp90
        orl $1, %ecx #, tmp90
        movb %cl, (%rbx,%rdx) # tmp90, MEM[base: array_7, index: i_25, offset: 0B]
        addq $1, %rdx #, i
        cmpq %rax, %rdx # tmp95, i
        jne .L3 #,
        movabsq $5368709119, %rax #, tmp92
        cmpb $0, (%rbx,%rax) #, MEM[(char *)array_7 + 5368709119B]

        Без оптимизаций или с O1 — падает с segmentation fault, то есть честно 32бита выдерживает.

        Выходит, релиз в VCC включает аггрессивные оптимизации, но совершенно молча.
        • +16
          Компилятор считает, что неопределенного поведения в программе нет. Точка.

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

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

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

          • +1
            А что тогда «warning: iteration 2147483647ul invokes undefined behavior» означает?

            Я к тому, что UB есть, но при проведении аггрессивных оптимизаций компилятор вправе трактовать любые UB несущественными и оптимизировать считая что их нет — но поведение GCC с ворнингом на эту тему мне больше нравится.
            • +7
              Именно это. Компилятор намекает, что такое значение спровоцирует неопределенное поведение, но сам исходит из предположения, что программист не дурак и не рассчитывает на такой сценарий.

              Предворяю вопрос типа: «ну ёлки палки! варнинг он дать додумался, а правильно код скомпилировать не может! как так?». Это несколько разные вещи: дать варнинг и написать код в соответствии со стандартом.

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

              Разработчики стоят перед выбором: писать условно-безопасный код или писать быстрый. Исторически, языки семейства Си идут по пути скорости.
              • +1
                Вот кстати нет. GCC совершенно честно и правильно поступает с -O1: ворнинга нет, переменная signed 32 bit, идёт переполнение.
                С -O2 сообщает об UB, и транслирует как посчитал нужным, на что имеет право.

                У меня жалоба на VC — она не пожаловалась, но оттранслировала себе на уме. В том числе, следует помнить, что «int» это «не меньше 32 бит» а не «ровно 32 бита» — поэтому решение вполне корректное, но UB.
                • +6
                  …она не пожаловалась, но оттранслировала себе на уме
                  Повторюсь на всякий случай: это совершенно не означает, что VC такая бяка и не сказала о проблеме. Компилятор мог быть совершенно не в курсе.

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

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

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

                  Советую почитать вот эти статьи из блога LLVM:



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

            Верно. Почему разместив 32 битную переменную в младшей части, код производит вычисления с 64 битной переменной, невзирая на мусор в старшей части?

            000000013F6D102D  xor         ecx,ecx  
            000000013F6D1036  mov         byte ptr [rcx+rbx],dl 

            Или автор статьи не разместил этот кусок инициализации и старшая часть rcx сбрасывается где-то раньше?
            • +1
              Она либо сбрасывается, либо не учитывается при возвращении результата. Обычно вся эта кухня крутится вокруг «наблюдаемого поведения». Компилятор может делать что угодно, если наблюдаемый результат выполнения модифицированной программы с точки зрения программиста не будет отличаться от буквальной интерпретации и при условии отсутствия UB в коде.

              Простейший пример: знаковое переполнение считается неопределенным поведением. Поэтому код вида…

              for (int i = 0; i < array.size(); i++) { do_smth(array[i]); }

              …уже́ провоцирует неопределенное поведение, поскольку мы инкрементируем знаковую переменную. Компилятор считает, что в этом случае переполнения не произойдет. А поэтому имеет полное право закодировать инкремент как (на примере LLVM IR)

              %i.next = i32 add nsw nuw %i.cur, 1

              Спецификаторы nsw и nuw буквально означают «no signed wrap» и «no unsigned wrap».

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

              При отсутствии UB, наблюдаемое поведение будет идентично, как и положено.
            • 0
              Учите же, блин, матчасть! После первой инструкции старшая часть ecx — нулевая!

              Для упрощения процессора в x86-64 нет 32-битных регистров. В принципе. Совсем. Вот 8-битные есть, 16-битные есть, даже 64-битные есть. А 32-битных — нет.

              Есть 32-битные операции. В старшую половину соотвествующего регистра они пишут нуль.
              • +1
                Т.е. xor rcx, rcx и xor ecx, ecx делают одно и то же?
                Не знал, спасибо за разъяснение.
                • 0
                  Да, с точки зрения практического результата "xor rcx, rcx" и "xor ecx, ecx" ничем не отличаются. Но "xor ecx, ecx" на один байт короче :-)
                  • 0
                    А вот это уже неожиданно. Обычно же "родные" команды короче, а под чужую разрядность — с префиксом. Не приходилось писать на асм под x86-64, но под x86/IA32 писал очень много. Пришла пора подтянуться и попрактиковаться. Вот так один дилетантский вопрос может сподвигнуть к практике.
                    • +1
                      В случае с x86-64 "родные" команды — как раз 32-битные. Собственно потому Intel использовал одно время название IA-32e. Никто этой "гениальной идеи" не понял, так что навание не прижилось, но в каком-то смысле оно было ближе к истине.

                      Как я уже сказал 32-битных регистров архитектура не предусматривает вообще, но большинство инструкций — по умолчанию 32-битные. Исключения — jcc/jmp/call и push/pop. А вот при обращении к памяти — по умолчанию используется полный регистр.

                      P.S. У RISC'ов все инструкции имеют одинаковую длину, но при записи в "32-битный" регистр — старшая часть обнуляется. Собственно всё просто: когда расширяли 80286й до 80386го, то проще было сделать так, чтобы запись шла только в часть регистра. Банально меньше связей между частями процессора нужно. А потом — появилось переименование регистров. И вот тут выяснилось, что если писать не во весь регистр, а в его часть — то соотвествующему модулю работать сложнее и программы работают медленнее. Насколько я знаю все (или почти все) 64-битные архитектуры устроены так, что у них нет отдельно 32-битных и 64-битных регистров. Точно знаю что ARM так делает, MIPS и POWER.

                      P.P.S. По настоящему крышесносительное решение Intel принял, когда создавал AVX. Там регистры со 128-бит расширили до 256 бит. Но при этом SSE-интсрукции старшую половину не обнуляют! Вместо этого для всех инструкций завели AVX-"дубликата", который это делает. А также для большинства инструкций, конечно, есть и версия, которая работает со всем регистром. В документации категорически не рекомендуется смешивать SSE и AVX инструкции и есть специальная инструкция обнуляющая старшие половинки всех регистров. Вот объяснения — чего и сколько нужно выкурить, чтобы подобную архитектуру изобразить я не знаю до сих пор. Я бы ещё мог себе представить что произошло, если бы одна ревизия добавила "широкие" инструкции, а другая — "узкие с обнулением" (ну сглупили/не додумали/etc), но нет — это всё в одной ревизии добавилось...
        • +8
          Это вам повезло просто. GCC 4.8.4 ничего не выдаёт, программа не падает.

          Я уже писал: неопределённое поведение — оно вообще инструкцией для программистов, а не для разработчиков компилятора, является.

          Компилятор исходит из аксиомы: «данная мне на вход программа никогда не вызывает неопределённого поведения». Выяснить правда это или нет он не может (всё упрётся в проблему остановки), потому поступает так же как и при нарушнии, скажем, ODR: пусть будет, как будет, ведь как-нибудь да будет, никогда ещё не было, чтобы никак не было.

          Пожаловаться каждый раз, когда компилятор полагается на то, что в программе нет UB — раз плюнуть, но вы только представьте что будет, если каждый раз, когда компилятор какое-нибудь if (a + 3 > b + 2) превращает в (a + 1 > b) он будет жаловаться. Вы же с ума сойдёте!
    • 0
      Это очень простая оптимизация. И выглядит она следующим образом:
      int index = 0; // лежит в INT_MIN..INT_MAX
      for(size_t iter = 0; iter != FAR_AWAY_FROM_INT_MAX; ++iter)
        arr[index++] = char(iter); // мы ведь не добежим до INT_MAX+1, мамой клянёмся
      

      то есть, сделаем не более INT_MAX итераций, то есть, условие цикла всегда истинно, то есть, можем крутить цикл вечно!

      Кроме того, инвариант цикла — index == iter. Поэтому мы можем свободно выбирать между arr[index] и arr[iter] — поэтому гусь стреляет в минус-бесконечность, а вася бежит до плюс-бесконечности.

      Похожий пример, как задрючить гуся: безо всякой стрельбы по памяти.
      ideone.com/f07SyA
      #include <iostream>
      using namespace std;
       
      int main() {
      	int const F = 1000000000;
      	int const N = (~0U >> 1) / F;
      	int x = 0;
      	cout << "N = " << N << endl; // N=2.
      	for(int y = 0; y < N + 5; ++y) // 7 итераций, думаете вы...
      	{
      		cout << "x = " << x << " : y = " << y << endl;
      		x += F; // ненене, мы клянёмся сделать только 2 итерации по-честному...
      	}
      }
      

      Мы получим бесконечный цикл. А если как-то ещё пошевелить программу, то получим 2 итерации вместо 7. (Сейчас не помню, как это сделать, а экспериментировать лень).

      Безо всякой 64-битности, заметьте!
      • 0
        > int const N = (~0U >> 1)
        Сдвиг вправо отрицательного числа — UB.
        • +2
          А где это у меня отрицательное число? Буковка U там зачем стоит, как думаете?
          • 0
            совсем плохо с головой, угу.

            мозг мне взорвали, спасибо. O_o
  • 0
    Я, может, немного затупляю, но почему с unsigned нет undefined behavior?

    * Edit: а, тупо в спецификации так написано? Ок… А почему такая разница между signed/unsigned?
    • +5
      Потому что переполнение unsigned значений разрешено в стандарте. Оно все равно будет некорректным с точки зрения программиста, но определенным с точки зрения компилятора.
      • –1
        Ну да, я понял, что разрешено. А почему такое различие между казалось бы схожими типами? Чем это обусловлено?
        • +8
          Реализаций signed integer существует много, и у каждой своё поведение при переполнении, а стандарт Си не мог быть привязан только к одной из них.
          • +1
            Звучит разумно, спасибо.
  • 0
    Андрей, планируете ли вы делать проверки на соответствие Cpp Core Guidelines?
    • +7
      Я смотрел этот документ и даже сделал себе пару пометок, о том, что можно добавить в анализатор.

      Но в целом, ответ нет. Обоснование:

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

      2. Там описано много плохих паттернов. Но далеко не каждый плохой паттерн — это ошибка. Нам не нравится выдавать просто рекомендации. От этого анализатор быстро портится. Посмотрит человек первые 20 предупреждений, а там что-то в духе: класс плохо назван, локальных переменных много и т.п. Скажет — ага, понятно. И удалит анализатор. Хотя среди всего этого мусора были полезные предупреждения. Поэтому мы ориентируемся именно на поиск ошибок, а не на выдачу рекомендаций по улучшению.
      • 0
        Ну можно какую-нибудь галочку типа «выдавать помимо ошибок ещё и рекомендации» :-)
        • +4
          Человек при знакомстве не глядя включит все галочки, а потом будет «а, понятно…».
      • 0
        Вел один проект, который собирался с полным выводов ворнингов в GCC: pedantic, wall, weffc++… Там во многих местах компилятор ругался на ерунду. Я просто дефайнами отключил ворнинги в нужных участках кода. Почему не поступать как-нибудь в этом роде?
        • +3
          Отключить то не проблема. И, кстати, в PVS-Studio есть масса механизмов для этого. Можно писать комментарии в специальных местах, можно использовать глобальные комментарии для макросов и иных повторяющихся конструкций, есть специальный #ifdef, есть база разметки неинтересных сообщений (для быстрого внедрения анализатора), и так далее.

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

          Можно конечно по умолчанию отключать малоприоритетное. Что кстати мы и делаем в демонстрационной версии. Но в целом проблема есть и очень большая. Никто ведь не мешает включить всё на максимум, когда мы выдаём пробный ключ для более плотного изучения. Все почему-то сразу лезут в настройки и включают всё что могут (например, заказные диагностики). Не знаю, почему так происходит. Но сам не раз наблюдал такое поведение на подопытных кроликах. :)
          • +1
            Расскажу свой опыт, как я играюсь с разными анализаторами: я включаю всё-всё-всё, прогоняю, складываю в лог.
            Результат отсортирую и разбиваю по каждой отдельной проверке.
            Затем их отсортировал по размеру от маленьких к большим (по результатам).

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

            Например, на PVS у меня получилось:
            DISABLE_CHECKS=«V122|V813|V128|V690|V112|V616»

            Может оно и полезно, но не в этой жизни.

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

            … вот только в итоге реально было 2 фикса, и мне не удалось найти хорошего примера в истории изменений, где бы анализатор смог найти что-то, что не было бы поймано на первом же ревью и/или тестами.
  • 0
    (промахнулся)
  • 0
    Этот код корректно работает, если собрать 32-битную версию программы.

    Этот код точно такой же некорректный и для 32-битной версии.
    Надо всегда помнить, что int не может быть короче short int'а (по стандарту C89), но легко может быть равен ему.
    Поэтому перебор миллиарда символов там тоже может привести к переполнению на ЛЮБОМ компиляторе.
  • –8
    Что, на reddit.com/r/cpp уже забанили?
  • –7
    Так много человек незамечают, что пример в самом начале статьи использовать не совсем верно… И так мало человек отвечают правильно на вопрос когда его кто-то задает на собеседованиях, в том числе сами «собеседователи»…
    Дело в том, что это выражение ( i = i++ + ++i) всегда определЁнно, хотя порядок вычисления операндов сложения не определён :)
    например, пусть в i у нас 1, сначала вычисляется левый операнд:
    1 + 3 = 4
    сначала вычисляется правый операнд:
    2 + 2 = 4
    и тд для всех целых
    • +1
      Нет.
    • +1
      Не вводите людей в заблуждение и разберитесь для начала сами.
    • +3
      Для того, чтобы понять где и почему вы неправы нужно немного знать про то как устроен не только C, но и ассемблер. Вот сколько у вас тут операций, по вашему происходит? Три? Как бы не так: восемь (а может и больше: скомпилируйте программу с -O0 — сами увидите)!

      i++
      A1. Прочитать значение i из памяти в регистр α.
      A2. Увеличить значение регистра α.
      A3. Положить значение в память из регистра α.

      ++i:
      B1. Прочитать значение i.
      B2. Увеличить значение регистра β.
      B3. Записать значение регистра β в память.

      i++ + ++i:
      C1. Сложить значение регистра α после шага A1, но до шага A2 со значением регистра β после шага B2, положив значение в регистр γ.
      C2. Записать значение γ в память.

      Никто не мешает компилятору, скажем, взять и выполнить операции в такой последовательности:
      A1, B1, B2, C1, С2, B3, A2, A3.
      В результате i будет равно 2.

      Обычно компиляторы в современных CPU-архитектурах таких вещей не делают, так как непонятно что на этом можно выиграть, но если у вас есть, скажем, автоинкрементирующаяся память (как на PDP-7 и PDP-8, то подобные вещи вполне возможны.

      Соотвественно в переносимой программе их быть не должно и компилятор имеет право на это опираться.
      • –4
        Нет. Ваше утверждение эквивалетно, что неопределено уже a=a+a. А это не так. В данном примере неопределенно только выполнение поярдка вычисления левого и правого операнда для операции сложения. Приравнивание уже вполне определенно будет принимать значение операции сложения. Именно поэтому хотя порядок неопределен, значение определено.
        • +2
          Нет, не эквивалентно, так как есть Sequence point перед присвоением результата.
          В случае же с пре/пост инкрементами внутри вычисления их нет.
        • +3
          В данном примере неопределенно только выполнение поярдка вычисления левого и правого операнда для операции сложения.
          В данном примере не определено в какой последовательности произойдёт отставка трёх операций. Реализация, которая «выносит» все пост-и-прединкременты из выражений (и выполняет все принкременты до «основного» выражения, а все постинкременты — после) — абсолютно законна.

          Почитайте хотя бы википедию.
      • +5
        Можете не метать бисер перед свиньями. Не оценят.

        Мне уже тут этот товарищ объяснил в личке, что он из вселенной, где подобное выражение имеет вполне определённое значение, ну а если в нашей вселейнной с этим несогласны авторы стандартов C и C++, компиляторов gcc и clang, редакторы Википедии и прочие — ну дык это значит, что нужно поправить статью в Википедии, исправить стандарт и компиляторы. Делов-то.

        P.S. И ведь если я правильно понял автор этого комментария не только пишет на C, но и интервьюирует людей, которые потом будут писать на C! И ведь всё это потом кто-то использует… возможно даже я… как страшно жить.
        • 0
          Может он явист в душе? Вот там всё чётко по стандарту и такое выражение имеет точное значение всегда. Но писать такое в коде — не уважать никого.
  • 0
    Умеет ли PVS ловить такие ситуации, связанные с допущениями компилятора о невыходе за границу диапазона?
    Ведь UB легко отловить даже вот так
    ideone.com/5M0MeA
    #include <iostream>
    using namespace std;
    
    int main() {
    	int const F = 1000000000;
    
    	int x;
    	for(int i=0; i<5; ++i) {
    		x = i*F;
    	}
    	cout << "OK: " << x << endl; // -294967296 = F*4, што?!
    
    	x = 0;
    	for(int i=0; i<5; ++i) {
    		x += F;
    	}
    	cout << "OK: " << x << endl; // 705032704 = F*5, што?!!!
    
    	for(int i=0; i<5; ++i) {
    		cout << (i*F) << endl; // печатаем... печатаем...
    	}
    	cout << "OK: " << x << endl; // nevermore!
    }
    
    • 0
      Такое — нет. Неблагодарное занятие.
      • +1
        А компиляторы, заррразы, отлавливают! Только трактуют в свою пользу :)))
        • 0
          Да, знаменитый bug 33498, который превращает функцию с простым переполнением в «убийцу».

          Хорошо, что есть -fwrapv и плохо, что -ftrapv работает через пень-колоду…
          • 0
            Кстати, начиная с версии 4.8 GCC выдаёт таки предупреждение

            8 : warning: iteration 31u invokes undefined behavior [-Waggressive-loop-optimizations]

            Clang генерирует "честный" код, но не выдаёт никаких предупреждений. (Но начиная с версии 3.7.0 разворачивает цикл полностью).
  • 0
    Самая интересная на этот счет ошибка которая мне попалась бага с OS X sysctl и HW_USERMEM параметром.
    Суть бага в том что наше приложение проверяло на старте доступное кол-во памяти с помощью вот этого.
    Но у нас кроссплатформенное приложение и потом для мака разработка велась не всегда и использовались мак мини в котороых памяти было не очень много. Но тут появился мак бук про с 16 гигами и начались не понятные невозможности стартануть приложение которое просто падало на старте, ассертов в том месте не было, просто закрывалось приложение.
    То есть приложение не запускается, ребут — уже запускается, через время опять не хочет, потом опять нормально.
    Это какой-то ужас был. Но все-таки копнул поглубже и что выяснилось:
    HW_USERMEM у OS X возвращает всегда 4 байтный инт, варианта с 8 — нет, например как у FreeBSD и в итоге, при вызове sysctl с HW_USERMEM
    возвращался переполненный инт, иногда отрицательный, иногда нет.
    В общем, починил выкинув данную проверку, так как на реальных тачках для нашего приложения она не очень имела смысл.

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

Самое читаемое Разработка