Порядок оценки индексов массива (по сравнению с выражением) в C

47

Глядя на этот код:

static int global_var = 0;

int update_three(int val)
{
    global_var = val;
    return 3;
}

int main()
{
    int arr[5];
    arr[global_var] = update_three(2);
}

Какая запись массива обновляется? 0 или 2?

Есть ли часть в спецификации C, которая указывает приоритет работы в этом конкретном случае?

Jiminion
источник
21
Это пахнет неопределенным поведением. Это, безусловно, то, что никогда не должно быть преднамеренно закодировано.
Возня с битами
1
Я согласен, что это пример плохого кодирования.
Jiminion
4
Некоторые неофициальные результаты: godbolt.org/z/hM2Jo2
Bob__
15
Это не имеет никакого отношения к индексам массива или порядку операций. Это связано с тем, что спецификация C называет «точками последовательности», и, в частности, с тем фактом, что выражения присваивания НЕ создают точку последовательности между левым и правым выражением, поэтому компилятор может делать так, как он выбирает.
Ли Даниэль Крокер
4
Вам следует сообщить о запросе функции, clangчтобы этот фрагмент кода вызвал предупреждение IMHO.
Малат

Ответы:

51

Порядок левого и правого операндов

Чтобы выполнить присваивание arr[global_var] = update_three(2), реализация C должна оценить операнды и, в качестве побочного эффекта, обновить сохраненное значение левого операнда. C 2018 6.5.16 (что касается присвоений), параграф 3 говорит нам, что нет последовательности в левом и правом операндах:

Оценки операндов не являются последовательными.

Это означает, что реализация C может сначала вычислить lvalue arr[global_var] (вычисляя lvalue, мы имеем в виду выяснение того, к чему относится это выражение), затем вычислить update_three(2)и, наконец, присвоить значение последнего первому; или update_three(2)сначала вычислить, затем вычислить l-значение, затем присвоить первое последнему; или для оценки lvalue и update_three(2)каким-то смешанным образом, а затем присвоить правильное значение левому lvalue.

Во всех случаях присвоение значения lvalue должно выполняться последним, потому что 6.5.16 3 также говорит:

… Побочный эффект обновления сохраненного значения левого операнда секвенируется после вычислений значения левого и правого операнда…

Нарушение последовательности

Некоторые могут задуматься о неопределенном поведении из-за его использования global_varи отдельного обновления в нарушение 6.5 2, которое гласит:

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

Многим специалистам по Си довольно хорошо известно, что поведение выражений, таких как x + x++, не определяется стандартом Си, поскольку они оба используют значение xи по отдельности изменяют его в одном и том же выражении без последовательности. Однако в этом случае у нас есть вызов функции, который обеспечивает некоторую последовательность. global_varиспользуется arr[global_var]и обновляется в вызове функции update_three(2).

6.5.2.2 10 сообщает нам, что перед вызовом функции есть точка последовательности:

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

Внутри функции, global_var = val;является полное выражение , и так это 3в return 3;, за 6,8 4:

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

Тогда есть точка последовательности между этими двумя выражениями, снова в 6.8 4:

… Между оценкой полного выражения и оценкой следующего полного выражения, подлежащего оценке, есть точка последовательности.

Таким образом, реализация C может arr[global_var]сначала выполнить оценку, а затем выполнить вызов функции, и в этом случае между ними есть точка последовательности, потому что перед вызовом функции существует единица, или она может выполнить оценку global_var = val;в вызове функции, а затем arr[global_var], в этом случае точка последовательности между ними, потому что есть одна после полного выражения. Таким образом, поведение не определено - любая из этих двух вещей может быть оценена первой - но оно не является неопределенным.

Эрик Постпищил
источник
24

Результат здесь не уточняется .

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

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

Для пояснения стандарт C определяет неопределенное поведение в разделе 3.4.3 как:

неопределенное поведение

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

и определяет неопределенное поведение в разделе 3.4.4 как:

неуточненное поведение

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

Стандарт гласит, что порядок вычисления аргументов функции не определен, что в данном случае означает, что либо arr[0]устанавливается в 3, либо arr[2]в 3.

