Порядок оцінки індексів масиву (проти виразу) у С


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 частина, яка вказує на пріоритет роботи в даному конкретному випадку?


21
Це пахне невизначеною поведінкою. Це, звичайно, щось, що ніколи не слід навмисно кодувати.
Свердління бітів

1
Я згоден, це приклад поганого кодування.
Jiminion

4
Деякі анекдотичні результати: godbolt.org/z/hM2Jo2
Bob__

15
Це не має нічого спільного з індексами масиву або порядком операцій. Це має відношення до того, що специфікація C називає "точками послідовності", і, зокрема, тим, що вирази присвоєння НЕ створюють точку послідовності між виразом ліворуч і праворуч, тому компілятор вільний робити так, як це вибирає.
Лі Даніел Крокер

4
Ви повинні повідомити про запит на функцію, щоб clangцей фрагмент коду викликав попередження IMHO.
malat

Відповіді:


51

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

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

Оцінки операндів не є наслідком.

Це означає, що реалізація C вільна спочатку обчислити значення arr[global_var] (обчисливши значення lvalue, ми маємо на увазі з'ясувати, на що позначається цей вираз), потім оцінити update_three(2)і, нарешті, присвоїти значення останнього першому; або оцінити update_three(2)спочатку, потім обчислити значення, потім призначити перше; або оцінити значення lvalue і update_three(2)деяким змішаним способом, а потім призначити правильне значення лівому значення.

У всіх випадках присвоєння значення 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.


"Виклик функції вводить точку послідовності" недостатньо. Якщо лівий операнд оцінюється спочатку, його достатньо, оскільки тоді точка послідовності відокремлює лівий операнд від оцінок у функції. Але, якщо лівий операнд оцінюється після виклику функції, то точка послідовності, викликана викликом функції, знаходиться не між оцінками функції та оцінкою лівого операнда. Вам також потрібна точка послідовності, яка розділяє повні вирази.
Eric Postpischil

2
@EricPostpischil У термінології до C11 є точка послідовності входу та виходу функції. У термінології C11 все тіло функції нескінченно секвенується щодо контексту виклику. Вони обидва вказують те саме, просто використовуючи різні терміни
ММ

Це абсолютно неправильно. Порядок оцінки аргументів завдання не визначений. Що стосується результату цього конкретного призначення, то це створення масиву з недостовірним вмістом, як непредставним, так і внутрішньо неправильним (невідповідним семантиці або будь-якому із намічених результатів). Ідеальний випадок невизначеної поведінки.
kuroi neko

1
@kuroineko Тільки тому, що вихід може змінюватися, це автоматично не робить його невизначеною поведінкою. Стандарт має різні визначення для невизначеної та не визначеної поведінки, і в цій ситуації це остання.
dbush

@EricPostpischil Ви маєте тут пункти послідовності (з інформаційного додатка C11): "Між оцінками позначувача функції та фактичними аргументами виклику функції та фактичним викликом. (6.5.2.2)", "Між оцінкою повного виразу і наступний повний вираз, що підлягає оцінці ... / - / ... вираз (необов'язковий) вираз у зворотному виразі (6.8.6.4) ". І добре, на кожній крапці з комою теж, бо це повний вираз.
Лундін

1

Я спробував, і я оновив запис 0.

Однак відповідно до цього питання: чи буде правою частиною виразу завжди оцінюватися спочатку

Порядок оцінювання не визначений і невизначений. Тому я думаю, що такого коду слід уникати.


Я також отримав оновлення на вході 0.
Jiminion

1
Поведінка не визначена, але не визначена. Природно, залежно від будь-якого слід уникати.
Антті Хаапала

@AnttiHaapala Я редагував
Міцкель Б.

1
Хм-а-а, це не без наслідків, а невизначено секвенується ... 2 людини, що стоять випадковим чином у черзі, невизначено послідовні. Нео всередині агента Сміта - це непідвладне і не буде визначено поведінку.
Антті Хаапала

0

Оскільки мало сенсу випромінювати код для призначення, перш ніж мати значення для призначення, більшість компіляторів С спочатку видають код, який викликає функцію, і збережуть результат кудись (зареєструйте, стекайте і т. Д.), Потім вони будуть випромінювати код, який записує це значення до свого кінцевого пункту призначення, і тому вони будуть читати глобальну змінну після її зміни. Назвемо це "природним порядком", визначеним не будь-яким стандартом, а чистою логікою.

Але в процесі оптимізації компілятори намагатимуться усунути проміжний крок тимчасового зберігання значення десь і спробувати записати результат функції якомога прямо до кінцевого пункту призначення, і в такому випадку їм часто доведеться спочатку прочитати індекс , наприклад, до регістра, щоб мати можливість безпосередньо перемістити результат функції до масиву. Це може призвести до зчитування глобальної змінної до її зміни.

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

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, ви отримуєте набагато швидший код, переміщуючи біт x на 1 і додаючи x до результату - і навіть цей трюк виконується сучасними компіляторами, якщо ви помножите на 3 і включите агресивну оптимізацію, якщо це не сучасна ціль Процесор, де множення настільки ж швидко, як і додавання, з тих пір фокус уповільнить обчислення.)


