Вся правда об указателях. Часть 1: Вводная

С самого начала моей карьеры, как программиста, я постоянно встречаю людей(лично или в сети), которые при слове «указатели» впадают в состояние уныния, или наоборот, крайнего возбуждения, находясь в котором, они начинают бранить C++ и указатели на чём свет стоит. И это можно встретить как у начинающих программистов, так и у тех, кто уже довольно давно и долго программирует на языках типа Java и C#. Вообще, у всех, кто «ненавидит» C++(и C), на мой взгляд, есть всегда 2 довода: шаблоны и указатели, исключая шаблоны из уравнения, мы остаёмся с этим «страшными» указателями. Я никогда не понимал почему люди так боятся указателей, ведь это так просто! Но видимо не всем в своё время их объяснили в той мере, чтобы человек понял, потому что я далёк от мысли, что указателей боятся те люди, которые их понимают. Устав от предыдущих статей, посвящённых низкому уровню многопоточной разработки, а также имея давнее желание иметь материал, который можно было бы представлять всем тем, кто не понимает указатели, я и решил написать этот пост, который можно отнести к категории «для самых маленьких». Если вы неплохо ориентируетесь в указателях, то настоящая статья не откроет вам ничего нового.

Что такое указатель?

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

Шкаф

Одним из интересных свойств этого шкафа является то, что каждый выдвижной ящик является кубом, размером 1*1*1 дц3. Примем этот размер за единицу, и в дальнейшем всё будем измерять в ящиках. Таким образом мы ввели размерность — ящик. Кроме этого свойства, есть и другое — мы имеем перед собой шкаф-трансформер, т.е. если у нас есть какая-то вещь, которая не вмещается в один ящик, мы просто вынимаем несколько ящиков, группируем их(неважно как, пусть будет магия) и, положив вещь в ящик, вставляем новый, большой ящик обратно в шкаф:

Шкаф с большим ящиком

Ах да, у нас есть специальные очки, в которых мы смотрим на шкаф, что делает его прозрачным для нас(другие не могут видеть внутренности, пока не откроют ящик!):

Шкаф с прозрачными ящиками

Как вы можете видеть, в нашем шкафу поместилась книга Страуструпа, которая заняла один ящик — размер книги один ящик, а также четырёхтомник Кнута, размером в 4 ящика. Т.к. человек привык к определённому порядку, то ему как-то надо запоминать где у него что лежит(у него нет наших очков!), поэтому он прибегает к простейшему способу — он нумерует ящики слева-направо, начиная с единицы. Так, книга Страуструпа лежит в 1-м ящике, а четырёхтомник Кнута лежит в 4-м ящике и занимает 4 ящика. Поэтому, положив в наш шкаф ещё и книгу Майерса, сразу после Кнута, она будет лежать в (4+4) — в 8-м ящике. Т.е. Кнут занимает 4, 5, 6 и 7 ящики, пусть они и выглядят как один большой ящик.

Пока всё нормально, наш подопытный запомнил(записал), где у него что лежит и когда надо он достаёт нужную книгу. Но тут кому-то(пусть будет дворецкий) вдруг пришло в голову, что книгу Страуструпа стоит переложить в ящик номер 2, и он, замыслив этот план, его исполняет. Но дворецкий понимает, что хозяин привык к тому, что книга лежит в другом месте, и что хозяин рискует не обнаружить её в дальнейшем. Поэтому дворецкий кладёт листок бумаги, на котором указывает, что книга лежит в ящике номер 2. Таким образом мы получаем следующую картину:

Шкаф с указателем в первом ящике

Но дальше хуже, пришла теперь экономка(а она что в хозяйском шкафу забыла?), которая решила, что Страуструпу лучше лежать в 3-м ящике, но она не знает, что во второй ящик книгу переложил дворецкий и не подозревает о наличии указателя в первом ящике. Поэтому она кладёт книгу в третий ящик и оставляет указатель во втором:

