Почему f (i = -1, i = -1) неопределенное поведение?

267

Я читал о порядке нарушений оценки , и они приводят пример, который озадачивает меня.

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

// snip
f(i = -1, i = -1); // undefined behavior

В этом контексте iэто скалярный объект , который, по-видимому, означает

Арифметические типы (3.9.1), типы перечисления, типы указателей, указатели на типы элементов (3.9.2), std :: nullptr_t и cv-квалифицированные версии этих типов (3.9.3) вместе называются скалярными типами.

Я не вижу, как это утверждение неоднозначно в этом случае. Мне кажется, что независимо от того, оценивается ли первый или второй аргумент первым, он iзаканчивается как -1, и оба аргумента также -1.

Может кто-нибудь уточнить, пожалуйста?


ОБНОВИТЬ

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


РЕЗЮМЕ

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

по какой причине

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

Это неопределенное поведение, потому что не определено, что это за поведение.

Ответ в целом очень хороший с точки зрения объяснения того, что говорит стандарт C ++. Также рассматриваются некоторые связанные случаи UB, такие как f(++i, ++i);и f(i=1, i=-1);. В первом из связанных случаев не ясно, должен ли первый аргумент быть, i+1а второй i+2или наоборот; во-вторых, не ясно, iдолжно ли быть 1 или -1 после вызова функции. Оба эти случая являются UB, потому что они подпадают под следующее правило:

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

Следовательно, f(i=-1, i=-1)это также UB, поскольку он подпадает под то же правило, несмотря на то, что намерение программиста является (IMHO) очевидным и однозначным.

Пол Дрэйпер также ясно указывает в своем заключении, что

Могло ли это быть определено поведение? Да. Это было определено? Нет.

что приводит нас к вопросу «по какой причине / цели было f(i=-1, i=-1)оставлено неопределенное поведение?»

по какой причине / цели

Хотя в стандарте C ++ есть некоторые упущения (возможно, небрежные), многие упущения хорошо обоснованы и служат определенной цели. Хотя я знаю, что цель часто заключается в том, чтобы «облегчить работу автора-компилятора» или «ускорить код», мне было интересно узнать, есть ли веская причина оставить f(i=-1, i=-1) UB.

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

Суперкат представляет собой связное изложение подводных камней, пытающихся добиться того, f(i=-1, i=-1)на что он похож. Он указывает, что на некоторых архитектурах существуют жесткие ограничения на одновременную запись в один и тот же адрес памяти. Компилятору может быть трудно поймать это, если мы имеем дело с чем-то менее тривиальным, чем f(i=-1, i=-1).

Дэвидф также приводит пример инструкций чередования, очень похожих на вредные.

Хотя каждый из примеров вредителей, суперкатов и дэвидфов несколько надуман, вместе взятые они все же служат обоснованной причиной, почему f(i=-1, i=-1)должно быть неопределенное поведение.

Я принял ответ вредителя, потому что он наилучшим образом справился со всеми смыслами того, почему, хотя в ответе Пола Дрейпера был лучше проработан раздел «по какой причине».

другие ответы

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

Нику Стирка
источник
1
Скалярный объект - это объект скалярного типа. См. 3.9 / 9: «Арифметические типы (3.9.1), типы перечисления, типы указателей, указатели на типы элементов (3.9.2) std::nullptr_tи cv-квалифицированные версии этих типов (3.9.3) вместе называются скалярными типами . "
Роб Кеннеди
1
Может быть, на странице есть ошибка, и они на самом деле имели в виду f(i-1, i = -1)или что-то подобное.
Мистер Листер
Посмотрите на этот вопрос: stackoverflow.com/a/4177063/71074
Роберт С. Барнс
@RobKennedy Спасибо. Включает ли "арифметические типы" bool?
Нику Стирка
1
SchighSchagh ваше обновление должно быть в разделе ответов.
Грижеш Чаухан

Ответы:

343

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

Если A не секвенируется до B, а B не секвенируется до A, то существуют две возможности:

  • оценки A и B не являются последовательными: они могут выполняться в любом порядке и могут перекрываться (в пределах одного потока выполнения компилятор может чередовать инструкции CPU, которые содержат A и B)

  • оценки A и B имеют неопределенную последовательность: они могут выполняться в любом порядке, но не могут пересекаться: либо A будет завершен до B, либо B будет завершен до A. Порядок может быть противоположным в следующий раз, когда то же выражение оценивается.

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

