
Стандарт не оговаривает форму представления указателей, но оговаривает — в большей или меньшей степени — операции с ними. Ниже мы рассмотрим эти операции и особенности их определения в стандарте. Начнём со следующего примера:
#include <stdio.h>
int main(void) {
int a, b;
int *p = &a;
int *q = &b + 1;
printf("%p %p %d\n", (void *)p, (void *)q, p == q);
return 0;
}
Если мы скомпилируем этот код GCC и запустим программу под Linux x86-64, она напечатает следующее:
0x7fff4a35b19c 0x7fff4a35b19c 0
Обратите внимание, что указатели p и q ссылаются на один и тот же адрес. Однако результат выражения p == q есть false, и это на первый взгляд кажется странным. Разве два указателя на один и тот же адрес не должны быть равны?
Вот как стандарт C определяет результат проверки двух указателей на равенство:
| C11 § 6.5.9 пункт 6
Два указателя равны тогда и только тогда, когда оба являются нулевыми, либо указывают на один и тот же объект (в том числе указатель на объект и первый подобъект в составе объекта) или функцию, либо указывают на позицию за последним элементом массива, либо один указатель ссылается на позицию за последним элементом массива, а другой — на начало другого массива, следующего сразу за первым в том же адресном пространстве. |
Прежде всего возникает вопрос: что такое «объект»? Поскольку речь идёт о языке C, то очевидно, что здесь объекты не имеют ничего общего с объектами в языках ООП вроде C++. В стандарте C это понятие определяется не вполне строго:
| C11 § 3.15
Объект — это область хранения данных в среде выполнения, содержимое которой может использоваться для представления значений ПРИМЕЧАНИЕ При упоминании объект может рассматриваться как имеющий конкретный тип; см. 6.3.2.1. |
Давайте разбираться. 16-битная целочисленная переменная — это набор данных в памяти, которые могут представлять 16-битные целочисленные значения. Следовательно, такая переменная является объектом. Будут ли два указателя равны, если один из них ссылается на первый байт данного целого числа, а второй — на второй байт этого же числа? Комитет по стандартизации языка, разумеется, имел в виду совсем не это. Но тут надо заметить, что на этот счёт у него нет чётких разъяснений, и мы вынуждены гадать, что же имелось в виду на самом деле.
Когда на пути встаёт компилятор
Вернёмся к нашему первому примеру. Указатель p получен из объекта a, а указатель q — из объекта b. Во втором случае применяется адресная арифметика, которая для операторов «плюс» и «минус» определена следующим образом:
| C11 § 6.5.6 пункт 7
При использовании с этими операторами указатель на объект, не являющийся элементом массива, ведёт себя, как указатель на начало массива длиной в один элемент, тип которого соответствует типу исходного объекта. |
Поскольку любой указатель на объект, не являющийся массивом, фактически становится указателем на массив длиной в один элемент, стандарт определяет адресную арифметику только для указателей на массивы — это уже пункт 8. Нас интересует следующая его часть:
| C11 § 6.5.6 пункт 8
Если целочисленное выражение прибавляется к указателю или вычитается из него, результирующий указатель имеет тот же тип, что и исходный указатель. Если исходный указатель ссылается на элемент массива и массив имеет достаточную длину, то исходный и результирующий элементы отстоят друг от друга так, что разность между их индексами равна значению целочисленного выражения. Другими словами, если выражение P указывает на i-й элемент массива, выражения (P)+N (или равносильное ему N+(P)) и (P)-N (где N имеет значение n) указывают соответственно на (i+n)-й и (i−n)-й элементы массива, при условии что они существуют. Более того, если выражение P указывает на последний элемент массива, то выражение (P)+1 указывает на позицию за последним элементом массива, а если выражение Q указывает на позицию за последним элементом массива, то выражение (Q)-1 указывает на последний элемент массива. Если и исходный, и результирующий указатели ссылаются на элементы одного и того же массива либо на позицию за последним элементом массива, то переполнение исключено; в противном случае поведение не определено. Если результирующий указатель ссылается на позицию за последним элементом массива, к нему не может применяться унарный оператор *. |
Из этого следует, что результатом выражения &b + 1 совершенно точно должен быть адрес, и, значит, p и q — это валидные указатели. Напомню, как определено равенство двух указателей в стандарте: "Два указателя равны тогда и только тогда, когда [...] один указатель ссылается на позицию за последним элементом массива, а другой — на начало другого массива, следующего сразу за первым в том же адресном пространстве" (C11 § 6.5.9 пункт 6). Именно это мы и наблюдаем в нашем примере. Указатель q ссылается на позицию за объектом b, за которым сразу же следует объект a, на который ссылается указатель p. Получается, в GCC баг? Это противоречие было описано в 2014 году как ошибка #61502, но разработчики GCC не считают его багом и поэтому исправлять его не собираются.
С похожей проблемой в 2016 году столкнулись программисты под Linux. Рассмотрим следующий код:
extern int _start[];
extern int _end[];
void foo(void) {
for (int *i = _start; i != _end; ++i) { /* ... */ }
}
Символами _start и _end задают границы области памяти. Поскольку они вынесены во внешний файл, компилятор не знает, как на самом деле массивы расположены в памяти. По этой причине он должен здесь проявить осторожность и исходить из предположения, что они следуют в адресном пространстве друг за другом. Однако GCC компилирует условие цикла так, что оно всегда верно, из-за чего цикл становится бесконечным. Эта проблема описана вот в этом посте на LKML — там используется похожий фрагмент кода. Кажется, в данном случае авторы GCC все-таки учли замечания и изменили поведение компилятора. По крайней мере я не смог воспроизвести эту ошибку в версии GCC 7.3.1 под Linux x86_64.
Разгадка — в отчёте об ошибке #260?
Наш случай может прояснить отчёт об ошибке #260. Он больше касается неопределённых значений, однако в нём можно найти любопытный комментарий от комитета:
Реализации компиляторов [...] могут также различать указатели, полученные из разных объектов, даже если эти указатели имеют одинаковый набор битов.
Если понимать этот комментарий буквально, то тогда логично, что результат выражения p == q есть «ложь», так как p и q получены из разных объектов, никак не связанных между собой. Похоже, мы всё ближе подбираемся к истине — или нет? До сих пор мы имели дело с операторами равенства, а как насчёт операторов отношения?
Окончательная разгадка — в операторах отношения?
Определение операторов отношения <, <=, > и >= в контексте сравнения указателей содержит одну любопытную мысль:
| C11 § 6.5.8 пункт 5
Результат сравнения двух указателей зависит от взаимного расположения указываемых объектов в адресном пространстве. Если два указателя на объектные типы ссылаются на один и тот же объект, либо оба ссылаются на позицию за последним элементом одного и того же массива, то такие указатели равны. Если указываемые объекты являются членами одного и того же составного объекта, то указатели на члены структуры, объявленные позже, больше указателей на члены, объявленные раньше, а указатели на элементы массива с большими индексами больше указателей на элементы того же массива с меньшими индексами. Все указатели на члены одного и того же объединения равны. Если выражение P указывает на элемент массива, а выражение Q — на последний элемент того же массива, то значение указателя-выражения Q+1 больше, чем значение выражения P. Во всех остальных случаях поведение не определено. |
Согласно этому определению, результат сравнения указателей определён только в том случае, если указатели получены из одного и того же объекта. Покажем это на двух примерах.
int *p = malloc(64 * sizeof(int));
int *q = malloc(64 * sizeof(int));
if (p < q) // неопределённое поведение
foo();
Здесь указатели p и q ссылаются на два разных объекта, которые не связаны между собой. Поэтому результат их сравнения не определён. А вот в следующем примере:
int *p = malloc(64 * sizeof(int));
int *q = p + 42;
if (p < q)
foo();
указатели p и q ссылаются на один и тот же объект и, следовательно, связаны между собой. Значит, их можно сравнить — если только malloc не вернёт нулевое значение.
Резюме
Стандарт C11 недостаточно строго описывает сравнение указателей. Наиболее проблемным моментом, с которым мы столкнулись, стал пункт 6 § 6.5.9, где явно разрешено сравнивать два указателя, ссылающиеся на два разных массива. Это противоречит комментарию из отчёта об ошибке #260. Однако там речь идёт о неопределённых значениях, и я не хотел бы строить свои рассуждения на основании одного лишь этого комментария и толковать его в другом контексте. При сравнении указателей операторы отношения определяются несколько иначе, чем операторы равенства — а именно, операторы отношения определены, только если оба указателя получены из одного и того же объекта.
Если отвлечься от текста стандарта и задаться вопросом, можно ли сравнивать два указателя, полученных из двух различных объектов, то в любом случае ответ, скорее всего, будет «нет». Пример в начале статьи демонстрирует скорее теоретическую проблему. Поскольку переменные a и b имеют автоматическую продолжительность хранения, наши предположения об их размещении в памяти будут ненадёжными. В отдельных случаях мы можем угадать, но совершенно очевидно, что такой код не получится безопасно портировать, и узнать смысл программы можно, только скомпилировав и запустив или деассемблировав код, а это противоречит любой серьёзной парадигме программирования.
Однако в целом я не удовлетворён формулировками в стандарте C11, и так как уже несколько человек столкнулось с этой проблемой, актуальным остаётся вопрос: почему бы не сформулировать правила яснее?
Дополнение
Указатели на позицию за последним элементом массива
Что касается правила о сравнении и адресной арифметике указателей на позицию за последним элементом массива, сплошь и рядом можно найти исключения из него. Предположим, что стандарт не разрешал бы сравнивать два указателя, полученные из одного и того же массива, при том что хотя бы один из них ссылается на позицию за концом массива. Тогда следующий код не работал бы:
const int num = 64;
int x[num];
for (int *i = x; i < &x[num]; ++i) { /* ... */ }
С помощью цикла мы обходим весь массив x, состоящий из 64 элементов, т.е. тело цикла должно выполниться ровно 64 раза. Но на самом деле условие проверяется 65 раз — на один раз больше, чем число элементов в массиве. В первые 64 итерации указатель i всегда ссылается внутрь массива x, тогда как выражение &x[num] всегда указывает на позицию за последним элементом массива. На 65-й итерации указатель i будет также ссылаться на позицию за концом массива x, из-за чего условие цикла станет ложным. Это удобный способ обойти весь массив, при этом он опирается на исключение из правила о неопределённости поведения при сравнении таких указателей. Обратите внимание, что стандарт описывает поведение лишь при сравнении указателей; их разыменование — это отдельная тема.
Можно ли изменить наш пример так, чтобы на позицию за последним элементом массива x не ссылался бы ни один указатель? Можно, но это будет сложнее. Придётся изменить условие цикла и запретить инкремент переменной i на последней итерации.
const int num = 64;
int x[num];
for (int *i = x; i <= &x[num-1]; ++i) {
/* ... */
if (i == &x[num-1]) break;
}
В этом коде полно технических тонкостей, возня с которыми отвлекает от главной задачи. Кроме того, в теле цикла появилась дополнительная ветка. Так что я считаю разумным, что стандарт разрешает исключения при сравнении указателей на позицию за последним элементом массива.
Примечание команды PVS-Studio
При разработке анализатора кода PVS-Studio нам приходится иногда разбираться с тонкими моментами, чтобы сделать диагностики более точными или чтобы давать подробные консультации нашим клиентам. Эта статья показалась нам интересной, так как затрагивает вопросы, в которых мы сами до конца не чувствуем себя уверенными. Поэтому мы попросили у автора выложить на сайте эту статью и её перевод. Надеемся, так с ней познакомится больше C и C++ программистов и поймут, что не всё так просто и что, когда вдруг анализатор выдаёт странное сообщение, не стоит сразу спешить считать его ложным срабатыванием :).
Статья впервые была опубликована на английском языке на сайте stefansf.de. Оригинал и перевод публикуются на нашем сайте с разрешения автора.
Комментариев нет:
Отправить комментарий