5

Вопрос по поводу арифметики указателей С. Пусть

int *p;
int *q;
int *o;
int i;
o = NULL;

Допустим p и q указывают на разные элементы одного массива. Допустимы выражения (i, естественно, где-то определено):

p+i; q-i; p-q;

А допустимо ли:

p-o;
o-p;

И что получится, если допустимы? Т.е. с одной стороны o не указывает на тот же массив и вроде нет, но он же равен и обычному 0, и первое вроде бы да.

andy.37
  • 7,461
  • Допустимы, но бессмысленны. – Mirdin Dec 23 '15 at 15:44
  • 1
    @Mirdin, что означает бессмысленны? Истинно ли p == p - NULL? И что такое NULL - p - на что он указывает и указатель ли это. Или в обоих случаях UB? – andy.37 Dec 23 '15 at 15:50

4 Answers4

8

Нет, не допустимы.

Стандарт С++ говорит (С++11 [expr.add]p6), что если два указателя не принадлежат одному массиву, то поведение не определено.

Результат вычитания двух указателей - это количество элементов между ними. Если p не выровнен на sizeof(int), то выражение p-o должно вернуть дробное количество элементов, чего не может быть.

При этом выражение p-NULL может быть валидно только если NULL - это #define NULL 0, и не валидно если, например, NULL определено как (void*)0 (в Си). Поскольку стандарт не говорит как именно должен быть определен NULL, то следует считать, что выражение p-NULL не валидно.

Примечание: выражение p-0 валидно и равно p, но это работает только для целого числа 0.

Abyx
  • 31,143
  • а границы массива разве компилятор отслеживает? – Mirdin Dec 23 '15 at 16:04
  • Почему про 0 удалили? К тому же, если оба указателя есть 0, то их можно вычитать друг из друга. По Вашей же ссылке п.7. – αλεχολυτ Dec 23 '15 at 16:05
  • Это понятно, интересовало, не преобразуется ли нулевой указатель в целый ноль, хотя, если задуматься, нет ни одной причины для этого. – andy.37 Dec 23 '15 at 16:05
  • @Mirdin нет, конечно, не отслеживает. Можно смело вылезать за границы массива, если нужно. Просто не могу придумать, зачем это может понадобиться. – andy.37 Dec 23 '15 at 16:07
  • @andy.37 "С - язык трюков и хаков" ;) – Mirdin Dec 23 '15 at 16:10
  • К последнему дополнению - p-NULL невалидно, но int *o=NULL; p-o; - валидно, также, как и p - (int*)NULL хоть и UB. – andy.37 Dec 23 '15 at 16:10
  • @Abyx, я имел в виду валидно/невалидно с точки зрения компилятора (синтаксиса). Кстати еще малелький вопросик, всегда ли NULL - это #define NULL (void*)0? – andy.37 Dec 23 '15 at 16:14
  • @andy.37 UB это очень коварный тип ошибок, который может возникать, даже если программа корректна по синтаксису и типизации. Так что даже если ответ "да, валидно", пользоваться таким кодом на практике вы не захотите. –  Dec 23 '15 at 16:16
  • 1
    @andy.37 NULL не может быть определен как (void*)0 в c++, о чем однозначно сказано в Стандарте: Possible definitions include 0 and 0L, but not (void*)0. Вытекает это из того, что в плюсах void* не может быть неявно преобразован в T*. Если бы NULL был определен как (void*)0, то конструкция вида int* p = NULL вызывала бы ошибку компиляции, но она валидна. – αλεχολυτ Dec 24 '15 at 08:29
  • @alexolut спасибо. Оказывается, это довольно существенное отличие С и С++. Оставлю здесь эту ссылку: http://stackoverflow.com/questions/7016861/why-are-null-pointers-defined-differently-in-c-and-c - довольно подробно все расписано. – andy.37 Dec 24 '15 at 09:05
  • Начиная с С++11 допускается определение NULL как nullptr. Поэтому в С++ выражение p - NULL в общем случае некорректно. – AnT stands with Russia Feb 27 '18 at 18:28
3

Краткий ответ:

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

Подробный ответ:

В соответствии со Стандартом C++ в п. 18.2/3 сказано:

The macro NULL is an implementation-defined C++ null pointer constant in this International Standard (4.10).

При этом в сноске указаны возможные и однозначно невозможные варианты реализации:

Possible definitions include 0 and 0L, but not (void*)0.

п.4.10/1 говорит, что такое null pointer constant (выделено мной):

A null pointer constant is an integer literal (2.14.2) with value zero or a prvalue of type std::nullptr_t.

Т.о. NULL развовачивается либо в целочисленный тип, но не ясно в какой (int, long и т.д.), либо в константу nullptr (это единственно возможное значение для типа std::nullptr_t). Более подробно тут.

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

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

int arr[] = {1, 2, 3};
int* p = arr;
int* o = NULL;

