Система Orphus

 

Поиск по сайту

 

Статьи » Точки следования (sequence points) и порядок вычисления выражений в C++

Точки следования (sequence points) и порядок вычисления выражений в C++

Sequence Points and Expression Evaluation

Журнал Visual Systems, август 2002

© Клаус Крэфт и Анжелика Лангер

Знаете ли Вы, что такое точки следования? Каждый C++ программист должен о них знать. Однако когда мы задаем этот вопрос, даже программисты, имеющие многолетний практический опыт признают, что они не имеют о них никакого понятия. В этой статье мы хотим пролить свет на эту тайну и объяснить, что такое точки следования и почему о них важно знать.

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

C++ программа состоит в основном из двух типов утверждений:

  • Объявления и определения типов, констант, имен функций и т.д. Эта информация, которую компилятор будет считывать и обрабатывать во время компиляции. Лишь незначительная часть этой информации будет исполняемой на стадии выполнения программы.
  • Выражения. Этот вид информации интерпретируется компилятором и переводится в инструкции, которые исполняются на стадии выполнения программы.

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

Побочные эффекты вычисления выражений

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

Существуют следующие побочные эффекты выполнения C++ программы:

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

Таким образом, основными действиями C++ программы являются модификация ячеек памяти и вызов системных функций. (Для получения дополнительной информации об особенностях доступа к volatile объектам, см. вкладку 1.)

Вкладка 1. Volatile переменные

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

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

int i=0;
... другие операторы, не воздействующие на i ...
if (i==0)
...

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

Чем же полезен спецификатор volatile? И зачем нужно отключать такую полезную оптимизацию?

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

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

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

Порядок вычисления выражений

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

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

Примеры

Давайте рассмотрим несколько примеров. Когда мы видим выражение i = 2, то мы ожидаем, что когда поток управления достигнет точки с запятой, то переменная i будет иметь значение 2. Наши ожидания являются оправданными, поскольку, действительно, в конце каждого " полного выражения" существует точка следования. (Пока не волнуйтесь по поводу термина "полное выражение", обычно конец выражения определяется точкой с запятой). Это означает, что в конце каждого оператора, оператор был выполнен, как и ожидалось.

Но подождите! Как насчет такого примера:

x[i]=i++ + 1;

Давайте предположим, что переменная i первоначально была равна 1. Каким будет результат вычисления этого выражения? Правильный ответ: мы не знаем. Тем не менее, многие программисты полагают, что они знают, что происходит в этом фрагменте программы. Вот типичные ответы: "х[1] будет иметь значение 2", или "х[2] равно 2" или даже "х[1] равно 3".

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

На этот вопрос нет определенного ответа. Это полностью зависит от порядка, в котором компилятор вычисляет подвыражения. Если компилятор начинает с  правой части присваивания и вычисляет i++ + 1 до момента определения позиции в массиве х, будет изменен элемент массива х[2], потому что i уже была увеличена в ходе оценки подвыражения i++. И наоборот, если компилятор начинается с левой части и определяет позицию i в массиве перед вычислением правой части выражения присваивания, то будет модифицирован элемент массива х[1]. Оба результата одинаково вероятны и одинаково правильны.

Вкладка 2. Различия между префиксным и постфиксным инкрементами и декрементами

Префиксные операторы отличаются от постфиксных возвращаемым значением.

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

Оба действия, как побочный эффект так и определение возвращаемого значения, происходят в то же самое время, а именно, когда вычисляются префиксные и постфиксные выражения. Иногда люди ошибочно полагают, что при постфиксном приращении само приращение происходит "потом", как в следующем примере:

f(i++);

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

Как же это может быть, что результат такого невинного присваивания, как х[i] = i++ + 1; не определен? Ответ в следующем - это прямое следствие того, что порядок вычисления выражений и подвыражений между точками следования не определен. Помните, что единственная точка следования в этом фрагменте кода находится в конце полного выражения, то есть, где находится точка с запятой. Общее заблуждение состоит в том, что люди полагают, что оператор присваивания вводит точку следования, на основе которой компилятор вычислит значение левой части присваивания до вычисления правой. Но оператор присваивания не устанавливает точку следования, как и практически никакой из операторов в С++. (Единственным исключением, как мы увидим далее, является редко используемый оператор 'запятая'.)

Трудности против безопасных выражений

Как же так получается, что присваивание х[i] = i++ + 1; является проблематичным в смысле определенности и предсказуемости результата, в то время как присваивание i = 2; совершенно прозрачно? Суть в том, что в выражении х[i] = i++ + 1; есть два обращения к переменной i, одно из которых, а именно i++, является модифицирующим. Так как порядок вычисления между точками следования не определен, мы не знаем, будет ли i изменена прежде, чем она будет прочитана или наоборот, прочитана, а потом модифицирована. Источником проблемы является множественный доступ к переменной между точками следования, если один доступов является модифицирующим.

Вот еще один пример. Что здесь будет, если перед вызовом функции i и j имеют значения 1 и 2?

f(i++, j++, i+j);

Какое значение будет передано функции f в качестве третьего аргумента? Опять же, мы этого не знаем. Это может быть любое из следующих значений: 3, 4 или 5. Это зависит от порядка вычисления аргументов функции. Распространенное заблуждение заключается в том, что аргументы рассчитываются слева направо. Или, может быть справа налево? На самом деле, определение языка не описывает этот порядок.

