Законно ли использовать оператор инкремента в вызове функции C++?

По этому вопросу ведутся споры о том, является ли следующий код допустимым для C++:

std::list<item*>::iterator i = items.begin();
while (i != items.end())
{
    bool isActive = (*i)->update();
    if (!isActive)
    {
        items.erase(i++);  // *** Is this undefined behavior? ***
    }
    else
    {
        other_code_involving(*i);
        ++i;
    }
}

Проблема здесь в том, что erase() рассматриваемый итератор станет недействительным. Если это происходит до того, i++ как оценивается, то i такое приращение является технически неопределенным поведением, даже если кажется, что оно работает с конкретным компилятором. Одна сторона дискуссии утверждает, что все аргументы функции полностью оцениваются перед вызовом функции. Другая сторона говорит: «Единственная гарантия состоит в том, что i ++ произойдет до следующего оператора и после использования i ++. Будет ли это до вызова erase (i ++) или после него, зависит от компилятора».

Я открыл этот вопрос, чтобы, надеюсь, разрешить эту дискуссию.

Ответов (8)

Решение

Каркнул C++ стандарт 1.9.16:

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

Мне кажется, что этот код:

foo(i++);

совершенно законно. Он будет увеличиваться, i а затем вызывать foo предыдущее значение i . Однако этот код:

foo(i++, i++);

приводит к неопределенному поведению, потому что в параграфе 1.9.16 также говорится:

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

Чтобы основываться на ответе Билла Ящера:

int i = 1;
foo(i++, i++);

также может привести к вызову функции

foo(1, 1);

(это означает, что фактические данные оцениваются параллельно, а затем применяются постопсии).

- MarkusQ

Это нормально. Переданное значение будет значением «i» перед приращением.

++ Кристо!

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

Приятно видеть, что стандарты улучшаются!


Однако я отчетливо помню, как использовал старые компиляторы C (до ANSI), в которых:

foo -> bar(i++) -> charlie(i++);

Не делал то, что думаешь! Вместо этого он скомпилировал эквивалент:

foo -> bar(i) -> charlie(i); ++i; ++i;

И это поведение зависело от реализации компилятора. (Делает портирование забавным.)


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

#define SHOW(S,X)  cout << S << ":  " # X " = " << (X) << endl

struct Foo
{
  Foo & bar(const char * theString, int theI)
    { SHOW(theString, theI);   return *this; }
};

int
main()
{
  Foo f;
  int i = 0;
  f . bar("A",i) . bar("B",i++) . bar("C",i) . bar("D",i);
  SHOW("END ",i);
}


Ответ на комментарий в ветке ...

... И опираясь в значительной степени на ответы КАЖДОГО ... (Спасибо, ребята!)


Я думаю, нам нужно это пояснить немного лучше:

Данный:

baz(g(),h());

Тогда мы не знаем, будет ли g () вызываться до или после h () . Это «не указано .

Но мы знаем, что и g (), и h () будут вызываться перед baz () .

Данный:

bar(i++,i++);

Опять же, мы не знаем, какой i ++ будет оцениваться первым, и, возможно, даже не знаем, будет ли i увеличиваться один или два раза перед вызовом bar () . Результаты не определены! (Учитывая , я = 0 , то это может быть бар (0,0) или бар (1,0) или бар (0,1) или что - то действительно странно!)


Данный:

foo(i++);

Теперь мы знаем, что i будет увеличиваться до вызова foo () . Как указал Кристо в стандартном разделе 1.9.16 C++:

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

Хотя я думаю, что раздел 5.2.6 говорит об этом лучше:

Значение постфиксного выражения ++ - это значение его операнда. [Примечание: полученное значение является копией исходного значения - конечное примечание] Операнд должен быть изменяемым lvalue. Тип операнда должен быть арифметическим типом или указателем на полный действующий тип объекта. Значение объекта операнда изменяется путем добавления к нему 1, если объект не имеет тип bool, и в этом случае он имеет значение true. [Примечание: такое использование не рекомендуется, см. Приложение D. - последнее примечание] Вычисление значения выражения ++ выполняется в последовательности перед изменением объекта операнда.Что касается вызова функции с неопределенной последовательностью, операция postfix ++ является однократной оценкой. [Примечание: Следовательно, вызов функции не должен вмешиваться между преобразованием lvalue-to-rvalue и побочным эффектом, связанным с любым отдельным оператором postfix ++. - конец примечания] Результат - rзначение. Тип результата - это cv-неквалифицированная версия типа операнда. См. Также 5.7 и 5.17.

