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

В обсуждался вопрос о том, является ли следующий код законным С++:

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, как это, технически undefined, даже если оно работает с конкретным компилятором. Одна из сторон дискуссии гласит, что все аргументы функции полностью оцениваются до вызова функции. Другая сторона говорит: "Единственными гарантиями являются то, что я ++ будет происходить до следующего оператора и после использования я ++. Идет ли это до стирания (i ++) или впоследствии зависит от компилятора".

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

Ответы

Ответ 1

Введите С++ standard 1.9.16:

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

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

foo(i++);

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

foo(i++, i++);

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

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

Ответ 2

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

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

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

Ответ 3

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

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++);

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

Ответ 4

Все отлично. Прошедшее значение будет значением "i" перед приращением.

Ответ 5

++ Kristo!

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

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


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

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);
}


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

... И, опираясь на ответы EVERYONE на самом деле... (Спасибо, ребята!)


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

Дано:

baz(g(),h());

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

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

Дано:

bar(i++,i++);

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


Дано:

foo(i++);

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

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

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

Значение выражения postfix ++ является значением его операнда. [Примечание: полученное значение является копией исходного примечания - конец). Операнд должен быть модифицируемым значением lvalue. Тип операнда должен быть арифметическим типом или указателем на полный эффективный тип объекта. Значение объекта операнда модифицируется добавлением 1 к нему, если только объект не имеет тип bool, и в этом случае он установлен в true. [Примечание: это использование устарело, см. Приложение D. - примечание). Вычисление значения выражения ++ секвенируется до модификации объекта операнда. Что касается вызова функции с неопределенной последовательностью, то операция postfix ++ является отдельной оценкой. [Примечание. Поэтому вызов функции не должен вмешиваться между преобразованием lvalue-to-rvalue и побочным эффектом, связанным с любым одним оператором postfix ++. - end note] Результат - значение 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 global! */
void foo(int theI) { SHOW(theI);  SHOW(i); }
int main() { foo(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" , я ++) и т.д. для "C" и "D".

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


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

j_random_hacker пишет:+1, но я должен был внимательно прочитать стандарт, чтобы убедиться, что все в порядке. Правильно ли я полагаю, что если bar() вместо этого является глобальной функцией, возвращающей say 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);

Теперь у нас есть только выражение ONE. Согласно стандарту (раздел 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); или = (a + (b + 32765)); поскольку значения для a и b могли быть соответственно 4 и -8 или -17 и 12. Однако на машине, в которой переполнение не создает исключение и в котором результаты переполнения обратимы, приведенные выше выражение выражения может быть переписано реализацией любым из вышеперечисленных способов, поскольку произойдет тот же результат. - end note]

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

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

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

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

Что красиво определяет порядок оценки бара().

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

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

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


Но рассмотрим еще одну проблему:

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

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

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

Действительно, R равно нулю. я + = 1 не происходит до тех пор, пока выражение не будет оценено.


Вот почему я считаю:

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

Является законным... Он имеет три выражения:

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

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


PS: Рассмотрим:

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

В этом случае я + = 1 необходимо оценить до foo (j). я равно 1. И R равно 0 ^ 0 ^ 1 = 1.

Ответ 6

Чтобы построить ответ MarkusQ:;)

Или, скорее, Билл прокомментировал это:

( Изменить: Aw, комментарий снова ушел... О, хорошо)

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

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

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

  • Возьмем значение я для правильного аргумента
  • Приращение я в правильном аргументе
  • Возьмем значение я для левого аргумента
  • Приращение я в левом аргументе

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

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

Ответ 7

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

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

также может вызвать вызов функции

foo(1, 1);

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

- MarkusQ

Ответ 8

Sutter Гуру недели № 55 (и соответствующий фрагмент в разделе "Более исключительный С++" ) обсуждает этот конкретный случай в качестве примера.

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

items.erase(i);
i++;

делает not код, который семантически эквивалентен исходному выражению.