dbush
источник
«Вызов функции вводит точку последовательности» недостаточно. Если левый операнд вычисляется первым, этого достаточно, так как тогда точка последовательности отделяет левый операнд от вычислений в функции. Но если левый операнд вычисляется после вызова функции, точка последовательности, вызванная вызовом функции, не находится между оценками в функции и оценкой левого операнда. Вам также нужна точка последовательности, которая разделяет полные выражения.
Эрик Постпишил
2
@EricPostpischil В терминологии до C11 есть точка последовательности при входе и выходе функции. В терминологии C11 все тело функции определено в последовательности вызывающего контекста. Они оба определяют одно и то же, просто используют разные термины
ММ
Это абсолютно неправильно. Порядок оценки аргументов присваивания не уточняется. Что касается результата этого конкретного назначения, то это создание массива с ненадежным содержимым, как непереносимым, так и по своей сути неправильным (несовместимым с семантикой или с любым из предполагаемых результатов). Идеальный случай неопределенного поведения.
kuroi neko
1
@kuroineko То, что выходные данные могут отличаться, автоматически не делает их неопределенными. Стандарт имеет разные определения для неопределенного и неопределенного поведения, и в этой ситуации это последнее.
dbush
@EricPostpischil У вас есть точки последовательности здесь (из информативного Приложения C C11): «Между оценками указателя функции и фактическими аргументами в вызове функции и фактическим вызовом. (6.5.2.2)», «Между оценкой полного выражения и следующее полное выражение для оценки ... / - / ... (необязательное) выражение в операторе возврата (6.8.6.4) ". И хорошо, на каждой точке с запятой тоже, так как это полное выражение.
Лундин
1

Я попытался, и я получил запись 0 обновлена.

Однако в соответствии с этим вопросом: всегда будет вычисляться правая часть выражения в первую очередь

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

Микаэль Б.
источник
Я получил обновление в записи 0, а также.
Jiminion
1
Поведение не является неопределенным, но не определено. Естественно, в зависимости от любого следует избегать.
Антти Хаапала
@AnttiHaapala Я редактировал
Микаэль Б.
1
Хм ах, и это не последовательность, а неопределенная последовательность ... 2 человека, стоящие в очереди случайным образом, имеют неопределенную последовательность. Нео внутри агента Смита нет последовательности, и произойдет неопределенное поведение.
Антти Хаапала
0

Так как бессмысленно выдавать код для назначения до того, как у вас есть значение для назначения, большинство компиляторов C сначала генерируют код, который вызывает функцию, и сохраняют результат где-нибудь (регистр, стек и т. Д.), Затем они генерируют код, который записывает это значение в конечный пункт назначения, и поэтому они будут читать глобальную переменную после ее изменения. Давайте назовем это «естественным порядком», определяемым не каким-либо стандартом, а чистой логикой.

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

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

int idx = global_var;
arr[idx] = update_three(2);

или кодирование:

int temp = update_three(2);
arr[global_var] = temp;

Как хорошее правило: если глобальные переменные не являются const(или нет, но вы знаете, что ни один код не изменит их как побочный эффект), вы никогда не должны использовать их непосредственно в коде, как в многопоточной среде, даже это может быть неопределенным:

int result = global_var + (2 * global_var);
// Is not guaranteed to be equal to `3 * global_var`!

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

(Просто на тот случай, если кто-то спросит, зачем кому-то делать x + 2 * xвместо этого 3 * x- на некоторых процессорах добавление происходит очень быстро и умножается на степень два, так как компилятор превратит их в битовые сдвиги ( 2 * x == x << 1), но умножение на произвольные числа может быть очень медленным таким образом, вместо умножения на 3, вы получаете намного более быстрый код, сдвигая бит на 1 и добавляя х к результату - и даже этот трюк выполняется современными компиляторами, если вы умножаете на 3 и включаете агрессивную оптимизацию, если это не современная цель Процессор, в котором умножение одинаково быстр как сложение с тех пор, как трюк замедлит вычисления.)

Mecki
источник
2
Это не неопределенное поведение - стандарт перечисляет возможности, и одна из них выбирается в любом случае
Антти Хаапала
Компилятор не превратится 3 * xв два чтения x. Он может прочитать x один раз, а затем выполнить метод x + 2 * x в регистре, в который он читает x
MM
6
@Mecki «Если вы не можете сказать, что результат, просто взглянув на код, результат не определен» - неопределенное поведение имеет очень специфическое значение в C / C ++, и это не так. Другие ответчики объяснили, почему этот конкретный экземпляр не определен , но не определен .
Марсель
3
Я ценю намерение пролить свет на внутреннюю часть компьютера, даже если это выходит за рамки первоначального вопроса. Тем не менее, UB - это очень точный жаргон C / C ++, и его следует использовать осторожно, особенно когда речь идет о технической специфике языка. Вы могли бы вместо этого использовать правильный термин «неопределенное поведение», который значительно улучшит ответ.
Курой Неко
2
@Mecki " Undefined имеет очень особое значение в английском языке " ... но в вопросе с меткой language-lawyer, где у рассматриваемого языка есть свое собственное "очень специальное значение" для неопределенного , вы будете только приводить в замешательство, не используя определение языка.
TripeHound
-1

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

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

Мне довелось спроектировать и внедрить несколько критически важных встроенных систем реального времени еще в дни K & R (включая контроллер электромобиля, который мог отправить людей врезаться в ближайшую стену, если двигатель не контролировался, 10-тонный промышленный робота, который мог бы раздавить людей до полусмерти, если им не командовали должным образом, и системного уровня, который, хотя и был бы безвреден, имел бы несколько десятков процессоров, высасывающих их шину данных без нагрузки с нагрузкой системы менее 1%).

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

