вторник, Ноябрь 15, 2005

Точки следования (sequence points)

Их не рассматривают на занятиях, о них редко пишут в книгах. Тем не менее любой, кто пишет на C++, с ними сталкивается постоянно в ходе своей работе. И если знать о них чуть больше, многие из вещей, казавшихся раньше таинственными, становятся очевидными.

О таинственном. Я пишу программу
int x=0;
x=x++;
cout<<x;

Вопрос: что выведется на экран? Ну что может быть проще - сейчас запустим да проверим. Я запустила MSVC++6.0, ответ получился 1.
Потом запустила gcc 3.4.4 и получился 0.
Если бы запустила что-то еще, то могло получиться нечто третье...
Кто из них прав? На самом деле все. А вот почему так в двух словах и не скажешь...

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

Точки следования (sequence points) - это некие точки в программе, где состояние реальной программы полностью соответствует состоянию абстрактной машины, описанной в Стандарте. С помощью точек следования стандарт объясняет, что может, а чего не может делать компилятор и что нам нужно сделать, чтобы написать корректный код.

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

Если код выглядит так:
int x;
x = 3;
cout << x << endl;
x = 4;

То нет никаких сомнений, что на печать будет выведено 3. Точка с запятой в конце выражения "x = 3;" как и любая точка с запятой в конце любого выражения - это точка следования. Перед тем как любой код после этой точки с запятой выполнится, побочный эффект от сохранения значения 3 в int объект x должен быть полностью завершен. И мы точно знаем, что вывод не будет равен 4, потому что вывод полностью завершен к тому моменту, как выполнение программы достигнет точки с запятой в конце выражения "cout << x << endl;". Побочный эффект от сохранения значения 4 в x в следующем выражении еще не случился.

Где находятся точки следования


  1. В конце каждого полного выражения(Глава Стандарта 1.9/16). Обычно они помечены точкой с запятой ;

  2. В точке вызова функции (1.9/17). Но после вычисления всех аргументов. Это и для inline функций в том числе.

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

  4. (1.9/18) После первого выражения (здесь оно называется 'a') в следующих конструкциях:

    a || b

    a && b

    a , b

    a ? b : c

    Вычисление здесь идет слева направо. То есть левое выражение (по имени 'a') вычисляется и все побочные эффекты от такого вычисления завершаются. Потом, если все значение всего выражения известно, правое выражение не вычисляется, иначе вычисляется.
    Правило слево-направо не работает для переопределенных операторов. В этом случае переопределенный оператор ведет себя как обычная функция. Еще такой момент - ?: не может быть переопределен по стандарту.

    (Упомянутая запятая это оператор запятая. Она не имеет никакого отношения к запятой, разделяющей аргументы функции.)


Если рассмотреть пример "x = x++;", то здесь имеет место попытка изменить объект дважды.
Если программа пытается модифицировать одну переменную дважды не пересекая точку следования, то это ведет к undefined behavior. Так говорит Стандарт. И тут же в Стандарте приводятся примеры.

1) i = v[i++]; //unspecified behavior
2) i = 7, i++, i++; //i равно 9

3) i = ++i + 1; //unspecified behavior
4) i = i + 1; //значение i увеличено на 1

Что-то с чем-то не сходится. В первом и третьем примерах указано "unspecified behavior", хотя переменная изменяется дважды и только что было сказано, что это ведет к undefined behavior.
И точно в списке багов Стандарта это место упомянуто. В предложении по устранению бага советуют поменять слово unspecified на слово undefined.
Но на самом деле не так уж и важно, unspecified или undefined behavior. Важно то, что непонятно, что именно получится в итоге. Поэтому не надо модифицировать переменную больше одного раза между двумя точками следования, поскольку это может привести к весьма неожиданным последствиям. Вот, например
int x;
x = 3;
cout << (++ x + ++ x) + ++ x << endl;
x = 3;
cout << ++ x + (++ x + ++ x) << endl;

gcc 3.4.4:
16
18
Получилось, что операция сложения не ассоциативна!

MSVC++6.0:
18
18

Примеры:
i = ++i; // undefined behavior, переменная модифицируется дважды
i = i + 1; // все в порядке
i ? i=1 : i=5; // все в порядке (там, где знак ? есть точка следования, а потом выполнится лишь одно из выражений)
i=1; i++; // все в порядке (после каждого выражения находится точка следования)
i=1, i++; // все в порядке (на операторе запятая находится точка следования)

Пример с функциями:
void f(int, int);
int g();
int h();
f(g(), h());
По Стандарту неизвестно, какая из функций g или h будет вызвана первой, но известно, что f() будет вызвана последней.