Шкаф с двумя указателями

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

  1. Открыть первый ящик, где он ожидает увидеть книгу, найти там записку-указатель на 2-й ящик.
  2. Открыть второй ящик, также обнаружить там указатель, но уже на 3-й ящик.
  3. Открыть третий ящик и, наконец, начать чтение.

Так вот к чему я всё это тут рассказываю? А вот к чему: пусть шкаф, который мы видели ранее будет оперативной памятью, ящик, являющийся минимальной ячейкой шкафа, — ячейкой памяти, размером в один байт. Теперь всё становится ближе к нашей программистской вселенной и разговор пойдёт более предметный.

Итак, продолжая преобразовывать нашу аналогию во что-то существенное, мы теперь должны преобразовать наши книги в «программистские» аналоги. Так, книга Страуструпа размером в один ящик, будет переменной размером в один байт, в C++ для этого служит тип char. Он вмещает как раз один байт данных. Книга Кнута, размером 4 ящика, становится переменной размером 4 байта, для большинства архитектур нам подойдёт тип int из C++. Дадим имена нашим переменным:

char straustrup;
int knuth;

Таким образом, мы имеем следующую картину памяти:

Модель памяти

Мы как-бы дали имена некоторой части памяти, т.е. возвращаясь к нашей аналогии, хозяин «запомнил» где у него лежит Страуструп, а где лежит Кнут, но он их ещё не положил туда! Правда, в отличии от шкафа, каждый ящик которого может быть либо пуст, либо полон, — все ячейки памяти всегда что-то содержат. Это могут быть старые данные, это могут быть нули — не важно, важно то, что даже если мы ничего не положили туда, мы всегда можем что-то оттуда достать. Правда мы не знаем что это будет. Пока отложим это знание, т.к. на данный момент нам это не нужно, это будет нужно позже.

Теперь положим в наши «ящики» какие-то данные(книги в ящики):

char straustrup = 'S';
int knuth = 100500;

Память стала выглядеть вот так:

Память с инициализированными переменными

Теперь мы знаем, что лежит в наших именованных частях памяти. Таким образом, впоследствии мы можем заглянуть в наши «ящики» и гарантировано найти там конкретные «книги». Т.е. наш хозяин точно знает, что обратившись к straustrup он увидит ‘S’. Итак, мы уже прошли всю нашу ситуацию, вплоть до дворецкого. Здесь ситуация становится интереснее: давайте «переложим», наше значение ‘S’ из первой ячейки во вторую. Сделали. Теперь нам как-то нужно указать хозяину, что ‘S’ находится в другом месте(напоминаю, у хозяина есть только переменная типа char с именем straustrup). Можно попробовать положить в straustrup адрес второй ячейки, давайте это сделаем:

char straustrup = 2;

Но это означает, что если позднее хозяин посмотрит на то, что содержит участок памяти с именем straustrup, то он увидит число 2. Как он может понять, что это адрес другой ячейки? Никак, ведь в отличии от нашей истории со шкафом он не увидит, что 2 написано на клочке бумаги, в котором поясняется, что это указатель на другой ящик. А это значит, что идя в ногу с аналогией, мы должны сообщить хозяину, что в ячейки памяти с именем straustrup лежит указатель на другую ячейку, а не сама книга. Для этих целей в C++ есть специальная нотация:

char* straustrup = 2;

Если вы попробуете скомпилировать вышенаписанный код, то вам это не удастся, но это на данный момент не важно. Главное понять суть.

Код выше, по-русски, можно написать так: объявляем переменную типа «указатель на переменную типа char», даём переменной имя straustrup и присваиваем ей значение 2. Теперь, если хозяин решит прочитать значение переменной straustrup, то он получит всё ту же двойку(возвращаясь к нашей аналогии, он увидит листок с цифрой 2), но теперь он видит, что тип переменной это не просто хранилище значений, а указатель(видит, что на бумаге нарисован указатель). Поэтому он понимает, что для получения значения, на которое указывает указатель ему нужно «перейти по указателю», т.е. посмотреть в ту ячейку, адрес которой содержится в переменной типа char*. В C++ эта операция называется разыменованием(dereferencing) и записывается следующим образом:

*straustrup

Вышеозначенная строчка выполняет следующее:

  1. Извлекает адрес, который лежит в переменной straustrup
  2. «Проходит» по данному адресу
  3. Возвращает нам ту часть памяти(ячейку) на которую указывает straustrup

Что мы можем делать с *straustrup? Мы можем использовать это выражение для получения значения, которое лежит по адресу(в нашем случае адрес это 2) или же мы может положить что-то новое по этому адресу. Таким образом, данное выражение даёт нам анонимный доступ на чтение и запись к некоторой ячейке. Почему анонимный? Потому что мы не именовали эту ячейку ранее(к примеру, char bigValue), а просто использовали результат разыменовывания указателя. Если записать предыдущие разглагольствования кодом, то получится следующее:

// Создадим указатель, который указывает на 222-ю ячейку
char* pointerToData = 222;
// Поместим 33 в 222-ю ячейку
*pointerToData = 33;
// В 222 ячейке гарантировано лежит 33
assert(*pointerToData == 33)

Мы ещё не заверши��и нашу аналогию(помните экономку?), поэтому давайте добавим ещё указателей! Итак, наш «Страуструп» перемещён в ячейку с номером три, а в ячейку номер 2 помещён указатель на него. Как нам описать это в C++? Очень просто, но сначала давайте попробуем по старинке:

char* straustrup = 2;

Хозяин, пройдя по указателю(*straustrup) получит значение 3(экономка постаралась), но это не то значение, что он ожидает и он(как и в первый раз) не знает, что 3 это тоже указатель, а не значение которое он хочет получить. Мы должны явно сказать ему, что 3 нужно интерпретировать как указатель. Как это сделать? Давайте разбираться. В прошлый раз мы по-русски записали наше определение выше как: объявляем переменную типа «указатель на переменную типа char», даём переменной имя straustrup и присваиваем ей значение 2. Тут всё понятно, но ситуация у нас изменилась и что-то нужно поправить. А изменилось у нас то, что наш указатель указывает на ячейку, которая содержит другой указатель. Т.е. наш тип будет теперь «указатель на переменную типа указатель на char», другими словами — двойной указатель. С++ является довольно последовательным языком, поэтому синтаксис очевиден:

char** straustrup = 2;

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

**straustrup;

Как это работает? Да очень просто, давайте разобьём это выражение на шаги:

char** straustrup = 2;
// Т.к. straustrup указывает на char*, объявим переменную
char* straustrup2 = *straustrup;
// Теперь получим искомое
char desiredStraustrup = *straustrup2;

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

// Создадим указатель, который указывает на 222-ю ячейку
char** pointerToPointer = 222;
// Поместим в 222-ю адрес 333-й ячейки
*pointerToPointer = 333;
// Поместим 11 в 333-ю ячейку
**pointerToData = 11;
// В 333-й ячейке гарантировано лежит 11
assert(**pointerToData == 11)

Графически это будет выглядеть так:

Память содержащая 2 указателя

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

Указатель — это переменная, которая содержит адрес ячейки памяти.

На что он указывает?

Разобравшись с тем, что указатель это всего лишь переменная хранящая адрес, нужно разобраться на что же он обычно указывает. В предыдущем параграфе мы явно назначали адреса ячеек, но тот код не мог быть скомпилирован ни одним компилятором, который придерживается стандарта? Почему? Потому что мы переменной типа «указатель на char», пытались присвоить значение типа int. Как вы наверное знаете, C++ является строго-типизированным языком, а это значит, что при любом присваивании проверяется может ли переменная одного типа быть присвоена другой. Разумеется, в переменную типа указатель, можно положить лишь другой указатель. Так что же нам делать, как положить адрес ячейки в указатель? Нужно использовать явное приведение типов, чтобы «заткнуть» компилятор:

int* pointerToInt = reinterpret_cast<int*>(10);

Для разнообразия, я использовал тип «указатель на int». Но куда указывает наш указатель? «На 10-ю ячейку, вестимо!» — скажет внимательный читатель. Да, это правда, но что находится в этой десятой ячейке? Разве мы положили туда что-то? Нам кто-то разрешал трогать десятую ячейку? На эти вопросы можно два раза ответить «нет» — мы ничего туда не клали, и никто нам позволения не давал туда лезть. С другой стороны, я уже упоминал, что память не ящик, и там всегда лежит что-то, даже если мы туда ничего не клали. Более того, почему это мы должны у кого-то спрашивать разрешения?

Давайте разбираться, что же говорит нам наш простенький кусок кода. Говорит он следующее: мы имеем указатель(pointerToInt), который указывает на 10-ю ячейку памяти, т.е. на 10-й байт памяти. Что ещё можно сказать? При разыменовании мы получим анонимный доступ к участку памяти размеров в 4 байта(здесь и далее мы считаем sizeof(int)==4). Т.к. мы ничего не положили в нашу анонимную переменную типа int, давайте просто прочитаем память — вдруг там что-то интересное лежит?!

std::cout << *pointerToInt;

Выполнив этот код на любой современной ОС для настольных ПК, используя нормальный компилятор, ваша программа просто-напросто упадёт. Почему? Потому что нечего лезть в память, которую вам никто не выделял. С точки зрения языка C++, код выше является неопределённым поведением(undefined behavior), т.к. происходит обращение к памяти, которую мы ранее за собой не «застолбили».

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

Но если так делать неправильно, то как тогда нужно инициализировать указатели? Очень просто, раз указатель должен указывать на какой-то участок памяти, который был ранее выделен, — нужно его выделить! Начнём с самого простого: когда мы объявляем переменную любого типа внутри функции мы выделяем память на стековой памяти, т.е. следующий код выделяет память под 4 байта на стеке, даёт этому участку имя value и кладёт туда число 33:

int main()
{
    int value = 33;
}

Хорошо, теперь добавим указатель:

int main()
{
    int value = 33;
    int* pointer = nullptr;
}

Теперь у нас на стеке выделено ещё 4 байта(по поводу размера позже поговорим) под переменную указателя, этому участку дано имя pointer и в него помещён nullptr(это будет 0, в числовом значении, если заглянуть в память). Хорошо, теперь у нас есть две именованных области памяти, а значит мы можем использовать адреса этих областей для того, чтобы инициализировать указатель! Ведь ранее мы говорили о том, что мы можем обращаться по указателю лишь к памяти, которую мы ранее выделили. Теперь мы имеем такую, поэтому давайте перепишем наш код выше на следующий:

int main()
{
    int value = 33;
    int* pointer = &value;
}

Теперь у нас в переменной pointer лежит адрес переменной value, поэтому сейчас у нас есть два способа достучаться до этого участка памяти:

  • Мы можем использовать именованный доступ, используя value
  • Мы можем использовать анонимный доступ, использую pointer

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

Переменная и указатель на неё

Давайте приведём пример:

#include <iostream>
using namespace std;

int main()
{
    int value = 33;
    int* pointer = &value;
    cout << "value: " << value << ", *pointer: " << *pointer << "\n";
    value = 55;
    cout << "value: " << value << ", *pointer: " << *pointer << "\n";
    *pointer = 77;
    cout << "value: " << value << ", *pointer: " << *pointer << "\n";
}

Кстати, т.к. pointer это обычная переменная, то у неё тоже есть адрес и мы также можем его получить:

#include <iostream>
using namespace std;

int main()
{
    int value = 33;
    int* pointer = &value;
    int** ptrToPtr = &pointer;
    int*** ptrToPtrToPtr = &ptrToPtr;
    cout << "*pointer: " << *pointer << 
        ", **ptrToPtr: " << **ptrToPtr <<
        ", ***ptrToPtrToPtr: " << ***ptrToPtrToPtr << "\n";
}