Вся эта куча отвратительных абстракций барьера памяти происходит просто из-за временного набора ограничений многопроцессорных систем кэширования, которые можно безопасно инкапсулировать в общие объекты синхронизации ОС, такие как, например, мьютексы и переменные состояния C ++. предложения.
Стоимость такой инкапсуляции всего лишь незначительное снижение производительности по сравнению с тем, чего в некоторых случаях может достичь использование мелкозернистых специфических инструкций процессора. Ключевое слово (или
volatile#pragma dont-mess-with-that-variableдля меня, как системного программиста, было бы достаточно заботы, чтобы сказать компилятору прекратить переупорядочивать доступ к памяти. Оптимальный код может быть легко получен с помощью прямых asm-директив для посыпания низкоуровневого драйвера и кода ОС специальными инструкциями для процессора. Без глубоких знаний о том, как работает базовое оборудование (кеш-система или интерфейс шины), вы все равно будете писать бесполезный, неэффективный или неисправный код.

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

Только на этот раз изменение потребовало также ослабить фундаментальный аспект C, поскольку эти «барьеры» должны были генерироваться даже в низкоуровневом коде C, чтобы работать должным образом. Это, помимо всего прочего, нанесло ущерб определению выражений без каких-либо объяснений или оправданий.

В заключение следует отметить, что тот факт, что компилятор может генерировать согласованный машинный код из этого абсурдного фрагмента C, является лишь отдаленным следствием того, как ребята из C ++ справлялись с потенциальными несоответствиями систем кэширования в конце 2000-х годов.
Это привело к ужасной путанице в одном фундаментальном аспекте C (определение выражений), так что подавляющее большинство программистов на C - которым наплевать на системы кэширования, и это правильно - теперь вынуждены полагаться на гуру для объяснения Разница между a = b() + c()и a = b + c.

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

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

По моему, возможно, обоснованному мнению, C уже достаточно опасен в своем состоянии K & R. Полезной эволюцией будет добавление мер безопасности на основе здравого смысла. Например, используя этот усовершенствованный инструмент анализа кода, спецификации заставляют компилятор, по крайней мере, генерировать предупреждения о беккерах, вместо того, чтобы молча генерировать код, потенциально ненадежный до крайности.
Но вместо этого ребята решили, например, определить фиксированный порядок оценки в C ++ 17. Теперь каждый программный дурак активно настроен на то, чтобы целенаправленно добавлять побочные эффекты в свой код, греясь в уверенности, что новые компиляторы будут охотно обрабатывать обфускацию детерминистическим способом.

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

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

курой нэко
источник
Это неопределенное поведение, если в одном и том же выражении есть побочные эффекты для одного и того же объекта, C17 6.5 / 2. Они не упорядочены в соответствии с C17 6.5.18 / 3. Но текст из 6.5 / 2 «Если побочный эффект на скалярный объект не секвенирован относительно другого побочного эффекта на тот же скалярный объект или вычисления значения с использованием значения того же скалярного объекта, поведение не определено». не применяется, так как вычисление значения внутри функции упорядочено до или после доступа к индексу массива, независимо от того, имеет ли оператор присваивания непоследовательные операнды.
Лундин
Вызов функции действует как «мьютекс против неупорядоченного доступа», если хотите. Похоже на неясную запятую, оператор дерьмо вроде 0,expr,0.
Лундин
Я думаю, что вы поверили авторам стандарта, когда они сказали: «Неопределенное поведение дает разработчику лицензию не отлавливать определенные программные ошибки, которые трудно диагностировать. Он также определяет области возможного соответствующего расширения языка: разработчик может расширять язык, предоставляя определение официально неопределенного поведения . " и сказал, что Стандарт не должен унижать полезные программы, которые строго не соответствуют. Я думаю, что большинство авторов Стандарта считали бы очевидным, что люди, которые хотят писать качественные компиляторы ...
суперкат
... следует стремиться использовать UB как возможность сделать свои компиляторы максимально полезными для своих клиентов. Я сомневаюсь, что кто-либо мог предположить, что авторы компилятора будут использовать его в качестве предлога для ответа на жалобы: «Ваш компилятор обрабатывает этот код с меньшей пользой, чем все остальные» с «Это потому, что стандарт не требует от нас его полезной обработки и реализации что полезно обрабатывать программы, поведение которых не предписано Стандартом, просто способствует написанию неработающих программ ».
суперкат
Я не вижу смысла в вашем замечании. Опора на поведение, специфичное для компилятора, является гарантией непереносимости. Это также требует большой веры в производителя компилятора, который может прекратить любое из этих «дополнительных определений» в любой момент. Единственное, что может сделать компилятор, - это генерировать предупреждения, которые мудрый и знающий программист может решить обрабатывать как ошибки. Проблема, которую я вижу с этим монстром ISO, заключается в том, что он делает такой зловещий код, как пример OP, законным (по крайне неясным причинам по сравнению с определением выражения K & R).
Куроя Неко