Разработчикам gcc периодически сабмитят баги по этому поводу, в итоге в
Frequently Reported Bugs in GCC было внесено:
Результат следующих операций непредсказуем и это полностью соответствует стандарту
x[i]=++i
foo(i,++i)
i*(++i) /* special case with foo=="operator*" */
std::cout << i << ++i /* foo(foo(std::cout,i),++i) */

Вот пример такой баги: Bug13403. Их таких там очень много.

В следующей программе
#include <iostream>
int main(){
int i=0;
i = 6+ i++ + 2000;
std::cout << i << std::endl;
return 0;
}

Результат единица! А должен быть 2006.
Если убрать "i++", то результат правильный ("2006"). Но пока есть "i++" внутри выражения, я могу делить, умножать, вычитать, складывать, все равно результат всегда "1".
Например в выражении
i = (6+ i++ + 2000)/2;
"i" все равно единица.

Но если я заменю постфикс "i++" префиксом "++i" тогда все считается корректно.


Ну отвечают на это все - "это undefined behavior по Стандарту" и баг закрывают.

Несколько присваиваний подряд
Интересный момент с таким кодом:
int a=1, b=2, c=3;
a=b=c=0;
Стандарт как-то очень нечетко описывает такую ситуацию. Он говорит, что операция должна происходить справа налево, но ничего не говорит дополнительно по поводу того, когда должен быть результат этой операции записан в переменную. Точек следования внутри выражения нет, что значит, что компилятор может теоретически творить здесь что угодно и не обязательно все переменные в итоге будут равны 0. Так что, опять же теоретически, здесь имеет место unspecified behavior. По этому поводу периодически рождаются хвостатые флеймы в comp.lang.c++.moderated. Но на самом деле я не слышала о компиляторе, который делал бы в такой ситуации присвоение не справа налево. Вопрос этот известен, а компиляторы не садисты пишут.

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

Ссылки по теме:
Разница между unspecified behavior и undefined behavior
comp.lang.c++.moderated Undefined behavior?? I think not!
comp.lang.c++.moderated postfix increment and assignment
comp.lang.c++.moderated fresh look on copying objects and passing args by value
comp.lang.c++.moderated Evaluation precedence questions
comp.lang.c++.moderated = Operator?
comp.lang.c++.moderated How defined is undefined behaviour?
comp.lang.c++.moderated Case for post-increment/decrement
comp.lang.c++.moderated What is a sequence point?
comp.lang.c++.moderated Sequence point and evaluation order
comp.lang.c++.moderated sequence point and reference
comp.lang.c++.moderated sequence points and , operator

24 комментария(ев):

Анонимный комментирует...

Ага, подробно не разбирали. Ну, просто говорили, что "неопределенное поведение" (запомните это, и никогда так не делайте), а точки следования, да, не упоминали.
Забавно другое: что вопросы вида
i++ + ++i (написанные i+++++i) или что значит ++i++ (тут, правда, уже и другое примешивается) часто задавали на собеседованиях. При этом услышать хотели вовсе не "неопределенное поведение" (и тем более не точки следования), а именно про префиксный и постфиксный инкременты, порядок следования и т.д. И даже если скажешь: поведение неопределенное, все равно нужен был ответ ("ну компилятор же все-таки это как-то вычислит, вот и напишите про порядок операций и т.д. и т.д.").
Впрочем, это уже о не совсем корректных задачах на собеседованиях на работу...
// tensor

Sergey Petrov комментирует...

Все замечательно, только ты слово "undefined" несколько раз написала как "underfined". Просто обращаю внимание :)

Maniac комментирует...

Когда я вижу слово "Стандарт" - вот так с большой буквы, то у меня непреодолимо напрашиваются ассоциации, что это надо читать как "Святая Библия" :-)

А "Глава Стандарта 1.9/16" и вообще подозрительно смахивает на "Иезекиль, 25:17" :-)

Alena комментирует...

2tensor:
А, кстати, интересно, что делать в таких ситуациях? Упорствовать или сказать то, что хочет услышать собеседник? Я думаю, я бы вежливо, но твердо, настояла на своем...
Да и я знаю, здесь есть люди, которые сами проводят собеседования. Как бы вы отнеслись к тому, что соискатель указал вам на ваше давнее заблуждение?

2Sergey Petrov:
Спасибо, поправила.

2Maniac:
Ладно тебе зубоскалить, ты так же относишься к веб-стандартам. :-)

Анонимный комментирует...