Как вы можете видеть, в коде выше, мы имеем двойной и даже тройной указатель!

Указатель на указатели

Можете в таком же ключе добавить хоть пятидесятерной указатель, ничего не изменится. Главное добавлять соответствующе количество звёздочек разыменования, только тогда удастся добраться до конечного результата. А что если убрать все звёздочки у каждого разыменовывания? Давайте попробуем:

#include <iostream>
using namespace std;

int main()
{
    int value = 33;
    int* pointer = &value;
    int** ptrToPtr = &pointer;
    int*** ptrToPtrToPtr = &ptrToPtr;
    cout << "pointer: " << pointer <<
        ", ptrToPtr: " << ptrToPtr <<
        ", ptrToPtrToPtr: " << ptrToPtrToPtr << "\n";
}

Этот код выводит какие-то «непонятные» цифры, но эти цифры есть ни что иное, как адреса того, на что ссылается указатель: первым числом является адрес value, вторым — адрес pointer, наконец 3-м является адрес ptrToPtr. Вы можете поиграться с количеством звёздочек при разыменовании, только помните, что количество звёздочек при разыменовывании может варьироваться от 0 до (кол-во звёздочек у типа указателя - 1). Нарушив это правило вы получите неопределённое поведение.

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

Приведение типов

Итак, указатели на указатели мы рассмотрели, теперь давайте рассмотрим ещё один интересный случай. Мы уже говорили, что тип указателя задаёт нам то, как будет интерпретироваться участок памяти, когда мы будет разыменовывать указатель. В предыдущем примере у нас всё хорошо, мы выделили память под int, и указатель у нас на int. Но что, если мы определим указатель на char и положим туда адрес value?

int value = 33;
char* pointer = reinterpret_cast<char*>(&value);

Т.к. тип указателя отличается от того, что даёт нам &value(int*), то нам необходимо явно привести тип к нужному нам, что мы и делаем. Мы имеем ситуацию, когда pointer указывает на первый байт value, т.е. по сравнению с нашим предыдущим примером ничего не изменилось, но вот если мы разыменуем наш pointer, то в отличие от предыдущего примера, мы получим доступ только к одному байту — у нас указатель на char, не забываем. Таким образом, модифицировав ячейку памяти мы затронем лишь эту ячейку и результирующее число может нас несколько удивить:

#include <iostream>
using namespace std;

int main()
{
    int value = 33;
    char* pointer = reinterpret_cast<char*>(&value);
    *pointer = 66;
    cout << "value: " << value << "\n";
}

На системах с прямым порядком байт(little-endian) вывод будет такой, какой можно было бы ожидать не зная ничего о памяти — 66. Но это лишь потому, что мы имели изначально число 33, которое помещается в один байт и он является первым, давайте немного изменим начальное число, и посмотрим, что у нас получится в итоге:

#include <iostream>
using namespace std;

int main()
{
    int value = 333;
    char* pointer = reinterpret_cast<char*>(&value);
    *pointer = 66;
    cout << "value: " << value << "\n";
}

Данный код выводит 322, что должно быть вам уже понятно — мы заменили первый байт, но не тронули второй, в результате имеем весьма «необычное» число. Какой из этого можно сделать вывод? Мы можем обращаться к участку памяти, который ранее был выделен как к целому куску, а можем обращаться к его отдельным частям как к массиву [байт], помня что такое обращение на запись может дать результирующий объект в несколько неожиданном виде .

Применение

Мы рассмотрели, что с помощью указателя можно получить анонимный доступ к переменной, но какой в этом прок? Мы ведь можем просто пользоваться именованными переменными и не страдать от этих указателей. Это правда, но есть одна область, где указатели довольно удобны — аргументы функции. В C++ все аргументы функции всегда копируются в функцию, поэтому следующий код не изменяет переменную value внутри функции:

#include <iostream>
using namespace std;

void increment(int value)
{
    value += 1;
}

int main()
{
    int value = 333;
    increment(value);
    cout << "value: " << value << "\n";
}

Выводом данной программы будет 333, что очевидно — value, которое мы выделили в функции main было скопирован в value, которое было выделено для функции increment. value в main и value в increment это две совершенно разные переменные, которые имею разные адреса и их изменение не отражается друг на друге! Запустив следующую программу, это становится очевидно:

#include <iostream>
using namespace std;

void increment(int value)
{
    value += 1;
    cout << "The address of value in INCREMENT: " << &value << "\n";
}

int main()
{
    int value = 333;
    increment(value);
    cout << "The address of value in MAIN: " << &value << "\n";
}

Так вот, чтобы решить данную проблему мы можем использовать указатель:

#include <iostream>
using namespace std;

void increment(int* value)
{
    *value += 1;
}

int main()
{
    int value = 333;
    int* pointer = &value;
    increment(pointer);
    cout << "value: " << value << "\n";
}

Теперь программа выведет 334. «Но почему? Вы говорили, что все аргументы копируются!». Это правда и я не отказываюсь от своих слов, переменная pointer была скопирована в переменную value функции increment. Т.е. value является точной копией pointer. Но pointer содержит адрес переменной value из main, а это значит, что value из increment содержит тот же адрес, а это, в свою очередь, означает, что разыменовав value из increment мы получаем доступ к value из main! Всё просто. Конечно, мы могли бы вообще не заводить переменную pointer:

#include <iostream>
using namespace std;

void increment(int* value)
{
    *value += 1;
}

int main()
{
    int value = 333;
    increment(&value);
    cout << "value: " << value << "\n";
}

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

Размер указателя

Ранее мы говорил, что для нашего указателя на стеке было выделено 4 байта, но почему 4? от чего это зависит? В сущности, это может зависит от чего угодно, но в в наиболее популярных случаях это зависит от размера адресного пространства, к которому может обратиться приложение. К примеру, в 32-х битных системах мы имеем 232 адресов доступных нашему приложению(в теории), в 16 разрядных это 216 и в 64-и разрядных это 264, соответственно. Поэтому, в 16-и разрядной системе переменная указателя будет занимать 16 бит(2 байта), в 32-х — 4 байта, а в 64-х 8 байт. И это правило будет исполнятся безотносительно того, сколько звёздочек и на какой конечный тип указывает указатель:

sizeof(char*) == sizeof(int***) == sizeof(std::string*) ...

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

Арифметика указателей

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

  • Указатель можно сложить со скалярным значением
  • Из указателя можно вычесть скалярное значение
  • Из указатель можно вычесть другой указатель того же типа.

Начнём с первых двух(второе является вариацией первого). Чтобы лучше понять то, как происходит сложение указателей давайте отвлечёмся от стандартного C++ и вступим на почву конкретной реализации: в указателях хранятся конкретные адреса памяти, каждый адрес представляет собой 32-х битное число и память является линейным массивом ячеек, размером в 1 байт каждая.

Так вот, приняв это во внимание давайте вспомним наш недавний пример:

#include <iostream>
using namespace std;

int main()
{
    int value = 333;
    char* pointer = reinterpret_cast<char*>(&value);
}

Мы имеем pointer, который указывает на начало блока в 4 байта(ранее выделенного нам), но обратиться мы можем только к первому байту(т.к. char*), что делать если мы хотим обратиться ко второму? Нет проблем, нам нужно просто прибавить к нашему указателю единицу:

#include <iostream>
using namespace std;

int main()
{
    int value = 333;
    char* pointer = reinterpret_cast<char*>(&value);
    char* pointerToSecond = pointer + 1;
}

Теперь мы имеем следующую ситуацию:

Указатель «внутрь» объекта