Стандарт в разделе 1.9.16 также перечисляет (как часть своих примеров):

i = 7, i++, i++;    // i becomes 9 (valid)
f(i = -1, i = -1);  // the behavior is undefined

И мы можем тривиально продемонстрировать это с помощью:

#define SHOW(X)  cout << # X " = " << (X) << endl
int i = 0;  /* Yes, it's global! */
void foo(int theI) { SHOW(theI);  SHOW(i); }
int main() { foo(i++); }

Итак, да, i увеличивается до вызова foo () .


Все это имеет смысл с точки зрения:

class Foo
{
public:
  Foo operator++(int) {...}  /* Postfix variant */
}

int main() {  Foo f;  delta( f++ ); }

Здесь Foo :: operator ++ (int) должен вызываться до delta () . И операция приращения должна быть завершена во время этого вызова.


В моем (возможно, слишком сложном) примере:

f . bar("A",i) . bar("B",i++) . bar("C",i) . bar("D",i);

f.bar («A», i) должен быть выполнен для получения объекта, используемого для object.bar («B», i ++) , и так далее для «C и «D .

Итак, мы знаем, что i ++ увеличивает i до вызова bar («B», i ++) (даже если bar («B», ...) вызывается со старым значением i ), и поэтому i увеличивается до bar ( «C», i) и бар («D», i) .


Возвращаясь к комментарию j_random_hacker :

j_random_hacker пишет:
+1, но мне пришлось внимательно прочитать стандарт, чтобы убедиться, что это нормально. Прав ли я, думая, что если bar () вместо этого была глобальной функцией, возвращающей, скажем, int, f был int, и эти вызовы были связаны, скажем, «^» вместо «.», То любой из A, C и D мог сообщить "0"?

Этот вопрос намного сложнее, чем вы думаете ...

Переписывая свой вопрос как код ...

int bar(const char * theString, int theI) { SHOW(...);  return i; }

bar("A",i)   ^   bar("B",i++)   ^   bar("C",i)   ^   bar("D",i);

Теперь у нас есть только ОДНО выражение. Согласно стандарту (раздел 1.9, стр. 8, pdf стр. 20):

Примечание: операторы могут быть перегруппированы по обычным математическим правилам только тогда, когда операторы действительно ассоциативны или коммутативны. (7) Например, в следующем фрагменте: a = a + 32760 + b + 5; оператор выражения ведет себя точно так же, как: a = (((a + 32760) + b) +5); из-за ассоциативности и приоритета этих операторов. Таким образом, результат суммы (a + 32760) затем добавляется к b, и этот результат затем добавляется к 5, что приводит к значению, присвоенному a. На машине, в которой переполнение вызывает исключение и в которой диапазон значений, представляемых int, равен [-32768, + 32767], реализация не может переписать это выражение как a = ((a + b) +32765); поскольку, если бы значения для a и b были соответственно -32754 и -15, сумма a + b создала бы исключение, а исходное выражение - нет; также нельзя переписать выражение как a = ((a + 32765) + b); или а = (а + (b + 32765)); поскольку значения a и b могли быть соответственно 4 и -8 или -17 и 12.Однако на машине, на которой переполнение не вызывает исключения и в котором результаты переполнения обратимы, приведенный выше оператор выражения может быть переписан реализацией любым из вышеперечисленных способов, поскольку будет получен тот же результат. - конец примечания]

Таким образом, мы можем подумать, что из-за приоритета наше выражение будет таким же, как:

(
       (
              ( bar("A",i) ^ bar("B",i++)
              )
          ^  bar("C",i)
       )
    ^ bar("D",i)
);

Но, поскольку (a ^ b) ^ c == a ^ (b ^ c) без каких-либо возможных ситуаций переполнения, его можно было переписать в любом порядке ...

Но поскольку вызывается bar (), и гипотетически он может включать побочные эффекты, это выражение нельзя переписать в любом порядке. Правила старшинства по-прежнему применяются.

Что хорошо определяет порядок оценки bar () .

Теперь, когда происходит это i + = 1 ? Ну, это все еще должно произойти до вызова bar ("B", ...) . (Даже если bar ("B", ....) вызывается со старым значением.)

Таким образом, это детерминированно происходит до бара (C) и бара (D) , и после бара (A) .