Простор для оптимизации

Почему спецификация языка оставляет открытым вопрос, в каком порядке компиляторы должны вычислять выражения между последовательностью точек? Может, было бы проще указать определенный порядок вычислений?

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

Полный список точек следования в C++

До сих пор мы говорили только о точках следования в конце полного выражения. Вот полный список всех возможных точек следования в C++:

  • в конце полного выражения
  • после вычисления всех аргументов в вызове функции и перед выполнением любых выражений в ее теле
  • после копирования возвращаемого значения и до выполнения любого выражения вне функции
  • после вычисления первого выражения в a&&b, a||b, a?b:c или a,b
  • после инициализации каждого базового класса и члена в списке инициализации конструктора

Давайте кратко рассмотрим эти точки следования:

Конец полного выражения. Концом полного выражения в обычных операторах является точка с запятой. В условном выражении, таком как if(i==0 && j==f(x,y,z)) концом полного выражения является закрывающая скобка.

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

Операторы. Точки следования в таких логических выражениях, как && и ||, тернарном операторе ? и операторе 'запятой' означают, что левосторонний операнд вычисляется раньше правостороннего. Только эти операнды в С++ устанавливают точки следования.

Заметим, что оператор 'запятую' часто путают с запятой в качестве разделителя, как, например, в списке аргументов функции. В основном в программе запятая используется в качестве разделителя. Оператор 'запятая' используется редко, в основном в циклах for как в примере for(i=0, j=0; i<100||j<200;++i,++j). В списке аргументов функции f(++i,++j) запятая является просто разделителем между аргументами функций и не предполагает какого-либо порядка вычисления аргументов функции.

Просто для демонстрации того, как все может быть запутанным, рассмотрим следующий пример. Пусть f - функция с одним аргументом и мы передаем в нее (++i,++j), как здесь f((++i,++j)). В этом случае запятая будет не разделителем между аргументами функции, а оператором ‘запятой‘. Тогда ++i будет вычисляться раньше ++j. Результат вычисления будет передаваться в качестве аргумента в функцию f. И тут возникает вопрос: что является результатом оператора ‘запятой‘? Результат вычисления правого операнда!

Не менее запутанным является такое выражение array[++i, ++j]. Помните, что на элементы в двухмерном массиве ссылаются как array[i, j]. Поэтому запятая в array[++i,++j] является оператором, а не разделителем.

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

class Array 
{
  private:
    int* data;
    unsigned size;
    int lBound, hBound;

  public:
    Array(int low, int high) : size(high-low+1), 
      lBound(low), hBound(high), data(new int[size]) {}
};

Порядок инициализации такой: data(new int(size)), затем size(high-low+1), затем lBound(low), затем hBound(high), что в данном случае ведет к проблеме, поскольку size используется до инициализации разумным значением. Это еще одна известная ловушка в C++.

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

Скрытые зависимости

Теперь, когда вы знаете все точки следования, вы готовы рассмотреть несколько дополнительных примеров. До сих пор мы рассматривали примеры с очевидным множественным доступом к переменной. Как насчет такого примера?

x = f() + g() + h();

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

Точки следования и исключения

Вот еще один интересный случай:

f( new X(i++), new Y(i) );

Здесь происходит много вещей: мы производим побочные эффекты модификации переменной i, выделения памяти для объектов типа X и Y и конструирования этих объектов. Мы не знаем, в каком порядке эти побочные эффекты будут происходить. Однако мы знаем, что i++ будет вычисляться до вызова конструктора X, и мы можем смело предположить, что исполняющая система будет выделять память, прежде чем она попытается инициализировать ее через вызов конструктора. Но мы не знаем, будет ли i инкрементирована прежде, чем она будет передана конструктору Y. И мы не знаем, для какого объекта будет выделена память в первую очередь, и/или какой конструктор будет вызван. Допустима такая ситуация, когда компилятор может выделить память для обоих объектов перед вызовом любого из конструкторов.

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

Избегайте сложных выражений

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

x[i]=i++ + 1;

мы могли бы написать

x[i]=i + 1;
i++;

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

f( new X(i++), new Y(i) );

мы можем написать

X* xptr = new X(i++);
Y* yptr = new Y(i);
f( xptr, yptr );

Если мы теперь поймаем исключение типа Yexception (по этому типу мы можем сказать, что его сгенерировал конструктор Y), то мы могли бы сказать, что объект X уже был успешно создан и теперь у нас есть гораздо больше шансов обработать это исключение более правильно. Или мы могли бы заключить каждый из этих операторов в самостоятельный try-блок, если бы хотели обрабатывать исключения  bad_alloc от каждого вызова оператора new.

Рекомендации

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

Заключение

Порядок вычисления выражений и подвыражений не определен между точками следования. C++ имеет удивительно мало определенных точек следования. Распространенное заблуждение состоит в том, что их гораздо больше, чем на самом деле. Большинство операторов не являются точками следования за исключением &&, ||, ? и оператора 'запятой'. Разделители не являются точками следования. Единственная точка следования, которую легко идентифицировать - это конец полного выражения (точка с запятой).

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

Источник: http://www.angelikalanger.com/Articles/VSJ/SequencePoints/SequencePoints.html