Например, представьте, что было бы более эффективно обнулить память, затем уменьшить ее по сравнению с загрузкой значения -1 в. Тогда это:

f(i=-1, i=-1)

может стать:

clear i
clear i
decr i
decr i

Теперь мне -2.

Это, вероятно, фиктивный пример, но это возможно.

harmic
источник
59
Очень хороший пример того, как выражение может на самом деле сделать что-то неожиданное, в соответствии с правилами последовательности. Да, немного надумано, но код, который я обрезал, я спрашиваю в первую очередь. :)
Нику Стирка
10
И даже если назначение выполняется как элементарная операция, можно представить себе суперскалярную архитектуру, в которой оба назначения выполняются одновременно, что вызывает конфликт доступа к памяти, который приводит к сбою. Язык разработан таким образом, чтобы создатели компиляторов имели как можно больше свободы в использовании преимуществ целевой машины.
ACH
11
Мне очень нравится ваш пример того, как даже присвоение одного и того же значения одной и той же переменной в обоих параметрах может привести к неожиданному результату, потому что два назначения не последовательны
Мартин Дж.
1
+ 1e + 6 (хорошо, +1) для точки, что скомпилированный код не всегда то, что вы ожидаете. Оптимизаторы действительно хороши в том, чтобы бросать в вас такие кривые, когда вы не следуете правилам: P
Кори
3
На процессор Arm нагрузка 32 бита может принимать до 4 инструкций: load 8bit immediate and shiftдо 4 раз. Обычно компилятор выполняет косвенную адресацию для извлечения числа из таблицы, чтобы избежать этого. (-1 можно сделать в 1 инструкции, но можно выбрать другой пример).
Ctrl-Alt-Delor
208

Во-первых, «скалярный объект» означает тип, такой как int, floatили указатель (см. Что такое скалярный объект в C ++? ).


Во-вторых, может показаться более очевидным, что

f(++i, ++i);

будет иметь неопределенное поведение. Но

f(i = -1, i = -1);

менее очевидно.

Немного другой пример:

int i;
f(i = 1, i = -1);
std::cout << i << "\n";

Какое назначение произошло «последним» i = 1, или i = -1? Это не определено в стандарте. В самом деле, это iможет быть 5(см. Ответ вредника для вполне правдоподобного объяснения того, как это может иметь место). Или ваша программа может segfault. Или переформатируйте свой жесткий диск.

Но теперь вы спрашиваете: «Как насчет моего примера? Я использовал одно и то же значение ( -1) для обоих назначений. Что может быть неясным в этом?»

Вы правы ... за исключением того, как комитет по стандартам C ++ описал это.

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

Они могли бы сделать специальное исключение для вашего особого случая, но они этого не сделали. (А почему они? Что толку было бы, что когда - нибудь , возможно , есть?) Так что , iвсе еще может быть 5. Или ваш жесткий диск может быть пустым. Таким образом, ответ на ваш вопрос:

Это неопределенное поведение, потому что не определено, что это за поведение.

(Это заслуживает особого внимания, потому что многие программисты думают, что «неопределенный» означает «случайный» или «непредсказуемый». Это не так; это означает, что не определено стандартом. Поведение может быть на 100% непротиворечивым и все же быть неопределенным.)

Могло ли это быть определено поведение? Да. Это было определено? Нет. Следовательно, это "неопределено".

Тем не менее, «undefined» не означает, что компилятор отформатирует ваш жесткий диск ... это означает, что он может и все равно будет совместимым со стандартами компилятором. На самом деле, я уверен, что g ++, Clang и MSVC будут делать то, что вы ожидали. Они просто не "должны".


Другой вопрос может быть: Почему комитет по стандартам C ++ решил сделать этот побочный эффект неубедительным? , Этот ответ будет включать в себя историю и мнения комитета. Или что хорошего в том, что этот побочный эффект не секвенируется в C ++? , которая допускает любое обоснование, было ли это фактическим обоснованием комитета по стандартам. Вы можете задать эти вопросы здесь или на сайте programmers.stackexchange.com.