Ответ: НЕТ . У нас всегда будет «A = 0, B = 0, C = 1, D = 1», если компилятор соответствует стандартам.


Но рассмотрим другую проблему:

i = 0;
int & j = i;
R = i ^ i++ ^ j;

Какое значение R?

Если бы i + = 1 произошло до j , у нас было бы 0 ^ 0 ^ 1 = 1. Но если бы i + = 1 произошло после всего выражения, у нас было бы 0 ^ 0 ^ 0 = 0.

Фактически, R равно нулю. I + 1 = не происходит до тех пор , после того, как выражение было оценено.


Я считаю, почему:

я = 7, я ++, я ++; // i становится 9 (действительно)

Допустимо ... Имеет три выражения:

  • я = 7
  • я ++
  • я ++

И в каждом случае значение i изменяется в конце каждого выражения. (Перед оценкой любых последующих выражений.)


PS: Учтите:

int foo(int theI) { SHOW(theI);  SHOW(i);  return theI; }
i = 0;
int & j = i;
R = i ^ i++ ^ foo(j);

В этом случае i + = 1 должно быть вычислено перед foo (j) . theI равно 1. И R равно 0 ^ 0 ^ 1 = 1.

Чтобы основываться на ответе Кристо ,

foo(i++, i++);

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

int i = 1;
foo(i++, i++);

может привести к вызову функции

foo(2, 1);

или

foo(1, 2);

или даже

foo(1, 1);

Выполните следующее, чтобы увидеть, что происходит на вашей платформе:

#include <iostream>

using namespace std;

void foo(int a, int b)
{
    cout << "a: " << a << endl;
    cout << "b: " << b << endl;
}

int main()
{
    int i = 1;
    foo(i++, i++);
}

На моей машине я получаю

$ ./a.out
a: 2
b: 1

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

Чтобы основываться на ответе MarkusQ:;)

Вернее, комментарий Билла к нему:

( Edit: Ой, комментарий снова пропал ... Ну ладно)

Они позволили быть оценены параллельно. С технической точки зрения не имеет значения, происходит это на практике или нет.

Для этого вам не нужен параллелизм потоков, просто оцените первый шаг из обоих (примите значение i) перед вторым (приращение i). Совершенно законно, и некоторые компиляторы могут посчитать это более эффективным, чем полная оценка одного i ++ перед запуском второго.

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

  1. Возьмите значение i в качестве правильного аргумента
  2. Приращение i в правильном аргументе
  3. Возьмите значение i в качестве левого аргумента
  4. Приращение i в левом аргументе

Но на самом деле нет никакой зависимости между левым и правым аргументом. Оценка аргументов происходит в неопределенном порядке, и ее также не нужно выполнять последовательно (поэтому new () в аргументах функции обычно является утечкой памяти, даже если она заключена в интеллектуальный указатель) Также не определено, что происходит, когда вы изменяете ту же переменную. дважды в одном и том же выражении. Однако у нас есть зависимость между 1 и 2, а также между 3 и 4. Так почему же компилятор должен ждать завершения 2 перед вычислением 3? Это приводит к дополнительной задержке, и потребуется даже больше времени, чем необходимо, прежде чем 4 станет доступным. Предполагая, что между ними есть задержка в 1 цикл, потребуется 3 цикла от завершения 1 до тех пор, пока не будет готов результат 4, и мы сможем вызвать функцию.

Но если мы переупорядочим их и оценим в порядке 1, 3, 2, 4, мы сможем сделать это за 2 цикла. 1 и 3 могут быть запущены в одном цикле (или даже объединены в одну инструкцию, поскольку это одно и то же выражение), а в дальнейшем могут быть вычислены 2 и 4. Все современные процессоры могут выполнять 3-4 инструкции за цикл, и хороший компилятор должен попытаться использовать это.

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

std::list<item*>::iterator i_before = i;

i = i_before + 1;

items.erase(i_before);

а не быть:

std::list<item*>::iterator i_before = i;

items.erase(i);

i = i_before + 1;

Так что в этом случае это безопасно, потому что list.erase () специально не отменяет никаких итераторов, кроме удаленного.

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

i = items.erase(i);

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

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

(void)items.erase(i++);

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

Guru of the Week # 55 Саттера (и соответствующая статья в "More Exceptional C++") обсуждает именно этот случай в качестве примера.

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

items.erase (i);
i ++;

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