2
Це не визначена поведінка - у стандарті перераховані можливості, і одна із них обрана в будь-якому випадку
Антті Хаапала,

Компілятор не перетвориться 3 * xна два читання x. Він може прочитати x один раз, а потім виконати метод x + 2 * x в регістрі, на який він читав x
MM

6
@Mecki "Якщо ви не можете сказати, що є результатом, просто подивившись на код, результат не визначений" - невизначена поведінка має дуже специфічне значення в C / C ++, і це не все. Інші відповіді пояснили, чому саме цей примірник не визначений , але не визначений .
marcelm

3
Я ціную намір кинути трохи світла на внутрішній комп'ютер, навіть якщо це виходить за рамки оригінального питання. Однак UB дуже чіткий жаргон C / C ++ і його слід обережно використовувати, особливо якщо мова йде про мовні технічні характеристики. Можна замість цього використати відповідний термін "не визначена поведінка", що значно покращить відповідь.
kuroi neko

2
@Mecki « Undefined має особливе значення в англійській мові » ... але в питанні з написом language-lawyer, де мова в питанні має своє власне «особливого значення» для невизначених , ви тільки збираєтеся викликати плутанину, не використовуючи визначення мови.
TripeHound

-1

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

Я хотів би повірити, що С був пошкоджений, але на жаль, оскільки C11 його прирівнювали до C ++. Мабуть, знаючи, що компілятор буде робити з побічними ефектами в виразах, зараз потрібно вирішити невелику загадку математики, що включає часткове впорядкування послідовностей коду на основі "розташована перед точкою синхронізації".

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

Я можу бути занадто старечим або дурним, щоб зрозуміти різницю між невизначеним та не визначеним, але я думаю, що все ще маю досить гарне уявлення про те, що означають паралельне виконання та доступ до даних. На мою, напевно, поінформовану думку, ця одержимість C ++ і тепер хлопці C зі своїми домашніми улюбленцями, що займаються питаннями синхронізації, - це дорога мрія. Або ви знаєте, що таке одночасне виконання, і вам не потрібна жодна з цих штучок, або ви цього не робите, і ви зробили б світові в цілому послугу, не намагаючись з цим возитися.

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

Хвилинне коригування volatileключового слова та Боб було б всім, окрім дядька найсміливіших програмістів низького рівня. Замість цього звичайна банда вивідок математики 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.
Лундін

Думаю, ви повірили авторам Стандарту, коли вони сказали, що "Невизначена поведінка дає виконавцю ліцензію не вловлювати певні програмні помилки, які важко діагностувати. Він також визначає області можливого відповідного розширення мови: реалізатор може доповнити мову шляхом надання визначення офіційно невизначеної поведінки ". і сказав, що Стандарт не повинен применшувати корисні програми, які не суворо відповідали. Я думаю, що більшість авторів стандарту вважають очевидним, що люди, які прагнуть написати якісні компілятори ...
supercat

... слід прагнути використовувати UB як можливість зробити їх компілятори максимально корисними для своїх клієнтів. Я сумніваюся, хтось міг уявити, що автори-компілятори використовуватимуть це як привід для відповіді на скарги "Ваш компілятор обробляє цей код менш корисно, ніж усі інші". Це тому, що Стандарт не вимагає, щоб ми його обробляли корисно та реалізаціями які корисно обробляють програми, поведінка яких не передбачено Стандартом, просто сприяють написанню порушених програм ".
supercat

Я не бачу сенсу у вашому зауваженні. Покладаючись на поведінку компілятора, це гарантія непереносимості. Це також вимагає великої віри в виробника компілятора, який може в будь-який момент припинити будь-яке з цих "зайвих визначень". Єдине, що може зробити компілятор, - це генерувати попередження, які мудрий і знаючий програміст може вирішити, як помилки. Проблема, яку я бачу з цим монстром ISO, полягає в тому, що він робить такий жорстокий код, як приклад ОП (з надзвичайно незрозумілих причин, порівняно з визначенням виразу K&R).
kuroi neko
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.