Я думаю, что интуитивно это должно и так быть понятно. При сложении указателя со скалярным числом N, указатель смещается на N позиций в сторону увеличения адресов(если N > 0), или же в сторону уменьшения адресов(если N < 0, т.е. происходит вычитание). Но что значит «смещается не N позиций»? Это значит, что результатом выражения pointer + N, будет служить указатель, который будет указывать на N-й объект в памяти относительно базового указателя pointer. Заметьте, я нигде тут не оперировал байтами, потому что в арифметике указателей во главе угла находится тип объекта, на который указатель указывает. Так в примере выше мы имеем char, но sizeof(char) == 1, поэтому pointerToSecond указывает на следующий байт, но если мы изменим код на такой:

#include <iostream>
using namespace std;

int main()
{
    int value = 333;
    int* pointer = &value;
    int* pointerToSecond = pointer + 1;
}

То картина будет следующей:

Указатель на следующий объект

Если немного подумать над этим, то всё становится на свои места: при сложении указателя с единицей, результирующий указатель будет указывать на следующий элемент того же типа, что и исходный указатель. Т.е. в нашем случае на следующий int. Но мы же не выделяли никакого другого int, куда указывает наш pointerToSecond? Это правда, мы имеем только один объект типа int, а pointerToSecond указывается на объект типа int, который мы не выделили. Другими словами pointerToSecond является недействительным, т.к. не может быть разыменован, потому что память, на которую он указывает нам недоступна.

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

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

pointer + N === reinterpret_cast<pointerBaseType*>(address + N*sizeof(pointerBaseType))

Где pointerBaseType это всё, что идёт до последней звездочки, к примеру, в char*, pointerBaseType  это char, а в int***, pointerBaseType это int**. address это реальный адрес(число) ячейки, который лежит в переменной-указателе.

Словами получается следующее: при сложении указателя p и скаляра N, получается другой указатель p2, значением которого является сумма адреса, который лежит в p, и количества байт, которое занимают N  объектов базового типа, на который указывает p.

Разумеется, мы не обязаны хранить результат суммы в какой-то другой переменной, мы можем сразу модифицировать наш указатель:

#include <iostream>
using namespace std;

int main()
{
    int value = 333;
    int* pointer = &value;
    pointer += 1;
    pointer++;
    --pointer;
}

Вот, собственно и всё, что стоит сказать по поводу сложения указателя и скаляра, теперь давайте разберём разницу указателей.

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

#include <cstddef>
using namespace std;

int main()
{
    int firstValue = 1;
    int secondValue = 2;

    char* ptr1 = reinterpret_cast<char*>(&firstValue);
    char* ptr2 = ptr1 + 2;
    char* ptr3 = reinterpret_cast<char*>(&secondValue);

    ptrdiff_t first = ptr2 - ptr1;
    assert(first == 2);
    // Неопределённое поведение!
    ptrdiff_t second = ptr3 - ptr1;
}

Как вы можете видеть, ptr1 и ptr2 указывают на один объект(firstValue), тогда как ptr3 указывает на другой объект(secondValue), поэтому разница между ptr2 и ptr1 имеет смысл и имеет весьма определённое значение, а вот разница (ptr3 – ptr1) может как не иметь смысла, так и иметь неопределённое значение. Стандартом операция (ptr3 – ptr1) не регламентируется, поэтому её наличие в программе, свободной от «хаков», является ошибкой. Разумеется, подобные операции часто используются в различных «хаках», но для этого нужно знать, что и как размещает конкретный компилятор на конкретной архитектуре, поэтому использование подобных возможностей является весьма нишевым инструментом.

Как видно из кода выше, разность указателей даёт скаляр, который показывает сколько нужно прибавить к вычитаемому, чтобы получить уменьшаемое. Итак, поговорили про вычитание, но где же сложение? А вот сложения указателей между собой в C++ не существует. Почему? Потому что это не имеет никакого смысла, — что будет результатом такой операции?

Итог

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

 


Другие статьи цикла:

Часть 2. Памятная

Часть 3. Завершающая