Пол Дрэйпер
источник
9
@hvd, да, на самом деле я знаю, что если вы включите -Wsequence-pointg ++, он предупредит вас.
Пол Дрейпер
47
«Я уверен, что g ++, Clang и MSVC будут делать то, что вы ожидали», - я бы не стал доверять современному компилятору. Они злые. Например, они могут признать, что это неопределенное поведение, и предположить, что этот код недоступен. Если они не сделают этого сегодня, они могут сделать это завтра. Любой UB - это бомба замедленного действия.
CodesInChaos
8
@ BlacklightShining «ваш ответ плохой, потому что он не хороший», не очень полезная обратная связь, не так ли?
Винсент ван дер Уил
13
@BobJarvis Компиляторы абсолютно не обязаны генерировать даже удаленно правильный код перед лицом неопределенного поведения. TI может даже предположить, что этот код никогда не вызывается и, таким образом, заменить все это на nop (обратите внимание, что компиляторы фактически делают такие предположения перед лицом UB). Поэтому я бы сказал, что правильная реакция на такой отчет об ошибке может быть «закрыта, работает как задумано»
Grizzly
7
@SchighSchagh Иногда людям нужно перефразировать термины (которые только на первый взгляд кажутся тавтологическими ответами). Большинство людей, плохо undefined behaviorзнакомых с техническими спецификациями, считают something random will happen, что это далеко не всегда.
Изката
27

Практическая причина не делать исключения из правил только потому, что два значения одинаковы:

// config.h
#define VALUEA  1

// defaults.h
#define VALUEB  1

// prog.cpp
f(i = VALUEA, i = VALUEB);

Рассмотрим случай, когда это было разрешено.

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

 #define VALUEB 2

Кажется безобидным, не так ли? И все же внезапно prog.cpp больше не будет компилироваться. Тем не менее, мы считаем, что компиляция не должна зависеть от значения литерала.

Итог: нет никаких исключений из правила, потому что оно сделает успешную компиляцию зависимой от значения (скорее от типа) константы.

РЕДАКТИРОВАТЬ

@HeartWare указал, что константные выражения формы A DIV Bне допускаются в некоторых языках, когда Bэто 0, и приводят к сбою компиляции. Следовательно, изменение константы может привести к ошибкам компиляции в другом месте. Что, ИМХО, неудачно. Но, конечно, хорошо ограничивать такие вещи неизбежным.

Инго
источник
Конечно, но пример делает использование целого числа литералов. У вас f(i = VALUEA, i = VALUEB);определенно есть потенциал для неопределенного поведения. Я надеюсь, что вы на самом деле не кодируете значения за идентификаторы.
Вольф
3
@Wold Но компилятор не видит макросы препроцессора. И даже если бы это было не так, трудно найти пример на любом языке программирования, где исходный код компилируется до тех пор, пока кто-то не изменит некоторую постоянную int с 1 на 2. Это просто неприемлемо и необъяснимо, в то время как вы видите очень хорошие объяснения здесь почему этот код не работает даже с одинаковыми значениями.
Инго
Да, компиляция не видит макросов. Но был ли это вопрос?
Вольф
1
В вашем ответе нет смысла, прочитайте ответ вредителя и комментарий ОП.
Вольф
1
Это может сделать SomeProcedure(A, B, B DIV (2-A)). В любом случае, если в языке говорится, что CONST должен быть полностью оценен во время компиляции, то, конечно, моя заявка недействительна для этого случая. Так как это как-то размывает различие времени компиляции и времени выполнения. Будет ли это замечать, если мы напишем CONST C = X(2-A); FUNCTION X:INTEGER(CONST Y:INTEGER) = B/Y; ? Или функции не разрешены?
Инго
12

Путаница заключается в том, что сохранение постоянного значения в локальной переменной - это не одна атомарная инструкция в каждой архитектуре, для которой предназначен C. В этом случае процессор, на котором работает код, имеет большее значение, чем компилятор. Например, в ARM, где каждая инструкция не может нести полную 32-битную константу, хранение int в переменной требует более одной инструкции. Пример с этим псевдокодом, где вы можете хранить только 8 битов за раз и должны работать в 32-битном регистре, я - int32:

reg = 0xFF; // first instruction
reg |= 0xFF00; // second
reg |= 0xFF0000; // third
reg |= 0xFF000000; // fourth
i = reg; // last

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

reg = 0xFF;
reg |= 0xFF00;
reg |= 0xFF0000;
reg = 0xFF;
reg |= 0xFF000000;
i = reg; // writes 0xFF0000FF == -16776961
reg |= 0xFF00;
reg |= 0xFF0000;
reg |= 0xFF000000;
i = reg; // writes 0xFFFFFFFF == -1

Однако в моих тестах gcc достаточно любезен, чтобы признать, что одно и то же значение используется дважды и генерирует его один раз, и ничего странного не делает. Я получаю -1, -1 Но мой пример все еще действителен, так как важно учитывать, что даже константа может быть не такой очевидной, как кажется.

davidf
источник
Я предполагаю, что на ARM компилятор просто загрузит константу из таблицы. То, что вы описываете, больше похоже на MIPS.
ACH
1
@AndreyChernyakhovskiy Да, но в случае, когда это не просто -1(что компилятор где-то сохранил), а скорее 3^81 mod 2^32, но постоянно, тогда компилятор может делать именно то, что сделано здесь, и в некотором рычаге омтимизации я чередую последовательности вызовов чтобы избежать ожидания.
лет»
@tohecz, да, я уже проверил это. Действительно, компилятор слишком умен, чтобы загружать каждую константу из таблицы. В любом случае, он никогда не будет использовать один и тот же регистр для вычисления двух констант. Это так же точно «не определит» определенное поведение.
ACH
@AndreyChernyakhovskiy Но вы, вероятно, не "каждый программист компилятора C ++ в мире". Помните, что есть машины с 3 короткими регистрами, доступные только для вычислений.
лет»
@tohecz, рассмотрим пример, f(i = A, j = B)где iи jдва отдельных объекта. Этот пример не имеет UB. Машина, имеющая 3 коротких регистра, не является оправданием для компилятора смешивать два значения Aи Bв одном и том же регистре (как показано в ответе @ davidf), потому что это нарушит семантику программы.
ACH
11

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

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

Конечно, тот факт, что кто-то может реализовать компилятор C на такой платформе, не предполагает, что такое поведение не должно определяться на аппаратных платформах при использовании хранилищ типов, достаточно маленьких, чтобы обрабатываться атомарно. Попытка сохранить два разных значения в неупорядоченной форме может привести к странностям, если компилятор не знает об этом; например, учитывая:

uint8_t v;  // Global

void hey(uint8_t *p)
{
  moo(v=5, (*p)=6);
  zoo(v);
  zoo(v);
}

если компилятор вставляет вызов «moo» и может сказать, что он не изменяет «v», он может сохранить 5 в v, затем сохранить 6 в * p, затем передать 5 в «zoo», а затем передать содержимое v в "зоопарк". Если «zoo» не изменяет «v», не должно быть никакого способа, чтобы двум вызовам передавались разные значения, но это может легко произойти в любом случае. С другой стороны, в тех случаях, когда оба хранилища будут записывать одно и то же значение, такая странность не может возникнуть, и на большинстве платформ не будет разумной причины для того, чтобы реализация сделала что-то странное. К сожалению, некоторые авторы компиляторов не нуждаются в каких-либо оправданиях для глупого поведения, кроме «потому что это допускает Стандарт», поэтому даже эти случаи небезопасны.

Supercat
источник
9

Тот факт, что в этом случае результат будет одинаковым в большинстве реализаций, является случайным; порядок оценки пока не определен. Подумайте f(i = -1, i = -2): здесь порядок имеет значение. Единственная причина, по которой это не имеет значения в вашем примере - это случайность того, что оба значения -1.

Учитывая, что выражение указано как выражение с неопределенным поведением, злонамеренно совместимый компилятор может отображать неподходящее изображение при оценке f(i = -1, i = -1)и прерывании выполнения - и при этом считаться полностью правильным. К счастью, никаких компиляторов, о которых я знаю, не делают.

Amadan
источник
8

