Указатели в С++. Первая битва.

Ты че, гнида, в указателях запутался? (с)

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

Читая эту статью для более полного понимания вам так же потребуется знать:

  • Что такое формальные и фактические параметры функции.
  • Передача аргументов в функцию по значению.
  • Что такое выражение.
  • Что такое ссылка в C++ (хотя и не обязательно)
  • Структуры или классы
  • Массивы и как они хранятся в памяти

Основные сведения об указателях

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

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

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

Первое, что надо запомнить — это переменная, такая же как и все остальные, как int, float, double и т.д.

Второе, что надо запомнить — она содержит адрес, т.е. какое-то целое число (однако выводится обычно в 16-ой системе счисления, но это детали). Это целое число указывает на номер байта в оперативной памяти, начиная с которой хранится какое-то значение.

Как и любая другая переменная, указатели должны быть определены, т.е. нужно задать её имя и тип.  Переменные-указатели (или переменные типа указатель) объявляются следующим образом:

тип * имя_переменной_указателя;

Звездочка в определении говорит о том, что данная переменная является указателем, т.е. может содержать адрес другой переменной. Здесь «тип» означает базовый тип указателя, он говорит о том, на значения какого типа указывает указатель (да, для переменных разного типа разные указатели) и сколько байт занимает переменная в памяти (помним, что указатель хранит только номер первого байта).

Чтобы определить переменную p указателем на int-значение, нужно написать так:

int * p;

Для определить указателя на float-значение, нужно написать так:

float * p;

И так можно создавать указатель на другой любой тип данных, на double, char, long double, long long, собственную структуру, класс и т.д.

В общем случае, использование символа звездочка «*» перед именем переменной в инструкции объявления переменной превращает эту переменную в указатель.

Предупреждение!

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

int *p1, *p2, *p3; // Так создано 3 указателя.
int *p1, p2, p2;  // А так создан 1 указатель и 2 переменные типа int.

Основные операции с указателями

С указателями используются две операции:

  • Операция разыменовывания, и она же опять звездочка *
  • Операция взятия адреса & (амперсанд)

Операция разыменовывания (*) применяется к какому-то указателю и возвращает значение, которое находится по адресу, который содержится в этом указателе.

Операция взятия адреса & применяется к любой переменной (даже к указателю) и возвращает ее адрес.

Следующий пример демонстрирует, как это все работает:

int i = 5;
int * p;
int k;

p =  = &i; // Взяли адрес переменной i и поместили его в указатель p. 
// Теперь p указывает на i, т.е. содержит адрес, по которому хранится переменная i.

k = *p; // А теперь разыменовываем указатель p, т.е. получаем значение, которое хранится по адресу, который содержится в p,
// а там содержится адрес переменной i, т.е. получаем её значение и присваиваем в переменную k значение переменной i.

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

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

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

Также, хоть это и очевидно, хочу обратить внимание на то, что операции * и & ставятся перед переменной и являются унарными, т.е. имеют один операнд.

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

Присваивание и указатели

С помощью указателя не только можно использовать значение переменной, на которую он указывает, но также можно и изменять значение переменной без её участия, т.е. косвенно. Для этого необходимо поместить *имя_переменной_указателя в левую часть оператора присваивания =.

Выглядит это следующим образом:

int k = 5;
int * p = &k;

*p = 25; // Теперь в переменной k хранится значение 25, а не 5.

И того мы пришли к ещё одному заключению:

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

Важное замечание!

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

Однако это не значит, что адрес в указателе нельзя изменить, очень даже можно. В отличие от ссылок в С++, указатели могут изменять свои значения, ссылка же не может, связывание с переменной происходит только при создании ссылки и далее измениться не может.

Посмотрим изменение адреса указателя на следующем примере:

int k = 5;
int j = 6;
int *p = &k; // p указывает на переменную k
int *p1 = &j; // p1 указывает на переменную j

p1 = p; // Теперь p1 тоже указывает на k

*p1 = 6; // Теперь в k лежит 6
*p = 7; // Теперь в k лежит 7

И вновь повторяюсь, указатель это все та же переменная, просто с ней бОльший ассортимент действий, чем с обычной переменной или ссылкой. Хочешь — изменяй значение по указателю. Хочешь — изменяй сам указатель. Хочешь — используй значение переменной через саму переменную. Хочешь — используй значение переменной через указатель.

Некоторые примеры с использованием указателя

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

int k1 = 5;
int k2 = 10;

int * p1, *p2;
p1 = &k1;
p2 = &k2;

int c = *p1 + *p2; // Получим 15. 
/*
Как это проще воспринимать?
Делать мысленные замены, т.е.:
Т.к. разыменование есть получение значения по адресу, который в указателе,
то по сути мы используем переменную k1. Делаем мысленно эту замену.
int c = k1 + *p2;
Тоже самое проделываем и со вторым указателем.
int c = k1 + k2;
Вроде не сложно, если проявить немного фантазии.
*/

Поработаем немножко с символами. Чисто для демонстрации побалуемся присваиваниями:

char c1 = 'k';
char c2 = 'u';

char * p = &c1;
*p = c2; // Теперь в c1 лежит 'u'.

Добавим цикл ради интереса:

int i;
int * ip = &i;

int summa = 0;
for(i = 0; *ip < 5; *ip = *ip + 1) {
    summa += *ip;
}

/*
Управляем счетчиком цикла через указатель и суммируем индексы тоже через указатель

Мысленно подставим вместо *ip переменную i и получим следующее:
int summa = 0;
for(i = 0; i < 5; i = i + 1) {
    summa += i;
}
*/

И вот она, вкусняшка, что-то дельное, что показывает одно из применений указателя:

void swap(int *a, int *b) {
   int tmp = *a;
   *a = *b;
   *b = tmp;
}

void main() {
   int k = 5, b = 6;
   swap(&k, &b); // После выполнения функции в k будет лежать 6, а в b будет лежать 5.
   // Это классическая задача обмена двух переменных значениями. Или же "метод 3 стаканов".
}

/*
В заголовке функции говорится, что она принимает 2 указателя, а что у нас в указателе? Правильно, адрес.
Отсюда следует логический вывод, что функция принимает адреса переменных, которые необходимо обменять местами.
Она манипулирует с переменными посредством указателей (они же адреса) на эти переменные, т.е. косвенно.
*/

Пример с инкрементом и декрементом:

int i = 0;
int * p = &i;

(*p)++; // Эквивалентно записи *p = *p + 1;
(*p)--; // Эквивалентно записи *p = *p - 1;

++(*p); // Эквивалентно *p = *p + 1;
--(*p); // Эквивалентно *p = *p - 1;

int k = 10;
int * p1 = &k;

*p1 %= 3; // Эквивалентно *p1 = *p1 % 3; С остальными сокращенными присваиваниями тоже актуально.

 

Многоуровневая адресация. Указатели на указатели.

А пару абзацев назад я написал следующее:

Операция взятия адреса & применяется к любой переменной (даже к указателю) и возвращает ее адрес.

Да, можно взять адрес указателя, все верно. Возникает несколько вопросов:

  • Вопрос первый: куда его положить?
  • Вопрос второй: он-то зачем нужен?
  • Вопрос третий: логика работы остается той же?

Ответ на второй вопрос будет позже.

Отвечу сразу на третий вопрос:

Да, все то, что было написано выше, теперь применимо и сейчас.

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

Ответ на первый вопрос:

Можно создать указатель, который будет указывать на другой указатель и туда положить адрес указателя. Это называют указателем на указатель. А ещё это обзывают многоуровневой адресацией.

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

Указатель на указатель создается следующим образом (я ограничусь только типом int, с остальными типами все тоже самое):

int ** p; // Указатель на указатель
int *** p1; // Указатель на указатель, который указывает на указатель
int **** p2; // Указатель на указатель, который указывает на указатель, который в свою очередь указывает на указатель.

// И этот список можно продолжать до посинения.
// На практике мне однажды понадобился указатель с тремя звездочками.

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

int k = 5;
int t = 6;
int * p = &k; // Указывает на k
int ** p1 = &p; // Указывает на p
int *** p2 = &p1; // Указывает на p1

*p1 = &t;
/* Т.к. p1 указывает на p, то разыменовывание p1 дает нам p
   Делаем мысленную замену p = &t; 
   Получаем, что в p теперь адрес t.
*/ 
k = **p1; 
/* А вот и пошло-поехало. Аж 2 звездочки. Как справиться с этим?
   Для начала понять, что эти звездочки будут применяться последовательно.
   А значит это выражение можно представить себе как k = *(*p1); 
   Итак, мы уже заменяли *p1 на p, проделаем это ещё раз и получим k = *(p);
   На что там у нас указывает p? Ах да, на t, т.е. *p это у нас t.
   В нашей голове запись преобразуется в k = t; 
   Ничего сложного, просто некоторые мысленные преобразования и все становится очень просто.
*/

/*
Ну а теперь пойдем во все тяжкие и изменим значение переменной t через p2;
Для этого нужно аж 3 раза разыменовать p2.

Заметили кое-что?
Чтобы добраться из p до значения t, нужно разыменовать p 1 раз.
Чтобы добраться из p1 до значения t, нужно разыменовать p1 2 раза.
Чтобы добраться из p2 до значения t, нужно разыменовать p2 3 раза.

Отсюда такое простенькое правило. Чтобы добраться из определенного указателя до значения,
необходимо разыменовать его столько раз, сколько звездочек у него указано при определении.
*/

***p2 = 25;
// Начинаем серию мысленных преобразований:
// *(*(*p2)) = 25
// *(*p1) = 25
// *p = 25
//  t = 25

 

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

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

Рассмотрим такой кусок кода:

int A[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int * p = &A[0];

p = p + 1;

Итак, указатель p показывал на первый элемент массива, т.е. на 1, а потом мы взяли и к значению указателя прибавили единицу, т.е. к адресу (потому что разыменовывание мы не используем). Что произойдет? Мы сместимся на следующий элемент в массиве? Мы сместимся на 1 байт вправо по оперативной памяти? Так нельзя делать?

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

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

Мы написали p + 1, но компилятор переделает эту конструкцию в p + 4, т.к. размер int в байтах равен 4. И получается, что мы попадем на адрес следующего элемента массива. Получается, что в программе выражением p = p + 1 говорим о том, что необходимо передвинуться на следующий int, а не на 1 байт вперед.

Для сокращения записи p = p + 1 обычно пишут p++. Двигаться можно как вправо, так и влево. Так что теперь никто не мешает нам пододвинуться обратно с помощью операции p—;

Что же, смогли сместиться на 1, почему бы не сместиться на 2 или 3? А может 5? И это все возможно.

p += 5;    p = p + 5;    p -= 5;    p = p — 5;

Каждое из этих выражений смещает указатель на 5 интов влево или вправо.

А что будет, если записать выражение p = p + 1 без присваивания? Что получим тогда? Очевидно, что просто адрес следующего элемента, но значение указателя при этом не изменится. Т.е. мы можем получать значения относительно указателя. Пятый справа, шестой слева и т.п.

Если мы можем получить адрес, то мы можем получить и значение с помощью разыменовывания. Написав *(p+1), мы получим значение элемента справа от того, на который показывает указатель p. Для этого есть ещё и сокращенная запись. Запись использует квадратные скобки, как в массивах. Вместо *(p+1) мы можем написать p[1] и получим тот же результат.

Теперь мы можем собрать список операций в кучу. Интерпретировать следующий список стоит так: «Данное выражение имеет следующий смысл …»

  1. *p — значение по текущему адресу
  2. p+i — адрес на i-ую переменную после *p
  3. p-i  — адрес на i-ую переменную перед *p
  4. *(p+i) — значение i-ой переменной после *p
  5. p[i] — значение i-ой переменной после *p (эквивалентно *(p+i))
  6. p++ — переместить указатель на следующую переменную
  7. p—  — переместить указатель на предыдущую переменную
  8. p+=i — переместить указатель на i переменных вперед
  9. p-=i  — переместить указатель на i переменных назад
  10. *p++ — получить значение по адресу и переместиться на следующую переменную
  11. *(—p) — переместиться на предыдущую переменную и получить её значение
  12. p+1 — получить адрес следующей переменной

Связь указателя и массива

Вернемся к вот этому:

int A[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int * p = &A[0];

p++; //  *p равно A[1] = 2

Думаю, что часть читателей заметила это сходство или уже о нем знает из прочитанного из других источников материала. Если мы можем присвоить указателю адрес на первый элемент массива и мы уже знаем, что выражение p[i] означает *(p+i) и что это эквивалентно взятие i-го значения после p, то если присвоить в указатель адрес первого элемента, то, по сути, мы получаем альтернативное имя массива.

Увидеть это можно на этом примере:

int A[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
  int * p = &A[0];

  for (int i = 0; i < 10; ++i)
    cout << A[i];

  for (int i = 0; i < 10; ++i)
    cout << p[i];

Результаты будут совершенно аналогичными. А теперь сделаем вот так:

int A[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
  int * p = &A[0];

  cout << A << endl;
  cout << p << endl;

Если запустить это программу, то вы получите 2 абсолютно одинаковых значения. Почему так я объяснять не буду, иначе улечу в ассемблер. Смысл-то в чем? Смысл в том, что имя массива это есть адрес на первый элемент массива и его можно присвоить указателю, но есть одно маленькое но. Имя массива имеет тип const тип *, а наш указатель тип тип*, но об этом немножко позже. Присваивать указателю имя массива все равно можно, но не наоборот. Следующий кусок кода полностью эквивалентент предыдущему.

  int A[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
  int * p = A;

  cout << A << endl;
  cout << p << endl;

 

Примеры использования арифметики указателей

Указатели часто применяются для работы со строками в стиле Си, вот типичный пример:

int myStrlen(char * str)
{
  int count = 0;
  while (*str++ != '\0') count++;
  /*
    Пример интересен своей записью. Операция постфиксного инкремента выполнится после разыменовывания.
    Что получится? Строки, как известно, всегда ограничены специальным символом '\0', который сигнализирует о конце строки.
    Т.е. дойдя до него можно сделать вывод, что строка закончена. 
    Что происходит в голове цикла? Сначала разыменовывается значение по адресу str, сравнивается с нулем, а затем указатель смещается на следующий элемент в строке.

    Можно переписать этот кусок кода попроще. Это будет выглядеть так:
    while( *str != '\0') {
      count++;
      str++;
    }
    */
  return count;
}
int sumInt(int * p, int n)
{
  int sum = 0;
  for (int i = 0; i < n; ++i)
    sum += *p;
 
  /*
    Пусть имеется массив A размером 10 элементов, то теперь с помощью данной функции можно вычислить сумму его элементов.
    Для этого в функции надо передать массив и его размерность.
    sumInt(A, 10);
  */

  return sum;
}

 

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *