В C99 f () + g () не определено или просто не определено? - PullRequest
54 голосов
/ 17 октября 2010

Раньше я думал, что в C99, даже если побочные эффекты функций f и g вмешиваются, и хотя выражение f() + g() не содержит точку последовательности, f и g будут содержат некоторые, поэтому поведение будет неопределенным: либо f () будет вызываться перед g (), либо g () перед f ().

Я больше не уверен. Что если компилятор указывает функции (которые может решить компилятор, даже если функции не объявлены inline), а затем переупорядочивает инструкции? Можно ли получить результат, отличный от двух предыдущих? Другими словами, это неопределенное поведение?

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

Ответы [ 3 ]

25 голосов
/ 17 октября 2010

Выражение f() + g() содержит минимум 4 точки последовательности; один перед вызовом f() (после того, как все нули его аргументов оценены); один перед вызовом g() (после того, как все нули его аргументов оценены); один в ответ на вызов f(); и один в ответ на звонок g(). Кроме того, две точки последовательности, связанные с f(), встречаются либо до, либо после двух точек последовательности, связанных с g(). Чего вы не можете сказать, так это того, в каком порядке появятся точки последовательности - находятся ли f-точки перед g-точками или наоборот.

Даже если компилятор встроил код, он должен подчиняться правилу «как будто» - код должен вести себя так же, как если бы функции не чередовались. Это ограничивает область повреждения (при условии, что компилятор не содержит ошибок).

Таким образом, последовательность, в которой оцениваются f() и g(), не определена. Но все остальное довольно чисто.


В комментарии суперкат спрашивает:

Я ожидаю, что вызовы функций в исходном коде останутся точками последовательности, даже если компилятор сам решит встроить их. Остаётся ли это верным для функций, объявленных как «встроенные», или компилятор получает дополнительную широту?

Я считаю, что применяется правило «как будто», и компилятор не получает лишней широты, чтобы пропустить точки последовательности, потому что он использует явно inline функцию. Основная причина думать, что (будучи слишком ленивым, чтобы искать точную формулировку в стандарте), является то, что компилятору разрешено включать или не включать функцию в соответствии с ее правилами, но поведение программы не должно изменяться (за исключением производительность).

Кроме того, что можно сказать о последовательности (a(),b()) + (c(),d())? Возможно ли выполнение c() и / или d() между a() и b() или a() или b() для выполнения между c() и d()?

  • Очевидно, что a выполняется до b, а c выполняется до d. Я полагаю, что возможно выполнение c и d между a и b, хотя весьма маловероятно, что компилятор сгенерирует такой код; аналогично, a и b могут выполняться между c и d. И хотя я использовал 'и' в 'c и d', это может быть 'или' - то есть любая из этих последовательностей операций соответствует ограничениям:

    • Определенно разрешено
  • CDAB
  • Возможно разрешено (сохраняет порядок ≺ b, c ≺ d)
  • ACBD
  • AcDb
  • CADB
  • CABD


Я считаю, что охватывает все возможные последовательности. См. Также чат между Джонатаном Леффлером и AnArrayOfFunctions - суть в том, что AnArrayOfFunctions не считает, что «возможно разрешенные» последовательности вообще разрешены.

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

Существуют значительные различия между встроенными функциями и макросами, но я не думаю, что упорядочение в выражении является одним из них. То есть любая из функций a, b, c или d может быть заменена макросом, и может произойти такое же упорядочение макротел. Основное различие, как мне кажется, заключается в том, что с помощью встроенных функций, есть гарантированные точки последовательности при вызовах функций - как указано в основном ответе - а также у операторов запятой. С макросами вы теряете связанные с функцией точки последовательности. (Так что, может быть, это существенная разница ...) Однако, во многих отношениях проблема скорее похожа на вопросы о том, сколько ангелов может танцевать на головке булавки - это не очень важно на практике. Если бы кто-то подарил мне выражение (a(),b()) + (c(),d()) в обзоре кода, я бы попросил его переписать код, чтобы он стал понятнее:

a();
c();
x = b() + d();

И это предполагает, что нет критических требований к последовательности для b() против d().

14 голосов
/ 17 октября 2010

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

1 голос
/ 17 октября 2010

@ dmckee

Ну, это не вписывается в комментарий, но вот в чем дело:

Сначала вы пишете правильный статический анализатор. «Правильный» в данном контексте означает, что он не будет хранить молчание, если в анализируемом коде есть что-то сомнительное, поэтому на этом этапе вы весело объединяете неопределенные и неуказанные поведения. Они оба плохие и неприемлемые в критическом коде, и вы правильно предупредите их обоих.

Но вы хотите предупредить только один раз об одной возможной ошибке, а также знаете, что ваш анализатор будет оцениваться в тестах с точки зрения «точности» и «отзыва» по сравнению с другими, возможно, не правильными, анализаторами, поэтому вы не должен дважды предупреждать об одной и той же проблеме ... Будь то истинная или ложная тревога (вы не знаете, какая. Вы никогда не знаете, какую, иначе это было бы слишком легко).

То есть вы хотите выдать одно предупреждение для

*p = x;
y = *p;

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

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

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

И вы говорите «им»: вам не нужно проверять все (возможно, часто ложные) сигналы тревоги, сгенерированные при первом анализе. Программа нарезки ведет себя так же, как и исходная программа, если ни одна из них не запущена. Слайсер создает программы, эквивалентные критерию нарезки для «определенных» путей выполнения.

А пользователи весело игнорируют сигналы тревоги и используют слайсер.

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

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

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

И это конец истории, которая определенно не вписалась в комментарий. Приносим извинения всем, кто читал это далеко.

...