Законно ли использовать оператор приращения в вызове функции C ++? - PullRequest
42 голосов
/ 28 февраля 2009

В этом вопросе велись споры о том, допустим ли следующий код 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 ]

57 голосов
/ 28 февраля 2009

Стандарт C ++ 1.9.16:

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

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

foo(i++);

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

foo(i++, i++);

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

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

13 голосов
/ 28 февраля 2009

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

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

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

5 голосов
/ 28 февраля 2009

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

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

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

3 голосов
/ 28 февраля 2009

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

2 голосов
/ 01 марта 2009

++ Kristo!

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

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


Однако я отчетливо помню использование более старых (до 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);
}


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

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


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

Дано:

baz(g(),h());

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

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

Дано:

bar(i++,i++);

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


Дано:

foo(i++);

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

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

Хотя я думаю, что в разделе 5.2.6 это сказано лучше:

Значением выражения postfix ++ является значение его операнда. [Примечание: полученное значение является копией исходного значения - примечание конца] Операндом должно быть изменяемое значение l. Тип операнда должен быть арифметическим типом или указателем на полный эффективный тип объекта. Значение объекта операнда изменяется, добавляя 1 к нему, если объект не имеет тип bool, в этом случае это установлено в true. [Примечание: это использование устарело, см. Приложение D. - примечание к концу]. Вычисление значения выражения ++ выполняется до модификации объекта операнда. Что касается вызова функции с неопределенной последовательностью, операция postfix ++ представляет собой единственную оценку. [Примечание: Следовательно, вызов функции не должен вмешиваться между преобразованием lvalue-to-rvalue и побочным эффектом, связанным с любым отдельным оператором postfix ++. - примечание конца] Результат - значение. Тип результата - 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);

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

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


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

i = 7, i ++, 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.

2 голосов
/ 28 февраля 2009

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

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

( Редактировать: Оу, комментарий снова пропал ... О, хорошо)

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

Вам не нужен параллелизм потоков, чтобы это произошло, просто оцените первый шаг обоих (примите значение 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 инструкции за цикл, и хороший компилятор должен попытаться использовать это.

0 голосов
/ 05 марта 2009

Саттер Гуру недели # 55 (и соответствующая часть в «Больше исключительных C ++») обсуждает этот конкретный случай в качестве примера.

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

items.erase(i);
i++;

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

0 голосов
/ 28 февраля 2009

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

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

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

foo(1, 1);

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

- MarkusQ

...