А, кстати, интересно, что делать в таких ситуациях?
Упорствовать или нет - сложно сказать, особенно если собеседование. Мне приходилось в инете поправлять высказывания или статьи - так даже давая ссылки на источники, убедительные примеры, замечаю, что большинство ну оочень неохотно признает ошибки. А на собеседовании, где и книжки может не быть (куда ткнуть), и к интернету скорее всего не пойдут искать и проверять...
но выводы у меня твердо сделаны: если человек ошибается в том, о чем сам спрашивает соискателя (о чем сам заговорил), то такая работа пойдет лесом. Ибо с такими людьми дальше только хуже будет.

Как бы вы отнеслись к тому, что соискатель указал вам на ваше давнее заблуждение?
Да, да, смелее! Все дружно признаемся, как умеем адекватно реагировать на свои собственные ошибки ;)
// tensor

Анонимный комментирует...

Угу.
Заказчик недавно тестил наш компилятор. Прислал баг-репорт со следующим текстом:

for (int i=0; i<5; i=i++)
// do something

и долго ругался, что зацикливается это. Пришлось все в посте описанное объяснять тестеру. ;)

alex_shubert комментирует...

Мой любимый тест. Меня так самого 5 лет назад принимали.
Испытуемому задается вопрос. Он отвечает. После чего вы с начальственным апломбом указываете на мнимую ошибку.

Если тут же согласится или начнет греться и кипятиться - резюме в мусорку.
Наиболее интересны два типа: те, которые, по глазам видно, твердо уверены в собственной правоте, но говорят, нечто вроде "надо свериться с литературой" и те, которые указывают, где можно найти верный ответ / предлагают провести эксперимент.
Последний, как правило, тут же получает работу.

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

Рома комментирует...

По-моему, со случаем
a = b = c = d;
всё вполне прозрачно, потому что это

a::operator = (b::operator = (c::operator = (d)));

elide комментирует...

после прочтения сего замечательного поста вспомнил про вопрос, который давно не давал мне покоя. если взять код
int i = 0;
std::cout << i++ << i++ << i++;
то по своей сути, он представляет из себя
int i = 0;
( ( std::cout.operator<<(i++) ).operator<<(i++) ).operator<<(i++);
т.е. если и вызов функции, и возврат из нее есть точки следования, то этот код валиден и должен выводить "012", однако он выводит "210" на всех доступных мне платфорах.
внимание, вопрос: это я идиот и так и должно быть? или компиляторы глючат?

elide@bk.ru

Alena комментирует...

т.е. если и вызов функции, и возврат из нее есть точки следования, то этот код валиден и должен выводить "012"

У меня в посте есть аналогичный пример с сайта gcc.
std::cout << i << ++i

Что раскладывается в
foo(foo(std::cout,i),++i)

где функция foo в данном случае - это operator<<.

Порядок вычисления аргументов функции может быть любым.

или компиляторы глючат?

Если код проверен на нескольких компиляторах, то вряд ли они все будут глючить, причем совершенно одинаково. Плюс, есть же списки известных багов. В случае gcc, у них вообще доступ в багтрак открыт для всех желающих, подавляющее большинство багов известно. http://gcc.gnu.org/bugzilla/
Можно, конечно, напороться на нечто, что еще никто не находил, но вероятность очень невелика.

Spalex комментирует...

Здравствуйте, Алёна! Не подскажете, ссылку на место в Стандарте, где написано, что "Правило слево-направо не работает для переопределенных операторов". Верю, но вот найти пока не могу :(.

Alena комментирует...

Здравствуйте, Алёна!

Приветствую!

Не подскажете, ссылку на место в Стандарте, где написано, что "Правило слево-направо не работает для переопределенных операторов". Верю, но вот найти пока не могу :(.

Прямо такой фразы дословно там нет.
Там так: в пятой главе рассказывается о встроенных операторах, где говорится, что для них правило "слева направо" выполняется.
В главе 13.5 рассказывается о перегрузке операторов. Где упоминается, что перегрузка осуществляется с помощью operator function. Поскольку это уже функции, то и правила для них работают те, что для функций, а не те, что для встроенных операторов. И там не только правило "слева направо" перестает работать. Например, если перегрузить оператор ++, то на нем появится точка следования.

Alena комментирует...

2Spalex:

Только что совершенно случайно наткнулась: очень подробное и хорошее объяснение про эти операторы есть у Мейерса в More Effective C++.
Item 7: Never overload &&, ||, or ,.

Spalex комментирует...

Спасибо за наводочку! ;)

Анонимный комментирует...

Кстати, если серьезно к этому подходить: ++ ++ iter — тоже UB! :-)

Роман.

P. S. Мне почему-то немецкий интерфейс подсунули… Ну и что, что IP из Германии?

P. P. S. Шлите мне спам: dodge_this@qwertty.com

MasterZiv комментирует...

Аллёна! Ты мой куммир ! Ты раскрыла людям глаза ! Теперь фсе будут знать, что такое точки следования в С !

Анонимный комментирует...

>a=b=c=0;

c=0 - это выражение, значение которого равно нулю. точно такое же, как например ++i

archimed7592 комментирует...

Для ++i в Стандарте явно сказано, что результатом будет новое значение. Что же касается оператора присваивания, то этого там вовсе не сказано - сказано, что результатом будет сохранённое значение. Когда же происходит сохранение(side effect) - неизвестно(точнее известно, но не с достаточной точностью, чтобы гаарнтировать результат).
Т.о. даже очень часто используемая конструкция
if ((result = f()) == -1) { ... }
Представляет из себя UB(IS-5/4).

Для неверующих вот цитата из Стандарта про оператор присваивания:
IS-5.17/1 Assignment operators
There are several assignment operators, all of which group right-to-left. All require a modifiable lvalue as their left operand, and the type of an assignment expression is that of its left operand. The result of the assignment operation is the value stored in the left operand after the assignment has taken place; the result is an lvalue.

Насчёт того, когда именно "assignment" будет "taken place" читаем IS: 1.9/7-18, 5/1-4 или читаем пост Алёны про sequence points и последовавшую за ним дискуссию.

Анонимный комментирует...

to archimed7592

Так и не важно когда именно произойдёт присваивание. Стандарт требует, чтобы результатом выражения всегда было то значение, которое присвоится. Тут не сказано, что будет взято значения из левого операнда [в неизвестно какой момент], а чётко указано, что будет использоваться значение, которое будет иметь левый операнд после присвоения.

Алёна комментирует...

2Анонимный:
Так и не важно когда именно произойдёт присваивание. Стандарт требует, чтобы результатом выражения всегда было то значение, которое присвоится.

Я предлагаю не продолжать эту дискуссию, потому что продолжать ее можно бесконечно. Чтобы увидеть всю аргументацию обеих сторон можно почитать любой флейм на comp.lang.c++.moderated. Ссылки в посте есть.

Анонимный комментирует...

x +=1; //?
x =++x; UB
x =x+1; //Норма

Почему во втором случае UB, а в 3 норма и как трактовать 1 вариант?
ведь во всех 3 случаях логически одно и тоже (сложение потом присваивание)

И как можно трактовать вот это выражение?
int x =-1;
if (++x) x=x
else x=-x;

если такой вариант вполне gприемлем
x =(++x)?x:-x;

Алёна комментирует...

x +=1; //?
x =++x; UB
x =x+1; //Норма

Почему во втором случае UB, а в 3 норма и как трактовать 1 вариант?
ведь во всех 3 случаях логически одно и тоже (сложение потом присваивание)


Нет, во втором случае попытка изменить переменную x дважды. ++x - тоже подразумевает присваивание.

Первый вариант - просто прибавить единицу. Модификация одна.

И как можно трактовать вот это выражение?
int x =-1;
if (++x) x=x
else x=-x;


Эээ... А что не так с выражением?

Анонимный комментирует...

И как можно трактовать вот это выражение?
int x =-1;
if (++x) x=x
else x=-x;

Эээ... А что не так с выражением?

А что не так - то?

Анонимный комментирует...

В следующей программе
#include .....
int main(){
int i=0;
i = 6+ i++ + 2000;
std::cout << i << std::endl;
return 0;
}

Результат единица! А должен быть 2006.
Если убрать "i++", то результат правильный ("2006"). Но пока есть "i++" внутри выражения, я могу делить, умножать, вычитать, складывать, все равно результат всегда "1".
Например в выражении
i = (6+ i++ + 2000)/2;
"i" все равно единица.

Но если я заменю постфикс "i++" префиксом "++i" тогда все считается корректно.

Ну отвечают на это все - "это undefined behavior по Стандарту" и баг закрывают.

Разумеется единица!
i увеличили после окончания операции (его сохранённое значение) и записали на место получившегося было 2007 после окончания операции.
Чего удивительного? Удивительно было бы ожидать увидеть 2007...
Вот тогда действительно следовало бы репу долго чесать...
А если ++i тогда действительно 2007.
Никакой мистики. Просто нужно хотя бы приблизительно понимать, как реально осуществляет операции скомпилированный код.