Мне кажется, что единственное правило, относящееся к последовательности выражения аргумента функции:

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

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

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

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

void f(int l, int r) {
    return l < -1;
}
auto b = f(i = -1, i = -2);
if (b) {
    formatDisk();
}
Мартин Дж.
источник
8

C ++ 17 определяет более строгие правила оценки. В частности, это последовательность аргументов функции (хотя и в неопределенном порядке).

N5659 §4.6:15
Оценки A и B являются неопределенно упорядоченными, когда либо A упорядочивается перед B, либо B упорядочивается перед A , но не указано, какие именно. [ Примечание : неопределенно последовательные оценки не могут перекрываться, но любой из них может быть выполнен первым. - конец примечания ]

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

Это позволяет некоторые случаи, которые были бы UB раньше:

f(i = -1, i = -1); // value of i is -1
f(i = -1, i = -2); // value of i is either -1 or -2, but not specified which one
AlexD
источник
2
Спасибо за добавление этого обновления для C ++ 17 , поэтому мне не пришлось. ;)
Якк - Адам Невраумонт
Круто, большое спасибо за этот ответ. Незначительное продолжение: если fподпись была f(int a, int b), гарантирует ли C ++ 17 это a == -1и b == -2если вызывается, как во втором случае?
Нику Стирка
Да. Если у нас есть параметры aи b, то либоi -then- aинициализируется -1, после чего i-then- bинициализируется до -2, или наоборот. В обоих случаях мы заканчиваем с a == -1и b == -2. По крайней мере, так я прочитал: « Инициализация параметра, включая каждое связанное с ним вычисление значения и побочный эффект, определяется неопределенным образом относительно последовательности любого другого параметра ».
AlexD
Я думаю, что это было то же самое в Си с незапамятных времен.
fuz
5

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

struct A {
    bool first;
    A () : first (false) {
    }
    const A & operator = (int i) {
        first = !first;
        return * this;
    }
};

void f (A a1, A a2) {
    // ...
}


// ...
A i;
f (i = -1, i = -1);   // the argument evaluated first has ax.first == true
Johnb
источник
1
Верно, но вопрос был о скалярных типах , которые другие указывали, по существу, на семейство, семейство с плавающей точкой и указатели.
Нику Стирка
Реальная проблема в этом случае заключается в том, что оператор присваивания является состоящим, поэтому даже регулярные манипуляции с переменной подвержены таким проблемам.
AJMansfield
2

Это просто ответ на вопрос «Я не уверен, что может означать« скалярный объект »помимо чего-то вроде int или float».

Я бы интерпретировал «скалярный объект» как аббревиатуру «объекта скалярного типа» или просто «переменная скалярного типа». Затем pointer, enum(константа) имеют скалярного типа.

Это статья о скалярных типах в MSDN .

Пэн Чжан
источник
Это выглядит как «ссылка только ответ». Можете ли вы скопировать соответствующие биты из этой ссылки в этот ответ (в цитате)?
Коул Джонсон
1
@ColeJohnson Это не только ссылка, ответ. Ссылка только для дальнейшего объяснения. Мой ответ "указатель", "перечисление".
Пэн Чжан
Я не сказал, что ваш ответ был только ссылкой. Я сказал, что "читается как [один]" . Я предлагаю вам прочитать, почему мы не хотим ссылаться только на ответы в разделе справки. Причина в том, что если Microsoft обновляет свои URL-адреса на своем сайте, эта ссылка разрывается.
Коул Джонсон
1

На самом деле, есть причина не зависеть от того факта, что компилятор проверит, что iему присвоено одно и то же значение дважды, так что его можно заменить одним присваиванием. Что если у нас есть выражения?

void g(int a, int b, int c, int n) {
    int i;
    // hey, compiler has to prove Fermat's theorem now!
    f(i = 1, i = (ipow(a, n) + ipow(b, n) == ipow(c, n)));
}
polkovnikov.ph
источник
1
Не нужно , чтобы доказать теорему Ферма: просто назначить 1на i. Либо оба аргумента назначают, 1и это делает «правильную» вещь, либо аргументы назначают разные значения, и это неопределенное поведение, поэтому наш выбор все еще разрешен.