auto s1 = p - o;
auto s2 = o - p;
auto s3 = p - NULL;
auto s4 = o - NULL;
auto s5 = NULL - NULL;
auto s6 = o - o;

Выражения s1 и s2 имеют тип такой же как std::ptrdiff_t из <cstddef> и они приводят к неопределенному поведению (UB) на основании того, что это вычитание двух указателей, не принадлежащих одному массиву (5.7/6) (ведь NULL не может указывать ни на один существующий в программе объект):

Unless both pointers point to elements of the same array object, or one past the last element of the array object, the behavior is undefined

Выражения s3 и s4 имеют тип такой же, как у p и o соответственно. В данном случае это int* и они валидны, на основании того, что это вычитание целочисленного аргумента (см. что такое NULL в начале ответа) из указателя (5.7/8):

If the value 0 is added to or subtracted from a pointer value, the result compares equal to the original pointer value.

При этом значение s3 такое же как у p, а значение s4 такое же как у o.

Выражение s5 имеет целочисленный тип, зависимый от того, как определен NULL на основании продвижения (promotions) целочисленных аргументов (4.5) и, разумеется, валидно, т.к. представляет собой разность двух целых значений.

Выражение s6 валидно и имеет тип std::ptrdiff_t на основании п. 5.7/8 (выделено мной):

If two pointers point to the same object or both point one past the end of the same array or both are null, and the two pointers are subtracted, the result compares equal to the value 0 converted to the type std::ptrdiff_t.

αλεχολυτ
  • 28,987
  • 13
  • 60
  • 119
  • 1
    Ну, это бесспорно, и очевидно, а вот если один из указателей - 0?. И, сорри, это не ответ на вопрос. – andy.37 Dec 23 '15 at 15:46
  • @andy.37, результатом будет разность чиселенных значений указателей делёная на размер типа данных указателя. Если указатели невалидные, то результат просто не будет иметь какого-то практического смысла. – insolor Dec 23 '15 at 15:50
  • Possible definitions include 0 and 0L, but not (void*)0. Означает ли это, что макрос NULL может быть определён только как 0 или 0L? Или он может быть определён как, например, 0LL или, например, nullptr? – wololo Feb 20 '18 at 10:33
  • @wololo из этой фразы следует возможность как минимум двух реализаций и невозможности как минимум одной. – αλεχολυτ Feb 20 '18 at 10:58
  • @alexolut, т.е. NULL может быть nullptr? – wololo Feb 20 '18 at 12:07
  • @wololo nullptr вводился именно как замена NULL в с++. Я навскидку не вижу причин, почему бы он не мог им быть, кроме как для целей совместимости. Всякие ошибки преобразования NULL в целое сразу повылазили бы. Например: int p = NULL; стало бы невалидным использованием. Но оно концептуально и должно быть таким. – αλεχολυτ Feb 20 '18 at 13:07
  • @alexolut, но у вас в ответе написано: Т.о. NULL однозначно развовачивается в целочисленный тип, но не ясно в какой (int, long и т.д.). – wololo Feb 20 '18 at 15:35
  • @wololo с nullptr не будет работать арифметика указателей, которая интересует ТС. – αλεχολυτ Feb 20 '18 at 19:36
  • 1
    @wololo чтиво на тему, может ли NULL быть nullptr – αλεχολυτ Feb 21 '18 at 12:28
  • @alexolut, "с nullptr не будет работать арифметика указателей". А в вашем ответе сказано, что с NULL арифметика указателей работать однозначно будет. – wololo Feb 21 '18 at 22:51
2

Для традиционных систем с линейным адресным пространством, размер которого задан разрядностью указателя, например, x86 (и по крайней мере в Linux/gcc/g++ (в других не проверял)) все компилируется и как ни странно вполне осмысленно.

Посмотрим небольшую программку

avp@avp-xub11:hashcode$ cat c2.c
#ifdef __cplusplus
#include <iostream>
#endif

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#ifndef T
#define T int
#endif

int 
main (int ac, char *av[])
{
  T arr[] = {1, 2, 3};
  T* p = arr;
  T* o = NULL;
  long i = p - o, j = o - p;

  printf("%p %lx (%lx) %lx (%lx) [%lx + %lx = %lx]\n", p, 
     (long)(p - o), (long)((p - o)) * sizeof(*p),
     (long)(o - p), (long)((o - p)) * sizeof(*p), 
     i, j, j + i);

};

avp@avp-xub11:hashcode$ uname -a
Linux avp-xub11 3.13.0-74-generic #118-Ubuntu SMP Thu Dec 17 22:52:02 UTC 2015 i686 i686 i686 GNU/Linux
avp@avp-xub11:hashcode$ g++ c2.c
avp@avp-xub11:hashcode$ ./a.out 
0xbfd3daa4 eff4f6a9 (bfd3daa4) 100b0957 (402c255c) [eff4f6a9 + 100b0957 = 0]
avp@avp-xub11:hashcode$ scp c2.c avp@nas: 
c2.c                                                 100%  429     0.4KB/s   00:00    
avp@avp-xub11:hashcode$ ssh nas uname -a
Linux nas.inlinegroup.ru 2.6.32-573.7.1.el6.x86_64 #1 SMP Thu Sep 10 13:42:16 EDT 2015 x86_64 x86_64 x86_64 GNU/Linux
avp@avp-xub11:hashcode$ ssh nas gcc c2.c
avp@avp-xub11:hashcode$ ssh nas ./a.out
0x7ffcd89e7530 1fff36279d4c (7ffcd89e7530) ffffe000c9d862b4 (ffff800327618ad0) [1fff36279d4c + ffffe000c9d862b4 = 0]
avp@avp-xub11:hashcode$ 

Все это можно интерпретировать так -- p - o это количество элементов типа int от начала адресного пространства до arr[], а o - p это тоже самое, но "в другую сторону", т.е. от arr[] до начала (отрицательная величина).
А i + j естественно дает 0 из-за переполнения (т.к. мы прошли все адреса "по кругу").

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

avp
  • 46,098
  • 6
  • 48
  • 116
  • Да, спасибо, я тоже все это компилировал, запускал и проверял. Просто системно-зависимое поведение, по сути, и есть UB, следовательно осмысленность подобных операций - спорна. – andy.37 Dec 24 '15 at 13:38
  • @andy.37, возможно. А возможно более спорно желание всегда писать абсолютно переносимые программы. Чаще лучше явно учитывать специфику оборудования. – avp Dec 24 '15 at 13:48
  • Под спецификой, имхо, следовало бы понимать то, что в Стандарте называется 'implementation defined', а не 'undefined behavior'. Сегодня UB ведет себя так, а завтра (другие опции, другой компилятор, другая ОС) на том же оборудовании иначе. – αλεχολυτ Dec 24 '15 at 20:51
  • @alexolut, если под оборудованием понимать не только железо, а все вместе (компилятор с опциями, ОС, архитектура ... -- назовем, для определенности -- платформа), то UB ведет себя всегда одинаково на одинаковых платформах. А поскольку мало кто (и я в их числе) глубоко изучает все release notes, то вряд ли на практике есть существенная разница между UB и 'implementation defined'. В обоих случаях речь просто о переносимости кода между платформами. И часто ее проще всего обеспечить #ifdef-ами для платформозависимых частей кода. – avp Dec 24 '15 at 22:26
  • @avp, если разные опции компилятора порождают разные платформы (по Вашему определению), то, я думаю, проще учесть и не использовать вообще все места возникновения UB, нежели знать специфику таких 'платформ'. Хотя и опции компилятора знать, безусловно, полезно. Существенная разница между UB и ID в том, что UB может приводить к краху приложения, а ID - нет. Это очень важно иметь в виду, когда от работы ПО зависят жизни людей. Правильнее исключить UB из кода, чем всю жизнь провести на одной 'платформе'. Возможно ли не менять опции компилятора вообще никогда? Уверен, что так не получится. – αλεχολυτ Dec 25 '15 at 05:17
  • @alexolut, не будем заниматься пустым буквоедством спорить о 'платформах`. Думаю, Вы поняли, что я имел в виду говоря об оборудовании. Что же касается Вашего утверждения о разнице между UB и ID, то Вы в корне не правы, говоря о крахе. Лучше задайте соответствующие вопросы (в том числе о жизненном цикле приложений) здесь, а для начала почитайте хотя бы http://stackoverflow.com/questions/18420753 о UB и ID. – avp Dec 25 '15 at 09:15
  • @avp в чем конкретно я не прав? Понятно, что если для ID будет явно написано о крахе, то он возможен, но я не встречал таких вариантов. – αλεχολυτ Dec 25 '15 at 11:09
  • @alexolut, задайте соответствующий вопрос о UB и ID. Может там и обсудим все детали. Если коротко, то по сути и ID и UB все это только политкорректные слова, прикрывающие разные точки зрения на текущие проблемы реализации языка о которых не удается либо договориться либо вообще найти непротиворечивое решение. – avp Dec 25 '15 at 11:45
0

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

P.S. Операции с NULL ЕМНИП запрещены.

Mirdin
  • 5,849
  • На указатели разных типов ругается компилятором, причем ошибкой, а не предупреждением. Также, как и на p-NULL (т.к. NULL имеет тип void*). Похоже, действительно UB, NULL не преобразуется в целочисленный нуль (что, впрочем, естественно) - получается бессмысленное long int число. Непосредственно p-NULL не должно сработать, даже если p имеет тип void*, т.к. арифметика указателей на void запрещена ЕМНИП. Проверил экспериментально. – andy.37 Dec 23 '15 at 16:00
  • @andy.37 я довольно давно работал с этими языками, поэтому ответ Abyx с ссылкой на более новый стандарт будет полутше – Mirdin Dec 23 '15 at 16:03